Skip to content

Commit 3e3e1de

Browse files
Moyhubmoyucursoragent
authored
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>
1 parent 5bfe6fa commit 3e3e1de

28 files changed

Lines changed: 2248 additions & 30 deletions

packages/builtin-tools/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
2020
export { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
2121
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
2222
export { GlobTool } from './tools/GlobTool/GlobTool.js'
23+
export { GoalTool } from './tools/GoalTool/GoalTool.js'
2324
export { GrepTool } from './tools/GrepTool/GrepTool.js'
2425
export { LSPTool } from './tools/LSPTool/LSPTool.js'
2526
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { z } from 'zod/v4'
2+
import { buildTool, type ToolDef } from 'src/Tool.js'
3+
import { jsonStringify } from 'src/utils/slowOperations.js'
4+
import { lazySchema } from 'src/utils/lazySchema.js'
5+
import {
6+
completeGoal,
7+
formatGoalElapsed,
8+
formatGoalStatusLabel,
9+
getGoal,
10+
recordBlockedAttempt,
11+
} from 'src/services/goal/goalState.js'
12+
import { persistCurrentGoal } from 'src/services/goal/goalStorage.js'
13+
import { GOAL_TOOL_NAME } from './constants.js'
14+
import { DESCRIPTION, generatePrompt } from './prompt.js'
15+
16+
function toolLog(msg: string): void {
17+
try {
18+
const { logForDebugging } =
19+
require('src/utils/debug.js') as typeof import('src/utils/debug.js')
20+
logForDebugging(`[goal] tool: ${msg}`)
21+
} catch {
22+
/* debug not available */
23+
}
24+
}
25+
26+
const inputSchema = lazySchema(() =>
27+
z.strictObject({
28+
action: z
29+
.enum(['get', 'update'])
30+
.optional()
31+
.describe(
32+
'Action to perform: "get" to read status, "update" to mark complete or blocked. Defaults to "update" if status is provided, otherwise "get".',
33+
),
34+
status: z
35+
.enum(['complete', 'blocked'])
36+
.optional()
37+
.describe(
38+
'Required for "update". Only "complete" or "blocked" are accepted.',
39+
),
40+
reason: z
41+
.string()
42+
.optional()
43+
.describe('Explanation for the status change. Required for "update".'),
44+
}),
45+
)
46+
type InputSchema = ReturnType<typeof inputSchema>
47+
48+
const outputSchema = lazySchema(() =>
49+
z.object({
50+
success: z.boolean(),
51+
goal: z
52+
.object({
53+
objective: z.string(),
54+
status: z.string(),
55+
tokensUsed: z.number(),
56+
tokenBudget: z.number().nullable(),
57+
elapsed: z.string(),
58+
turnsExecuted: z.number(),
59+
})
60+
.optional(),
61+
message: z.string().optional(),
62+
report: z.string().optional(),
63+
error: z.string().optional(),
64+
}),
65+
)
66+
type OutputSchema = ReturnType<typeof outputSchema>
67+
68+
export type Input = z.infer<InputSchema>
69+
export type Output = z.infer<OutputSchema>
70+
71+
function buildGoalSnapshot() {
72+
const goal = getGoal()
73+
if (!goal) return undefined
74+
return {
75+
objective: goal.objective,
76+
status: formatGoalStatusLabel(goal.status),
77+
tokensUsed: goal.tokensUsed,
78+
tokenBudget: goal.tokenBudget,
79+
elapsed: formatGoalElapsed(goal),
80+
turnsExecuted: goal.turnsExecuted,
81+
}
82+
}
83+
84+
function buildCompletionReport(): string {
85+
const goal = getGoal()
86+
if (!goal) return ''
87+
const budget =
88+
goal.tokenBudget !== null
89+
? `Token usage: ${goal.tokensUsed} / ${goal.tokenBudget}`
90+
: `Token usage: ${goal.tokensUsed}`
91+
return [
92+
'Goal achieved — usage report:',
93+
` ${budget}`,
94+
` Active time: ${formatGoalElapsed(goal)}`,
95+
` Continuation turns: ${goal.turnsExecuted}`,
96+
].join('\n')
97+
}
98+
99+
export const GoalTool = buildTool({
100+
name: GOAL_TOOL_NAME,
101+
searchHint: 'get or update the active goal (complete/blocked)',
102+
maxResultSizeChars: 10_000,
103+
async description() {
104+
return DESCRIPTION
105+
},
106+
async prompt() {
107+
return generatePrompt()
108+
},
109+
get inputSchema(): InputSchema {
110+
return inputSchema()
111+
},
112+
get outputSchema(): OutputSchema {
113+
return outputSchema()
114+
},
115+
userFacingName() {
116+
return 'Goal'
117+
},
118+
shouldDefer: true,
119+
isConcurrencySafe() {
120+
return true
121+
},
122+
isReadOnly(input: Input) {
123+
const action = input.action ?? (input.status ? 'update' : 'get')
124+
return action === 'get'
125+
},
126+
toAutoClassifierInput(input: Input) {
127+
const action = input.action ?? (input.status ? 'update' : 'get')
128+
if (action === 'get') return 'get goal status'
129+
return `update goal: ${input.status}${input.reason ?? ''}`
130+
},
131+
async checkPermissions(input: Input) {
132+
return { behavior: 'allow' as const, updatedInput: input }
133+
},
134+
renderToolUseMessage(input: Input) {
135+
const action = input.action ?? (input.status ? 'update' : 'get')
136+
if (action === 'get') return 'Checking goal status…'
137+
return `Updating goal: ${input.status}${input.reason ? ` — ${input.reason}` : ''}`
138+
},
139+
renderToolResultMessage(output: Output) {
140+
if (output.error) return `Goal error: ${output.error}`
141+
if (output.report) return output.report
142+
if (output.goal) {
143+
return `Goal "${output.goal.objective}" — ${output.goal.status}`
144+
}
145+
return output.message ?? 'Done'
146+
},
147+
renderToolUseRejectedMessage() {
148+
return 'Goal operation rejected'
149+
},
150+
async call(input: Input): Promise<{ data: Output }> {
151+
const action = input.action ?? (input.status ? 'update' : 'get')
152+
toolLog(
153+
`called: action=${action}${input.status ? ` status=${input.status}` : ''}${input.reason ? ` reason="${input.reason.slice(0, 60)}"` : ''}`,
154+
)
155+
if (action === 'get') {
156+
const snapshot = buildGoalSnapshot()
157+
if (!snapshot) {
158+
return {
159+
data: {
160+
success: true,
161+
message:
162+
'No active goal. The user can set one with `/goal <objective>`.',
163+
},
164+
}
165+
}
166+
return { data: { success: true, goal: snapshot } }
167+
}
168+
169+
// action === 'update'
170+
if (!input.status) {
171+
return {
172+
data: {
173+
success: false,
174+
error:
175+
'The "status" field is required for update. Use "complete" or "blocked".',
176+
},
177+
}
178+
}
179+
180+
const goal = getGoal()
181+
if (!goal) {
182+
return {
183+
data: {
184+
success: false,
185+
error: 'No active goal to update.',
186+
},
187+
}
188+
}
189+
190+
if (input.status === 'complete') {
191+
const report = buildCompletionReport()
192+
completeGoal()
193+
persistCurrentGoal()
194+
return {
195+
data: {
196+
success: true,
197+
goal: buildGoalSnapshot(),
198+
report,
199+
},
200+
}
201+
}
202+
203+
// status === 'blocked'
204+
const reason = input.reason ?? 'unspecified blocker'
205+
const result = recordBlockedAttempt(reason)
206+
if (!result) {
207+
return {
208+
data: {
209+
success: false,
210+
error: 'Goal is not in a state that accepts blocked attempts.',
211+
},
212+
}
213+
}
214+
persistCurrentGoal()
215+
216+
if (result.status === 'blocked') {
217+
return {
218+
data: {
219+
success: true,
220+
goal: buildGoalSnapshot(),
221+
message: `Goal marked as blocked after ${result.attempts} consecutive attempts. Reason: ${reason}`,
222+
},
223+
}
224+
}
225+
226+
return {
227+
data: {
228+
success: true,
229+
goal: buildGoalSnapshot(),
230+
message: `Blocked attempt ${result.attempts} recorded. The goal remains active — the same condition must persist for 3 consecutive turns before it is marked blocked.`,
231+
},
232+
}
233+
},
234+
mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
235+
if (content.error) {
236+
return {
237+
tool_use_id: toolUseID,
238+
type: 'tool_result' as const,
239+
content: `Error: ${content.error}`,
240+
is_error: true,
241+
}
242+
}
243+
const parts: string[] = []
244+
if (content.message) parts.push(content.message)
245+
if (content.report) parts.push(content.report)
246+
if (content.goal) parts.push(jsonStringify(content.goal))
247+
return {
248+
tool_use_id: toolUseID,
249+
type: 'tool_result' as const,
250+
content: parts.join('\n') || 'Done',
251+
}
252+
},
253+
} satisfies ToolDef<InputSchema, Output>)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const GOAL_TOOL_NAME = 'GoalTool'
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const DESCRIPTION =
2+
'Get or update the active goal status. The model may only mark a goal as "complete" or "blocked".'
3+
4+
export function generatePrompt(): string {
5+
return `Use this tool to interact with the active thread goal.
6+
7+
## Actions
8+
9+
### get
10+
Returns the current goal state (objective, status, token usage, elapsed time, turns executed).
11+
No input required beyond \`action: "get"\`.
12+
13+
### update
14+
Transition the goal to a terminal status. Only two values are accepted:
15+
- **complete** — All requirements are verified (see Completion Audit below).
16+
- **blocked** — An insurmountable obstacle has persisted for 3+ consecutive turns (see Blocked Audit below).
17+
18+
When marking complete, provide a brief \`reason\` summarising what was achieved.
19+
When marking blocked, provide a \`reason\` describing the specific blocker.
20+
21+
## Completion Audit (required before marking complete)
22+
1. Derive concrete requirements from the objective.
23+
2. Preserve the original scope — do not redefine success around existing work.
24+
3. For every requirement, identify authoritative evidence (test output, file content, command result).
25+
4. Treat tests and manifests as evidence only after confirming they cover the requirement.
26+
5. Treat uncertain or indirect evidence as "not achieved".
27+
6. The audit must PROVE completion, not merely fail to find remaining work.
28+
29+
## Blocked Audit (required before marking blocked)
30+
1. The same blocking condition must persist across at least 3 consecutive continuation turns.
31+
2. "Difficult", "slow", or "partially incomplete" is NOT blocked.
32+
3. Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.).
33+
34+
## Important
35+
- You cannot pause, resume, or clear a goal — only the user can do that via \`/goal\`.
36+
- If no goal is active, \`get\` returns a message saying so; \`update\` returns an error.
37+
- On completion, the tool result includes a usage report (tokens, time, turns).`
38+
}

