mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
feat: enable autonomous goal loops from slash commands
Coding sessions need a native way to keep working until a stated completion condition is actually met. This adds a CLI /goal command, goal state and evaluator logic, and a headless-safe command filter so interactive and print-mode runs can both start a goal and continue when the evaluator says more work remains. Constraint: The evaluator must work with saved third-party provider runtimes that may reject structured output schemas. Constraint: Completion evidence must come from visible transcript text, not hidden goal prompts or assistant thinking blocks. Rejected: Implement /goal as a bundled skill only | it would not own the query loop or support native continuation semantics. Rejected: Expose all local-jsx commands to headless mode | interactive UI commands would become available in print mode. Confidence: high Scope-risk: moderate Directive: Keep /goal evaluator evidence scoped to visible transcript text before adding richer completion signals. Tested: bun test src/commands/headless.test.ts src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts Tested: bun run check:server Tested: bun run verify Tested: Real provider /goal smoke with MiniMax-M2.7-highspeed, including a two-turn continuation that logged Goal continuation #1 and completed with GOAL_SMOKE_LOOP_DONE. Not-tested: Durable cross-process goal persistence for resumed sessions.
This commit is contained in:
parent
523db62b3b
commit
1e32942eab
@ -6,6 +6,7 @@ import btw from './commands/btw/index.js'
|
|||||||
import goodClaude from './commands/good-claude/index.js'
|
import goodClaude from './commands/good-claude/index.js'
|
||||||
import issue from './commands/issue/index.js'
|
import issue from './commands/issue/index.js'
|
||||||
import feedback from './commands/feedback/index.js'
|
import feedback from './commands/feedback/index.js'
|
||||||
|
import goal from './commands/goal/index.js'
|
||||||
import clear from './commands/clear/index.js'
|
import clear from './commands/clear/index.js'
|
||||||
import color from './commands/color/index.js'
|
import color from './commands/color/index.js'
|
||||||
import commit from './commands/commit.js'
|
import commit from './commands/commit.js'
|
||||||
@ -303,6 +304,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
tag,
|
tag,
|
||||||
theme,
|
theme,
|
||||||
feedback,
|
feedback,
|
||||||
|
goal,
|
||||||
review,
|
review,
|
||||||
ultrareview,
|
ultrareview,
|
||||||
rewind,
|
rewind,
|
||||||
|
|||||||
72
src/commands/goal/goal.tsx
Normal file
72
src/commands/goal/goal.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { getSessionId } from '../../bootstrap/state.js'
|
||||||
|
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||||
|
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||||
|
import {
|
||||||
|
buildGoalStartPrompt,
|
||||||
|
clearThreadGoal,
|
||||||
|
formatGoalStatus,
|
||||||
|
getThreadGoal,
|
||||||
|
parseGoalCommand,
|
||||||
|
setThreadGoal,
|
||||||
|
updateThreadGoalStatus,
|
||||||
|
} from '../../goals/goalState.js'
|
||||||
|
|
||||||
|
export async function call(
|
||||||
|
onDone: LocalJSXCommandOnDone,
|
||||||
|
_context: LocalJSXCommandContext,
|
||||||
|
args: string,
|
||||||
|
): Promise<React.ReactNode> {
|
||||||
|
const threadId = getSessionId()
|
||||||
|
try {
|
||||||
|
const parsed = parseGoalCommand(args)
|
||||||
|
if (parsed.type === 'status') {
|
||||||
|
onDone(formatGoalStatus(getThreadGoal(threadId)), { display: 'system' })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (parsed.type === 'clear') {
|
||||||
|
const cleared = clearThreadGoal(threadId)
|
||||||
|
onDone(cleared ? 'Goal cleared.' : 'No active goal.', { display: 'system' })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (parsed.type === 'pause') {
|
||||||
|
const goal = updateThreadGoalStatus(threadId, 'paused')
|
||||||
|
onDone(goal ? formatGoalStatus(goal) : 'No active goal.', {
|
||||||
|
display: 'system',
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (parsed.type === 'resume') {
|
||||||
|
const goal = updateThreadGoalStatus(threadId, 'active')
|
||||||
|
onDone(goal ? formatGoalStatus(goal) : 'No goal to resume.', {
|
||||||
|
display: 'system',
|
||||||
|
shouldQuery: Boolean(goal),
|
||||||
|
metaMessages: goal ? [buildGoalStartPrompt(goal)] : [],
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (parsed.type === 'complete') {
|
||||||
|
const goal = updateThreadGoalStatus(threadId, 'complete')
|
||||||
|
onDone(goal ? 'Goal marked complete.' : 'No active goal.', {
|
||||||
|
display: 'system',
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const goal = setThreadGoal(threadId, {
|
||||||
|
objective: parsed.objective,
|
||||||
|
tokenBudget: parsed.tokenBudget,
|
||||||
|
})
|
||||||
|
onDone(formatGoalStatus(goal), {
|
||||||
|
display: 'system',
|
||||||
|
shouldQuery: true,
|
||||||
|
metaMessages: [buildGoalStartPrompt(goal)],
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
onDone(error instanceof Error ? error.message : String(error), {
|
||||||
|
display: 'system',
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/commands/goal/index.ts
Normal file
12
src/commands/goal/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { Command } from '../../commands.js'
|
||||||
|
|
||||||
|
const goal = {
|
||||||
|
type: 'local-jsx',
|
||||||
|
supportsNonInteractive: true,
|
||||||
|
name: 'goal',
|
||||||
|
description: 'Set or manage a completion goal that keeps the session working until it is met',
|
||||||
|
argumentHint: '[status|clear|pause|resume|--tokens <budget> <objective>|<objective>]',
|
||||||
|
load: () => import('./goal.js'),
|
||||||
|
} satisfies Command
|
||||||
|
|
||||||
|
export default goal
|
||||||
47
src/commands/headless.test.ts
Normal file
47
src/commands/headless.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { Command } from '../types/command.js'
|
||||||
|
import { filterCommandsForHeadlessMode } from './headless.js'
|
||||||
|
|
||||||
|
describe('filterCommandsForHeadlessMode', () => {
|
||||||
|
test('keeps /goal without exposing other local-jsx UI commands', () => {
|
||||||
|
const commands = [
|
||||||
|
{
|
||||||
|
type: 'local-jsx',
|
||||||
|
supportsNonInteractive: true,
|
||||||
|
name: 'goal',
|
||||||
|
description: 'Set a goal',
|
||||||
|
load: async () => ({ call: async () => null }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'local-jsx',
|
||||||
|
name: 'config',
|
||||||
|
description: 'Open config UI',
|
||||||
|
load: async () => ({ call: async () => null }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
name: 'review',
|
||||||
|
description: 'Review code',
|
||||||
|
progressMessage: 'reviewing',
|
||||||
|
contentLength: 0,
|
||||||
|
source: 'builtin',
|
||||||
|
getPromptForCommand: async () => [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prompt',
|
||||||
|
name: 'statusline',
|
||||||
|
description: 'Hidden from print mode',
|
||||||
|
progressMessage: 'checking',
|
||||||
|
contentLength: 0,
|
||||||
|
source: 'builtin',
|
||||||
|
disableNonInteractive: true,
|
||||||
|
getPromptForCommand: async () => [],
|
||||||
|
},
|
||||||
|
] satisfies Command[]
|
||||||
|
|
||||||
|
expect(filterCommandsForHeadlessMode(commands).map(command => command.name)).toEqual([
|
||||||
|
'goal',
|
||||||
|
'review',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
11
src/commands/headless.ts
Normal file
11
src/commands/headless.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import type { Command } from '../types/command.js'
|
||||||
|
|
||||||
|
export function supportsHeadlessSlashCommand(command: Command): boolean {
|
||||||
|
if (command.type === 'prompt') return command.disableNonInteractive !== true
|
||||||
|
if (command.type === 'local') return command.supportsNonInteractive
|
||||||
|
return command.supportsNonInteractive === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCommandsForHeadlessMode(commands: Command[]): Command[] {
|
||||||
|
return commands.filter(supportsHeadlessSlashCommand)
|
||||||
|
}
|
||||||
136
src/goals/goalEvaluator.test.ts
Normal file
136
src/goals/goalEvaluator.test.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type { Message } from '../types/message.js'
|
||||||
|
import { createAssistantMessage, createUserMessage } from '../utils/messages.js'
|
||||||
|
import { evaluateThreadGoalAfterTurn } from './goalEvaluator.js'
|
||||||
|
import { getThreadGoal, setThreadGoal } from './goalState.js'
|
||||||
|
|
||||||
|
describe('goalEvaluator', () => {
|
||||||
|
test('continues an active goal when the evaluator says it is incomplete', async () => {
|
||||||
|
const goal = setThreadGoal('thread-eval-continue', {
|
||||||
|
objective: 'tests pass',
|
||||||
|
now: 1_000,
|
||||||
|
})
|
||||||
|
const messages: Message[] = [
|
||||||
|
createUserMessage({ content: 'run the tests' }),
|
||||||
|
createAssistantMessage({
|
||||||
|
content: [{ type: 'text', text: 'I changed code but did not test it.' }],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const decision = await evaluateThreadGoalAfterTurn({
|
||||||
|
threadId: 'thread-eval-continue',
|
||||||
|
messages,
|
||||||
|
assistantMessages: [],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
now: 2_000,
|
||||||
|
evaluate: async () => ({
|
||||||
|
complete: false,
|
||||||
|
reason: 'Tests have not been run.',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(decision.action).toBe('continue')
|
||||||
|
expect(decision.goal.goalId).toBe(goal.goalId)
|
||||||
|
expect(decision.prompt).toContain('Tests have not been run.')
|
||||||
|
expect(getThreadGoal('thread-eval-continue')?.status).toBe('active')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not let hidden goal prompts satisfy the evaluator transcript', async () => {
|
||||||
|
setThreadGoal('thread-eval-meta', {
|
||||||
|
objective: 'finish after MAGIC_DONE appears',
|
||||||
|
now: 1_000,
|
||||||
|
})
|
||||||
|
let capturedTranscript = ''
|
||||||
|
|
||||||
|
const decision = await evaluateThreadGoalAfterTurn({
|
||||||
|
threadId: 'thread-eval-meta',
|
||||||
|
messages: [
|
||||||
|
createUserMessage({
|
||||||
|
content: 'The hidden instruction says MAGIC_DONE is the target.',
|
||||||
|
isMeta: true,
|
||||||
|
}),
|
||||||
|
createAssistantMessage({
|
||||||
|
content: [{ type: 'text', text: 'Still working.' }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
assistantMessages: [],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
now: 2_000,
|
||||||
|
evaluate: async ({ transcript }) => {
|
||||||
|
capturedTranscript = transcript
|
||||||
|
return {
|
||||||
|
complete: false,
|
||||||
|
reason: 'No completion evidence.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(decision.action).toBe('continue')
|
||||||
|
expect(capturedTranscript).not.toContain('MAGIC_DONE')
|
||||||
|
expect(capturedTranscript).toContain('Assistant: Still working.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not let hidden assistant thinking satisfy the evaluator transcript', async () => {
|
||||||
|
setThreadGoal('thread-eval-thinking', {
|
||||||
|
objective: 'finish after LOOP_DONE appears',
|
||||||
|
now: 1_000,
|
||||||
|
})
|
||||||
|
let capturedTranscript = ''
|
||||||
|
|
||||||
|
await evaluateThreadGoalAfterTurn({
|
||||||
|
threadId: 'thread-eval-thinking',
|
||||||
|
messages: [
|
||||||
|
createAssistantMessage({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'I will output LOOP_DONE next turn.',
|
||||||
|
signature: 'test',
|
||||||
|
} as unknown as BetaContentBlock,
|
||||||
|
{ type: 'text', text: 'STEP_ONE' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
assistantMessages: [],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
now: 2_000,
|
||||||
|
evaluate: async ({ transcript }) => {
|
||||||
|
capturedTranscript = transcript
|
||||||
|
return {
|
||||||
|
complete: false,
|
||||||
|
reason: 'No visible completion evidence.',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedTranscript).not.toContain('LOOP_DONE')
|
||||||
|
expect(capturedTranscript).toContain('Assistant: STEP_ONE')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('marks an active goal complete when the evaluator says it is complete', async () => {
|
||||||
|
setThreadGoal('thread-eval-complete', {
|
||||||
|
objective: 'tests pass',
|
||||||
|
now: 1_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const decision = await evaluateThreadGoalAfterTurn({
|
||||||
|
threadId: 'thread-eval-complete',
|
||||||
|
messages: [createUserMessage({ content: 'bun test passed' })],
|
||||||
|
assistantMessages: [],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
now: 3_000,
|
||||||
|
evaluate: async () => ({
|
||||||
|
complete: true,
|
||||||
|
reason: 'The transcript shows the tests passed.',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(decision.action).toBe('complete')
|
||||||
|
expect(decision.reason).toBe('The transcript shows the tests passed.')
|
||||||
|
expect(getThreadGoal('thread-eval-complete')?.status).toBe('complete')
|
||||||
|
expect(getThreadGoal('thread-eval-complete')?.lastReason).toBe(
|
||||||
|
'The transcript shows the tests passed.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
266
src/goals/goalEvaluator.ts
Normal file
266
src/goals/goalEvaluator.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import type {
|
||||||
|
BetaMessage,
|
||||||
|
BetaContentBlock,
|
||||||
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type { QuerySource } from '../constants/querySource.js'
|
||||||
|
import type { AssistantMessage, Message } from '../types/message.js'
|
||||||
|
import { extractTextContent } from '../utils/messages.js'
|
||||||
|
import { getSmallFastModel } from '../utils/model/model.js'
|
||||||
|
import { safeParseJSON } from '../utils/json.js'
|
||||||
|
import { sideQuery } from '../utils/sideQuery.js'
|
||||||
|
import {
|
||||||
|
accountThreadGoalUsage,
|
||||||
|
buildGoalContinuationPrompt,
|
||||||
|
getThreadGoal,
|
||||||
|
incrementThreadGoalContinuation,
|
||||||
|
markThreadGoalComplete,
|
||||||
|
updateThreadGoalStatus,
|
||||||
|
type ThreadGoal,
|
||||||
|
} from './goalState.js'
|
||||||
|
|
||||||
|
export type GoalEvaluation = {
|
||||||
|
complete: boolean
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GoalTurnDecision =
|
||||||
|
| { action: 'none' }
|
||||||
|
| { action: 'continue'; goal: ThreadGoal; prompt: string; reason: string }
|
||||||
|
| { action: 'complete'; goal: ThreadGoal; reason: string }
|
||||||
|
| { action: 'budget_limited'; goal: ThreadGoal }
|
||||||
|
|
||||||
|
type EvaluateFn = (input: {
|
||||||
|
goal: ThreadGoal
|
||||||
|
transcript: string
|
||||||
|
signal: AbortSignal
|
||||||
|
querySource?: QuerySource
|
||||||
|
}) => Promise<GoalEvaluation>
|
||||||
|
|
||||||
|
const DEFAULT_MAX_CONTINUATIONS = 500
|
||||||
|
|
||||||
|
export async function evaluateThreadGoalAfterTurn(input: {
|
||||||
|
threadId: string
|
||||||
|
messages: Message[]
|
||||||
|
assistantMessages: AssistantMessage[]
|
||||||
|
signal: AbortSignal
|
||||||
|
now?: number
|
||||||
|
querySource?: QuerySource
|
||||||
|
evaluate?: EvaluateFn
|
||||||
|
}): Promise<GoalTurnDecision> {
|
||||||
|
const now = input.now ?? Date.now()
|
||||||
|
const current = getThreadGoal(input.threadId)
|
||||||
|
if (!current || current.status !== 'active') return { action: 'none' }
|
||||||
|
|
||||||
|
const tokens = input.assistantMessages.reduce(
|
||||||
|
(sum, msg) =>
|
||||||
|
sum +
|
||||||
|
(msg.message.usage?.input_tokens ?? 0) +
|
||||||
|
(msg.message.usage?.output_tokens ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const accounted = accountThreadGoalUsage(input.threadId, tokens, now) ?? current
|
||||||
|
|
||||||
|
if (
|
||||||
|
accounted.tokenBudget !== null &&
|
||||||
|
accounted.tokensUsed >= accounted.tokenBudget
|
||||||
|
) {
|
||||||
|
const limited =
|
||||||
|
updateThreadGoalStatus(input.threadId, 'budget_limited', now) ?? accounted
|
||||||
|
return { action: 'budget_limited', goal: limited }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounted.continuationCount >= getMaxContinuations()) {
|
||||||
|
const limited =
|
||||||
|
updateThreadGoalStatus(input.threadId, 'budget_limited', now) ?? accounted
|
||||||
|
return { action: 'budget_limited', goal: limited }
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = formatTranscript([
|
||||||
|
...input.messages,
|
||||||
|
...input.assistantMessages,
|
||||||
|
])
|
||||||
|
const evaluator = input.evaluate ?? evaluateGoalCompletion
|
||||||
|
const evaluation = await evaluator({
|
||||||
|
goal: accounted,
|
||||||
|
transcript,
|
||||||
|
signal: input.signal,
|
||||||
|
querySource: input.querySource,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (evaluation.complete) {
|
||||||
|
const completed =
|
||||||
|
markThreadGoalComplete(input.threadId, {
|
||||||
|
reason: evaluation.reason,
|
||||||
|
now,
|
||||||
|
}) ?? accounted
|
||||||
|
return {
|
||||||
|
action: 'complete',
|
||||||
|
goal: completed,
|
||||||
|
reason: evaluation.reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continued =
|
||||||
|
incrementThreadGoalContinuation(input.threadId, {
|
||||||
|
reason: evaluation.reason,
|
||||||
|
now,
|
||||||
|
}) ?? accounted
|
||||||
|
return {
|
||||||
|
action: 'continue',
|
||||||
|
goal: continued,
|
||||||
|
reason: evaluation.reason,
|
||||||
|
prompt: buildGoalContinuationPrompt(continued, evaluation.reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateGoalCompletion(input: {
|
||||||
|
goal: ThreadGoal
|
||||||
|
transcript: string
|
||||||
|
signal: AbortSignal
|
||||||
|
querySource?: QuerySource
|
||||||
|
}): Promise<GoalEvaluation> {
|
||||||
|
const baseRequest = {
|
||||||
|
querySource: input.querySource ?? 'hook_prompt',
|
||||||
|
model: getSmallFastModel(),
|
||||||
|
skipSystemPromptPrefix: true,
|
||||||
|
thinking: false,
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 512,
|
||||||
|
signal: input.signal,
|
||||||
|
system:
|
||||||
|
'You evaluate whether a coding-agent goal is complete. ' +
|
||||||
|
'Return JSON only. Say complete=true only when the transcript contains concrete visible evidence that the objective is satisfied.',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user' as const,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: [
|
||||||
|
`<objective>${input.goal.objective}</objective>`,
|
||||||
|
'',
|
||||||
|
'<transcript>',
|
||||||
|
input.transcript,
|
||||||
|
'</transcript>',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sideQuery({
|
||||||
|
...baseRequest,
|
||||||
|
output_format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
complete: { type: 'boolean' },
|
||||||
|
reason: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['complete', 'reason'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return parseEvaluationResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
if (input.signal.aborted) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await sideQuery({
|
||||||
|
...baseRequest,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: [
|
||||||
|
`<objective>${input.goal.objective}</objective>`,
|
||||||
|
'',
|
||||||
|
'<transcript>',
|
||||||
|
input.transcript,
|
||||||
|
'</transcript>',
|
||||||
|
'',
|
||||||
|
'Return exactly one JSON object with this shape and no markdown:',
|
||||||
|
'{"complete": false, "reason": "short evidence-based reason"}',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return parseEvaluationResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvaluationResponse(response: BetaMessage): GoalEvaluation {
|
||||||
|
const text = extractTextContent(response.content, '').trim()
|
||||||
|
const parsed = safeParseJSON(text) ?? safeParseJSON(extractJsonObject(text))
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
'complete' in parsed &&
|
||||||
|
typeof parsed.complete === 'boolean'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
complete: parsed.complete,
|
||||||
|
reason:
|
||||||
|
'reason' in parsed && typeof parsed.reason === 'string'
|
||||||
|
? parsed.reason
|
||||||
|
: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
complete: false,
|
||||||
|
reason: 'The evaluator did not return a valid completion decision.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonObject(text: string): string {
|
||||||
|
const start = text.indexOf('{')
|
||||||
|
const end = text.lastIndexOf('}')
|
||||||
|
if (start === -1 || end <= start) return text
|
||||||
|
return text.slice(start, end + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTranscript(messages: Message[]): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
const recent = messages.slice(-40)
|
||||||
|
for (const message of recent) {
|
||||||
|
if (message.type === 'user') {
|
||||||
|
if (message.isMeta) continue
|
||||||
|
lines.push(`User: ${contentToText(message.message.content)}`)
|
||||||
|
} else if (message.type === 'assistant') {
|
||||||
|
lines.push(`Assistant: ${assistantVisibleText(message.message.content)}`)
|
||||||
|
} else if (message.type === 'system' && typeof message.content === 'string') {
|
||||||
|
lines.push(`System: ${message.content}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n\n').slice(-24_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentToText(content: string | readonly BetaContentBlock[]): string {
|
||||||
|
if (typeof content === 'string') return content
|
||||||
|
return extractTextContent(content, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantVisibleText(content: readonly BetaContentBlock[]): string {
|
||||||
|
return content
|
||||||
|
.filter(block => block.type === 'text')
|
||||||
|
.map(block => block.text)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxContinuations(): number {
|
||||||
|
const raw = process.env.CLAUDE_CODE_GOAL_MAX_CONTINUES
|
||||||
|
if (!raw) return DEFAULT_MAX_CONTINUATIONS
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0
|
||||||
|
? parsed
|
||||||
|
: DEFAULT_MAX_CONTINUATIONS
|
||||||
|
}
|
||||||
80
src/goals/goalState.test.ts
Normal file
80
src/goals/goalState.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
buildGoalContinuationPrompt,
|
||||||
|
clearThreadGoal,
|
||||||
|
formatGoalStatus,
|
||||||
|
getThreadGoal,
|
||||||
|
markThreadGoalComplete,
|
||||||
|
parseGoalCommand,
|
||||||
|
setThreadGoal,
|
||||||
|
updateThreadGoalStatus,
|
||||||
|
} from './goalState.js'
|
||||||
|
|
||||||
|
describe('goalState', () => {
|
||||||
|
test('parses token budget before the goal objective', () => {
|
||||||
|
const parsed = parseGoalCommand(
|
||||||
|
'--tokens 250K migrate auth to the new API until tests pass',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
type: 'set',
|
||||||
|
objective: 'migrate auth to the new API until tests pass',
|
||||||
|
tokenBudget: 250_000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stores and formats the current thread goal', () => {
|
||||||
|
const goal = setThreadGoal('thread-a', {
|
||||||
|
objective: 'all provider tests pass',
|
||||||
|
tokenBudget: 10_000,
|
||||||
|
now: 1_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(goal.status).toBe('active')
|
||||||
|
expect(getThreadGoal('thread-a')?.objective).toBe('all provider tests pass')
|
||||||
|
expect(formatGoalStatus(goal, 61_000)).toContain('Goal: active')
|
||||||
|
expect(formatGoalStatus(goal, 61_000)).toContain('Budget: 0 / 10,000 tokens')
|
||||||
|
expect(formatGoalStatus(goal, 61_000)).toContain('Elapsed: 1m')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pause, resume, complete, and clear are scoped to the thread', () => {
|
||||||
|
setThreadGoal('thread-a', { objective: 'ship feature', now: 1_000 })
|
||||||
|
setThreadGoal('thread-b', { objective: 'different work', now: 1_000 })
|
||||||
|
|
||||||
|
expect(updateThreadGoalStatus('thread-a', 'paused', 2_000)?.status).toBe(
|
||||||
|
'paused',
|
||||||
|
)
|
||||||
|
expect(updateThreadGoalStatus('thread-a', 'active', 3_000)?.status).toBe(
|
||||||
|
'active',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
markThreadGoalComplete('thread-a', {
|
||||||
|
reason: 'Done according to the transcript.',
|
||||||
|
now: 4_000,
|
||||||
|
})?.status,
|
||||||
|
).toBe('complete')
|
||||||
|
expect(formatGoalStatus(getThreadGoal('thread-a'), 4_000)).toContain(
|
||||||
|
'Latest reason: Done according to the transcript.',
|
||||||
|
)
|
||||||
|
expect(getThreadGoal('thread-b')?.status).toBe('active')
|
||||||
|
expect(clearThreadGoal('thread-a')).toBe(true)
|
||||||
|
expect(getThreadGoal('thread-a')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('builds a native-style continuation prompt', () => {
|
||||||
|
const goal = setThreadGoal('thread-c', {
|
||||||
|
objective: 'PR is ready and all tests pass',
|
||||||
|
now: 1_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
|
||||||
|
).toContain('Continue working toward the active /goal')
|
||||||
|
expect(
|
||||||
|
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
|
||||||
|
).toContain('<objective>PR is ready and all tests pass</objective>')
|
||||||
|
expect(
|
||||||
|
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
|
||||||
|
).toContain('Tests have not been run yet.')
|
||||||
|
})
|
||||||
|
})
|
||||||
218
src/goals/goalState.ts
Normal file
218
src/goals/goalState.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
export type ThreadGoalStatus = 'active' | 'paused' | 'complete' | 'budget_limited'
|
||||||
|
|
||||||
|
export type ThreadGoal = {
|
||||||
|
goalId: string
|
||||||
|
threadId: string
|
||||||
|
objective: string
|
||||||
|
status: ThreadGoalStatus
|
||||||
|
tokenBudget: number | null
|
||||||
|
tokensUsed: number
|
||||||
|
continuationCount: number
|
||||||
|
lastReason: string | null
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedGoalCommand =
|
||||||
|
| { type: 'status' }
|
||||||
|
| { type: 'clear' }
|
||||||
|
| { type: 'pause' }
|
||||||
|
| { type: 'resume' }
|
||||||
|
| { type: 'complete' }
|
||||||
|
| { type: 'set'; objective: string; tokenBudget?: number | null }
|
||||||
|
|
||||||
|
const goalsByThread = new Map<string, ThreadGoal>()
|
||||||
|
|
||||||
|
export function parseGoalCommand(args: string): ParsedGoalCommand {
|
||||||
|
const trimmed = args.trim()
|
||||||
|
if (!trimmed || trimmed === 'status') return { type: 'status' }
|
||||||
|
if (['clear', 'stop', 'off', 'reset', 'none', 'cancel'].includes(trimmed)) {
|
||||||
|
return { type: 'clear' }
|
||||||
|
}
|
||||||
|
if (trimmed === 'pause') return { type: 'pause' }
|
||||||
|
if (trimmed === 'resume') return { type: 'resume' }
|
||||||
|
if (trimmed === 'complete') return { type: 'complete' }
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
let tokenBudget: number | null | undefined
|
||||||
|
let objectiveStart = 0
|
||||||
|
if (parts[0] === '--tokens') {
|
||||||
|
const rawBudget = parts[1]
|
||||||
|
if (!rawBudget) {
|
||||||
|
throw new Error('Usage: /goal --tokens <budget> <objective>')
|
||||||
|
}
|
||||||
|
tokenBudget = parseTokenBudget(rawBudget)
|
||||||
|
objectiveStart = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const objective = parts.slice(objectiveStart).join(' ').trim()
|
||||||
|
if (!objective) {
|
||||||
|
throw new Error('Usage: /goal [--tokens <budget>] <objective>')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'set', objective, tokenBudget }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThreadGoal(
|
||||||
|
threadId: string,
|
||||||
|
input: {
|
||||||
|
objective: string
|
||||||
|
tokenBudget?: number | null
|
||||||
|
now?: number
|
||||||
|
},
|
||||||
|
): ThreadGoal {
|
||||||
|
const now = input.now ?? Date.now()
|
||||||
|
const existing = goalsByThread.get(threadId)
|
||||||
|
const goal: ThreadGoal = {
|
||||||
|
goalId: existing?.goalId ?? randomUUID(),
|
||||||
|
threadId,
|
||||||
|
objective: input.objective.trim(),
|
||||||
|
status: 'active',
|
||||||
|
tokenBudget:
|
||||||
|
input.tokenBudget !== undefined
|
||||||
|
? input.tokenBudget
|
||||||
|
: (existing?.tokenBudget ?? null),
|
||||||
|
tokensUsed: existing?.tokensUsed ?? 0,
|
||||||
|
continuationCount: 0,
|
||||||
|
lastReason: null,
|
||||||
|
createdAt: existing?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
goalsByThread.set(threadId, goal)
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThreadGoal(threadId: string): ThreadGoal | null {
|
||||||
|
return goalsByThread.get(threadId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearThreadGoal(threadId: string): boolean {
|
||||||
|
return goalsByThread.delete(threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateThreadGoalStatus(
|
||||||
|
threadId: string,
|
||||||
|
status: ThreadGoalStatus,
|
||||||
|
now = Date.now(),
|
||||||
|
): ThreadGoal | null {
|
||||||
|
const goal = goalsByThread.get(threadId)
|
||||||
|
if (!goal) return null
|
||||||
|
const updated = { ...goal, status, updatedAt: now }
|
||||||
|
goalsByThread.set(threadId, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markThreadGoalComplete(
|
||||||
|
threadId: string,
|
||||||
|
input: { reason?: string; now?: number } = {},
|
||||||
|
): ThreadGoal | null {
|
||||||
|
const goal = goalsByThread.get(threadId)
|
||||||
|
if (!goal) return null
|
||||||
|
const updated = {
|
||||||
|
...goal,
|
||||||
|
status: 'complete' as const,
|
||||||
|
lastReason: input.reason ?? goal.lastReason,
|
||||||
|
updatedAt: input.now ?? Date.now(),
|
||||||
|
}
|
||||||
|
goalsByThread.set(threadId, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountThreadGoalUsage(
|
||||||
|
threadId: string,
|
||||||
|
tokens: number,
|
||||||
|
now = Date.now(),
|
||||||
|
): ThreadGoal | null {
|
||||||
|
const goal = goalsByThread.get(threadId)
|
||||||
|
if (!goal || tokens <= 0) return goal ?? null
|
||||||
|
const updated = {
|
||||||
|
...goal,
|
||||||
|
tokensUsed: goal.tokensUsed + tokens,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
goalsByThread.set(threadId, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementThreadGoalContinuation(
|
||||||
|
threadId: string,
|
||||||
|
input: { reason?: string; now?: number } = {},
|
||||||
|
): ThreadGoal | null {
|
||||||
|
const goal = goalsByThread.get(threadId)
|
||||||
|
if (!goal) return null
|
||||||
|
const updated = {
|
||||||
|
...goal,
|
||||||
|
continuationCount: goal.continuationCount + 1,
|
||||||
|
lastReason: input.reason ?? goal.lastReason,
|
||||||
|
updatedAt: input.now ?? Date.now(),
|
||||||
|
}
|
||||||
|
goalsByThread.set(threadId, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGoalStatus(goal: ThreadGoal | null, now = Date.now()): string {
|
||||||
|
if (!goal) return 'No active goal.'
|
||||||
|
return [
|
||||||
|
`Goal: ${goal.status}`,
|
||||||
|
`Objective: ${goal.objective}`,
|
||||||
|
`Budget: ${goal.tokensUsed.toLocaleString()} / ${
|
||||||
|
goal.tokenBudget === null ? 'unlimited' : goal.tokenBudget.toLocaleString()
|
||||||
|
} tokens`,
|
||||||
|
`Elapsed: ${formatElapsed(Math.max(0, now - goal.createdAt))}`,
|
||||||
|
`Continuations: ${goal.continuationCount.toLocaleString()}`,
|
||||||
|
goal.lastReason ? `Latest reason: ${goal.lastReason}` : null,
|
||||||
|
]
|
||||||
|
.filter((line): line is string => line !== null)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGoalStartPrompt(goal: ThreadGoal): string {
|
||||||
|
return [
|
||||||
|
'You are now pursuing this /goal until the completion condition is met.',
|
||||||
|
'',
|
||||||
|
`<objective>${goal.objective}</objective>`,
|
||||||
|
'',
|
||||||
|
'Work autonomously. Research, implement, test, and review as needed.',
|
||||||
|
'Before claiming completion, perform a concrete completion audit against the objective.',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGoalContinuationPrompt(
|
||||||
|
goal: ThreadGoal,
|
||||||
|
reason: string,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
'Continue working toward the active /goal.',
|
||||||
|
'',
|
||||||
|
`<objective>${goal.objective}</objective>`,
|
||||||
|
'',
|
||||||
|
'The goal evaluator says the objective is not complete yet.',
|
||||||
|
`Reason: ${reason || 'No reason provided.'}`,
|
||||||
|
'',
|
||||||
|
'Resume directly from the current state. Do not ask the user to continue. Do the next concrete step, then test or review before stopping.',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenBudget(raw: string): number {
|
||||||
|
const match = raw.trim().match(/^(\d+(?:\.\d+)?)([kKmM])?$/)
|
||||||
|
if (!match) throw new Error(`Invalid token budget: ${raw}`)
|
||||||
|
const value = Number(match[1])
|
||||||
|
const suffix = match[2]?.toLowerCase()
|
||||||
|
const multiplier = suffix === 'm' ? 1_000_000 : suffix === 'k' ? 1_000 : 1
|
||||||
|
const budget = Math.floor(value * multiplier)
|
||||||
|
if (!Number.isFinite(budget) || budget <= 0) {
|
||||||
|
throw new Error(`Invalid token budget: ${raw}`)
|
||||||
|
}
|
||||||
|
return budget
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
||||||
|
if (minutes > 0) return `${minutes}m`
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
@ -86,6 +86,7 @@ import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEve
|
|||||||
import { initializeAnalyticsGates } from 'src/services/analytics/sink.js';
|
import { initializeAnalyticsGates } from 'src/services/analytics/sink.js';
|
||||||
import { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js';
|
import { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js';
|
||||||
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
|
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
|
||||||
|
import { filterCommandsForHeadlessMode } from './commands/headless.js';
|
||||||
import type { StatsStore } from './context/stats.js';
|
import type { StatsStore } from './context/stats.js';
|
||||||
import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js';
|
import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js';
|
||||||
import { SHOW_CURSOR } from './ink/termio/dec.js';
|
import { SHOW_CURSOR } from './ink/termio/dec.js';
|
||||||
@ -2639,7 +2640,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
|
|
||||||
// Headless mode supports all prompt commands and some local commands
|
// Headless mode supports all prompt commands and some local commands
|
||||||
// If disableSlashCommands is true, return empty array
|
// If disableSlashCommands is true, return empty array
|
||||||
const commandsHeadless = disableSlashCommands ? [] : commands.filter(command => command.type === 'prompt' && !command.disableNonInteractive || command.type === 'local' && command.supportsNonInteractive);
|
const commandsHeadless = disableSlashCommands ? [] : filterCommandsForHeadlessMode(commands);
|
||||||
const defaultState = getDefaultAppState();
|
const defaultState = getDefaultAppState();
|
||||||
const headlessInitialState: AppState = {
|
const headlessInitialState: AppState = {
|
||||||
...defaultState,
|
...defaultState,
|
||||||
|
|||||||
47
src/query.ts
47
src/query.ts
@ -99,12 +99,14 @@ import { runTools } from './services/tools/toolOrchestration.js'
|
|||||||
import { applyToolResultBudget } from './utils/toolResultStorage.js'
|
import { applyToolResultBudget } from './utils/toolResultStorage.js'
|
||||||
import { recordContentReplacement } from './utils/sessionStorage.js'
|
import { recordContentReplacement } from './utils/sessionStorage.js'
|
||||||
import { handleStopHooks } from './query/stopHooks.js'
|
import { handleStopHooks } from './query/stopHooks.js'
|
||||||
|
import { evaluateThreadGoalAfterTurn } from './goals/goalEvaluator.js'
|
||||||
import { buildQueryConfig } from './query/config.js'
|
import { buildQueryConfig } from './query/config.js'
|
||||||
import { productionDeps, type QueryDeps } from './query/deps.js'
|
import { productionDeps, type QueryDeps } from './query/deps.js'
|
||||||
import type { Terminal, Continue } from './query/transitions.js'
|
import type { Terminal, Continue } from './query/transitions.js'
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import {
|
import {
|
||||||
getCurrentTurnTokenBudget,
|
getCurrentTurnTokenBudget,
|
||||||
|
getSessionId,
|
||||||
getTurnOutputTokens,
|
getTurnOutputTokens,
|
||||||
incrementBudgetContinuationCount,
|
incrementBudgetContinuationCount,
|
||||||
} from './bootstrap/state.js'
|
} from './bootstrap/state.js'
|
||||||
@ -1305,6 +1307,51 @@ async function* queryLoop(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!toolUseContext.agentId) {
|
||||||
|
try {
|
||||||
|
const goalDecision = await evaluateThreadGoalAfterTurn({
|
||||||
|
threadId: getSessionId(),
|
||||||
|
messages: messagesForQuery,
|
||||||
|
assistantMessages,
|
||||||
|
signal: toolUseContext.abortController.signal,
|
||||||
|
querySource,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (goalDecision.action === 'continue') {
|
||||||
|
logForDebugging(
|
||||||
|
`Goal continuation #${goalDecision.goal.continuationCount}: ${goalDecision.reason}`,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
messages: [
|
||||||
|
...messagesForQuery,
|
||||||
|
...assistantMessages,
|
||||||
|
createUserMessage({
|
||||||
|
content: goalDecision.prompt,
|
||||||
|
isMeta: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
toolUseContext,
|
||||||
|
autoCompactTracking: tracking,
|
||||||
|
maxOutputTokensRecoveryCount: 0,
|
||||||
|
hasAttemptedReactiveCompact: false,
|
||||||
|
maxOutputTokensOverride: undefined,
|
||||||
|
pendingToolUseSummary: undefined,
|
||||||
|
stopHookActive: undefined,
|
||||||
|
turnCount,
|
||||||
|
transition: { reason: 'goal_continuation' } as Continue,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (goalDecision.action === 'budget_limited') {
|
||||||
|
logForDebugging('Goal stopped because its budget was reached')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error)
|
||||||
|
logForDebugging('Goal evaluator failed; returning control to user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (feature('TOKEN_BUDGET')) {
|
if (feature('TOKEN_BUDGET')) {
|
||||||
const decision = checkTokenBudget(
|
const decision = checkTokenBudget(
|
||||||
budgetTracker!,
|
budgetTracker!,
|
||||||
|
|||||||
@ -143,6 +143,7 @@ export type LocalJSXCommandModule = {
|
|||||||
|
|
||||||
type LocalJSXCommand = {
|
type LocalJSXCommand = {
|
||||||
type: 'local-jsx'
|
type: 'local-jsx'
|
||||||
|
supportsNonInteractive?: boolean
|
||||||
/**
|
/**
|
||||||
* Lazy-load the command implementation.
|
* Lazy-load the command implementation.
|
||||||
* Returns a module with a call() function.
|
* Returns a module with a call() function.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user