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.
- package/package.json +1 -1
- package/src/cli/ai/agent.js +1342 -1342
- package/src/cli/ai/context.js +431 -253
- package/src/cli/ai/context.test.js +376 -258
- package/src/cli/ai/runtime.test.js +7 -4
- package/src/cli/ai/turn.js +166 -166
- package/src/config.js +906 -871
- package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-CKiVaqrY.js} +1 -1
- package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-CRqQYAzh.css} +1 -1
- package/src/ui/public/assets/{AgentView-BFRGoIVb.js → AgentView-c1frCBc3.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-IWS9XBUV.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-C_mK2mkS.js} +1 -1
- package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-C6I9rQXn.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-2zVHBuqT.js} +1 -1
- package/src/ui/public/assets/EditorView-DUJrWJgh.js +1 -0
- package/src/ui/public/assets/{EditorView-8Rb4n-Wh.css → EditorView-DqagGedH.css} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-0Xw0oDen.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-BKUspY-s.js} +1 -1
- package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-pbwn5n2S.js} +1 -1
- package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-DYh6nuuF.js} +1 -1
- package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-CsJyUL9v.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-BhFQfAX1.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-DFljcYFH.js → RecentDirectoriesChat-DM6sRO9L.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-CIZagKkC.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-Dj-B-6WU.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-pIlmQzYw.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-CaefOO1F.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-1--I25vm.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-BSQ87CDi.css → WorkbenchView-BLzcZqy0.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-bpzL6m84.js +20 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-Dz1ARW9a.js → _plugin-vue_export-helper-UTW98kcD.js} +5 -5
- package/src/ui/public/assets/agentConversations-B9LNft3v.js +8 -0
- package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-H-n3ukZ_.js} +1 -1
- package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-Br4Eexe7.js} +3 -3
- package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-CZdvD4lq.js} +1 -1
- package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-4kZ-2J2Y.js} +1 -1
- package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
- package/src/ui/public/assets/{index-BQxIFopZ.js → index-CD7otmb_.js} +9 -9
- package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-CoYCcK3Q.js} +1 -1
- package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-DndLP6GO.js} +1 -1
- package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-Jt5zzo6D.js} +1 -1
- package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-J8tzRTjU.js} +1 -1
- package/src/ui/public/assets/{vendor-yntih_em.js → vendor-BD09UDuP.js} +500 -500
- package/src/ui/public/assets/vendor-DNiTBOIX.css +1 -0
- package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-CkM05TWt.js} +1 -1
- package/src/ui/public/index.html +13 -13
- package/src/ui/server/routes/config.js +1321 -1314
- package/src/ui/server/routes/workbench/agentChat.js +594 -571
- package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
- package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
- package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
- package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
- 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 <
|
|
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 <=
|
|
199
|
-
assert.ok(JSON.stringify(compact).length <
|
|
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++) {
|
package/src/cli/ai/turn.js
CHANGED
|
@@ -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 按全局配置
|
|
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
|
+
}
|