scripts/defines.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,7 @@ export const DEFAULT_BUILD_FEATURES = [
9595
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
9696
// Autofix PR
9797
'AUTOFIX_PR', // /autofix-pr 命令(fork 引入;docs/jira/AUTOFIX-PR-001.md 承诺默认开启)
98+
// Persistent thread goal command — auto-continuation, JSONL persistence,
99+
// strict completion/blocked audit. See src/services/goal.
100+
'GOAL',
98101
] as const

src/commands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ const poor = feature('POOR')
162162
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
163163
).default
164164
: null
165+
const goalCmd = feature('GOAL')
166+
? (
167+
require('./commands/goal/index.js') as typeof import('./commands/goal/index.js')
168+
).default
169+
: null
165170
/* eslint-enable @typescript-eslint/no-require-imports */
166171
import thinkback from './commands/thinkback/index.js'
167172
import thinkbackPlay from './commands/thinkback-play/index.js'
@@ -362,6 +367,7 @@ const COMMANDS = memoize((): Command[] => [
362367
...(forkCmd ? [forkCmd] : []),
363368
...(buddy ? [buddy] : []),
364369
...(poor ? [poor] : []),
370+
...(goalCmd ? [goalCmd] : []),
365371
...(proactive ? [proactive] : []),
366372
...(monitorCmd ? [monitorCmd] : []),
367373
...(coordinatorCmd ? [coordinatorCmd] : []),

0 commit comments

Comments
 (0)