mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +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 issue from './commands/issue/index.js'
|
||||
import feedback from './commands/feedback/index.js'
|
||||
import goal from './commands/goal/index.js'
|
||||
import clear from './commands/clear/index.js'
|
||||
import color from './commands/color/index.js'
|
||||
import commit from './commands/commit.js'
|
||||
@ -303,6 +304,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
tag,
|
||||
theme,
|
||||
feedback,
|
||||
goal,
|
||||
review,
|
||||
ultrareview,
|
||||
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 { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js';
|
||||
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
|
||||
import { filterCommandsForHeadlessMode } from './commands/headless.js';
|
||||
import type { StatsStore } from './context/stats.js';
|
||||
import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.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
|
||||
// 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 headlessInitialState: AppState = {
|
||||
...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 { recordContentReplacement } from './utils/sessionStorage.js'
|
||||
import { handleStopHooks } from './query/stopHooks.js'
|
||||
import { evaluateThreadGoalAfterTurn } from './goals/goalEvaluator.js'
|
||||
import { buildQueryConfig } from './query/config.js'
|
||||
import { productionDeps, type QueryDeps } from './query/deps.js'
|
||||
import type { Terminal, Continue } from './query/transitions.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
import {
|
||||
getCurrentTurnTokenBudget,
|
||||
getSessionId,
|
||||
getTurnOutputTokens,
|
||||
incrementBudgetContinuationCount,
|
||||
} from './bootstrap/state.js'
|
||||
@ -1305,6 +1307,51 @@ async function* queryLoop(
|
||||
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')) {
|
||||
const decision = checkTokenBudget(
|
||||
budgetTracker!,
|
||||
|
||||
@ -143,6 +143,7 @@ export type LocalJSXCommandModule = {
|
||||
|
||||
type LocalJSXCommand = {
|
||||
type: 'local-jsx'
|
||||
supportsNonInteractive?: boolean
|
||||
/**
|
||||
* Lazy-load the command implementation.
|
||||
* Returns a module with a call() function.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user