mocode-ai 1.6.9 → 1.6.10

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.
@@ -21,7 +21,7 @@ function traceCauseCode(err) {
21
21
  }
22
22
  /** Executes context preparation plus exactly one model step, including the single overflow retry path. */
23
23
  export async function runModelTurn(input) {
24
- const { opts, ctx, history, historyManager, runtimeContextState, scheduler, contextTrimmer, modelRunner, activeTools, runPolicy, step, readDedup, cacheState, turnLifecycle, cancellationLifecycle, rebuildHistoryIndexes, } = input;
24
+ const { opts, ctx, history, historyManager, runtimeContextState, scheduler, contextTrimmer, modelRunner, activeTools, runPolicy, step, maxSteps, readDedup, cacheState, turnLifecycle, cancellationLifecycle, rebuildHistoryIndexes, } = input;
25
25
  const { signal, onContextUpdate, hooks } = opts;
26
26
  const { usageMeter, emitTrace } = turnLifecycle;
27
27
  const requestBaseURL = ctx.config.baseURL;
@@ -105,11 +105,16 @@ export async function runModelTurn(input) {
105
105
  const provider = ctx.safeProviderId(requestBaseURL);
106
106
  emitTrace('model_start', { model: requestModel, provider });
107
107
  const buildRequestHistory = () => {
108
+ const remainingSteps = maxSteps - step;
108
109
  const ephemeralReminder = [
109
110
  runPolicy.reminder,
110
111
  !opts.suppressOpeningAnalysis && step === 0
111
112
  ? '## Opening analysis\nStart your first response of this turn with a brief analysis of the request and approach (1-3 sentences, no filler), then start tool calls. This is the only expected pre-tool prose; afterwards work quietly, no narration between calls.'
112
113
  : '',
114
+ remainingSteps <= 3
115
+ ? `## Step budget warning\nOnly ${remainingSteps} step(s) remain before the hard step limit (${maxSteps}). ` +
116
+ 'Stop opening new lines of exploration NOW. Wind down: finish the current tool call if any, then use the remaining step(s) to produce your final summary — what you found, key files/evidence, and what remains undone. A terse partial report is far better than being cut off mid-work.'
117
+ : '',
113
118
  historyRebuilt
114
119
  ? '## Post-compaction recovery\nContext was compacted before this request. Recover first, in this order:\n' +
115
120
  '1. Read the session summary at the top of the history: `## Completed` is done — do not redo or re-verify it; `## In Progress` / `## Next Steps` say where work stopped and what is next.\n' +
@@ -177,6 +177,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
177
177
  activeTools,
178
178
  runPolicy,
179
179
  step,
180
+ maxSteps,
180
181
  readDedup,
181
182
  cacheState: modelCacheState,
182
183
  turnLifecycle,
@@ -378,127 +379,55 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
378
379
  calls.forEach((tc, index) => (tc.name === ADD_TOOL_GROUPS_TOOL_NAME ? controlIndexes : otherIndexes).push(index));
379
380
  const hasToolRouteBarrier = controlIndexes.length > 0;
380
381
  if (hasToolRouteBarrier) {
381
- // 同批非控制调用全部为「未被禁用的并行安全(只读)工具」时(纯 solo 空集也满足),
382
- // 先并发执行只读、再应用扩容;混有写/执行等非并行工具则整批保守拒绝。
383
- const safeReadonlyBatch = otherIndexes.every((index) => isParallelTool(calls[index].name, ctx.toolRuntime) && !isToolDeniedForStep(calls[index].name));
384
- if (safeReadonlyBatch) {
385
- for (let index = 0; index < calls.length; index++) {
386
- hooks.onToolHeader?.(calls[index]);
387
- }
388
- if (otherIndexes.length > 0) {
389
- hooks.onToolStart?.(calls[otherIndexes[0]].name);
390
- const startedReadonly = otherIndexes.map((index) => ctx.toolRuntime.executeToolOutcome(calls[index].name, calls[index].arguments, signal, {
382
+ // add_tool_groups 混批语义 = 部分执行:同批所有「未被禁用的并行安全(只读)」调用
383
+ // 本 step 照常并发执行,控制调用照常应用扩容(只改下一 step 的 schema);仅写/执行
384
+ // 等非并行调用被配对拒绝并提示下一 step 重试——单个违规调用不再让整批陪葬。
385
+ for (let index = 0; index < calls.length; index++) {
386
+ hooks.onToolHeader?.(calls[index]);
387
+ }
388
+ // 只读调用先并发启动(执行序不变);发布(history 回灌/事件)按 provider
389
+ // 声明序交错进行:批校验按 assistant tool_calls 逐位配对 id,若按执行序
390
+ // 发布(只读先、扩容后),add_tool_groups 排在只读之前时必然 id 错位,整轮 rollback。
391
+ // 非并行(写/执行/串行)调用不启动:它们与扩容同批时被拒绝,下一 step 重试。
392
+ const startedReadonly = new Array(calls.length);
393
+ const eligibleReadonly = otherIndexes.filter((index) => isParallelTool(calls[index].name, ctx.toolRuntime) && !isToolDeniedForStep(calls[index].name));
394
+ if (eligibleReadonly.length > 0) {
395
+ hooks.onToolStart?.(calls[eligibleReadonly[0]].name);
396
+ for (const index of eligibleReadonly) {
397
+ startedReadonly[index] = ctx.toolRuntime.executeToolOutcome(calls[index].name, calls[index].arguments, signal, {
391
398
  callId: calls[index].id,
392
399
  allowedToolNames: currentAllowedToolNames(),
393
400
  delegation: delegationForOrchestrator(),
394
- }));
395
- for (let k = 0; k < otherIndexes.length; k++) {
396
- const index = otherIndexes[k];
397
- const tc = calls[index];
398
- const outcome = await startedReadonly[k];
399
- usageMeter.add(outcome.usage);
400
- opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
401
- traceToolEnd(tc, index, outcome);
402
- hooks.onToolResult?.(tc, outcome.output, null, null, 1);
403
- pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
404
- }
405
- hooks.onToolDone?.();
401
+ });
406
402
  }
407
- for (const index of controlIndexes) {
403
+ }
404
+ const lastReadonlyIndex = eligibleReadonly.length > 0 ? eligibleReadonly[eligibleReadonly.length - 1] : -1;
405
+ for (let index = 0; index < calls.length; index++) {
406
+ const readonlyPromise = startedReadonly[index];
407
+ if (readonlyPromise) {
408
408
  const tc = calls[index];
409
- const parsed = parseArgs(tc.arguments);
410
- let outcome;
411
- if (isToolDeniedForStep(tc.name)) {
412
- outcome = {
413
- status: 'denied',
414
- code: 'TOOL_DISABLED',
415
- retryable: false,
416
- output: `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`,
417
- changedFiles: [],
418
- durationMs: 0,
419
- };
420
- }
421
- else if (!opts.toolPolicy) {
422
- outcome = {
423
- status: 'denied',
424
- code: 'TOOL_DISABLED',
425
- retryable: false,
426
- output: '错误:当前 Agent 未启用动态工具策略,无法调用 add_tool_groups。',
427
- changedFiles: [],
428
- durationMs: 0,
429
- };
430
- }
431
- else if (!parsed ||
432
- !Array.isArray(parsed.groups) ||
433
- parsed.groups.length === 0 ||
434
- typeof parsed.reason !== 'string' ||
435
- !parsed.reason.trim()) {
436
- outcome = {
437
- status: 'error',
438
- code: 'INVALID_ARGUMENTS',
439
- retryable: false,
440
- output: '错误:add_tool_groups 需要非空 groups 数组和非空 reason。',
441
- changedFiles: [],
442
- durationMs: 0,
443
- };
444
- }
445
- else {
446
- const expansion = opts.toolPolicy.expand(parsed.groups, parsed.reason);
447
- const succeeded = expansion.added.length > 0;
448
- const details = [
449
- succeeded
450
- ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
451
- : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
452
- expansion.implied.length > 0
453
- ? `Implied groups also activated: ${expansion.implied.join(', ')}.`
454
- : '',
455
- expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
456
- succeeded ? 'The added tool schemas become available on the next model step.' : '',
457
- ]
458
- .filter(Boolean)
459
- .join('\n');
460
- outcome = {
461
- status: succeeded ? 'success' : 'error',
462
- code: succeeded ? 'OK' : 'INVALID_ARGUMENTS',
463
- retryable: false,
464
- output: details,
465
- changedFiles: [],
466
- durationMs: 0,
467
- };
468
- emitTrace('tool_route_expand', {
469
- policyId: expansion.snapshot.id,
470
- fromVersion: policySnapshot?.version,
471
- toVersion: expansion.snapshot.version,
472
- requestedGroups: parsed.groups.map(String),
473
- addedGroups: expansion.added,
474
- impliedGroups: expansion.implied,
475
- rejected: expansion.rejected,
476
- reason: parsed.reason,
477
- status: outcome.status,
478
- });
479
- }
480
- opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
409
+ const outcome = await readonlyPromise;
410
+ usageMeter.add(outcome.usage);
411
+ opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
412
+ traceToolEnd(tc, index, outcome);
481
413
  hooks.onToolResult?.(tc, outcome.output, null, null, 1);
482
414
  pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
483
- traceToolEnd(tc, index, outcome);
415
+ // done 紧跟最后一个只读结果(控制调用在尾部时与旧事件序完全一致)。
416
+ if (index === lastReadonlyIndex)
417
+ hooks.onToolDone?.();
418
+ continue;
484
419
  }
485
- }
486
- else {
487
- // 不安全批:不扩容、不执行普通工具,逐 call 按原序配对拒绝结果。
488
- for (let index = 0; index < calls.length; index++) {
420
+ if (calls[index].name !== ADD_TOOL_GROUPS_TOOL_NAME) {
421
+ // 非并行(写/执行/串行)或被禁用的普通调用:与扩容同批时不执行,
422
+ // 配对拒绝并提示下一 step 重试;控制调用与只读不受其影响照常处理。
489
423
  const tc = calls[index];
490
- hooks.onToolHeader?.(tc);
491
- const isControl = tc.name === ADD_TOOL_GROUPS_TOOL_NAME;
492
- const isReadonly = isParallelTool(tc.name, ctx.toolRuntime);
493
424
  const outcome = {
494
425
  status: 'denied',
495
- code: isControl ? 'INVALID_ARGUMENTS' : 'TOOL_DISABLED',
426
+ code: 'TOOL_DISABLED',
496
427
  retryable: false,
497
- output: isControl
498
- ? '错误:add_tool_groups 只能单独调用,或与只读工具(read_file/glob/grep/web 等)同批;本次没有扩容。'
499
- : isReadonly
500
- ? `错误:同一响应包含 add_tool_groups,工具 ${tc.name} 未执行。请在下一 step 重试。`
501
- : `错误:add_tool_groups 不能与写/执行工具 ${tc.name} 同批;请先完成扩容,再在下一 step 调用 ${tc.name}。`,
428
+ output: isToolDeniedForStep(tc.name)
429
+ ? `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`
430
+ : `错误:add_tool_groups 不能与写/执行工具 ${tc.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${tc.name}。`,
502
431
  changedFiles: [],
503
432
  durationMs: 0,
504
433
  };
@@ -506,11 +435,91 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
506
435
  hooks.onToolResult?.(tc, outcome.output, null, null, 1);
507
436
  pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, false);
508
437
  traceToolEnd(tc, index, outcome);
438
+ continue;
439
+ }
440
+ // 控制调用(header 已发):校验并应用扩容。扩容只改下一 step 的 schema,
441
+ // 本批只读执行都走 step 快照,与执行的先后无行为差。
442
+ const tc = calls[index];
443
+ const parsed = parseArgs(tc.arguments);
444
+ let outcome;
445
+ if (isToolDeniedForStep(tc.name)) {
446
+ outcome = {
447
+ status: 'denied',
448
+ code: 'TOOL_DISABLED',
449
+ retryable: false,
450
+ output: `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`,
451
+ changedFiles: [],
452
+ durationMs: 0,
453
+ };
454
+ }
455
+ else if (!opts.toolPolicy) {
456
+ outcome = {
457
+ status: 'denied',
458
+ code: 'TOOL_DISABLED',
459
+ retryable: false,
460
+ output: '错误:当前 Agent 未启用动态工具策略,无法调用 add_tool_groups。',
461
+ changedFiles: [],
462
+ durationMs: 0,
463
+ };
509
464
  }
465
+ else if (!parsed ||
466
+ !Array.isArray(parsed.groups) ||
467
+ parsed.groups.length === 0 ||
468
+ typeof parsed.reason !== 'string' ||
469
+ !parsed.reason.trim()) {
470
+ outcome = {
471
+ status: 'error',
472
+ code: 'INVALID_ARGUMENTS',
473
+ retryable: false,
474
+ output: '错误:add_tool_groups 需要非空 groups 数组和非空 reason。',
475
+ changedFiles: [],
476
+ durationMs: 0,
477
+ };
478
+ }
479
+ else {
480
+ const expansion = opts.toolPolicy.expand(parsed.groups, parsed.reason);
481
+ const succeeded = expansion.added.length > 0;
482
+ const details = [
483
+ succeeded
484
+ ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
485
+ : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
486
+ expansion.implied.length > 0
487
+ ? `Implied groups also activated: ${expansion.implied.join(', ')}.`
488
+ : '',
489
+ expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
490
+ succeeded ? 'The added tool schemas become available on the next model step.' : '',
491
+ ]
492
+ .filter(Boolean)
493
+ .join('\n');
494
+ outcome = {
495
+ status: succeeded ? 'success' : 'error',
496
+ code: succeeded ? 'OK' : 'INVALID_ARGUMENTS',
497
+ retryable: false,
498
+ output: details,
499
+ changedFiles: [],
500
+ durationMs: 0,
501
+ };
502
+ emitTrace('tool_route_expand', {
503
+ policyId: expansion.snapshot.id,
504
+ fromVersion: policySnapshot?.version,
505
+ toVersion: expansion.snapshot.version,
506
+ requestedGroups: parsed.groups.map(String),
507
+ addedGroups: expansion.added,
508
+ impliedGroups: expansion.implied,
509
+ rejected: expansion.rejected,
510
+ reason: parsed.reason,
511
+ status: outcome.status,
512
+ });
513
+ }
514
+ opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
515
+ hooks.onToolResult?.(tc, outcome.output, null, null, 1);
516
+ pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
517
+ traceToolEnd(tc, index, outcome);
510
518
  }
511
519
  }
512
520
  // add_tool_groups 是 step 屏障:只要本响应出现该控制调用,本批所有普通工具都不执行。
513
521
  // 但上面仍为每个 provider tool_call 写入了配对 tool_result,保持 OpenAI 协议完整。
522
+ // (部分执行语义:并行安全只读与扩容照常,写/执行调用跳过待下一 step 重试。)
514
523
  let i = hasToolRouteBarrier ? calls.length : 0;
515
524
  while (i < calls.length) {
516
525
  const currentCall = calls[i];
@@ -551,12 +560,17 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
551
560
  allowedToolNames: currentAllowedToolNames(),
552
561
  delegation: delegationForOrchestrator(),
553
562
  }));
563
+ // 完成即发:usage/onToolOutcome/trace 在各 outcome 落地瞬间发出(1s 的
564
+ // grep 不再被 60s 的 web_fetch 卡住可见性);history 回灌严格按原序。
565
+ await Promise.all(started.map(async (pending, k) => {
566
+ const outcome = await pending;
567
+ usageMeter.add(outcome.usage);
568
+ opts.onToolOutcome?.(batch[k].name, parseArgs(batch[k].arguments) ?? {}, outcome);
569
+ traceToolEnd(batch[k], i + k, outcome);
570
+ }));
554
571
  for (let k = 0; k < batch.length; k++) {
555
572
  const tc = batch[k];
556
573
  const outcome = await started[k];
557
- usageMeter.add(outcome.usage);
558
- opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
559
- traceToolEnd(tc, i + k, outcome);
560
574
  const output = outcome.output;
561
575
  hooks.onToolResult?.(tc, output, null, null, 1); // 并行工具无 diff
562
576
  if (tc.name === 'ask_human' && outcome.status === 'success') {
@@ -620,44 +634,54 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
620
634
  const firstAllowed = entries.find((entry) => !entry.denied);
621
635
  if (firstAllowed)
622
636
  hooks.onToolStart?.(firstAllowed.tc.name);
637
+ // 动态补位池:启动上限 concurrency,任意一个完成立即补位——静态分块时
638
+ // 「7 个任务、1 个跑 10 分钟」会让第 5 分钟起槽位空转而 6/7 号干等,
639
+ // 总耗时≈最慢链;补位后≈最满调度。与 staged dispatcher 同构。
640
+ // 完成即发:usage/onToolOutcome/trace 在各 outcome 落地瞬间发出(快调用
641
+ // 不再被批内慢调用卡住可见性),history 回灌(pushToolResult)仍严格原序。
642
+ // 实现用 worker 循环而非「补位回调 + outcomes.map」:outcomes 是稀疏数组,
643
+ // Array.prototype.map 会跳过未启动槽位的洞,补位链条会在第 N+1 个断掉。
623
644
  const concurrency = Math.max(1, ctx.config.subAgentConcurrency);
624
- for (let chunkStart = 0; chunkStart < entries.length; chunkStart += concurrency) {
625
- const chunk = entries.slice(chunkStart, chunkStart + concurrency);
626
- // 块内同时启动(executeToolOutcome 调用即开始 I/O),再按原序 await + 回灌:
627
- // 完成顺序任意,history 与 trace 顺序始终是原调用序。
628
- const started = chunk.map((entry) => {
629
- if (entry.denied)
630
- return Promise.resolve(entry.denied);
645
+ const outcomes = new Array(entries.length);
646
+ let cursor = 0;
647
+ const runWorker = async () => {
648
+ while (cursor < entries.length) {
649
+ const position = cursor++;
650
+ const entry = entries[position];
631
651
  const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
632
- return ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
633
- callId: entry.tc.id,
634
- allowedToolNames: currentAllowedToolNames(),
635
- delegation: delegationForOrchestrator(),
636
- ...(hint ? { argumentErrorHint: hint } : {}),
637
- onLockAcquired: (lockedArgs) => {
638
- entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
639
- },
640
- });
641
- });
642
- for (let k = 0; k < chunk.length; k++) {
643
- const entry = chunk[k];
644
- const outcome = await started[k];
652
+ const outcome = entry.denied
653
+ ? entry.denied
654
+ : await ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
655
+ callId: entry.tc.id,
656
+ allowedToolNames: currentAllowedToolNames(),
657
+ delegation: delegationForOrchestrator(),
658
+ ...(hint ? { argumentErrorHint: hint } : {}),
659
+ onLockAcquired: (lockedArgs) => {
660
+ entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
661
+ },
662
+ });
663
+ outcomes[position] = Promise.resolve(outcome);
645
664
  usageMeter.add(outcome.usage);
646
665
  opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
647
- traceToolEnd(entry.tc, i + chunkStart + k, outcome);
648
- hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
649
- pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
650
- const invalidatedFiles = [
651
- ...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])]),
652
- ];
653
- if (invalidatedFiles.length > 0) {
654
- for (const changedFile of invalidatedFiles) {
655
- relprune?.observeMutation(history, changedFile);
656
- lifecycle?.pushMutation(history, history.length - 1, changedFile);
657
- }
658
- invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
659
- runtimeContextState.lifecycleStats = lifecycle?.stats();
666
+ traceToolEnd(entry.tc, i + position, outcome);
667
+ }
668
+ };
669
+ await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, () => runWorker()));
670
+ for (let offset = 0; offset < entries.length; offset++) {
671
+ const entry = entries[offset];
672
+ const outcome = await outcomes[offset];
673
+ hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
674
+ pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
675
+ const invalidatedFiles = [
676
+ ...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])]),
677
+ ];
678
+ if (invalidatedFiles.length > 0) {
679
+ for (const changedFile of invalidatedFiles) {
680
+ relprune?.observeMutation(history, changedFile);
681
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
660
682
  }
683
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
684
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
661
685
  }
