@zooique/memora 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +720 -14
  2. package/README.en.md +4 -4
  3. package/README.md +303 -303
  4. package/config.example.json +1 -8
  5. package/dist/agent/agent.d.ts +48 -4
  6. package/dist/agent/agent.d.ts.map +1 -1
  7. package/dist/agent/agent.js +130 -12
  8. package/dist/agent/agent.js.map +1 -1
  9. package/dist/agent/assembler.d.ts +19 -5
  10. package/dist/agent/assembler.d.ts.map +1 -1
  11. package/dist/agent/assembler.js +76 -13
  12. package/dist/agent/assembler.js.map +1 -1
  13. package/dist/agent/backgroundTasks.d.ts +187 -0
  14. package/dist/agent/backgroundTasks.d.ts.map +1 -0
  15. package/dist/agent/backgroundTasks.js +307 -0
  16. package/dist/agent/backgroundTasks.js.map +1 -0
  17. package/dist/agent/builtinToolHandlers.d.ts +2 -1
  18. package/dist/agent/builtinToolHandlers.d.ts.map +1 -1
  19. package/dist/agent/builtinToolHandlers.js +8 -4
  20. package/dist/agent/builtinToolHandlers.js.map +1 -1
  21. package/dist/agent/builtinTools.d.ts +15 -1
  22. package/dist/agent/builtinTools.d.ts.map +1 -1
  23. package/dist/agent/builtinTools.js +107 -27
  24. package/dist/agent/builtinTools.js.map +1 -1
  25. package/dist/agent/constants.d.ts +31 -0
  26. package/dist/agent/constants.d.ts.map +1 -1
  27. package/dist/agent/constants.js +31 -0
  28. package/dist/agent/constants.js.map +1 -1
  29. package/dist/agent/guardRail.d.ts +11 -0
  30. package/dist/agent/guardRail.d.ts.map +1 -1
  31. package/dist/agent/guardRail.js +36 -1
  32. package/dist/agent/guardRail.js.map +1 -1
  33. package/dist/agent/loop.d.ts +274 -22
  34. package/dist/agent/loop.d.ts.map +1 -1
  35. package/dist/agent/loop.js +587 -87
  36. package/dist/agent/loop.js.map +1 -1
  37. package/dist/agent/managers/archiveCoordinator.d.ts +5 -2
  38. package/dist/agent/managers/archiveCoordinator.d.ts.map +1 -1
  39. package/dist/agent/managers/archiveCoordinator.js +17 -2
  40. package/dist/agent/managers/archiveCoordinator.js.map +1 -1
  41. package/dist/agent/managers/loopMetrics.d.ts +14 -0
  42. package/dist/agent/managers/loopMetrics.d.ts.map +1 -1
  43. package/dist/agent/managers/loopMetrics.js +15 -0
  44. package/dist/agent/managers/loopMetrics.js.map +1 -1
  45. package/dist/agent/managers/memoryInspector.d.ts +1 -8
  46. package/dist/agent/managers/memoryInspector.d.ts.map +1 -1
  47. package/dist/agent/managers/memoryInspector.js +6 -30
  48. package/dist/agent/managers/memoryInspector.js.map +1 -1
  49. package/dist/agent/managers/roundSummaryGenerator.d.ts +28 -0
  50. package/dist/agent/managers/roundSummaryGenerator.d.ts.map +1 -1
  51. package/dist/agent/managers/roundSummaryGenerator.js +67 -2
  52. package/dist/agent/managers/roundSummaryGenerator.js.map +1 -1
  53. package/dist/agent/managers/sessionArchiver.d.ts +21 -3
  54. package/dist/agent/managers/sessionArchiver.d.ts.map +1 -1
  55. package/dist/agent/managers/sessionArchiver.js +20 -3
  56. package/dist/agent/managers/sessionArchiver.js.map +1 -1
  57. package/dist/agent/managers/sessionManager.d.ts.map +1 -1
  58. package/dist/agent/managers/sessionManager.js +17 -0
  59. package/dist/agent/managers/sessionManager.js.map +1 -1
  60. package/dist/agent/managers/toolCallHelpers.d.ts +109 -28
  61. package/dist/agent/managers/toolCallHelpers.d.ts.map +1 -1
  62. package/dist/agent/managers/toolCallHelpers.js +93 -37
  63. package/dist/agent/managers/toolCallHelpers.js.map +1 -1
  64. package/dist/agent/messageHistory.d.ts.map +1 -1
  65. package/dist/agent/messageHistory.js +9 -5
  66. package/dist/agent/messageHistory.js.map +1 -1
  67. package/dist/agent/seed/orchestrator.d.ts +12 -1
  68. package/dist/agent/seed/orchestrator.d.ts.map +1 -1
  69. package/dist/agent/seed/orchestrator.js +12 -1
  70. package/dist/agent/seed/orchestrator.js.map +1 -1
  71. package/dist/agent/textSanitize.d.ts +36 -0
  72. package/dist/agent/textSanitize.d.ts.map +1 -0
  73. package/dist/agent/textSanitize.js +55 -0
  74. package/dist/agent/textSanitize.js.map +1 -0
  75. package/dist/agent/toolExecutor.d.ts +48 -22
  76. package/dist/agent/toolExecutor.d.ts.map +1 -1
  77. package/dist/agent/toolExecutor.js +372 -71
  78. package/dist/agent/toolExecutor.js.map +1 -1
  79. package/dist/agent/toolLedger.d.ts +93 -41
  80. package/dist/agent/toolLedger.d.ts.map +1 -1
  81. package/dist/agent/toolLedger.js +114 -42
  82. package/dist/agent/toolLedger.js.map +1 -1
  83. package/dist/agent/toolResultOffload.d.ts +8 -0
  84. package/dist/agent/toolResultOffload.d.ts.map +1 -1
  85. package/dist/agent/toolResultOffload.js +10 -3
  86. package/dist/agent/toolResultOffload.js.map +1 -1
  87. package/dist/agent/toolRunner.d.ts +14 -7
  88. package/dist/agent/toolRunner.d.ts.map +1 -1
  89. package/dist/agent/toolRunner.js +71 -29
  90. package/dist/agent/toolRunner.js.map +1 -1
  91. package/dist/agent/tracer.d.ts +13 -1
  92. package/dist/agent/tracer.d.ts.map +1 -1
  93. package/dist/agent/types.d.ts +66 -5
  94. package/dist/agent/types.d.ts.map +1 -1
  95. package/dist/code-exec/types.d.ts +9 -0
  96. package/dist/code-exec/types.d.ts.map +1 -1
  97. package/dist/index.d.ts +21 -23
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +36 -28
  100. package/dist/index.js.map +1 -1
  101. package/dist/memory/roundStore.d.ts +67 -81
  102. package/dist/memory/roundStore.d.ts.map +1 -1
  103. package/dist/memory/roundStore.js +4 -15
  104. package/dist/memory/roundStore.js.map +1 -1
  105. package/dist/memory/types.d.ts +7 -0
  106. package/dist/memory/types.d.ts.map +1 -1
  107. package/dist/memory/types.js +4 -0
  108. package/dist/memory/types.js.map +1 -1
  109. package/dist/role-pack/rolePackManager.d.ts +42 -10
  110. package/dist/role-pack/rolePackManager.d.ts.map +1 -1
  111. package/dist/role-pack/rolePackManager.js +65 -12
  112. package/dist/role-pack/rolePackManager.js.map +1 -1
  113. package/dist/role-pack/strategyKeys.d.ts +36 -0
  114. package/dist/role-pack/strategyKeys.d.ts.map +1 -1
  115. package/dist/role-pack/strategyKeys.js +45 -0
  116. package/dist/role-pack/strategyKeys.js.map +1 -1
  117. package/dist/security/confirmEntries.d.ts +44 -0
  118. package/dist/security/confirmEntries.d.ts.map +1 -0
  119. package/dist/security/confirmEntries.js +59 -0
  120. package/dist/security/confirmEntries.js.map +1 -0
  121. package/dist/security/pathGuard.d.ts +41 -5
  122. package/dist/security/pathGuard.d.ts.map +1 -1
  123. package/dist/security/pathGuard.js +125 -4
  124. package/dist/security/pathGuard.js.map +1 -1
  125. package/dist/skill/skillScriptRunner.d.ts +147 -15
  126. package/dist/skill/skillScriptRunner.d.ts.map +1 -1
  127. package/dist/skill/skillScriptRunner.js +479 -68
  128. package/dist/skill/skillScriptRunner.js.map +1 -1
  129. package/dist/utils/configResourceManager.d.ts +6 -2
  130. package/dist/utils/configResourceManager.d.ts.map +1 -1
  131. package/dist/utils/configResourceManager.js +8 -2
  132. package/dist/utils/configResourceManager.js.map +1 -1
  133. package/dist/utils/errors.d.ts +9 -0
  134. package/dist/utils/errors.d.ts.map +1 -1
  135. package/dist/utils/errors.js +9 -0
  136. package/dist/utils/errors.js.map +1 -1
  137. package/dist/utils/eventEmitter.d.ts +57 -2
  138. package/dist/utils/eventEmitter.d.ts.map +1 -1
  139. package/dist/utils/eventEmitter.js +23 -0
  140. package/dist/utils/eventEmitter.js.map +1 -1
  141. package/dist/utils/time.d.ts +5 -2
  142. package/dist/utils/time.d.ts.map +1 -1
  143. package/dist/utils/time.js +5 -2
  144. package/dist/utils/time.js.map +1 -1
  145. package/docs/memora-api-reference.md +121 -20
  146. package/docs/memora-/346/216/245/345/205/245/346/214/207/345/215/227.md +6 -5
  147. package/package.json +1 -1
  148. package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/persona.md +46 -46
  149. package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/rules.md +16 -15
  150. package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/emotion-wave.md +46 -46
  151. package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/foreshadow.md +29 -29
  152. package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/outline-expand.md +48 -46
  153. package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/project-init/scripts/init-novel-project.mjs +441 -441
  154. package/role-packs//345/205/261/351/270/243/345/260/217/350/257/264/345/256/266/skills/story-bible.md +62 -60
@@ -10,18 +10,47 @@ import { logger } from '../logging/logger.js';
10
10
  import { ResultReplacementStrategy, ReplaceRoundsStrategy, DEFAULT_REPLACE_KEEP_RECENT_ROUNDS, } from '../agent/compaction.js';
11
11
  import { offloadLargeToolResult } from '../agent/toolResultOffload.js';
12
12
  import { deriveDialogueRounds } from '../agent/budget.js';
13
- import { parseAskCalls, wrapToolResult, isRetryableToolError, } from '../agent/managers/toolCallHelpers.js';
13
+ import { parseAskCalls, wrapToolResult, unwrapToolResultBody, isRetryableToolError,
14
+ // B4 判据切换:护栏/替身/compress/remember 的 outcome 就地构造用
15
+ blockedOutcome, okOutcome, failedOutcome, } from '../agent/managers/toolCallHelpers.js';
14
16
  import { LlmCaller } from '../agent/managers/llmCaller.js';
