From d42b83c199c4fce45c57b41a3a65e5ef98f1674b Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Fri, 10 Jul 2026 21:19:09 +0800 Subject: [PATCH 1/5] feat: add durable goal loop --- packages/cli/src/ui/components/App.tsx | 230 +++++++- packages/cli/src/ui/components/ChatInput.tsx | 2 +- packages/cli/src/ui/hooks/use-agent.ts | 530 ++++++++++++++++-- packages/cli/src/ui/utils.ts | 33 ++ packages/cli/tests/utils.test.ts | 28 + packages/core/src/agent/goal/coordinator.ts | 59 ++ packages/core/src/agent/goal/final-summary.ts | 17 + packages/core/src/agent/goal/index.ts | 8 + packages/core/src/agent/goal/input.ts | 34 ++ packages/core/src/agent/goal/prompts.ts | 73 +++ packages/core/src/agent/goal/runner.ts | 188 +++++++ packages/core/src/agent/goal/state.ts | 182 ++++++ packages/core/src/agent/goal/types.ts | 109 ++++ packages/core/src/agent/goal/verifier.ts | 333 +++++++++++ packages/core/src/agent/loop-state.ts | 12 + packages/core/src/agent/loop.ts | 10 + packages/core/src/agent/session-store.ts | 95 +++- .../core/src/agent/sub-agents/built-in.ts | 18 + packages/core/src/agent/sub-agents/runner.ts | 1 + packages/core/src/agent/tool-execution.ts | 22 + packages/core/src/index.ts | 35 +- packages/core/src/tools/create-goal.ts | 27 + packages/core/src/tools/get-goal.ts | 36 ++ packages/core/src/tools/index.ts | 3 + packages/core/src/tools/update-goal.ts | 47 ++ packages/core/src/types/index.ts | 3 + packages/core/tests/goal-coordinator.test.ts | 40 ++ packages/core/tests/goal-input.test.ts | 20 + packages/core/tests/goal-runner.test.ts | 91 +++ packages/core/tests/goal-state.test.ts | 44 ++ packages/core/tests/goal-tools.test.ts | 31 + packages/core/tests/goal-verifier.test.ts | 98 ++++ .../core/tests/process-tool-calls.test.ts | 36 ++ packages/core/tests/session-store.test.ts | 25 + 34 files changed, 2465 insertions(+), 55 deletions(-) create mode 100644 packages/cli/tests/utils.test.ts create mode 100644 packages/core/src/agent/goal/coordinator.ts create mode 100644 packages/core/src/agent/goal/final-summary.ts create mode 100644 packages/core/src/agent/goal/index.ts create mode 100644 packages/core/src/agent/goal/input.ts create mode 100644 packages/core/src/agent/goal/prompts.ts create mode 100644 packages/core/src/agent/goal/runner.ts create mode 100644 packages/core/src/agent/goal/state.ts create mode 100644 packages/core/src/agent/goal/types.ts create mode 100644 packages/core/src/agent/goal/verifier.ts create mode 100644 packages/core/src/tools/create-goal.ts create mode 100644 packages/core/src/tools/get-goal.ts create mode 100644 packages/core/src/tools/update-goal.ts create mode 100644 packages/core/tests/goal-coordinator.test.ts create mode 100644 packages/core/tests/goal-input.test.ts create mode 100644 packages/core/tests/goal-runner.test.ts create mode 100644 packages/core/tests/goal-state.test.ts create mode 100644 packages/core/tests/goal-tools.test.ts create mode 100644 packages/core/tests/goal-verifier.test.ts diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 0ed373a..dabaec1 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -25,6 +25,8 @@ import { import type { AgentOptions, DiffStats, + GoalState, + GoalVerifier, KnowledgeFact, LanguageModel, LoadedSession, @@ -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,104 @@ function formatRelativeTime(epochMs: number): string { return new Date(epochMs).toISOString().slice(0, 10) } +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 +} + +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 + + 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-agent') { + const agent = tokens[++i] + if (agent) { + verifiers.push({ + kind: 'subagent', + agent, + prompt: 'Verify that the current goal objective is fully complete in the repository/session state.', + timeoutMs: 120000, + }) + } + continue + } + objectiveParts.push(token) + } + + return { + objective: objectiveParts.join(' ').trim(), + maxTurns, + tokenBudget, + requiresUserConfirmation, + verifiers, + } +} + +function formatGoalStatus(goal: GoalState): 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: ${goal.tokenBudget?.toLocaleString('en-US') ?? 'n/a'}`, + `- 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 +461,14 @@ export function App({ const { state, submit, + runGoal, + pauseGoal, + resumeGoal, + cancelGoal, + clearGoal, + steerGoal, + editGoal, + verifyGoal, resolvePermission, resolveQuestion, abort, @@ -362,7 +485,6 @@ export function App({ getThinking, invalidateSystemPromptCache, addInfoMessage, - addUserMessage, echoCommand, addCommandMessage, addCommandResult, @@ -834,6 +956,11 @@ export function App({ await handleCompact() return + case 'goal': + echoCommand(text) + await handleGoal(arg) + return + case 'resume': echoCommand(text) await handleResume() @@ -1267,6 +1394,107 @@ 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) : '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) { + 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"] [--max-turns n] [--token-budget n] [--confirm]') + 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..bb5d124 100644 --- a/packages/cli/src/ui/components/ChatInput.tsx +++ b/packages/cli/src/ui/components/ChatInput.tsx @@ -585,7 +585,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 diff --git a/packages/cli/src/ui/hooks/use-agent.ts b/packages/cli/src/ui/hooks/use-agent.ts index 1ef1b2f..abcfa71 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() + await prepareGoalSession(ls, command.objective) + const goal = createCoreGoal(ls, { + objective: command.objective, + maxTurns: command.maxTurns, + tokenBudget: command.tokenBudget, + verifiers: command.verifiers ?? [], + requiresUserConfirmation: command.requiresUserConfirmation, + }) + await appendGoalState(ls) + setState((prev) => ({ ...prev, goalStatus: { ...goal }, goalRunnerActive: true })) + + 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 () => { @@ -797,6 +1215,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini 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 @@ -839,6 +1258,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 +1425,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/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/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/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..b2d4bc8 --- /dev/null +++ b/packages/core/src/agent/goal/prompts.ts @@ -0,0 +1,73 @@ +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.', + '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 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}`, + 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}`, + '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..c4d5f24 --- /dev/null +++ b/packages/core/src/agent/goal/runner.ts @@ -0,0 +1,188 @@ +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 + + 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 + } + 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 === 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), + }) + updateGoalStatus(goal, reason, summary) + void appendGoalState(input.state) +} + +function preview(text: string): string { + return text.replace(/\s+/g, ' ').trim().slice(0, 240) +} 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..892edbe --- /dev/null +++ b/packages/core/src/agent/goal/types.ts @@ -0,0 +1,109 @@ +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 + 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' | '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..36da6dc --- /dev/null +++ b/packages/core/src/agent/goal/verifier.ts @@ -0,0 +1,333 @@ +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 + 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 summary = ok + ? `All ${results.length} verifier step(s) passed.` + : (results.find((result) => !result.ok)?.summary ?? 'Verification failed.') + return { ok, 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() + 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, + ) + 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 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 + summary: string + start: number + verificationRunId: string + exitCode?: number | null + stdout?: string + stderr?: string +}): GoalVerificationResult { + return { + verifier: input.verifier, + ok: input.ok, + 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..5dae5ab 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 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/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..e7bfc4a --- /dev/null +++ b/packages/core/tests/goal-runner.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { hasPendingGoalInput } from '../src/agent/goal/input.js' +import { runGoalLoop } from '../src/agent/goal/runner.js' +import { createGoal } 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) + }) +}) 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..cd814ad --- /dev/null +++ b/packages/core/tests/goal-verifier.test.ts @@ -0,0 +1,98 @@ +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 spawn = vi.fn() + +vi.mock('../src/tools/shell-provider.js', () => ({ + getShellProvider: () => ({ + type: 'powershell', + spawn, + }), +})) + +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: '' }) + }) + + 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() + }) +}) 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) + }) }) From 8bdb3f2e76120a8c0ce4333940091ae13372c8af Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Fri, 10 Jul 2026 21:53:54 +0800 Subject: [PATCH 2/5] fix: preserve cwd in dev cli --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e0c5240..d519608 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "scripts": { "build": "pnpm -r run build", - "dev": "pnpm -r run build && pnpm --filter @x-code-cli/cli run dev", + "dev": "pnpm -r run build && pnpm exec tsx packages/cli/src/index.ts", "test": "vitest run", "test:watch": "vitest", "test:e2e": "tsx packages/cli/tests/e2e/runner.ts", From 49f9187c3dce17c86f3afb07a7490265daf8870b Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Fri, 10 Jul 2026 21:57:30 +0800 Subject: [PATCH 3/5] Revert "fix: preserve cwd in dev cli" This reverts commit 8bdb3f2e76120a8c0ce4333940091ae13372c8af. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d519608..e0c5240 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "scripts": { "build": "pnpm -r run build", - "dev": "pnpm -r run build && pnpm exec tsx packages/cli/src/index.ts", + "dev": "pnpm -r run build && pnpm --filter @x-code-cli/cli run dev", "test": "vitest run", "test:watch": "vitest", "test:e2e": "tsx packages/cli/tests/e2e/runner.ts", From 3d31dc7aa9ca1291c7451bc73bee0e5281e88cd7 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Sat, 11 Jul 2026 13:02:22 +0800 Subject: [PATCH 4/5] fix: harden goal loop execution --- packages/cli/src/ui/commands/goal.ts | 89 ++++++++++++ packages/cli/src/ui/components/App.tsx | 100 ++++---------- packages/cli/src/ui/components/ChatInput.tsx | 97 ++++++++++--- packages/cli/src/ui/hooks/use-agent.ts | 46 ++++--- .../cli/src/ui/hooks/use-stream-buffer.ts | 12 +- .../cli/tests/chat-input-geometry.test.ts | 17 +++ packages/cli/tests/goal-command.test.ts | 36 +++++ .../tests/use-stream-buffer-boundary.test.ts | 22 +++ packages/cli/vitest.config.ts | 7 + packages/core/src/agent/goal/prompts.ts | 4 + packages/core/src/agent/goal/runner.ts | 51 ++++++- packages/core/src/agent/goal/types.ts | 3 +- packages/core/src/agent/goal/verifier.ts | 36 ++++- packages/core/src/agent/loop.ts | 9 ++ packages/core/tests/agent-loop.test.ts | 38 ++++++ packages/core/tests/goal-runner.test.ts | 127 +++++++++++++++++- packages/core/tests/goal-verifier.test.ts | 84 +++++++++++- vitest.config.ts | 15 +++ 18 files changed, 669 insertions(+), 124 deletions(-) create mode 100644 packages/cli/src/ui/commands/goal.ts create mode 100644 packages/cli/tests/chat-input-geometry.test.ts create mode 100644 packages/cli/tests/goal-command.test.ts create mode 100644 packages/cli/tests/use-stream-buffer-boundary.test.ts create mode 100644 vitest.config.ts 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 dabaec1..e573c02 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -26,7 +26,6 @@ import type { AgentOptions, DiffStats, GoalState, - GoalVerifier, KnowledgeFact, LanguageModel, LoadedSession, @@ -37,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' @@ -255,75 +255,21 @@ function formatRelativeTime(epochMs: number): string { return new Date(epochMs).toISOString().slice(0, 10) } -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 +function canResumeGoalStatus(goal: GoalState): boolean { + return ( + goal.status === 'active' || goal.status === 'paused' || goal.status === 'blocked' || goal.status === 'max_turns' + ) } -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 - - 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-agent') { - const agent = tokens[++i] - if (agent) { - verifiers.push({ - kind: 'subagent', - agent, - prompt: 'Verify that the current goal objective is fully complete in the repository/session state.', - timeoutMs: 120000, - }) - } - continue - } - objectiveParts.push(token) - } - - return { - objective: objectiveParts.join(' ').trim(), - maxTurns, - tokenBudget, - requiresUserConfirmation, - verifiers, - } +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): string { +function formatGoalStatus(goal: GoalState, usage?: TokenUsage): string { const latest = goal.verificationResults.at(-1) const verifiers = goal.verifiers.length ? goal.verifiers @@ -340,7 +286,7 @@ function formatGoalStatus(goal: GoalState): string { `- Objective: ${goal.objective}`, `- Status: ${goal.status}`, `- Turns: ${goal.turnCount}/${goal.maxTurns}`, - `- Token budget: ${goal.tokenBudget?.toLocaleString('en-US') ?? 'n/a'}`, + `- 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}`, @@ -941,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': @@ -1401,7 +1341,7 @@ export function App({ const lower = subcommand.toLowerCase() if (!arg.trim() || lower === 'status') { - addCommandResult(goal ? formatGoalStatus(goal) : 'No current goal.') + addCommandResult(goal ? formatGoalStatus(goal, state.usage) : 'No current goal.') return } @@ -1413,7 +1353,7 @@ export function App({ if (lower === 'resume') { const maxTurnsArg = rest[0] === '--max-turns' ? rest[1] : undefined - if (maxTurnsArg && goal) { + if (maxTurnsArg && goal && canResumeGoalStatus(goal)) { const maxTurns = maxTurnsArg.startsWith('+') ? goal.maxTurns + Number(maxTurnsArg.slice(1)) : Number(maxTurnsArg) @@ -1479,7 +1419,13 @@ export function App({ const parsed = parseGoalCreateArgs(arg) if (!parsed.objective) { - addCommandResult('Usage: /goal [--verify "cmd"] [--max-turns n] [--token-budget n] [--confirm]') + 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}`) diff --git a/packages/cli/src/ui/components/ChatInput.tsx b/packages/cli/src/ui/components/ChatInput.tsx index bb5d124..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) @@ -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 abcfa71..614c83f 100644 --- a/packages/cli/src/ui/hooks/use-agent.ts +++ b/packages/cli/src/ui/hooks/use-agent.ts @@ -884,7 +884,6 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini const runGoal = useCallback( async (command: RunGoalCommand): Promise => { const ls = ensureLoopState() - await prepareGoalSession(ls, command.objective) const goal = createCoreGoal(ls, { objective: command.objective, maxTurns: command.maxTurns, @@ -892,8 +891,9 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini verifiers: command.verifiers ?? [], requiresUserConfirmation: command.requiresUserConfirmation, }) - await appendGoalState(ls) setState((prev) => ({ ...prev, goalStatus: { ...goal }, goalRunnerActive: true })) + await prepareGoalSession(ls, command.objective) + await appendGoalState(ls) await goalCoordinatorRef.current.run(goal.id, async (signal) => { try { @@ -1210,19 +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 = [] - 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 })) - }, [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 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/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/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/prompts.ts b/packages/core/src/agent/goal/prompts.ts index b2d4bc8..3984c56 100644 --- a/packages/core/src/agent/goal/prompts.ts +++ b/packages/core/src/agent/goal/prompts.ts @@ -6,6 +6,7 @@ export function buildInitialGoalPrompt(goal: GoalState): string { `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.', @@ -18,6 +19,7 @@ export function buildContinuationPrompt(goal: GoalState): string { ``, `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') @@ -36,6 +38,7 @@ export function buildVerifierFailurePrompt( ``, `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.', @@ -48,6 +51,7 @@ export function buildBlockedRetryPrompt(goal: GoalState, blocker: string, repeat ``, `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') diff --git a/packages/core/src/agent/goal/runner.ts b/packages/core/src/agent/goal/runner.ts index c4d5f24..395ba80 100644 --- a/packages/core/src/agent/goal/runner.ts +++ b/packages/core/src/agent/goal/runner.ts @@ -94,6 +94,11 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise input.runAgentTurn(content, options), }) + clearPendingTransition(goal) updateGoalStatus(goal, reason, summary) void appendGoalState(input.state) } @@ -186,3 +199,39 @@ async function finalizeBySummary( 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/types.ts b/packages/core/src/agent/goal/types.ts index 892edbe..be89c7e 100644 --- a/packages/core/src/agent/goal/types.ts +++ b/packages/core/src/agent/goal/types.ts @@ -40,6 +40,7 @@ export type GoalVerifier = GoalVerifierShell | GoalVerifierSubAgent | GoalVerifi export interface GoalVerificationResult { verifier: GoalVerifier ok: boolean + retryable?: boolean summary: string exitCode?: number | null stdout?: string @@ -59,7 +60,7 @@ export interface GoalAttempt { turnCount: number tokenUsageBefore: TokenUsage tokenUsageAfter?: TokenUsage - finish: 'stop' | 'aborted' | 'error' | 'max_turns' | 'verification_failed' | 'complete' | 'blocked' + finish: 'stop' | 'aborted' | 'error' | 'max_turns' | 'budget_limited' | 'verification_failed' | 'complete' | 'blocked' } export interface GoalPendingTransition { diff --git a/packages/core/src/agent/goal/verifier.ts b/packages/core/src/agent/goal/verifier.ts index 36da6dc..5c324ef 100644 --- a/packages/core/src/agent/goal/verifier.ts +++ b/packages/core/src/agent/goal/verifier.ts @@ -15,6 +15,7 @@ import type { GoalState, GoalVerificationResult, GoalVerifier } from './types.js export interface GoalVerifierLadderResult { ok: boolean + retryable: boolean results: GoalVerificationResult[] summary: string } @@ -71,10 +72,11 @@ export async function runVerifierLadder(input: { } 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, results, summary } + return { ok, retryable, results, summary } } async function runSingleVerifier(input: { @@ -245,6 +247,16 @@ async function runSubAgentVerifier(input: { }): 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.', @@ -267,6 +279,16 @@ async function runSubAgentVerifier(input: { }, 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, @@ -290,6 +312,16 @@ async function runSubAgentVerifier(input: { } } +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.'] } @@ -308,6 +340,7 @@ function parseVerifierJson(text: string): { ok: boolean; findings: string[]; req function makeResult(input: { verifier: GoalVerifier ok: boolean + retryable?: boolean summary: string start: number verificationRunId: string @@ -318,6 +351,7 @@ function makeResult(input: { return { verifier: input.verifier, ok: input.ok, + retryable: input.retryable, summary: input.summary, exitCode: input.exitCode, stdout: input.stdout, diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 5dae5ab..1608947 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -784,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/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-runner.test.ts b/packages/core/tests/goal-runner.test.ts index e7bfc4a..ca30e33 100644 --- a/packages/core/tests/goal-runner.test.ts +++ b/packages/core/tests/goal-runner.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { hasPendingGoalInput } from '../src/agent/goal/input.js' -import { runGoalLoop } from '../src/agent/goal/runner.js' -import { createGoal } from '../src/agent/goal/state.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' @@ -88,4 +88,127 @@ describe('goal runner', () => { 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-verifier.test.ts b/packages/core/tests/goal-verifier.test.ts index cd814ad..469187c 100644 --- a/packages/core/tests/goal-verifier.test.ts +++ b/packages/core/tests/goal-verifier.test.ts @@ -5,7 +5,10 @@ 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 spawn = vi.fn() +const { runSubAgent, spawn } = vi.hoisted(() => ({ + runSubAgent: vi.fn(), + spawn: vi.fn(), +})) vi.mock('../src/tools/shell-provider.js', () => ({ getShellProvider: () => ({ @@ -14,6 +17,10 @@ vi.mock('../src/tools/shell-provider.js', () => ({ }), })) +vi.mock('../src/agent/sub-agents/runner.js', () => ({ + runSubAgent, +})) + function callbacks(decision: 'yes' | 'always' | 'no' = 'yes'): AgentCallbacks { return { onTextDelta: vi.fn(), @@ -44,6 +51,21 @@ 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 () => { @@ -95,4 +117,64 @@ describe('goal verifier', () => { 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/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', + }, +}) From 201bf0cae7ed532995022e163b6ea1d3beba1bd7 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Sat, 11 Jul 2026 13:08:44 +0800 Subject: [PATCH 5/5] fix: lint error --- packages/cli/src/ui/hooks/use-prompt-input.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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)