Files
claude-code/packages/builtin-tools/src/tools/GoalTool/GoalTool.ts
moy16 3e3e1de81b feat: /goal命令能力支持,参考codex实现 (#1261)
* feat: /goal命令能力支持,参考codex实现

* fix: 修复promp和提示词不一致的问题

* fix: 修复 goal 功能多项 AI 审查问题

- prompt 中 update 行为描述与运行时不一致(no-op → error)
- src/commands/goal/ 使用相对路径导入,改为 src/* 别名
- /goal 命令标记 bridgeSafe 但含交互式对话框,改为 false
- useGoalContinuation 中 origin 使用 as unknown as string 强转,改为直接传字符串
- ResumeConversation 路径缺少 goal hydration,补齐恢复逻辑
- onCancel 在非查询状态下误暂停 goal,加 queryGuard 守卫
- resumeGoal 允许从终态恢复,收紧为仅允许 paused 状态
- buildGoalContextBlock 生成畸形 XML 属性,改为合法 budget 属性

* fix: 修复剩余AI审查的问题

* fix: 防止goal状态丢失

* fix: 修复Biome规范错误问题

* fix: 修复部分情况下goal无法启动的问题

* fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。

* fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。

* fix: apply biome formatting to pass CI lint check

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: skip slash command echo in setUserInputOnProcessing to prevent UI flash

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: moyu <moyu@kingsoft.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 10:44:10 +08:00

254 lines
7.0 KiB
TypeScript

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<typeof inputSchema>
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<typeof outputSchema>
export type Input = z.infer<InputSchema>
export type Output = z.infer<OutputSchema>
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 <objective>`.',
},
}
}
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<InputSchema, Output>)