15
17
  import { LoopMetrics } from '../agent/managers/loopMetrics.js';
16
18
  import { DefaultDuplicateCallInterceptor } from '../agent/duplicateInterceptor.js';
17
19
  import { ToolResultCache, DEDUP_SUBJECT_EXTRACTORS, WRITE_PATH_EXTRACTORS, normalizePathKey, } from '../agent/toolResultCache.js';
18
20
  import { createDefaultGuards, GUARD_THRESHOLDS } from '../agent/guardRail.js';
19
- import { FileExposureLedger, parseReadFileCoverage, formatLedgerStub, shouldEchoLedgerStub, READ_DIGEST_CHARS, } from '../agent/toolLedger.js';
21
+ import { FileExposureLedger, parseReadFileCoverage, formatLedgerStub, formatLedgerStubRange, shouldEchoLedgerStub, READ_DIGEST_CHARS, READ_FILE_NOTICE_PREFIX, } from '../agent/toolLedger.js';
20
22
  import { nowIso } from '../utils/time.js';
21
23
  import { DEFAULT_L2_STRATEGY } from '../role-pack/strategyResolver.js';
22
24
  import { DEFAULT_MAX_ITERATIONS } from '../role-pack/strategyKeys.js';
23
25
  import { ToolRunner } from '../agent/toolRunner.js';
24
26
  import { detectNeedsPlanning, PLAN_NUDGE_PROMPT } from '../agent/needsPlanning.js';
27
+ import { formatBackgroundTaskNotice, } from '../agent/backgroundTasks.js';
28
+ /** 合法 target 清单(工具描述与错误提示共用此真源,避免文案列举与判据漂移) */
29
+ export const COMPRESS_TARGETS = [
30
+ 'earliest_round',
31
+ 'earliest_steps',
32
+ 'largest_tool_result',
33
+ ];
34
+ /**
35
+ * target → 中文标签(宿主 notice 文案的真源,**穷举映射不做 else 兜底**)
36
+ *
37
+ * 穷举而非三元/else:三元在枚举增补时把新值静默落到最后一个旧标签上(说谎),
38
+ * TS 侧因 `Record<CompressTarget, string>` 穷尽键而在增补时**编译期报错**——
39
+ * 让「加枚举忘了改文案」变成一道红闸,而不是运行时的一句假话。
40
+ */
41
+ export const COMPRESS_TARGET_LABELS = {
42
+ earliest_round: '最早轮次摘要',
43
+ earliest_steps: '最早执行步骤摘要',
44
+ largest_tool_result: '最大工具结果摘要',
45
+ };
46
+ /**
47
+ * 解析压缩目标(非法/缺失降级 `earliest_round`——保持既有行为,不扩大缺省面)
48
+ *
49
+ * 单点收口:判据只此一处,工具描述与回传提示的列举均引 `COMPRESS_TARGETS`。
50
+ */
51
+ function parseCompressTarget(raw) {
52
+ return COMPRESS_TARGETS.find((t) => t === raw) ?? 'earliest_round';
53
+ }
25
54
  /*
26
55
  * 职责边界登记(暂不拆分)
27
56
  *
@@ -56,7 +85,7 @@ export class AgentLoop {
56
85
  opts;
57
86
  messages = [];
58
87
  maxIterations;
59
- /** provider 上下文窗口(原始声明值,默认 120_000)。模型热切换经 setContextWindow 写入。 */
88
+ /** provider 上下文窗口(原始声明值;缺省 = `AGENT_CONSTANTS.DEFAULT_MAX_CONTEXT_TOKENS`,数字不落账)。模型热切换经 setContextWindow 写入。 */
60
89
  providerContextWindow;
61
90
  /** **有效上下文窗口** = min(providerContextWindow, strategy.contextLimit>0 ? contextLimit : ∞)。
62
91
  * 派生值(非独立真源):由 #recomputeEffectiveWindow 在「provider 窗口变更」与「角色策略变更」
@@ -143,10 +172,36 @@ export class AgentLoop {
143
172
  this.interruptQueue.splice(idx, 1);
144
173
  }
145
174
  }
146
- /** step 边界气口申请统一队列(pause/interject 统一入队,无独立 pauseRequested flag)。
147
- * 用户申请的气口(pause/interject)统一入队,_handleInterrupt 在 step 边界消费:
148
- * 先注入型(interject → appendUser),后挂起型(pause → yield paused)。 */
175
+ /** step 边界气口申请统一队列(pause/interject/command-result 统一入队,无独立 pauseRequested flag)。
176
+ * 消费在 _handleInterrupt:先注入型(interject → appendUser,command-result → appendSystem),
177
+ * 后挂起型(pause → yield paused)。 */
149
178
  interruptQueue = [];
179
+ /**
180
+ * 后台任务注册表(可选,装配层注入)
181
+ *
182
+ * loop 侧只做三件事,**不碰进程治理**(spawn / 杀树 / 内存护栏全在 skillScriptRunner):
183
+ * ① 注册完成监听 → 自然终态时把结果入队(`kind:'command-result'`),下个 step 边界注入 system 消息;
184
+ * ② turn 终态脱管存活进程(不杀,见 detachAll 注释),回流跨 turn 留存;
185
+ * ③ Agent 实例终态(close)真杀全部存活任务并注销 exit 兜底钩子(见 shutdownBackgroundTasks)——
186
+ * 跨 turn 但不跨宿主寿命,边界与 ② 不同;close 窗口赶不上时 exit 钩子同步兜底。
187
+ * 未注入时这些动作都是 no-op(无后台任务可管),不报错——纯 ToolExecutor 单测场景无注册表。
188
+ */
189
+ backgroundTasks;
190
+ /** 后台任务**自然终态**时通知装配层(由 Agent 在装配期接到事件面上,loop 只发不处理 UI)。
191
+ * 脱管后 turn 已结束,宿主挂在 turn 生命周期上的推送时机覆盖不到「任务自然跑完」⇒ 缺此
192
+ * 出口 UI 会显示假活跃。挂注册表 completion listener(notify)单点链,仅 settle 自然终态
193
+ * 到达;主动 kill 刻意不回调 listener(防同份结果双份消费),不经此事件。 */
194
+ emitBackgroundTaskSettled;
195
+ /**
196
+ * 装配层在装配期注入「后台任务自然终态」发射回调(**仅装配期一次**,非公开 API)。
197
+ *
198
+ * 走 Agent 事件面(`backgroundTaskSettled`)而非独立回调字段:与既有宿主事件
199
+ * (sessionError / contextTruncated / …)同型,宿主 `bindAgentNoticeEvents` 的
200
+ * 「先 off 再 on」幂等模式天然适用。
201
+ */
202
+ setBackgroundTaskSettledEmitter(fn) {
203
+ this.emitBackgroundTaskSettled = fn;
204
+ }
150
205
  /** 主动提问回调(LLM 调 ask_user 工具时调用,Agent 注入,loop 只回调不处理 UI) */
151
206
  onPendingQuestion;
152
207
  /** 有效窗口变更回调(Agent 注入):把 loop 算出的**有效窗口**分发给 loop 之外仍持有窗口拷贝的
@@ -158,6 +213,15 @@ export class AgentLoop {
158
213
  strategy = { ...DEFAULT_L2_STRATEGY };
159
214
  /** 本 turn 是否已执行过自审查(单次终审:布尔状态,不再需要轮次计数) */
160
215
  selfReviewDone = false;
