mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Land autonomous goal loop support on main
The /goal slash command was developed and verified in an isolated worktree, while local main has advanced with unrelated desktop fixes. Merge the feature branch rather than rebasing so both lines of local history remain intact. Constraint: local main contains newer independent commits after the feature base Rejected: Fast-forward merge | main is not an ancestor of the feature branch Confidence: high Scope-risk: moderate Directive: Keep /goal continuation limits bounded and provider-agnostic when modifying the loop 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 DeepSeek v4 pro CLI and desktop /goal validation in /tmp business fixtures Not-tested: push or remote PR creation
This commit is contained in:
commit
966070d496
@ -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