zen-gitsync 2.17.46 → 2.17.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/cli/ai/agent.js +1342 -1342
  3. package/src/cli/ai/context.js +431 -253
  4. package/src/cli/ai/context.test.js +376 -258
  5. package/src/cli/ai/runtime.test.js +7 -4
  6. package/src/cli/ai/turn.js +166 -166
  7. package/src/config.js +906 -871
  8. package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-CKiVaqrY.js} +1 -1
  9. package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-CRqQYAzh.css} +1 -1
  10. package/src/ui/public/assets/{AgentView-BFRGoIVb.js → AgentView-c1frCBc3.js} +1 -1
  11. package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-IWS9XBUV.js} +2 -2
  12. package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-C_mK2mkS.js} +1 -1
  13. package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-C6I9rQXn.js} +1 -1
  14. package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-2zVHBuqT.js} +1 -1
  15. package/src/ui/public/assets/EditorView-DUJrWJgh.js +1 -0
  16. package/src/ui/public/assets/{EditorView-8Rb4n-Wh.css → EditorView-DqagGedH.css} +1 -1
  17. package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-0Xw0oDen.js} +1 -1
  18. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-BKUspY-s.js} +1 -1
  19. package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-pbwn5n2S.js} +1 -1
  20. package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-DYh6nuuF.js} +1 -1
  21. package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-CsJyUL9v.js} +1 -1
  22. package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-BhFQfAX1.js} +1 -1
  23. package/src/ui/public/assets/{RecentDirectoriesChat-DFljcYFH.js → RecentDirectoriesChat-DM6sRO9L.js} +1 -1
  24. package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-CIZagKkC.js} +1 -1
  25. package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-Dj-B-6WU.js} +1 -1
  26. package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-pIlmQzYw.js} +1 -1
  27. package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-CaefOO1F.js} +1 -1
  28. package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-1--I25vm.js} +1 -1
  29. package/src/ui/public/assets/{WorkbenchView-BSQ87CDi.css → WorkbenchView-BLzcZqy0.css} +1 -1
  30. package/src/ui/public/assets/WorkbenchView-bpzL6m84.js +20 -0
  31. package/src/ui/public/assets/{_plugin-vue_export-helper-Dz1ARW9a.js → _plugin-vue_export-helper-UTW98kcD.js} +5 -5
  32. package/src/ui/public/assets/agentConversations-B9LNft3v.js +8 -0
  33. package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-H-n3ukZ_.js} +1 -1
  34. package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-Br4Eexe7.js} +3 -3
  35. package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-CZdvD4lq.js} +1 -1
  36. package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-4kZ-2J2Y.js} +1 -1
  37. package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
  38. package/src/ui/public/assets/{index-BQxIFopZ.js → index-CD7otmb_.js} +9 -9
  39. package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-CoYCcK3Q.js} +1 -1
  40. package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-DndLP6GO.js} +1 -1
  41. package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-Jt5zzo6D.js} +1 -1
  42. package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-J8tzRTjU.js} +1 -1
  43. package/src/ui/public/assets/{vendor-yntih_em.js → vendor-BD09UDuP.js} +500 -500
  44. package/src/ui/public/assets/vendor-DNiTBOIX.css +1 -0
  45. package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-CkM05TWt.js} +1 -1
  46. package/src/ui/public/index.html +13 -13
  47. package/src/ui/server/routes/config.js +1321 -1314
  48. package/src/ui/server/routes/workbench/agentChat.js +594 -571
  49. package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
  50. package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
  51. package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
  52. package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
  53. package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
  54. package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
3
3
  import { streamChatOnce } from './transport.js'
4
4
  import { normalizeUsage, addUsage } from './telemetry.js'
5
5
  import { runAgentTurn } from './turn.js'
6
- import { buildRequestMessages, repairToolHistory } from './context.js'
6
+ import { buildRequestMessages, repairToolHistory, resolveRequestBudget } from './context.js'
7
7
  import { createAssistantWriter, stripAnsi } from './termui.js'
8
8
 
9
9
  const model = { model: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
@@ -191,12 +191,15 @@ test('failed turns retain the user request and report partial usage honestly', a
191
191
  })
192
192
 