216
+ /**
217
+ * 本 turn 是否已在 step 边界挂起(paused)——**turn 终态脱管的判据**(定案锚:ADR-036)。
218
+ *
219
+ * 置位点在 `_handleInterrupt` 的 pause 分支、**yield 之前**(宿主 break 会触发
220
+ * generator finally,时序敏感);复位点在 `resetTurnState`(两个 turn 入口都调)。
221
+ * 语义:挂起 = 同 turn 续跑,后台进程继续跑;不挂起的收场(done / interrupted /
222
+ * error)才是终态,对存活后台进程脱管(不杀,跨轮存活)。
223
+ */
224
+ pauseBoundaryReached = false;
161
225
  /** 本 turn(processUserInput)内是否实际执行过工具步。
162
226
  * 自审查的唯一触发门槛:只有多轮 turn(发生过工具调用)才审查,
163
227
  * 一遍过的纯文本问答不触发。由 processUserInput 入口重置(续跑 continueAfterPause 保留)。
@@ -369,7 +433,8 @@ export class AgentLoop {
369
433
  }
370
434
  // 单工具执行器:注入 loop 稳定能力窄面,strategy 经闭包读最新(setStrategy 动态生效)
371
435
  this.toolRunner = new ToolRunner({
372
- execute: (name, args) => this.opts.toolExecutor(name, args),
436
+ // 原生 outcome 回调逐跳透传:ToolRunner → opts.toolExecutor → toolExecutor.execute
437
+ execute: (name, args, emitOutcome) => this.opts.toolExecutor(name, args, emitOutcome),
373
438
  builtinTools: opts.builtinTools,
374
439
  preExecutionCheck: opts.preExecutionCheck,
375
440
  onToolExecuted: opts.onToolExecuted,
@@ -473,6 +538,23 @@ export class AgentLoop {
473
538
  throw err;
474
539
  }
475
540
  finally {
541
+ // turn 终态后台任务脱管(后台任务跨轮存活方案·脱管定案):done / interrupted / error / 达到最大迭代
542
+ // 四条收场路径都经过本 finally ⇒ 调用点唯一。
543
+ // 判据 = pauseBoundaryReached:挂起是同 turn 续跑(后台继续跑、其回流照常注入),
544
+ // 不算终态、不脱管。脱管 = 不杀进程(error 路径也不杀);进程寿命的终点在另一处:
545
+ // Agent 实例终态 close → shutdownBackgroundTasks 真杀 + exit 钩子同步兜底
546
+ // (观察点 ⑧ 真机定类后补,方案 §4.2)。
547
+ if (!this.pauseBoundaryReached) {
548
+ const bgReport = this.finalizeBackgroundTasksOnTurnEnd();
549
+ if (bgReport) {
550
+ // 收尾报告双通道:chunk 上屏(finally 中 yield 合法——推迟 generator 完成,
551
+ // 宿主照常 for-await 消费)+ system 消息进 LLM 历史(真源不因上屏形态漂移)。
552
+ // generator.return() 硬关闭路径下 yield 被丢弃不上屏(用户已主动停止),
553
+ // system 消息仍写入——报告不因上屏通道状态丢失。
554
+ yield { type: 'background_report', content: bgReport };
555
+ this.appendSystemMessage(bgReport, { executionTemp: true });
556
+ }
557
+ }
476
558
  const durationMs = Date.now() - taskStartAt;
477
559
  this.metrics.taskTotalDurationMs += durationMs;
478
560
  if (taskSucceeded) {
@@ -540,11 +622,149 @@ export class AgentLoop {
540
622
  clearPauseRequest() {
541
623
  this.cancelPauseEntry();
542
624
  }
543
- /** 插话(申请入队):把用户补充输入作为 InterruptRequest{kind:'interject'} 入 interruptQueue。
544
- * 与 requestPause(queueInterrupt pause)同为「申请 → 气口生效」——不中断当前 LLM/工具执行,
545
- * 只在 step 边界统一消费(先注入型 → appendUser,后挂起型 → yield paused)。 */
625
+ /**
626
+ * 插话(申请入队):把用户补充输入作为 InterruptRequest{kind:'interject'} 入 interruptQueue。
627
+ * 与 requestPause(queueInterrupt pause)同为「申请 → 气口生效」——不中断当前 LLM/工具执行,
628
+ * 只在 step 边界统一消费(先注入型 → appendUser,后挂起型 → yield paused)。
629
+ *
630
+ * 上限裁决(内核资源不变量,值真源 = LOOP_CONSTANTS.MAX_PENDING_INTERJECTIONS):待注入插话达上限时
631
+ * **整条拒收**返回 false(插话是原子思路,截半条 = 语义破坏);宿主以此返回值做 UI
632
+ * 提示(webview 预检与其 shared 镜像常量由守卫测试与本常量锁同值)。pause 条目不受
633
+ * 本上限管辖(软暂停申请是内部控制语义,非用户输入洪流)。
634
+ *
635
+ * @returns true=已入队;false=待注入插话已满,本条未入队(调用方不应持久化/上屏)
636
+ */
546
637
  interject(content) {
638
+ if (this.getPendingInterjections().length >= LOOP_CONSTANTS.MAX_PENDING_INTERJECTIONS) {
639
+ return false;
640
+ }
547
641
  this.interruptQueue.push({ kind: 'interject', content });
642
+ return true;
643
+ }
644
+ /**
645
+ * 后台命令完成回流入队(**生产者入口**,由装配层从注册表完成监听器调用)
646
+ *
647
+ * 走既有 `interruptQueue`(判别联合队列)新增一个 kind,零新基建:
648
+ * 与插话同机制(iteration 间检查点消费、挂起/恢复全链路复用),但**异质**——
649
+ * - 消费时进 `appendSystemMessage`(系统事件),不进 `appendUserMessage`(不伪装用户发言);
650
+ * - 不占插话满员计数(`interject()` 的容量裁决只数 kind==='interject');
651
+ * - **跨 turn 留存**(脱管定案,后台任务跨轮存活方案):turn 终态不丢弃,下个 turn 的 step 边界照常吸收——
652
+ * 与插话跨 turn 同标准,同性质数据不搞两套生命周期。
653
+ *
654
+ * @param content 已格式化的完成通知(含来源标记「后台命令完成」+ 命令 + 输出)
655
+ */
656
+ enqueueCommandResult(content) {
657
+ this.interruptQueue.push({ kind: 'command-result', content });
658
+ }
659
+ /**
660
+ * 装配后台任务注册表(assembler 调用,loop 创建之后)
661
+ *
662
+ * 两件职责一次接线,避免装配层散写两处:
663
+ * ① 完成监听 → `enqueueCommandResult`(回流通道生产者);
664
+ * ② 持有引用供 turn 终态脱管报告。
665
+ */
666
+ setBackgroundTasks(registry) {
667
+ this.backgroundTasks = registry;
668
+ registry.setCompletionListener((task) => {
669
+ const notice = formatBackgroundTaskNotice(task);
670
+ this.enqueueCommandResult(notice);
671
+ // 后台自然终局失败结算(METRICS-BGTASK-1 显式闭合 · SCRIPT-2 批次 B3):
672
+ // 本路径结构上不经工具结果通道——无 toolCallId、不产 tool_result,B4 判据切换
673
+ // 碰不到它,不在此显式施工则后台失败对 toolFailureCount 永久不可见。
674
+ // killed 不经本回调(注册表刻意不回调,防双份消费),只余 completed / timedOut:
675
+ // timedOut 或退出码非 0 = 执行事实失败,判定式与 run_command 同步路径同源
676
+ // (toolExecutor 的 commandFailed 同式,改一处必查另一处)。
677
+ // 即时结算、无 step 归属:metrics 是 loop 实例级累计(无 per-turn 重置),
678
+ // 脱管后终局照常落账;不产 ToolOutcome——无 toolCallId 可挂(工具结果通道
679
+ // 只覆盖经 runOne 的同步执行),后台失败经本点直接结算进 metrics。
680
+ // ⚠️ 口径变更:后台失败由不可见→计入 toolFailureCount,数字上升且历史不可比
681
+ // (论证:docs/方案-工具结果status字段化-20261006.md §三批次表 B3、§七)。
682
+ // task.result 类型可选,但自然终态必有(注册表 settle 先赋值后同步 notify),
683
+ // 判空即跳过、不计失败。
684
+ if (task.result && (task.result.timedOut || task.result.exitCode !== 0)) {
685
+ this.metrics.toolFailureCount++;
686
+ }
687
+ // 终态同时出宿主面(阶段二补漏 · 见方案文档 §5.2):脱管后 turn 已结束,
688
+ // 宿主周期性推送(step_boundary / background_report / kill 后回推)覆盖不到
689
+ // 「turn 结束后任务自然跑完」⇒ UI 会一直显示「运行中」。经 Agent 事件面转发
690
+ // (走既有 TypedEventEmitter,与 bindAgentNoticeEvents 的 off/on 幂等模式对齐),
691
+ // **不新增注册表 listener**。主动 kill 不经此回调(注册表刻意不回调,防双份消费)。
692
+ this.emitBackgroundTaskSettled?.(task);
693
+ });
694
+ }
695
+ /**
696
+ * 后台任务只读快照(宿主 UI 出口 · 见 `docs/方案-后台任务可见性与kill入口-20261004.md` §2.1)
697
+ *
698
+ * 纯透传注册表 `list()`——投影已剥离 `killNow` / `peek` 进程句柄,宿主无从触碰进程。
699
+ * 注册表未装配(后台能力未启用 / 装配期之前)返回空数组,不抛。
700
+ */
701
+ getBackgroundTasks() {
702
+ return this.backgroundTasks?.list() ?? [];
703
+ }
704
+ /**
705
+ * 终止后台任务(宿主 UI 出口 · 同上 §2.1)
706
+ *
707
+ * 纯透传注册表 `kill()`:先取已捕获输出快照再走杀树原语,随后回读终态投影。
708
+ * 任务不存在 → `null`(`kill` 与 `get` 对未知 taskId 同为 null,无需二次判空)。
709
+ *
710
+ * ⚠️ 主动 kill 不触发回流——输出已直接返回调用方,再回流一次会让 LLM
711
+ * 收到同一份结果的第二份副本(重复消费)。
712
+ */
713
+ killBackgroundTask(taskId) {
714
+ if (!this.backgroundTasks)
715
+ return null;
716
+ // kill 对未知 taskId 返回 null 时 get 同样为 null,无需二次判空(防冗余分支)
717
+ this.backgroundTasks.kill(taskId);
718
+ return this.backgroundTasks.get(taskId);
719
+ }
720
+ /**
721
+ * Agent 实例终态收割(**唯一调用点 = `Agent.close`**):真杀全部存活后台命令任务
722
+ * 并注销其 exit 兜底钩子。
723
+ *
724
+ * 纯透传注册表 `shutdown()`,判据不复制。与 turn 终态 `finalizeBackgroundTasksOnTurnEnd`
725
+ * 的边界见注册表 `detachAll / shutdown` 注释:跨 turn 存活是刻意语义,实例销毁后
726
+ * 任务不可寻址,留着只能是 OS 层孤儿(观察点 ⑧ 真机实锤)。
727
+ *
728
+ * 不产 `background_report`、不推 UI 快照——宿主正在退出,界面与回流都已无消费方。
729
+ * 注册表未装配返回 0,不抛。
730
+ *
731
+ * @returns 实际收割的 running 任务数
732
+ */
733
+ shutdownBackgroundTasks() {
734
+ return this.backgroundTasks?.shutdown() ?? 0;
735
+ }
736
+ /**
737
+ * turn 终态后台任务脱管(定案锚:ADR-036;论证见 `docs/方案-后台任务跨轮存活-20261004.md` §三)
738
+ *
739
+ * 判据 = 调用方传入的「本 turn 已收场」(挂起不算终态,见 `pauseBoundaryReached`):
740
+ * done / interrupted / error / 达到最大迭代 四条收场路径统一经此,调用点唯一
741
+ * (`_runWithSlo` 的 finally)。
742
+ *
743
+ * 此处只做脱管(detach)——把存活任务移出跟踪,**不杀进程、不丢未消费的 command-result**。
744
+ *
745
+ * 为什么不杀:turn 是「一问一答」的**归档单元**,不是进程的容器。进程属于**会话**,
746
+ * 活过 turn 是应有语义(Claude Code 的后台任务同样活到进程退出)。真机实锤过一个反例:
747
+ * 用户起 `ping -n 90`,turn 仅 6.3 秒结束就把进程杀了,UI 底部条从「运行中」变「已终止」
748
+ * 而用户从未点过——「后台」名不副实,用户视角完全不可理解。
749
+ *
750
+ * 为什么不丢 command-result:回流与插话性质相同(外部产生的事实、显式一条消息、分别经
751
+ * appendSystem / appendUser),只封杀 `command-result` 而放行 `interject` ⇒ 同一现象
752
+ * 两套标准 = 违反 SSOT;且 turn 内跑的 command-result 走的就是同一条通路(`_handleInterrupt`
753
+ * → `appendSystem`),**机制在 turn 边界突然失效是一致性缺口,不是保护**。
754
+ *
755
+ * 跨 turn 留存的边界(如实写):`interruptQueue` 挂在 Agent 级单例 `loop` 上
756
+ * (Agent 关闭即销毁);`MAX_PENDING_COMMAND_RESULTS=3` 只限**每步注入条数**(防一次性
757
+ * 灌爆上下文),**不限队列总量**——队列条数与注册表终态条目目前都无 GC,是已登记的
758
+ * 设计缺口(真机出现症状再立项,不在本次提前加机制)。
759
+ *
760
+ * @returns 脱管报告文本(null = 无后台任务,纯问答 turn 的常态——不产 chunk 不写 system 消息)
761
+ */
762
+ finalizeBackgroundTasksOnTurnEnd() {
763
+ const detached = this.backgroundTasks?.detachAll() ?? [];
764
+ if (detached.length === 0)
765
+ return null;
766
+ const detail = detached.map((t) => `- ${t.taskId}(仍在运行):${t.command}`).join('\n');
767
+ return `[本轮结束 · 后台任务继续运行] 本轮结束时有 ${detached.length} 个后台命令在运行,已转为后台常驻(本轮不终止;正常退出 VS Code 时随扩展终止,崩溃/被强杀除外)。结果不会自动进入上下文:你可以在后续任意一轮用 kill_command 传入该 taskId 取回截至当时的输出。\n${detail}`;
548
768
  }
