llm-api-gateway-cli 1.0.0

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 (50) hide show
  1. package/.env.example +10 -0
  2. package/README.md +1127 -0
  3. package/cli-agent.js +666 -0
  4. package/cli-anthropic.js +236 -0
  5. package/cli-claude-code.js +317 -0
  6. package/cli-openai.js +212 -0
  7. package/completions/_llm-api-gateway-cli +65 -0
  8. package/completions/llm-api-gateway-cli.bash +64 -0
  9. package/completions/llm-api-gateway-cli.fish +43 -0
  10. package/images/chat.png +0 -0
  11. package/images/settings.png +0 -0
  12. package/images/task.png +0 -0
  13. package/lib/agent.js +607 -0
  14. package/lib/commands.js +468 -0
  15. package/lib/common.js +196 -0
  16. package/lib/config.js +70 -0
  17. package/lib/configcmd.js +230 -0
  18. package/lib/hub.js +1494 -0
  19. package/lib/jsonstore.js +49 -0
  20. package/lib/mcp.js +375 -0
  21. package/lib/memory.js +109 -0
  22. package/lib/plandoc.js +178 -0
  23. package/lib/pricing.js +52 -0
  24. package/lib/runner.js +234 -0
  25. package/lib/runstore.js +96 -0
  26. package/lib/secrets.js +198 -0
  27. package/lib/sessionstore.js +269 -0
  28. package/lib/settings.js +517 -0
  29. package/lib/tasksession.js +594 -0
  30. package/lib/taskstore.js +740 -0
  31. package/lib/tools.js +927 -0
  32. package/package.json +55 -0
  33. package/public/app.js +1055 -0
  34. package/public/index.html +167 -0
  35. package/public/manual.css +215 -0
  36. package/public/manual.html +381 -0
  37. package/public/manual.js +186 -0
  38. package/public/models.js +121 -0
  39. package/public/render.js +250 -0
  40. package/public/styles.css +955 -0
  41. package/public/task-slash.js +493 -0
  42. package/public/task.css +739 -0
  43. package/public/task.html +220 -0
  44. package/public/task.js +3127 -0
  45. package/public/theme.js +91 -0
  46. package/public/tint.js +261 -0
  47. package/scripts/install.ps1 +537 -0
  48. package/scripts/install.sh +510 -0
  49. package/server.js +14 -0
  50. package/task-server.js +15 -0
