From cf56226b5a0c2993862ed0d03de00030e701393e Mon Sep 17 00:00:00 2001 From: moyu Date: Mon, 8 Jun 2026 16:28:05 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20/goal=E5=91=BD=E4=BB=A4=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=94=AF=E6=8C=81=EF=BC=8C=E5=8F=82=E8=80=83codex?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/builtin-tools/src/index.ts | 1 + .../src/tools/GoalTool/GoalTool.ts | 253 +++++++++++++++ .../src/tools/GoalTool/constants.ts | 1 + .../src/tools/GoalTool/prompt.ts | 38 +++ scripts/defines.ts | 3 + src/commands.ts | 6 + .../goal/GoalReplaceConfirmDialog.tsx | 75 +++++ src/commands/goal/goal.tsx | 144 +++++++++ src/commands/goal/index.ts | 13 + src/components/StatusLine.tsx | 47 +++ src/cost-tracker.ts | 18 ++ src/hooks/useGoalContinuation.ts | 140 +++++++++ src/screens/REPL.tsx | 46 +++ src/services/goal/__tests__/goalState.test.ts | 248 +++++++++++++++ src/services/goal/goalAudit.ts | 32 ++ src/services/goal/goalState.ts | 261 ++++++++++++++++ src/services/goal/goalStorage.ts | 55 ++++ src/services/goal/prompts.ts | 151 +++++++++ src/tools.ts | 5 + src/types/logs.ts | 72 +++++ src/utils/sessionStorage.ts | 75 +++++ tests/integration/goal-lifecycle.test.ts | 289 ++++++++++++++++++ 22 files changed, 1973 insertions(+) create mode 100644 packages/builtin-tools/src/tools/GoalTool/GoalTool.ts create mode 100644 packages/builtin-tools/src/tools/GoalTool/constants.ts create mode 100644 packages/builtin-tools/src/tools/GoalTool/prompt.ts create mode 100644 src/commands/goal/GoalReplaceConfirmDialog.tsx create mode 100644 src/commands/goal/goal.tsx create mode 100644 src/commands/goal/index.ts create mode 100644 src/hooks/useGoalContinuation.ts create mode 100644 src/services/goal/__tests__/goalState.test.ts create mode 100644 src/services/goal/goalAudit.ts create mode 100644 src/services/goal/goalState.ts create mode 100644 src/services/goal/goalStorage.ts create mode 100644 src/services/goal/prompts.ts create mode 100644 tests/integration/goal-lifecycle.test.ts diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index c31d600b33..88d1238b00 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -20,6 +20,7 @@ export { FileEditTool } from './tools/FileEditTool/FileEditTool.js' export { FileReadTool } from './tools/FileReadTool/FileReadTool.js' export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js' export { GlobTool } from './tools/GlobTool/GlobTool.js' +export { GoalTool } from './tools/GoalTool/GoalTool.js' export { GrepTool } from './tools/GrepTool/GrepTool.js' export { LSPTool } from './tools/LSPTool/LSPTool.js' export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js' diff --git a/packages/builtin-tools/src/tools/GoalTool/GoalTool.ts b/packages/builtin-tools/src/tools/GoalTool/GoalTool.ts new file mode 100644 index 0000000000..742b99d326 --- /dev/null +++ b/packages/builtin-tools/src/tools/GoalTool/GoalTool.ts @@ -0,0 +1,253 @@ +import { z } from 'zod/v4' +import { buildTool, type ToolDef } from 'src/Tool.js' +import { jsonStringify } from 'src/utils/slowOperations.js' +import { lazySchema } from 'src/utils/lazySchema.js' +import { + completeGoal, + formatGoalElapsed, + formatGoalStatusLabel, + getGoal, + recordBlockedAttempt, +} from 'src/services/goal/goalState.js' +import { persistCurrentGoal } from 'src/services/goal/goalStorage.js' +import { GOAL_TOOL_NAME } from './constants.js' +import { DESCRIPTION, generatePrompt } from './prompt.js' + +function toolLog(msg: string): void { + try { + const { logForDebugging } = + require('src/utils/debug.js') as typeof import('src/utils/debug.js') + logForDebugging(`[goal] tool: ${msg}`) + } catch { + /* debug not available */ + } +} + +const inputSchema = lazySchema(() => + z.strictObject({ + action: z + .enum(['get', 'update']) + .optional() + .describe( + 'Action to perform: "get" to read status, "update" to mark complete or blocked. Defaults to "update" if status is provided, otherwise "get".', + ), + status: z + .enum(['complete', 'blocked']) + .optional() + .describe( + 'Required for "update". Only "complete" or "blocked" are accepted.', + ), + reason: z + .string() + .optional() + .describe('Explanation for the status change. Required for "update".'), + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + success: z.boolean(), + goal: z + .object({ + objective: z.string(), + status: z.string(), + tokensUsed: z.number(), + tokenBudget: z.number().nullable(), + elapsed: z.string(), + turnsExecuted: z.number(), + }) + .optional(), + message: z.string().optional(), + report: z.string().optional(), + error: z.string().optional(), + }), +) +type OutputSchema = ReturnType + +export type Input = z.infer +export type Output = z.infer + +function buildGoalSnapshot() { + const goal = getGoal() + if (!goal) return undefined + return { + objective: goal.objective, + status: formatGoalStatusLabel(goal.status), + tokensUsed: goal.tokensUsed, + tokenBudget: goal.tokenBudget, + elapsed: formatGoalElapsed(goal), + turnsExecuted: goal.turnsExecuted, + } +} + +function buildCompletionReport(): string { + const goal = getGoal() + if (!goal) return '' + const budget = + goal.tokenBudget !== null + ? `Token usage: ${goal.tokensUsed} / ${goal.tokenBudget}` + : `Token usage: ${goal.tokensUsed}` + return [ + 'Goal achieved — usage report:', + ` ${budget}`, + ` Active time: ${formatGoalElapsed(goal)}`, + ` Continuation turns: ${goal.turnsExecuted}`, + ].join('\n') +} + +export const GoalTool = buildTool({ + name: GOAL_TOOL_NAME, + searchHint: 'get or update the active goal (complete/blocked)', + maxResultSizeChars: 10_000, + async description() { + return DESCRIPTION + }, + async prompt() { + return generatePrompt() + }, + get inputSchema(): InputSchema { + return inputSchema() + }, + get outputSchema(): OutputSchema { + return outputSchema() + }, + userFacingName() { + return 'Goal' + }, + shouldDefer: true, + isConcurrencySafe() { + return true + }, + isReadOnly(input: Input) { + const action = input.action ?? (input.status ? 'update' : 'get') + return action === 'get' + }, + toAutoClassifierInput(input: Input) { + const action = input.action ?? (input.status ? 'update' : 'get') + if (action === 'get') return 'get goal status' + return `update goal: ${input.status} — ${input.reason ?? ''}` + }, + async checkPermissions(input: Input) { + return { behavior: 'allow' as const, updatedInput: input } + }, + renderToolUseMessage(input: Input) { + const action = input.action ?? (input.status ? 'update' : 'get') + if (action === 'get') return 'Checking goal status…' + return `Updating goal: ${input.status}${input.reason ? ` — ${input.reason}` : ''}` + }, + renderToolResultMessage(output: Output) { + if (output.error) return `Goal error: ${output.error}` + if (output.report) return output.report + if (output.goal) { + return `Goal "${output.goal.objective}" — ${output.goal.status}` + } + return output.message ?? 'Done' + }, + renderToolUseRejectedMessage() { + return 'Goal operation rejected' + }, + async call(input: Input): Promise<{ data: Output }> { + const action = input.action ?? (input.status ? 'update' : 'get') + toolLog( + `called: action=${action}${input.status ? ` status=${input.status}` : ''}${input.reason ? ` reason="${input.reason.slice(0, 60)}"` : ''}`, + ) + if (action === 'get') { + const snapshot = buildGoalSnapshot() + if (!snapshot) { + return { + data: { + success: true, + message: + 'No active goal. The user can set one with `/goal `.', + }, + } + } + return { data: { success: true, goal: snapshot } } + } + + // action === 'update' + if (!input.status) { + return { + data: { + success: false, + error: + 'The "status" field is required for update. Use "complete" or "blocked".', + }, + } + } + + const goal = getGoal() + if (!goal) { + return { + data: { + success: false, + error: 'No active goal to update.', + }, + } + } + + if (input.status === 'complete') { + const report = buildCompletionReport() + completeGoal() + persistCurrentGoal() + return { + data: { + success: true, + goal: buildGoalSnapshot(), + report, + }, + } + } + + // status === 'blocked' + const reason = input.reason ?? 'unspecified blocker' + const result = recordBlockedAttempt(reason) + if (!result) { + return { + data: { + success: false, + error: 'Goal is not in a state that accepts blocked attempts.', + }, + } + } + persistCurrentGoal() + + if (result.status === 'blocked') { + return { + data: { + success: true, + goal: buildGoalSnapshot(), + message: `Goal marked as blocked after ${result.attempts} consecutive attempts. Reason: ${reason}`, + }, + } + } + + return { + data: { + success: true, + goal: buildGoalSnapshot(), + message: `Blocked attempt ${result.attempts} recorded. The goal remains active — the same condition must persist for 3 consecutive turns before it is marked blocked.`, + }, + } + }, + mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) { + if (content.error) { + return { + tool_use_id: toolUseID, + type: 'tool_result' as const, + content: `Error: ${content.error}`, + is_error: true, + } + } + const parts: string[] = [] + if (content.message) parts.push(content.message) + if (content.report) parts.push(content.report) + if (content.goal) parts.push(jsonStringify(content.goal)) + return { + tool_use_id: toolUseID, + type: 'tool_result' as const, + content: parts.join('\n') || 'Done', + } + }, +} satisfies ToolDef) diff --git a/packages/builtin-tools/src/tools/GoalTool/constants.ts b/packages/builtin-tools/src/tools/GoalTool/constants.ts new file mode 100644 index 0000000000..c38a0c0cac --- /dev/null +++ b/packages/builtin-tools/src/tools/GoalTool/constants.ts @@ -0,0 +1 @@ +export const GOAL_TOOL_NAME = 'GoalTool' diff --git a/packages/builtin-tools/src/tools/GoalTool/prompt.ts b/packages/builtin-tools/src/tools/GoalTool/prompt.ts new file mode 100644 index 0000000000..ce65335cb4 --- /dev/null +++ b/packages/builtin-tools/src/tools/GoalTool/prompt.ts @@ -0,0 +1,38 @@ +export const DESCRIPTION = + 'Get or update the active goal status. The model may only mark a goal as "complete" or "blocked".' + +export function generatePrompt(): string { + return `Use this tool to interact with the active thread goal. + +## Actions + +### get +Returns the current goal state (objective, status, token usage, elapsed time, turns executed). +No input required beyond \`action: "get"\`. + +### update +Transition the goal to a terminal status. Only two values are accepted: +- **complete** — All requirements are verified (see Completion Audit below). +- **blocked** — An insurmountable obstacle has persisted for 3+ consecutive turns (see Blocked Audit below). + +When marking complete, provide a brief \`reason\` summarising what was achieved. +When marking blocked, provide a \`reason\` describing the specific blocker. + +## Completion Audit (required before marking complete) +1. Derive concrete requirements from the objective. +2. Preserve the original scope — do not redefine success around existing work. +3. For every requirement, identify authoritative evidence (test output, file content, command result). +4. Treat tests and manifests as evidence only after confirming they cover the requirement. +5. Treat uncertain or indirect evidence as "not achieved". +6. The audit must PROVE completion, not merely fail to find remaining work. + +## Blocked Audit (required before marking blocked) +1. The same blocking condition must persist across at least 3 consecutive continuation turns. +2. "Difficult", "slow", or "partially incomplete" is NOT blocked. +3. Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.). + +## Important +- You cannot pause, resume, or clear a goal — only the user can do that via \`/goal\`. +- If no goal is active, \`get\` returns a message saying so; \`update\` is a no-op. +- On completion, the tool result includes a usage report (tokens, time, turns).` +} diff --git a/scripts/defines.ts b/scripts/defines.ts index fb0a3b17c5..a21dfb3985 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -95,4 +95,7 @@ export const DEFAULT_BUILD_FEATURES = [ 'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行 // Autofix PR 'AUTOFIX_PR', // /autofix-pr 命令(fork 引入;docs/jira/AUTOFIX-PR-001.md 承诺默认开启) + // Persistent thread goal command — auto-continuation, JSONL persistence, + // strict completion/blocked audit. See src/services/goal. + 'GOAL', ] as const diff --git a/src/commands.ts b/src/commands.ts index 508498c27a..4de382b8c8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -162,6 +162,11 @@ const poor = feature('POOR') require('./commands/poor/index.js') as typeof import('./commands/poor/index.js') ).default : null +const goalCmd = feature('GOAL') + ? ( + require('./commands/goal/index.js') as typeof import('./commands/goal/index.js') + ).default + : null /* eslint-enable @typescript-eslint/no-require-imports */ import thinkback from './commands/thinkback/index.js' import thinkbackPlay from './commands/thinkback-play/index.js' @@ -362,6 +367,7 @@ const COMMANDS = memoize((): Command[] => [ ...(forkCmd ? [forkCmd] : []), ...(buddy ? [buddy] : []), ...(poor ? [poor] : []), + ...(goalCmd ? [goalCmd] : []), ...(proactive ? [proactive] : []), ...(monitorCmd ? [monitorCmd] : []), ...(coordinatorCmd ? [coordinatorCmd] : []), diff --git a/src/commands/goal/GoalReplaceConfirmDialog.tsx b/src/commands/goal/GoalReplaceConfirmDialog.tsx new file mode 100644 index 0000000000..ca13762631 --- /dev/null +++ b/src/commands/goal/GoalReplaceConfirmDialog.tsx @@ -0,0 +1,75 @@ +/** + * Confirmation dialog shown when the user runs `/goal ` + * while a non-complete goal is already active. + */ +import * as React from 'react'; + +import { Box, Text } from '@anthropic/ink'; + +import type { GoalState } from '../../types/logs.js'; +import { Select } from '../../components/CustomSelect/index.js'; +import { PermissionDialog } from '../../components/permissions/PermissionDialog.js'; +import { formatGoalElapsed, formatGoalStatusLabel } from '../../services/goal/goalState.js'; + +type Props = { + currentGoal: GoalState; + newObjective: string; + onConfirm: () => void; + onCancel: () => void; +}; + +export function GoalReplaceConfirmDialog({ currentGoal, newObjective, onConfirm, onCancel }: Props): React.ReactNode { + function handleResponse(value: 'yes' | 'no'): void { + if (value === 'yes') onConfirm(); + else onCancel(); + } + + const tokensDisplay = + currentGoal.tokenBudget !== null + ? `${currentGoal.tokensUsed} / ${currentGoal.tokenBudget}` + : `${currentGoal.tokensUsed}`; + + return ( + + + A goal is already in progress. Replacing it will reset all progress and counters. + + + Current goal: + + · Objective: + {currentGoal.objective} + + + · Status: + {formatGoalStatusLabel(currentGoal.status)} + + + · Time: + {formatGoalElapsed(currentGoal)} + + + · Tokens: + {tokensDisplay} + + + + + New objective: + {newObjective} + + + +