549
769
  /** 删除待注入的插话(宿主 UI 层用户后悔)。与 interject 对称,在 step 边界消费前可安全删除。
550
770
  * 委托 interruptQueue 中 interject 条目的索引。
@@ -629,6 +849,8 @@ export class AgentLoop {
629
849
  // 但 processUserInput 在 resetTurnState 之后会重判设值(续跑入口不复用因此不重判)
630
850
  this.planNeedsNudge = false;
631
851
  this.planNudgeInjected = false;
852
+ // 挂起标记复位:续跑入口(continueAfterPause)也走本方法,复位后本段跑到底即终态
853
+ this.pauseBoundaryReached = false;
632
854
  }
633
855
  /** 重置 askLimit 计数(turn 入口):仅 processUserInput 调用,continueAfterPause 不动,
634
856
  * 保证暂停-续跑同属一次用户输入、提问次数跨续跑累计(askLimit 语义:按输入打扰防刷)。 */
@@ -694,9 +916,19 @@ export class AgentLoop {
694
916
  // LLM 返回 done(纯文本完成)期间用户 interject() 入队的补充输入,若直接 return false 会被静默丢弃;
695
917
  // 消费并继续迭代,下一轮 LLM 必看到插话内容。委托 _consumeInterjects(SSOT)。
696
918
  // 主要消费发生在 _handleInterrupt(迭代开始前),此处覆盖「LLM 在收尾轮执行期间插话」的窗口。
919
+ //
920
+ // ⚠️ 后台回流(command-result)**同样**在此消费,理由与插话同源但多一层:
921
+ // 收尾轮期间完成的后台命令若随 turn 结束被丢弃,agent 就永远拿不到自己起的任务的输出
922
+ // ——LLM 会带着「任务大概在跑」的**错误信念交付答案**(进程虽已脱管继续跑,但结果永远不回流)。
923
+ // 故有后台结果就再跑一轮,让它看到。
697
924
  const consumedCount = this._consumeInterjects(this.interruptQueue);
925
+ const consumedResults = this._consumeCommandResults(this.interruptQueue);
926
+ // ⚠️ 只清 interject:**不清 command-result** —— 限量注入(MAX_PENDING_COMMAND_RESULTS)
927
+ // 意味着本轮可能只投了前 N 条,剩下的必须**留队**等下个 step 边界续投;这里一并清掉
928
+ // 就等于「限量」退化成「截半丢弃」。turn 终态**也不再清**(脱管定案,见
929
+ // `finalizeBackgroundTasksOnTurnEnd`)——跨 turn 留存是刻意语义,不是遗漏。
698
930
  this.interruptQueue = this.interruptQueue.filter((r) => r.kind !== 'interject');
699
- return consumedCount > 0;
931
+ return consumedCount > 0 || consumedResults > 0;
700
932
  }
