Skip to content

Commit a7ee13d

Browse files
fix(tools): 本会话允许审批 + 工具轮次触顶强制收尾
主助手多步 shell 任务会反复弹审批且 10 轮触顶后半截结束;增加「本会话允许」跳过同工具后续确认,并将 maxIterations 提到 32、触顶后强制无工具收尾。 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 41c0c6c commit a7ee13d

18 files changed

Lines changed: 391 additions & 29 deletions

File tree

src/main/ipc/home.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { getClient } from '../llm/retry'
3636
import { resolveThinkingConfig } from '../llm/thinking'
3737
import { listToolsForAgents } from '../tools/mcp'
3838
import { listMemoryKeysForPrompt } from '../tools/builtin/memory'
39+
import { resolveApprovalDecision } from '../tools/sessionApprovals'
3940
import {
4041
newRequestId,
4142
rejectAllUserInputs,
@@ -263,6 +264,7 @@ export function registerHomeHandlers(): void {
263264
}
264265
},
265266
// HITL 工具审批桥(shell_run / MCP always 工具):approval_request 事件 + 挂起等用户确认
267+
// 应答 approved / approved_session / denied(本会话允许写入 sessionApprovals)
266268
onApprove: async ({ toolName, args }) => {
267269
const requestId = newRequestId()
268270
const emit = (event: import('@shared/types').StreamEvent): void =>
@@ -271,7 +273,7 @@ export function registerHomeHandlers(): void {
271273
try {
272274
const response = await waitForUserInput(requestId, { nodeId: 'home', question: `approve ${toolName}` }, signal)
273275
emit({ type: 'approval_resolved', request_id: requestId, node_id: 'home', response })
274-
return { approved: response === 'approved', reason: response === 'approved' ? undefined : response }
276+
return resolveApprovalDecision(response, sid, toolName)
275277
} catch (e) {
276278
emit({ type: 'approval_resolved', request_id: requestId, node_id: 'home', response: '' })
277279
return { approved: false, reason: 'timeout or cancelled' }
@@ -345,7 +347,7 @@ export function registerHomeHandlers(): void {
345347
throw e
346348
}
347349
},
348-
// HITL 工具审批桥:approvalMode='always' → approval_request 事件 + 挂起等用户确认
350+
// HITL 工具审批桥:approvalMode='always' → approval_request;支持本会话允许
349351
onApprove: async ({ toolName, args }) => {
350352
const requestId = newRequestId()
351353
const emit = (event: import('@shared/types').StreamEvent): void =>
@@ -354,7 +356,7 @@ export function registerHomeHandlers(): void {
354356
try {
355357
const response = await waitForUserInput(requestId, { nodeId: node.id, question: `approve ${toolName}` }, signal)
356358
emit({ type: 'approval_resolved', request_id: requestId, node_id: node.id, response })
357-
return { approved: response === 'approved', reason: response === 'approved' ? undefined : response }
359+
return resolveApprovalDecision(response, sid, toolName)
358360
} catch (e) {
359361
emit({ type: 'approval_resolved', request_id: requestId, node_id: node.id, response: '' })
360362
return { approved: false, reason: 'timeout or cancelled' }
@@ -418,6 +420,9 @@ export function registerHomeHandlers(): void {
418420
)
419421
finalText = result.finalText
420422
finalThinking = result.finalThinking
423+
if (result.hitIterationLimit) {
424+
logger.warn('[home] 主 Agent 达工具轮次上限,已强制无工具收尾')
425+
}
421426

422427
// 流结束:判定直答 vs 组队
423428
const decision = detector.decide()
@@ -463,8 +468,11 @@ export function registerHomeHandlers(): void {
463468
logger.warn('[l2] 精炼失败', e),
464469
)
465470

466-
// 10. 结束事件
467-
emitStream({ type: 'message_stop', stop_reason: 'end_turn' })
471+
// 10. 结束事件:触顶收尾用 max_iterations,便于前端/日志区分假 end_turn
472+
emitStream({
473+
type: 'message_stop',
474+
stop_reason: result.hitIterationLimit ? 'max_iterations' : 'end_turn',
475+
})
468476
} catch (e) {
469477
// 错误推到 AI 气泡位置(而非聊天区上方),含可重试提示
470478
const msg = e instanceof Error ? e.message : String(e)

src/main/ipc/orchestrate.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { getSkill, getAgent, getDefaultProvider, resolveProviderCredentials } fr
2121
import { addMessage } from '../storage/sessions'
2222
import { SkillContextProvider } from '../skills/provider'
2323
import { listToolsForAgents } from '../tools/mcp'
24+
import { resolveApprovalDecision } from '../tools/sessionApprovals'
2425
import { resolveThinkingConfig } from '../llm/thinking'
2526
import type { AgentExecutorOptions } from '../orchestrator/patterns/agent'
2627
import { logger } from '../logger'
@@ -59,6 +60,8 @@ function makeResolveAgent(
5960
signal?: AbortSignal,
6061
/** R1/R2:builtin + 显式暴露的 MCP 工具快照(一次运行内固定) */
6162
agentTools: LlmToolDef[] = [],
63+
/** 会话 id:本会话允许工具审批放行键;编辑器试跑可能为空 */
64+
sessionId?: string,
6265
): {
6366
resolveAgent: (node: GraphNode) => AgentExecutorOptions | null
6467
/** 本运行创建的全部 SkillContextProvider(运行结束统一 afterRun 审计,铁律22) */
@@ -139,6 +142,7 @@ function makeResolveAgent(
139142
config,
140143
llmOpts: { apiKey, baseURL, authHeader },
141144
toolCtx: {
145+
sessionId,
142146
signal,
143147
// HITL 提问桥:ask_user → request_info 事件推前端 + 挂起等作答(userInput 队列)
144148
onAskUser: async ({ question, context }) => {
@@ -154,14 +158,14 @@ function makeResolveAgent(
154158
throw e
155159
}
156160
},
157-
// HITL 工具审批桥:approvalMode='always' → approval_request 事件 + 挂起等用户确认
161+
// HITL 工具审批桥:支持本会话允许(sessionId 有值时写入放行表)
158162
onApprove: async ({ toolName, args }) => {
159163
const requestId = newRequestId()
160164
emitStream({ type: 'approval_request', request_id: requestId, node_id: node.id, tool_name: toolName, args })
161165
try {
162166
const response = await waitForUserInput(requestId, { nodeId: node.id, question: `approve ${toolName}` }, signal)
163167
emitStream({ type: 'approval_resolved', request_id: requestId, node_id: node.id, response })
164-
return { approved: response === 'approved', reason: response === 'approved' ? undefined : response }
168+
return resolveApprovalDecision(response, sessionId, toolName)
165169
} catch (e) {
166170
emitStream({ type: 'approval_resolved', request_id: requestId, node_id: node.id, response: '' })
167171
return { approved: false, reason: 'timeout or cancelled' }
@@ -200,7 +204,7 @@ export function registerOrchestrateHandlers(): void {
200204
const { signal } = currentAbortController
201205
const agentTools = await listToolsForAgents()
202206
const { resolveAgent, skillProviders } = makeResolveAgent(
203-
modelId, apiKey, baseURL, authHeader, enableThinking, apiFormat, signal, agentTools,
207+
modelId, apiKey, baseURL, authHeader, enableThinking, apiFormat, signal, agentTools, sessionId,
204208
)
205209
const deps: BuildDeps = { resolveAgent }
206210

src/main/ipc/sessions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
removeSession,
1111
renameSession,
1212
} from '../storage/sessions'
13+
import { clearSessionToolApprovals } from '../tools/sessionApprovals'
1314

1415
// —— 会话历史 IPC(§八之二 B)——
1516
// 入参 Zod 校验:IPC 边界不做隐式 as 断言,畸形参数在入口处结构化报错(P1-12)。
@@ -31,7 +32,11 @@ const AddMessageSchema = z.object({
3132
export function registerSessionsHandlers(): void {
3233
withHandler<Session[]>('sessions:list', () => listSessions())
3334
withHandler<Session | null>('sessions:get', (_e, id) => getSession(IdSchema.parse(id)))
34-
withHandler<void>('sessions:remove', (_e, id) => removeSession(IdSchema.parse(id)))
35+
withHandler<void>('sessions:remove', (_e, id) => {
36+
const sid = IdSchema.parse(id)
37+
removeSession(sid)
38+
clearSessionToolApprovals(sid) // 删会话时清掉「本会话允许」放行
39+
})
3540
withHandler<void>('sessions:rename', (_e, id, title) =>
3641
renameSession(IdSchema.parse(id), z.string().min(1).parse(title)),
3742
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import type { LlmRequest, LlmResponse } from '@shared/types'
3+
import { Agent } from './agent'
4+
5+
// —— maxIterations 触顶收尾:不能静默停在半截 tool_use ——
6+
7+
const streamMock = vi.fn<(req: LlmRequest) => Promise<LlmResponse>>()
8+
9+
vi.mock('../llm/retry', () => ({
10+
getClient: () => ({ stream: (req: LlmRequest) => streamMock(req) }),
11+
}))
12+
13+
vi.mock('../tools/registry', () => ({
14+
executeTool: vi.fn(async (_name: string, _args: unknown, id: string) => ({
15+
toolUseId: id,
16+
content: JSON.stringify({ ok: true }),
17+
isError: false,
18+
})),
19+
getToolDefs: () => [],
20+
}))
21+
22+
function toolUseTurn(text: string, callId: string): LlmResponse {
23+
return {
24+
stopReason: 'tool_use',
25+
content: [
26+
...(text ? [{ type: 'text' as const, text }] : []),
27+
{ type: 'tool_use' as const, id: callId, name: 'web_search', input: { q: 'x' } },
28+
],
29+
}
30+
}
31+
32+
describe('Agent maxIterations 收尾', () => {
33+
beforeEach(() => {
34+
streamMock.mockReset()
35+
})
36+
37+
it('触顶且最后一轮仍是 tool_use → 再打一轮无工具收尾,不把半截话当终局', async () => {
38+
// 2 轮 tool_use + 1 轮强制收尾
39+
streamMock
40+
.mockResolvedValueOnce(toolUseTurn('先查一下…', 'tu_0'))
41+
.mockResolvedValueOnce(toolUseTurn('继续查…', 'tu_1'))
42+
.mockResolvedValueOnce({
43+
stopReason: 'end_turn',
44+
content: [{ type: 'text', text: '根据已有结果,结论是这样。' }],
45+
})
46+
47+
const agent = new Agent(
48+
{
49+
name: 'home',
50+
instructions: '助手',
51+
modelId: 'fake',
52+
tools: [{ name: 'web_search', description: 's', input_schema: { type: 'object' } }],
53+
defaultOptions: { maxTokens: 1024 },
54+
},
55+
{ llmOpts: {} },
56+
)
57+
58+
const result = await agent.run(
59+
{ messages: [{ role: 'user', content: '调研一下' }] },
60+
{},
61+
{ maxIterations: 2 },
62+
)
63+
64+
expect(streamMock).toHaveBeenCalledTimes(3)
65+
// 收尾轮不得再挂 tools
66+
const finalReq = streamMock.mock.calls[2][0]
67+
expect(finalReq.tools).toBeUndefined()
68+
expect(result.finalText).toBe('根据已有结果,结论是这样。')
69+
expect(result.hitIterationLimit).toBe(true)
70+
})
71+
72+
it('正常 end_turn 提前结束 → 不额外收尾轮,hitIterationLimit=false', async () => {
73+
streamMock.mockResolvedValueOnce({
74+
stopReason: 'end_turn',
75+
content: [{ type: 'text', text: '直接答完' }],
76+
})
77+
78+
const agent = new Agent(
79+
{
80+
name: 'a',
81+
instructions: 'x',
82+
modelId: 'fake',
83+
defaultOptions: { maxTokens: 1024 },
84+
},
85+
{ llmOpts: {} },
86+
)
87+
88+
const result = await agent.run(
89+
{ messages: [{ role: 'user', content: 'hi' }] },
90+
{},
91+
{ maxIterations: 2 },
92+
)
93+
94+
expect(streamMock).toHaveBeenCalledTimes(1)
95+
expect(result.finalText).toBe('直接答完')
96+
expect(result.hitIterationLimit).toBe(false)
97+
})
98+
})

src/main/orchestrator/agent.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ import { logger } from '../logger'
1818
// Agent 管 context(messages/system/options),tool-use 循环借力 SDK
1919
// (循环在 LLMClient.stream 内由 stop_reason 驱动,这里只编排多轮)。
2020
// maxTokens 从 config.defaultOptions 取(铁律8)。
21+
//
22+
// maxIterations 是防死循环保险丝,不是「任务做完」信号:
23+
// 触顶且末轮仍是 tool_use 时,强制再打一轮无工具收尾,避免半截话当终局。
2124

22-
const DEFAULT_MAX_ITERATIONS = 10
25+
/** 工具循环默认上限(原 10 对多步 shell/检索任务偏紧) */
26+
export const DEFAULT_MAX_ITERATIONS = 32
2327

2428
/**
2529
* 运行时上下文注入(system 末尾):当前本地时间 + 时区。
@@ -60,21 +64,29 @@ export class Agent {
6064
* 1. 组装 messages + system + tools + maxTokens(从 defaultOptions)
6165
* 2. stream LLM,逐 delta 回调
6266
* 3. 若 stop_reason='tool_use' → 执行工具 → 追加 tool_result → 继续循环
63-
* 4. 直至 stop_reason 非 tool_use 或达上限
67+
* 4. 直至 stop_reason 非 tool_use;若触顶仍停在 tool_result 后 → 强制无工具收尾轮
6468
*/
6569
async run(
6670
input: AgentRunInput,
6771
callbacks: AgentRunCallbacks = {},
6872
limits: AgentLimits = {},
69-
): Promise<{ messages: LlmMessage[]; finalText: string; finalThinking: string }> {
73+
): Promise<{
74+
messages: LlmMessage[]
75+
finalText: string
76+
finalThinking: string
77+
/** 是否因 maxIterations 触顶而强制收尾(非正常 end_turn) */
78+
hitIterationLimit: boolean
79+
}> {
7080
const maxIter = limits.maxIterations ?? DEFAULT_MAX_ITERATIONS
7181
let functionCallCount = 0
7282
const messages = [...input.messages]
7383
const tools = this.resolveTools()
7484
const client = getClient(this.config.modelId, this.deps.llmOpts)
85+
const system = injectRuntimeContext(this.config.instructions)
7586

7687
let finalText = ''
7788
let finalThinking = ''
89+
let hitIterationLimit = false
7890

7991
for (let iter = 0; iter < maxIter; iter++) {
8092
if (input.signal?.aborted) {
@@ -84,7 +96,7 @@ export class Agent {
8496
logger.debug('[agent] thinking config:', this.config.thinking)
8597
const response = await client.stream({
8698
model: this.config.modelId,
87-
system: injectRuntimeContext(this.config.instructions),
99+
system,
88100
messages,
89101
tools: tools.length ? tools : undefined,
90102
maxTokens: this.config.defaultOptions.maxTokens, // 铁律8
@@ -166,9 +178,38 @@ export class Agent {
166178
finalText = JSON.stringify({ handoff_to: handoffTarget })
167179
break
168180
}
181+
182+
// 本轮是最后一轮迭代槽且刚执行完工具 → 循环将结束,标记需收尾
183+
if (iter === maxIter - 1) {
184+
hitIterationLimit = true
185+
}
186+
}
187+
188+
// 触顶停在 tool_result 之后:强制无工具收尾,让模型基于已有结果给最终答复
189+
if (hitIterationLimit && needsToolResultFinalization(messages) && !input.signal?.aborted) {
190+
logger.warn(
191+
`[agent:${this.config.name}] 达 maxIterations=${maxIter},强制无工具收尾轮`,
192+
)
193+
const response = await client.stream({
194+
model: this.config.modelId,
195+
system,
196+
messages,
197+
tools: undefined, // 禁止再调工具
198+
maxTokens: this.config.defaultOptions.maxTokens,
199+
temperature: this.config.defaultOptions.temperature,
200+
thinking: this.config.thinking,
201+
signal: input.signal,
202+
onDelta: (delta: LlmDelta) => this.emitDelta(delta, callbacks),
203+
onRetry: (info) => callbacks.onRetry?.(info),
204+
})
205+
messages.push({ role: 'assistant', content: response.content })
206+
const text = extractText(response.content)
207+
if (text) finalText = text
208+
const thinkingText = extractThinking(response.content)
209+
if (thinkingText) finalThinking = thinkingText
169210
}
170211

171-
return { messages, finalText, finalThinking }
212+
return { messages, finalText, finalThinking, hitIterationLimit }
172213
}
173214

174215
private resolveTools() {
@@ -196,4 +237,12 @@ function extractThinking(blocks: LlmContentBlock[]): string {
196237
.map((b) => b.thinking)
197238
.join('')
198239
}
240+
241+
/** 末条是否为 tool_result user 消息(触顶后尚无最终 assistant 答复) */
242+
function needsToolResultFinalization(messages: LlmMessage[]): boolean {
243+
const last = messages[messages.length - 1]
244+
if (!last || last.role !== 'user' || typeof last.content === 'string') return false
245+
return last.content.some((b) => b.type === 'tool_result')
246+
}
247+
199248
export type { LlmResponse }

src/main/tools/builtin/shell.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { logger } from '../../logger'
77
// —— shell_run 工具(P1 · task.md 7.1)——
88
// 让 AI 在用户确认后执行 shell 命令(安装 CLI、跑系统级操作、驱动无 MCP 封装的上游工具)。
99
// 复刻 opencli_run / skill_run_script 的 async spawn 纪律(铁律 23)。
10-
// 安全:approvalMode='always'(P0 闸门执行)+ DANGER_PATTERNS 辅助硬拦(preCheck)+ env 敏感值过滤。
11-
// 正则黑名单可被绕过,真正边界是 P0 的每次确认 + 用户知情。
10+
// 安全:approvalMode='always'(P0 闸门)+ 可选「本会话允许」跳过后续弹窗
11+
// + DANGER_PATTERNS 辅助硬拦(preCheck,会话放行也不绕过)+ env 敏感值过滤。
12+
// 正则黑名单可被绕过,真正边界是 P0 确认(或会话级信任)+ 用户知情。
1213

1314
const DEFAULT_TIMEOUT_MS = 120_000
1415
const MAX_TIMEOUT_SEC = 300

0 commit comments

Comments
 (0)