662
686
  }
663
687
  if (firstAllowed)
@@ -724,12 +748,17 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
724
748
  },
725
749
  });
726
750
  });
751
+ // 完成即发:usage/onToolOutcome/trace 落地瞬间发出;history 回灌严格原序——
752
+ // 同文件排队由 canonical file 锁保证,异文件并发不受影响。
753
+ await Promise.all(started.map(async (pending, k) => {
754
+ const outcome = await pending;
755
+ usageMeter.add(outcome.usage);
756
+ opts.onToolOutcome?.(entries[k].tc.name, entries[k].parsed ?? {}, outcome);
757
+ traceToolEnd(entries[k].tc, i + k, outcome);
758
+ }));
727
759
  for (let k = 0; k < entries.length; k++) {
728
760
  const entry = entries[k];
729
761
  const outcome = await started[k];
730
- usageMeter.add(outcome.usage);
731
- opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
732
- traceToolEnd(entry.tc, i + k, outcome);
733
762
  hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
734
763
  pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
735
764
  const invalidatedFiles = [
@@ -886,10 +915,47 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
886
915
  hooks.onMaxSteps?.();
887
916
  turnLifecycle.markMaxSteps();
888
917
  const finalMutation = ctx.getCurrentTurnMutationState();
918
+ // ── 撞限 salvage:最后一次不带工具的收尾请求 ──
919
+ // 撞步数上限时 history 里往往已有几十步探索/实现成果,直接返回会把它们整体丢掉
920
+ // (调用方只拿到 finalText=null →「子 agent 被中断」,token 白花还得重新探索)。
921
+ // 这里多发一步无工具(tools=[])的请求,ephemeral system 指示立即输出最终摘要;
922
+ // 成功则把它作为 finalText 带回(terminationReason/completed 语义不变,诚实反映撞限),
923
+ // 请求失败或中止则静默回退旧行为。跳过条件:本来就无工具调用(纯闲聊撞限,无成果可捞)
924
+ // 或已 abort(用户主动取消,不应再花钱)。
925
+ let salvageText = null;
926
+ const hasToolHistory = history.some((m) => m.role === 'tool');
927
+ if (hasToolHistory && !signal?.aborted) {
928
+ const salvageInstruction = '## Step limit reached — final summary required\n' +
929
+ `The hard step limit (${maxSteps}) has been reached and no further tool calls are possible. ` +
930
+ 'Write your final report NOW as plain text (no tool calls): what you accomplished, key findings with file paths / evidence, decisions made, and what remains undone. Be concise but complete — this text is the only thing the caller will receive.';
931
+ try {
932
+ const salvage = await modelRunner.run({
933
+ history: [...history, { role: 'system', content: salvageInstruction }],
934
+ handlers: {},
935
+ tools: [],
936
+ }, signal);
937
+ if (salvage.content?.trim())
938
+ salvageText = salvage.content.trim();
939
+ if (salvage.usage)
940
+ usageMeter.add(salvage.usage);
941
+ emitTrace('max_steps_salvage', {
942
+ recovered: !!salvageText,
943
+ tokens: salvage.usage?.totalTokens,
944
+ });
945
+ }
946
+ catch (error) {
947
+ // salvage 是 best-effort:失败不改变撞限终止语义,静默回退;但留一条 trace 取证
948
+ // (否则事后只看到 finalText=null,无法区分「无事可捞」与「收尾请求本身失败」)。
949
+ emitTrace('max_steps_salvage', {
950
+ recovered: false,
951
+ error: error instanceof Error ? error.message.slice(0, 300) : String(error),
952
+ });
953
+ }
954
+ }
889
955
  return {
890
956
  completed: false,
891
957
  terminationReason: 'max_steps',
892
- finalText: null,
958
+ finalText: salvageText,
893
959
  usage: usageMeter.snapshot(),
894
960
  changedFiles: finalMutation.changedFiles.map((item) => item.path),
895
961
  };