701
933
  /**
702
934
  * 自审查注入判定(emit selfReview 通知与注入 SELF_REVIEW 提示共用单一真理源)。
@@ -760,11 +992,46 @@ export class AgentLoop {
760
992
  }
761
993
  return count;
762
994
  }
763
- /** 中断检查:step 边界统一消费 interruptQueue(pause + interject)+ 硬中止检查。
995
+ /**
996
+ * 消费注入型**系统事件**条目(command-result → appendSystemMessage)
997
+ *
998
+ * 与 `_consumeInterjects` 严格分开而非合并成「消费所有注入型」:
999
+ * 两者的注入通道(user / system)、role 语义、容量裁决(插话占满员名额 / 回流不占)不同;
1000
+ * **生命周期 = 跨 turn 留存**(turn 终态不丢弃,下个 turn 的 step 边界照常吸收,见 finalize 注释)。
1001
+ * 即便如此仍须分开:通道与容量判据各异,合并成一个泛化遍历等于把判据摊平到 if 分支里
1002
+ * ——将来任一判据变化都要在同一段代码里翻找,正是「同一语义两套判定」的开始。
1003
+ * 故:两个方法,各守一条通道。
1004
+ *
1005
+ * **限量注入**(`MAX_PENDING_COMMAND_RESULTS`):回流是已发生的事实、不可拒收,
1006
+ * 只能限量。每步只注入 N 条,其余留队列等下个 step 边界——避免 N 个后台任务同时完成时
1007
+ * 一次性灌爆上下文。
1008
+ *
1009
+ * `executionTemp: true` —— 回流是**本轮执行期事件**(进程已结束、结果已交付),
1010
+ * 下一轮闭环入口由 `cleanExecutionTemporary` 清掉,不进历史上下文。
1011
+ *
1012
+ * @param reqs 待消费的申请列表(只读遍历,不修改)
1013
+ * @returns 消费的条目数
1014
+ */
1015
+ _consumeCommandResults(reqs) {
1016
+ const cap = LOOP_CONSTANTS.MAX_PENDING_COMMAND_RESULTS;
1017
+ let count = 0;
1018
+ for (const req of reqs) {
1019
+ if (req.kind !== 'command-result')
1020
+ continue;
1021
+ if (count >= cap)
1022
+ break; // 超量留队,下个 step 边界续投
1023
+ this.appendSystemMessage(req.content, { executionTemp: true });
1024
+ count++;
1025
+ }
1026
+ return count;
1027
+ }
1028
+ /** 中断检查:step 边界统一消费 interruptQueue(pause + interject + command-result)+ 硬中止检查。
764
1029
  *
765
1030
  * 单一气口出口——气口申请统一收在 interruptQueue(无独立 pause flag / 插话数组写位),
766
- * 此处统一 queue.splice(0) 取出全部申请,按 kind 分两类处理:
767
- * - 注入型(interject):先 appendUserMessage,不暂停 loop,让补充输入立刻进入下一轮 step
1031
+ * 此处统一 queue.splice(0) 取出全部申请,按 kind 分三类处理:
1032
+ * - 注入型·用户意图(interject):appendUserMessage,不暂停 loop,让补充输入立刻进入下一轮 step
1033
+ * - 注入型·系统事件(command-result):appendSystemMessage(executionTemp),
1034
+ * 不暂停 loop;与 interject 的差别只在注入通道与 role 语义(系统事件 ≠ 用户发言)
768
1035
  * - 挂起型(pause):yield {type:'paused'} + return 'paused',generator 在 step 边界挂起
769
1036
  *
770
1037
  * 顺序:先消费注入型 → 再检查挂起型 → 最后硬中止。注入型优先是为了让补充输入在 pause 生效前
@@ -774,8 +1041,10 @@ export class AgentLoop {
774
1041
  async *_handleInterrupt(signal) {
775
1042
  // 统一取出全部气口申请(queue.splice(0) 原子消费,消费后队列为空)
776
1043
  const reqs = this.interruptQueue.splice(0);
777
- // ① 先处理注入型气口(interject → appendUserMessage)——不暂停 loop,让补充输入立刻生效
1044
+ // ① 先处理注入型气口(interject → appendUserMessage;command-result → appendSystemMessage)
1045
+ // ——不暂停 loop,让补充输入 / 后台结果立刻进入下一轮 step
778
1046
  this._consumeInterjects(reqs);
1047
+ this._consumeCommandResults(reqs);
779
1048
  // ② 后处理挂起型气口(pause → yield paused)——如果队列里有 pause 申请,在 step 边界挂起
780
1049
  // ⛔ 生命周期契约:yield paused 后本 generator 立即 return 结束,
781
1050
  // **不再产出任何后续 chunk**(挂起即本流结束,续跑靠宿主另起 resumeExecution 新流)。
@@ -784,6 +1053,11 @@ export class AgentLoop {
784
1053
  // 导致"暂停态输入补充"被误路由进 interject 排队而无人消费(卡死坑)。
785
1054
  if (reqs.some((r) => r.kind === 'pause')) {
786
1055
  // pause 消费后 clearPauseRequest 已由 splice(0) 自动完成——无需额外清 flag
1056
+ // 挂起**不是** turn 终态(后台任务跨轮存活方案):同 turn 续跑,后台进程应继续跑、其回流照常注入。
1057
+ // 标记必须在 yield 之前置位——宿主在 paused chunk 处 break 会让 generator 停在
1058
+ // 这个 yield 上并触发 finally(runIterationLoop 的收割判据),置位晚了就会
1059
+ // 把「续跑」误判成「终态」而错杀后台进程。
1060
+ this.pauseBoundaryReached = true;
787
1061
  yield { type: 'paused' };
788
1062
  return 'paused';
789
1063
  }
@@ -1149,13 +1423,20 @@ export class AgentLoop {
1149
1423
  };
1150
1424
  return 'aborted';
1151
1425
  }
1152
- // Reflection:本轮工具结果含 retryable 错误时,追加反思提示帮 LLM 聚焦修正而非放弃
1153
- // slice 按实际执行的 effectiveToolCalls.length 取窗口——若按 LLM 原始请求条数
1154
- // llmResult.toolCalls.length 切,toolStepLimit 截断时 slice 多看会把上一轮残留的
1155
- // tool 错误吸进判定窗口,误注入反思提示(坑)
1156
- const hasRetryableError = this.messages
1157
- .slice(-effectiveToolCalls.length) // 只看本轮工具结果
1158
- .some((m) => m.role === 'tool' && isRetryableToolError(m.content));
1426
+ // Reflection:本轮工具结果含 retryable 错误时,追加反思提示帮 LLM 聚焦修正而非放弃。
1427
+ // 判据改读 executeToolCalls **回流的 outcome**(本轮专属,天然不含上一轮残留)——
1428
+ // 原实现按 effectiveToolCalls.length 反切 messages 取窗口,toolStepLimit 截断时切错
1429
+ // 会把上一轮残留的 tool 错误吸进判定窗口,误注入反思提示(坑,随回流一并消除)。
1430
+ //
1431
+ // 重试轴 vs 计数轴分离(两条**正交**轴,判据面都是结构化字段):
1432
+ // · 计数轴 —— `_processToolResults` 只读 `outcome.status` 三值(成/败/拦,不编码类别),零文本判据;
1433
+ // · 重试轴 —— 判「败因是否可重试」,读 `outcome.errorCode`;`isRetryableToolError` 传入
1434
+ // outcome 时**直接读该字段**,无码才回退解析文本前缀(兼容不入错误码体系的 `ABORTED`
1435
+ // 与造裸文本 outcome 的测试桩)。
1436
+ // `errorCode` 与文本前缀在 `failedOutcomeWithCode` **同源产出一次**,前缀是其渲染面
1437
+ // ⇒ 读字段与读文本同解、不存在漂移。禁再散落手抄前缀:改一处漏一处会让 Reflection
1438
+ // 静默失效(该重试的不再重试,无报错)。
1439
+ const hasRetryableError = execResult.outcomes.some((o) => isRetryableToolError(o));
1159
1440
  if (hasRetryableError) {
1160
1441
  // 反思次数用显式计数器限制,避免 messages 裁剪导致计数失真
1161
1442
  if (this.reflectionCountThisTurn < this.maxReflectionRetries) {
@@ -1343,16 +1624,18 @@ export class AgentLoop {
1343
1624
  for (const tc of toolCalls) {
1344
1625
  this.appendToolMessage(wrapToolResult(tc.function.name, '[TOOL_ABORTED] 该调用因本轮被中止而未执行。如需其结果,请重新发起。'), tc.id);
1345
1626
  }
1346
- return { aborted: true };
1627
+ // 一条工具都没跑 ⇒ 无 outcome(重试分类自然为空集,TOOL_ABORTED 本就不可重试)
1628
+ return { aborted: true, outcomes: [] };
1347
1629
  }
1348
1630
  // 实际执行工具步:标记"本 turn 发生过工具调用",作为自审查触发门槛(多轮 turn 才审查)
1349
1631
  this.toolExecutedThisTurn = true;
1350
1632
  // 标记进入自主工具步(供内核向宿主暴露"可续跑"信号)
1351
1633
  this.inAutonomousStep = true;
1352
1634
  // yield tool_start 并并发发起所有工具执行(不 await,由 Promise.all 统一等待)
1353
- // blocked 与执行 promise 耦合为同一结构体,每个工具恰好一条。
1354
- // 每个工具一次 push 一个 { blocked, promise },结果按工具顺序对齐取出,
1355
- // 保证护栏拦截标记永不与执行结果错位(避免平行数组各自 push 导致索引漂移)。
1635
+ // 每个工具一次 push 一个 { promise },结果按工具顺序对齐取出,
1636
+ // 保证拦截事实与执行结果永不错位(避免平行数组各自 push 导致索引漂移)。
1637
+ // B4 判据切换:promise 直接产出 ToolOutcome——护栏/替身在推送点构造 blockedOutcome,
1638
+ // 经 ToolRunner 的由 runOne 返回,loop 内自执行(compress/remember)返回结构化结果。
1356
1639
  const toolExecs = [];
1357
1640
  // 同路径写串行闸:键 = 写工具的规范化目标路径(`WRITE_PATH_EXTRACTORS`),值 = 该路径的写链尾。
1358
1641
  // 为何需要——`write_file` 是「读盘 → 改 → 写盘」,同 step 内并行发起时两次都基于同一份旧快照,
@@ -1420,7 +1703,10 @@ export class AgentLoop {
1420
1703
  this.appendSystemMessage(`[SEARCH_LIMIT] 联网搜索已达 ${LOOP_CONSTANTS.MAX_WEB_SEARCH_CALLS} 次上限,视为未检索到更多相关内容。` +
1421
1704
  `请停止调用 web_search,直接基于已有信息继续下一步或作答。`, { executionTemp: true });
1422
1705
  }
1423
- toolExecs.push({ blocked: true, promise: Promise.resolve(guardHit.message) });
1706
+ toolExecs.push({
1707
+ // search_limit 护栏拦截 = 主动挡下 ⇒ blockedOutcome(原因 GuardHit 已带)
1708
+ promise: Promise.resolve(blockedOutcome(guardHit.reason, guardHit.message)),
1709
+ });
1424
1710
  continue;
1425
1711
  }
1426
1712
  // 其余护栏命中 → 硬拦回填拒绝文案(blocked 与拒绝 promise 一体推出);未命中 → 下方正常执行。
@@ -1440,16 +1726,29 @@ export class AgentLoop {
1440
1726
  }
1441
1727
  // 拦截归属回喂:read_dedup 的撞墙升级计数只认自己拦的(onExec 分不清拦截归属)
1442
1728
  this.guardier.notifyBlocked(guardHit.guardId, tc.function.name, tc.function.arguments);
1443
- toolExecs.push({ blocked: true, promise: Promise.resolve(guardHit.message) });
1729
+ toolExecs.push({
1730
+ // 其余护栏(ask_limit/write_loop/read_failed/read_dedup)= 主动挡下 ⇒ blockedOutcome;
1731
+ // read_failed 是唯一「挡下背后藏真失败」的护栏(hasRealFailure 事实随 outcome 带出)
1732
+ promise: Promise.resolve(blockedOutcome(guardHit.reason, guardHit.message, guardHit.reason === 'read_failed')),
1733
+ });
1444
1734
  continue;
1445
1735
  }
1446
- // 台账替身回显分支(摘要顶替,不收口进 guardRail):原文已压缩/分段脚注时用摘要或引导 offset 续读替代重读。
1447
- // 保守放行分支:无摘要 / 区间超出覆盖 → 放行(宁可多读一次,不可死锁)。
1736
+ // 台账替身回显分支(不收口进 guardRail):**原文仍在上下文**时,同参/变体重读用**请求区间
1737
+ // 原文**(切不出则退化顶头摘要)替代;原文已被压缩链清出 → 放行真读(替身是死路,见 stillInContext 注)。
1738
+ // 其余保守放行分支:无摘要 / 区间超出覆盖 → 放行(宁可多读一次,不可死锁)。
1448
1739
  // 文件被 write/delete 修改 → 结果处理循环 invalidate 台账,放行合法重读。
1449
1740
  const ledgerSubject = DEDUP_SUBJECT_EXTRACTORS[tc.function.name]?.(tc.function.arguments);
1450
1741
  if (ledgerSubject?.path) {
1451
1742
  const cov = this.fileExposure.get(ledgerSubject.path);
1452
- if (cov && shouldEchoLedgerStub(ledgerSubject, cov)) {
1743
+ // 前置:原文仍在上下文才拦。摘要替身只服务「手边有原文可回顾」的语境;原文已被压缩链
1744
+ // 清出时回显替身 = 死路(模型要的中段 token 永远不在顶头摘要里,同参重读被反复拦),
1745
+ // 必须放行真读。判据与 read_dedup 分支①共用 isMessageStillUnmodified(同源)。
1746
+ // 顺带取回原文:拦下后按请求区间回显(见下方 formatLedgerStubRange),一次 find 两处消费。
1747
+ const covContent = cov !== undefined
1748
+ ? this.stillUnmodifiedContent(cov.lastToolCallId, cov.fingerprint)
1749
+ : undefined;
1750
+ const stillInContext = covContent !== undefined;
1751
+ if (cov && shouldEchoLedgerStub(ledgerSubject, cov, stillInContext)) {
1453
1752
  // 观测「补缝过度拦截」候选:台账替身回显命中累加。
1454
1753
  this.metrics.ledgerStubEchoCount++;
1455
1754
  // 裁决证据落盘(悬案取证):计数判不了意图,「模型规避完整读取」vs「防重误拦合法
@@ -1471,8 +1770,16 @@ export class AgentLoop {
1471
1770
  logger.debug({
1472
1771
  path: ledgerSubject.path,
1473
1772
  cov: `${cov.coverStart}-${cov.coverEnd}/${cov.totalLines}`,
1474
- }, 'read_file 台账替身回显:已用摘要顶替整读');
1475
- toolExecs.push({ blocked: true, promise: Promise.resolve(formatLedgerStub(cov)) });
1773
+ },
1774
+ // 文案只陈述两形态皆真的事实(未重新读取文件)——不再写「摘要顶替整读」:
1775
+ // 命中区间时回显的是请求的区间原文,写「摘要顶替」即与行为不符(改一处漏一处的老伤)。
1776
+ 'read_file 台账替身回显:未重新读取文件');
1777
+ toolExecs.push({
1778
+ // 台账替身 = 主动挡下 ⇒ blockedOutcome(替身非护栏、不经 GuardHit:原因固定 ledger_stub)
1779
+ // 回显取材:优先按**请求区间**切原文(原文仍在上下文时零成本可取);切不出 → 退化顶头替身。
1780
+ // 旧版恒给顶头 400 字符,与模型请求的区间零重叠 → 模型绕道(生产轮 round-1791449684099)。
1781
+ promise: Promise.resolve(blockedOutcome('ledger_stub', formatLedgerStubRange(cov, ledgerSubject, covContent === undefined ? undefined : unwrapToolResultBody(covContent)))),
1782
+ });
1476
1783
  continue;
1477
1784
  }
1478
1785
  }
@@ -1483,7 +1790,7 @@ export class AgentLoop {
1483
1790
  const writeKey = pathExtractor?.(tc.function.arguments);
1484
1791
  const useWriteBarrier = OPAQUE_WRITE_TOOL_NAMES.includes(tc.function.name) ||
1485
1792
  (pathExtractor !== undefined && writeKey === undefined);
1486
- // 第二级压缩工具由 loop 拦截执行(现场压临时摘要替换,loop 收尾即弃),不落 ToolExecutor
1793
+ // 第二级压缩/情报区工具由 loop 拦截执行(决策四:直接返回 ToolOutcome,不经 ToolExecutor)
1487
1794
  let promise;
1488
1795
  if (tc.function.name === COMPRESS_CONTEXT_TOOL.name) {
1489
1796
  promise = this.compressContext(tc.function.arguments, signal);
@@ -1495,53 +1802,61 @@ export class AgentLoop {
1495
1802
  // 屏障:排在全部在途写链尾之后(同/异路径写与既有屏障都要等),执行后成为新屏障尾
1496
1803
  const prior = [...writeChains.values(), ...(opaqueWriteTail ? [opaqueWriteTail] : [])];
1497
1804
  promise = Promise.all(prior).then(() => this.toolRunner.runOne(tc, signal));
1498
- // 链尾吞掉失败:一次写失败不得毒化后续写(本次真实结果仍由 promise 原样上抛)
1499
- opaqueWriteTail = promise.catch(() => '');
1805
+ // 链尾吞掉失败:一次写失败不得毒化后续写(本次真实结果仍由 promise 原样上抛;
1806
+ // 链尾值从不被消费,仅作串行占位 ⇒ okOutcome('') 即可)
1807
+ opaqueWriteTail = promise.catch(() => okOutcome(''));
1500
1808
  }
1501
1809
  else if (writeKey) {
1502
1810
  // 等同路径上一次写落地后再执行——这样本次「读盘」拿到的是上一次写后的最新内容,
1503
1811
  // 不再基于旧快照、也就不会把上一次的改动覆盖掉;屏障在途时同样等它(目标可能就是本文件)。
1504
1812
  const prior = [
1505
- writeChains.get(writeKey) ?? Promise.resolve(''),
1813
+ writeChains.get(writeKey) ?? Promise.resolve(okOutcome('')),
1506
1814
  ...(opaqueWriteTail ? [opaqueWriteTail] : []),
1507
1815
  ];
1508
1816
  promise = Promise.all(prior).then(() => this.toolRunner.runOne(tc, signal));
1509
1817
  // 链尾吞掉失败:一次写失败不得毒化同路径的后续写(本次真实结果仍由 promise 原样上抛)
1510
- writeChains.set(writeKey, promise.catch(() => ''));
1818
+ writeChains.set(writeKey, promise.catch(() => okOutcome('')));
1511
1819
  }
1512
1820
  else {
1513
1821
  promise = this.toolRunner.runOne(tc, signal);
1514
1822
  }
1515
- toolExecs.push({ blocked: false, promise });
1823
+ toolExecs.push({ promise });
1516
1824
  }
1517
1825
  const results = await Promise.all(toolExecs.map((e) => e.promise));
1826
+ // 结果随 outcome 一并下传:status 是成败/拦截判据的唯一真源
1827
+ //(B4 判据切换:编排层与计数层读同一份 status,禁另立文本判据)
1518
1828
  this._processToolResults(toolCalls, results);
1519
1829
  // 按原始顺序 yield tool_result
1520
1830
  for (let i = 0; i < toolCalls.length; i++) {
1521
1831
  const tc = toolCalls[i];
1522
- const result = results[i];
1523
- // 第三态:策略拦截 blocked=true 非成功亦非失败——ok=false 且不计成功搜索数;
1524
- // 失败([ERR 前缀)与拦截区分开,UI 显示「已拦截」,metrics 失败数不把拦截算作失败
1525
- const blocked = toolExecs[i].blocked;
1526
- const ok = !blocked && !result.startsWith('[ERR');
1832
+ const outcome = results[i];
1833
+ // 渲染面文本(给 LLM / 防重指纹 / 台账用);成败与拦截一律读 outcome.status,不再扫文本
1834
+ const result = outcome.text;
1835
+ // 第三态:主动挡下(loop 护栏 / 执行层闸门 / 旁路拒绝 / 台账替身)非成功亦非失败——
1836
+ // ok=false 且不计成功搜索数;失败与拦截区分开,UI 显示「已拦截」,
1837
+ // metrics 失败数不把拦截算作失败(B4 口径:执行层闸门也归 blocked,见方案 §六决策二)
1838
+ const blocked = outcome.status === 'blocked';
1839
+ const ok = outcome.status === 'ok';
1527
1840
  // 搜索收敛护栏:累计本闭环成功 web_search 次数(LLM 反复搜索不收敛时据此注入收敛提示)
1528
1841
  if (tc.function.name === 'web_search' && ok) {
1529
1842
  this.successfulWebSearchCount++;
1530
1843
  }
1531
1844
  // 工具结果防重缓存:仅成功且是 info-fetch 类型的工具才写入(失败/拦截不缓存——
1532
1845
  // 失败可能是临时问题,拦截是我们主动挡的)。
1533
- // 一并记录 toolCallId + 内容指纹:拦截前据此判定「该结果是否仍在当前上下文中」。
1534
- // 指纹按进入上下文的包裹口径计算(复用 wrapToolResult 同一函数 → 同源,不会因模板改动失配)。
1535
1846
  const resultExtractor = DEDUP_SUBJECT_EXTRACTORS[tc.function.name];
1536
1847
  if (ok && resultExtractor) {
1537
1848
  const subject = resultExtractor(tc.function.arguments);
1538
1849
  if (subject) {
1850
+ // 指纹按进入上下文的包裹口径计算(复用 wrapToolResult 同一函数 → 同源,不会因模板改动失配)。
1851
+ // 一次计算两处消费(缓存 + 台账):分支①分支②的「原文是否仍在上下文」比对同一份指纹。
1852
+ const fingerprint = sha256Fingerprint(wrapToolResult(tc.function.name, result));
1539
1853
  this.toolResultCache.set(tc.function.name, subject, this.currentIteration, {
1540
1854
  toolCallId: tc.id,
1541
- fingerprint: sha256Fingerprint(wrapToolResult(tc.function.name, result)),
1855
+ fingerprint,
1542
1856
  });
1543
1857
  // 文件覆盖度台账写侧:read_file 返回分段脚注(= 文件确实大/被截断,按需读取信号)
1544
- // 时记录覆盖区间 + 轻量替身摘要——供替身回显分支在原文被压缩后回显(防重读永动机 / 空拦死锁)。
1858
+ // 时记录覆盖区间 + 轻量替身摘要——原文**仍在上下文**时替身回显分支据此拦截变体重读
1859
+ // (防重读永动机);原文被压缩后条目随指纹失配自动失效为放行(台账不清、判据把关)。
1545
1860
  if (tc.function.name === 'read_file' && subject.path) {
1546
1861
  const cov = parseReadFileCoverage(result);
1547
1862
  if (cov) {
@@ -1558,22 +1873,42 @@ export class AgentLoop {
1558
1873
  // 替身 = 已读正文前 READ_DIGEST_CHARS 字符(轻量启发式,零 LLM 成本)
1559
1874
  digest: cov.content.slice(0, READ_DIGEST_CHARS),
1560
1875
  cachedAtIteration: this.currentIteration,
1876
+ // 在上下文判定三件套:定位锚 + 指纹与缓存同源(见上方 fingerprint 注)
1877
+ lastToolCallId: tc.id,
1878
+ fingerprint,
1561
1879
  });
1562
1880
  }
1563
- else {
1564
- // 整读无脚注(= 已读到文件末尾 / 文件未超单段预算,读到末尾零噪音):
1565
- // 同样记「全文件覆盖」——否则小文件整读后无台账 → 替身回显永不触发 → 压缩后重读狂飙
1566
- // (真机 182 次 read_file 复发根因正是小文件不记——「补缝过度拦截」的反例)。
1567
- const totalLines = result.split('\n').length;
1568
- logger.debug({ path: subject.path, totalLines, src: 'whole-read', peek: result.slice(0, 80) }, 'read_file 台账写入(整读无脚注)');
1881
+ else if (!result.startsWith(READ_FILE_NOTICE_PREFIX)) {
1882
+ // 整读无脚注 = handler 不变量(sliceFileByLineBudget):请求区间读到文件末尾且
1883
+ // 预算足,返回**从请求 offset 到末尾**的完整尾段(正文首行 = 文件第 offset 行)。
1884
+ // 故覆盖区间按请求起点记:coverStart=offset、totalLines=offset+正文行数-1——
1885
+ // 错记成「1 起的整读」会把早段未覆盖误当已覆盖(真机 round-1791507775404:
1886
+ // offset=54 读到末尾被记 1–95/共95,随后 offset=1 早段读被假拦 + 回显行号错位)。
1887
+ // offset≤1 时与旧记账数值完全等价(起点即 1,总行数=正文行数)。
1888
+ const start = subject.offset ?? 1;
1889
+ const totalLines = start + result.split('\n').length - 1;
1890
+ logger.debug({
1891
+ path: subject.path,
1892
+ start,
1893
+ totalLines,
1894
+ src: 'whole-read',
1895
+ peek: result.slice(0, 80),
1896
+ }, 'read_file 台账写入(整读无脚注,按请求起点记账)');
1569
1897
  this.fileExposure.record(subject.path, {
1570
1898
  totalLines,
1571
- coverStart: 1,
1899
+ coverStart: start,
1572
1900
  coverEnd: totalLines,
1573
1901
  digest: result.slice(0, READ_DIGEST_CHARS),
1574
1902
  cachedAtIteration: this.currentIteration,
1903
+ lastToolCallId: tc.id,
1904
+ fingerprint,
1575
1905
  });
1576
1906
  }
1907
+ else {
1908
+ // 提示性返回(越界等,READ_FILE_NOTICE_PREFIX 开头):非文件正文,不进台账——
1909
+ // 记了会把「不存在的覆盖」当真,随后再次越界读命中归一判据被假拦(宁少记放行)
1910
+ logger.debug({ path: subject.path, peek: result.slice(0, 80) }, 'read_file 提示性返回,不进覆盖度台账');
1911
+ }
1577
1912
  }
1578
1913
  }
1579
1914
  }
@@ -1583,7 +1918,9 @@ export class AgentLoop {
1583
1918
  toolName: tc.function.name,
1584
1919
  argsJson: tc.function.arguments,
1585
1920
  toolCallId: tc.id,
1586
- outcome: blocked ? 'blocked' : ok ? 'ok' : 'failed',
1921
+ // B4 判据切换:执行层拒绝(宿主审批)从 'failed' 变 'blocked'——write_loop
1922
+ // 自持状态因此不再把「用户拒绝」当失败升级文案,语义对齐「主动挡下」
1923
+ outcome: outcome.status,
1587
1924
  });
1588
1925
  // 副作用型工具成功 → 主动失效关联的 read_file 缓存(放行后续合法重读)
1589
1926
  // 覆盖面派生自 `diskWrite:'path'` 声明(与串行闸同一真源),目标提取复用同一提取器——
@@ -1608,10 +1945,12 @@ export class AgentLoop {
1608
1945
  // 循环结束后再查 abort:最后一个工具执行期间被 abort 时返回 aborted:true,
1609
1946
  // 否则 processUserInput 会进入下一轮 LLM 调用(浪费资源)
1610
1947
  if (signal?.aborted) {
1611
- return { aborted: true };
1948
+ // 工具已跑完、本轮被中止:outcome 照常回流(重试分类仍基于真实结果,不因中止而失忆)
1949
+ return { aborted: true, outcomes: results };
1612
1950
  }
1613
1951
  this.inAutonomousStep = false;
1614
- return { aborted: false };
1952
+ // 回流本轮 outcome:重试分类据此读结构化 errorCode,不再回头解析 message 文本
1953
+ return { aborted: false, outcomes: results };
1615
1954
  }
1616
1955
  /**
1617
1956
  * 第二级压缩(LLM 主动触发兜底):把最早的 turn 或超大工具结果现场压成临时摘要替换。
@@ -1621,28 +1960,31 @@ export class AgentLoop {
1621
1960
  *
1622
1961
  * @param args 工具参数 JSON({ target: 'earliest_round' | 'largest_tool_result' })
1623
1962
  * @param signal 中止信号
1624
- * @returns 回传给 LLM 的确认串(失败/无目标时返回提示,不抛错阻断工具链)
1963
+ * @returns 结构化 outcome(B4 判据切换):无目标 → blocked(no_compress_target);
1964
+ * 真跑了但失败(摘要生成失败 / 压缩异常)→ **failed**(重归类:文本判据期这两串
1965
+ * 无失败前缀、曾被静默算成功,口径变更见方案 §七);成功 → ok。
1966
+ * text 仍是回传给 LLM 的提示串(失败/无目标不抛错阻断工具链)。
1625
1967
  */