package/lib/agent.js ADDED
@@ -0,0 +1,607 @@
1
+ /**
2
+ * 模式五 · 任务模式的 Agent 循环
3
+ *
4
+ * 一轮任务 = 反复「模型 → 工具 → 回填结果 → 再问模型」,直到模型不再要求调用工具。
5
+ *
6
+ * 写入类工具怎么处理由 run.mode 决定:
7
+ * manual —— 挂起等用户批准(默认);
8
+ * auto —— 直接执行,不再打断用户(界面仍逐条显示改了什么);
9
+ * plan —— 只提供只读工具,先出计划、不动文件。
10
+ */
11
+
12
+ import { availableTools, isWriteTool, isFileWriteTool, executeTool, describeCall, writePreview, TOOLS } from './tools.js';
13
+ import { refreshMcpTools, mcpHeadersForChat, mcpToolSpecs } from './mcp.js';
14
+ import { errorText, sessionBaggage } from './common.js';
15
+
16
+ /**
17
+ * 单次任务最多几轮工具调用。这里数的是「模型轮次」,不是工具次数 ——
18
+ * 一轮里模型可以并行发多个调用,所以 8 轮 / 40 轮对真实任务来说都太紧(实测一次代码调研就用掉 8 轮 / 21 次调用)。
19
+ * 会话、任务、挂起态都已落盘,撞线后能带着全部历史接着跑,轮次上限只是「防跑飞」的兜底而非省钱闸门,
20
+ * 所以放宽到 100;真要收紧用 --max-steps / TASK_MAX_STEPS / config.json 的 maxSteps 覆盖。
21
+ * 撞到上限也不是「不许干活」:会额外走一轮收尾给出结论,并且页面给一个「继续执行」按钮接着干。
22
+ */
23
+ const DEFAULT_MAX_STEPS = 100;
24
+ const APPROVAL_TTL_MS = 60 * 60 * 1000; // 挂起态保留 60 分钟(落盘,刷新/重启都还在)
25
+ const MAX_RUNS = 40;
26
+
27
+ /** 上限可按请求覆盖(cfg.maxSteps),非法值一律回落到默认 */
28
+ export function resolveMaxSteps(v) {
29
+ // 只接受数字或纯数字字符串:布尔值会被 Number() 悄悄变成 1(--max-steps 当成开关时踩过)
30
+ if (typeof v !== 'number' && typeof v !== 'string') return DEFAULT_MAX_STEPS;
31
+ if (typeof v === 'string' && !/^\s*\d+\s*$/.test(v)) return DEFAULT_MAX_STEPS;
32
+ const n = Number(v);
33
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_MAX_STEPS;
34
+ }
35
+
36
+ export const MODES = ['manual', 'auto', 'plan'];
37
+ export const MODE_LABELS = { manual: '手动', auto: '自动', plan: '计划' };
38
+
39
+ export function normalizeMode(mode) {
40
+ return MODES.includes(mode) ? mode : 'manual';
41
+ }
42
+
43
+ /** 计划模式只给只读工具 —— 模型拿不到写入工具,就没法动文件 */
44
+ export function toolsForMode(mode) {
45
+ const list = availableTools(); // bash 未开启时这里就不带它,别让模型去试
46
+ return normalizeMode(mode) === 'plan' ? list.filter((t) => !isWriteTool(t.function.name)) : list;
47
+ }
48
+
49
+ /* ---------- 上游请求重试 ---------- */
50
+
51
+ /** 只重试「再试一次有意义」的:限流、网关类错误、超时。4xx 业务错误重试纯属浪费 token */
52
+ const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
53
+ const MAX_FETCH_ATTEMPTS = 3;
54
+
55
+ const sleep = (ms, signal) =>
56
+ new Promise((resolve, reject) => {
57
+ if (signal?.aborted) return reject(Object.assign(new Error('已中止'), { name: 'AbortError' }));
58
+ const t = setTimeout(resolve, ms);
59
+ signal?.addEventListener?.(
60
+ 'abort',
61
+ () => {
62
+ clearTimeout(t);
63
+ reject(Object.assign(new Error('已中止'), { name: 'AbortError' }));
64
+ },
65
+ { once: true },
66
+ );
67
+ });
68
+
69
+ function backoffMs(attempt) {
70
+ const base = 300 * 2 ** attempt;
71
+ return base + Math.floor(Math.random() * 200); // 加抖动,避免多个请求同时重试又同时撞上
72
+ }
73
+
74
+ function retryAfterMs(resp) {
75
+ const raw = resp.headers?.get?.('retry-after');
76
+ if (!raw) return null;
77
+ const secs = Number(raw);
78
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, 10000);
79
+ const at = Date.parse(raw);
80
+ return Number.isFinite(at) ? Math.min(Math.max(at - Date.now(), 0), 10000) : null;
81
+ }
82
+
83
+ /**
84
+ * 带重试的 fetch。
85
+ *
86
+ * 关键前提:请求体是字符串(已经 JSON.stringify 过的),所以重发不会遇到「流已被消费」的问题。
87
+ * 中止信号必须原样生效 —— 用户按 Ctrl+C 时不能还在傻等退避。
88
+ */
89
+ async function fetchWithRetry(url, init, { signal, attempts = MAX_FETCH_ATTEMPTS } = {}) {
90
+ let lastError = null;
91
+ for (let i = 0; i < attempts; i++) {
92
+ if (signal?.aborted) throw Object.assign(new Error('已中止'), { name: 'AbortError' });
93
+ try {
94
+ const resp = await fetch(url, init);
95
+ if (!RETRYABLE_STATUS.has(resp.status) || i === attempts - 1) return resp;
96
+ // 把响应体读掉,免得连接挂着;随后按 Retry-After(若有)或指数退避等待
97
+ try {
98
+ await resp.text();
99
+ } catch {
100
+ /* 忽略 */
101
+ }
102
+ await sleep(retryAfterMs(resp) ?? backoffMs(i), signal);
103
+ } catch (e) {
104
+ if (signal?.aborted || e?.name === 'AbortError') throw e;
105
+ lastError = e;
106
+ if (i === attempts - 1) throw e;
107
+ await sleep(backoffMs(i), signal);
108
+ }
109
+ }
110
+ throw lastError || new Error('请求失败');
111
+ }
112
+
113
+ /** 挂起态优先放内存;配了落盘的话,重启后还能从磁盘捞回来 */
114
+ let persistence = null;
115
+
116
+ /** 装上挂起态持久化(Web 用;CLI 不装就是纯内存,行为不变) */
117
+ export function setRunPersistence(store) {
118
+ persistence = store;
119
+ }
120
+
121
+ const runs = new Map();
122
+
123
+ export function pruneRuns() {
124
+ const now = Date.now();
125
+ for (const [id, run] of runs) if (now - run.touchedAt > APPROVAL_TTL_MS) runs.delete(id);
126
+ while (runs.size > MAX_RUNS) runs.delete(runs.keys().next().value); // Map 保持插入序,淘汰最早的
127
+ // 磁盘那一份只让它自己按 TTL 清,**不往内存里搬** ——
128
+ // 否则每次 takeRun 都要把所有挂起文件读一遍,而且「内存里没有就去磁盘找」这条路永远是死的
129
+ if (persistence) {
130
+ try {
131
+ persistence.list(APPROVAL_TTL_MS);
132
+ } catch {
133
+ /* 磁盘读不动就只当内存用 */
134
+ }
135
+ }
136
+ }
137
+
138
+ export function saveRun(run) {
139
+ pruneRuns();
140
+ run.touchedAt = Date.now();
141
+ runs.set(run.id, run);
142
+ if (persistence) {
143
+ try {
144
+ persistence.write(run);
145
+ } catch {
146
+ /* 落盘失败不影响本次批准 */
147
+ }
148
+ }
149
+ return run;
150
+ }
151
+
152
+ /** 取一个挂起态;内存里没有就去磁盘找(服务重启后的情况) */
153
+ export function takeRun(id) {
154
+ pruneRuns();
155
+ let run = runs.get(id);
156
+ if (!run && persistence) {
157
+ try {
158
+ run = persistence.read(id);
159
+ } catch {
160
+ run = null;
161
+ }
162
+ }
163
+ if (!run) return null;
164
+ // 磁盘上可能躺着一个已经过期的(进程重启后内存里没有它的记录)
165
+ if (Date.now() - (run.touchedAt || 0) > APPROVAL_TTL_MS) {
166
+ dropRun(id);
167
+ return null;
168
+ }
169
+ run.touchedAt = Date.now();
170
+ runs.set(id, run);
171
+ // touchedAt 变了要写回,否则 TTL 会按旧时间把你正在处理的任务清掉
172
+ if (persistence) {
173
+ try {
174
+ persistence.write(run);
175
+ } catch {
176
+ /* 忽略 */
177
+ }
178
+ }
179
+ return run;
180
+ }
181
+
182
+ export function dropRun(id) {
183
+ runs.delete(id);
184
+ if (persistence) {
185
+ try {
186
+ persistence.drop(id);
187
+ } catch {
188
+ /* 忽略 */
189
+ }
190
+ }
191
+ }
192
+
193
+ /** 当前所有待批准的挂起态(页面刷新后用它把批准卡片恢复出来) */
194
+ export function listRuns() {
195
+ pruneRuns();
196
+ // 内存是缓存、磁盘是底账:两个来源合起来才是「当前所有挂起态」。
197
+ // 重启之后内存是空的,全靠这里从磁盘读回来。
198
+ const merged = new Map();
199
+ if (persistence) {
200
+ try {
201
+ for (const run of persistence.list(APPROVAL_TTL_MS)) merged.set(run.id, run);
202
+ } catch {
203
+ /* 磁盘读不动就只能报内存里的 */
204
+ }
205
+ }
206
+ for (const [id, run] of runs) merged.set(id, run);
207
+ return [...merged.values()].map((r) => ({
208
+ runId: r.id,
209
+ taskId: r.taskId || null,
210
+ mode: normalizeMode(r.mode),
211
+ workingDir: r.workingDir,
212
+ touchedAt: r.touchedAt,
213
+ pending: r.pending
214
+ ? {
215
+ id: r.pending.id,
216
+ name: r.pending.name,
217
+ args: r.pending.args,
218
+ label: describeCall(r.pending.name, r.pending.args),
219
+ }
220
+ : null,
221
+ preview: r.pendingPreview || null,
222
+ }));
223
+ }
224
+
225
+ export function runStats() {
226
+ return { pending: runs.size, max: MAX_RUNS, ttlMinutes: APPROVAL_TTL_MS / 60000 };
227
+ }
228
+
229
+ /** 工具执行包一层:失败也要作为工具结果回到模型,让它自己纠正 */
230
+ async function safeExec(run, call, allowWrite) {
231
+ try {
232
+ // 把「这一步是哪个任务节点」带给 MCP 调用(见 mcp.js 的 PLAN_NODE_HEADER)。
233
+ // 节点是网关在**上一轮对话响应头**里回带的;本机工具用不到这个字段。
234
+ // 任务页/交互式聊天都走同一口径:会话标识用 taskId || id,与对话请求一致,
235
+ // 否则同一次会话的对话与工具调用会被网关归到两棵树上。
236
+ const result = await executeTool(call.name, call.args, run.workingDir, {
237
+ allowWrite,
238
+ mcp: { planNode: run.planNode, sessionId: run.taskId || run.id },
239
+ });
240
+ return { ok: true, ...result };
241
+ } catch (e) {
242
+ const message = e?.message || String(e);
243
+ return { ok: false, summary: `失败:${message}`, content: `工具执行失败:${message}` };
244
+ }
245
+ }
246
+
247
+ /** 预览要按工具类型算(apply_patch 要看补丁后的结果,bash 要看命令),所以带上工具名 */
248
+ const previewOf = (run, call) => writePreview(run.workingDir, { ...(call.args || {}), __tool: call.name });
249
+
250
+ /**
251
+ * 最后一条「带 tool_calls 的 assistant 消息」里**还没有被回应**的调用。
252
+ *
253
+ * 恢复路径靠它拿到「这一批里还没跑的部分」。从 `run.messages` 现算、而不是另存一份进度,
254
+ * 是因为 messages 本来就已经落盘(`sessionstore` / `taskstore` 都存它),所以进程重启、
255
+ * 页面刷新之后照样能重建——不会出现「进度存了、重启后和消息对不上」。
256
+ *
257
+ * 它同时是**自愈**的:无论什么原因让某个 tool_call 少了回应,下一轮都会补上,
258
+ * 而不是等网关回一个 400 把整条任务打死。
259
+ */
260
+ function unansweredCalls(run) {
261
+ const msgs = run.messages || [];
262
+ let idx = -1;
263
+ for (let i = msgs.length - 1; i >= 0; i--) {
264
+ const m = msgs[i];
265
+ if (m && m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) { idx = i; break; }
266
+ }
267
+ if (idx < 0) return [];
268
+ const answered = new Set();
269
+ for (let i = idx + 1; i < msgs.length; i++) {
270
+ const m = msgs[i];
271
+ if (m && m.role === 'tool' && m.tool_call_id) answered.add(m.tool_call_id);
272
+ }
273
+ return msgs[idx].tool_calls
274
+ .filter((c) => c && c.id && !answered.has(c.id))
275
+ // 消息里的回传形态是 {id, type, function:{name, arguments}};执行要的是 {id, name, args, argsRaw}
276
+ .map((c) => {
277
+ const argsRaw = c.function?.arguments || '{}';
278
+ let args = {};
279
+ try { args = argsRaw ? JSON.parse(argsRaw) : {}; } catch { args = {}; }
280
+ return { id: c.id, name: c.function?.name || '', argsRaw, args };
281
+ })
282
+ .filter((c) => c.name);
283
+ }
284
+
285
+ /**
286
+ * 跑完**一批**工具调用(一条 assistant 消息里的全部 tool_calls)。
287
+ *
288
+ * 返回 `{ status: 'completed' }`;手动模式下遇到需要批准的工具时返回
289
+ * `{ status: 'awaiting-approval', pending, preview }`。
290
+ *
291
+ * **为什么必须整批一起管**:这批调用属于**同一条** assistant 消息,协议要求每个
292
+ * `tool_calls[i]` 都有一个对应的 `role:'tool'` 消息。原先手动模式分支在循环里直接
293
+ * `return`,于是**排在待批准调用后面的调用既不执行、也不回应**;恢复时又只补了那一个
294
+ * 就开下一轮 ⇒ 请求里 tool_calls 数与 tool 消息数不等,网关(正确地)拒绝:
295
+ * `400 An assistant message with 'tool_calls' must be followed by tool messages ...`
296
+ *
297
+ * 实测:一批计划了 8 个调用(`list_dir` + `maps_geo` + `maps_weather` + 5 个
298
+ * `text_search`),只跑了 2 个就 400,整条任务作废——所以恢复时必须先补完这一批
299
+ * (见 `unansweredCalls`)。
300
+ */
301
+ async function runToolBatch({ run, send, calls, mode }) {
302
+ for (const call of calls) {
303
+ send({ type: 'tool_call', id: call.id, name: call.name, args: call.args, label: describeCall(call.name, call.args) });
304
+
305
+ // 计划模式:写入类工具根本没给模型,这里再兜一道。用 isWriteTool(含 MCP)以便与
306
+ // toolsForMode 的过滤口径**保持一致**,否则会出现「清单里没有、执行时却放行」。
307
+ if (mode === 'plan' && isWriteTool(call.name)) {
308
+ const refusal = '当前是计划模式,不能修改任何文件。请只用读取类工具调研,然后输出一份可执行的计划。';
309
+ send({ type: 'tool_result', id: call.id, name: call.name, ok: false, summary: '计划模式已拒绝写入', content: refusal, blocked: 'plan' });
310
+ run.messages.push({ role: 'tool', tool_call_id: call.id, content: refusal });
311
+ continue;
312
+ }
313
+
314
+ // 只有**改本机文件**的工具需要批准;MCP 调用直接执行(见 tools.js 的 isFileWriteTool)
315
+ if (isFileWriteTool(call.name)) {
316
+ if (mode === 'auto') {
317
+ // 自动模式:直接执行,但把「改了什么」照样发给前端逐条显示
318
+ const result = await safeExec(run, call, true);
319
+ send({
320
+ type: 'tool_result',
321
+ id: call.id,
322
+ name: call.name,
323
+ ok: result.ok,
324
+ summary: result.summary,
325
+ content: result.content,
326
+ approved: true,
327
+ auto: true,
328
+ preview: previewOf(run, call),
329
+ });
330
+ run.messages.push({ role: 'tool', tool_call_id: call.id, content: result.content });
331
+ continue;
332
+ }
333
+
334
+ // 手动模式:挂起等用户批准;运行态交给调用方保存
335
+ run.pending = call;
336
+ run.pendingPreview = previewOf(run, call);
337
+ return { status: 'awaiting-approval', pending: call, preview: run.pendingPreview };
338
+ }
339
+
340
+ const result = await safeExec(run, call, false);
341
+ send({
342
+ type: 'tool_result',
343
+ id: call.id,
344
+ name: call.name,
345
+ ok: result.ok,
346
+ summary: result.summary,
347
+ content: result.content,
348
+ });
349
+ run.messages.push({ role: 'tool', tool_call_id: call.id, content: result.content });
350
+ }
351
+
352
+ return { status: 'completed' };
353
+ }
354
+
355
+ /**
356
+ * 跑一次模型调用,把增量转发给前端,并累积正文与工具调用。
357
+ * 工具调用在流里是分片下发的(name 一次、arguments 多次),需要按 index 拼接。
358
+ */
359
+ async function streamModelTurn({ cfg, run, send, signal, allowTools = true }) {
360
+ const payload = {
361
+ model: run.model,
362
+ messages: run.messages,
363
+ stream: true,
364
+ stream_options: { include_usage: true },
365
+ };
366
+ if (allowTools) {
367
+ // 本地文件/bash 工具没有工作目录就无法执行,不能声明;但**远端 MCP 工具不需要根目录**,
368
+ // 所以任务模式/页面(会话默认没有工作目录)照样能把 MCP 工具给模型用。
369
+ const allowed = new Set(toolsForMode(run.mode).map((t) => t.function.name));
370
+ const tools = run.workingDir
371
+ ? toolsForMode(run.mode)
372
+ : mcpToolSpecs(new Set(TOOLS.map((t) => t.function.name))).filter((t) => allowed.has(t.function.name));
373
+ if (tools.length) {
374
+ payload.tools = tools;
375
+ payload.tool_choice = 'auto';
376
+ }
377
+ }
378
+ if (Number.isFinite(run.temperature)) payload.temperature = run.temperature;
379
+ if (Number.isFinite(run.maxTokens) && run.maxTokens > 0) payload.max_tokens = run.maxTokens;
380
+
381
+ // 告诉网关「这次请求属于哪个任务/会话」:任务页用 taskId(它就是那次对话,
382
+ // 一次对话的所有轮次共用同一个),交互式聊天用 session.id。
383
+ // 网关据此把一趟任务的 N 次调用聚成一棵任务树,而不是靠指纹去猜。
384
+ const headers = { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` };
385
+ const baggage = sessionBaggage(run.taskId || run.id);
386
+ if (baggage) headers.baggage = baggage;
387
+ // CLI 自己执行 MCP 工具时,若后台绑定模式是 loop,两边会各执行一次同一个工具
388
+ // (等于白烧一次第三方配额)。这条头只让网关这一轮别插手,不改后台配置。
389
+ Object.assign(headers, mcpHeadersForChat());
390
+
391
+ const resp = await fetchWithRetry(
392
+ `${cfg.baseUrl}/v1/chat/completions`,
393
+ {
394
+ method: 'POST',
395
+ headers,
396
+ body: JSON.stringify(payload),
397
+ signal,
398
+ },
399
+ { signal },
400
+ );
401
+ if (!resp.ok) throw Object.assign(new Error(await errorText(resp)), { status: resp.status });
402
+
403
+ // 网关回带的「这一轮是哪个任务节点」(`log<账本行 id>`)。本轮的工具调用把它
404
+ // 原样带回 /v1/mcp/call,那次调用才能被**精确**挂到这一步上;拿不到就退回
405
+ // 会话级口径——功能照常,只是页面上挂接精度差一档。
406
+ // 老版本网关没有这个头:`.get()` 返回 null,这里就是空串,什么都不变。
407
+ run.planNode = String(
408
+ (typeof resp.headers?.get === 'function' ? resp.headers.get('x-dsh-plan-node') : '') ?? '',
409
+ );
410
+
411
+ const slots = [];
412
+ let content = '';
413
+ let sawReasoning = false;
414
+ let finish = null;
415
+
416
+ const reader = resp.body.getReader();
417
+ const decoder = new TextDecoder();
418
+ let buf = '';
419
+ for (;;) {
420
+ const { done, value } = await reader.read();
421
+ if (done) break;
422
+ buf += decoder.decode(value, { stream: true });
423
+ const lines = buf.split('\n');
424
+ buf = lines.pop() ?? '';
425
+ for (const line of lines) {
426
+ const t = line.trim();
427
+ if (!t.startsWith('data:')) continue;
428
+ const data = t.slice(5).trim();
429
+ if (!data || data === '[DONE]') continue;
430
+ let chunk;
431
+ try {
432
+ chunk = JSON.parse(data);
433
+ } catch {
434
+ continue;
435
+ }
436
+ const choice = chunk?.choices?.[0];
437
+ const delta = choice?.delta;
438
+ if (delta?.reasoning_content) {
439
+ sawReasoning = true;
440
+ send({ type: 'reasoning', text: delta.reasoning_content });
441
+ }
442
+ if (delta?.content) {
443
+ content += delta.content;
444
+ send({ type: 'delta', text: delta.content });
445
+ }
446
+ if (Array.isArray(delta?.tool_calls)) {
447
+ for (const tc of delta.tool_calls) {
448
+ const i = Number.isInteger(tc.index) ? tc.index : slots.length;
449
+ if (!slots[i]) slots[i] = { id: '', name: '', argsRaw: '' };
450
+ if (tc.id) slots[i].id = tc.id;
451
+ if (tc.function?.name) slots[i].name += tc.function.name;
452
+ if (tc.function?.arguments) slots[i].argsRaw += tc.function.arguments;
453
+ }
454
+ }
455
+ if (choice?.finish_reason) finish = choice.finish_reason;
456
+ if (chunk?.usage) {
457
+ run.usage.prompt += chunk.usage.prompt_tokens ?? 0;
458
+ run.usage.completion += chunk.usage.completion_tokens ?? 0;
459
+ send({
460
+ type: 'usage',
461
+ usage: {
462
+ prompt: chunk.usage.prompt_tokens ?? null,
463
+ completion: chunk.usage.completion_tokens ?? null,
464
+ total: chunk.usage.total_tokens ?? null,
465
+ },
466
+ });
467
+ }
468
+ }
469
+ }
470
+
471
+ const calls = slots
472
+ .filter((s) => s && s.name)
473
+ .map((s) => {
474
+ let args = {};
475
+ try {
476
+ args = s.argsRaw ? JSON.parse(s.argsRaw) : {};
477
+ } catch {
478
+ args = {}; // 参数不是合法 JSON,交给工具自己报错
479
+ }
480
+ return { id: s.id || `call_${Math.random().toString(36).slice(2, 10)}`, name: s.name, argsRaw: s.argsRaw || '{}', args };
481
+ });
482
+
483
+ // 模型这一轮什么都没产出(既没正文也没工具调用)。
484
+ // 光看 finish_reason 会漏:思考模型把预算耗在思维链上时,finish 常常是 'stop' 而不是 'length',
485
+ // 结果页面「读完文件就没下文了」却不给任何解释。所以只要空轮就提示,并带上 finish 便于排查。
486
+ if (!content && !calls.length) {
487
+ send({
488
+ type: 'notice',
489
+ text: sawReasoning
490
+ ? `上游只返回了思维链、没产出正文(finish_reason=${finish || '未知'})—— 通常是 max_tokens 太小,被思考过程耗尽了,调大后再试`
491
+ : `上游结束但没有返回任何正文(finish_reason=${finish || '未知'})`,
492
+ });
493
+ }
494
+
495
+ return { content, calls, finish };
496
+ }
497
+
498
+ /**
499
+ * 主循环。返回 { status: 'done' | 'awaiting-approval' | 'aborted', pending? }。
500
+ * 调用方负责 SSE 的开头与结尾。
501
+ */
502
+ export async function runAgent({ cfg, run, send, signal }) {
503
+ const mode = normalizeMode(run.mode);
504
+ run.mode = mode;
505
+
506
+ // MCP 工具清单按**当次密钥**从网关取(best-effort,失败静默):
507
+ // 网关没开 MCP / 断网 / 密钥无效时,这里不会有任何工具,行为与改动前一致。
508
+ //
509
+ // 计划模式下 MCP 工具会被 toolsForMode 当成写工具过滤掉,取了也用不上,跳过;
510
+ // 其余模式一律取(best-effort、静默失败、60 秒 TTL 内不会重复出网)。
511
+ // 注意:**不能**再拿 workingDir 当闸门 —— 任务模式/页面的会话默认没有工作目录,
512
+ // 之前这样写等于让整条任务模式用不上 MCP。
513
+ if (run.mode !== 'plan') {
514
+ await refreshMcpTools({ baseUrl: cfg?.baseUrl, key: cfg?.key });
515
+ }
516
+
517
+ const MAX_STEPS = resolveMaxSteps(cfg?.maxSteps ?? run.maxSteps);
518
+
519
+ for (let step = 0; step < MAX_STEPS; step++) {
520
+ // 让页面知道「模型正在生成第几轮」,否则最后一次工具调用之后就是一段没有任何状态的长静默
521
+ send({ type: 'turn_start', step: step + 1, max: MAX_STEPS });
522
+ let turn;
523
+ try {
524
+ turn = await streamModelTurn({ cfg, run, send, signal });
525
+ } catch (e) {
526
+ if (signal?.aborted) return { status: 'aborted' };
527
+ throw e;
528
+ }
529
+
530
+ if (!turn.calls.length) {
531
+ run.messages.push({ role: 'assistant', content: turn.content || '' });
532
+ // 计划模式下模型给出计划就结束了,前端据此显示「按计划执行」
533
+ return { status: 'done', planReady: mode === 'plan' && Boolean(turn.content) };
534
+ }
535
+
536
+ // 带工具调用的 assistant 消息必须原样回传(arguments 用原始字符串)
537
+ run.messages.push({
538
+ role: 'assistant',
539
+ content: turn.content || null,
540
+ tool_calls: turn.calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: c.argsRaw } })),
541
+ });
542
+
543
+ if (turn.content) send({ type: 'assistant_text_end' });
544
+
545
+ const batch = await runToolBatch({ run, send, calls: turn.calls, mode });
546
+ if (batch.status === 'awaiting-approval') return batch;
547
+ }
548
+
549
+ // 用满工具轮数:不要「读完一堆文件然后戛然而止」,最后一次不给工具、明确要求它收尾,
550
+ // 这样用户至少能拿到一份基于已读内容的结论。
551
+ send({ type: 'turn_start', step: MAX_STEPS + 1, max: MAX_STEPS, wrapUp: true });
552
+ run.messages.push({
553
+ role: 'user',
554
+ content: '已经用完工具调用次数了,现在不要再调用任何工具。请直接基于你上面已经读到的内容给出结论;信息不足的地方就明说还缺什么。',
555
+ });
556
+ try {
557
+ const last = await streamModelTurn({ cfg, run, send, signal, allowTools: false });
558
+ if (last.content) {
559
+ run.messages.push({ role: 'assistant', content: last.content });
560
+ send({ type: 'notice', text: `已用完 ${MAX_STEPS} 轮工具,以上基于已读内容。` });
561
+ return { status: 'done', hitLimit: true, wrappedUp: true };
562
+ }
563
+ } catch (e) {
564
+ if (signal?.aborted) return { status: 'aborted' };
565
+ if (cfg.verbose) console.error(`[agent] 收尾轮失败:${e?.message || e}`);
566
+ }
567
+
568
+ send({ type: 'notice', text: `已达上限(${MAX_STEPS} 轮),可继续追问。` });
569
+ return { status: 'done', hitLimit: true };
570
+ }
571
+
572
+ /** 用户对写入做出决定后,把结果回填并继续循环 */
573
+ export async function resumeWithApproval({ cfg, run, send, signal, approved }) {
574
+ const pending = run.pending;
575
+ if (!pending) throw Object.assign(new Error('该任务没有待批准的写入'), { status: 409 });
576
+ run.pending = null;
577
+ run.pendingPreview = null;
578
+
579
+ const result = approved
580
+ ? await safeExec(run, pending, true)
581
+ : {
582
+ ok: false,
583
+ summary: '用户拒绝了这次写入',
584
+ content: '用户拒绝执行这次写入操作。不要重试同样的写入,请改为向用户说明你的思路,或提出替代方案。',
585
+ };
586
+
587
+ send({
588
+ type: 'tool_result',
589
+ id: pending.id,
590
+ name: pending.name,
591
+ ok: result.ok,
592
+ summary: result.summary,
593
+ content: result.content,
594
+ approved: Boolean(approved),
595
+ });
596
+ run.messages.push({ role: 'tool', tool_call_id: pending.id, content: result.content });
597
+
598
+ // 把**同一条 assistant 消息里剩下的**调用补跑完,再交给模型。
599
+ // 漏掉任何一个,下一次请求都会被网关以「tool_calls 与 tool 消息数量不等」拒掉(400),
600
+ // 整条任务作废——这是实测踩出来的(详见 runToolBatch 的说明)。
601
+ const more = await runToolBatch({ run, send, calls: unansweredCalls(run), mode: normalizeMode(run.mode) });
602
+ if (more.status === 'awaiting-approval') return more;
603
+
604
+ return runAgent({ cfg, run, send, signal });
605
+ }
606
+
607
+ export const AGENT_LIMITS = { MAX_STEPS: DEFAULT_MAX_STEPS, DEFAULT_MAX_STEPS, RUN_TTL_MS: APPROVAL_TTL_MS, APPROVAL_TTL_MS, MAX_RUNS, MODES };