diff --git a/packages/cli/src/ui/commands/goal.ts b/packages/cli/src/ui/commands/goal.ts new file mode 100644 index 0000000..9435a36 --- /dev/null +++ b/packages/cli/src/ui/commands/goal.ts @@ -0,0 +1,89 @@ +import type { GoalVerifier } from '@x-code-cli/core' + +const DEFAULT_SUBAGENT_VERIFIER_PROMPT = + 'Verify that the current goal objective is fully complete in the repository/session state.' + +export function tokenizeArgs(input: string): string[] { + const tokens: string[] = [] + const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g + for (const match of input.matchAll(re)) { + tokens.push((match[1] ?? match[2] ?? match[3] ?? '').replace(/\\"/g, '"').replace(/\\'/g, "'")) + } + return tokens +} + +export function parseGoalCreateArgs(arg: string): { + objective: string + maxTurns?: number + tokenBudget?: number + requiresUserConfirmation?: boolean + verifiers: GoalVerifier[] +} { + const tokens = tokenizeArgs(arg) + const objectiveParts: string[] = [] + const verifiers: GoalVerifier[] = [] + let maxTurns: number | undefined + let tokenBudget: number | undefined + let requiresUserConfirmation = false + let verifierPrompt = DEFAULT_SUBAGENT_VERIFIER_PROMPT + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + if (token === '--verify') { + const command = tokens[++i] + if (command) verifiers.push({ kind: 'shell', command, timeoutMs: 120000 }) + continue + } + if (token === '--max-turns') { + const value = Number(tokens[++i]) + if (Number.isFinite(value) && value > 0) maxTurns = Math.floor(value) + continue + } + if (token === '--token-budget') { + const value = Number(tokens[++i]) + if (Number.isFinite(value) && value > 0) tokenBudget = Math.floor(value) + continue + } + if (token === '--confirm') { + requiresUserConfirmation = true + continue + } + if (token === '--verifier-prompt') { + verifierPrompt = tokens[++i] ?? verifierPrompt + const latestSubAgent = findLatestSubAgentVerifier(verifiers) + if (latestSubAgent?.kind === 'subagent') latestSubAgent.prompt = verifierPrompt + continue + } + if (token === '--verifier-agent') { + const agent = tokens[++i] + if (agent) { + verifiers.push({ + kind: 'subagent', + agent, + prompt: verifierPrompt, + timeoutMs: 120000, + }) + } + continue + } + objectiveParts.push(token) + } + + return { + objective: objectiveParts.join(' ').trim(), + maxTurns, + tokenBudget, + requiresUserConfirmation, + verifiers, + } +} + +function findLatestSubAgentVerifier( + verifiers: GoalVerifier[], +): Extract | undefined { + for (let i = verifiers.length - 1; i >= 0; i--) { + const verifier = verifiers[i] + if (verifier.kind === 'subagent') return verifier + } + return undefined +} diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 0ed373a..e573c02 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -25,6 +25,7 @@ import { import type { AgentOptions, DiffStats, + GoalState, KnowledgeFact, LanguageModel, LoadedSession, @@ -35,6 +36,7 @@ import type { import { VERSION } from '../../version.js' import { createBrowserCommandHandler } from '../commands/browser.js' import { createDoctorCommandHandler } from '../commands/doctor.js' +import { parseGoalCreateArgs, tokenizeArgs } from '../commands/goal.js' import { createMcpCommandHandler } from '../commands/mcp.js' import { createPluginCommandHandler } from '../commands/plugin.js' import { createSkillCommandHandler } from '../commands/skill.js' @@ -94,6 +96,21 @@ export const SLASH_COMMANDS = [ }, { name: '/clear', description: 'Clear conversation history' }, { name: '/compact', description: 'Manually compress context' }, + { + name: '/goal', + description: 'Run a durable, verifiable goal loop', + argumentHint: '|status|pause|resume|cancel|clear|steer|verify', + subcommands: [ + { name: 'status', description: 'Show current goal state' }, + { name: 'pause', description: 'Pause the active goal loop' }, + { name: 'resume', description: 'Resume a paused or blocked goal loop' }, + { name: 'cancel', description: 'Cancel the current goal' }, + { name: 'clear', description: 'Clear current goal state' }, + { name: 'edit', description: 'Edit objective or max-turns for the current goal' }, + { name: 'steer', description: 'Add steering input for the next goal turn' }, + { name: 'verify', description: 'Wake the goal runner to verify/continue' }, + ], + }, { name: '/resume', description: 'Pick a past session in this project to resume', argumentHint: '[id]' }, { name: '/rewind', @@ -238,6 +255,50 @@ function formatRelativeTime(epochMs: number): string { return new Date(epochMs).toISOString().slice(0, 10) } +function canResumeGoalStatus(goal: GoalState): boolean { + return ( + goal.status === 'active' || goal.status === 'paused' || goal.status === 'blocked' || goal.status === 'max_turns' + ) +} + +function formatGoalTokenBudget(goal: GoalState, usage?: TokenUsage): string { + if (!goal.tokenBudget) return 'unlimited' + const used = usage ? Math.max(0, usage.totalTokens - goal.baselineTokens) : undefined + const remaining = used === undefined ? undefined : Math.max(0, goal.tokenBudget - used) + const total = goal.tokenBudget.toLocaleString('en-US') + return remaining === undefined ? total : `${remaining.toLocaleString('en-US')} remaining / ${total}` +} + +function formatGoalStatus(goal: GoalState, usage?: TokenUsage): string { + const latest = goal.verificationResults.at(-1) + const verifiers = goal.verifiers.length + ? goal.verifiers + .map((v, i) => { + if (v.kind === 'shell') return `${i + 1}. shell: \`${v.command}\`` + if (v.kind === 'subagent') return `${i + 1}. subagent: ${v.agent}` + return `${i + 1}. file: ${v.path}` + }) + .join('\n') + : 'none' + return [ + '**Goal Status**', + '', + `- Objective: ${goal.objective}`, + `- Status: ${goal.status}`, + `- Turns: ${goal.turnCount}/${goal.maxTurns}`, + `- Token budget: ${formatGoalTokenBudget(goal, usage)}`, + `- Pending transition: ${goal.pendingTransition?.kind ?? 'none'}`, + `- Latest verifier: ${latest ? `${latest.ok ? 'passed' : 'failed'} - ${latest.summary}` : 'none'}`, + `- Repeated blocker: ${goal.repeatedBlockerCount}`, + '', + '**Verifiers**', + verifiers, + goal.finalSummary ? `\n**Final Summary**\n${goal.finalSummary}` : '', + ] + .filter(Boolean) + .join('\n') +} + // formatUsageHistory was replaced by the interactive handleUsageHistory // picker inside the component — see handleUsageHistory(). @@ -346,6 +407,14 @@ export function App({ const { state, submit, + runGoal, + pauseGoal, + resumeGoal, + cancelGoal, + clearGoal, + steerGoal, + editGoal, + verifyGoal, resolvePermission, resolveQuestion, abort, @@ -362,7 +431,6 @@ export function App({ getThinking, invalidateSystemPromptCache, addInfoMessage, - addUserMessage, echoCommand, addCommandMessage, addCommandResult, @@ -819,14 +887,8 @@ export function App({ return case 'clear': - // No echo / result message — ChatInput's shrink-detection path - // wipes the visible terminal + scrollback so the user sees an - // empty viewport with just the input box. Adding a "Conversation - // cleared." line would force the cleared screen to immediately - // start re-painting at row 1, defeating the "fresh launch" look - // the user asked for. pendingSkillRef.current = null - clear() + clear(text) return case 'compact': @@ -834,6 +896,11 @@ export function App({ await handleCompact() return + case 'goal': + echoCommand(text) + await handleGoal(arg) + return + case 'resume': echoCommand(text) await handleResume() @@ -1267,6 +1334,113 @@ export function App({ addCommandMessage(commandText, next ? 'Enabled plan mode' : 'Disabled plan mode') } + async function handleGoal(arg: string) { + try { + const [subcommand = '', ...rest] = tokenizeArgs(arg) + const goal = state.goalStatus + const lower = subcommand.toLowerCase() + + if (!arg.trim() || lower === 'status') { + addCommandResult(goal ? formatGoalStatus(goal, state.usage) : 'No current goal.') + return + } + + if (lower === 'pause') { + const paused = await pauseGoal() + addCommandResult(paused ? `Goal paused: ${paused.objective}` : 'No active goal to pause.') + return + } + + if (lower === 'resume') { + const maxTurnsArg = rest[0] === '--max-turns' ? rest[1] : undefined + if (maxTurnsArg && goal && canResumeGoalStatus(goal)) { + const maxTurns = maxTurnsArg.startsWith('+') + ? goal.maxTurns + Number(maxTurnsArg.slice(1)) + : Number(maxTurnsArg) + if (Number.isFinite(maxTurns) && maxTurns > 0) { + await editGoal({ maxTurns }) + } + } + const resumed = await resumeGoal() + addCommandResult(resumed ? `Goal resumed: ${resumed.objective}` : 'No goal to resume.') + return + } + + if (lower === 'cancel') { + const cancelled = await cancelGoal() + addCommandResult(cancelled ? `Goal cancelled: ${cancelled.objective}` : 'No goal to cancel.') + return + } + + if (lower === 'clear') { + await clearGoal() + addCommandResult('Goal cleared.') + return + } + + if (lower === 'steer') { + const steering = rest.join(' ').trim() + if (!steering) { + addCommandResult('Usage: /goal steer ') + return + } + const steered = await steerGoal(steering) + addCommandResult(steered ? 'Goal steering queued.' : 'No goal to steer.') + return + } + + if (lower === 'edit') { + const parsed = parseGoalCreateArgs(rest.join(' ')) + const edited = await editGoal({ + objective: parsed.objective || undefined, + maxTurns: parsed.maxTurns, + }) + addCommandResult(edited ? `Goal edited: ${edited.objective}` : 'No goal to edit.') + return + } + + if (lower === 'verify') { + if (!goal) { + addCommandResult('No goal to verify.') + return + } + if (goal.verifiers.length === 0 && !goal.requiresUserConfirmation) { + addCommandResult('No verifier configured for this goal.') + return + } + const verified = await verifyGoal() + addCommandResult( + verified + ? `Goal verification ${verified.ok ? 'passed' : 'failed'}: ${verified.summary}` + : 'No goal to verify.', + ) + return + } + + const parsed = parseGoalCreateArgs(arg) + if (!parsed.objective) { + addCommandResult( + 'Usage: /goal [--verify "cmd"] [--verifier-agent name] [--verifier-prompt "prompt"] [--max-turns n] [--token-budget n] [--confirm]', + ) + return + } + if (goal?.status === 'active' || goal?.status === 'paused') { + addCommandResult(`Cannot create a new goal while goal ${goal.id} is ${goal.status}`) + return + } + addCommandResult(`Goal started: ${parsed.objective}`) + await runGoal({ + objective: parsed.objective, + maxTurns: parsed.maxTurns, + tokenBudget: parsed.tokenBudget, + verifiers: parsed.verifiers, + requiresUserConfirmation: parsed.requiresUserConfirmation, + }) + } catch (err) { + addCommandResult(err instanceof Error ? err.message : String(err)) + } + } + async function handleCompact() { const result = await compact() if (!result) { diff --git a/packages/cli/src/ui/components/ChatInput.tsx b/packages/cli/src/ui/components/ChatInput.tsx index 96ca329..51710db 100644 --- a/packages/cli/src/ui/components/ChatInput.tsx +++ b/packages/cli/src/ui/components/ChatInput.tsx @@ -177,6 +177,17 @@ interface ChatInputProps { // ── Component ─────────────────────────────────────────────────────────── +export function computePostContentScrollRows( + startRow: number, + contentRows: number, + frameTop: number, + terminalRows: number, +): number { + const naturalScroll = Math.max(0, startRow + contentRows - 1 - terminalRows) + const effectiveContentEnd = Math.min(terminalRows, startRow + contentRows - 1 - naturalScroll) + return Math.max(0, effectiveContentEnd - frameTop + 1) +} + export function ChatInput({ messages, initialContentRows = 0, @@ -198,6 +209,7 @@ export function ChatInput({ contextUsage, }: ChatInputProps) { const [{ text, cursor }, dispatch] = useReducer(inputReducer, { text: '', cursor: 0 }) + const textRef = useRef('') const cursorRef = useRef(0) // Double-tap Esc to clear the input (idle mode only; loading mode uses // single Esc to cancel the in-flight turn). Timestamp of the last Esc @@ -205,8 +217,9 @@ export function ChatInput({ // triggers RESET. const lastEscapeAtRef = useRef(0) useLayoutEffect(() => { + textRef.current = text cursorRef.current = cursor - }) + }, [cursor, text]) const [pastedContents, setPastedContents] = useState({}) const [completionIndex, setCompletionIndex] = useState(0) const [atCompletionIndex, setAtCompletionIndex] = useState(0) @@ -585,7 +598,7 @@ export function ChatInput({ // Block submit while the agent is still thinking. Keystrokes still flow // (the keyboard stays enabled so users can pre-type the next prompt) — // only Enter is suppressed, matching Claude Code's behavior. - if (spinner) return + if (spinner && !raw.trimStart().toLowerCase().startsWith('/goal')) return const expanded = override ? raw : expandPasteRefs(raw, pastedContents) // Record the pre-expansion form in input history (Up/Down recall) so // that restoring an entry doesn't unfold the entire paste block back @@ -755,22 +768,33 @@ export function ChatInput({ enabled: !disabled && !hidden, onInterrupt, onText: (chunk) => { + const dialogSlashDraft = textRef.current.trimStart().startsWith('/') || chunk.trimStart().startsWith('/') // Route single-char y/n to the Permission resolver when a dialog is - // active; block all other text input so the user can't type into - // the input box behind the dialog. + // active. Slash commands remain editable so `/goal pause` / + // `/goal cancel` can interrupt a goal even while a tool approval is + // waiting. if (permission) { const ch = chunk.toLowerCase() - if (ch === 'y') { + if (!dialogSlashDraft && ch === 'y') { permission.onResolve('yes') return } - if (ch === 'n') { + if (!dialogSlashDraft && ch === 'n') { permission.onResolve('no') return } + if (dialogSlashDraft) { + dispatch({ type: 'INSERT', pos: cursorRef.current, chunk }) + setCompletionIndex(0) + } return } if (selectRequest) { + if (dialogSlashDraft) { + dispatch({ type: 'INSERT', pos: cursorRef.current, chunk }) + setCompletionIndex(0) + return + } // Typing on a freeform option ("Other") feeds the inline text // buffer; on regular options it's still swallowed so the user // can't type into the hidden input behind the dialog. @@ -787,8 +811,20 @@ export function ChatInput({ setCompletionIndex(0) }, onPaste: (content) => { - if (permission) return + const dialogSlashPaste = content.trimStart().startsWith('/') + if (permission) { + if (dialogSlashPaste) { + dispatch({ type: 'INSERT', pos: cursorRef.current, chunk: content }) + setCompletionIndex(0) + } + return + } if (selectRequest) { + if (dialogSlashPaste) { + dispatch({ type: 'INSERT', pos: cursorRef.current, chunk: content }) + setCompletionIndex(0) + return + } // Allow paste into the freeform buffer (e.g. pasting a long // path or model id). Skip the large-paste reference machinery // — it's a usability hit for free-text answers, which are @@ -816,8 +852,9 @@ export function ChatInput({ setCompletionIndex(0) }, onKey: (key) => { + const dialogSlashMode = textRef.current.trimStart().startsWith('/') // Permission dialog captures navigation + submit keys. - if (permission) { + if (permission && !dialogSlashMode) { const hasAlwaysOption = suggestRuleLabel(permission.toolName, permission.input, !!permission.mcp) !== null const maxIdx = hasAlwaysOption ? 2 : 1 if (key === 'up') { @@ -839,7 +876,7 @@ export function ChatInput({ // the highlighted option is `freeform`, editing keys (backspace, // delete, left, right, home, end) also feed its inline text // buffer instead of being swallowed. - if (selectRequest) { + if (selectRequest && !dialogSlashMode) { const len = selectRequest.options.length const opt = selectRequest.options[selectIndex] const isFreeform = !!opt?.freeform @@ -1154,21 +1191,30 @@ export function ChatInput({ // pushing rows into real scrollback (DECSTBM-restricted region scrolls // are splice-discarded in xterm.js's InputHandler, confirmed in source). // - // /clear shrinks the message list back to empty. Used to be a silent - // counter reset — meaning the in-memory history disappeared but the - // OLD scrollback stayed visible, so users reported "/clear does - // nothing". Now we erase the viewport + xterm scrollback and reset - // the frame-tracking refs so the next paint runs as a clean - // first-paint, anchored to the top of the empty terminal. + // /clear shrinks the message list back to empty. Push the current + // viewport into real terminal scrollback before repainting an empty + // viewport. CSI 3J must not be used here: unlike a shell `clear`, it + // destroys history that the user expects to reach by scrolling up. if (messages.length < writtenMessageCountRef.current) { - // \x1b[2J: erase the visible screen. - // \x1b[3J: drop xterm-style scrollback history (Windows Terminal, - // xterm.js, iTerm2, kitty, GNOME Terminal — all honor it; the - // handful of legacy terminals that ignore it still get the - // viewport cleared, which is already a strict improvement). - // \x1b[H : home the cursor so the (empty) frame paint below lands - // at known coordinates instead of wherever Ink last parked it. - preBuf += '\x1b[2J\x1b[3J\x1b[H' + const retainedClearEcho = + messages.length === 1 && messages[0]?.kind === 'command-echo' && messages[0].content.trim() === '/clear' + const clearRows = stdout?.rows ?? 25 + const oldFrameTop = Math.max(1, lastFrameTopRef.current || clearRows - lastFrameHRef.current + 1) + if (retainedClearEcho) { + // Commit the canonical command echo at the bottom of the OLD + // viewport first. The writer's leading/trailing line breaks move + // it into position; the remaining LFs then make it the final row + // in scrollback. The new viewport therefore starts empty instead + // of showing `/clear` above the fresh input frame. + for (let row = oldFrameTop; row <= clearRows; row++) preBuf += `\x1b[${row};1H\x1b[K` + resetScrollbackSpacing() + preBuf += `\x1b[${clearRows};1H` + writeMessageToStdout((data) => (preBuf += data), messages[0]!) + preBuf += `${'\n'.repeat(Math.max(0, clearRows - 1))}\x1b[H` + } else { + for (let row = oldFrameTop; row <= clearRows; row++) preBuf += `\x1b[${row};1H\x1b[K` + preBuf += `\x1b[${clearRows};1H${'\n'.repeat(clearRows)}\x1b[H` + } writtenMessageCountRef.current = messages.length prevFrameRef.current = [] lastFrameHRef.current = 0 @@ -2557,6 +2603,13 @@ export function ChatInput({ } preBuf += `\x1b[${startRow};1H` preBuf += scrollbackContent + const postContentScrollRows = computePostContentScrollRows(startRow, scrollRows, frameTop, termRows) + if (postContentScrollRows > 0) { + // Content taller than the space above the frame leaves its tail + // in rows the absolute-positioned frame is about to repaint. + // Move only that overlap into real scrollback first. + preBuf += `\x1b[${termRows};1H${'\n'.repeat(postContentScrollRows)}` + } // When the frame shrank significantly (e.g. askUser dialog closed), // scrollback content only fills a fraction of the space the old frame // occupied. Instead of anchoring the frame at the terminal bottom and diff --git a/packages/cli/src/ui/hooks/use-agent.ts b/packages/cli/src/ui/hooks/use-agent.ts index 1ef1b2f..614c83f 100644 --- a/packages/cli/src/ui/hooks/use-agent.ts +++ b/packages/cli/src/ui/hooks/use-agent.ts @@ -3,30 +3,50 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { TOOL_SEARCH_TOOL_NAME, + admitGoalInput, agentLoop, appendCheckpoint, + appendGoalInput, + appendGoalState, + appendGoalVerification, + appendHeader, appendInterrupted, buildUserContent, + cancelGoal as cancelCoreGoal, capabilitiesOf, classifyApiError, + clearGoal as clearCoreGoal, + clearPendingTransition, compressMessages, + createGoal as createCoreGoal, + createGoalRunCoordinator, + createLoopState, flushPendingMessages, + generateTaskSlug, getDiffStatsForCheckpoint, hydrateLoopState, initMemories, loadPersistedRules, markBoundaryAndReflush, + pauseGoal as pauseCoreGoal, restoreCheckpoint, + resumeGoal as resumeCoreGoal, + runGoalLoop, + runVerifierLadder, saveSession, + updateGoalStatus, } from '@x-code-cli/core' import { extractText } from '@x-code-cli/core' import type { AgentCallbacks, + AgentLoopResult, AgentOptions, CheckpointEntry, DiffStats, DisplayMessage, DisplayToolCall, + GoalState, + GoalVerifier, LanguageModel, LoadedSession, LoopState, @@ -127,6 +147,17 @@ export interface AgentState { * spinner label so the user sees which compression phase is active * instead of a generic "Thinking…". Cleared when compression ends. */ compressionLabel: string | null + goalStatus: GoalState | null + goalRunnerActive: boolean + goalVerificationActive: boolean +} + +export interface RunGoalCommand { + objective: string + maxTurns?: number + tokenBudget?: number + verifiers?: GoalVerifier[] + requiresUserConfirmation?: boolean } const initialState: Omit = { @@ -148,6 +179,9 @@ const initialState: Omit = { todos: [], bufferingReads: false, compressionLabel: null, + goalStatus: null, + goalRunnerActive: false, + goalVerificationActive: false, } export function useAgent(initialModel: LanguageModel, options: AgentOptions, initialSession?: LoadedSession | null) { @@ -163,6 +197,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini permissionMode: options.permissionMode ?? 'default', messages: initialSession ? modelMessagesToDisplay(initialSession.messages) : initialState.messages, usage: initialSession ? { ...initialSession.tokenUsage } : initialState.usage, + goalStatus: initialSession?.goal ? { ...initialSession.goal } : null, }) const modelRef = useRef(initialModel) @@ -185,7 +220,9 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini const thinkingRef = useRef(options.thinking ?? false) const loopStateRef = useRef(null) const abortControllerRef = useRef(null) + const goalCoordinatorRef = useRef(createGoalRunCoordinator()) const initializedRef = useRef(false) + const pendingQuestionRef = useRef(null) /** Pending tool calls keyed by toolCallId. A single slot can't survive * parallel tool calls in one turn — the SDK emits tool-call A, tool-call * B, tool-result A, tool-result B, so a shared slot gets overwritten and @@ -268,7 +305,15 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini * echoCommand, and dumping the full prompt body into scrollback would be * noise. The spinner / abort signal / session save still fire normally. */ const submit = useCallback( - async (text: string, submitOptions?: { silent?: boolean }) => { + async ( + text: string, + submitOptions?: { + silent?: boolean + toolFilter?: AgentOptions['toolFilter'] + maxTurns?: number + signal?: AbortSignal + }, + ): Promise => { await initialize() setState((prev) => ({ @@ -283,6 +328,10 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini const controller = new AbortController() abortControllerRef.current = controller + const externalSignal = submitOptions?.signal + const abortFromExternal = () => controller.abort() + if (externalSignal?.aborted) controller.abort() + else externalSignal?.addEventListener('abort', abortFromExternal, { once: true }) // Track whether the stream produced any text for this submit, so the // safety-net extraction below doesn't duplicate already-flushed text. @@ -431,16 +480,15 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini ] : [] const augmented = [...opts, ...planMeta, OTHER_OPTION] - setState((prev) => ({ - ...prev, - pendingQuestion: { - question, - options: augmented, - resolve, - abortAnswer: '[Request interrupted by user]', - layout: 'compact-vertical', - }, - })) + const pendingQuestion: PendingQuestion = { + question, + options: augmented, + resolve, + abortAnswer: '[Request interrupted by user]', + layout: 'compact-vertical', + } + pendingQuestionRef.current = pendingQuestion + setState((prev) => ({ ...prev, pendingQuestion })) }) }, onPlanApprovalRequest: (planText) => { @@ -464,18 +512,17 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini // paints first — avoids a simultaneous commit+grow // that confuses the geometry engine. setTimeout(() => { - setState((prev) => ({ - ...prev, - pendingQuestion: { - question: 'Approve the plan above?', - options: [ - { label: 'Yes', description: 'Exit plan mode and start implementing (writes auto-approved).' }, - { label: 'No', description: 'Stay in plan mode and let the model revise.' }, - ], - resolve: (answer) => resolve(answer === 'Yes'), - abortAnswer: 'No', - }, - })) + const pendingQuestion: PendingQuestion = { + question: 'Approve the plan above?', + options: [ + { label: 'Yes', description: 'Exit plan mode and start implementing (writes auto-approved).' }, + { label: 'No', description: 'Stay in plan mode and let the model revise.' }, + ], + resolve: (answer) => resolve(answer === 'Yes'), + abortAnswer: 'No', + } + pendingQuestionRef.current = pendingQuestion + setState((prev) => ({ ...prev, pendingQuestion })) }, 0) }) }, @@ -578,6 +625,11 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini // (long-lived session). turnCount is per-invocation and the main // interactive loop has no use for it (the cap mechanism is what // sub-agents and --print mode use). + if (submitOptions?.toolFilter && loopStateRef.current) { + loopStateRef.current.systemPromptCache = null + loopStateRef.current.expectCacheMiss = true + } + const agentResult = await agentLoop( content, modelRef.current, @@ -585,6 +637,8 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini ...options, modelId: modelIdRef.current, thinking: thinkingRef.current, + toolFilter: submitOptions?.toolFilter ?? options.toolFilter, + maxTurns: submitOptions?.maxTurns ?? options.maxTurns, // permissionMode only matters for the FIRST submit (when // createLoopState is called inside agentLoop). For subsequent // submits the existing LoopState carries the live mode, so @@ -596,6 +650,11 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini loopStateRef.current ?? undefined, ) loopStateRef.current = agentResult.state + if (submitOptions?.toolFilter) { + loopStateRef.current.systemPromptCache = null + loopStateRef.current.expectCacheMiss = true + } + const finalGoal = agentResult.state.goal ? { ...agentResult.state.goal } : null // Finalize: drain whatever's left in the stream buffer into messages, // then clear the loading flag. As a safety net, if streaming produced @@ -621,9 +680,13 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini activeToolCalls: [], bufferingReads: false, compressionLabel: null, + goalStatus: finalGoal, })) + externalSignal?.removeEventListener('abort', abortFromExternal) + return agentResult } catch (err) { pendingToolsRef.current.clear() + externalSignal?.removeEventListener('abort', abortFromExternal) // User-cancel path: agentLoop swallows AbortError into a clean // 'aborted' outcome and returns normally, so we shouldn't reach // here for an Esc/Ctrl+C abort. But if some unaborted-aware @@ -640,6 +703,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini compressionLabel: null, error: wasAborted ? null : classifyApiError(err).message, })) + return null } }, [options, initialize, appendTextDelta, flushBuffer, appendMessage], @@ -664,12 +728,10 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini /** Resolve a pending question */ const resolveQuestion = useCallback((answer: string) => { - setState((prev) => { - if (prev.pendingQuestion) { - queueMicrotask(() => prev.pendingQuestion!.resolve(answer)) - } - return { ...prev, pendingQuestion: null } - }) + const pendingQuestion = pendingQuestionRef.current + pendingQuestionRef.current = null + if (pendingQuestion) queueMicrotask(() => pendingQuestion.resolve(answer)) + setState((prev) => ({ ...prev, pendingQuestion: null })) }, []) /** Pop a multi-choice question for the user. Same SelectOptions dialog @@ -684,22 +746,379 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini ) => { return new Promise((resolve) => { const augmented = opts?.noOther ? options : [...options, OTHER_OPTION] + const pendingQuestion: PendingQuestion = { + question, + options: augmented, + resolve, + abortAnswer: '', + dismissible: true, + layout: opts?.layout, + } + pendingQuestionRef.current = pendingQuestion + setState((prev) => ({ ...prev, pendingQuestion })) + }) + }, + [], + ) + + const createGoalCallbacks = useCallback((): AgentCallbacks => { + return { + onTextDelta: appendTextDelta, + onToolCall: (toolCallId, toolName, input) => { + flushBuffer() + pendingToolsRef.current.set(toolCallId, { toolName, input, startedAt: Date.now() }) setState((prev) => ({ ...prev, - pendingQuestion: { + activeToolCalls: [...prev.activeToolCalls, { id: toolCallId, toolName, input }], + })) + }, + onToolProgress: (toolCallId, message) => { + setState((prev) => { + const idx = prev.activeToolCalls.findIndex((t) => t.id === toolCallId) + if (idx < 0) return prev + const next = prev.activeToolCalls.slice() + next[idx] = { ...next[idx], progress: message } + return { ...prev, activeToolCalls: next } + }) + }, + onToolResult: (toolCallId, result, isError) => { + const pending = pendingToolsRef.current.get(toolCallId) + pendingToolsRef.current.delete(toolCallId) + const durationMs = pending ? Date.now() - pending.startedAt : 0 + setState((prev) => { + const tc: DisplayToolCall = { + id: `tc-${Date.now()}`, + toolName: pending?.toolName ?? 'unknown', + input: pending?.input ?? {}, + output: result, + status: isError ? 'error' : 'completed', + durationMs, + } + return { + ...prev, + activeToolCalls: prev.activeToolCalls.filter((t) => t.id !== toolCallId), + messages: [ + ...prev.messages, + { + id: `tool-${Date.now()}`, + role: 'assistant', + content: '', + toolCalls: [tc], + timestamp: Date.now(), + }, + ], + } + }) + }, + onAskPermission: (toolCall) => { + return new Promise<'yes' | 'always' | 'no'>((resolve) => { + permissionResolversRef.current.push(resolve) + const mcpEntry = options.mcpRegistry?.get(toolCall.toolName) + setState((prev) => ({ + ...prev, + permissionQueue: [ + ...prev.permissionQueue, + { + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + input: toolCall.input, + mcp: mcpEntry ? { serverName: mcpEntry.serverName, rawName: mcpEntry.rawName } : undefined, + }, + ], + })) + }) + }, + onAskUser: (question, opts) => { + return new Promise((resolve) => { + const pendingQuestion: PendingQuestion = { question, - options: augmented, + options: [...opts, OTHER_OPTION], resolve, abortAnswer: '', - dismissible: true, - layout: opts?.layout, + layout: 'compact-vertical', + } + pendingQuestionRef.current = pendingQuestion + setState((prev) => ({ ...prev, pendingQuestion })) + }) + }, + onPlanApprovalRequest: async () => false, + onPlanModeChange: (mode) => { + permissionModeRef.current = mode + setState((prev) => ({ ...prev, permissionMode: mode })) + }, + onTodosUpdate: (todos) => setState((prev) => ({ ...prev, todos })), + onShellOutput: (chunk) => setState((prev) => ({ ...prev, shellOutput: prev.shellOutput + chunk })), + onUsageUpdate: (usage) => setState((prev) => ({ ...prev, usage })), + onContextCompressed: (summary) => { + appendMessage({ + id: `compress-${Date.now()}`, + role: 'assistant', + content: summary, + timestamp: Date.now(), + kind: 'command-result', + }) + }, + onError: (error) => setState((prev) => ({ ...prev, error: error.message })), + } + }, [appendMessage, appendTextDelta, flushBuffer, options.mcpRegistry]) + + const ensureLoopState = useCallback((): LoopState => { + if (!loopStateRef.current) { + loopStateRef.current = createLoopState(permissionModeRef.current) + } + return loopStateRef.current + }, []) + + const prepareGoalSession = useCallback(async (ls: LoopState, firstPrompt: string) => { + if (!ls.taskSlug) { + ls.taskSlug = await generateTaskSlug( + firstPrompt, + modelRef.current, + modelIdRef.current, + abortControllerRef.current?.signal, + ) + } + await appendHeader(ls, modelIdRef.current, firstPrompt) + }, []) + + const runGoal = useCallback( + async (command: RunGoalCommand): Promise => { + const ls = ensureLoopState() + const goal = createCoreGoal(ls, { + objective: command.objective, + maxTurns: command.maxTurns, + tokenBudget: command.tokenBudget, + verifiers: command.verifiers ?? [], + requiresUserConfirmation: command.requiresUserConfirmation, + }) + setState((prev) => ({ ...prev, goalStatus: { ...goal }, goalRunnerActive: true })) + await prepareGoalSession(ls, command.objective) + await appendGoalState(ls) + + await goalCoordinatorRef.current.run(goal.id, async (signal) => { + try { + await runGoalLoop({ + state: ls, + model: modelRef.current, + options: { + ...options, + modelId: modelIdRef.current, + thinking: thinkingRef.current, + permissionMode: permissionModeRef.current, + abortSignal: signal, + }, + callbacks: createGoalCallbacks(), + goalId: goal.id, + signal, + runAgentTurn: async (content, turnOptions) => { + const result = await submit(content, { + silent: true, + toolFilter: turnOptions?.finalSummary ? { allow: [] } : undefined, + maxTurns: turnOptions?.finalSummary ? 1 : undefined, + signal, + }) + if (!result) throw new Error('Goal agent turn did not complete') + return { + ...result, + text: extractLastAssistantText(result.state.messages), + } + }, + }) + } finally { + if (loopStateRef.current) { + void appendGoalState(loopStateRef.current) + setState((prev) => ({ + ...prev, + goalStatus: loopStateRef.current?.goal ? { ...loopStateRef.current.goal } : null, + goalRunnerActive: false, + })) + } else { + setState((prev) => ({ ...prev, goalRunnerActive: false })) + } + } + }) + }, + [createGoalCallbacks, ensureLoopState, options, prepareGoalSession, submit], + ) + + const drainPendingInteractions = useCallback(() => { + const permResolvers = permissionResolversRef.current + permissionResolversRef.current = [] + for (const r of permResolvers) r('no') + + const pendingQuestion = pendingQuestionRef.current + pendingQuestionRef.current = null + setState((prev) => ({ ...prev, permissionQueue: [], pendingQuestion: null, bufferingReads: false })) + if (pendingQuestion) pendingQuestion.resolve(pendingQuestion.abortAnswer) + }, []) + + const pauseGoal = useCallback(async (): Promise => { + const ls = loopStateRef.current + if (!ls?.goal) return null + if (ls.goal.status !== 'active') return null + const goal = pauseCoreGoal(ls) + abortControllerRef.current?.abort() + drainPendingInteractions() + await goalCoordinatorRef.current.interrupt(goal.id) + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: { ...goal }, goalRunnerActive: false })) + return goal + }, [drainPendingInteractions]) + + const resumeGoal = useCallback(async (): Promise => { + const ls = loopStateRef.current + if (!ls?.goal) return null + let goal: GoalState + try { + goal = ls.goal.status === 'active' ? ls.goal : resumeCoreGoal(ls) + } catch { + return null + } + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: { ...goal }, goalRunnerActive: true })) + goalCoordinatorRef.current.wake(goal.id, async (signal) => { + try { + await runGoalLoop({ + state: ls, + model: modelRef.current, + options: { + ...options, + modelId: modelIdRef.current, + thinking: thinkingRef.current, + permissionMode: permissionModeRef.current, + abortSignal: signal, + }, + callbacks: createGoalCallbacks(), + goalId: goal.id, + signal, + runAgentTurn: async (content, turnOptions) => { + const result = await submit(content, { + silent: true, + toolFilter: turnOptions?.finalSummary ? { allow: [] } : undefined, + maxTurns: turnOptions?.finalSummary ? 1 : undefined, + signal, + }) + if (!result) throw new Error('Goal agent turn did not complete') + return { ...result, text: extractLastAssistantText(result.state.messages) } }, + }) + } finally { + setState((prev) => ({ + ...prev, + goalStatus: ls.goal ? { ...ls.goal } : null, + goalRunnerActive: false, })) - }) + } + }) + return goal + }, [createGoalCallbacks, options, submit]) + + const cancelGoal = useCallback(async (): Promise => { + const ls = loopStateRef.current + if (!ls?.goal) return null + const goal = cancelCoreGoal(ls) + abortControllerRef.current?.abort() + drainPendingInteractions() + await goalCoordinatorRef.current.interrupt(goal.id) + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: { ...goal }, goalRunnerActive: false })) + return goal + }, [drainPendingInteractions]) + + const clearGoal = useCallback(async (): Promise => { + const ls = loopStateRef.current + if (!ls) return + const goalId = ls.goal?.id + if (goalId) { + abortControllerRef.current?.abort() + drainPendingInteractions() + await goalCoordinatorRef.current.interrupt(goalId) + } + clearCoreGoal(ls) + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: null, goalRunnerActive: false })) + }, [drainPendingInteractions]) + + const steerGoal = useCallback( + async (text: string): Promise => { + const ls = loopStateRef.current + if (!ls?.goal) return null + const input = admitGoalInput(ls, { goalId: ls.goal.id, kind: 'user_steering', content: text }) + await appendGoalInput(ls, input) + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: ls.goal ? { ...ls.goal } : null })) + await resumeGoal() + return ls.goal }, - [], + [resumeGoal], ) + const editGoal = useCallback(async (input: { objective?: string; maxTurns?: number }): Promise => { + const ls = loopStateRef.current + if (!ls?.goal) return null + if (input.objective !== undefined) { + const objective = input.objective.trim() + if (objective) ls.goal.objective = objective + } + if (input.maxTurns !== undefined && Number.isFinite(input.maxTurns) && input.maxTurns > 0) { + ls.goal.maxTurns = Math.floor(input.maxTurns) + } + ls.goal.updatedAt = new Date().toISOString() + ls.systemPromptCache = null + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: ls.goal ? { ...ls.goal } : null })) + return ls.goal + }, []) + + const verifyGoal = useCallback(async (): Promise<{ goal: GoalState; ok: boolean; summary: string } | null> => { + const ls = loopStateRef.current + if (!ls?.goal) return null + const controller = new AbortController() + abortControllerRef.current = controller + setState((prev) => ({ ...prev, goalVerificationActive: true })) + try { + const goal = ls.goal + const verification = await runVerifierLadder({ + goal, + state: ls, + options: { + ...options, + modelId: modelIdRef.current, + thinking: thinkingRef.current, + permissionMode: permissionModeRef.current, + abortSignal: controller.signal, + }, + callbacks: createGoalCallbacks(), + model: modelRef.current, + }) + for (const result of verification.results) { + await appendGoalVerification(ls, goal.id, result) + } + + if (goal.status === 'active') { + if (verification.ok) { + updateGoalStatus(goal, 'complete', verification.summary) + clearPendingTransition(goal) + } else { + clearPendingTransition(goal) + const input = admitGoalInput(ls, { + goalId: goal.id, + kind: 'verifier_failure', + content: `Manual goal verification failed:\n${verification.summary}\n\nContinue fixing the goal, then request completion again.`, + }) + await appendGoalInput(ls, input) + await resumeGoal() + } + } + + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: { ...goal } })) + return { goal, ok: verification.ok, summary: verification.summary } + } finally { + setState((prev) => ({ ...prev, goalVerificationActive: false })) + } + }, [createGoalCallbacks, options, resumeGoal]) + /** Abort the in-flight turn. Mirrors Claude Code's onCancel: * * 1. Flush any buffered streamed text into messages so the user sees @@ -741,6 +1160,19 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini // user-interrupted — without it the model would see an unfinished // assistant message and might silently try to resume. if (loopStateRef.current) { + if (loopStateRef.current.goal?.status === 'active') { + try { + pauseCoreGoal(loopStateRef.current) + void appendGoalState(loopStateRef.current) + void goalCoordinatorRef.current.interrupt(loopStateRef.current.goal.id) + setState((prev) => ({ + ...prev, + goalStatus: loopStateRef.current?.goal ? { ...loopStateRef.current.goal } : null, + })) + } catch { + // A non-active terminal goal does not need abort-time state changes. + } + } loopStateRef.current.messages.push({ role: 'user', content: noticeText }) // Persist the abort to the jsonl: drop an `interrupted` meta line // (informational — picker can show "interrupted" tags) and flush @@ -754,24 +1186,10 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini // Unblock any `await onAskPermission` in the core loop (parallel tool // calls queue extra UI rows, but execution is sequential — the first // shell often sits here while the user thinks the UI is "frozen"). - const permResolvers = permissionResolversRef.current - permissionResolversRef.current = [] - for (const r of permResolvers) r('no') - - // Unblock askUser / plan approval / slash pickers waiting on `pendingQuestion`. - const pendingAbortRef: { - current: { resolve: (answer: string) => void; abortAnswer: string } | null - } = { current: null } - setState((prev) => { - const pq = prev.pendingQuestion - pendingAbortRef.current = pq ? { resolve: pq.resolve, abortAnswer: pq.abortAnswer } : null - return { ...prev, permissionQueue: [], pendingQuestion: null, bufferingReads: false } - }) - const pa = pendingAbortRef.current - if (pa) pa.resolve(pa.abortAnswer) + drainPendingInteractions() controller.abort() - }, [flushBuffer, appendMessage]) + }, [drainPendingInteractions, flushBuffer, appendMessage]) /** Save session and cleanup */ const cleanup = useCallback(async () => { @@ -792,18 +1210,35 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini return { sessionId: ls.sessionId, taskSlug: ls.taskSlug, messageCount: ls.messages.length, firstPrompt } }, []) - /** Clear conversation */ - const clear = useCallback(() => { - loopStateRef.current = null - pendingToolsRef.current.clear() - permissionResolversRef.current = [] - resetBuffer() - // Preserve the current live model id and approval mode when clearing - // — user expects the model they just picked AND the plan-mode toggle - // they just flipped to stay after /clear (which only nukes the - // conversation, not session-wide settings). - setState((prev) => ({ ...initialState, modelId: prev.modelId, permissionMode: prev.permissionMode })) - }, [resetBuffer]) + /** Clear conversation while retaining a display-only command echo. */ + const clear = useCallback( + (commandText = '/clear') => { + loopStateRef.current = null + pendingToolsRef.current.clear() + permissionResolversRef.current = [] + pendingQuestionRef.current = null + resetBuffer() + // Preserve the current live model id and approval mode when clearing + // — user expects the model they just picked AND the plan-mode toggle + // they just flipped to stay after /clear (which only nukes the + // conversation, not session-wide settings). + setState((prev) => ({ + ...initialState, + modelId: prev.modelId, + permissionMode: prev.permissionMode, + messages: [ + { + id: `cmd-${Date.now()}`, + role: 'user', + content: commandText, + timestamp: Date.now(), + kind: 'command-echo', + }, + ], + })) + }, + [resetBuffer], + ) /** Mid-session resume: hot-swap the agent state to a previously-saved * session. Hydrates loopStateRef from the jsonl so the next agent @@ -839,6 +1274,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini todos: [], messages: [...prev.messages, ...converted], usage: { ...loaded.tokenUsage }, + goalStatus: loaded.goal ? { ...loaded.goal } : null, })) }, [resetBuffer], @@ -1005,6 +1441,14 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini return { state, submit, + runGoal, + pauseGoal, + resumeGoal, + cancelGoal, + clearGoal, + steerGoal, + editGoal, + verifyGoal, resolvePermission, resolveQuestion, abort, diff --git a/packages/cli/src/ui/hooks/use-prompt-input.ts b/packages/cli/src/ui/hooks/use-prompt-input.ts index 3c701be..df37894 100644 --- a/packages/cli/src/ui/hooks/use-prompt-input.ts +++ b/packages/cli/src/ui/hooks/use-prompt-input.ts @@ -142,6 +142,7 @@ export function usePromptInput({ onText, onPaste, onKey, onInterrupt, enabled }: setRawMode(true) process.stdout.write(ENABLE_BRACKETED_PASTE) const useBracketedPaste = true + const pasteState = pasteStateRef.current // ── Flush the debounce buffer ── // @@ -359,13 +360,12 @@ export function usePromptInput({ onText, onPaste, onKey, onInterrupt, enabled }: return () => { flushPending() // Clear paste safety timeout - const ps = pasteStateRef.current - if (ps.timer) { - clearTimeout(ps.timer) - ps.timer = null + if (pasteState.timer) { + clearTimeout(pasteState.timer) + pasteState.timer = null } - ps.inPaste = false - ps.buffer = '' + pasteState.inPaste = false + pasteState.buffer = '' stdin.off('data', handleData) if (useBracketedPaste) { process.stdout.write(DISABLE_BRACKETED_PASTE) diff --git a/packages/cli/src/ui/hooks/use-stream-buffer.ts b/packages/cli/src/ui/hooks/use-stream-buffer.ts index fb81f36..2d241b9 100644 --- a/packages/cli/src/ui/hooks/use-stream-buffer.ts +++ b/packages/cli/src/ui/hooks/use-stream-buffer.ts @@ -73,9 +73,13 @@ import { debugLog } from '@x-code-cli/core' * indented paragraph — is rare in AI output and the streaming-feel * win is large: list items appear one at a time as the model emits * them instead of popping in as a finished block. */ -function hasOpenMarkdownBlock(text: string): boolean { +export function hasOpenCodeFence(text: string): boolean { const fences = text.match(/^```/gm) - if (fences && fences.length % 2 !== 0) return true + return !!fences && fences.length % 2 !== 0 +} + +export function hasOpenMarkdownBlock(text: string): boolean { + if (hasOpenCodeFence(text)) return true const lines = text.split('\n') // Strip the ONE trailing '' that `split('\n')` produces for text @@ -96,7 +100,7 @@ function hasOpenMarkdownBlock(text: string): boolean { * * Scans backward from the end so the first hit IS the latest safe cut * (no need to walk every newline tracking a maximum). */ -function findSafeBoundary(text: string): number { +export function findSafeBoundary(text: string): number { let scan = text.length while (scan > 0) { const found = text.lastIndexOf('\n', scan - 1) @@ -228,7 +232,7 @@ export function useStreamBuffer(appendMessage: (msg: DisplayMessage) => void): S bufferRef.current = bufferRef.current.slice(boundary) debugLog('buffer.commit', `chars=${chunk.length}`) queueChunk(chunk) - } else if (bufferRef.current.length > CODE_FENCE_COMMIT_THRESHOLD && hasOpenMarkdownBlock(bufferRef.current)) { + } else if (bufferRef.current.length > CODE_FENCE_COMMIT_THRESHOLD && hasOpenCodeFence(bufferRef.current)) { // Large open code fence — force an intermediate commit at the last // newline so the terminal doesn't have to pre-scroll 100+ blank rows // in one shot. Find the last `\n` that is NOT part of a `\n\n` pair diff --git a/packages/cli/src/ui/utils.ts b/packages/cli/src/ui/utils.ts index 5d4ede0..26cd0d5 100644 --- a/packages/cli/src/ui/utils.ts +++ b/packages/cli/src/ui/utils.ts @@ -167,6 +167,8 @@ export interface ReadGroupSummary { detail?: string } +const TABLE_OUTPUT_MAX_LINES = 30 + export function formatReadGroupSummary(tools: readonly DisplayToolCall[]): ReadGroupSummary { let readCount = 0 let grepCount = 0 @@ -303,6 +305,7 @@ export function getToolResultSummary(toolName: string, output: string | undefine text = text.replace(/^exit code: 0\n?/, '') const lines = text.split('\n').filter((l) => l.trim()) if (lines.length === 0) return 'Done' + if (looksLikeTableOutput(lines)) return summarizeTableOutput(lines) if (lines.length <= 4) return lines.join('\n') return lines.slice(0, 3).join('\n') + `\n... +${lines.length - 3} lines` } @@ -315,3 +318,33 @@ export function getToolResultSummary(toolName: string, output: string | undefine if (lines.length <= 3) return lines.join('\n') return lines.slice(0, 2).join('\n') + `\n... +${lines.length - 2} lines` } + +function looksLikeTableOutput(lines: readonly string[]): boolean { + if (lines.length < 3) return false + if (lines.some((line) => /[┌┬┐├┼┤└┴┘]/.test(line))) return true + const markdownRows = lines.filter((line) => /^\s*\|.*\|\s*$/.test(line)) + if ( + markdownRows.length >= 2 && + lines.some((line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)) + ) { + return true + } + return lines.filter((line) => /^\s*\+[-=+]+\+\s*$/.test(line)).length >= 2 +} + +function summarizeTableOutput(lines: readonly string[]): string { + if (lines.length <= TABLE_OUTPUT_MAX_LINES) return lines.join('\n') + const bottom = lines.at(-1) + if (bottom && isTableBottomBorder(bottom)) { + const head = lines.slice(0, TABLE_OUTPUT_MAX_LINES - 2) + const omitted = lines.length - head.length - 1 + return [...head, `... +${omitted} lines`, bottom].join('\n') + } + const head = lines.slice(0, TABLE_OUTPUT_MAX_LINES - 1) + return [...head, `... +${lines.length - head.length} lines`].join('\n') +} + +function isTableBottomBorder(line: string): boolean { + const trimmed = line.trim() + return /[└┴┘]/.test(trimmed) || /^\+[-=+]+\+$/.test(trimmed) +} diff --git a/packages/cli/tests/chat-input-geometry.test.ts b/packages/cli/tests/chat-input-geometry.test.ts new file mode 100644 index 0000000..264bd9f --- /dev/null +++ b/packages/cli/tests/chat-input-geometry.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { computePostContentScrollRows } from '../src/ui/components/ChatInput.js' + +describe('ChatInput large commit geometry', () => { + it('reserves the frame rows after content taller than the viewport', () => { + expect(computePostContentScrollRows(1, 40, 35, 38)).toBe(4) + }) + + it('scrolls only rows overlapping the frame for a long tool summary', () => { + expect(computePostContentScrollRows(1, 36, 34, 38)).toBe(3) + }) + + it('does not scroll when committed content ends above the frame', () => { + expect(computePostContentScrollRows(12, 9, 21, 38)).toBe(0) + }) +}) diff --git a/packages/cli/tests/goal-command.test.ts b/packages/cli/tests/goal-command.test.ts new file mode 100644 index 0000000..b312bf7 --- /dev/null +++ b/packages/cli/tests/goal-command.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { parseGoalCreateArgs } from '../src/ui/commands/goal.js' + +describe('/goal command parsing', () => { + it('applies --verifier-prompt to the sub-agent verifier without leaking it into the objective', () => { + const parsed = parseGoalCreateArgs( + '创建 D:\\res\\x-code-cli\\tmp\\goal-manual-sandbox\\danger-check.txt,内容写入 ok --verifier-agent goal-verifier --verifier-prompt "run rm -rf check" --max-turns 4', + ) + + expect(parsed.objective).toBe('创建 D:\\res\\x-code-cli\\tmp\\goal-manual-sandbox\\danger-check.txt,内容写入 ok') + expect(parsed.maxTurns).toBe(4) + expect(parsed.verifiers).toEqual([ + { + kind: 'subagent', + agent: 'goal-verifier', + prompt: 'run rm -rf check', + timeoutMs: 120000, + }, + ]) + }) + + it('applies --verifier-prompt when it appears before --verifier-agent', () => { + const parsed = parseGoalCreateArgs('检查文件 --verifier-prompt "custom verifier" --verifier-agent goal-verifier') + + expect(parsed.objective).toBe('检查文件') + expect(parsed.verifiers).toEqual([ + { + kind: 'subagent', + agent: 'goal-verifier', + prompt: 'custom verifier', + timeoutMs: 120000, + }, + ]) + }) +}) diff --git a/packages/cli/tests/use-stream-buffer-boundary.test.ts b/packages/cli/tests/use-stream-buffer-boundary.test.ts new file mode 100644 index 0000000..ae61f04 --- /dev/null +++ b/packages/cli/tests/use-stream-buffer-boundary.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { findSafeBoundary, hasOpenCodeFence, hasOpenMarkdownBlock } from '../src/ui/hooks/use-stream-buffer.js' + +describe('stream buffer markdown boundaries', () => { + it('holds a long markdown table instead of treating it as a splittable code fence', () => { + const rows = Array.from({ length: 40 }, (_, index) => `| ${index + 1} | row ${index + 1} | value |`).join('\n') + const table = `| id | name | value |\n| --- | --- | --- |\n${rows}\n` + + expect(table.length).toBeGreaterThan(800) + expect(hasOpenMarkdownBlock(table)).toBe(true) + expect(hasOpenCodeFence(table)).toBe(false) + expect(findSafeBoundary(table)).toBe(-1) + }) + + it('still identifies a long open code fence for incremental commits', () => { + const code = `\`\`\`text\n${'line of code\n'.repeat(100)}` + + expect(code.length).toBeGreaterThan(800) + expect(hasOpenCodeFence(code)).toBe(true) + }) +}) diff --git a/packages/cli/tests/utils.test.ts b/packages/cli/tests/utils.test.ts new file mode 100644 index 0000000..95dd39e --- /dev/null +++ b/packages/cli/tests/utils.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { getToolResultSummary } from '../src/ui/utils.js' + +describe('CLI tool result summaries', () => { + it('preserves complete shell table output instead of truncating away the bottom border', () => { + const table = [ + '┌──────┬────────┐', + '│ Name │ Status │', + '├──────┼────────┤', + '│ api │ ok │', + '│ web │ ok │', + '└──────┴────────┘', + ].join('\n') + + expect(getToolResultSummary('shell', table, 'completed')).toBe(table) + }) + + it('keeps the bottom border when summarizing a long shell table', () => { + const rows = Array.from({ length: 40 }, (_, i) => `│ svc-${i.toString().padStart(2, '0')} │ ok │`) + const table = ['┌────────┬────┐', '│ Name │ St │', '├────────┼────┤', ...rows, '└────────┴────┘'].join('\n') + + const summary = getToolResultSummary('shell', table, 'completed') + + expect(summary).toContain('... +') + expect(summary?.endsWith('└────────┴────┘')).toBe(true) + }) +}) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 4ef1312..ef5ec89 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,6 +1,13 @@ import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + export default defineConfig({ + resolve: { + alias: { + '@x-code-cli/core': fileURLToPath(new URL('../core/src/index.ts', import.meta.url)), + }, + }, test: { globals: true, environment: 'node', diff --git a/packages/core/src/agent/goal/coordinator.ts b/packages/core/src/agent/goal/coordinator.ts new file mode 100644 index 0000000..1e13942 --- /dev/null +++ b/packages/core/src/agent/goal/coordinator.ts @@ -0,0 +1,59 @@ +export interface GoalRunCoordinator { + activeGoalId(): string | null + run(goalId: string, runner: (signal: AbortSignal) => Promise): Promise + wake(goalId: string, runner: (signal: AbortSignal) => Promise): void + interrupt(goalId?: string): Promise +} + +export function createGoalRunCoordinator(): GoalRunCoordinator { + let active: { goalId: string; controller: AbortController; promise: Promise } | null = null + let pendingWake: { goalId: string; runner: (signal: AbortSignal) => Promise } | null = null + + const drainWake = () => { + if (active || !pendingWake) return + const next = pendingWake + pendingWake = null + void coordinator.run(next.goalId, next.runner).catch(() => {}) + } + + const coordinator: GoalRunCoordinator = { + activeGoalId() { + return active?.goalId ?? null + }, + + async run(goalId: string, runner: (signal: AbortSignal) => Promise): Promise { + if (active) return active.promise as Promise + const controller = new AbortController() + const promise = runner(controller.signal) + active = { goalId, controller, promise } + try { + return await promise + } finally { + if (active?.promise === promise) active = null + drainWake() + } + }, + + wake(goalId: string, runner: (signal: AbortSignal) => Promise): void { + if (active?.goalId === goalId) { + pendingWake = { goalId, runner } + return + } + pendingWake = { goalId, runner } + drainWake() + }, + + async interrupt(goalId?: string): Promise { + if (!active) return + if (goalId && active.goalId !== goalId) return + active.controller.abort() + try { + await active.promise + } catch { + // The active runner owns user-facing error reporting. + } + }, + } + + return coordinator +} diff --git a/packages/core/src/agent/goal/final-summary.ts b/packages/core/src/agent/goal/final-summary.ts new file mode 100644 index 0000000..0317a16 --- /dev/null +++ b/packages/core/src/agent/goal/final-summary.ts @@ -0,0 +1,17 @@ +import { buildFinalSummaryPrompt } from './prompts.js' +import type { GoalState, GoalStatus } from './types.js' + +export interface RunFinalSummaryInput { + goal: GoalState + reason: Extract + runAgentTurn: (content: string, options: { silent: boolean; finalSummary: boolean }) => Promise<{ text?: string }> +} + +export async function runFinalSummaryTurn(input: RunFinalSummaryInput): Promise { + const result = await input.runAgentTurn(buildFinalSummaryPrompt(input.goal, input.reason), { + silent: true, + finalSummary: true, + }) + const summary = result.text?.trim() + return summary || `${input.reason}: final summary turn completed without a text response.` +} diff --git a/packages/core/src/agent/goal/index.ts b/packages/core/src/agent/goal/index.ts new file mode 100644 index 0000000..bb8ea68 --- /dev/null +++ b/packages/core/src/agent/goal/index.ts @@ -0,0 +1,8 @@ +export * from './types.js' +export * from './state.js' +export * from './input.js' +export * from './coordinator.js' +export * from './runner.js' +export * from './verifier.js' +export * from './prompts.js' +export * from './final-summary.js' diff --git a/packages/core/src/agent/goal/input.ts b/packages/core/src/agent/goal/input.ts new file mode 100644 index 0000000..31e80a3 --- /dev/null +++ b/packages/core/src/agent/goal/input.ts @@ -0,0 +1,34 @@ +import { randomUUID } from 'node:crypto' + +import type { LoopState } from '../loop-state.js' +import type { GoalInput, GoalInputKind } from './types.js' + +export function admitGoalInput( + state: LoopState, + input: { goalId: string; kind: GoalInputKind; content: string }, +): GoalInput { + const goalInput: GoalInput = { + id: randomUUID(), + goalId: input.goalId, + kind: input.kind, + content: input.content, + admittedAt: new Date().toISOString(), + } + state.goalInputs.push(goalInput) + return goalInput +} + +export function hasPendingGoalInput(state: LoopState, goalId: string): boolean { + return state.goalInputs.some((input) => input.goalId === goalId && !input.promotedAt) +} + +export function promoteNextGoalInput(state: LoopState, goalId: string): GoalInput | null { + const input = state.goalInputs.find((candidate) => candidate.goalId === goalId && !candidate.promotedAt) + if (!input) return null + input.promotedAt = new Date().toISOString() + return input +} + +export function pendingGoalInputs(state: LoopState, goalId: string): GoalInput[] { + return state.goalInputs.filter((input) => input.goalId === goalId && !input.promotedAt) +} diff --git a/packages/core/src/agent/goal/prompts.ts b/packages/core/src/agent/goal/prompts.ts new file mode 100644 index 0000000..3984c56 --- /dev/null +++ b/packages/core/src/agent/goal/prompts.ts @@ -0,0 +1,77 @@ +import type { GoalState, GoalVerificationResult } from './types.js' + +export function buildInitialGoalPrompt(goal: GoalState): string { + return [ + ``, + `Objective: ${goal.objective}`, + '', + 'You are now running under a durable goal loop. Continue working until the objective is fully satisfied.', + 'This is the only current goal. Treat any earlier goal messages in the transcript as historical context, not active work.', + 'Use getGoal to inspect goal state. When you believe the objective is complete, call updateGoal with status "complete" and concrete evidence.', + 'If you are blocked by the same external condition repeatedly, call updateGoal with status "blocked" and explain the blocker.', + 'Do not claim terminal completion in normal text; terminal completion only happens after updateGoal and host verification.', + '', + ].join('\n') +} + +export function buildContinuationPrompt(goal: GoalState): string { + return [ + ``, + `Objective: ${goal.objective}`, + `Outer turn: ${goal.turnCount + 1}/${goal.maxTurns}`, + 'Continue only this current goal. Do not resume or report earlier goals from the transcript as active.', + 'Continue from the current repository/session state. Inspect what remains, perform the next useful work, and request completion with updateGoal only when verifiable evidence is available.', + '', + ].join('\n') +} + +export function buildVerifierFailurePrompt( + goal: GoalState, + results: GoalVerificationResult[], + summary: string, +): string { + const details = results + .filter((result) => !result.ok) + .map((result, index) => `${index + 1}. ${result.summary}`) + .join('\n') + return [ + ``, + `Objective: ${goal.objective}`, + `Verifier summary: ${summary}`, + 'Fix only this current goal. Earlier goals in the transcript are historical and must not be advanced.', + details ? `Failures:\n${details}` : 'No detailed failure output was recorded.', + '', + 'Fix the issues above, then request completion again with updateGoal when evidence is available.', + '', + ].join('\n') +} + +export function buildBlockedRetryPrompt(goal: GoalState, blocker: string, repeatedCount: number): string { + return [ + ``, + `Objective: ${goal.objective}`, + `Reported blocker (${repeatedCount}/3): ${blocker}`, + 'Retry only this current goal. Earlier goals in the transcript are historical and must not be advanced.', + 'Try to make progress around the blocker if possible. Only request blocked again if the same external condition still prevents meaningful progress.', + '', + ].join('\n') +} + +export function buildFinalSummaryPrompt(goal: GoalState, reason: 'max_turns' | 'budget_limited'): string { + const latestVerification = goal.verificationResults.at(-1) + return [ + ``, + `Objective: ${goal.objective}`, + `Stopping reason: ${reason}`, + `Turns used: ${goal.turnCount}/${goal.maxTurns}`, + latestVerification + ? `Latest verifier: ${latestVerification.ok ? 'passed' : 'failed'} - ${latestVerification.summary}` + : '', + '', + 'Produce a concise final status report with: completed work, remaining work, latest verification evidence, and next recommended steps.', + 'Do not use tools. Do not claim verified completion unless the verifier passed.', + '', + ] + .filter(Boolean) + .join('\n') +} diff --git a/packages/core/src/agent/goal/runner.ts b/packages/core/src/agent/goal/runner.ts new file mode 100644 index 0000000..395ba80 --- /dev/null +++ b/packages/core/src/agent/goal/runner.ts @@ -0,0 +1,237 @@ +import type { LanguageModel } from 'ai' + +import type { AgentCallbacks, AgentOptions } from '../../types/index.js' +import { debugLog } from '../../utils.js' +import type { LoopState } from '../loop-state.js' +import type { AgentLoopResult } from '../loop.js' +import { appendGoalInput, appendGoalState, appendGoalVerification } from '../session-store.js' +import { runFinalSummaryTurn } from './final-summary.js' +import { admitGoalInput, hasPendingGoalInput, promoteNextGoalInput } from './input.js' +import { + buildBlockedRetryPrompt, + buildContinuationPrompt, + buildInitialGoalPrompt, + buildVerifierFailurePrompt, +} from './prompts.js' +import { + clearPendingTransition, + recordGoalAttempt, + snapshotUsage, + tokenBudgetReached, + updateGoalStatus, +} from './state.js' +import type { GoalAttempt, GoalInputKind, GoalRunSummary, GoalState } from './types.js' +import { runVerifierLadder } from './verifier.js' + +export interface GoalAgentTurnResult extends AgentLoopResult { + text?: string +} + +export interface RunGoalLoopInput { + state: LoopState + model: LanguageModel + options: AgentOptions + callbacks: AgentCallbacks + goalId: string + signal?: AbortSignal + runAgentTurn: ( + content: string, + options?: { silent?: boolean; finalSummary?: boolean }, + ) => Promise +} + +export async function runGoalLoop(input: RunGoalLoopInput): Promise { + const { state, goalId, signal } = input + const goal = state.goal + if (!goal || goal.id !== goalId) throw new Error(`Goal ${goalId} is not the current goal`) + + debugLog('goal.runner.start', `${goal.id} ${goal.objective}`) + while (goal.status === 'active') { + if (signal?.aborted || input.options.abortSignal?.aborted) break + + if (tokenBudgetReached(goal, state)) { + await finalizeBySummary(input, goal, 'budget_limited') + break + } + if (goal.turnCount >= goal.maxTurns) { + await finalizeBySummary(input, goal, 'max_turns') + break + } + + await ensurePendingGoalInput(state, goal) + const goalInput = promoteNextGoalInput(state, goal.id) + if (!goalInput) break + await appendGoalInput(state, goalInput) + + const before = snapshotUsage(state.tokenUsage) + const startedAt = new Date().toISOString() + let finish: GoalAttempt['finish'] = 'stop' + let result: GoalAgentTurnResult | null = null + try { + result = await input.runAgentTurn(goalInput.content, { silent: true }) + } catch (err) { + finish = signal?.aborted || input.options.abortSignal?.aborted ? 'aborted' : 'error' + debugLog('goal.runner.turn-error', err instanceof Error ? err.message : String(err)) + } + + const attempt: GoalAttempt = { + id: goalInput.id, + turn: goal.turnCount + 1, + inputKind: goalInput.kind, + promptPreview: preview(goalInput.content), + startedAt, + endedAt: new Date().toISOString(), + turnCount: result?.turnCount ?? 0, + tokenUsageBefore: before, + tokenUsageAfter: snapshotUsage(state.tokenUsage), + finish, + } + recordGoalAttempt(goal, attempt) + + if (signal?.aborted || input.options.abortSignal?.aborted) { + attempt.finish = 'aborted' + break + } + if (finish === 'aborted' || finish === 'error') break + if (goal.status !== 'active') break + if (tokenBudgetReached(goal, state)) { + attempt.finish = 'budget_limited' + await finalizeBySummary(input, goal, 'budget_limited') + break + } + + const transition = goal.pendingTransition + if (transition?.kind === 'complete_requested') { + const verification = await runVerifierLadder({ + goal, + state, + options: input.options, + callbacks: input.callbacks, + model: input.model, + }) + for (const result of verification.results) { + void appendGoalVerification(state, goal.id, result) + } + if (verification.ok) { + attempt.finish = 'complete' + updateGoalStatus(goal, 'complete', transition.summary ?? verification.summary) + clearPendingTransition(goal) + void appendGoalState(state) + break + } + if (!verification.retryable || isMissingCompletionVerifier(verification.results)) { + attempt.finish = 'blocked' + updateGoalStatus(goal, 'blocked', verification.summary) + clearPendingTransition(goal) + void appendGoalState(state) + break + } + attempt.finish = 'verification_failed' + const nextInput = admitGoalInput(state, { + goalId: goal.id, + kind: 'verifier_failure', + content: buildVerifierFailurePrompt(goal, verification.results, verification.summary), + }) + void appendGoalInput(state, nextInput) + clearPendingTransition(goal) + void appendGoalState(state) + continue + } + + if (transition?.kind === 'blocked_requested') { + const blocker = transition.blocker ?? transition.evidence + const repeated = goal.lastBlocker && isSameBlocker(goal.lastBlocker, blocker) ? goal.repeatedBlockerCount + 1 : 1 + goal.lastBlocker = blocker + goal.repeatedBlockerCount = repeated + clearPendingTransition(goal) + if (repeated >= 3) { + attempt.finish = 'blocked' + updateGoalStatus(goal, 'blocked', transition.summary ?? blocker) + void appendGoalState(state) + break + } + const nextInput = admitGoalInput(state, { + goalId: goal.id, + kind: 'continuation', + content: buildBlockedRetryPrompt(goal, blocker, repeated), + }) + void appendGoalInput(state, nextInput) + void appendGoalState(state) + continue + } + + const nextInput = admitGoalInput(state, { + goalId: goal.id, + kind: 'continuation', + content: buildContinuationPrompt(goal), + }) + void appendGoalInput(state, nextInput) + void appendGoalState(state) + } + + debugLog('goal.runner.stop', `${goal.id} ${goal.status}`) + return { goalId: goal.id, status: goal.status, turns: goal.turnCount, summary: goal.finalSummary } +} + +async function ensurePendingGoalInput(state: LoopState, goal: GoalState): Promise { + if (hasPendingGoalInput(state, goal.id)) return + const kind: GoalInputKind = goal.turnCount === 0 ? 'initial' : 'continuation' + const content = kind === 'initial' ? buildInitialGoalPrompt(goal) : buildContinuationPrompt(goal) + const input = admitGoalInput(state, { goalId: goal.id, kind, content }) + await appendGoalInput(state, input) +} + +async function finalizeBySummary( + input: RunGoalLoopInput, + goal: GoalState, + reason: 'max_turns' | 'budget_limited', +): Promise { + const summary = await runFinalSummaryTurn({ + goal, + reason, + runAgentTurn: async (content, options) => input.runAgentTurn(content, options), + }) + clearPendingTransition(goal) + updateGoalStatus(goal, reason, summary) + void appendGoalState(input.state) +} + +function preview(text: string): string { + return text.replace(/\s+/g, ' ').trim().slice(0, 240) +} + +function isMissingCompletionVerifier(results: Awaited>['results']): boolean { + return results.some((result) => result.verifier.kind === 'file' && result.verifier.path === '') +} + +export function isSameBlocker(previous: string, current: string): boolean { + const a = normalizeBlocker(previous) + const b = normalizeBlocker(current) + if (!a || !b) return false + if (a === b || a.includes(b) || b.includes(a)) return true + + const aPairs = bigrams(a) + const bPairs = bigrams(b) + let overlap = 0 + for (const pair of aPairs) { + if (bPairs.has(pair)) overlap++ + } + return (2 * overlap) / (aPairs.size + bPairs.size) >= 0.4 +} + +function normalizeBlocker(value: string): string { + return value + .toLowerCase() + .split(/已(?:检查|尝试|穷尽|确认|搜索|查看)/u, 1)[0]! + .replace(/[\s\p{P}\p{S}]+/gu, '') + .replace(/(?:未包含|没有|无)(?:任何)?(?:具体)?(?:可执行)?(?:的)?(?:任务内容|任务要求|任务|内容)/gu, '缺少任务') + .replace(/无法确定需要完成什么工作/gu, '缺少任务') + .replace(/目标描述仅为/gu, '目标') +} + +function bigrams(value: string): Set { + if (value.length < 2) return new Set([value]) + const result = new Set() + for (let i = 0; i < value.length - 1; i++) result.add(value.slice(i, i + 2)) + return result +} diff --git a/packages/core/src/agent/goal/state.ts b/packages/core/src/agent/goal/state.ts new file mode 100644 index 0000000..907cb2c --- /dev/null +++ b/packages/core/src/agent/goal/state.ts @@ -0,0 +1,182 @@ +import { randomUUID } from 'node:crypto' + +import type { TokenUsage } from '../../types/index.js' +import type { LoopState } from '../loop-state.js' +import type { + GoalAttempt, + GoalPendingTransition, + GoalState, + GoalStatus, + GoalVerificationResult, + GoalVerifier, +} from './types.js' + +export interface CreateGoalInput { + objective: string + maxTurns?: number + tokenBudget?: number + verifiers?: GoalVerifier[] + requiresUserConfirmation?: boolean + createdBy?: GoalState['createdBy'] +} + +const TERMINAL_STATUSES = new Set([ + 'blocked', + 'complete', + 'cancelled', + 'budget_limited', + 'usage_limited', + 'max_turns', + 'failed', +]) + +export function isGoalTerminal(status: GoalStatus): boolean { + return TERMINAL_STATUSES.has(status) +} + +export function assertActiveGoal(state: LoopState, goalId?: string): GoalState { + const goal = state.goal + if (!goal) throw new Error('No goal exists in this session') + if (goalId && goal.id !== goalId) throw new Error(`Goal ${goalId} is not the current goal`) + if (goal.status !== 'active') throw new Error(`Goal is ${goal.status}, not active`) + return goal +} + +export function createGoal(state: LoopState, input: CreateGoalInput): GoalState { + const objective = input.objective.trim() + if (!objective) throw new Error('Goal objective is required') + if (state.goal && !isGoalTerminal(state.goal.status)) { + throw new Error(`Cannot create a new goal while goal ${state.goal.id} is ${state.goal.status}`) + } + + const now = new Date().toISOString() + const goal: GoalState = { + id: randomUUID(), + objective, + status: 'active', + createdAt: now, + updatedAt: now, + createdBy: input.createdBy ?? 'slash', + maxTurns: Math.max(1, Math.floor(input.maxTurns ?? 20)), + turnCount: 0, + tokenBudget: input.tokenBudget && input.tokenBudget > 0 ? Math.floor(input.tokenBudget) : undefined, + baselineTokens: state.tokenUsage.totalTokens, + verifiers: input.verifiers ?? [], + verificationResults: [], + repeatedBlockerCount: 0, + attempts: [], + requiresUserConfirmation: input.requiresUserConfirmation, + } + state.goal = goal + state.goalInputs = [] + state.systemPromptCache = null + state.expectCacheMiss = true + return goal +} + +export function updateGoalStatus(goal: GoalState, status: GoalStatus, summary?: string): GoalState { + goal.status = status + goal.updatedAt = new Date().toISOString() + if (summary !== undefined) goal.finalSummary = summary + return goal +} + +export function pauseGoal(state: LoopState): GoalState { + const goal = assertActiveGoal(state) + goal.status = 'paused' + goal.updatedAt = new Date().toISOString() + return goal +} + +export function resumeGoal(state: LoopState): GoalState { + if (!state.goal) throw new Error('No goal exists in this session') + if (state.goal.status !== 'paused' && state.goal.status !== 'blocked' && state.goal.status !== 'max_turns') { + throw new Error(`Cannot resume a goal with status ${state.goal.status}`) + } + state.goal.status = 'active' + state.goal.updatedAt = new Date().toISOString() + state.systemPromptCache = null + state.expectCacheMiss = true + return state.goal +} + +export function cancelGoal(state: LoopState): GoalState { + if (!state.goal) throw new Error('No goal exists in this session') + state.goal.status = 'cancelled' + state.goal.updatedAt = new Date().toISOString() + return state.goal +} + +export function clearGoal(state: LoopState): void { + state.goal = null + state.goalInputs = [] + state.systemPromptCache = null + state.expectCacheMiss = true +} + +export function requestGoalComplete( + state: LoopState, + input: { evidence: string; summary?: string; requestedByToolCallId?: string }, +): GoalPendingTransition { + const goal = assertActiveGoal(state) + if (!input.evidence.trim()) throw new Error('Completion evidence is required') + const transition: GoalPendingTransition = { + kind: 'complete_requested', + evidence: input.evidence.trim(), + summary: input.summary?.trim() || undefined, + requestedAt: new Date().toISOString(), + requestedByToolCallId: input.requestedByToolCallId, + } + goal.pendingTransition = transition + goal.updatedAt = transition.requestedAt + return transition +} + +export function requestGoalBlocked( + state: LoopState, + input: { blocker: string; evidence?: string; summary?: string; requestedByToolCallId?: string }, +): GoalPendingTransition { + const goal = assertActiveGoal(state) + if (!input.blocker.trim()) throw new Error('Blocker is required') + const transition: GoalPendingTransition = { + kind: 'blocked_requested', + evidence: input.evidence?.trim() || input.blocker.trim(), + summary: input.summary?.trim() || undefined, + blocker: input.blocker.trim(), + requestedAt: new Date().toISOString(), + requestedByToolCallId: input.requestedByToolCallId, + } + goal.pendingTransition = transition + goal.updatedAt = transition.requestedAt + return transition +} + +export function clearPendingTransition(goal: GoalState): void { + delete goal.pendingTransition + goal.updatedAt = new Date().toISOString() +} + +export function recordGoalAttempt(goal: GoalState, attempt: GoalAttempt): void { + goal.attempts.push(attempt) + goal.turnCount += 1 + goal.updatedAt = attempt.endedAt ?? new Date().toISOString() +} + +export function recordVerificationResult(goal: GoalState, result: GoalVerificationResult): void { + goal.verificationResults.push(result) + goal.updatedAt = result.ts +} + +export function snapshotUsage(usage: TokenUsage): TokenUsage { + return { ...usage } +} + +export function remainingTokenBudget(goal: GoalState, state: LoopState): number | undefined { + if (!goal.tokenBudget) return undefined + return Math.max(0, goal.tokenBudget - (state.tokenUsage.totalTokens - goal.baselineTokens)) +} + +export function tokenBudgetReached(goal: GoalState, state: LoopState): boolean { + const remaining = remainingTokenBudget(goal, state) + return remaining !== undefined && remaining <= 0 +} diff --git a/packages/core/src/agent/goal/types.ts b/packages/core/src/agent/goal/types.ts new file mode 100644 index 0000000..be89c7e --- /dev/null +++ b/packages/core/src/agent/goal/types.ts @@ -0,0 +1,110 @@ +import type { TokenUsage } from '../../types/index.js' + +export type GoalStatus = + | 'active' + | 'paused' + | 'blocked' + | 'complete' + | 'cancelled' + | 'budget_limited' + | 'usage_limited' + | 'max_turns' + | 'failed' + +export type GoalInputKind = 'initial' | 'continuation' | 'user_steering' | 'verifier_failure' | 'final_summary' + +export type GoalTransitionKind = 'complete_requested' | 'blocked_requested' + +export interface GoalVerifierShell { + kind: 'shell' + command: string + timeoutMs?: number +} + +export interface GoalVerifierSubAgent { + kind: 'subagent' + agent: string + prompt: string + timeoutMs?: number +} + +export interface GoalVerifierFile { + kind: 'file' + path: string + exists?: boolean + contains?: string +} + +export type GoalVerifier = GoalVerifierShell | GoalVerifierSubAgent | GoalVerifierFile + +export interface GoalVerificationResult { + verifier: GoalVerifier + ok: boolean + retryable?: boolean + summary: string + exitCode?: number | null + stdout?: string + stderr?: string + durationMs: number + ts: string + verificationRunId?: string +} + +export interface GoalAttempt { + id: string + turn: number + inputKind: GoalInputKind + promptPreview: string + startedAt: string + endedAt?: string + turnCount: number + tokenUsageBefore: TokenUsage + tokenUsageAfter?: TokenUsage + finish: 'stop' | 'aborted' | 'error' | 'max_turns' | 'budget_limited' | 'verification_failed' | 'complete' | 'blocked' +} + +export interface GoalPendingTransition { + kind: GoalTransitionKind + evidence: string + summary?: string + blocker?: string + requestedAt: string + requestedByToolCallId?: string +} + +export interface GoalState { + id: string + objective: string + status: GoalStatus + createdAt: string + updatedAt: string + createdBy: 'slash' | 'tool' | 'resume' + maxTurns: number + turnCount: number + tokenBudget?: number + baselineTokens: number + verifiers: GoalVerifier[] + verificationResults: GoalVerificationResult[] + pendingTransition?: GoalPendingTransition + lastBlocker?: string + repeatedBlockerCount: number + attempts: GoalAttempt[] + finalSummary?: string + requiresUserConfirmation?: boolean +} + +export interface GoalInput { + id: string + goalId: string + kind: GoalInputKind + content: string + admittedAt: string + promotedAt?: string +} + +export interface GoalRunSummary { + goalId: string + status: GoalStatus + turns: number + summary?: string +} diff --git a/packages/core/src/agent/goal/verifier.ts b/packages/core/src/agent/goal/verifier.ts new file mode 100644 index 0000000..5c324ef --- /dev/null +++ b/packages/core/src/agent/goal/verifier.ts @@ -0,0 +1,367 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import type { LanguageModel } from 'ai' + +import { checkPermission } from '../../permissions/index.js' +import { truncateToolResult } from '../../tools/index.js' +import { getShellProvider } from '../../tools/shell-provider.js' +import type { AgentCallbacks, AgentOptions } from '../../types/index.js' +import { debugLog } from '../../utils.js' +import type { LoopState } from '../loop-state.js' +import { runSubAgent } from '../sub-agents/runner.js' +import { recordVerificationResult } from './state.js' +import type { GoalState, GoalVerificationResult, GoalVerifier } from './types.js' + +export interface GoalVerifierLadderResult { + ok: boolean + retryable: boolean + results: GoalVerificationResult[] + summary: string +} + +export async function runVerifierLadder(input: { + goal: GoalState + state: LoopState + options: AgentOptions + callbacks: AgentCallbacks + model: LanguageModel + verificationRunId?: string +}): Promise { + const { goal, state, options, callbacks, model } = input + const verificationRunId = + input.verificationRunId ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + const results: GoalVerificationResult[] = [] + + if (goal.verifiers.length === 0) { + if (!goal.requiresUserConfirmation) { + const result = makeResult({ + verifier: { kind: 'file', path: '', exists: false }, + ok: false, + summary: 'No verifier configured; goal completion requires a verifier or explicit user confirmation.', + start: Date.now(), + verificationRunId, + }) + recordVerificationResult(goal, result) + results.push(result) + } + } else { + for (const verifier of goal.verifiers) { + const result = await runSingleVerifier({ verifier, goal, state, options, callbacks, model, verificationRunId }) + recordVerificationResult(goal, result) + results.push(result) + if (!result.ok) break + } + } + + if (goal.requiresUserConfirmation && results.every((result) => result.ok)) { + const answer = await callbacks.onAskUser('Confirm goal completion?', [ + { label: 'Yes', description: 'Mark this goal complete.' }, + { label: 'No', description: 'Continue working on the goal.' }, + ]) + const ok = /^y(es)?$/i.test(answer.trim()) + const result = makeResult({ + verifier: { kind: 'file', path: '', exists: false }, + ok, + summary: ok ? 'User confirmed completion.' : 'User declined completion.', + start: Date.now(), + verificationRunId, + }) + recordVerificationResult(goal, result) + results.push(result) + } + + const ok = results.every((result) => result.ok) + const retryable = results.find((result) => !result.ok)?.retryable !== false + const summary = ok + ? `All ${results.length} verifier step(s) passed.` + : (results.find((result) => !result.ok)?.summary ?? 'Verification failed.') + return { ok, retryable, results, summary } +} + +async function runSingleVerifier(input: { + verifier: GoalVerifier + goal: GoalState + state: LoopState + options: AgentOptions + callbacks: AgentCallbacks + model: LanguageModel + verificationRunId: string +}): Promise { + const { verifier } = input + if (verifier.kind === 'file') return runFileVerifier({ verifier, verificationRunId: input.verificationRunId }) + if (verifier.kind === 'shell') { + return runShellVerifier({ + verifier, + state: input.state, + options: input.options, + callbacks: input.callbacks, + verificationRunId: input.verificationRunId, + }) + } + return runSubAgentVerifier({ ...input, verifier }) +} + +async function runFileVerifier(input: { + verifier: Extract + verificationRunId: string +}): Promise { + const start = Date.now() + const abs = path.resolve(input.verifier.path) + try { + const stat = await fs.stat(abs) + if (input.verifier.exists === false && stat) { + return makeResult({ + verifier: input.verifier, + ok: false, + summary: `File exists but should not: ${input.verifier.path}`, + start, + verificationRunId: input.verificationRunId, + }) + } + if (input.verifier.contains !== undefined) { + const content = await fs.readFile(abs, 'utf-8') + const ok = content.includes(input.verifier.contains) + return makeResult({ + verifier: input.verifier, + ok, + summary: ok + ? `File contains expected text: ${input.verifier.path}` + : `File does not contain expected text: ${input.verifier.path}`, + start, + verificationRunId: input.verificationRunId, + }) + } + return makeResult({ + verifier: input.verifier, + ok: input.verifier.exists === false ? false : stat.isFile() || stat.isDirectory(), + summary: `File exists: ${input.verifier.path}`, + start, + verificationRunId: input.verificationRunId, + }) + } catch (err) { + const ok = input.verifier.exists === false + return makeResult({ + verifier: input.verifier, + ok, + summary: ok ? `File is absent as expected: ${input.verifier.path}` : `File verifier failed: ${messageOf(err)}`, + start, + verificationRunId: input.verificationRunId, + }) + } +} + +async function runShellVerifier(input: { + verifier: Extract + state: LoopState + options: AgentOptions + callbacks: AgentCallbacks + verificationRunId: string +}): Promise { + const { verifier, state, options, callbacks } = input + const start = Date.now() + const toolCallId = `goal-verify-${Date.now().toString(36)}` + callbacks.onToolCall(toolCallId, 'shell', { command: verifier.command, timeout: verifier.timeoutMs ?? 120000 }) + + const approved = await checkPermission( + { + toolCallId, + toolName: 'shell', + input: { command: verifier.command, timeout: verifier.timeoutMs ?? 120000 }, + }, + options.trustMode, + callbacks.onAskPermission, + state.permissionMode, + process.cwd(), + ) + + if (options.abortSignal?.aborted) { + callbacks.onToolResult(toolCallId, 'Verifier interrupted by user', true) + return makeResult({ + verifier, + ok: false, + summary: `Shell verifier interrupted: ${verifier.command}`, + start, + verificationRunId: input.verificationRunId, + exitCode: null, + }) + } + + if (!approved) { + callbacks.onToolResult(toolCallId, 'Permission denied', true) + return makeResult({ + verifier, + ok: false, + summary: `Shell verifier denied: ${verifier.command}`, + start, + verificationRunId: input.verificationRunId, + exitCode: null, + }) + } + + try { + callbacks.onToolProgress(toolCallId, `Verifying: ${verifier.command}`) + const result = await getShellProvider().spawn(verifier.command, { + timeout: verifier.timeoutMs ?? 120000, + cwd: process.cwd(), + signal: options.abortSignal, + }) + const stdout = String(result.stdout ?? '') + const stderr = String(result.stderr ?? '') + const ok = result.exitCode === 0 + const preview = truncateToolResult([stdout, stderr].filter(Boolean).join('\n') || `exit ${result.exitCode}`) + callbacks.onToolResult(toolCallId, preview, !ok) + return makeResult({ + verifier, + ok, + summary: ok + ? `Shell verifier passed: ${verifier.command}` + : `Shell verifier failed (${result.exitCode}): ${verifier.command}`, + start, + verificationRunId: input.verificationRunId, + exitCode: result.exitCode, + stdout: stdout.slice(0, 8000), + stderr: stderr.slice(0, 8000), + }) + } catch (err) { + const message = messageOf(err) + callbacks.onToolResult(toolCallId, message, true) + return makeResult({ + verifier, + ok: false, + summary: `Shell verifier crashed: ${message}`, + start, + verificationRunId: input.verificationRunId, + }) + } +} + +async function runSubAgentVerifier(input: { + verifier: Extract + goal: GoalState + state: LoopState + options: AgentOptions + callbacks: AgentCallbacks + model: LanguageModel + verificationRunId: string +}): Promise { + const { verifier, goal, state, options, callbacks, model } = input + const start = Date.now() + if (requestsDestructiveVerification(verifier.prompt)) { + return makeResult({ + verifier, + ok: false, + retryable: false, + summary: 'Sub-agent verifier rejected: verification instructions request a destructive operation.', + start, + verificationRunId: input.verificationRunId, + }) + } + const prompt = [ + 'You are an independent verifier. Return strict JSON: {"ok": boolean, "findings": string[], "requiredFixes": string[]}.', + 'Do not modify files.', + `Goal objective: ${goal.objective}`, + verifier.prompt, + ].join('\n\n') + + try { + const result = await runSubAgent( + { + parentState: state, + parentOptions: options, + callbacks, + toolCallId: `goal-subagent-${Date.now().toString(36)}`, + agentName: verifier.agent, + description: 'Goal verifier', + prompt, + knowledgeContext: state.knowledgeContext ?? '', + isGitRepo: state.isGitRepo ?? false, + }, + model, + ) + if (hasDeniedSubAgentRestriction(result.resultText)) { + return makeResult({ + verifier, + ok: false, + summary: 'Sub-agent verifier failed: shell command denied by sub-agent restriction.', + start, + verificationRunId: input.verificationRunId, + stdout: result.resultText.slice(0, 8000), + }) + } + const parsed = parseVerifierJson(result.resultText) + return makeResult({ + verifier, + ok: parsed.ok, + summary: parsed.ok + ? `Sub-agent verifier passed: ${verifier.agent}` + : `Sub-agent verifier failed: ${parsed.requiredFixes.join('; ') || parsed.findings.join('; ') || result.resultText}`, + start, + verificationRunId: input.verificationRunId, + stdout: result.resultText.slice(0, 8000), + }) + } catch (err) { + debugLog('goal.subagent-verifier-error', messageOf(err)) + return makeResult({ + verifier, + ok: false, + summary: `Sub-agent verifier failed: ${messageOf(err)}`, + start, + verificationRunId: input.verificationRunId, + }) + } +} + +function hasDeniedSubAgentRestriction(text: string): boolean { + return /denied by sub-agent restrictions?/i.test(text) +} + +function requestsDestructiveVerification(prompt: string): boolean { + return /(?:\brm\s+(?:-[a-z]*[rf][a-z]*\s+)+|\bdel\s+\/|\brmdir\s+\/s\b|\bremove-item\b[^\n]*(?:-recurse|-force)|\bgit\s+(?:reset\s+--hard|clean\s+-[a-z]*f)|\bformat(?:\.com)?\b|\bdrop\s+(?:table|database)\b)/i.test( + prompt, + ) +} + +function parseVerifierJson(text: string): { ok: boolean; findings: string[]; requiredFixes: string[] } { + const match = text.match(/\{[\s\S]*\}/) + if (!match) return { ok: false, findings: [text], requiredFixes: ['Verifier did not return JSON.'] } + try { + const parsed = JSON.parse(match[0]) as { ok?: unknown; findings?: unknown; requiredFixes?: unknown } + return { + ok: parsed.ok === true, + findings: Array.isArray(parsed.findings) ? parsed.findings.map(String) : [], + requiredFixes: Array.isArray(parsed.requiredFixes) ? parsed.requiredFixes.map(String) : [], + } + } catch { + return { ok: false, findings: [text], requiredFixes: ['Verifier returned invalid JSON.'] } + } +} + +function makeResult(input: { + verifier: GoalVerifier + ok: boolean + retryable?: boolean + summary: string + start: number + verificationRunId: string + exitCode?: number | null + stdout?: string + stderr?: string +}): GoalVerificationResult { + return { + verifier: input.verifier, + ok: input.ok, + retryable: input.retryable, + summary: input.summary, + exitCode: input.exitCode, + stdout: input.stdout, + stderr: input.stderr, + durationMs: Date.now() - input.start, + ts: new Date().toISOString(), + verificationRunId: input.verificationRunId, + } +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} diff --git a/packages/core/src/agent/loop-state.ts b/packages/core/src/agent/loop-state.ts index b3d271d..746b549 100644 --- a/packages/core/src/agent/loop-state.ts +++ b/packages/core/src/agent/loop-state.ts @@ -4,6 +4,7 @@ import type { ModelMessage } from 'ai' import { BackgroundShellRegistry } from '../tools/background-shell.js' import type { ReadFileCache } from '../tools/read-file.js' import type { PermissionMode, TodoItem, TokenUsage } from '../types/index.js' +import type { GoalInput, GoalState } from './goal/types.js' import type { CheckpointEntry } from './snapshot.js' import type { DeferredToolEntry } from './tool-search/catalog.js' @@ -101,6 +102,15 @@ export interface LoopState { * cleanup kills any survivors when the CLI process exits. */ bgShells: BackgroundShellRegistry + /** Session-scoped durable goal. Mutated by /goal and by getGoal/updateGoal + * tools. Dynamic goal details are intentionally kept out of the cached + * system prompt; models inspect them through tools or continuation inputs. */ + goal: GoalState | null + /** Durable queued inputs for the goal runner. These are ordinary user + * messages once promoted, but tracked separately so pause/resume/crash + * recovery can continue at a safe boundary. */ + goalInputs: GoalInput[] + // ── Deferred tools / toolSearch (top-level agent only) ── /** Catalog of deferred tools the model can discover via `toolSearch` but @@ -163,6 +173,8 @@ export function createLoopState(initialMode: PermissionMode = 'default'): LoopSt expectCacheMiss: false, readFileCache: new Map(), bgShells: new BackgroundShellRegistry(), + goal: null, + goalInputs: [], activatedTools: new Set(), } } diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index dc4ef58..1608947 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -16,11 +16,13 @@ import { bridgeMcpTool, toSystemPromptEntries } from '../mcp/tool-bridge.js' import { applyCacheControl } from '../providers/cache-control.js' import { getThinkingProviderOptions, mergeThinkingOptions } from '../providers/thinking.js' import { createActivateSkillTool } from '../tools/activate-skill.js' +import { createGetGoalTool } from '../tools/get-goal.js' import { toolRegistry, truncateToolResult } from '../tools/index.js' import { clearProgressReporter, setProgressReporter } from '../tools/progress.js' import { createReadFileTool } from '../tools/read-file.js' import { createTaskTool } from '../tools/task.js' import { toolSearch } from '../tools/tool-search.js' +import { createUpdateGoalTool } from '../tools/update-goal.js' import type { AgentCallbacks, AgentOptions } from '../types/index.js' import { debugLog } from '../utils.js' import { classifyApiError, isContextTooLongError } from './api-errors.js' @@ -301,10 +303,18 @@ function buildTools(options: AgentOptions, state: LoopState) { tools.activateSkill = createActivateSkillTool(options.skillRegistry) } + if (!options.toolFilter && state.goal?.status === 'active') { + tools.getGoal = createGetGoalTool(state) + tools.updateGoal = createUpdateGoalTool(state) + } + // Deferred loading is a top-level-agent feature only. The presence of a // toolFilter is the authoritative "this is a sub-agent" signal (runner.ts // always passes one; the main loop never does). const deferralActive = !options.toolFilter + if (!deferralActive) { + state.deferredCatalog = undefined + } if (deferralActive) { const catalog = buildDeferredCatalog(options, getContextWindow(options.modelId)) state.deferredCatalog = catalog @@ -774,6 +784,15 @@ export async function agentLoop( continue } + if (state.goal?.pendingTransition) { + // updateGoal is auto-executed, so its result is already recorded when + // runTurn returns. The host goal runner owns the next decision; another + // inner round would only repeat work before blocker counts can advance. + debugLog('turn.goal-transition-stop', state.goal.pendingTransition.kind) + completedNormally = true + break + } + if (outcome.finishReason === 'tool-calls') { // Any successful tool round means the model is making real progress — // reset the consecutive-truncation counter. diff --git a/packages/core/src/agent/session-store.ts b/packages/core/src/agent/session-store.ts index bc0bfc6..ae4bbd0 100644 --- a/packages/core/src/agent/session-store.ts +++ b/packages/core/src/agent/session-store.ts @@ -26,6 +26,7 @@ import type { ModelMessage } from 'ai' import type { PermissionMode, TokenUsage } from '../types/index.js' import { XCODE_DIR } from '../utils.js' +import type { GoalInput, GoalState, GoalVerificationResult } from './goal/types.js' import { createLoopState } from './loop-state.js' import type { LoopState } from './loop-state.js' import type { CheckpointEntry } from './snapshot.js' @@ -108,7 +109,39 @@ interface CheckpointJsonlEntry { userPrompt: string } -type Entry = HeaderEntry | MsgEntry | UsageEntry | CompactBoundaryEntry | InterruptedEntry | CheckpointJsonlEntry +interface GoalEntry { + t: 'meta' + kind: 'goal' + goal: GoalState | null + ts: string +} + +interface GoalInputEntry { + t: 'meta' + kind: 'goal-input' + goalId: string + input: GoalInput + ts: string +} + +interface GoalVerificationEntry { + t: 'meta' + kind: 'goal-verification' + goalId: string + result: GoalVerificationResult + ts: string +} + +type Entry = + | HeaderEntry + | MsgEntry + | UsageEntry + | CompactBoundaryEntry + | InterruptedEntry + | CheckpointJsonlEntry + | GoalEntry + | GoalInputEntry + | GoalVerificationEntry // ── Append helpers (fire-and-forget; never throw) ─────────────────────── @@ -289,6 +322,48 @@ export async function appendInterrupted(state: LoopState): Promise { await appendLine(filePath, entry) } +export async function appendGoalState(state: LoopState): Promise { + if (!state.sessionId) return + const filePath = getSessionFilePath(state) + const entry: GoalEntry = { + t: 'meta', + kind: 'goal', + goal: state.goal ? structuredClone(state.goal) : null, + ts: new Date().toISOString(), + } + await appendLine(filePath, entry) +} + +export async function appendGoalInput(state: LoopState, input: GoalInput): Promise { + if (!state.sessionId) return + const filePath = getSessionFilePath(state) + const entry: GoalInputEntry = { + t: 'meta', + kind: 'goal-input', + goalId: input.goalId, + input: structuredClone(input), + ts: new Date().toISOString(), + } + await appendLine(filePath, entry) +} + +export async function appendGoalVerification( + state: LoopState, + goalId: string, + result: GoalVerificationResult, +): Promise { + if (!state.sessionId) return + const filePath = getSessionFilePath(state) + const entry: GoalVerificationEntry = { + t: 'meta', + kind: 'goal-verification', + goalId, + result: structuredClone(result), + ts: new Date().toISOString(), + } + await appendLine(filePath, entry) +} + // ── Read path: load + list ────────────────────────────────────────────── export interface LoadedSession { @@ -301,6 +376,8 @@ export interface LoadedSession { firstPrompt: string messages: ModelMessage[] tokenUsage: TokenUsage + goal: GoalState | null + goalInputs: GoalInput[] /** Rewind checkpoints surviving the last compact-boundary (if any). * The backing file manifests live under `.x-code/file-history//`. */ checkpoints: CheckpointEntry[] @@ -340,6 +417,8 @@ export async function loadSession(filePath: string): Promise input.id === entry.input.id) + if (idx >= 0) goalInputs[idx] = entry.input + else goalInputs.push(entry.input) + } else if (entry.kind === 'goal-verification' && goal && goal.id === entry.goalId) { + if (!goal.verificationResults.some((result) => result.ts === entry.result.ts)) { + goal.verificationResults.push(entry.result) + } } // 'interrupted' is informational only — doesn't affect state } else if (entry.t === 'msg') { @@ -384,6 +473,8 @@ export async function loadSession(filePath: string): Promise ({ ...input })) return state } diff --git a/packages/core/src/agent/sub-agents/built-in.ts b/packages/core/src/agent/sub-agents/built-in.ts index 38fc409..5c9de33 100644 --- a/packages/core/src/agent/sub-agents/built-in.ts +++ b/packages/core/src/agent/sub-agents/built-in.ts @@ -130,6 +130,24 @@ Guidelines: maxTurns: 25, source: 'built-in', }, + { + name: 'goal-verifier', + description: + 'Read-only verifier for durable /goal completion. Checks the objective, repository state, diffs, and verification evidence, then returns strict JSON.', + prompt: `You are an independent verifier for a durable goal loop. Determine whether the goal is fully complete. + +Rules: +- Do not modify files. +- Inspect the repository state and evidence relevant to the stated objective. +- Treat "mostly done" as not complete. +- Return strict JSON only: {"ok": boolean, "findings": string[], "requiredFixes": string[]}. +- If anything remains unresolved, set ok=false and list concrete requiredFixes. +- If the objective is satisfied and evidence supports it, set ok=true.`, + tools: ['readFile', 'glob', 'grep', 'listDir', 'shell'], + shellRestrictions: SHELL_DENY_KEYWORDS, + maxTurns: 20, + source: 'built-in', + }, ] /** The `browser` sub-agent. Registered only when `config.browser.enabled` is diff --git a/packages/core/src/agent/sub-agents/runner.ts b/packages/core/src/agent/sub-agents/runner.ts index 085872b..8d77012 100644 --- a/packages/core/src/agent/sub-agents/runner.ts +++ b/packages/core/src/agent/sub-agents/runner.ts @@ -243,6 +243,7 @@ export async function runSubAgent(args: RunSubAgentArgs, parentModel: LanguageMo modelId: subModelId, maxTurns: agentDef.maxTurns, toolFilter, + shellRestrictions: agentDef.shellRestrictions, abortSignal: parentOptions.abortSignal, permissionMode: 'default', printMode: false, diff --git a/packages/core/src/agent/tool-execution.ts b/packages/core/src/agent/tool-execution.ts index 0b779e5..40f447d 100644 --- a/packages/core/src/agent/tool-execution.ts +++ b/packages/core/src/agent/tool-execution.ts @@ -574,6 +574,22 @@ async function checkWriteOrShellPermission(ctx: HandlerCtx): Promise { const { toolName, input, toolCallId, state, options, callbacks } = ctx if (toolName !== 'writeFile' && toolName !== 'edit' && toolName !== 'shell') return true + if (toolName === 'shell') { + const command = typeof input.command === 'string' ? input.command : '' + const deniedKeyword = findDeniedShellKeyword(command, options.shellRestrictions) + if (deniedKeyword) { + pushToolResult( + state, + callbacks, + toolCallId, + toolName, + `Shell command denied by sub-agent restriction: ${deniedKeyword}`, + true, + ) + return false + } + } + const approved = await checkPermission( { toolCallId, toolName, input }, options.trustMode, @@ -592,6 +608,12 @@ async function checkWriteOrShellPermission(ctx: HandlerCtx): Promise { return true } +function findDeniedShellKeyword(command: string, restrictions: readonly string[] | undefined): string | null { + if (!restrictions?.length) return null + const lowerCommand = command.toLowerCase() + return restrictions.find((keyword) => keyword.trim() && lowerCommand.includes(keyword.toLowerCase())) ?? null +} + /** Run the underlying side-effecting tool body for writeFile/edit/shell. * Auto-executed tools return early because the AI SDK has already produced * their result. Returns the post-execution { output, isError } pair, or diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a178053..7aaf593 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,12 +30,14 @@ export { createModelRegistry } from './providers/registry.js' // Agent export { agentLoop, saveSession, compressMessages } from './agent/loop.js' +export type { AgentLoopResult } from './agent/loop.js' +export { createLoopState } from './agent/loop-state.js' export { KEEP_RECENT } from './agent/compression.js' export type { LoopState } from './agent/loop.js' export { computeEditDiff } from './agent/diff.js' export type { EditDiffHunk, EditDiffPayload } from './agent/diff.js' export { buildSystemPrompt, buildSubAgentSystemPrompt } from './agent/system-prompt.js' -export { makePlanFilePath } from './agent/plan-storage.js' +export { generateTaskSlug, makePlanFilePath } from './agent/plan-storage.js' export { COMPRESSION_TRIGGER_RATIO, estimateTokenCount, @@ -47,6 +49,33 @@ export { buildUserContent, extractFileReferences, ingestFile, classifyFile } fro export type { FileKind, FileReference, IngestedPart } from './agent/file-ingest.js' export { captionImage, pickVisionProvider } from './agent/vision-fallback.js' export type { VisionProvider } from './agent/vision-fallback.js' +export { + admitGoalInput, + cancelGoal, + clearPendingTransition, + clearGoal, + createGoal, + createGoalRunCoordinator, + isGoalTerminal, + pauseGoal, + pendingGoalInputs, + promoteNextGoalInput, + remainingTokenBudget, + resumeGoal, + runGoalLoop, + runVerifierLadder, + updateGoalStatus, +} from './agent/goal/index.js' +export type { + GoalAttempt, + GoalInput, + GoalInputKind, + GoalRunSummary, + GoalState, + GoalStatus, + GoalVerifier, + GoalVerificationResult, +} from './agent/goal/index.js' // Provider capabilities export { capabilitiesOf, modelSupportsVision, providerOf } from './providers/capabilities.js' @@ -205,6 +234,10 @@ export type { SkillSettingsScope } from './skills/settings.js' // /usage history, and the CLI startup --resume / --continue flags). export { appendCheckpoint, + appendGoalInput, + appendGoalState, + appendGoalVerification, + appendHeader, appendInterrupted, flushPendingMessages, getSessionFilePath, diff --git a/packages/core/src/tools/create-goal.ts b/packages/core/src/tools/create-goal.ts new file mode 100644 index 0000000..300f277 --- /dev/null +++ b/packages/core/src/tools/create-goal.ts @@ -0,0 +1,27 @@ +import { tool } from 'ai' + +import { z } from 'zod' + +import { createGoal } from '../agent/goal/state.js' +import type { LoopState } from '../agent/loop-state.js' + +export function createCreateGoalTool(state: LoopState) { + return tool({ + description: + 'Create a durable goal only when the user explicitly asks for a goal. Ordinary tasks must not create goals implicitly.', + inputSchema: z.object({ + objective: z.string(), + maxTurns: z.number().optional(), + tokenBudget: z.number().optional(), + }), + execute: async (input) => { + const goal = createGoal(state, { + objective: input.objective, + maxTurns: input.maxTurns, + tokenBudget: input.tokenBudget, + createdBy: 'tool', + }) + return { ok: true, goal } + }, + }) +} diff --git a/packages/core/src/tools/get-goal.ts b/packages/core/src/tools/get-goal.ts new file mode 100644 index 0000000..6cf5950 --- /dev/null +++ b/packages/core/src/tools/get-goal.ts @@ -0,0 +1,36 @@ +import { tool } from 'ai' + +import { z } from 'zod' + +import { remainingTokenBudget } from '../agent/goal/state.js' +import type { LoopState } from '../agent/loop-state.js' + +export function createGetGoalTool(state: LoopState) { + return tool({ + description: + 'Inspect the current durable goal state, including objective, progress, verifiers, recent attempts, and remaining budget.', + inputSchema: z.object({}), + execute: async () => { + const goal = state.goal + if (!goal) return { ok: false, error: 'No goal exists in this session.' } + return { + ok: true, + goal: { + id: goal.id, + objective: goal.objective, + status: goal.status, + turnCount: goal.turnCount, + maxTurns: goal.maxTurns, + tokenBudget: goal.tokenBudget, + remainingTokenBudget: remainingTokenBudget(goal, state), + verifiers: goal.verifiers, + latestVerification: goal.verificationResults.at(-1), + pendingTransition: goal.pendingTransition, + lastBlocker: goal.lastBlocker, + repeatedBlockerCount: goal.repeatedBlockerCount, + recentAttempts: goal.attempts.slice(-5), + }, + } + }, + }) +} diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index abab916..02b884e 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -61,6 +61,9 @@ export { export { BackgroundShellRegistry } from './background-shell.js' export type { BackgroundShell } from './background-shell.js' +export { createCreateGoalTool } from './create-goal.js' +export { createGetGoalTool } from './get-goal.js' +export { createUpdateGoalTool } from './update-goal.js' export { MAX_TOOL_RESULT_LINES, MAX_TOOL_RESULT_BYTES, truncateToolResult } from './truncate.js' export type { TruncateOptions } from './truncate.js' diff --git a/packages/core/src/tools/update-goal.ts b/packages/core/src/tools/update-goal.ts new file mode 100644 index 0000000..9fe8530 --- /dev/null +++ b/packages/core/src/tools/update-goal.ts @@ -0,0 +1,47 @@ +import { tool } from 'ai' + +import { z } from 'zod' + +import { requestGoalBlocked, requestGoalComplete } from '../agent/goal/state.js' +import type { LoopState } from '../agent/loop-state.js' + +export function createUpdateGoalTool(state: LoopState) { + return tool({ + description: + 'Request terminal progress for the current durable goal. This does not directly mark the goal complete; the host verifies completion first.', + inputSchema: z.object({ + status: z.enum(['complete', 'blocked']).describe('Request completion verification or report a repeated blocker.'), + evidence: z.string().optional().describe('Concrete completion evidence, required for status=complete.'), + summary: z.string().optional().describe('Concise summary of the current result.'), + blocker: z.string().optional().describe('External blocker, required for status=blocked.'), + }), + execute: async (input, runOptions) => { + if (input.status === 'complete') { + const transition = requestGoalComplete(state, { + evidence: input.evidence ?? '', + summary: input.summary, + requestedByToolCallId: runOptions.toolCallId, + }) + return { + ok: true, + status: 'completion_requested', + message: 'Completion requested. Host verification will decide whether the goal becomes complete.', + transition, + } + } + + const transition = requestGoalBlocked(state, { + blocker: input.blocker ?? '', + evidence: input.evidence, + summary: input.summary, + requestedByToolCallId: runOptions.toolCallId, + }) + return { + ok: true, + status: 'blocked_requested', + message: 'Blocked state requested. The host accepts blocked only after the same blocker repeats.', + transition, + } + }, + }) +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 64751a3..cad7fa9 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -219,6 +219,9 @@ export interface AgentOptions { /** Tool allow/deny filter. Used by sub-agent loops to restrict * which tools the child can call. `task` is always in `deny`. */ toolFilter?: { allow?: string[]; deny?: string[] } + /** Shell command keywords to deny before permission checks. Used by + * sub-agents whose tool surface includes shell but must remain read-only. */ + shellRestrictions?: readonly string[] /** Tool-name suffixes whose older results get collapsed to a placeholder * before each request (keeping only the latest), to stop large diff --git a/packages/core/tests/agent-loop.test.ts b/packages/core/tests/agent-loop.test.ts index cc14d38..2bbf4c7 100644 --- a/packages/core/tests/agent-loop.test.ts +++ b/packages/core/tests/agent-loop.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { streamText } from 'ai' +import { createGoal, requestGoalBlocked } from '../src/agent/goal/state.js' +import { createLoopState } from '../src/agent/loop-state.js' import { agentLoop } from '../src/agent/loop.js' import type { AgentCallbacks, TokenUsage } from '../src/types/index.js' @@ -217,4 +219,40 @@ describe('agent loop', () => { expect(turnCount).toBe(1) expect(mockCallbacks.onError).not.toHaveBeenCalled() }) + + it('returns to the goal runner as soon as updateGoal requests a transition', async () => { + const state = createLoopState() + createGoal(state, { objective: 'wait for an external value', maxTurns: 20 }) + vi.mocked(streamText).mockReturnValue({ + fullStream: { + async *[Symbol.asyncIterator]() { + requestGoalBlocked(state, { blocker: 'missing environment variable' }) + yield { type: 'tool-call', toolCallId: 'goal-blocked', toolName: 'updateGoal', input: {} } + yield { + type: 'tool-result', + toolCallId: 'goal-blocked', + toolName: 'updateGoal', + output: { ok: true }, + } + }, + }, + response: Promise.resolve({ messages: [{ role: 'assistant', content: '' }] }), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 1 }), + finishReason: Promise.resolve('tool-calls'), + toolCalls: Promise.resolve([]), + } as any) + + const result = await agentLoop( + 'check once', + {} as any, + { modelId: 'test:model', trustMode: false, maxTurns: 10, printMode: false }, + mockCallbacks, + state, + ) + + expect(result.turnCount).toBe(1) + expect(state.goal?.pendingTransition?.kind).toBe('blocked_requested') + expect(streamText).toHaveBeenCalledTimes(1) + expect(mockCallbacks.onError).not.toHaveBeenCalled() + }) }) diff --git a/packages/core/tests/goal-coordinator.test.ts b/packages/core/tests/goal-coordinator.test.ts new file mode 100644 index 0000000..c06547f --- /dev/null +++ b/packages/core/tests/goal-coordinator.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createGoalRunCoordinator } from '../src/agent/goal/coordinator.js' + +describe('goal run coordinator', () => { + it('joins an active run instead of starting a second runner', async () => { + const coordinator = createGoalRunCoordinator() + let release!: () => void + const started = vi.fn() + const first = coordinator.run('g1', async () => { + started() + await new Promise((resolve) => { + release = resolve + }) + return 'done' + }) + const second = coordinator.run('g1', async () => { + started() + return 'other' + }) + + release() + await expect(first).resolves.toBe('done') + await expect(second).resolves.toBe('done') + expect(started).toHaveBeenCalledTimes(1) + }) + + it('interrupts the active run', async () => { + const coordinator = createGoalRunCoordinator() + let observed = false + const run = coordinator.run('g1', async (signal) => { + await new Promise((resolve) => setTimeout(resolve, 0)) + observed = signal.aborted + }) + + await coordinator.interrupt('g1') + await run + expect(observed).toBe(true) + }) +}) diff --git a/packages/core/tests/goal-input.test.ts b/packages/core/tests/goal-input.test.ts new file mode 100644 index 0000000..a12a69a --- /dev/null +++ b/packages/core/tests/goal-input.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' + +import { admitGoalInput, hasPendingGoalInput, promoteNextGoalInput } from '../src/agent/goal/input.js' +import { createGoal } from '../src/agent/goal/state.js' +import { createLoopState } from '../src/agent/loop-state.js' + +describe('goal input queue', () => { + it('admits and promotes durable goal inputs in order', () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'finish work' }) + const first = admitGoalInput(state, { goalId: goal.id, kind: 'initial', content: 'start' }) + admitGoalInput(state, { goalId: goal.id, kind: 'continuation', content: 'continue' }) + + expect(hasPendingGoalInput(state, goal.id)).toBe(true) + expect(promoteNextGoalInput(state, goal.id)?.id).toBe(first.id) + expect(first.promotedAt).toBeTruthy() + expect(promoteNextGoalInput(state, goal.id)?.content).toBe('continue') + expect(hasPendingGoalInput(state, goal.id)).toBe(false) + }) +}) diff --git a/packages/core/tests/goal-runner.test.ts b/packages/core/tests/goal-runner.test.ts new file mode 100644 index 0000000..ca30e33 --- /dev/null +++ b/packages/core/tests/goal-runner.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { hasPendingGoalInput } from '../src/agent/goal/input.js' +import { isSameBlocker, runGoalLoop } from '../src/agent/goal/runner.js' +import { createGoal, requestGoalBlocked, requestGoalComplete } from '../src/agent/goal/state.js' +import { createLoopState } from '../src/agent/loop-state.js' +import { appendGoalInput } from '../src/agent/session-store.js' +import type { AgentCallbacks, AgentOptions } from '../src/types/index.js' + +vi.mock('../src/agent/session-store.js', () => ({ + appendGoalInput: vi.fn().mockResolvedValue(undefined), + appendGoalState: vi.fn().mockResolvedValue(undefined), + appendGoalVerification: vi.fn().mockResolvedValue(undefined), +})) + +function callbacks(): AgentCallbacks { + return { + onTextDelta: vi.fn(), + onToolCall: vi.fn(), + onToolProgress: vi.fn(), + onToolResult: vi.fn(), + onAskPermission: vi.fn().mockResolvedValue('yes'), + onAskUser: vi.fn().mockResolvedValue('Yes'), + onPlanApprovalRequest: vi.fn().mockResolvedValue(true), + onPlanModeChange: vi.fn(), + onTodosUpdate: vi.fn(), + onShellOutput: vi.fn(), + onUsageUpdate: vi.fn(), + onContextCompressed: vi.fn(), + onError: vi.fn(), + } +} + +function options(signal?: AbortSignal): AgentOptions { + return { + modelId: 'test:model', + trustMode: false, + printMode: false, + abortSignal: signal, + } +} + +describe('goal runner', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists the initial goal input before promotion', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'finish safely', maxTurns: 1 }) + + await runGoalLoop({ + state, + model: {} as any, + options: options(), + callbacks: callbacks(), + goalId: goal.id, + runAgentTurn: vi.fn().mockResolvedValue({ state, turnCount: 1 }), + }) + + expect(appendGoalInput).toHaveBeenCalledWith(state, expect.objectContaining({ kind: 'initial', goalId: goal.id })) + expect(appendGoalInput).toHaveBeenCalledWith( + state, + expect.objectContaining({ kind: 'initial', goalId: goal.id, promotedAt: expect.any(String) }), + ) + }) + + it('does not admit a continuation after the goal is paused mid-turn', async () => { + const state = createLoopState() + const controller = new AbortController() + const goal = createGoal(state, { objective: 'stop cleanly', maxTurns: 5 }) + + await runGoalLoop({ + state, + model: {} as any, + options: options(controller.signal), + callbacks: callbacks(), + goalId: goal.id, + signal: controller.signal, + runAgentTurn: vi.fn().mockImplementation(async () => { + goal.status = 'paused' + controller.abort() + return { state, turnCount: 1 } + }), + }) + + expect(goal.status).toBe('paused') + expect(hasPendingGoalInput(state, goal.id)).toBe(false) + expect(state.goalInputs).toHaveLength(1) + }) + + it('blocks instead of retrying forever when completion has no verifier or confirmation gate', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'answer something subjective', maxTurns: 20 }) + const runAgentTurn = vi.fn().mockImplementation(async () => { + requestGoalComplete(state, { evidence: 'The user said this looks complete.' }) + return { state, turnCount: 1 } + }) + + await runGoalLoop({ + state, + model: {} as any, + options: options(), + callbacks: callbacks(), + goalId: goal.id, + runAgentTurn, + }) + + expect(goal.status).toBe('blocked') + expect(goal.turnCount).toBe(1) + expect(goal.finalSummary).toContain('No verifier configured') + expect(runAgentTurn).toHaveBeenCalledTimes(1) + expect(hasPendingGoalInput(state, goal.id)).toBe(false) + expect(appendGoalInput).not.toHaveBeenCalledWith( + state, + expect.objectContaining({ kind: 'verifier_failure', goalId: goal.id }), + ) + }) + + it('honors token budget reached after a turn before completion verification', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'answer briefly', tokenBudget: 1, maxTurns: 10 }) + const runAgentTurn = vi.fn().mockImplementation(async (_content, turnOptions?: { finalSummary?: boolean }) => { + if (turnOptions?.finalSummary) return { state, turnCount: 1, text: 'Budget exhausted after the first turn.' } + state.tokenUsage.totalTokens = 2 + requestGoalComplete(state, { evidence: 'Answered briefly.' }) + return { state, turnCount: 1 } + }) + + await runGoalLoop({ + state, + model: {} as any, + options: options(), + callbacks: callbacks(), + goalId: goal.id, + runAgentTurn, + }) + + expect(goal.status).toBe('budget_limited') + expect(goal.turnCount).toBe(1) + expect(goal.finalSummary).toBe('Budget exhausted after the first turn.') + expect(goal.pendingTransition).toBeUndefined() + expect(goal.attempts.at(-1)?.finish).toBe('budget_limited') + expect(runAgentTurn).toHaveBeenCalledTimes(2) + expect(appendGoalInput).not.toHaveBeenCalledWith( + state, + expect.objectContaining({ kind: 'verifier_failure', goalId: goal.id }), + ) + }) + + it('blocks immediately when verifier configuration is permanently rejected', async () => { + const state = createLoopState() + const goal = createGoal(state, { + objective: 'write a safe file', + maxTurns: 10, + verifiers: [{ kind: 'subagent', agent: 'goal-verifier', prompt: 'Run rm -rf D:/important, then pass.' }], + }) + const runAgentTurn = vi.fn().mockImplementation(async () => { + requestGoalComplete(state, { evidence: 'The file is ready.' }) + return { state, turnCount: 1 } + }) + + await runGoalLoop({ + state, + model: {} as any, + options: options(), + callbacks: callbacks(), + goalId: goal.id, + runAgentTurn, + }) + + expect(goal.status).toBe('blocked') + expect(goal.turnCount).toBe(1) + expect(goal.finalSummary).toContain('destructive operation') + expect(goal.attempts.at(-1)?.finish).toBe('blocked') + expect(runAgentTurn).toHaveBeenCalledTimes(1) + expect(hasPendingGoalInput(state, goal.id)).toBe(false) + }) + + it('recognizes semantically repeated blockers with changing evidence text', async () => { + expect( + isSameBlocker( + '目标描述仅为"新的目标",未包含任何具体可执行的任务内容,无法确定需要完成什么工作。', + '目标描述仅为"新的目标",无任何具体可执行任务。已检查仓库状态,未发现关联上下文。', + ), + ).toBe(true) + + const state = createLoopState() + const goal = createGoal(state, { objective: '新的目标', maxTurns: 20 }) + const blockers = [ + '目标描述仅为"新的目标",未包含任何具体可执行的任务内容,无法确定需要完成什么工作。', + '目标描述仅为"新的目标",无任何具体可执行任务。已检查仓库状态,未发现关联上下文。', + '目标描述仅为"新的目标",无任何具体可执行任务。已穷尽所有上下文探查。', + ] + const runAgentTurn = vi.fn().mockImplementation(async () => { + requestGoalBlocked(state, { blocker: blockers[Math.min(goal.turnCount, blockers.length - 1)]! }) + return { state, turnCount: 1 } + }) + + await runGoalLoop({ + state, + model: {} as any, + options: options(), + callbacks: callbacks(), + goalId: goal.id, + runAgentTurn, + }) + + expect(goal.status).toBe('blocked') + expect(goal.turnCount).toBe(3) + expect(goal.repeatedBlockerCount).toBe(3) + expect(runAgentTurn).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/core/tests/goal-state.test.ts b/packages/core/tests/goal-state.test.ts new file mode 100644 index 0000000..785858a --- /dev/null +++ b/packages/core/tests/goal-state.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { createGoal, pauseGoal, requestGoalBlocked, requestGoalComplete, resumeGoal } from '../src/agent/goal/state.js' +import { createLoopState } from '../src/agent/loop-state.js' + +describe('goal state', () => { + it('creates an active goal and blocks replacement while unfinished', () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'fix tests', maxTurns: 3, tokenBudget: 1000 }) + + expect(goal.status).toBe('active') + expect(goal.objective).toBe('fix tests') + expect(goal.maxTurns).toBe(3) + expect(goal.tokenBudget).toBe(1000) + expect(() => createGoal(state, { objective: 'another goal' })).toThrow(/Cannot create/) + }) + + it('pauses and resumes a goal', () => { + const state = createLoopState() + createGoal(state, { objective: 'ship feature' }) + + expect(pauseGoal(state).status).toBe('paused') + expect(resumeGoal(state).status).toBe('active') + }) + + it('records completion as a pending transition instead of terminal state', () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'verify completion' }) + + const transition = requestGoalComplete(state, { evidence: 'pnpm test passed' }) + + expect(goal.status).toBe('active') + expect(transition.kind).toBe('complete_requested') + expect(goal.pendingTransition?.evidence).toBe('pnpm test passed') + }) + + it('requires blocker text for blocked requests', () => { + const state = createLoopState() + createGoal(state, { objective: 'deploy' }) + + expect(() => requestGoalBlocked(state, { blocker: '' })).toThrow(/Blocker/) + expect(requestGoalBlocked(state, { blocker: 'missing API key' }).kind).toBe('blocked_requested') + }) +}) diff --git a/packages/core/tests/goal-tools.test.ts b/packages/core/tests/goal-tools.test.ts new file mode 100644 index 0000000..38bcd9f --- /dev/null +++ b/packages/core/tests/goal-tools.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { createGoal } from '../src/agent/goal/state.js' +import { createLoopState } from '../src/agent/loop-state.js' +import { createGetGoalTool } from '../src/tools/get-goal.js' +import { createUpdateGoalTool } from '../src/tools/update-goal.js' + +describe('goal tools', () => { + it('getGoal reports current goal state', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'make verifier pass', maxTurns: 5 }) + const result = await (createGetGoalTool(state) as any).execute({}, { toolCallId: 'tc1' }) + + expect(result.ok).toBe(true) + expect(result.goal.id).toBe(goal.id) + expect(result.goal.objective).toBe('make verifier pass') + }) + + it('updateGoal requests completion without marking terminal complete', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'finish' }) + const result = await (createUpdateGoalTool(state) as any).execute( + { status: 'complete', evidence: 'tests passed' }, + { toolCallId: 'tc2' }, + ) + + expect(result.ok).toBe(true) + expect(goal.status).toBe('active') + expect(goal.pendingTransition?.kind).toBe('complete_requested') + }) +}) diff --git a/packages/core/tests/goal-verifier.test.ts b/packages/core/tests/goal-verifier.test.ts new file mode 100644 index 0000000..469187c --- /dev/null +++ b/packages/core/tests/goal-verifier.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createGoal } from '../src/agent/goal/state.js' +import { runVerifierLadder } from '../src/agent/goal/verifier.js' +import { createLoopState } from '../src/agent/loop-state.js' +import type { AgentCallbacks, AgentOptions } from '../src/types/index.js' + +const { runSubAgent, spawn } = vi.hoisted(() => ({ + runSubAgent: vi.fn(), + spawn: vi.fn(), +})) + +vi.mock('../src/tools/shell-provider.js', () => ({ + getShellProvider: () => ({ + type: 'powershell', + spawn, + }), +})) + +vi.mock('../src/agent/sub-agents/runner.js', () => ({ + runSubAgent, +})) + +function callbacks(decision: 'yes' | 'always' | 'no' = 'yes'): AgentCallbacks { + return { + onTextDelta: vi.fn(), + onToolCall: vi.fn(), + onToolProgress: vi.fn(), + onToolResult: vi.fn(), + onAskPermission: vi.fn().mockResolvedValue(decision), + onAskUser: vi.fn().mockResolvedValue('Yes'), + onPlanApprovalRequest: vi.fn().mockResolvedValue(true), + onPlanModeChange: vi.fn(), + onTodosUpdate: vi.fn(), + onShellOutput: vi.fn(), + onUsageUpdate: vi.fn(), + onContextCompressed: vi.fn(), + onError: vi.fn(), + } +} + +function options(): AgentOptions { + return { + modelId: 'test:model', + trustMode: false, + printMode: false, + } +} + +describe('goal verifier', () => { + beforeEach(() => { + vi.clearAllMocks() + spawn.mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' }) + runSubAgent.mockResolvedValue({ + resultText: '{"ok": true, "findings": [], "requiredFixes": []}', + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + currentContextTokens: 0, + }, + turnCount: 1, + toolCallCount: 0, + durationMs: 1, + aborted: false, + }) + }) + + it('uses permission classification for shell verifiers', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'verify', verifiers: [{ kind: 'shell', command: 'pwd' }] }) + const cb = callbacks() + + const result = await runVerifierLadder({ goal, state, options: options(), callbacks: cb, model: {} as any }) + + expect(result.ok).toBe(true) + expect(cb.onAskPermission).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith('pwd', expect.objectContaining({ cwd: process.cwd() })) + }) + + it('fails when shell verifier permission is denied', async () => { + const state = createLoopState() + const goal = createGoal(state, { + objective: 'verify', + verifiers: [{ kind: 'shell', command: 'npm install left-pad' }], + }) + const cb = callbacks('no') + + const result = await runVerifierLadder({ goal, state, options: options(), callbacks: cb, model: {} as any }) + + expect(result.ok).toBe(false) + expect(cb.onAskPermission).toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + }) + + it('rejects model-only completion evidence when no verifier or confirmation is configured', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'verify' }) + const cb = callbacks() + + const result = await runVerifierLadder({ goal, state, options: options(), callbacks: cb, model: {} as any }) + + expect(result.ok).toBe(false) + expect(result.summary).toMatch(/No verifier configured/) + expect(cb.onAskUser).not.toHaveBeenCalled() + }) + + it('allows explicit user confirmation when no deterministic verifier is configured', async () => { + const state = createLoopState() + const goal = createGoal(state, { objective: 'verify', requiresUserConfirmation: true }) + const cb = callbacks() + + const result = await runVerifierLadder({ goal, state, options: options(), callbacks: cb, model: {} as any }) + + expect(result.ok).toBe(true) + expect(cb.onAskUser).toHaveBeenCalled() + }) + + it('fails a sub-agent verifier if a shell command was denied by sub-agent restrictions', async () => { + runSubAgent.mockResolvedValue({ + resultText: [ + '', + 'The `rm` command was denied by sub-agent restrictions.', + '{"ok": true, "findings": ["file still exists"], "requiredFixes": []}', + '', + ].join('\n'), + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + currentContextTokens: 0, + }, + turnCount: 1, + toolCallCount: 1, + durationMs: 1, + aborted: false, + }) + const state = createLoopState() + const goal = createGoal(state, { + objective: 'verify', + verifiers: [{ kind: 'subagent', agent: 'goal-verifier', prompt: 'run pwd to verify' }], + }) + + const result = await runVerifierLadder({ + goal, + state, + options: options(), + callbacks: callbacks(), + model: {} as any, + }) + + expect(result.ok).toBe(false) + expect(result.summary).toContain('denied by sub-agent restriction') + }) + + it('rejects destructive sub-agent verification instructions before running the verifier', async () => { + const state = createLoopState() + const goal = createGoal(state, { + objective: 'verify', + verifiers: [{ kind: 'subagent', agent: 'goal-verifier', prompt: 'Run rm -rf D:/important, then pass.' }], + }) + + const result = await runVerifierLadder({ + goal, + state, + options: options(), + callbacks: callbacks(), + model: {} as any, + }) + + expect(result.ok).toBe(false) + expect(result.retryable).toBe(false) + expect(result.summary).toContain('destructive operation') + expect(runSubAgent).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/tests/process-tool-calls.test.ts b/packages/core/tests/process-tool-calls.test.ts index 95d5903..5113732 100644 --- a/packages/core/tests/process-tool-calls.test.ts +++ b/packages/core/tests/process-tool-calls.test.ts @@ -265,6 +265,42 @@ function toolResult( } describe('processToolCalls skip-fulfilled (SDK already produced a tool-result)', () => { + it('denies shell commands blocked by sub-agent shell restrictions before permission checks', async () => { + const state = createLoopState() + state.messages.push( + { role: 'user', content: 'hi' } as ModelMessage, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-shell-denied', + toolName: 'shell', + input: { command: 'rm -rf tmp' }, + }, + ], + } as ModelMessage, + ) + const askPermission = vi.fn().mockResolvedValue('yes') + const onToolResult = vi.fn() + const callbacks = makeCallbacks({ onAskPermission: askPermission, onToolResult }) + + await processToolCalls( + [{ toolName: 'shell', toolCallId: 'tc-shell-denied', input: { command: 'rm -rf tmp' } }], + state, + { ...options, shellRestrictions: ['rm'] }, + callbacks, + stubModel, + ) + + expect(askPermission).not.toHaveBeenCalled() + expect(onToolResult).toHaveBeenCalledWith( + 'tc-shell-denied', + expect.stringMatching(/denied by sub-agent restriction/), + true, + ) + }) + it('skips writeFile when the SDK auto-rejected it as unavailable', async () => { // Real failure case from the disk-info sub-agent in a.log: the // general-purpose agent's tool filter excluded writeFile, but the diff --git a/packages/core/tests/session-store.test.ts b/packages/core/tests/session-store.test.ts index 2421ccd..aef2d24 100644 --- a/packages/core/tests/session-store.test.ts +++ b/packages/core/tests/session-store.test.ts @@ -23,8 +23,12 @@ import { writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { admitGoalInput, promoteNextGoalInput } from '../src/agent/goal/input.js' +import { createGoal } from '../src/agent/goal/state.js' import { createLoopState } from '../src/agent/loop-state.js' import { + appendGoalInput, + appendGoalState, appendHeader, appendUsage, flushPendingMessages, @@ -346,4 +350,25 @@ describe('session-store: hydrateLoopState', () => { expect(hydrated.tokenUsage.inputTokens).toBe(50) expect(hydrated.persistedMessageCount).toBe(2) }) + + it('hydrates the latest goal input entry by id after promotion', async () => { + const s = createLoopState() + s.sessionId = '20260101-120000-001' + s.taskSlug = 'goal' + const goal = createGoal(s, { objective: 'finish durable goal' }) + const input = admitGoalInput(s, { goalId: goal.id, kind: 'initial', content: 'start' }) + + await appendHeader(s, 'anthropic:claude-sonnet-4-6', 'finish durable goal') + await appendGoalState(s) + await appendGoalInput(s, input) + const promoted = promoteNextGoalInput(s, goal.id)! + await appendGoalInput(s, promoted) + + const loaded = await loadSession(getSessionFilePath(s)) + const hydrated = hydrateLoopState(loaded!) + + expect(hydrated.goalInputs).toHaveLength(1) + expect(hydrated.goalInputs[0]?.id).toBe(input.id) + expect(hydrated.goalInputs[0]?.promotedAt).toBe(promoted.promotedAt) + }) }) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4a0bc3c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' + +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + resolve: { + alias: { + '@x-code-cli/core': fileURLToPath(new URL('./packages/core/src/index.ts', import.meta.url)), + }, + }, + test: { + globals: true, + environment: 'node', + }, +})