193
193
  test('long tool turns have bounded request history without losing original goals or mutating the transcript', () => {
194
+ // 200 组 × 6000 字符(clip 后)≈ 240 万字符,远超默认预算 —— 数据量必须跟着
195
+ // 默认预算走:60 组在 400k 预算下压根不触发裁剪,断言会假绿(见 context.test.js 同名注释)。
194
196
  const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'original goal' }]
195
- for (let i = 0; i < 60; i++) messages.push({ role: 'assistant', tool_calls: [call(i)] }, { role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
197
+ for (let i = 0; i < 200; i++) messages.push({ role: 'assistant', tool_calls: [call(i)] }, { role: 'tool', tool_call_id: `call${i}`, content: 'data'.repeat(4000) })
196
198
  const before = structuredClone(messages)
199
+ const budget = resolveRequestBudget(undefined)
197
200
  const compact = buildRequestMessages(messages)
198
- assert.ok(compact.length <= 40)
199
- assert.ok(JSON.stringify(compact).length < 90000)
201
+ assert.ok(compact.length <= budget.maxMessages, `期望 <= ${budget.maxMessages} 条,实际 ${compact.length}`)
202
+ assert.ok(JSON.stringify(compact).length < budget.maxChars * 1.1)
200
203
  assert.ok(compact.some(m => m.content === 'original goal'))
201
204
  assert.deepEqual(messages, before)
202
205
  for (let i = 0; i < compact.length; i++) {
@@ -1,166 +1,166 @@
1
- import { executeTool, splitToolOutput, toolMessageContent } from './tools.js'
2
- import { streamChatOnce } from './transport.js'
3
- import { createThinkFilter } from './streamFilter.js'
4
- import { imageToDataUrl } from './images.js'
5
- import { prepareRequestMessages } from './context.js'
6
- import { createTurnStats, addUsage, accumulateSessionStats } from './telemetry.js'
7
- import * as terminal from './termui.js'
8
-
9
- // 「等人」型工具:执行期间会接管输入行、停下来等用户作答(目前只有 ask_user)。
10
- // 这类工具不能转工具 spinner —— ora 每 80ms 在同一行 clearLine + 重绘,会把
11
- // readline 刚画出的提示符(「请输入序号或直接输入回答:」)连同用户正在敲的字
12
- // 一起清掉,屏幕上只剩下一个不断跳秒数的「执行 ask_user...」,看起来就像"选不了"。
13
- // 它自己的交互界面(问题 + 编号选项 + 提示符)就是进度提示,不需要再叠一层。
14
- const INTERACTIVE_TOOLS = new Set(['ask_user'])
15
-
16
- export async function runAgentTurn(state, userText, t, images = [], dependencies = {}) {
17
- const chat = dependencies.chat || streamChatOnce
18
- const baseExecute = dependencies.execute || executeTool
19
- // 扩展(目前是 MCP)工具按名字分流:名字属于扩展就走扩展,否则回落内置工具。
20
- // 必须先用 owns() 判断 —— 不能把所有调用都先喂给扩展再靠 null 回落,
21
- // 那样每个内置工具都会多绕一层,而且扩展端也无法假设"进来的都是自己的"。
22
- // 没装扩展时 owns() 恒为 false,等价于直接调内置工具,零开销。
23
- const extensions = dependencies.extensions || state.extensions
24
- const execute = extensions
25
- ? async (name, args, ctx) => {
26
- if (extensions.owns?.(name)) {
27
- const output = await extensions.execute(name, args, ctx)
28
- if (output !== null && output !== undefined) return output
29
- }
30
- return baseExecute(name, args, ctx)
31
- }
32
- : baseExecute
33
- const ui = { ...terminal, ...dependencies.ui }
34
- const stats = createTurnStats()
35
- const started = performance.now()
36
- const cancelled = () => state.cancelRequested || state.abortController?.signal.aborted
37
- const checkpoint = () => state.persistSession?.()
38
- const recordUsage = usage => {
39
- if (!usage) return
40
- stats.usageRequests++
41
- stats.usage = addUsage(stats.usage, usage)
42
- }
43
- try {
44
- let userContent = userText
45
- if (images.length) {
46
- userContent = [{ type: 'text', text: userText }]
47
- for (const img of images) {
48
- try { userContent.push({ type: 'image_url', image_url: { url: await imageToDataUrl(img.path) } }) }
49
- catch { ui.printWarn(t.imageBadPath(img.path)) }
50
- }
51
- }
52
- state.messages.push({ role: 'user', content: userContent })
53
- await checkpoint()
54
- // 兜底只在 state.maxToolIterations 缺失/非正数时生效,正常路径由 loadConfig 规范化后传入。
55
- // 默认值与 config.js 的 aiMaxToolIterations 保持一致(1000),别让两处悄悄分叉。
56
- const maxIterations = state.maxToolIterations > 0 ? state.maxToolIterations : 1000
57
- for (let iter = 0; iter < maxIterations; iter++) {
58
- if (cancelled()) { stats.status = 'cancelled'; return stats }
59
- const spinner = ui.startSpinner(t.waiting)
60
- const writer = ui.createAssistantWriter({
61
- showThinking: state.showThinking !== false,
62
- thinkingHeader: t.thinkingLabel,
63
- answerHeader: t.answerLabel,
64
- thinkingLimit: state.thinkingMode === 'compact' ? terminal.THINKING_PREVIEW_LINES : Infinity,
65
- thinkingHint: t.thinkingHint,
66
- })
67
- const filter = createThinkFilter()
68
- const render = seg => {
69
- if (seg.content) {
70
- if (seg.content.trim() && stats.firstAnswerMs === null) stats.firstAnswerMs = performance.now() - started
71
- writer.writeContent(seg.content)
72
- } else if (seg.thinking) writer.writeThinking(seg.thinking)
73
- }
74
- let result
75
- const llmStart = performance.now()
76
- stats.requests++
77
- try {
78
- // 统一入口(与 Web 智能体面板共用):有界化 + 旧图片降级 + 消毒,只作用于请求副本,
79
- // 磁盘上的完整会话记录不受影响。
80
- // 预算来自 state.requestBudget(agent.js 按全局配置 aiMaxRequestChars 解析);
81
- // 缺失时 prepareRequestMessages 各自兜底默认值,老调用方不用改。
82
- const messages = prepareRequestMessages(state.messages, { locale: state.locale, ...(state.requestBudget || {}) })
83
- result = await chat({ model: state.model, messages, signal: state.abortController?.signal,
84
- sessionId: state.sessionId, extraTools: extensions?.tools,
85
- onToken: token => {
86
- if (stats.firstTokenMs === null) stats.firstTokenMs = performance.now() - started
87
- if (token.content || (token.thinking && state.showThinking !== false)) spinner.stop()
88
- if (token.thinking) render({ thinking: token.thinking })
89
- if (token.content) filter.feed(token.content).forEach(render)
90
- } })
91
- recordUsage(result.usage)
92
- } catch (err) {
93
- recordUsage(err.usage)
94
- throw err
95
- } finally {
96
- stats.llmMs += performance.now() - llmStart
97
- spinner.stop()
98
- filter.flush().forEach(render)
99
- writer.finish()
100
- }
101
- if (result.aborted || cancelled()) {
102
- if (result.content) state.messages.push({ role: 'assistant', content: result.content })
103
- stats.status = 'cancelled'; return stats
104
- }
105
- const { content, toolCalls } = result
106
- const assistant = { role: 'assistant', content: content || null }
107
- if (result.reasoning) assistant.reasoning_content = result.reasoning
108
- if (!toolCalls.length) {
109
- if (!content?.trim()) throw new Error(t.emptyResponse)
110
- state.messages.push(assistant)
111
- stats.status = 'completed'; return stats
112
- }
113
- // Assign fallback IDs once, so every result references its assistant call.
114
- toolCalls.forEach((call, i) => { if (!call.id) call.id = `call_${iter}_${i}` })
115
- state.messages.push({ ...assistant, tool_calls: toolCalls })
116
- await checkpoint()
117
- for (const tc of toolCalls) {
118
- const name = tc.function?.name || ''
119
- let output
120
- let args
121
- let toolMs // 只有真执行过的工具才有耗时(见下面的渲染判断)
122
- if (cancelled()) {
123
- // 与改动前一致:取消的这次调用只入历史,不渲染结果块
124
- output = 'Cancelled by user; this tool was not executed.'
125
- } else {
126
- try { args = JSON.parse(tc.function?.arguments || '{}') }
127
- catch { output = '错误: 工具参数不是合法 JSON,请修正后重试。' }
128
- if (output === undefined) {
129
- ui.printToolHeader(name, ui.summarizeToolArgs(name, args, { chars: t.chars }), undefined, { locale: state.locale })
130
- const toolSpinner = INTERACTIVE_TOOLS.has(name) ? null : ui.startSpinner(t.toolRunning(name))
131
- const toolStart = performance.now()
132
- stats.toolCalls++
133
- try { output = await execute(name, args, { ...state.ctx, signal: state.abortController?.signal }) }
134
- finally { toolSpinner?.stop(); toolMs = performance.now() - toolStart; stats.toolsMs += toolMs }
135
- } else {
136
- ui.printToolResult(output)
137
- }
138
- }
139
- // 工具结果可能是多模态的(read_image)。渲染层和回灌的消息都只吃**文本部分**,
140
- // 图片要进 content 数组 —— 拆在最后统一做,免得上面每个分支各写一遍。
141
- const { text: outText, images: outImages } = splitToolOutput(output)
142
- if (toolMs !== undefined) {
143
- ui.printToolResult(outText, undefined, toolMs, { full: state.fullTools, locale: state.locale })
144
- // 工具级的收尾渲染(目前只有 update_plan 画计划清单)。
145
- // 走钩子而不是在这里写 if (name === 'x'):再加工具时这一行不用动。
146
- ui.afterTool?.(name, args, outText, { locale: state.locale })
147
- }
148
- state.messages.push({ role: 'tool', tool_call_id: tc.id, name, content: toolMessageContent(outText, outImages) })
149
- await checkpoint()
150
- }
151
- }
152
- stats.status = cancelled() ? 'cancelled' : 'limit'
153
- if (!cancelled()) ui.printWarn(t.toolIterLimit(maxIterations))
154
- } catch (err) {
155
- stats.status = cancelled() ? 'cancelled' : 'failed'
156
- if (!cancelled()) ui.printError(t.llmError(err.message))
157
- } finally {
158
- stats.totalMs = performance.now() - started
159
- stats.completedAt = new Date().toISOString()
160
- state.lastTurnStats = stats
161
- state.sessionStats = accumulateSessionStats(state.sessionStats, stats)
162
- await checkpoint()
163
- ui.printTurnSummary(stats, { locale: state.locale })
164
- }
165
- return stats
166
- }
1
+ import { executeTool, splitToolOutput, toolMessageContent } from './tools.js'
2
+ import { streamChatOnce } from './transport.js'
3
+ import { createThinkFilter } from './streamFilter.js'
4
+ import { imageToDataUrl } from './images.js'
5
+ import { prepareRequestMessages } from './context.js'
6
+ import { createTurnStats, addUsage, accumulateSessionStats } from './telemetry.js'
7
+ import * as terminal from './termui.js'
8
+
9
+ // 「等人」型工具:执行期间会接管输入行、停下来等用户作答(目前只有 ask_user)。
10
+ // 这类工具不能转工具 spinner —— ora 每 80ms 在同一行 clearLine + 重绘,会把
11
+ // readline 刚画出的提示符(「请输入序号或直接输入回答:」)连同用户正在敲的字
12
+ // 一起清掉,屏幕上只剩下一个不断跳秒数的「执行 ask_user...」,看起来就像"选不了"。
13
+ // 它自己的交互界面(问题 + 编号选项 + 提示符)就是进度提示,不需要再叠一层。
14
+ const INTERACTIVE_TOOLS = new Set(['ask_user'])
15
+
16
+ export async function runAgentTurn(state, userText, t, images = [], dependencies = {}) {
17
+ const chat = dependencies.chat || streamChatOnce
18
+ const baseExecute = dependencies.execute || executeTool
19
+ // 扩展(目前是 MCP)工具按名字分流:名字属于扩展就走扩展,否则回落内置工具。
20
+ // 必须先用 owns() 判断 —— 不能把所有调用都先喂给扩展再靠 null 回落,
21
+ // 那样每个内置工具都会多绕一层,而且扩展端也无法假设"进来的都是自己的"。
22
+ // 没装扩展时 owns() 恒为 false,等价于直接调内置工具,零开销。
23
+ const extensions = dependencies.extensions || state.extensions
24
+ const execute = extensions
25
+ ? async (name, args, ctx) => {
26
+ if (extensions.owns?.(name)) {
27
+ const output = await extensions.execute(name, args, ctx)
28
+ if (output !== null && output !== undefined) return output
29
+ }
30
+ return baseExecute(name, args, ctx)
31
+ }
32
+ : baseExecute
33
+ const ui = { ...terminal, ...dependencies.ui }
34
+ const stats = createTurnStats()
35
+ const started = performance.now()
36
+ const cancelled = () => state.cancelRequested || state.abortController?.signal.aborted
37
+ const checkpoint = () => state.persistSession?.()
38
+ const recordUsage = usage => {
39
+ if (!usage) return
40
+ stats.usageRequests++
41
+ stats.usage = addUsage(stats.usage, usage)
42
+ }
43
+ try {
44
+ let userContent = userText
45
+ if (images.length) {
46
+ userContent = [{ type: 'text', text: userText }]
47
+ for (const img of images) {
48
+ try { userContent.push({ type: 'image_url', image_url: { url: await imageToDataUrl(img.path) } }) }
49
+ catch { ui.printWarn(t.imageBadPath(img.path)) }
50
+ }
51
+ }
52
+ state.messages.push({ role: 'user', content: userContent })
53
+ await checkpoint()
54
+ // 兜底只在 state.maxToolIterations 缺失/非正数时生效,正常路径由 loadConfig 规范化后传入。
55
+ // 默认值与 config.js 的 aiMaxToolIterations 保持一致(1000),别让两处悄悄分叉。
56
+ const maxIterations = state.maxToolIterations > 0 ? state.maxToolIterations : 1000
57
+ for (let iter = 0; iter < maxIterations; iter++) {
58
+ if (cancelled()) { stats.status = 'cancelled'; return stats }
59
+ const spinner = ui.startSpinner(t.waiting)
60
+ const writer = ui.createAssistantWriter({
61
+ showThinking: state.showThinking !== false,
62
+ thinkingHeader: t.thinkingLabel,
63
+ answerHeader: t.answerLabel,
64
+ thinkingLimit: state.thinkingMode === 'compact' ? terminal.THINKING_PREVIEW_LINES : Infinity,
65
+ thinkingHint: t.thinkingHint,
66
+ })
67
+ const filter = createThinkFilter()
68
+ const render = seg => {
69
+ if (seg.content) {
70
+ if (seg.content.trim() && stats.firstAnswerMs === null) stats.firstAnswerMs = performance.now() - started
71
+ writer.writeContent(seg.content)
72
+ } else if (seg.thinking) writer.writeThinking(seg.thinking)
73
+ }
74
+ let result
75
+ const llmStart = performance.now()
76
+ stats.requests++
77
+ try {
78
+ // 统一入口(与 Web 智能体面板共用):有界化 + 旧图片降级 + 消毒,只作用于请求副本,
79
+ // 磁盘上的完整会话记录不受影响。
80
+ // 预算来自 state.requestBudget(agent.js 按全局配置 aiMaxRequestTokens 解析);
81
+ // 缺失时 prepareRequestMessages 各自兜底默认值,老调用方不用改。
82
+ const messages = prepareRequestMessages(state.messages, { locale: state.locale, ...(state.requestBudget || {}) })
83
+ result = await chat({ model: state.model, messages, signal: state.abortController?.signal,
84
+ sessionId: state.sessionId, extraTools: extensions?.tools,
85
+ onToken: token => {
86
+ if (stats.firstTokenMs === null) stats.firstTokenMs = performance.now() - started
87
+ if (token.content || (token.thinking && state.showThinking !== false)) spinner.stop()
88
+ if (token.thinking) render({ thinking: token.thinking })
89
+ if (token.content) filter.feed(token.content).forEach(render)
90
+ } })
91
+ recordUsage(result.usage)
92
+ } catch (err) {
93
+ recordUsage(err.usage)
94
+ throw err
95
+ } finally {
96
+ stats.llmMs += performance.now() - llmStart
97
+ spinner.stop()
98
+ filter.flush().forEach(render)
99
+ writer.finish()
100
+ }
101
+ if (result.aborted || cancelled()) {
102
+ if (result.content) state.messages.push({ role: 'assistant', content: result.content })
103
+ stats.status = 'cancelled'; return stats
104
+ }
105
+ const { content, toolCalls } = result
106
+ const assistant = { role: 'assistant', content: content || null }
107
+ if (result.reasoning) assistant.reasoning_content = result.reasoning
108
+ if (!toolCalls.length) {
109
+ if (!content?.trim()) throw new Error(t.emptyResponse)
110
+ state.messages.push(assistant)
111
+ stats.status = 'completed'; return stats
112
+ }
113
+ // Assign fallback IDs once, so every result references its assistant call.
114
+ toolCalls.forEach((call, i) => { if (!call.id) call.id = `call_${iter}_${i}` })
115
+ state.messages.push({ ...assistant, tool_calls: toolCalls })
116
+ await checkpoint()
117
+ for (const tc of toolCalls) {
118
+ const name = tc.function?.name || ''
119
+ let output
120
+ let args
121
+ let toolMs // 只有真执行过的工具才有耗时(见下面的渲染判断)
122
+ if (cancelled()) {
123
+ // 与改动前一致:取消的这次调用只入历史,不渲染结果块
124
+ output = 'Cancelled by user; this tool was not executed.'
125
+ } else {
126
+ try { args = JSON.parse(tc.function?.arguments || '{}') }
127
+ catch { output = '错误: 工具参数不是合法 JSON,请修正后重试。' }
128
+ if (output === undefined) {
129
+ ui.printToolHeader(name, ui.summarizeToolArgs(name, args, { chars: t.chars }), undefined, { locale: state.locale })
130
+ const toolSpinner = INTERACTIVE_TOOLS.has(name) ? null : ui.startSpinner(t.toolRunning(name))
131
+ const toolStart = performance.now()
132
+ stats.toolCalls++
133
+ try { output = await execute(name, args, { ...state.ctx, signal: state.abortController?.signal }) }
134
+ finally { toolSpinner?.stop(); toolMs = performance.now() - toolStart; stats.toolsMs += toolMs }
135
+ } else {
136
+ ui.printToolResult(output)
137
+ }
138
+ }
139
+ // 工具结果可能是多模态的(read_image)。渲染层和回灌的消息都只吃**文本部分**,
140
+ // 图片要进 content 数组 —— 拆在最后统一做,免得上面每个分支各写一遍。
141
+ const { text: outText, images: outImages } = splitToolOutput(output)
142
+ if (toolMs !== undefined) {
143
+ ui.printToolResult(outText, undefined, toolMs, { full: state.fullTools, locale: state.locale })
144
+ // 工具级的收尾渲染(目前只有 update_plan 画计划清单)。
145
+ // 走钩子而不是在这里写 if (name === 'x'):再加工具时这一行不用动。
146
+ ui.afterTool?.(name, args, outText, { locale: state.locale })
147
+ }
148
+ state.messages.push({ role: 'tool', tool_call_id: tc.id, name, content: toolMessageContent(outText, outImages) })
149
+ await checkpoint()
150
+ }
151
+ }
152
+ stats.status = cancelled() ? 'cancelled' : 'limit'
153
+ if (!cancelled()) ui.printWarn(t.toolIterLimit(maxIterations))
154
+ } catch (err) {
155
+ stats.status = cancelled() ? 'cancelled' : 'failed'
156
+ if (!cancelled()) ui.printError(t.llmError(err.message))
157
+ } finally {
158
+ stats.totalMs = performance.now() - started
159
+ stats.completedAt = new Date().toISOString()
160
+ state.lastTurnStats = stats
161
+ state.sessionStats = accumulateSessionStats(state.sessionStats, stats)
162
+ await checkpoint()
163
+ ui.printTurnSummary(stats, { locale: state.locale })
164
+ }
165
+ return stats
166
+ }