mocode-ai 0.7.0 → 0.7.2
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 +4 -3
- package/README.zh-CN.md +3 -3
- package/dist/__trace_manual_test__.js +1 -0
- package/dist/agent/core.js +621 -260
- package/dist/agent/index.js +20 -0
- package/dist/agent/spawn.js +9 -1
- package/dist/config/index.js +12 -0
- package/dist/i18n/index.js +34 -0
- package/dist/llm/index.js +20 -2
- package/dist/mcp/index.js +15 -2
- package/dist/permissions/index.js +149 -93
- package/dist/repl/index.js +66 -10
- package/dist/rollback/index.js +36 -0
- package/dist/session/index.js +3 -0
- package/dist/session/trace-metrics.js +70 -0
- package/dist/session/trace-sanitize.js +34 -0
- package/dist/session/trace.js +54 -0
- package/dist/tools/builtins/edit-file.js +13 -1
- package/dist/tools/builtins/index.js +42 -4
- package/dist/tools/builtins/run-command.js +115 -66
- package/dist/tools/builtins/task.js +5 -2
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/constants.js +5 -1
- package/dist/tools/registry.js +141 -39
- package/dist/tools/resource-lock.js +148 -0
- package/dist/verification/affected.js +149 -0
- package/dist/verification/diagnostics.js +108 -0
- package/dist/verification/discovery.js +48 -0
- package/dist/verification/fingerprint.js +54 -0
- package/dist/verification/index.js +333 -0
- package/dist/verification/postconditions.js +98 -0
- package/dist/verification/profile.js +237 -0
- package/dist/verification/targeted-tests.js +96 -0
- package/dist/verification/types.js +1 -0
- package/package.json +5 -2
package/dist/agent/core.js
CHANGED
|
@@ -6,20 +6,24 @@
|
|
|
6
6
|
// spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { chat, estimatePromptTokens, planChatTools, chatTools, } from '../llm/index.js';
|
|
9
|
-
import {
|
|
9
|
+
import { executeToolOutcome, getToolCapabilities, isFileMutationTool, tools, } from '../tools/registry.js';
|
|
10
10
|
import { checkPermission } from '../permissions/index.js';
|
|
11
|
-
import { getPlanDisabledTools } from '../tools/constants.js';
|
|
11
|
+
import { getPlanDisabledTools, getRuntimeDisabledTools } from '../tools/constants.js';
|
|
12
12
|
import { getAgentMode, setAgentMode } from './mode.js';
|
|
13
|
-
import { maybeCompact, contextState, dropContextFromHistory } from '../session/index.js';
|
|
13
|
+
import { maybeCompact, contextState, dropContextFromHistory, createTraceEvent, summarizeToolArguments, safeProviderId, } from '../session/index.js';
|
|
14
14
|
import { createBudgetScheduler } from '../session/scheduler.js';
|
|
15
15
|
import { optimizeToolResult, HOT_TURN_WINDOW, userTurnBoundary } from '../context/index.js';
|
|
16
16
|
import { createAgeAwareEncodingState, } from '../context/age-aware.js';
|
|
17
17
|
import { createRelevancePruner } from '../context/relevance.js';
|
|
18
18
|
import { isToolResultSuccess } from '../context/utils.js';
|
|
19
19
|
import { config } from '../config/index.js';
|
|
20
|
+
import { t } from '../i18n/index.js';
|
|
20
21
|
import { jailResolve } from '../sandbox/index.js';
|
|
21
22
|
import { createLifecycleEngine } from '../context/lifecycle.js';
|
|
22
23
|
import { getTokenCalibration, updateTokenCalibration, } from '../context/token-calibration.js';
|
|
24
|
+
import { getCurrentTurnId, getCurrentTurnMutationState } from '../rollback/index.js';
|
|
25
|
+
import { createAutomaticValidator, } from '../verification/index.js';
|
|
26
|
+
import { getCurrentSessionId } from '../session/state.js';
|
|
23
27
|
/** Stable per-history age state survives user turns; WeakMap avoids retaining closed sessions. */
|
|
24
28
|
const ageAwareStateByHistory = new WeakMap();
|
|
25
29
|
function ageAwareStateFor(history) {
|
|
@@ -57,17 +61,27 @@ function thrashHint(name, args, count) {
|
|
|
57
61
|
'- edit_file → old_string mismatch; re-read the file to find the exact text\n' +
|
|
58
62
|
'- otherwise → re-read the tool description; the argument shape may be wrong');
|
|
59
63
|
}
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
/** 只有显式声明 parallel 且无需权限确认的工具才进入普通并发组。 */
|
|
65
|
+
function isParallelTool(name) {
|
|
66
|
+
const tool = tools.find((candidate) => candidate.name === name);
|
|
67
|
+
return !!tool && (tool.risk ?? 'safe') === 'safe' &&
|
|
68
|
+
getToolCapabilities(tool).concurrency === 'parallel';
|
|
69
|
+
}
|
|
70
|
+
/** resource-locked 工具先顺序完成权限预检,再依赖 canonical resource lock 并发执行。 */
|
|
71
|
+
function isResourceLockedTool(name) {
|
|
72
|
+
const tool = tools.find((candidate) => candidate.name === name);
|
|
73
|
+
return !!tool && getToolCapabilities(tool).concurrency === 'resource-locked';
|
|
74
|
+
}
|
|
75
|
+
/** 文件 mutation 由 capability metadata 判定,供 diff、回滚与上下文失效共用。 */
|
|
76
|
+
const isMutationTool = (name) => isFileMutationTool(name);
|
|
77
|
+
function deniedOutcome(name) {
|
|
78
|
+
return {
|
|
79
|
+
status: 'denied',
|
|
80
|
+
code: 'PERMISSION_DENIED',
|
|
81
|
+
retryable: false,
|
|
82
|
+
output: `错误:用户拒绝了工具 ${name} 的执行。`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
71
85
|
/** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
|
|
72
86
|
* edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
|
|
73
87
|
* 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
|
|
@@ -116,8 +130,8 @@ function readDiffContext(tc, parsed) {
|
|
|
116
130
|
* - lifecycle 也在每个 runAgentCore 实例化一次,登记 grep/glob/codegraph 等 producer
|
|
117
131
|
* 与 read/edit/write 的 consumer 关系;孤立+老化自动 STUB(观察类工具永不到 STUB)。
|
|
118
132
|
* - 开关关闭时 lifecycle=null 完全跳过。 */
|
|
119
|
-
function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState) {
|
|
120
|
-
const succeeded = isToolResultSuccess(output);
|
|
133
|
+
function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState, succeededOverride) {
|
|
134
|
+
const succeeded = succeededOverride ?? isToolResultSuccess(output);
|
|
121
135
|
const ageAware = config.contextOptimize ? ageAwareStateFor(history) : null;
|
|
122
136
|
const encodingContext = ageAware?.preparePush(tc, succeeded);
|
|
123
137
|
const msg = {
|
|
@@ -157,7 +171,37 @@ export async function runAgentCore(opts) {
|
|
|
157
171
|
const savedMode = getAgentMode();
|
|
158
172
|
// 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
|
|
159
173
|
const t0 = Date.now();
|
|
174
|
+
const traceSessionId = opts.traceContext?.sessionId ?? getCurrentSessionId() ?? `ephemeral-${process.pid}`;
|
|
175
|
+
const traceTurnId = opts.traceContext?.turnId ?? getCurrentTurnId();
|
|
176
|
+
let currentTraceStep;
|
|
177
|
+
let abortTraced = false;
|
|
178
|
+
const emitTrace = (type, data = {}, ids = {}) => {
|
|
179
|
+
try {
|
|
180
|
+
opts.onTraceEvent?.(createTraceEvent({
|
|
181
|
+
sessionId: traceSessionId,
|
|
182
|
+
turnId: traceTurnId,
|
|
183
|
+
type,
|
|
184
|
+
...(currentTraceStep === undefined ? {} : {
|
|
185
|
+
step: currentTraceStep,
|
|
186
|
+
stepId: `${traceTurnId}:step:${currentTraceStep}`,
|
|
187
|
+
}),
|
|
188
|
+
...ids,
|
|
189
|
+
data,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Trace is best-effort and must never alter execution.
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
emitTrace('turn_start', { mode: getAgentMode() });
|
|
160
197
|
let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
|
|
198
|
+
let traceStatus = 'error';
|
|
199
|
+
let toolCallCount = 0;
|
|
200
|
+
let latestValidation;
|
|
201
|
+
const initialMutationVersion = getCurrentTurnMutationState().version;
|
|
202
|
+
let validatedMutationVersion = initialMutationVersion;
|
|
203
|
+
let fullyValidatedMutationVersion = initialMutationVersion;
|
|
204
|
+
const validator = opts.validator ?? createAutomaticValidator();
|
|
161
205
|
// 本轮 token 累计:每步 chat() 返回后把 result.usage 累加,供 onDone 摘要行 + AgentRunResult.usage
|
|
162
206
|
// 透传给 repl(显示在底栏模式 chip 右边)。未开启 include_usage 或全失败时为 undefined。
|
|
163
207
|
let turnUsage;
|
|
@@ -217,8 +261,8 @@ export async function runAgentCore(opts) {
|
|
|
217
261
|
let mode = 'idle';
|
|
218
262
|
let gotText = false;
|
|
219
263
|
let lastChar = '';
|
|
220
|
-
// 早退重探:本 turn 已执行过工具但模型突然返回无工具调用 +
|
|
221
|
-
//
|
|
264
|
+
// 早退重探:本 turn 已执行过工具但模型突然返回无工具调用 + 空文本 → 推一条提示让模型继续。
|
|
265
|
+
// 短回复也可能是合法完成结果,不再按字符数误判;每 turn 最多触发 1 次以防死循环。
|
|
222
266
|
let nudgeCount = 0;
|
|
223
267
|
let hadToolsThisTurn = false;
|
|
224
268
|
const onText = (s) => {
|
|
@@ -240,6 +284,10 @@ export async function runAgentCore(opts) {
|
|
|
240
284
|
// 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原到本 turn 前 + 模式还原。
|
|
241
285
|
// 两处共用:① await chat() 抛 AbortError 的 catch;② 工具被 abort 杀后循环顶检查。
|
|
242
286
|
const abortRestore = () => {
|
|
287
|
+
if (!abortTraced) {
|
|
288
|
+
emitTrace('abort', { phase: 'observed', reason: 'signal' });
|
|
289
|
+
abortTraced = true;
|
|
290
|
+
}
|
|
243
291
|
hooks.onAbort?.();
|
|
244
292
|
history.length = 0;
|
|
245
293
|
history.push(...savedHistory);
|
|
@@ -248,279 +296,592 @@ export async function runAgentCore(opts) {
|
|
|
248
296
|
};
|
|
249
297
|
try {
|
|
250
298
|
for (let step = 0; step < maxSteps; step++) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return { completed: false, finalText: null };
|
|
255
|
-
}
|
|
256
|
-
// 本步只计算一次实际工具集合,调度、请求和 usage 校准必须使用完全相同的 schema。
|
|
257
|
-
const activeTools = opts.toolsOverride
|
|
258
|
-
?? (getAgentMode() === 'plan' ? planChatTools : chatTools);
|
|
259
|
-
const requestBaseURL = config.baseURL;
|
|
260
|
-
const requestModel = config.model;
|
|
261
|
-
const storedCalibration = getTokenCalibration(requestBaseURL, requestModel, activeTools);
|
|
262
|
-
runtimeContextState.correction = storedCalibration.correction;
|
|
263
|
-
runtimeContextState.calibrationSamples = storedCalibration.samples;
|
|
264
|
-
// 初次 tool push 只做保守编码;预算评估前先对 Cold 且 age 达阈值的旧结果降级,
|
|
265
|
-
// 避免 scheduler 根据马上会被 sweep 的陈旧占用误触发 history compact。
|
|
266
|
-
ageAware?.sweep(history, userTurnBoundary(history, HOT_TURN_WINDOW));
|
|
267
|
-
// 步前:五区 Budget Scheduler 在优化后的 history 上决策;开关关闭时退化回原 maybeCompact 路径。
|
|
268
|
-
// 此时 spinner 已停,通知行干净。
|
|
269
|
-
let historyRebuilt = false;
|
|
270
|
-
if (scheduler) {
|
|
271
|
-
historyRebuilt = await scheduler.runStep(history, step, activeTools);
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools);
|
|
275
|
-
historyRebuilt = compactResult?.historyRebuilt === true;
|
|
276
|
-
}
|
|
277
|
-
// compact 用新消息数组原地重建 history 后,所有按消息位置恢复的状态都需重建。
|
|
278
|
-
if (historyRebuilt) {
|
|
279
|
-
if (lifecycle) {
|
|
280
|
-
lifecycle = createLifecycleEngine(history);
|
|
281
|
-
runtimeContextState.lifecycleStats = lifecycle.stats();
|
|
282
|
-
}
|
|
283
|
-
ageAware?.rehydrate(history);
|
|
284
|
-
}
|
|
285
|
-
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
286
|
-
mode = 'idle';
|
|
287
|
-
gotText = false;
|
|
288
|
-
lastChar = '';
|
|
289
|
-
let result;
|
|
299
|
+
currentTraceStep = step;
|
|
300
|
+
const stepStartedAt = Date.now();
|
|
301
|
+
emitTrace('step_start', { ordinal: step });
|
|
290
302
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
catch (e) {
|
|
294
|
-
// 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
|
|
295
|
-
// 工具执行现已串 signal:run_command/web_fetch 被 abort 即时杀,循环顶检查兜底(不会留未配对 tool_call_id)。
|
|
296
|
-
if (signal?.aborted ||
|
|
297
|
-
(e instanceof Error &&
|
|
298
|
-
(e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
|
|
303
|
+
// 上一步工具被 abort 杀(run_command/web_fetch 等)→ signal.aborted,直接还原退出,不等 maybeCompact + chat()
|
|
304
|
+
if (signal?.aborted) {
|
|
299
305
|
abortRestore();
|
|
300
|
-
|
|
306
|
+
traceStatus = 'aborted';
|
|
307
|
+
const mutation = getCurrentTurnMutationState();
|
|
308
|
+
return {
|
|
309
|
+
completed: false,
|
|
310
|
+
terminationReason: 'aborted',
|
|
311
|
+
finalText: null,
|
|
312
|
+
validation: latestValidation,
|
|
313
|
+
changedFiles: mutation.changedFiles.map((item) => item.path),
|
|
314
|
+
};
|
|
301
315
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
316
|
+
// 本步只计算一次实际工具集合,调度、请求和 usage 校准必须使用完全相同的 schema。
|
|
317
|
+
const activeTools = opts.toolsOverride
|
|
318
|
+
?? (getAgentMode() === 'plan' ? planChatTools : chatTools);
|
|
319
|
+
const requestBaseURL = config.baseURL;
|
|
320
|
+
const requestModel = config.model;
|
|
321
|
+
const storedCalibration = getTokenCalibration(requestBaseURL, requestModel, activeTools);
|
|
322
|
+
runtimeContextState.correction = storedCalibration.correction;
|
|
323
|
+
runtimeContextState.calibrationSamples = storedCalibration.samples;
|
|
324
|
+
// 初次 tool push 只做保守编码;预算评估前先对 Cold 且 age 达阈值的旧结果降级,
|
|
325
|
+
// 避免 scheduler 根据马上会被 sweep 的陈旧占用误触发 history compact。
|
|
326
|
+
ageAware?.sweep(history, userTurnBoundary(history, HOT_TURN_WINDOW));
|
|
327
|
+
// 步前:五区 Budget Scheduler 在优化后的 history 上决策;开关关闭时退化回原 maybeCompact 路径。
|
|
328
|
+
// 此时 spinner 已停,通知行干净。
|
|
329
|
+
let historyRebuilt = false;
|
|
330
|
+
const compactStartedAt = Date.now();
|
|
331
|
+
if (scheduler) {
|
|
332
|
+
historyRebuilt = await scheduler.runStep(history, step, activeTools);
|
|
333
|
+
if (scheduler.lastRunLog?.compactHistoryCalled) {
|
|
334
|
+
emitTrace('compact', {
|
|
335
|
+
source: 'automatic',
|
|
336
|
+
reason: 'scheduled',
|
|
337
|
+
historyRebuilt,
|
|
338
|
+
durationMs: Date.now() - compactStartedAt,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools);
|
|
344
|
+
historyRebuilt = compactResult?.historyRebuilt === true;
|
|
345
|
+
if (compactResult) {
|
|
346
|
+
emitTrace('compact', {
|
|
347
|
+
source: 'automatic_fallback',
|
|
348
|
+
reason: compactResult.reason,
|
|
349
|
+
compacted: compactResult.compacted,
|
|
350
|
+
historyRebuilt,
|
|
351
|
+
estimateBefore: compactResult.estimateBefore,
|
|
352
|
+
estimateAfter: compactResult.estimateAfter,
|
|
353
|
+
durationMs: Date.now() - compactStartedAt,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// compact 用新消息数组原地重建 history 后,所有按消息位置恢复的状态都需重建。
|
|
358
|
+
if (historyRebuilt) {
|
|
359
|
+
if (lifecycle) {
|
|
360
|
+
lifecycle = createLifecycleEngine(history);
|
|
361
|
+
runtimeContextState.lifecycleStats = lifecycle.stats();
|
|
362
|
+
}
|
|
363
|
+
ageAware?.rehydrate(history);
|
|
364
|
+
}
|
|
365
|
+
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
366
|
+
mode = 'idle';
|
|
367
|
+
gotText = false;
|
|
368
|
+
lastChar = '';
|
|
369
|
+
let result;
|
|
370
|
+
const modelStartedAt = Date.now();
|
|
371
|
+
const provider = safeProviderId(requestBaseURL);
|
|
372
|
+
emitTrace('model_start', { model: requestModel, provider });
|
|
373
|
+
try {
|
|
374
|
+
result = await chat(history, {
|
|
375
|
+
onText,
|
|
376
|
+
onToolCall,
|
|
377
|
+
onRetry: (retry) => emitTrace('model_retry', {
|
|
378
|
+
model: requestModel,
|
|
379
|
+
provider,
|
|
380
|
+
attempt: retry.attempt,
|
|
381
|
+
nextAttempt: retry.nextAttempt,
|
|
382
|
+
waitMs: retry.waitMs,
|
|
383
|
+
code: retry.code,
|
|
384
|
+
}),
|
|
385
|
+
}, signal, activeTools);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
const errorValue = e && typeof e === 'object'
|
|
389
|
+
? e
|
|
390
|
+
: undefined;
|
|
391
|
+
emitTrace('model_end', {
|
|
392
|
+
model: requestModel,
|
|
393
|
+
provider,
|
|
394
|
+
status: signal?.aborted ? 'aborted' : 'error',
|
|
395
|
+
code: typeof errorValue?.status === 'number'
|
|
396
|
+
? `HTTP_${errorValue.status}`
|
|
397
|
+
: errorValue?.code ?? errorValue?.name ?? 'MODEL_ERROR',
|
|
398
|
+
durationMs: Date.now() - modelStartedAt,
|
|
399
|
+
});
|
|
400
|
+
// 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
|
|
401
|
+
// 工具执行现已串 signal:run_command/web_fetch 被 abort 即时杀,循环顶检查兜底(不会留未配对 tool_call_id)。
|
|
402
|
+
if (signal?.aborted ||
|
|
403
|
+
(e instanceof Error &&
|
|
404
|
+
(e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
|
|
405
|
+
abortRestore();
|
|
406
|
+
traceStatus = 'aborted';
|
|
407
|
+
const mutation = getCurrentTurnMutationState();
|
|
408
|
+
return {
|
|
409
|
+
completed: false,
|
|
410
|
+
terminationReason: 'aborted',
|
|
411
|
+
finalText: null,
|
|
412
|
+
validation: latestValidation,
|
|
413
|
+
changedFiles: mutation.changedFiles.map((item) => item.path),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
emitTrace('model_end', {
|
|
419
|
+
model: requestModel,
|
|
420
|
+
provider,
|
|
421
|
+
status: 'success',
|
|
422
|
+
durationMs: Date.now() - modelStartedAt,
|
|
423
|
+
promptTokens: result.usage?.promptTokens,
|
|
424
|
+
completionTokens: result.usage?.completionTokens,
|
|
425
|
+
totalTokens: result.usage?.totalTokens,
|
|
426
|
+
cachedTokens: result.usage?.cachedTokens,
|
|
427
|
+
reasoningTokens: result.usage?.reasoningTokens,
|
|
331
428
|
});
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
429
|
+
runtimeContextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
430
|
+
addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
|
|
431
|
+
// 用本次实际发送的 tools 计算分母,再以 EWMA 更新 provider/model/tool-set 校准。
|
|
432
|
+
// 只持久化比例与样本数;无 usage 或短 prompt 时保持既有值。
|
|
433
|
+
if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
|
|
434
|
+
const estimated = estimatePromptTokens(history, activeTools);
|
|
435
|
+
const updated = updateTokenCalibration(requestBaseURL, requestModel, activeTools, estimated, result.usage.promptTokens);
|
|
436
|
+
runtimeContextState.correction = updated.correction;
|
|
437
|
+
runtimeContextState.calibrationSamples = updated.samples;
|
|
438
|
+
}
|
|
439
|
+
hooks.onChatDone?.(); // 主 agent:spinner.stop()
|
|
440
|
+
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
441
|
+
onContextUpdate?.();
|
|
442
|
+
if (result.toolCalls.length > 0) {
|
|
443
|
+
toolCallCount += result.toolCalls.length;
|
|
444
|
+
hadToolsThisTurn = true;
|
|
445
|
+
// 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
|
|
446
|
+
if (mode !== 'idle' && lastChar !== '\n')
|
|
447
|
+
hooks.onTextEnd?.();
|
|
448
|
+
// 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
|
|
449
|
+
history.push({
|
|
450
|
+
role: 'assistant',
|
|
451
|
+
content: result.content,
|
|
452
|
+
tool_calls: result.toolCalls.map((tc) => ({
|
|
453
|
+
id: tc.id,
|
|
454
|
+
type: 'function',
|
|
455
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
456
|
+
})),
|
|
457
|
+
});
|
|
458
|
+
// 工具分组执行(保 tool_calls 原顺序):safe parallel 工具照常并发;连续
|
|
459
|
+
// resource-locked mutation 先按序完成权限预检,再按 canonical resource lock 启动。
|
|
460
|
+
// registry 对所有真实资源访问统一持锁,所以不同 Agent 间的 read/write/process 也不会竞态。
|
|
461
|
+
// 串行工具仍是本调用列表内的屏障;渲染/history 回灌始终按原 tool_calls 顺序。
|
|
462
|
+
// executeToolOutcome 永不抛错,失败通过结构化 status/code 返回。
|
|
463
|
+
const calls = result.toolCalls;
|
|
464
|
+
const tracedCalls = calls.map((tc, index) => ({
|
|
465
|
+
toolCallId: `${traceTurnId}:step:${step}:tool:${index}`,
|
|
466
|
+
args: summarizeToolArguments(tc.arguments),
|
|
467
|
+
}));
|
|
468
|
+
for (let index = 0; index < calls.length; index++) {
|
|
469
|
+
const tc = calls[index];
|
|
470
|
+
const traceCall = tracedCalls[index];
|
|
471
|
+
emitTrace('tool_call_start', {
|
|
472
|
+
tool: tc.name,
|
|
473
|
+
argumentHash: traceCall.args.sha256,
|
|
474
|
+
arguments: traceCall.args,
|
|
475
|
+
attempt: 1,
|
|
476
|
+
retry: 0,
|
|
477
|
+
}, {
|
|
478
|
+
toolCallId: traceCall.toolCallId,
|
|
479
|
+
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
480
|
+
});
|
|
366
481
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
482
|
+
const traceToolEnd = (tc, index, outcome) => {
|
|
483
|
+
const traceCall = tracedCalls[index];
|
|
484
|
+
emitTrace('tool_call_end', {
|
|
485
|
+
tool: tc.name,
|
|
486
|
+
argumentHash: traceCall.args.sha256,
|
|
487
|
+
status: outcome.status,
|
|
488
|
+
code: outcome.code,
|
|
489
|
+
retryable: outcome.retryable,
|
|
490
|
+
durationMs: outcome.durationMs ?? 0,
|
|
491
|
+
retry: 0,
|
|
492
|
+
changedFiles: outcome.changedFiles ?? [],
|
|
493
|
+
}, {
|
|
494
|
+
toolCallId: traceCall.toolCallId,
|
|
495
|
+
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
let i = 0;
|
|
499
|
+
while (i < calls.length) {
|
|
500
|
+
const currentCall = calls[i];
|
|
501
|
+
if (getRuntimeDisabledTools().has(currentCall.name)) {
|
|
502
|
+
hooks.onToolHeader?.(currentCall);
|
|
503
|
+
const error = t('task.disabled');
|
|
504
|
+
const outcome = {
|
|
505
|
+
status: 'denied',
|
|
506
|
+
code: 'TOOL_DISABLED',
|
|
507
|
+
retryable: false,
|
|
508
|
+
output: error,
|
|
509
|
+
changedFiles: [],
|
|
510
|
+
durationMs: 0,
|
|
511
|
+
};
|
|
512
|
+
hooks.onToolResult?.(currentCall, error, null, null, 1);
|
|
513
|
+
const hint = recordAndHint(currentCall.name, currentCall.arguments);
|
|
514
|
+
pushToolResult(history, currentCall, hint ? `${error}${hint}` : error, relprune, lifecycle, scheduler, runtimeContextState, false);
|
|
515
|
+
traceToolEnd(currentCall, i, outcome);
|
|
386
516
|
i++;
|
|
387
517
|
continue;
|
|
388
518
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
519
|
+
if (isParallelTool(currentCall.name)) {
|
|
520
|
+
// 收集连续只读组(≥1),并发执行:先渲染所有 header,再一次性启动所有
|
|
521
|
+
// (executeTool 调用即开始 I/O),最后按原顺序逐个 await + 回灌。
|
|
522
|
+
// 必须先 header 后 execute:grep 等同步快速工具会在 executeTool 返回 Promise 前
|
|
523
|
+
// 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
|
|
524
|
+
// 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
|
|
525
|
+
let j = i;
|
|
526
|
+
while (j < calls.length && isParallelTool(calls[j].name))
|
|
527
|
+
j++;
|
|
528
|
+
const batch = calls.slice(i, j);
|
|
529
|
+
for (const tc of batch)
|
|
530
|
+
hooks.onToolHeader?.(tc);
|
|
531
|
+
hooks.onToolStart?.(batch[0].name);
|
|
532
|
+
const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, { dropContext }));
|
|
533
|
+
for (let k = 0; k < batch.length; k++) {
|
|
534
|
+
const tc = batch[k];
|
|
535
|
+
const outcome = await started[k];
|
|
536
|
+
traceToolEnd(tc, i + k, outcome);
|
|
537
|
+
const output = outcome.output;
|
|
538
|
+
hooks.onToolResult?.(tc, output, null, null, 1); // 并行工具无 diff
|
|
539
|
+
// Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
|
|
540
|
+
const hint = recordAndHint(tc.name, tc.arguments);
|
|
541
|
+
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
|
|
542
|
+
}
|
|
543
|
+
hooks.onToolDone?.();
|
|
544
|
+
i = j;
|
|
545
|
+
}
|
|
546
|
+
else if (isResourceLockedTool(currentCall.name) &&
|
|
547
|
+
!(getAgentMode() === 'plan' && getPlanDisabledTools().has(currentCall.name))) {
|
|
548
|
+
// 连续文件 mutation:权限确认仍严格按原序进行;全部 preflight 完成后再启动。
|
|
549
|
+
// 每个执行在 registry 内按 canonical path 获取锁,不同文件可并发,同文件别名会排队。
|
|
550
|
+
let j = i;
|
|
551
|
+
while (j < calls.length &&
|
|
552
|
+
isResourceLockedTool(calls[j].name) &&
|
|
553
|
+
!getRuntimeDisabledTools().has(calls[j].name) &&
|
|
554
|
+
!(getAgentMode() === 'plan' && getPlanDisabledTools().has(calls[j].name)))
|
|
555
|
+
j++;
|
|
556
|
+
const batch = calls.slice(i, j);
|
|
557
|
+
const entries = [];
|
|
558
|
+
for (let k = 0; k < batch.length; k++) {
|
|
559
|
+
const tc = batch[k];
|
|
560
|
+
const parsed = parseArgs(tc.arguments);
|
|
561
|
+
const tool = tools.find((candidate) => candidate.name === tc.name);
|
|
562
|
+
let denied;
|
|
563
|
+
if (tool) {
|
|
564
|
+
const perm = await checkPermission(tool, parsed ?? {}, signal);
|
|
565
|
+
emitTrace('permission', {
|
|
566
|
+
source: 'agent_tool',
|
|
567
|
+
tool: tc.name,
|
|
568
|
+
decision: perm,
|
|
569
|
+
argumentHash: tracedCalls[i + k].args.sha256,
|
|
570
|
+
}, {
|
|
571
|
+
toolCallId: tracedCalls[i + k].toolCallId,
|
|
572
|
+
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
573
|
+
});
|
|
574
|
+
if (perm === 'deny')
|
|
575
|
+
denied = deniedOutcome(tc.name);
|
|
576
|
+
}
|
|
577
|
+
entries.push({
|
|
578
|
+
tc,
|
|
579
|
+
parsed,
|
|
580
|
+
diff: { preWriteOld: null, editStartLine: 1 },
|
|
581
|
+
...(denied ? { denied } : {}),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
for (const entry of entries)
|
|
585
|
+
hooks.onToolHeader?.(entry.tc);
|
|
586
|
+
const firstAllowed = entries.find((entry) => !entry.denied);
|
|
587
|
+
if (firstAllowed)
|
|
588
|
+
hooks.onToolStart?.(firstAllowed.tc.name);
|
|
589
|
+
const started = entries.map((entry) => entry.denied
|
|
590
|
+
? Promise.resolve(entry.denied)
|
|
591
|
+
: executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
|
|
592
|
+
dropContext,
|
|
593
|
+
onLockAcquired: (lockedArgs) => {
|
|
594
|
+
entry.diff = readDiffContext(entry.tc, lockedArgs);
|
|
595
|
+
},
|
|
596
|
+
}));
|
|
597
|
+
for (let k = 0; k < entries.length; k++) {
|
|
598
|
+
const entry = entries[k];
|
|
599
|
+
const outcome = await started[k];
|
|
600
|
+
traceToolEnd(entry.tc, i + k, outcome);
|
|
601
|
+
hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
|
|
602
|
+
const hint = recordAndHint(entry.tc.name, entry.tc.arguments);
|
|
603
|
+
pushToolResult(history, entry.tc, hint ? `${outcome.output}${hint}` : outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
|
|
604
|
+
if (isMutationTool(entry.tc.name) && outcome.status === 'success') {
|
|
605
|
+
const mutationPath = entry.parsed?.path;
|
|
606
|
+
if (typeof mutationPath === 'string' && mutationPath) {
|
|
607
|
+
relprune?.observeMutation(history, mutationPath);
|
|
608
|
+
lifecycle?.pushMutation(history, history.length - 1, mutationPath);
|
|
609
|
+
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (firstAllowed)
|
|
614
|
+
hooks.onToolDone?.();
|
|
615
|
+
i = j;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
// 单步串行(mutation / run_command / use_skill)——逐个执行,保快照序
|
|
619
|
+
const tc = calls[i];
|
|
620
|
+
// plan 模式防御 backstop:schema 已剔除这些工具,正常不会进这里;防后端幻觉调用——
|
|
621
|
+
// 不执行,直接返错回灌(让模型看到「plan 模式禁用」并停止),绝不写盘 / 跑命令。
|
|
622
|
+
if (getAgentMode() === 'plan' && getPlanDisabledTools().has(tc.name)) {
|
|
623
|
+
hooks.onToolHeader?.(tc);
|
|
624
|
+
const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
|
|
625
|
+
const outcome = {
|
|
626
|
+
status: 'denied',
|
|
627
|
+
code: 'MODE_DENIED',
|
|
628
|
+
retryable: false,
|
|
629
|
+
output: err,
|
|
630
|
+
changedFiles: [],
|
|
631
|
+
durationMs: 0,
|
|
632
|
+
};
|
|
633
|
+
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
634
|
+
// Thrashing:同上
|
|
635
|
+
const hint = recordAndHint(tc.name, tc.arguments);
|
|
636
|
+
pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
|
|
637
|
+
traceToolEnd(tc, i, outcome);
|
|
638
|
+
i++;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
// 权限预检查:在渲染 ● 头之前弹确认面板(体验:先问再执行,而非执行完再问)。
|
|
642
|
+
// 拒绝时只渲染拒绝结果,不渲染执行头;放行则继续走 header → start → executeTool 流程。
|
|
397
643
|
const parsed = parseArgs(tc.arguments);
|
|
398
644
|
const tool = tools.find((t) => t.name === tc.name);
|
|
399
645
|
if (tool) {
|
|
400
646
|
const perm = await checkPermission(tool, parsed ?? {}, signal);
|
|
647
|
+
emitTrace('permission', {
|
|
648
|
+
source: 'agent_tool',
|
|
649
|
+
tool: tc.name,
|
|
650
|
+
decision: perm,
|
|
651
|
+
argumentHash: tracedCalls[i].args.sha256,
|
|
652
|
+
}, {
|
|
653
|
+
toolCallId: tracedCalls[i].toolCallId,
|
|
654
|
+
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
655
|
+
});
|
|
401
656
|
if (perm === 'deny') {
|
|
402
657
|
hooks.onToolHeader?.(tc);
|
|
403
|
-
const
|
|
404
|
-
hooks.onToolResult?.(tc,
|
|
658
|
+
const outcome = deniedOutcome(tc.name);
|
|
659
|
+
hooks.onToolResult?.(tc, outcome.output, null, null, 1);
|
|
405
660
|
const hint = recordAndHint(tc.name, tc.arguments);
|
|
406
|
-
pushToolResult(history, tc, hint ? `${
|
|
661
|
+
pushToolResult(history, tc, hint ? `${outcome.output}${hint}` : outcome.output, relprune, lifecycle, scheduler, runtimeContextState, false);
|
|
662
|
+
traceToolEnd(tc, i, outcome);
|
|
663
|
+
i++;
|
|
407
664
|
continue;
|
|
408
665
|
}
|
|
409
666
|
}
|
|
410
|
-
allowedBatch.push(tc);
|
|
411
|
-
}
|
|
412
|
-
if (allowedBatch.length === 0) {
|
|
413
|
-
i = j;
|
|
414
|
-
continue; // 全部被拒绝,跳过执行
|
|
415
|
-
}
|
|
416
|
-
const started = allowedBatch.map((tc) => executeTool(tc.name, tc.arguments, signal, { dropContext }));
|
|
417
|
-
// 先批量打印所有头 + 启 spinner(多 task 并发,spinner 只显一个,但 ● 头都打出来)
|
|
418
|
-
for (const tc of allowedBatch) {
|
|
419
667
|
hooks.onToolHeader?.(tc);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
668
|
+
const mutationParsed = isMutationTool(tc.name)
|
|
669
|
+
? parsed
|
|
670
|
+
: null;
|
|
671
|
+
let diff = readDiffContext(tc, mutationParsed);
|
|
672
|
+
hooks.onToolStart?.(tc.name);
|
|
673
|
+
const outcome = await executeToolOutcome(tc.name, tc.arguments, signal, {
|
|
674
|
+
dropContext,
|
|
675
|
+
onLockAcquired: (lockedArgs) => {
|
|
676
|
+
if (mutationParsed)
|
|
677
|
+
diff = readDiffContext(tc, lockedArgs);
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
traceToolEnd(tc, i, outcome);
|
|
681
|
+
const output = outcome.output;
|
|
682
|
+
hooks.onToolDone?.();
|
|
683
|
+
hooks.onToolResult?.(tc, output, mutationParsed, diff.preWriteOld, diff.editStartLine);
|
|
684
|
+
// Thrashing:同上(history 附 hint,UI 干净)
|
|
428
685
|
const hint = recordAndHint(tc.name, tc.arguments);
|
|
429
|
-
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
// 不执行,直接返错回灌(让模型看到「plan 模式禁用」并停止),绝不写盘 / 跑命令。
|
|
439
|
-
if (getAgentMode() === 'plan' && getPlanDisabledTools().has(tc.name)) {
|
|
440
|
-
hooks.onToolHeader?.(tc);
|
|
441
|
-
const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
|
|
442
|
-
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
443
|
-
// Thrashing:同上
|
|
444
|
-
const hint = recordAndHint(tc.name, tc.arguments);
|
|
445
|
-
pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
|
|
446
|
-
i++;
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
// 权限预检查:在渲染 ● 头之前弹确认面板(体验:先问再执行,而非执行完再问)。
|
|
450
|
-
// 拒绝时只渲染拒绝结果,不渲染执行头;放行则继续走 header → start → executeTool 流程。
|
|
451
|
-
const parsed = parseArgs(tc.arguments);
|
|
452
|
-
const tool = tools.find((t) => t.name === tc.name);
|
|
453
|
-
if (tool) {
|
|
454
|
-
const perm = await checkPermission(tool, parsed ?? {}, signal);
|
|
455
|
-
if (perm === 'deny') {
|
|
456
|
-
hooks.onToolHeader?.(tc);
|
|
457
|
-
const err = `错误:用户拒绝了工具 ${tc.name} 的执行。`;
|
|
458
|
-
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
459
|
-
const hint = recordAndHint(tc.name, tc.arguments);
|
|
460
|
-
pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
|
|
461
|
-
i++;
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
hooks.onToolHeader?.(tc);
|
|
466
|
-
const mutationParsed = isMutationTool(tc.name)
|
|
467
|
-
? parsed
|
|
468
|
-
: null;
|
|
469
|
-
const { preWriteOld, editStartLine } = readDiffContext(tc, mutationParsed);
|
|
470
|
-
hooks.onToolStart?.(tc.name);
|
|
471
|
-
const output = await executeTool(tc.name, tc.arguments, signal, { dropContext });
|
|
472
|
-
hooks.onToolDone?.();
|
|
473
|
-
hooks.onToolResult?.(tc, output, mutationParsed, preWriteOld, editStartLine);
|
|
474
|
-
// Thrashing:同上(history 附 hint,UI 干净)
|
|
475
|
-
const hint = recordAndHint(tc.name, tc.arguments);
|
|
476
|
-
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
|
|
477
|
-
// 只有成功 mutation 才会使旧 read 失效;pruner 与 lifecycle 独立启停。
|
|
478
|
-
if (isMutationTool(tc.name) && isToolResultSuccess(output)) {
|
|
479
|
-
const mp = mutationParsed?.path;
|
|
480
|
-
if (typeof mp === 'string' && mp) {
|
|
481
|
-
relprune?.observeMutation(history, mp);
|
|
482
|
-
lifecycle?.pushMutation(history, history.length - 1, mp);
|
|
483
|
-
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
686
|
+
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
|
|
687
|
+
// 只有成功 mutation 才会使旧 read 失效;pruner 与 lifecycle 独立启停。
|
|
688
|
+
if (isMutationTool(tc.name) && outcome.status === 'success') {
|
|
689
|
+
const mp = mutationParsed?.path;
|
|
690
|
+
if (typeof mp === 'string' && mp) {
|
|
691
|
+
relprune?.observeMutation(history, mp);
|
|
692
|
+
lifecycle?.pushMutation(history, history.length - 1, mp);
|
|
693
|
+
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
694
|
+
}
|
|
484
695
|
}
|
|
696
|
+
i++;
|
|
485
697
|
}
|
|
486
|
-
|
|
698
|
+
}
|
|
699
|
+
// 工具步末尾补一空行:与下一轮的思考 / 正文分隔(否则 ↳ 后紧接 ▎ 思考,无空行不好看;
|
|
700
|
+
// 与正文→● 的 1 空行对称)。工具结果已以 \n 收尾,此处再补 \n 恰好 1 空行。
|
|
701
|
+
hooks.onToolBatchEnd?.();
|
|
702
|
+
// 刷新中断快照:工具全部执行完毕后,history 处于一致状态(assistant+tool_calls+tool 结果完整),
|
|
703
|
+
// 此时中断可安全保留这些已完成的消息,只丢弃下一轮未完成的 chat() 响应。
|
|
704
|
+
savedHistory = history.slice();
|
|
705
|
+
continue; // 带着工具结果再调一次 LLM
|
|
706
|
+
}
|
|
707
|
+
if (mode !== 'idle' && lastChar !== '\n')
|
|
708
|
+
hooks.onTextEnd?.(); // 流式末尾补换行
|
|
709
|
+
// 早退保护:本 turn 已执行过工具调用,但模型突然返回无工具 + 极短/空文本 → 很可能是在探索中途
|
|
710
|
+
// 提前"说完了"。此时推一条 user 提示消息让模型继续探索,而非直接退出。每 turn 最多 1 次,防死循环。
|
|
711
|
+
// 早退重探只处理真正的空回复。短回复可能是合法完成结果(例如精确状态标记);
|
|
712
|
+
// 仅凭字符数继续调用会重复输出,并产生一次完整的额外模型请求。
|
|
713
|
+
const replyIsEmpty = (result.content?.trim().length ?? 0) === 0;
|
|
714
|
+
if (hadToolsThisTurn && nudgeCount < 1 && replyIsEmpty) {
|
|
715
|
+
nudgeCount++;
|
|
716
|
+
history.push({ role: 'assistant', content: result.content });
|
|
717
|
+
history.push({
|
|
718
|
+
role: 'user',
|
|
719
|
+
content: 'You stopped before completing the task. Please continue investigating — call more tools if needed, or provide a complete answer based on what you have gathered so far.',
|
|
720
|
+
});
|
|
721
|
+
continue; // 带着提示再调一次 LLM
|
|
722
|
+
}
|
|
723
|
+
// 没有工具调用:候选正文已流式打印。若本轮有新的代码变更,先通过框架验证门;
|
|
724
|
+
// failed 作为 system observation 风格的 user 消息回灌,不能伪造无配对的 tool 消息。
|
|
725
|
+
if (!gotText)
|
|
726
|
+
hooks.onNoReply?.();
|
|
727
|
+
const candidate = { role: 'assistant', content: result.content };
|
|
728
|
+
const mutationBeforeValidation = getCurrentTurnMutationState();
|
|
729
|
+
const shouldValidate = opts.autoValidate === true &&
|
|
730
|
+
getAgentMode() !== 'plan' &&
|
|
731
|
+
(mutationBeforeValidation.version > validatedMutationVersion || latestValidation?.status === 'failed');
|
|
732
|
+
if (shouldValidate) {
|
|
733
|
+
history.push(candidate);
|
|
734
|
+
emitTrace('validation_start', {
|
|
735
|
+
mutationVersion: mutationBeforeValidation.version,
|
|
736
|
+
changedFiles: mutationBeforeValidation.changedFiles.map((item) => item.path),
|
|
737
|
+
});
|
|
738
|
+
try {
|
|
739
|
+
latestValidation = await validator(signal, {
|
|
740
|
+
onCommandStart: (command) => hooks.onValidationStart?.(command),
|
|
741
|
+
onPermissionDecision: (permission) => {
|
|
742
|
+
const summary = summarizeToolArguments(JSON.stringify(permission.arguments));
|
|
743
|
+
emitTrace('permission', {
|
|
744
|
+
source: 'automatic_validation',
|
|
745
|
+
tool: permission.tool,
|
|
746
|
+
decision: permission.decision,
|
|
747
|
+
argumentHash: summary.sha256,
|
|
748
|
+
});
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
const mutation = getCurrentTurnMutationState();
|
|
754
|
+
const message = `Automatic validation failed to run: ${error instanceof Error ? error.message : String(error)}`;
|
|
755
|
+
const status = signal?.aborted ? 'aborted' : 'failed';
|
|
756
|
+
latestValidation = {
|
|
757
|
+
status,
|
|
758
|
+
level: 'V0',
|
|
759
|
+
output: message,
|
|
760
|
+
durationMs: 0,
|
|
761
|
+
diagnostics: [{
|
|
762
|
+
level: 'V0', source: 'verifier', severity: 'error', code: 'VERIFIER_ERROR', message,
|
|
763
|
+
}],
|
|
764
|
+
stages: [],
|
|
765
|
+
verificationComplete: false,
|
|
766
|
+
fingerprint: `verifier-error-${mutation.version}`,
|
|
767
|
+
inputFingerprint: `mutation-${mutation.version}`,
|
|
768
|
+
inputMutationVersion: mutation.version,
|
|
769
|
+
affectedPackages: [],
|
|
770
|
+
changedFiles: mutation.changedFiles.map((item) => item.path),
|
|
771
|
+
mutationVersion: mutation.version,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
emitTrace('validation_end', {
|
|
775
|
+
status: latestValidation.status,
|
|
776
|
+
level: latestValidation.level,
|
|
777
|
+
durationMs: latestValidation.durationMs,
|
|
778
|
+
verificationComplete: latestValidation.verificationComplete,
|
|
779
|
+
skipReason: latestValidation.skipReason,
|
|
780
|
+
fingerprint: latestValidation.fingerprint,
|
|
781
|
+
mutationVersion: latestValidation.mutationVersion,
|
|
782
|
+
stages: latestValidation.stages.map((stage) => ({
|
|
783
|
+
level: stage.level,
|
|
784
|
+
status: stage.status,
|
|
785
|
+
adapter: stage.adapter,
|
|
786
|
+
code: stage.diagnostics[0]?.code,
|
|
787
|
+
durationMs: stage.durationMs,
|
|
788
|
+
cached: stage.cached === true,
|
|
789
|
+
})),
|
|
790
|
+
});
|
|
791
|
+
hooks.onValidationResult?.(latestValidation);
|
|
792
|
+
validatedMutationVersion = latestValidation.mutationVersion;
|
|
793
|
+
if (latestValidation.status === 'passed' && latestValidation.verificationComplete) {
|
|
794
|
+
fullyValidatedMutationVersion = latestValidation.mutationVersion;
|
|
795
|
+
}
|
|
796
|
+
if (signal?.aborted || latestValidation.status === 'aborted') {
|
|
797
|
+
abortRestore();
|
|
798
|
+
traceStatus = 'aborted';
|
|
799
|
+
const mutation = getCurrentTurnMutationState();
|
|
800
|
+
return {
|
|
801
|
+
completed: false,
|
|
802
|
+
terminationReason: 'aborted',
|
|
803
|
+
finalText: null,
|
|
804
|
+
validation: latestValidation,
|
|
805
|
+
changedFiles: mutation.changedFiles.map((item) => item.path),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
if (latestValidation.status === 'failed') {
|
|
809
|
+
history.push({
|
|
810
|
+
role: 'user',
|
|
811
|
+
content: '[System observation: automatic validation failed]\n' +
|
|
812
|
+
`Command: ${latestValidation.command ?? '(internal verifier)'}\n` +
|
|
813
|
+
`${latestValidation.output}\n\n` +
|
|
814
|
+
'Fix the reported problem, then finish the task. Do not claim success until validation passes.',
|
|
815
|
+
});
|
|
816
|
+
// 验证及其失败观察均已完整落入 history;下一步中断时可安全保留。
|
|
817
|
+
savedHistory = history.slice();
|
|
818
|
+
continue;
|
|
487
819
|
}
|
|
488
820
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
821
|
+
else {
|
|
822
|
+
history.push(candidate);
|
|
823
|
+
}
|
|
824
|
+
const finalMutation = getCurrentTurnMutationState();
|
|
825
|
+
done = true;
|
|
826
|
+
traceStatus = 'completed';
|
|
827
|
+
const verified = finalMutation.version <= fullyValidatedMutationVersion;
|
|
828
|
+
return {
|
|
829
|
+
completed: true,
|
|
830
|
+
terminationReason: verified ? 'completed' : 'completed_unverified',
|
|
831
|
+
finalText: result.content,
|
|
832
|
+
usage: turnUsage,
|
|
833
|
+
validation: latestValidation,
|
|
834
|
+
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
835
|
+
};
|
|
496
836
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
// 判定标准:无 gotText(完全没输出)或正文极短(< 80 字符,通常是一句"我需要更多信息"级别的截断)。
|
|
502
|
-
const textLen = result.content?.trim().length ?? 0;
|
|
503
|
-
if (hadToolsThisTurn && nudgeCount < 1 && (!gotText || textLen < 80)) {
|
|
504
|
-
nudgeCount++;
|
|
505
|
-
history.push({ role: 'assistant', content: result.content });
|
|
506
|
-
history.push({
|
|
507
|
-
role: 'user',
|
|
508
|
-
content: 'You stopped before completing the task. Please continue investigating — call more tools if needed, or provide a complete answer based on what you have gathered so far.',
|
|
837
|
+
finally {
|
|
838
|
+
emitTrace('step_end', {
|
|
839
|
+
durationMs: Date.now() - stepStartedAt,
|
|
840
|
+
aborted: signal?.aborted === true,
|
|
509
841
|
});
|
|
510
|
-
continue; // 带着提示再调一次 LLM
|
|
511
842
|
}
|
|
512
|
-
// 没有工具调用:流式正文即最终回复(已实时打印)
|
|
513
|
-
if (!gotText)
|
|
514
|
-
hooks.onNoReply?.();
|
|
515
|
-
history.push({ role: 'assistant', content: result.content });
|
|
516
|
-
done = true;
|
|
517
|
-
return { completed: true, finalText: result.content, usage: turnUsage };
|
|
518
843
|
}
|
|
519
844
|
hooks.onMaxSteps?.();
|
|
520
845
|
done = true;
|
|
521
|
-
|
|
846
|
+
traceStatus = 'max_steps';
|
|
847
|
+
const finalMutation = getCurrentTurnMutationState();
|
|
848
|
+
const hasUnverifiedChanges = finalMutation.version > fullyValidatedMutationVersion;
|
|
849
|
+
return {
|
|
850
|
+
completed: false,
|
|
851
|
+
terminationReason: hasUnverifiedChanges ? 'unverified_changes' : 'max_steps',
|
|
852
|
+
finalText: null,
|
|
853
|
+
usage: turnUsage,
|
|
854
|
+
validation: latestValidation,
|
|
855
|
+
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
856
|
+
};
|
|
522
857
|
}
|
|
523
858
|
finally {
|
|
859
|
+
const finalMutation = getCurrentTurnMutationState();
|
|
860
|
+
currentTraceStep = undefined;
|
|
861
|
+
emitTrace('turn_end', {
|
|
862
|
+
status: traceStatus,
|
|
863
|
+
durationMs: Date.now() - t0,
|
|
864
|
+
toolCalls: toolCallCount,
|
|
865
|
+
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
866
|
+
totalTokens: turnUsage?.totalTokens,
|
|
867
|
+
validationStatus: latestValidation?.status,
|
|
868
|
+
});
|
|
869
|
+
try {
|
|
870
|
+
opts.onTrace?.({
|
|
871
|
+
ts: new Date().toISOString(),
|
|
872
|
+
sessionId: traceSessionId,
|
|
873
|
+
turnId: traceTurnId,
|
|
874
|
+
status: traceStatus,
|
|
875
|
+
durationMs: Date.now() - t0,
|
|
876
|
+
toolCalls: toolCallCount,
|
|
877
|
+
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
878
|
+
usage: turnUsage,
|
|
879
|
+
validation: latestValidation,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
// Trace is best-effort and must not change the turn result.
|
|
884
|
+
}
|
|
524
885
|
// 跑完(正常 / 达上限)在回复末尾打耗时摘要行;中断 done=false 不打。
|
|
525
886
|
if (done) {
|
|
526
887
|
hooks.onDone?.(Date.now() - t0, turnUsage);
|
|
@@ -528,4 +889,4 @@ export async function runAgentCore(opts) {
|
|
|
528
889
|
}
|
|
529
890
|
}
|
|
530
891
|
// ── 导出共享辅助(主 agent 的 TUI hooks 实现要用)──────────────────────────
|
|
531
|
-
export { parseArgs, readDiffContext, isMutationTool,
|
|
892
|
+
export { parseArgs, readDiffContext, isMutationTool, isParallelTool };
|