1626
1968
  async compressContext(args, signal) {
1627
1969
  try {
1628
1970
  // 解析目标(非法/缺失降级 earliest_round)
1629
1971
  let target;
1630
1972
  try {
1631
- const parsed = JSON.parse(args).target;
1632
- target = parsed === 'largest_tool_result' ? 'largest_tool_result' : 'earliest_round';
1973
+ target = parseCompressTarget(JSON.parse(args).target);
1633
1974
  }
1634
1975
  catch {
1635
1976
  target = 'earliest_round';
1636
1977
  }
1637
- // 定位目标消息
1638
- const targetMsgs = target === 'largest_tool_result' ? this.findLargestToolResult() : this.findEarliestRound();
1978
+ // 定位目标消息(各定位器只返回「可安全整段替换」的区间——配对完整性由定位器自身保证)
1979
+ const targetMsgs = this.findCompressTarget(target);
1639
1980
  if (!targetMsgs || targetMsgs.length === 0) {
1640
- return '[compress_context] 无可压缩目标(上下文为空或目标不存在)';
1981
+ return blockedOutcome('no_compress_target', `[compress_context] 无可压缩目标(上下文为空或目标不存在;可用 target=${COMPRESS_TARGETS.join(' | ')})`);
1641
1982
  }
1642
1983
  // LLM 现场压成临时摘要
1643
1984
  const summary = await this.summarizeForCompression(targetMsgs, signal);
1644
1985
  if (!summary) {
1645
- return '[compress_context] 摘要生成失败,已跳过(不破坏上下文)';
1986
+ // B4 重归类:真跑了压缩但摘要生成为空 ⇒ failed(原无失败前缀,曾被算成功)
1987
+ return failedOutcome('[compress_context] 摘要生成失败,已跳过(不破坏上下文)');
1646
1988
  }
1647
1989
  // 替换为目标内容为临时摘要 system 消息(executionTemp:loop 收尾即弃)
1648
1990
  const tempSummaryMsg = {
@@ -1654,23 +1996,30 @@ export class AgentLoop {
1654
1996
  const startIdx = this.messages.indexOf(first);
1655
1997
  const endIdx = last === first ? startIdx : this.messages.indexOf(last);
1656
1998
  if (startIdx === -1 || endIdx === -1) {
1657
- return '[compress_context] 目标已不在当前上下文,已跳过';
1999
+ return blockedOutcome('no_compress_target', '[compress_context] 目标已不在当前上下文,已跳过');
1658
2000
  }
1659
2001
  this.messages.splice(startIdx, endIdx - startIdx + 1, tempSummaryMsg);
1660
2002
  this.executionTempSystem.add(tempSummaryMsg);
1661
2003
  logger.info({ target, replacedCount: targetMsgs.length }, 'compress_context 已压缩为临时摘要(loop 收尾即弃)');
1662
2004
  // 回调宿主:LLM 主动压缩完成(第二级压缩,与 contextTruncated 的内核自动截断区分)
1663
2005
  this.onContextCompressed?.(target, targetMsgs.length, summary.length);
1664
- return `[compress_context] 已把目标压缩为临时摘要(${summary.length} 字,loop 收尾即弃):${summary.slice(0, 80)}`;
2006
+ return okOutcome(`[compress_context] 已把目标压缩为临时摘要(${summary.length} 字,loop 收尾即弃):${summary.slice(0, 80)}`);
1665
2007
  }
1666
2008
  catch (err) {
1667
2009
  logger.warn({ err: toError(err).message }, 'compress_context 压缩失败,已跳过');
1668
- return '[compress_context] 压缩失败,已跳过(不阻断工具链)';
2010
+ // B4 重归类:压缩异常 = 真跑了但失败 ⇒ failed(原无失败前缀,曾被算成功)
2011
+ return failedOutcome('[compress_context] 压缩失败,已跳过(不阻断工具链)');
1669
2012
  }
1670
2013
  }
1671
2014
  /**
1672
2015
  * 定位当前触发输入(顶级锚点)之前最早的 turn;无旧轮次(新对话第一轮)返回 null
1673
2016
  * (当前输入永不压缩——交软上限收尾而非压掉触发输入继续硬跑)。
2017
+ *
2018
+ * ⚠️ **单轮 turn 内本定位器恒返回 null**(CTX-WIN-2 实锤):锚点是
2019
+ * 「第一个 user vs 最后一个 user」,而单轮 turn 内**只有一个 user**(当前触发输入)
2020
+ * ⇒ `firstUserIdx >= lastUserIdx` 恒成立 ⇒ 无旧 turn 可压。
2021
+ * 这不是「长单轮不需要压缩」,而是**锚点粒度选错了**——单轮内的历史是 step 序列,
2022
+ * 由 `findEarliestSteps` 承接。两定位器互补,单轮长任务才有目标。
1674
2023
  */
1675
2024
  findEarliestRound() {
1676
2025
  // 最后一个 user = 当前触发输入(顶级锚点,永不压缩)
@@ -1698,7 +2047,16 @@ export class AgentLoop {
1698
2047
  }
1699
2048
  return out;
1700
2049
  }
1701
- /** 定位最大的 tool 结果(超大 tool_result 的压缩目标) */
2050
+ /**
2051
+ * 定位最大的 tool 结果所在**整段 step**(超大 tool_result 的压缩目标)。
2052
+ *
2053
+ * 🔴 **配对完整性(CTX-WIN-2 实锤,原实现带伤)**:本定位器曾只返回
2054
+ * **单条 tool 消息**,而它所属的 `assistant.toolCalls` 留在上下文里
2055
+ * ⇒ 下一次请求命中发送边界守卫 `unpairedAssistantCall` **fail-fast 抛错,整个 turn 崩掉**
2056
+ * (实证:单轮 turn 内 `target=largest_tool_result` 必崩,非边缘情形)。
2057
+ * 故返回**整段**:该 tool 消息 + 所属 assistant 消息(toolCalls 全部摘除)
2058
+ * + 中间夹在两者之间的其他消息——保证替换后剩余上下文仍处处配对。
2059
+ */
1702
2060
  findLargestToolResult() {
1703
2061
  let largest = null;
1704
2062
  for (const m of this.messages) {
@@ -1708,7 +2066,97 @@ export class AgentLoop {
1708
2066
  largest = m;
1709
2067
  }
1710
2068
  }
1711
- return largest ? [largest] : null;
2069
+ if (!largest)
2070
+ return null;
2071
+ const toolIdx = this.messages.indexOf(largest);
2072
+ // 向前找所属 assistant(最近的、带 toolCalls 且含该 toolCallId 者)
2073
+ let assistantIdx = -1;
2074
+ for (let i = toolIdx - 1; i >= 0; i--) {
2075
+ const m = this.messages[i];
2076
+ if (m.role === 'assistant' && m.toolCalls?.some((tc) => tc.id === largest.toolCallId)) {
2077
+ assistantIdx = i;
2078
+ break;
2079
+ }
2080
+ // 撞上 user/system 边界 ⇒ 该 tool 无所属 assistant(异常态),只取自身
2081
+ if (m.role === 'user')
2082
+ break;
2083
+ }
2084
+ const start = assistantIdx === -1 ? toolIdx : assistantIdx;
2085
+ return this.messages.slice(start, toolIdx + 1);
2086
+ }
2087
+ /**
2088
+ * 定位**最早的若干个已完成 step**(单轮 turn 内的压缩目标,CTX-WIN-2 新增)。
2089
+ *
2090
+ * 为什么需要它(第一性原理):单轮长任务(一个 user 输入跑几十个 step)的上下文增长
2091
+ * 全部来自 **step 序列**(assistant 思考 + tool 结果),而 `earliest_round` 的锚点是
2092
+ * 「user 消息」⇒ 单轮内只有一个 user ⇒ 恒无目标。上一轮三样本 38.5万 / 105.3万 / 72.0万
2093
+ * token 的长单轮就是这样把窗口顶满、却没有任何可压目标。
2094
+ *
2095
+ * **step 边界判据**(内核既有结构,零新概念):一个 step = 一条 `assistant` 消息
2096
+ * (思考/工具调用意图)到**下一条 assistant 消息之前**的全部消息(含其 tool 结果与
2097
+ * 夹在中间的 system 注入)。故本定位器 = 「跳过当前触发输入,取第一个 assistant 起的
2098
+ * 一个完整 step」。
2099
+ *
2100
+ * 边界纪律(与既有两定位器同源):
2101
+ * ① **当前触发输入(最后一个 user)永不压缩**——顶级锚点;
2102
+ * ② **只取「已完成」的 step**:末条 assistant 之后若还有 tool 结果,说明该 step 尚未走完
2103
+ * (结果可能是 `[ASK_SUSPENDED]` 等),留待下轮;
2104
+ * ③ **整段替换**——step 内 assistant.toolCalls 与其 tool 结果同进同出,配对恒成立。
2105
+ */
2106
+ findEarliestSteps() {
2107
+ // 顶级锚点:当前触发输入位置(其后的消息才可压)
2108
+ let lastUserIdx = -1;
2109
+ for (let i = this.messages.length - 1; i >= 0; i--) {
2110
+ if (this.messages[i].role === 'user') {
2111
+ lastUserIdx = i;
2112
+ break;
2113
+ }
2114
+ }
2115
+ if (lastUserIdx === -1)
2116
+ return null;
2117
+ // 第一个 assistant = 最早 step 的起点(必须晚于顶级锚点:user 之前的 assistant 属上一轮,
2118
+ // 由 findEarliestRound 负责,不在此越界)
2119
+ let firstAssistantIdx = -1;
2120
+ for (let i = lastUserIdx + 1; i < this.messages.length; i++) {
2121
+ if (this.messages[i].role === 'assistant') {
2122
+ firstAssistantIdx = i;
2123
+ break;
2124
+ }
2125
+ }
2126
+ if (firstAssistantIdx === -1)
2127
+ return null;
2128
+ // 下一条 assistant 之前 = 本 step 的右边界
2129
+ let endIdx = this.messages.length;
2130
+ for (let i = firstAssistantIdx + 1; i < this.messages.length; i++) {
2131
+ if (this.messages[i].role === 'assistant') {
2132
+ endIdx = i;
2133
+ break;
2134
+ }
2135
+ }
2136
+ const step = this.messages.slice(firstAssistantIdx, endIdx);
2137
+ if (step.length === 0)
2138
+ return null;
2139
+ // 「已完成」判据 = 本 step 内每个 assistant.toolCalls 的 id 都有配对的 tool 消息回齐。
2140
+ // 逐 id 核验(而非「区间内有无 tool」):后者被**后续** step 的 tool 结果误判为未完成
2141
+ // —— 那是拿「别的 step 的状态」当「本 step 的状态」,判据层级选错。
2142
+ const toolIdsInStep = new Set(step.filter((m) => m.role === 'tool' && m.toolCallId).map((m) => m.toolCallId));
2143
+ const allAnswered = step
2144
+ .flatMap((m) => (m.toolCalls ?? []).map((tc) => tc.id))
2145
+ .every((id) => toolIdsInStep.has(id));
2146
+ if (!allAnswered)
2147
+ return null;
2148
+ return step;
2149
+ }
2150
+ /** 压缩目标分派(三个定位器互补,各守一条通道,不合并成泛化遍历) */
2151
+ findCompressTarget(target) {
2152
+ switch (target) {
2153
+ case 'largest_tool_result':
2154
+ return this.findLargestToolResult();
2155
+ case 'earliest_steps':
2156
+ return this.findEarliestSteps();
2157
+ case 'earliest_round':
2158
+ return this.findEarliestRound();
2159
+ }
1712
2160
  }
1713
2161
  /** 用 provider 把目标内容压成临时摘要(走 summary 路由,轻量模型优先;失败降级空串) */
1714
2162
  async summarizeForCompression(targetMsgs, signal) {
@@ -2128,6 +2576,9 @@ export class AgentLoop {
2128
2576
  unparsedToolIntentCount: this.metrics.unparsedToolIntentCount,
2129
2577
  ledgerStubEchoCount: this.metrics.ledgerStubEchoCount,
2130
2578
  readDedupBlockCount: this.metrics.readDedupBlockCount,
2579
+ // SCRIPT-2 B4 口径:执行层闸门归 blocked(不再计入 failureCount),见字段 JSDoc
2580
+ toolBlockedCount: this.metrics.toolBlockedCount,
2581
+ toolBlockedWithFailureCount: this.metrics.toolBlockedWithFailureCount,
2131
2582
  },
2132
2583
  context: {
2133
2584
  truncationCount: this.contextManager.truncationCount,
@@ -2382,7 +2833,11 @@ export class AgentLoop {
2382
2833
  */
2383
2834
  appendToolMessage(content, toolCallId) {
2384
2835
  const finalContent = this.offloadDir
2385
- ? offloadLargeToolResult(content, { offloadDir: this.offloadDir }).content
2836
+ ? offloadLargeToolResult(content, {
2837
+ offloadDir: this.offloadDir,
2838
+ // 尾部预览:长输出的关键信息常在尾部(缺口 D);份额在既有预览预算内划分
2839
+ tailPreviewChars: LOOP_CONSTANTS.TOOL_RESULT_OFFLOAD_TAIL_PREVIEW_CHARS,
2840
+ }).content
2386
2841
  : content;
2387
2842
  this.messages.push({
2388
2843
  role: 'tool',
@@ -2398,7 +2853,8 @@ export class AgentLoop {
2398
2853
  * ack **不回填 note 原文**(工具结果只回「已记录」),note 原文只进情报区 system 消息 → 不在用户流。
2399
2854
  *
2400
2855
  * @param args remember_intel 的 JSON arguments
2401
- * @returns 简短 ack(作为该工具调用的 tool 结果)
2856
+ * @returns 结构化 outcome(B4 判据切换):note 为空 → blocked(invalid_intel_note)
2857
+ * (主动挡下:没写就谈不上失败);已记录 → ok。text 仍为简短 ack(工具结果不回填 note 原文)。
2402
2858
  */
2403
2859
  handleRememberIntel(args) {
2404
2860
  let note;
@@ -2411,7 +2867,7 @@ export class AgentLoop {
2411
2867
  note = undefined;
2412
2868
  }
2413
2869
  if (!note) {
2414
- return '[情报区] 未写入:note 为空或缺少该参数。';
2870
+ return blockedOutcome('invalid_intel_note', '[情报区] 未写入:note 为空或缺少该参数。');
2415
2871
  }
2416
2872
  const prev = this.intelNote;
2417
2873
  this.intelNote = prev ? `${prev}\n${note}` : note;
@@ -2419,7 +2875,7 @@ export class AgentLoop {
2419
2875
  if (this.intelNote.length > LOOP_CONSTANTS.MAX_INTEL_PREFIX_LEN) {
2420
2876
  this.intelNote = this.intelNote.slice(-LOOP_CONSTANTS.MAX_INTEL_PREFIX_LEN);
2421
2877
  }
2422
- return '[情报区] 已记录(对你私有,不会展示给用户)。';
2878
+ return okOutcome('[情报区] 已记录(对你私有,不会展示给用户)。');
2423
2879
  }
2424
2880
  /**
2425
2881
  * 每轮装配把情报区注入为**单条尾部私有 system 消息**。
@@ -2449,13 +2905,41 @@ export class AgentLoop {
2449
2905
  }
2450
2906
  }
2451
2907
  /**
2452
- * 缓存条目对应的工具结果是否**仍在当前上下文中** —— 防重拦截的前提(调用点见 handleToolCalls)。
2908
+ * 指定 toolCallId 的 tool 消息是否仍在上下文中且内容未被改动 —— 「结果仍在上下文」的共用判据。
2453
2909
  *
2454
- * 三道判据全过才算「在」:① 条目带 toolCallId 与指纹;② `this.messages` 中仍有该 toolCallId 的
2455
- * tool 消息;③ 其内容指纹与记录一致(内容未被压缩链替换成 `[Previous: used x]` 占位符)。
2910
+ * 两个消费语境(SSOT):① read_dedup 分支①(经 isCachedResultStillInContext);
2911
+ * ② 台账替身回显分支②(按 FileCoverage.lastToolCallId + fingerprint 直调)。
2912
+ * 二者比对口径必须一致(wrapped 后消息内容指纹),否则两路对「同一结果在不在」的理解分叉。
2456
2913
  *
2457
- * **保守方向:宁可放行,不可误拦。** 找不到消息 / 指纹不符 / 条目缺元信息 → 一律 false(放行)。
2914
+ * 三道判据全过才算「在」:消息数组中仍有该 toolCallId 的 tool 消息,且内容指纹与记录一致
2915
+ * (内容未被压缩链替换成 `[Previous: used x]` 占位符)。
2916
+ *
2917
+ * **保守方向:宁可放行,不可误拦。** 找不到消息 / 指纹不符 → false(放行)。
2458
2918
  * 误放行的代价是「多读一次」;误拦的代价是「内容已不在手上却读不回来 = 死锁」——两者不对称。
2919
+ */
2920
+ isMessageStillUnmodified(toolCallId, fingerprint) {
2921
+ return this.stillUnmodifiedContent(toolCallId, fingerprint) !== undefined;
2922
+ }
2923
+ /**
2924
+ * 指定 toolCallId 的 tool 消息**内容**(判「在不在」与取内容同源,避免两处各自 find 各算一次)。
2925
+ *
2926
+ * 判据与 `isMessageStillUnmodified` 完全一致(布尔版委托本方法),差别只在返回原文:
2927
+ * 分支②拦截后要按请求区间回显原文,而「在不在」的判据本身已经把消息翻出来了——
2928
+ * 再翻一次既浪费,又容易让两处对「同一结果在不在」的理解分叉(SSOT:翻找+指纹比对只此一处)。
2929
+ *
2930
+ * @returns 仍在上下文且未被压缩链改动的原始消息内容;否则 undefined
2931
+ */
2932
+ stillUnmodifiedContent(toolCallId, fingerprint) {
2933
+ const msg = this.messages.find((m) => m.role === 'tool' && m.toolCallId === toolCallId);
2934
+ if (!msg)
2935
+ return undefined;
2936
+ return sha256Fingerprint(msg.content) === fingerprint ? msg.content : undefined;
2937
+ }
2938
+ /**
2939
+ * 缓存条目对应的工具结果是否**仍在当前上下文中** —— read_dedup 分支①拦截的前提(调用点见 handleToolCalls)。
2940
+ *
2941
+ * 实际判定委托 `isMessageStillUnmodified`(与台账分支②共用判据);本方法只补条目元信息缺失的
2942
+ * 保守放行(条目没带 toolCallId/指纹 → 无从核验 → 视为「不在」放行)。
2459
2943
  *
2460
2944
  * 注:超阈结果经入口关落盘后,上下文里留的是「路径 + 预览」,与记录指纹(包裹后的原文)不符
2461
2945
  * → 判为「不在」,重读放行。这是正确语义:原文确实已不在上下文中。
@@ -2463,26 +2947,42 @@ export class AgentLoop {
2463
2947
  isCachedResultStillInContext(entry) {
2464
2948
  if (entry.toolCallId === undefined || entry.fingerprint === undefined)
2465
2949
  return false;
2466
- const msg = this.messages.find((m) => m.role === 'tool' && m.toolCallId === entry.toolCallId);
2467
- if (!msg)
2468
- return false;
2469
- return sha256Fingerprint(msg.content) === entry.fingerprint;
2950
+ return this.isMessageStillUnmodified(entry.toolCallId, entry.fingerprint);
2470
2951
  }
2471
2952
  /** 整体替换执行上下文(截断落盘 / 恢复历史 / 装配重排:传入的数组已是完整上下文) */
2472
2953
  replaceContext(next) {
2473
2954
  this.messages = next;
2474
2955
  }
2475
2956
  // ─── Reflection 辅助方法 ────────────────────────────────
2476
- /** 处理工具执行结果:push tool 消息 + 统计失败数 */
2957
+ /**
2958
+ * 处理工具执行结果:push tool 消息 + 按结构化 `status` 结算计数。
2959
+ *
2960
+ * B4 判据切换:成败/拦截判据唯一真源 = `ToolOutcome.status`(三值契约),不再解析文本前缀。
2961
+ * 三路互斥穷尽:
2962
+ * · `blocked` → `toolBlockedCount`(主动挡下总数:loop 护栏 + 旁路拒绝 + 台账替身 + 执行层闸门)
2963
+ * 其中 `hasRealFailure=true`(当前唯一 = read_failed 护栏)→ 另计 `toolBlockedWithFailureCount`
2964
+ * · `failed` → `toolFailureCount`;`ok` → 无计数
2965
+ * **口径变更(Breaking,CHANGELOG 已登记)**:执行层闸门(宿主审批拒绝 / 只读拒绝 /
2966
+ * 幂等跳过)从 `toolFailureCount` 剔除、归入 `toolBlockedCount` ⇒ 失败数下降、挡下数上升,
2967
+ * 历史不可比(方案 §六决策二)。
2968
+ */
2477
2969
  _processToolResults(toolCalls, results) {
2478
2970
  for (let i = 0; i < toolCalls.length; i++) {
2479
2971
  const tc = toolCalls[i];
2480
- const result = results[i];
2972
+ const outcome = results[i];
2481
2973
  // 工具结果隔离:用 <tool_result> 包裹 + 指令前缀,防外部工具返回承载间接注入;
2482
- // ERR 前缀保留在包裹内,供 isRetryableToolError 识别(该正则不锚定行首)
2483
- const wrapped = wrapToolResult(tc.function.name, result);
2974
+ // 重试分类主判据是 outcome.errorCode(结构化契约,toolCallHelpers),
2975
+ // ERR 前缀仅兜底 errorCode 缺失的旧形态文本(该正则不锚定行首,包裹内仍可识别)
2976
+ const wrapped = wrapToolResult(tc.function.name, outcome.text);
2484
2977
  this.appendToolMessage(wrapped, tc.id);
2485
- if (result.startsWith('[ERR')) {
2978
+ // 计数读 status(禁另立文本判据——判据面与渲染面分离是 SCRIPT-2 的核心契约)
2979
+ if (outcome.status === 'blocked') {
2980
+ this.metrics.toolBlockedCount++;
2981
+ // blocked 背后藏真失败的子集计数(独立于失败总数,防「主动停手」与「真失败」混数)
2982
+ if (outcome.hasRealFailure === true)
2983
+ this.metrics.toolBlockedWithFailureCount++;
2984
+ }
2985
+ else if (outcome.status === 'failed') {
2486
2986
  this.metrics.toolFailureCount++;
2487
2987
  }
2488
2988
  }