mingdao-harness 0.6.11 → 0.6.13
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/README.md +1 -1
- package/docs/CONFIG.md +155 -0
- package/docs/DEVELOPER.md +34 -1
- package/package.json +1 -1
- package/presets/local-model.json +8 -0
- package/presets/readonly-audit.json +10 -0
- package/src/agent.js +647 -10
- package/src/model-discovery.js +628 -2
- package/src/presets.js +61 -4
- package/src/web/app.js +33 -2
- package/src/web/server.js +224 -14
- package/presets/local-audit.json +0 -10
package/src/agent.js
CHANGED
|
@@ -34,6 +34,301 @@ const MAX_STEPS = 24;
|
|
|
34
34
|
// 提到与主循环一致(24),只读子任务每步是 read/grep(输入便宜、无输出 token),成本增量可忽略。
|
|
35
35
|
const SUBAGENT_MAX_STEPS = 24;
|
|
36
36
|
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// v0.6.13(A / 真机实测:本地 35B 的审计回合跑了 4+ 小时、界面 0 步、日志零行)
|
|
39
|
+
//
|
|
40
|
+
// 现有三个超时(src/providers/index.js)**全是单请求级**:
|
|
41
|
+
// · 首帧等待 firstTokenMs:本地 600s / 远程 300s(第一帧到达即解除)
|
|
42
|
+
// · 流式空闲 streamIdleMs:120s(每收到一帧就重置)
|
|
43
|
+
// · 单请求总量 totalMs :本地 1800s / 远程 600s
|
|
44
|
+
// 而一个回合的最坏规模是 `maxRounds × stepLimit` 次请求(默认 3 × 24 = 72),每次都能耗到
|
|
45
|
+
// totalMs;`task` 子代理**各自**又有同样一整套预算且可递归派生。于是"每十几分钟返回一次、
|
|
46
|
+
// 什么都没做成"的回合能跑掉几小时——单请求超时一次都不会响,日志里一行都不会留。
|
|
47
|
+
//
|
|
48
|
+
// 这里补两件**回合级**的事(与单请求超时互补,不替代):
|
|
49
|
+
// ① 无进展看门狗:连续 noProgressTimeoutMs 既没有工具结果返回、也没有新正文/推理增量
|
|
50
|
+
// → 主动中止并如实报告(默认值依据见下);
|
|
51
|
+
// ② 进度可见性:首帧时延(TTFT)/ 帧数 / 字符数 / 模型轮次 / 工具调用数 / 正在等待的工具
|
|
52
|
+
// 挂在 io.turnProgress 上,由 WebUI 服务端(src/web/server.js)定期写 web-server.log
|
|
53
|
+
// 并随状态条下发——"在跑"与"卡死"必须能一眼区分。
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** 无进展看门狗默认阈值(ms)= 2 × 现有**单请求总量**上限(本地 1800s,见 providers/index.js)。
|
|
57
|
+
*
|
|
58
|
+
* 依据(不拍脑袋,逐条对齐现有超时):
|
|
59
|
+
* · 首帧 600s / 空闲 120s / 总量 1800s 都是**单请求**级;回合级阈值若小于单请求上限,
|
|
60
|
+
* 一次慢 prefill 就会被误判成"卡死"——那比现有超时更激进,明确不做;
|
|
61
|
+
* · 取 2×(60 分钟)而不是 1×:一次请求跑满 1800s 时,报错该由**更具体**的请求级超时给出
|
|
62
|
+
* (首帧/空闲/总量各有各的文案、各有各的处置建议),看门狗不抢答;2× 又足以兜住
|
|
63
|
+
* "请求都按时返回、整回合零产出"的空转(本次真机就是这一形态:610 次工具调用、
|
|
64
|
+
* 相邻两次最长只隔 7.1 分钟,却 4+ 小时没有任何交付);
|
|
65
|
+
* · 真干活的回合每隔几秒就有工具结果或正文增量,60 分钟零增量在任何正常回合都不可能。
|
|
66
|
+
*
|
|
67
|
+
* 可配置:config.noProgressTimeoutMs(0/负数/NaN/Infinity 一律回落默认——**不能**用它关掉看门狗)。 */
|
|
68
|
+
export const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 3600000;
|
|
69
|
+
|
|
70
|
+
/** 父回合在等待 `task` 子代理时额外给的一点点宽限(ms)。
|
|
71
|
+
*
|
|
72
|
+
* v0.6.13(问题 3):子代理的看门狗与父回合是**同一个阈值**,两者会在同一刻到点;
|
|
73
|
+
* 必须让**子代理先响**——否则父回合先掐断,`task` 的工具结果里只剩"父回合中止",
|
|
74
|
+
* 用户看不出子代理自己卡在哪一步、跑到了第几轮(真机现场正是这种形态)。
|
|
75
|
+
* 这不是"把阈值调大掩盖问题":判据没动、默认值没动,只是在"父回合正等子代理"这一种
|
|
76
|
+
* 形态下多等 15s(相对默认 60 分钟是 0.4%),换"子代理自己的结论能回传父回合"。 */
|
|
77
|
+
export const SUBAGENT_WAIT_GRACE_MS = 15000;
|
|
78
|
+
|
|
79
|
+
/** 解析无进展阈值:非正/非有限一律回落默认(fail-safe,防"配置写 0 就静默失去看门狗")。 */
|
|
80
|
+
export function resolveNoProgressTimeoutMs(/** @type {any} */ cfg) {
|
|
81
|
+
const v = Number(cfg?.noProgressTimeoutMs);
|
|
82
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_NO_PROGRESS_TIMEOUT_MS;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 上下文占窗口比例的告警线(≥ 此值 → 显式警告"缩小任务/或调大 firstTokenMs")。 */
|
|
86
|
+
export const PREFILL_WARN_RATIO = 0.8;
|
|
87
|
+
/** 本地端点的 prompt 规模告警线(tokens):**只在没有预检数据时**用它兜底(有实测就不用规模猜)。 */
|
|
88
|
+
export const PREFILL_WARN_TOKENS = 60000;
|
|
89
|
+
/** 由预检外推的 prefill 超过它就显式告警(ms)。依据见下面 prefillForecast 的注释。 */
|
|
90
|
+
export const PREFILL_SLOW_WARN_MS = 60000;
|
|
91
|
+
/** "线性外推会低估"的**实测证据**(写进文案,让用户知道外推值不是承诺)。
|
|
92
|
+
*
|
|
93
|
+
* 真机(负责人 2026-10-09,同一个 taskId t424aaab2aecab8a3):
|
|
94
|
+
* 04:13:27 预告 本轮上下文 ≈ 15,983 tokens;预检「参考 prefill 2000 tokens 0.08s」
|
|
95
|
+
* 04:14:17 chat 首帧 t424aaab2aecab8a3 **53.7s**
|
|
96
|
+
* 线性外推 = 0.08s × 15983/2000 ≈ 0.6s,实测 53.7s —— **差约 90 倍**。
|
|
97
|
+
* 原因:2000 tokens 的参考样本落在引擎的短上下文快路径上;真实任务把 KV cache 占满后,
|
|
98
|
+
* dequant/内存压力会让 prefill 显著变慢(真机现场还有 `insufficient memory … during prefill`)。
|
|
99
|
+
* 所以文案**只报外推值是不够的**,必须同时说出这条实测差值。 */
|
|
100
|
+
export const PREFILL_LONGCTX_EVIDENCE = '本机实测 16k tokens 首帧 53.7s,而按同一份预检外推只有 0.6s(约 90 倍)';
|
|
101
|
+
|
|
102
|
+
/** 把毫秒格式化成给人看的时长(<60s 用秒、否则 m分s秒)。 */
|
|
103
|
+
const fmtDur = (/** @type {number} */ ms) => {
|
|
104
|
+
const s = Number(ms) / 1000;
|
|
105
|
+
if (!Number.isFinite(s) || s < 0) return '?';
|
|
106
|
+
return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** 参考 prefill 的耗时格式化:保留两位(76ms → 0.08s),否则"0.1s/2000 tokens 外推"会把
|
|
110
|
+
* 预检实测值四舍五入到看不出量级(真机预检正是 0.08s 这一档)。 */
|
|
111
|
+
const fmtRefDur = (/** @type {number} */ ms) => {
|
|
112
|
+
const s = Number(ms) / 1000;
|
|
113
|
+
if (!Number.isFinite(s) || s < 0) return '?';
|
|
114
|
+
return s < 60 ? `${s.toFixed(2)}s` : fmtDur(ms);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 由**预检实测**外推本轮 prefill 耗时(纯函数,便于断言/变异)。
|
|
119
|
+
*
|
|
120
|
+
* 为什么是线性外推而不是照搬老文案:老文案对任何本地端点都说"prefill 可能需数分钟至数十分钟",
|
|
121
|
+
* 而同一条预告的后半句又写着"参考 prefill 2000 tokens 0.08s"——**同一行自相矛盾**,
|
|
122
|
+
* 结果就是用户学会忽略这条告警(本次真机 04:13:27 的现场)。
|
|
123
|
+
*
|
|
124
|
+
* 判据(只认实测,不认规模):
|
|
125
|
+
* · 有 `probe.prefillMs`(预检测过固定参考 prefill)→ `prefillMs × 本轮tokens / 参考tokens`;
|
|
126
|
+
* · 没有 → 返回 null,调用方**退回**保守文案(不可达 / 不可判定 / 未预检)。
|
|
127
|
+
* 外推的已知偏差(长上下文下引擎显著变慢)不由本函数掩盖——由调用方写进文案(见 PREFILL_LONGCTX_EVIDENCE)。
|
|
128
|
+
* @param {{ promptTokens: number, probe?: any }} input
|
|
129
|
+
* @returns {{ ms: number, refTokens: number, refMs: number, tokens: number }|null}
|
|
130
|
+
*/
|
|
131
|
+
export function prefillEstimateMs({ promptTokens, probe = null }) {
|
|
132
|
+
const p = Math.max(0, Math.round(Number(promptTokens) || 0));
|
|
133
|
+
const refMs = Number(probe?.prefillMs);
|
|
134
|
+
if (!(refMs > 0) || p <= 0) return null;
|
|
135
|
+
const refTokens = Number(probe?.prefillTokens) > 0 ? Number(probe.prefillTokens) : 2000;
|
|
136
|
+
return { ms: Math.round((refMs * p) / refTokens), refTokens, refMs, tokens: p };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fmtTok = (/** @type {any} */ n) => Number(n || 0).toLocaleString('en-US');
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 发送前的规模预告(纯函数,便于断言/变异):把"这次要 prefill 多少"在**请求发出前**说出来。
|
|
143
|
+
* 本地端点尤其重要——它既不提供 prefill 进度,首帧又可能是几十分钟。
|
|
144
|
+
*
|
|
145
|
+
* v0.6.13(问题 1 修法):**有预检实测就必须由它推导**,并在文案里如实标注
|
|
146
|
+
* ①这是外推、②长上下文下外推会低估(附实测差值);只有在**没有预检数据**时才退回保守文案。
|
|
147
|
+
* @param {{ promptTokens: number, contextWindow?: number, isLocal?: boolean,
|
|
148
|
+
* firstTokenMs?: number, budget?: number, probe?: any }} input
|
|
149
|
+
* @returns {{ text: string, warn: boolean, promptTokens: number, ratio: number, estimateMs: number|null }}
|
|
150
|
+
*/
|
|
151
|
+
export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null }) {
|
|
152
|
+
const p = Math.max(0, Math.round(Number(promptTokens) || 0));
|
|
153
|
+
const win = Number(contextWindow) > 0 ? Number(contextWindow) : 0;
|
|
154
|
+
const ratio = win > 0 ? p / win : 0;
|
|
155
|
+
const pct = Math.round(ratio * 100);
|
|
156
|
+
const secs = Number(firstTokenMs) > 0 ? Math.round(Number(firstTokenMs) / 1000) : 0;
|
|
157
|
+
const head = `📏 本轮上下文 ≈ ${fmtTok(p)} tokens${win ? `(${fmtTok(win)} 窗口的 ${pct}%)` : ''}${Number(budget) > 0 ? `,预算 ${fmtTok(budget)}` : ''}`;
|
|
158
|
+
const est = prefillEstimateMs({ promptTokens: p, probe });
|
|
159
|
+
const estNote = est ? `按预检 ${fmtRefDur(est.refMs)}/${fmtTok(est.refTokens)} tokens 外推,本轮 ≈${fmtDur(est.ms)}` : '';
|
|
160
|
+
// 三个分支(顺序即优先级):
|
|
161
|
+
// ① 有预检实测 → 报外推值 + **如实**说明长上下文会低估(附真机差值);
|
|
162
|
+
// ② 无预检数据(不可达/不可判定/未预检)→ 退回既有保守文案(真机 01:26 那次就是这一支);
|
|
163
|
+
// ③ 远程端点 → 各自一句。
|
|
164
|
+
const timing = est
|
|
165
|
+
? `${isLocal ? '本地端点' : '远程端点'}:${estNote}` +
|
|
166
|
+
`(**线性外推**,只作量级参考——长上下文下引擎可能显著变慢:${PREFILL_LONGCTX_EVIDENCE};` +
|
|
167
|
+
`外推低估时以首帧等待为准);首帧等待上限 ${secs}s(config.timeout.firstTokenMs)`
|
|
168
|
+
: isLocal
|
|
169
|
+
? `本地端点:prefill 可能需数分钟至数十分钟(该端点不提供进度预告,只能等首帧);首帧等待上限 ${secs}s(config.timeout.firstTokenMs)`
|
|
170
|
+
: `远程端点:首帧通常在数十秒内;首帧等待上限 ${secs}s(config.timeout.firstTokenMs)`;
|
|
171
|
+
// 三条告警线:
|
|
172
|
+
// · 占窗口 ≥80%:任何端点都危险(与 prefill 快慢无关,这是"上下文压力");
|
|
173
|
+
// · **外推** prefill ≥ PREFILL_SLOW_WARN_MS:端点实测明显慢,依据 = 预检实测 × 本轮 tokens;
|
|
174
|
+
// · 本地端点 + prompt ≥ PREFILL_WARN_TOKENS:**仅在拿不到预检数据时**兜底
|
|
175
|
+
// (有实测就不再用规模猜——那正是"0.08s 的端点被说成需要数十分钟"的来源)。
|
|
176
|
+
const hitRatio = ratio >= PREFILL_WARN_RATIO;
|
|
177
|
+
const hitSlow = est != null && est.ms >= PREFILL_SLOW_WARN_MS;
|
|
178
|
+
const hitLocal = est == null && Boolean(isLocal) && p >= PREFILL_WARN_TOKENS;
|
|
179
|
+
const warn = hitRatio || hitSlow || hitLocal;
|
|
180
|
+
const why = hitRatio
|
|
181
|
+
? `上下文已占窗口 ${pct}%(≥${Math.round(PREFILL_WARN_RATIO * 100)}%)`
|
|
182
|
+
: hitSlow
|
|
183
|
+
? `按预检外推本轮 prefill ≈${fmtDur(est ? est.ms : 0)}(${est ? fmtRefDur(est.refMs) : '?'}/${est ? fmtTok(est.refTokens) : '?'} tokens × ${fmtTok(p)} tokens,≥${Math.round(PREFILL_SLOW_WARN_MS / 1000)}s 告警线)`
|
|
184
|
+
: `本地端点 + 上下文 ${fmtTok(p)} tokens(≥${fmtTok(PREFILL_WARN_TOKENS)}),且本次没有任何预检实测可外推`;
|
|
185
|
+
const advice = warn
|
|
186
|
+
? `\n ⚠ ${why}:` +
|
|
187
|
+
`建议缩小任务(新建会话、只给必要文件、先 /compact)或调大 config.timeout.firstTokenMs——` +
|
|
188
|
+
`但注意调大它只会让"失败"更晚被发现,不会让 prefill 变快。`
|
|
189
|
+
: '';
|
|
190
|
+
// v0.6.13(A 追加):把**端点预检**结果并进这条预告——"这个端点行不行"与"这次要 prefill 多少"
|
|
191
|
+
// 必须一起出现在发送前,而不是等出事后翻日志(引擎实际加载的模型名与配置名不一致也在这里说)。
|
|
192
|
+
const probeLine = probe && probe.note ? `\n 端点预检:${probe.note}` : '';
|
|
193
|
+
return { text: `${head}\n ${timing}${probeLine}${advice}`, warn, promptTokens: p, ratio, estimateMs: est ? est.ms : null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 无进展中止文案(纯函数):必须同时说清「多久没进展」「发生了什么」「在等谁」「下一步怎么办」,
|
|
198
|
+
* 并且**与 capped/aborted 区分开**(v0.6.13 问题 4:真机上用户只看到一句"回合收尾",
|
|
199
|
+
* 分不清"跑满了步数(可续跑)""我点了停止"和"再等下去也不会有新东西")。
|
|
200
|
+
* @param {{ waitedMs: number, toolCalls?: number, modelRounds?: number, unproductive?: number,
|
|
201
|
+
* pendingTool?: { name: string, ms: number }|null,
|
|
202
|
+
* subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null }} input
|
|
203
|
+
*/
|
|
204
|
+
export function noProgressNotice({ waitedMs, toolCalls = 0, modelRounds = 0, unproductive = 0, pendingTool = null, subagent = null }) {
|
|
205
|
+
// v0.6.13(问题 3 附带修):不足 1 分钟必须按**秒**说。原实现 Math.max(1, round(ms/60000)) 会把
|
|
206
|
+
// 600ms 说成"连续 1 分钟"——真机上子代理被掐时就谎报过"连续 1 分钟"(它其实一直在干活)。
|
|
207
|
+
const min = Math.max(1, Math.round(Number(waitedMs) / 60000));
|
|
208
|
+
const howLong = Number(waitedMs) >= 60000 ? `${min} 分钟` : `${Math.max(1, Math.round(Number(waitedMs) / 1000))} 秒`;
|
|
209
|
+
const extra = Number(unproductive) > 0 ? `——其中 ${Number(unproductive)} 次工具调用是**重复调用或失败**(没有产生任何新信息)` : '';
|
|
210
|
+
const waiting = pendingTool
|
|
211
|
+
? `\n ⏳ 中止时**正在等待工具 ${pendingTool.name}**(已 ${Math.round(Number(pendingTool.ms) / 1000)}s)` +
|
|
212
|
+
(subagent
|
|
213
|
+
? `;该子代理最后进展:第 ${Number(subagent.modelRounds) || 0} 轮 / ${Number(subagent.toolCalls) || 0} 次工具调用` +
|
|
214
|
+
(Number(subagent.at) > 0 ? `(${Math.max(0, Math.round((Date.now() - Number(subagent.at)) / 1000))}s 前)` : '') +
|
|
215
|
+
'。子代理自己的看门狗会先于父回合中止它并回传原因;这里先响说明**连子代理也没有新进展**。'
|
|
216
|
+
: '。')
|
|
217
|
+
: '';
|
|
218
|
+
return (
|
|
219
|
+
`⏹ 连续 ${howLong}没有任何工具调用、也没有新内容(模型请求 ${modelRounds} 轮、工具调用 ${toolCalls} 次${extra}),已中止。\n` +
|
|
220
|
+
` ⓘ 这是**整轮零进展**(不是步数上限 capped、也不是你点的停止 aborted):capped 的回合可以接着续跑,本条是"再等下去也不会产出新东西"。` +
|
|
221
|
+
waiting +
|
|
222
|
+
`\n 常见原因:① 该端点/模型不做 function calling(tool_calls 恒为空);② 本地引擎的 prefill 已停滞或内存吃紧;③ 上游连接还在但不再产出。\n` +
|
|
223
|
+
` 下一步:把任务拆小(新建会话、只给必要文件)或换支持工具调用的端点;阈值可调 config.noProgressTimeoutMs(默认 ${Math.round(DEFAULT_NO_PROGRESS_TIMEOUT_MS / 60000)} 分钟)。`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 子代理因**父回合的截止时刻**收口时的文案(纯函数)。
|
|
229
|
+
*
|
|
230
|
+
* 为什么必须与 noProgressNotice 分开:真机现场(~/.mingdao/sessions/2026-10-09T02-42-31-qaaa.jsonl
|
|
231
|
+
* 第 28/29 行)两条 `task` 结果都写「连续 1 分钟没有任何工具调用」,而同一句话里又写着
|
|
232
|
+
* 「模型请求 15 轮、工具调用 18 次」——子代理**一直在干活**。原因是继承的截止时刻是
|
|
233
|
+
* spawn 那一刻的**绝对快照**,到点即掐,触发时又拿"子代理自己的最后进展"当等待时长,于是谎报。
|
|
234
|
+
* 修法:①截止时刻随父回合进展前移(不再掐正在干活的子代理);②真到点时必须如实说
|
|
235
|
+
* "是父回合判定整轮无进展、不是子代理卡住"。
|
|
236
|
+
* @param {{ waitedMs?: number, toolCalls?: number, modelRounds?: number }} input
|
|
237
|
+
*/
|
|
238
|
+
export function inheritedStopNotice({ waitedMs = 0, toolCalls = 0, modelRounds = 0 }) {
|
|
239
|
+
const secs = Math.max(0, Math.round(Number(waitedMs) / 1000));
|
|
240
|
+
const ago = secs >= 60 ? `${Math.round(secs / 60)} 分钟` : `${secs} 秒`;
|
|
241
|
+
return (
|
|
242
|
+
`⏹ 父回合已判定**整轮无进展**,子代理随整棵子代理树一起收口,已中止。\n` +
|
|
243
|
+
` 子代理自身最后一次真实进展在 ${ago}前(模型请求 ${modelRounds} 轮、工具调用 ${toolCalls} 次)——` +
|
|
244
|
+
`即**不是子代理卡住**,而是父回合整体零进展(父回合没有把子代理的进展计为进展时就会走到这里)。\n` +
|
|
245
|
+
` 下一步:把子任务拆小、减少并行子代理数,或调大 config.noProgressTimeoutMs(默认 ${Math.round(DEFAULT_NO_PROGRESS_TIMEOUT_MS / 60000)} 分钟)。`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 回合结束时的「0 次工具调用」提示(纯函数)。
|
|
251
|
+
* 触发条件:**任务态**(用户消息含写/执行意图)或发了多轮请求,却一次工具调用都没有——
|
|
252
|
+
* 单轮纯问答本来就不该有工具调用,不该被这条提示打扰。
|
|
253
|
+
* @param {{ modelRounds: number, steps?: number, taskLike?: boolean }} input
|
|
254
|
+
*/
|
|
255
|
+
export function zeroToolCallNotice({ modelRounds, steps = 0, taskLike = false }) {
|
|
256
|
+
if (!(Number(modelRounds) > 1) && !taskLike) return '';
|
|
257
|
+
return (
|
|
258
|
+
`⚠ 本回合 0 次工具调用(共 ${Number(modelRounds)} 轮模型请求 / ${Number(steps)} 步):该端点看起来不返回 tool_calls。\n` +
|
|
259
|
+
` 若任务需要读写文件、执行命令,请换支持 function calling 的模型或端点;纯问答可以忽略本条。`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 上游/引擎错误的可操作解读(纯函数):把「HTTP 400 + 一段 Jinja/模板报错」这类原文
|
|
265
|
+
* 变成「原文 + 这是什么 + 该怎么办」。识别不出时 hint 为 null(**绝不改原文**)。
|
|
266
|
+
* @param {any} err
|
|
267
|
+
* @returns {{ message: string, hint: string|null, status: number|null }}
|
|
268
|
+
*/
|
|
269
|
+
export function describeUpstreamError(/** @type {any} */ err) {
|
|
270
|
+
const raw = String(err?.message ?? err ?? '').trim();
|
|
271
|
+
const status = Number(err?.status) > 0 ? Number(err.status) : null;
|
|
272
|
+
const t = raw.toLowerCase();
|
|
273
|
+
/** @type {string|null} */
|
|
274
|
+
let hint = null;
|
|
275
|
+
if (/jinja|template|no user query|parser for this template/.test(t)) {
|
|
276
|
+
hint =
|
|
277
|
+
'上游拒绝的是**它的 prompt 模板**(不是网络问题):该端点的模板不接受当前 messages 形态(典型:按单轮 query 设计的 chatflow/模板,收到的是 OpenAI 多轮 messages)。' +
|
|
278
|
+
'这类端点通常也**不返回 tool_calls**——请改用支持 function calling 的模型/端点,或让网关把 messages 映射成它要的单轮入参。';
|
|
279
|
+
} else if (/memory_refusal|insufficient memory|gib|内存不足|内存拒绝/.test(t)) {
|
|
280
|
+
hint =
|
|
281
|
+
'本地引擎**内存/显存不足**(在 prefill 前就拒绝了)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后重试。';
|
|
282
|
+
} else if (/首 token 等待超限|流式响应空闲超限|请求总时长超限/.test(raw)) {
|
|
283
|
+
hint =
|
|
284
|
+
'这是**内核自己的超时**(不是上游报错):端点连上了但迟迟不产出。本地大模型长上下文 prefill 很慢——可调大 config.timeout.firstTokenMs/streamIdleMs/totalMs,' +
|
|
285
|
+
'或把任务拆小(新建会话、只给必要文件);本地端点还有一种情形是引擎 prefill 停滞(内存吃紧),此时调大超时只会让失败更晚被发现。';
|
|
286
|
+
} else if (status === 401 || status === 403 || /unauthorized|invalid.*api.?key|鉴权|密钥/.test(t)) {
|
|
287
|
+
hint = '鉴权失败:请检查该服务商的 API Key(WebUI ⚙ 设置 →「模型与 API Key」,或 mingdao key set)。';
|
|
288
|
+
} else if (status === 404) {
|
|
289
|
+
hint = '端点不存在:请确认 baseUrl 指向 OpenAI 兼容入口(通常以 /v1 结尾),且模型名是端点认识的。';
|
|
290
|
+
} else if (status === 429) {
|
|
291
|
+
hint = '上游限流(429):稍后重试,或降低并发/换端点。';
|
|
292
|
+
} else if (status !== null && status >= 500 && status <= 504) {
|
|
293
|
+
hint = '上游 5xx:内核已按瞬态错误自动重试过;若原文是确定性错误(模板/参数类),重试不会改变结果,请直接修端点配置。';
|
|
294
|
+
}
|
|
295
|
+
// 上游原文可能很长(内核只保留前 400 字):如实标注它被截断过,避免用户以为这就是全部
|
|
296
|
+
const truncated = /…|\.\.\.$/.test(raw) || raw.length >= 380;
|
|
297
|
+
return { message: raw, hint: hint ? `💡 ${hint}${truncated ? '(上面的上游原文可能被截断)' : ''}` : null, status };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 进度行(纯函数):web-server.log 与界面状态条**共用同一份文案**,口径不会漂移。
|
|
302
|
+
* @param {{ phase?: string, elapsedMs: number, modelRounds?: number, toolCalls?: number,
|
|
303
|
+
* toolExecuted?: number, contentChars?: number, ttftMs?: number|null,
|
|
304
|
+
* pendingTool?: { name: string, ms: number }|null, tokensPerSec?: number|null,
|
|
305
|
+
* subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null }} p
|
|
306
|
+
*/
|
|
307
|
+
export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCalls = 0, toolExecuted = 0, contentChars = 0, ttftMs = null, pendingTool = null, tokensPerSec = null, subagent = null }) {
|
|
308
|
+
const s = Math.max(0, Math.round(Number(elapsedMs) / 1000));
|
|
309
|
+
const parts = [
|
|
310
|
+
`已跑 ${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`,
|
|
311
|
+
`阶段=${phase || '未知'}`,
|
|
312
|
+
`模型轮次=${modelRounds}`,
|
|
313
|
+
`工具调用=${toolCalls}${toolCalls === 0 ? '(0 次工具调用)' : `(已执行完 ${toolExecuted})`}`,
|
|
314
|
+
];
|
|
315
|
+
if (ttftMs != null) parts.push(`首帧=${(Number(ttftMs) / 1000).toFixed(1)}s`);
|
|
316
|
+
if (tokensPerSec != null) parts.push(`≈${Number(tokensPerSec).toFixed(1)} tok/s`);
|
|
317
|
+
parts.push(`已收正文=${contentChars} 字`);
|
|
318
|
+
if (pendingTool) parts.push(`正在等待工具 ${pendingTool.name}(已 ${Math.round(Number(pendingTool.ms) / 1000)}s)`);
|
|
319
|
+
// v0.6.13(问题 3):等 `task` 子代理时必须能看出"这个子任务跑到哪一步"——
|
|
320
|
+
// 否则"等子代理 620s"与"卡死 620s"在日志上完全同形(真机现场正是如此)。
|
|
321
|
+
if (subagent) {
|
|
322
|
+
const label = String(subagent.label || '').trim();
|
|
323
|
+
const ago = Number(subagent.at) > 0 ? Math.max(0, Math.round((Date.now() - Number(subagent.at)) / 1000)) : null;
|
|
324
|
+
parts.push(
|
|
325
|
+
`子代理${label ? `「${label}」` : ''}已跑 ${Number(subagent.modelRounds) || 0} 轮 / ${Number(subagent.toolCalls) || 0} 次工具调用` +
|
|
326
|
+
(ago != null ? `,最后进展 ${ago}s 前` : '')
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return parts.join(' · ');
|
|
330
|
+
}
|
|
331
|
+
|
|
37
332
|
// 只读档工具集(省钱 B1 的「只读阶段」)——**单一来源已移到 `tools-flow.js`**(v0.6.11 / P1-1 第三刀):
|
|
38
333
|
// 它与「本轮可见哪些工具」的判据是同一条规则,放在一起才能保证"改一处就够"。
|
|
39
334
|
// 这里只做**再导出**:bench/smoke 既有的 `from './agent.js'` 导入面与集合内容都不变(行为零变化)。
|
|
@@ -154,6 +449,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
154
449
|
// v0.5.0 A4:当前正在执行的 Pack 工具所属 Pack(由 runTool 按 `pack__<pack>__` 前缀设置),
|
|
155
450
|
// 供 ctx.llm 写归因记录时标注来源。
|
|
156
451
|
let currentPack = /** @type {any} */ (null);
|
|
452
|
+
// v0.6.13(A):当前回合的进度快照(runTurn 内赋值、finally 清空)。
|
|
453
|
+
// spawnTask 用它把父回合的"无进展截止时刻"传给子代理:子代理树必须一起收口,
|
|
454
|
+
// 否则 24 个并行子代理各自跑满自己的看门狗,父回合看起来就是"挂了几小时"。
|
|
455
|
+
let currentTurnProgress = /** @type {any} */ (null);
|
|
157
456
|
// 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
|
|
158
457
|
const undo = undoStore || { backups: new Map() };
|
|
159
458
|
const stepLimit = maxSteps || MAX_STEPS;
|
|
@@ -204,13 +503,37 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
204
503
|
: { check: (/** @type {any} */ name, /** @type {any} */ args) => permission.check(name, args, '(子任务)') };
|
|
205
504
|
// 自动路由:子代理固定走 executor 模型(便宜的执行单元)
|
|
206
505
|
const subModel = subagentModel(cfg, modelName);
|
|
506
|
+
// v0.6.13(A):子代理**继承父回合的无进展截止时刻**——24 个并行子代理各自跑几十分钟时,
|
|
507
|
+
// 父回合不该等它们把各自的看门狗超时跑完(真机实测:一个回合因此跑了 4+ 小时)。
|
|
508
|
+
// v0.6.13(问题 3 修法):这个 `turnProgressDeadlineAt` **只是 spawn 时刻的快照兜底**;
|
|
509
|
+
// 真正的判据在子代理侧取 `parentProgress`(父回合的进度对象,**活的引用**):
|
|
510
|
+
// · 快照版的问题(真机 02:42–03:13):子代理刚有进展(15 轮 / 18 次工具调用)也被
|
|
511
|
+
// "父回合剩余时间"掐掉,而且触发时按子代理自己的最后进展算等待时长 → 谎报"连续 1 分钟";
|
|
512
|
+
// · 现在子代理每次真实进展都会回调父回合(onSubagentProgress)→ 父回合续期 →
|
|
513
|
+
// 父回合的 lastProgressAt 前移 → 子代理读到的截止时刻随之前移。**真在干活就不收口**。
|
|
207
514
|
const subAgent = createAgent({
|
|
208
515
|
provider,
|
|
209
516
|
permission: subPermission,
|
|
210
517
|
io: subIo,
|
|
211
518
|
modelName: subModel,
|
|
212
519
|
workingDir,
|
|
213
|
-
cfg: {
|
|
520
|
+
cfg: {
|
|
521
|
+
...cfg,
|
|
522
|
+
contextBudget: Math.min(budget, 64000),
|
|
523
|
+
...(currentTurnProgress
|
|
524
|
+
? { turnProgressDeadlineAt: currentTurnProgress.lastProgressAt + currentTurnProgress.noProgressMs }
|
|
525
|
+
: {}),
|
|
526
|
+
// 下面是 v0.6.13(问题 3)补的两项。**上面三行一个字都不动**:那个 spread 是 §139 与
|
|
527
|
+
// batch28 ⑥ 共同钉住的形状(改缩进/换写法会让既有变异变成"变异点未找到"——本轮实际踩到过),
|
|
528
|
+
// 而它现在的语义只当"spawn 时刻的快照兜底",真正的判据是 parentProgress(活引用)。
|
|
529
|
+
...(currentTurnProgress ? { parentProgress: currentTurnProgress } : {}),
|
|
530
|
+
...(description ? { subagentLabel: description } : {}),
|
|
531
|
+
onSubagentProgress: (/** @type {any} */ info) => {
|
|
532
|
+
try {
|
|
533
|
+
currentTurnProgress?.onSubagentProgress?.(info);
|
|
534
|
+
} catch {}
|
|
535
|
+
},
|
|
536
|
+
},
|
|
214
537
|
undoStore: undo,
|
|
215
538
|
maxSteps: SUBAGENT_MAX_STEPS,
|
|
216
539
|
mcp,
|
|
@@ -247,11 +570,22 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
247
570
|
}
|
|
248
571
|
// v0.4.1:子代理空输出给主线程可用的失败信号(含 note 原因),而非笼统「无输出」——
|
|
249
572
|
// 主智能体据此决定是否重试/换法,而非把子代理静默当作「已完成但没说话」。
|
|
573
|
+
// v0.6.13(问题 3):把子代理**自己跑到哪一步**一并回传(轮次/工具调用/最后进展距今)——
|
|
574
|
+
// 真机上父回合只知道"在等 task 620s",完全看不出子代理是在干活还是卡死;
|
|
575
|
+
// 而子代理被收口的**原因**(自己无进展 / 父回合整轮无进展)也必须原样带给父回合。
|
|
576
|
+
const subTp = /** @type {any} */ (/** @type {any} */ (subIo).turnProgress);
|
|
577
|
+
const subStat = subTp
|
|
578
|
+
? `已跑 ${Number(subTp.modelRounds) || 0} 轮 / ${Number(subTp.toolCalls) || 0} 次工具调用` +
|
|
579
|
+
(Number(subTp.lastProgressAt) > 0 ? `,最后进展 ${Math.max(0, Math.round((Date.now() - Number(subTp.lastProgressAt)) / 1000))}s 前` : '') +
|
|
580
|
+
(subTp.stallCause === 'inherited' ? '(因父回合整轮无进展而收口)' : subTp.stalled ? '(子代理自身无进展而收口)' : '')
|
|
581
|
+
: '';
|
|
250
582
|
const text =
|
|
251
583
|
res.text ||
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
584
|
+
`(子任务${res.stalled ? '被无进展看门狗中止' : res.truncated ? '达到步骤上限,未完成' : '无输出'}` +
|
|
585
|
+
(subStat ? `;${subStat}` : '') +
|
|
586
|
+
(res.note ? ':' + res.note : '') +
|
|
587
|
+
')';
|
|
588
|
+
io.print(style(` ↳ 子任务完成(${ms}ms${res.stalled ? ',被无进展看门狗中止' : ''})`, C.magenta));
|
|
255
589
|
return text;
|
|
256
590
|
}
|
|
257
591
|
|
|
@@ -448,9 +782,26 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
448
782
|
deliverables: [...deliverables], // v0.3.1:CLI/REPL 续跑检查点复用(此前 artifacts 恒空)
|
|
449
783
|
// v0.6.7(报告一 H-4):本回合是否出现过"模型有响应但服务端没回 usage"
|
|
450
784
|
usageUnknown: outcome.usageUnknown,
|
|
785
|
+
// v0.6.13(A/可观测性):工具调用与首帧时延随 perf 一起回给调用方(WebUI 的 done 事件
|
|
786
|
+
// 用它写"本回合 0 次工具调用",状态栏/账本也能读到 TTFT)。
|
|
787
|
+
toolCalls: turnProgress.toolCalls,
|
|
788
|
+
noToolCalls: turnProgress.toolCalls === 0,
|
|
789
|
+
llmCalls: turnProgress.llmCalls,
|
|
790
|
+
ttftMs: turnProgress.ttftMs,
|
|
791
|
+
stalled: turnProgress.stalled,
|
|
792
|
+
// v0.6.13(问题 3/4):stalled 的**原因**('own' 自己零进展 / 'inherited' 父回合整轮零进展)——
|
|
793
|
+
// 前端与日志据此区分"子代理卡住"和"父回合没算子代理的进展"(真机上这两者被混成一句)。
|
|
794
|
+
stallCause: turnProgress.stallCause,
|
|
795
|
+
subagent: turnProgress.subagent,
|
|
796
|
+
// v0.6.13(A):收尾提示的**单一来源**(服务端直接取这条字符串,不再自己拼一遍文案)。
|
|
797
|
+
// 注意:return 里的 perf() 先于 finally 求值,所以这里必须**惰性计算**(否则返回值里恒为 null,
|
|
798
|
+
// 而日志/界面那边却已经打印过——同一事实两个说法,正是本项目反复栽过的那类不一致)。
|
|
799
|
+
zeroToolCalls: ensureZeroToolCallNotice(),
|
|
451
800
|
});
|
|
452
801
|
let aborted = false;
|
|
453
802
|
let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
|
|
803
|
+
// v0.6.13(A):本轮用户消息是否为"任务态"(含写/执行意图)——用于收尾时的「0 次工具调用」提示
|
|
804
|
+
const turnUserTaskLike = hasWriteIntent(String([...messages].reverse().find((m) => m?.role === 'user')?.content ?? ''));
|
|
454
805
|
let currentAc = /** @type {any} */ (null);
|
|
455
806
|
// v0.3.2 边缘检测状态:模型上报 prompt_tokens 逼近窗口 → 下一轮强制压缩(见下方 compactConversation force)
|
|
456
807
|
let windowPressure = false;
|
|
@@ -485,6 +836,160 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
485
836
|
const turnStrippedSet = new Set(usedToolNames);
|
|
486
837
|
// 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
|
|
487
838
|
const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
|
|
839
|
+
// ---- v0.6.13(A):回合级进度快照 + 无进展看门狗 ----
|
|
840
|
+
// 快照挂在 io 上:WebUI 服务端(src/web/server.js)每 5s 读它下发状态条、每 30s 落一行日志。
|
|
841
|
+
// 这里不改 web-io.js 的接口(它不在本次改动面内),字段是"只读约定"。
|
|
842
|
+
const noProgressMs = resolveNoProgressTimeoutMs(cfg);
|
|
843
|
+
/** @type {any} */
|
|
844
|
+
const turnProgress = {
|
|
845
|
+
startedAt,
|
|
846
|
+
phase: '待发请求',
|
|
847
|
+
llmCalls: 0,
|
|
848
|
+
modelRounds: 0,
|
|
849
|
+
toolCalls: 0,
|
|
850
|
+
toolExecuted: 0,
|
|
851
|
+
// v0.6.13(A):**无效**工具调用数(重复调用或失败)——"工具在跑、什么都没做成"的量化依据
|
|
852
|
+
unproductive: 0,
|
|
853
|
+
streamFrames: 0,
|
|
854
|
+
contentChars: 0,
|
|
855
|
+
ttftMs: null,
|
|
856
|
+
lastTtftMs: null,
|
|
857
|
+
pendingTool: null,
|
|
858
|
+
// v0.6.13(问题 3):当前正在跑的子代理快照(由 onSubagentProgress 刷新)——
|
|
859
|
+
// 界面/日志据此显示"在等哪个子任务、它跑到第几轮 / 几次工具调用"
|
|
860
|
+
subagent: null,
|
|
861
|
+
forecast: null,
|
|
862
|
+
noProgressMs,
|
|
863
|
+
lastProgressAt: Date.now(),
|
|
864
|
+
progressReason: '回合开始',
|
|
865
|
+
stalled: false,
|
|
866
|
+
stallCause: null,
|
|
867
|
+
note: null,
|
|
868
|
+
zeroToolCalls: false,
|
|
869
|
+
};
|
|
870
|
+
try {
|
|
871
|
+
io.turnProgress = turnProgress;
|
|
872
|
+
} catch {}
|
|
873
|
+
currentTurnProgress = turnProgress; // 子代理据此继承同一个截止时刻(见 spawnTask)
|
|
874
|
+
let noProgressTimer = /** @type {any} */ (null);
|
|
875
|
+
let noProgressFired = false;
|
|
876
|
+
// 本回合的"调用签名 → 次数":完全相同(name+args)的重复调用不算进展(见 finishTool)
|
|
877
|
+
const turnSigCounts = new Map();
|
|
878
|
+
/**
|
|
879
|
+
* 继承来的"整棵树一起收口"截止时刻——**每次都重新取**,不是 spawn 那一刻的快照。
|
|
880
|
+
*
|
|
881
|
+
* 真机 bug(02:42–03:13,`status=stalled`):快照版把子代理的等待压到"父回合剩余时间",
|
|
882
|
+
* 子代理刚有进展(会话记录里写着「模型请求 15 轮、工具调用 18 次」)就被掐掉,
|
|
883
|
+
* 且文案按子代理自己的最后进展算时长 → 谎报"连续 1 分钟没有任何工具调用"。
|
|
884
|
+
* 现在父回合的 lastProgressAt 会被子代理的进展续期(见 markProgress / onSubagentProgress),
|
|
885
|
+
* 于是这个截止时刻**只前移不倒退**:真在干活就不收口,真停了才一起收口。
|
|
886
|
+
*/
|
|
887
|
+
const inheritedDeadlineAt = () => {
|
|
888
|
+
const snap = Number(cfg.turnProgressDeadlineAt) > 0 ? Number(cfg.turnProgressDeadlineAt) : 0;
|
|
889
|
+
const pp = /** @type {any} */ (cfg.parentProgress);
|
|
890
|
+
const live = pp && Number(pp.noProgressMs) > 0 && Number(pp.lastProgressAt) > 0 ? Number(pp.lastProgressAt) + Number(pp.noProgressMs) : 0;
|
|
891
|
+
const d = Math.max(snap, live);
|
|
892
|
+
return d > 0 ? d : null;
|
|
893
|
+
};
|
|
894
|
+
/**
|
|
895
|
+
* 看门狗到点。`cause` 必须如实区分(问题 3/4):
|
|
896
|
+
* · 'own' —— 本回合自己连续 N 没有真实进展;
|
|
897
|
+
* · 'inherited' —— 父回合已判定整轮无进展,子代理随整棵树收口(**不是**子代理卡住)。
|
|
898
|
+
*/
|
|
899
|
+
// 触发原因由 armNoProgress 在**安排计时器前**写入(而不是当参数传进 fireNoProgress):
|
|
900
|
+
// `noProgressTimer = setTimeout(fireNoProgress, left);` 这一行的形状被 §139 与 batch28 ① 共同钉住,
|
|
901
|
+
// 不能为了多带一个参数去改它(那会让既有变异验证变成"变异点未找到"——本轮实际踩到过)。
|
|
902
|
+
let noProgressCause = /** @type {'own'|'inherited'} */ ('own');
|
|
903
|
+
const fireNoProgress = () => {
|
|
904
|
+
const cause = noProgressCause;
|
|
905
|
+
noProgressFired = true;
|
|
906
|
+
const waited = Date.now() - turnProgress.lastProgressAt;
|
|
907
|
+
turnProgress.stalled = true;
|
|
908
|
+
turnProgress.stallCause = cause;
|
|
909
|
+
turnProgress.phase = cause === 'inherited' ? '已中止(父回合无进展)' : '已中止(无进展)';
|
|
910
|
+
turnProgress.waitedMs = waited;
|
|
911
|
+
const pt = turnProgress.pendingTool;
|
|
912
|
+
turnProgress.note =
|
|
913
|
+
cause === 'inherited'
|
|
914
|
+
? inheritedStopNotice({ waitedMs: waited, toolCalls: turnProgress.toolCalls, modelRounds: turnProgress.modelRounds })
|
|
915
|
+
: noProgressNotice({
|
|
916
|
+
waitedMs: waited,
|
|
917
|
+
toolCalls: turnProgress.toolCalls,
|
|
918
|
+
modelRounds: turnProgress.modelRounds,
|
|
919
|
+
unproductive: turnProgress.unproductive,
|
|
920
|
+
// 问题 4:必须说清"在等哪个工具、等了多久",否则用户无从判断该调什么
|
|
921
|
+
pendingTool: pt ? { name: String(pt.name || ''), ms: Date.now() - Number(pt.startedAt || Date.now()) } : null,
|
|
922
|
+
subagent: turnProgress.subagent,
|
|
923
|
+
});
|
|
924
|
+
try { io.print(style(turnProgress.note, C.yellow)); } catch {}
|
|
925
|
+
try { currentAc?.abort(new Error(`无进展 ${Math.max(1, Math.round(waited / 60000))} 分钟,已中止`)); } catch {}
|
|
926
|
+
};
|
|
927
|
+
const armNoProgress = () => {
|
|
928
|
+
if (noProgressTimer) clearTimeout(noProgressTimer);
|
|
929
|
+
const ownDeadline = turnProgress.lastProgressAt + noProgressMs;
|
|
930
|
+
const inh = inheritedDeadlineAt();
|
|
931
|
+
// 正等子代理时给一点点宽限,保证**子代理自己的看门狗先响**并把原因回传父回合(见常量注释)。
|
|
932
|
+
const grace = turnProgress.subagent && turnProgress.pendingTool ? SUBAGENT_WAIT_GRACE_MS : 0;
|
|
933
|
+
// 继承的截止时刻只**提前**("一整棵树一起收口");自己的判据之外只多那点宽限。
|
|
934
|
+
const useInherited = inh != null && inh < ownDeadline;
|
|
935
|
+
const target = useInherited ? inh : ownDeadline + grace;
|
|
936
|
+
const left = Math.max(1000, target - Date.now());
|
|
937
|
+
noProgressCause = useInherited ? 'inherited' : 'own';
|
|
938
|
+
noProgressTimer = setTimeout(fireNoProgress, left);
|
|
939
|
+
try { noProgressTimer.unref?.(); } catch {} // 不因为这个计时器拖住进程退出
|
|
940
|
+
};
|
|
941
|
+
/** 收尾提示:惰性计算一次并缓存(perf() 与 finally 都取它,界面只打印一次)。 */
|
|
942
|
+
const ensureZeroToolCallNotice = () => {
|
|
943
|
+
if (turnProgress.zeroToolCallsNote === undefined || turnProgress.zeroToolCallsNote === null) {
|
|
944
|
+
const n = zeroToolCallNotice({ modelRounds: turnProgress.modelRounds, steps, taskLike: turnUserTaskLike });
|
|
945
|
+
turnProgress.zeroToolCallsNote = n || null;
|
|
946
|
+
if (n) turnProgress.zeroToolCalls = true;
|
|
947
|
+
}
|
|
948
|
+
return turnProgress.zeroToolCallsNote;
|
|
949
|
+
};
|
|
950
|
+
/** 有真实进展才续期:工具结果返回 / 模型正文或推理增量。 */
|
|
951
|
+
let lastSubNotifyAt = 0;
|
|
952
|
+
const markProgress = (/** @type {string} */ why) => {
|
|
953
|
+
const now = Date.now();
|
|
954
|
+
turnProgress.lastProgressAt = now;
|
|
955
|
+
turnProgress.progressReason = why;
|
|
956
|
+
armNoProgress();
|
|
957
|
+
// v0.6.13(问题 3 的核心修法):**子代理的进展必须算父回合的进展**。
|
|
958
|
+
// 真机现场:父回合第 12 轮派出两个只读子代理,子代理在 10 分钟里跑了 15 轮 / 18 次工具调用
|
|
959
|
+
// (会话记录原文),父回合却只看得到"正在等待工具 task(已 620s)"→ 看门狗判"整轮无进展"
|
|
960
|
+
// 把父子一起掐了。父回合此刻并不能从工具结果里拿到任何中间信号,所以进展必须由子代理
|
|
961
|
+
// **主动回传**(onSubagentProgress)——父回合据此续期,并把快照挂到 turnProgress.subagent,
|
|
962
|
+
// 让界面/日志能写出"在等哪个子任务、它跑到第几轮"。
|
|
963
|
+
// 节流 1s:增量帧可能成百上千,父回合的续期只需"还在动"这个事实。
|
|
964
|
+
// 例外:**工具结果**是低频且高价值的进展信号("子代理跑到第几步"就靠它),一律照原样回传。
|
|
965
|
+
const isToolStep = /^工具 /.test(why);
|
|
966
|
+
if (typeof cfg?.onSubagentProgress === 'function' && (isToolStep || now - lastSubNotifyAt >= 1000)) {
|
|
967
|
+
lastSubNotifyAt = now;
|
|
968
|
+
try {
|
|
969
|
+
cfg.onSubagentProgress({
|
|
970
|
+
label: String(cfg?.subagentLabel || ''),
|
|
971
|
+
modelRounds: turnProgress.modelRounds,
|
|
972
|
+
toolCalls: turnProgress.toolCalls,
|
|
973
|
+
reason: why,
|
|
974
|
+
});
|
|
975
|
+
} catch {}
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
/** 子代理进展 → 本回合续期 + 记录快照(供 UI/日志"在等哪个子任务、它到哪一步了")。 */
|
|
979
|
+
turnProgress.onSubagentProgress = (/** @type {any} */ info) => {
|
|
980
|
+
const label = String(info?.label || '');
|
|
981
|
+
const rounds = Number(info?.modelRounds) || 0;
|
|
982
|
+
const calls = Number(info?.toolCalls) || 0;
|
|
983
|
+
// 只前进不后退:并行子代理里"后到的旧快照"不得把更靠前的进展覆盖回去
|
|
984
|
+
const prev = turnProgress.subagent;
|
|
985
|
+
if (!prev || rounds + calls >= (Number(prev.modelRounds) || 0) + (Number(prev.toolCalls) || 0)) {
|
|
986
|
+
turnProgress.subagent = { label, modelRounds: rounds, toolCalls: calls, at: Date.now(), reason: String(info?.reason || '') };
|
|
987
|
+
} else if (prev) {
|
|
988
|
+
prev.at = Date.now();
|
|
989
|
+
}
|
|
990
|
+
markProgress(`子代理进展${label ? `(${label})` : ''}`);
|
|
991
|
+
};
|
|
992
|
+
armNoProgress();
|
|
488
993
|
// ⚠ 两个 ctx,别混:
|
|
489
994
|
// · `ctx`(内核 ctx)——本轮内核自己用(循环里的权限判定走闭包里的 permission 本体);
|
|
490
995
|
// 它**不裁剪、不冻结**,且**不交给任何工具**。
|
|
@@ -586,8 +1091,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
586
1091
|
// v0.6.5(审计 BUG-025,实测复现):Ctrl+C 落在**工具执行期间**时,此前只把 `aborted`
|
|
587
1092
|
// 置真、却没有任何收敛点——工具跑完仍会走回这里发出**下一次请求**(实测:SIGINT 之后
|
|
588
1093
|
// chat 仍被调用 2 次)。这里与工具循环里的检查一起把它收口成「立刻停止、如实回报 aborted」。
|
|
589
|
-
if (aborted) {
|
|
1094
|
+
if (aborted || noProgressFired) {
|
|
590
1095
|
stripOrphanCalls();
|
|
1096
|
+
// v0.6.13(A):无进展看门狗触发——如实报告,而不是当作"正常结束"或"用户中断"。
|
|
1097
|
+
if (noProgressFired) {
|
|
1098
|
+
markOutcome('stalled');
|
|
1099
|
+
return {
|
|
1100
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false, stalled: true,
|
|
1101
|
+
note: turnProgress.note || noProgressNotice({ waitedMs: Date.now() - turnProgress.lastProgressAt, toolCalls: turnProgress.toolCalls, modelRounds: turnProgress.modelRounds }),
|
|
1102
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
591
1105
|
return {
|
|
592
1106
|
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true,
|
|
593
1107
|
note: '已按 Ctrl+C 中断(工具执行期间收到中断信号),本回合未再发出请求。',
|
|
@@ -603,6 +1117,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
603
1117
|
// 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
|
|
604
1118
|
if (cfg.autoCompact !== false) {
|
|
605
1119
|
try {
|
|
1120
|
+
if (turnProgress.phase !== '已中止(无进展)') turnProgress.phase = '压缩上下文';
|
|
606
1121
|
const compacted = await compactConversation({
|
|
607
1122
|
messages,
|
|
608
1123
|
budget,
|
|
@@ -754,11 +1269,36 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
754
1269
|
io.beginTurn();
|
|
755
1270
|
io.startSpinner('正在思考…');
|
|
756
1271
|
|
|
1272
|
+
// v0.6.13(A):**发送前的规模预告**——本地模型"prefill 数十分钟"必须先说出来,
|
|
1273
|
+
// 而不是让用户盯着一个没有任何输出的界面猜。阈值与文案见 prefillForecast()。
|
|
1274
|
+
// 首帧上限与 providers/index.js 同口径(那里没有导出,这里镜像;本地 600s / 远程 300s)。
|
|
1275
|
+
{
|
|
1276
|
+
const effFirstTokenMs =
|
|
1277
|
+
Number(cfg?.timeout?.firstTokenMs) > 0 ? Number(cfg.timeout.firstTokenMs) : caps.isLocal ? 600000 : 300000;
|
|
1278
|
+
try {
|
|
1279
|
+
let promptTokens = 0;
|
|
1280
|
+
for (const m of sanitized) promptTokens += messageTokens(m, count);
|
|
1281
|
+
const f = prefillForecast({
|
|
1282
|
+
promptTokens,
|
|
1283
|
+
contextWindow: caps.contextWindow,
|
|
1284
|
+
isLocal: caps.isLocal,
|
|
1285
|
+
firstTokenMs: Number(cfg?.timeout?.firstTokenMs) > 0 ? Number(cfg.timeout.firstTokenMs) : effFirstTokenMs,
|
|
1286
|
+
budget,
|
|
1287
|
+
probe: cfg?.endpointProbe || null,
|
|
1288
|
+
});
|
|
1289
|
+
turnProgress.forecast = f;
|
|
1290
|
+
// 首次请求一定说;之后只在命中告警线时说(否则每步一条横幅会刷屏)
|
|
1291
|
+
if (turnProgress.llmCalls === 0 || f.warn) io.print(style(f.text, f.warn ? C.yellow : C.dim));
|
|
1292
|
+
} catch {}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
757
1295
|
let res;
|
|
758
1296
|
// 审计(tsc 扩面发现):llmT0 此前在 try 内声明、catch 内引用——chat 抛错时
|
|
759
1297
|
// catch 自身 ReferenceError,掩盖原始错误且计时丢失;提到 try 外声明。
|
|
760
1298
|
const llmT0 = Date.now();
|
|
761
1299
|
lastRequestStartAt = llmT0;
|
|
1300
|
+
turnProgress.llmCalls += 1;
|
|
1301
|
+
turnProgress.phase = '发请求(等首帧)';
|
|
762
1302
|
try {
|
|
763
1303
|
res = await provider.chat({
|
|
764
1304
|
model: activeModel,
|
|
@@ -771,7 +1311,22 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
771
1311
|
signal: ac.signal,
|
|
772
1312
|
onDelta(/** @type {any} */ d) {
|
|
773
1313
|
io.stopSpinner();
|
|
1314
|
+
// v0.6.13(A):帧/字符计数 + 首帧时延(TTFT)——本地模型最关键的指标
|
|
1315
|
+
turnProgress.streamFrames += 1;
|
|
1316
|
+
const chunkChars = (d.text ? String(d.text).length : 0) + (d.reasoning ? String(d.reasoning).length : 0);
|
|
1317
|
+
if (chunkChars) turnProgress.contentChars += chunkChars;
|
|
774
1318
|
if (firstTokenAt == null) firstTokenAt = Date.now(); // 首个增量即首 token
|
|
1319
|
+
const ttft = firstTokenAt - llmT0;
|
|
1320
|
+
turnProgress.lastTtftMs = ttft;
|
|
1321
|
+
if (turnProgress.ttftMs == null) turnProgress.ttftMs = ttft;
|
|
1322
|
+
turnProgress.phase = '解码中(收到增量)';
|
|
1323
|
+
// TTFT 留一行:首次请求必说;后续请求只在"慢"(≥30s)时说——那正是需要被看见的情形
|
|
1324
|
+
if (turnProgress.llmCalls === 1 || ttft >= 30000) {
|
|
1325
|
+
try {
|
|
1326
|
+
io.print(style(`⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(第 ${turnProgress.llmCalls} 次模型请求;本地模型这一项直接反映 prefill 快慢)`, C.dim));
|
|
1327
|
+
} catch {}
|
|
1328
|
+
}
|
|
1329
|
+
markProgress('模型增量');
|
|
775
1330
|
if (d.text) io.writeText(d.text);
|
|
776
1331
|
if (d.reasoning) {
|
|
777
1332
|
reasoningTokens += approxTokens(d.reasoning); // 省钱 B3:推理 token 估算(分账维度)
|
|
@@ -780,10 +1335,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
780
1335
|
},
|
|
781
1336
|
});
|
|
782
1337
|
llmMsTotal += Date.now() - llmT0;
|
|
1338
|
+
turnProgress.modelRounds += 1;
|
|
1339
|
+
// v0.6.13(A):**"轮次返回"本身不算进展**——真机现场就是"每十几分钟返回一次、
|
|
1340
|
+
// 每次都是一模一样的失败工具调用",若把返回当进展,看门狗永远不会响。
|
|
1341
|
+
// 只有真的产出了正文/推理才算(工具调用的进展由 finishTool 按"成功且不重复"判定)。
|
|
1342
|
+
if (res.text || res.reasoning) markProgress('模型轮次返回(有正文/推理)');
|
|
783
1343
|
} catch (err) {
|
|
784
1344
|
llmMsTotal += Date.now() - llmT0;
|
|
785
1345
|
io.stopSpinner();
|
|
786
1346
|
io.endTurn();
|
|
1347
|
+
// v0.6.13(A):看门狗触发的中止——如实报告"连续 N 分钟无进展",不冒充 Ctrl+C/正常结束
|
|
1348
|
+
if (noProgressFired) {
|
|
1349
|
+
stripOrphanCalls();
|
|
1350
|
+
markOutcome('stalled');
|
|
1351
|
+
return {
|
|
1352
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false, stalled: true,
|
|
1353
|
+
note: turnProgress.note || noProgressNotice({ waitedMs: Date.now() - turnProgress.lastProgressAt, toolCalls: turnProgress.toolCalls, modelRounds: turnProgress.modelRounds }),
|
|
1354
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
787
1357
|
if (aborted) {
|
|
788
1358
|
stripOrphanCalls();
|
|
789
1359
|
markOutcome('aborted', { aborted: true });
|
|
@@ -809,6 +1379,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
809
1379
|
perf: perf(),
|
|
810
1380
|
};
|
|
811
1381
|
}
|
|
1382
|
+
// v0.6.13(A):**上游/引擎错误必须透出且可操作**。
|
|
1383
|
+
// 此前这里直接 `throw err`:原文(400 的模板报错、引擎的 memory_refusal、内核自己的三类超时)
|
|
1384
|
+
// 只在最外层变成一条 error 事件,用户看到一坨堆栈式原文却不知道该改什么;子代理路径里
|
|
1385
|
+
// 它更会被压成一行工具结果。现在统一补一条"这是什么 + 该怎么办",并**同时**在界面上打一条,
|
|
1386
|
+
// 让"错误被谁吞掉"不再可能(原始 message 一字不改地保留在首行)。
|
|
1387
|
+
{
|
|
1388
|
+
const d = describeUpstreamError(e);
|
|
1389
|
+
if (d.hint) {
|
|
1390
|
+
try { io.print(style(`⚠ 上游错误:${d.message}\n ${d.hint}`, C.yellow)); } catch {}
|
|
1391
|
+
const enriched = new Error(`${d.message}\n ${d.hint}`);
|
|
1392
|
+
if (e && typeof e === 'object') {
|
|
1393
|
+
if (e.status !== undefined) /** @type {any} */ (enriched).status = e.status;
|
|
1394
|
+
if (e.headers !== undefined) /** @type {any} */ (enriched).headers = e.headers;
|
|
1395
|
+
}
|
|
1396
|
+
throw enriched;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
812
1399
|
throw err;
|
|
813
1400
|
}
|
|
814
1401
|
|
|
@@ -876,6 +1463,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
876
1463
|
}
|
|
877
1464
|
|
|
878
1465
|
if (res.toolCalls?.length) {
|
|
1466
|
+
// v0.6.13(A):本回合 tool_call 计数(界面/日志/收尾提示的单一来源)
|
|
1467
|
+
turnProgress.toolCalls += res.toolCalls.length;
|
|
879
1468
|
// v0.2.8 步数上限收尾(对齐 DSH):末轮仍返回工具调用(无视收尾指令)时不再执行工具——
|
|
880
1469
|
// 有正文直接收尾,无正文跳出循环进入兜底总结,避免再次跑满步数后静默结束。
|
|
881
1470
|
if (steps === stepLimit) {
|
|
@@ -1034,6 +1623,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1034
1623
|
// 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
|
|
1035
1624
|
async function runTool(/** @type {any} */ prep) {
|
|
1036
1625
|
io.renderToolStart?.(prep.name, prep.args);
|
|
1626
|
+
// v0.6.13(A):登记"正在等待哪个工具"——`task` 子代理/长命令动辄几十分钟,
|
|
1627
|
+
// 界面与日志必须能显示"在等谁、等了多久",否则与卡死无法区分。
|
|
1628
|
+
const prevPending = turnProgress.pendingTool;
|
|
1629
|
+
turnProgress.pendingTool = { name: String(prep.name || ''), startedAt: Date.now() };
|
|
1630
|
+
turnProgress.phase = `执行工具 ${prep.name}`;
|
|
1037
1631
|
// Pack 工具名形如 pack__<pack>__<tool>;执行期间标注来源,供 ctx.llm 归因
|
|
1038
1632
|
const pkMatch = /^pack__([a-z0-9-]+)__/.exec(String(prep.name || ''));
|
|
1039
1633
|
const prevPack = currentPack;
|
|
@@ -1042,6 +1636,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1042
1636
|
const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
|
|
1043
1637
|
if (dedupKey && turnToolCache.has(dedupKey)) {
|
|
1044
1638
|
prep.cached = true;
|
|
1639
|
+
turnProgress.pendingTool = prevPending;
|
|
1045
1640
|
return turnToolCache.get(dedupKey);
|
|
1046
1641
|
}
|
|
1047
1642
|
try {
|
|
@@ -1064,6 +1659,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1064
1659
|
return JSON.stringify({ ok: false, error: String(err?.message || err) });
|
|
1065
1660
|
} finally {
|
|
1066
1661
|
currentPack = prevPack; // 还原(并行只读批次下 finally 保证不乱序)
|
|
1662
|
+
turnProgress.pendingTool = prevPending;
|
|
1067
1663
|
}
|
|
1068
1664
|
}
|
|
1069
1665
|
|
|
@@ -1071,6 +1667,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1071
1667
|
function finishTool(/** @type {any} */ prep, /** @type {any} */ result, /** @type {any} */ t0) {
|
|
1072
1668
|
const ms = Date.now() - t0;
|
|
1073
1669
|
toolMsTotal += ms;
|
|
1670
|
+
// v0.6.13(A):**只有"成功且不重复"的工具结果才算进展**。
|
|
1671
|
+
// 真机现场(610 次工具调用、4+ 小时零交付)里大量是**完全相同**的重复调用
|
|
1672
|
+
// (`skill {}` 155 次、同一个 `git clone` 43 次…)与失败调用——若把它们也算进展,
|
|
1673
|
+
// 看门狗永远不会响,"工具在跑但什么都没做成"就永远抓不住。
|
|
1674
|
+
turnProgress.toolExecuted += 1;
|
|
1675
|
+
const sig = `${prep.name}|${JSON.stringify(prep.args || {})}`;
|
|
1676
|
+
const repeatedBefore = turnSigCounts.get(sig) || 0;
|
|
1677
|
+
turnSigCounts.set(sig, repeatedBefore + 1);
|
|
1678
|
+
let toolOk = true;
|
|
1679
|
+
if (typeof result === 'string') {
|
|
1680
|
+
toolOk = !result.includes('"ok": false');
|
|
1681
|
+
} else if (result && typeof result === 'object') {
|
|
1682
|
+
toolOk = result.ok !== false;
|
|
1683
|
+
}
|
|
1684
|
+
if (toolOk && repeatedBefore === 0) markProgress(`工具 ${prep.name} 返回`);
|
|
1685
|
+
else if (toolOk) { turnProgress.unproductive += 1; turnProgress.repeatedCalls = (turnProgress.repeatedCalls || 0) + 1; }
|
|
1686
|
+
else turnProgress.unproductive += 1;
|
|
1074
1687
|
// 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
|
|
1075
1688
|
const ts = toolStats.get(prep.name) || { calls: 0, ms: 0 };
|
|
1076
1689
|
ts.calls += 1;
|
|
@@ -1358,7 +1971,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1358
1971
|
// v0.4.1 修复:输入轻量化——此前用 trimMessages(messages, budget) 全量历史,本地 q8 量化模型
|
|
1359
1972
|
// (prefill ~165 tok/s)≈98k token 的 prefill 逼近/超过 600s 首 token 超时 → 总结请求失败被吞 →
|
|
1360
1973
|
// 表现为「输出截断/子代理无反馈」。改用 system + 交付物清单 + 提示(几 k token),慢 prefill 也能秒出总结。
|
|
1361
|
-
|
|
1974
|
+
// v0.6.13(A):看门狗已判定"整回合无进展"时**不再发兜底总结请求**——
|
|
1975
|
+
// 那正是又一次要等几分钟到几十分钟的请求,而 0 工具调用/0 增量的事实不会因此改变。
|
|
1976
|
+
if (!aborted && !noProgressFired && messages.length) {
|
|
1362
1977
|
try {
|
|
1363
1978
|
const sys = messages.find((/** @type {any} */ m) => m.role === 'system');
|
|
1364
1979
|
const wrapReq = [
|
|
@@ -1412,12 +2027,21 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1412
2027
|
try { io.print(style(`⚠ 兜底总结失败:${String(err?.message || err)}`, C.yellow)); } catch {}
|
|
1413
2028
|
}
|
|
1414
2029
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
2030
|
+
// v0.6.13(A):看门狗触发时以 'stalled' 收尾(不是 capped:不是预算用尽,是"零进展")
|
|
2031
|
+
markOutcome(noProgressFired ? 'stalled' : 'capped', { capHit: !noProgressFired, truncated: !noProgressFired });
|
|
2032
|
+
return {
|
|
2033
|
+
text: null, reasoning: '', usage, steps, finish, truncated: outcome.truncated, aborted: false,
|
|
2034
|
+
capHit: outcome.capHit, upstreamTruncated, ...(noProgressFired ? { stalled: true, note: turnProgress.note || undefined } : {}),
|
|
2035
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
2036
|
+
};
|
|
1417
2037
|
}
|
|
1418
2038
|
// 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
|
|
1419
|
-
markOutcome('capped', { capHit:
|
|
1420
|
-
return {
|
|
2039
|
+
markOutcome(noProgressFired ? 'stalled' : 'capped', { capHit: !noProgressFired, truncated: !noProgressFired });
|
|
2040
|
+
return {
|
|
2041
|
+
text: null, reasoning: '', usage, steps, finish, truncated: outcome.truncated, aborted: false,
|
|
2042
|
+
capHit: outcome.capHit, upstreamTruncated, ...(noProgressFired ? { stalled: true, note: turnProgress.note || undefined } : {}),
|
|
2043
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
2044
|
+
};
|
|
1421
2045
|
} catch (/** @type {any} */ err) {
|
|
1422
2046
|
// 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
|
|
1423
2047
|
// assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
|
|
@@ -1427,6 +2051,19 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1427
2051
|
} finally {
|
|
1428
2052
|
currentAc = null;
|
|
1429
2053
|
currentUsage = null; // 回合结束:避免 runTurn 之外调用的 spawnTask 写入陈旧累加器
|
|
2054
|
+
// v0.6.13(A):看门狗计时器必须随回合结束清掉(每回合一个,长驻 WebUI 里不能泄漏);
|
|
2055
|
+
// 同时把"本回合 0 次工具调用"如实说出来——否则端点不返回 tool_calls 时,界面看起来
|
|
2056
|
+
// 一切正常(有正文、ok:true),实际一次工具都没调,"看起来在工作、其实什么都没做"。
|
|
2057
|
+
if (noProgressTimer) clearTimeout(noProgressTimer);
|
|
2058
|
+
turnProgress.pendingTool = null;
|
|
2059
|
+
currentTurnProgress = null;
|
|
2060
|
+
try {
|
|
2061
|
+
const notice = ensureZeroToolCallNotice();
|
|
2062
|
+
if (notice && !turnProgress.zeroToolCallsPrinted) {
|
|
2063
|
+
turnProgress.zeroToolCallsPrinted = true;
|
|
2064
|
+
io.print(style(notice, C.yellow));
|
|
2065
|
+
}
|
|
2066
|
+
} catch {}
|
|
1430
2067
|
// v0.6.0 C1:回合收尾事件(状态/步数/费用)。
|
|
1431
2068
|
// 费用按**发起时刻**计价(lastRequestStartAt)——与 recordUsage 同款口径,跨 12:00/18:00
|
|
1432
2069
|
// 边界的请求才不会被错记一档。estimateCost 对无价模型返回 null,于是 priced:false 会与
|