@bolloon/bolloon-agent 0.4.33 → 0.5.1

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 (214) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +580 -40
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/model-policy.js +651 -0
  31. package/dist/agents/network-pulse.js +304 -18
  32. package/dist/agents/pi-sdk-tools.js +84 -33
  33. package/dist/agents/pi-sdk.js +157 -42
  34. package/dist/agents/run-store.js +71 -5
  35. package/dist/agents/skill-readiness.js +52 -10
  36. package/dist/agents/subagent-manager.js +209 -3
  37. package/dist/agents/task/task-runner.js +17 -4
  38. package/dist/agents/task-group.js +8 -51
  39. package/dist/agents/task-public-text.js +89 -0
  40. package/dist/agents/workflow-pivot-loop.js +19 -12
  41. package/dist/cli/clipboard.js +65 -0
  42. package/dist/cli/commands/index.js +1 -0
  43. package/dist/cli/commands/tasks.js +350 -2
  44. package/dist/cli/identity-command.js +177 -0
  45. package/dist/cli/ink-app.js +145 -17
  46. package/dist/cli/log-gate.js +386 -0
  47. package/dist/cli/mention-data.js +2 -0
  48. package/dist/cli/model-selector.js +538 -0
  49. package/dist/cli/reply-hygiene.js +54 -0
  50. package/dist/cli/setup-wizard.js +757 -69
  51. package/dist/cli/startup-notice.js +160 -0
  52. package/dist/cli/stores.js +22 -1
  53. package/dist/cli/theme.js +49 -0
  54. package/dist/cli/tui-select.js +970 -0
  55. package/dist/cli/update-commands.js +150 -24
  56. package/dist/cli-entry.js +137 -20
  57. package/dist/electron-build/agents/contacts/chain.js +26 -6
  58. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  59. package/dist/electron-build/agents/execution-supervisor.js +578 -38
  60. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  61. package/dist/electron-build/agents/external-events.js +34 -5
  62. package/dist/electron-build/agents/external-events.js.map +1 -1
  63. package/dist/electron-build/agents/goal-criteria.js +10 -3
  64. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  65. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  66. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  67. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  68. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  69. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  70. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  71. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  72. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  73. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  74. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  75. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  76. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  86. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  87. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  88. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  89. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  90. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  91. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  92. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  93. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  94. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  95. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  96. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  97. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  98. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  99. package/dist/electron-build/agents/goal-store.js +35 -1
  100. package/dist/electron-build/agents/goal-store.js.map +1 -1
  101. package/dist/electron-build/agents/model-policy.js +706 -0
  102. package/dist/electron-build/agents/model-policy.js.map +1 -0
  103. package/dist/electron-build/agents/network-pulse.js +307 -19
  104. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  105. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  106. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  107. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  108. package/dist/electron-build/agents/pi-sdk.js +156 -41
  109. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  110. package/dist/electron-build/agents/run-store.js +75 -6
  111. package/dist/electron-build/agents/run-store.js.map +1 -1
  112. package/dist/electron-build/agents/skill-readiness.js +52 -9
  113. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  114. package/dist/electron-build/agents/task/task-runner.js +16 -3
  115. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  116. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  117. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  118. package/dist/electron-build/llm/config-store.js +146 -6
  119. package/dist/electron-build/llm/config-store.js.map +1 -1
  120. package/dist/electron-build/llm/connection-probe.js +764 -0
  121. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  122. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  123. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  124. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  125. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  126. package/dist/electron-build/llm/model-catalog.js +778 -0
  127. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  128. package/dist/electron-build/llm/model-discovery.js +1202 -0
  129. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  130. package/dist/electron-build/llm/model-selection.js +1315 -0
  131. package/dist/electron-build/llm/model-selection.js.map +1 -0
  132. package/dist/electron-build/llm/pi-ai.js +70 -11
  133. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  134. package/dist/electron-build/llm/provider-catalog.js +969 -0
  135. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  136. package/dist/electron-build/llm/provider-registry.js +950 -0
  137. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  138. package/dist/electron-build/llm/tool-name.js +266 -0
  139. package/dist/electron-build/llm/tool-name.js.map +1 -0
  140. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  141. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  142. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  143. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  144. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  145. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  146. package/dist/electron-build/utils/dual-source.js +347 -0
  147. package/dist/electron-build/utils/dual-source.js.map +1 -0
  148. package/dist/electron-build/utils/update-manager.js +328 -104
  149. package/dist/electron-build/utils/update-manager.js.map +1 -1
  150. package/dist/electron-build/utils/update-state.js +9 -11
  151. package/dist/electron-build/utils/update-state.js.map +1 -1
  152. package/dist/electron-build/utils/version-identity.js +193 -0
  153. package/dist/electron-build/utils/version-identity.js.map +1 -0
  154. package/dist/electron-build/utils/version-info.js +57 -31
  155. package/dist/electron-build/utils/version-info.js.map +1 -1
  156. package/dist/index.js +385 -108
  157. package/dist/ios/bolloon-web.json +10 -0
  158. package/dist/ios/chain-explorer.js +617 -0
  159. package/dist/ios/explorer.html +163 -0
  160. package/dist/ios/index.html +96 -0
  161. package/dist/ios/mobile-agent.js +2 -2
  162. package/dist/ios/mobile-contacts.js +362 -0
  163. package/dist/ios/mobile-core.js +3102 -310
  164. package/dist/ios/mobile-privacy.js +185 -0
  165. package/dist/ios/mobile-tasks.js +339 -0
  166. package/dist/ios/mobile-update.js +1442 -0
  167. package/dist/ios/mobile.css +19 -0
  168. package/dist/ios/mobile.html +96 -0
  169. package/dist/ios/mobile.js +1013 -41
  170. package/dist/ios/routes-chain-index.js +108 -0
  171. package/dist/ios/routes-contacts.js +420 -0
  172. package/dist/ios/routes-llm-config.js +344 -52
  173. package/dist/ios/routes-mobile-tasks.js +353 -0
  174. package/dist/ios/routes-onchain-trade.js +99 -0
  175. package/dist/ios/routes-x402-info.js +1 -0
  176. package/dist/ios/server.js +921 -0
  177. package/dist/ios/sw.js +1 -1
  178. package/dist/ios/task-frame-server.js +211 -0
  179. package/dist/llm/config-store.js +146 -6
  180. package/dist/llm/connection-probe.js +744 -0
  181. package/dist/llm/custom-provider-store.js +224 -0
  182. package/dist/llm/data/provider-catalog-baked.js +19 -0
  183. package/dist/llm/model-catalog.js +717 -0
  184. package/dist/llm/model-discovery.js +1137 -0
  185. package/dist/llm/model-selection.js +1241 -0
  186. package/dist/llm/pi-ai.js +70 -11
  187. package/dist/llm/provider-catalog.js +898 -0
  188. package/dist/llm/provider-registry.js +917 -0
  189. package/dist/llm/tool-name.js +220 -0
  190. package/dist/orbitdb/cid-database.js +81 -16
  191. package/dist/orbitdb/ipfs-node.js +31 -2
  192. package/dist/setup/onboard.js +34 -8
  193. package/dist/utils/dual-source-facts.js +223 -0
  194. package/dist/utils/dual-source.js +297 -0
  195. package/dist/utils/update-cli.js +16 -21
  196. package/dist/utils/update-health.js +35 -5
  197. package/dist/utils/update-manager.js +325 -96
  198. package/dist/utils/update-state.js +13 -10
  199. package/dist/utils/version-identity.js +176 -0
  200. package/dist/utils/version-info.js +27 -22
  201. package/dist/web/bolloon-web.json +10 -0
  202. package/dist/web/mobile-contacts.js +44 -11
  203. package/dist/web/mobile-core.js +2901 -580
  204. package/dist/web/mobile-tasks.js +339 -0
  205. package/dist/web/mobile-update.js +1442 -0
  206. package/dist/web/mobile.css +19 -0
  207. package/dist/web/mobile.html +64 -0
  208. package/dist/web/mobile.js +838 -35
  209. package/dist/web/routes-llm-config.js +344 -52
  210. package/dist/web/routes-mobile-tasks.js +353 -0
  211. package/dist/web/server.js +163 -4
  212. package/dist/web/sw.js +1 -1
  213. package/package.json +3 -1
  214. package/scripts/build-web.ts +36 -0
@@ -29,7 +29,10 @@ import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtim
29
29
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
30
30
  import { initDocumentReceiver } from './p2p-document-tools.js';
31
31
  import { startRun, recordStep, finishRun, readRun, budgetVerdict, recordDegradation, recordHarnessEvent, recordRecovery, setRunStatus, prepareResume, markRunRunning, buildResumeInstruction, argsDigestOf, repeatedFailureCount, classifyError as classifyRunError } from './run-store.js';
32
- import { createGoal, readGoal, attachRun, findActiveGoal, completeGoalIfEligible, setUnresolved, addEvidence, evaluateGoalCompletion } from './goal-store.js';
32
+ import { createGoal, attachRun, findActiveGoal } from './goal-store.js';
33
+ import { captureRunModelConfig } from '../llm/model-selection.js';
34
+ // 2026-09-26: 工具名出网净化 (pi-ai.ts 唯一边界) + 回程派发还原 (原名 ↔ API 名)
35
+ import { resolveApiToolName, expandKnownToolNames } from '../llm/tool-name.js';
33
36
  import { PiAgentHarness } from './pi-harness.js';
34
37
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
35
38
  import { SkillRegistry } from '@bolloon/constraint-runtime';
@@ -645,6 +648,18 @@ export class PiAgentSession {
645
648
  return false;
646
649
  }
647
650
  }
651
+ /**
652
+ * 2026-09-26: 这个 Run 开始那一刻**真实生效**的模型配置快照, 由 startRun 落进 Run 记录。
653
+ * 拿不到 (配置读不出来) 就返回 undefined —— 宁可这个字段缺, 也不编一个假的进去。
654
+ */
655
+ async runModelSnapshot() {
656
+ try {
657
+ return await captureRunModelConfig();
658
+ }
659
+ catch {
660
+ return undefined;
661
+ }
662
+ }
648
663
  /** 2026-08-07: prompt 出口统一上报 token 用量到 ContextManager (fallback/pivot/react 全路径覆盖) —
649
664
  * 之前只有 runReActLoop 迭代内上报, chitchat/fallback/pivot 路径状态栏恒 0 */
650
665
  reportUsageToContextManager() {
@@ -749,6 +764,7 @@ export class PiAgentSession {
749
764
  goalId: boundGoalId,
750
765
  channelId: this.currentChannelId || undefined,
751
766
  agentId: this.currentAgentId || undefined,
767
+ modelConfig: await this.runModelSnapshot(),
752
768
  });
753
769
  this.lastRunId = rec.runId;
754
770
  await attachRun(boundGoalId, rec.runId).catch(() => null);
@@ -757,6 +773,30 @@ export class PiAgentSession {
757
773
  status: 'needs_human',
758
774
  error: 'LLM 不可用: provider 未初始化或无 apiKey (fallback 不是执行结果, 需要配置或人工处理)',
759
775
  });
776
+ // ★ M0: "工具/权限不可用"也是一条终止路径 → 同样进收尾漏斗 (规则 ④)。
777
+ const wiring = await import('./goal-flywheel-wiring.js');
778
+ const closed = await wiring.closeRunOnce({
779
+ goalId: boundGoalId,
780
+ runId: rec.runId,
781
+ caller: 'runner',
782
+ now: new Date().toISOString(),
783
+ finalReview: `LLM 不可用 → fallback 收尾 (不是执行结果): ${response}`,
784
+ });
785
+ if (!wiring.isRefusal(closed) && closed.outcome) {
786
+ const { reduceGoalState } = await import('./goal-state-reducer.js');
787
+ await reduceGoalState({
788
+ goalId: boundGoalId,
789
+ intent: 'closure_outcome',
790
+ now: new Date().toISOString(),
791
+ by: 'runner',
792
+ outcome: {
793
+ goalStatus: wiring.goalStatusFromDecision(closed.outcome.decision),
794
+ continuation: wiring.toGoalStoreContinuation(closed.outcome.continuation),
795
+ reason: closed.outcome.decision.reason,
796
+ },
797
+ runId: rec.runId,
798
+ });
799
+ }
760
800
  console.log(`[PiAgent] LLM 不可用 → 本次仍落 Run ${rec.runId} (needs_human), 不伪装成正常执行`);
761
801
  }
762
802
  catch (err) {
@@ -1057,7 +1097,7 @@ export class PiAgentSession {
1057
1097
  const backoffMs = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s
1058
1098
  console.log(`[PiAgent] loop 自动重试 ${attempt}/${MAX_LOOP_RETRIES}, 等待 ${backoffMs}ms`);
1059
1099
  if (onStream) {
1060
- onStream({ type: 'status', content: `↻ 自动重试 loop ${attempt}/${MAX_LOOP_RETRIES} (${(backoffMs / 1000).toFixed(0)}s 后)`, tool: 'system' });
1100
+ onStream({ type: 'status', internal: true, content: `↻ 自动重试 loop ${attempt}/${MAX_LOOP_RETRIES} (${(backoffMs / 1000).toFixed(0)}s 后)`, tool: 'system' });
1061
1101
  }
1062
1102
  // 中途 abort 也要响应
1063
1103
  await new Promise((resolve, reject) => {
@@ -1357,17 +1397,51 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1357
1397
  * 2026-09-16 (M2): 从 checkpoint 恢复一次运行 —— **不是**重发原 prompt。
1358
1398
  * 语义: prepareResume (校验状态 + 读 checkpoint + 记 recovery + 落 recovering) → 用恢复指令
1359
1399
  * 驱动同一个 runId 继续 (历史保留), 非幂等动作由重放守卫挡住。
1400
+ *
1401
+ * 2026-09-26: 恢复前**反查一次模型配置**。Run 记录里存的 `modelConfig` 快照此前只被写进
1402
+ * 快照和回显, 没人拿它核对现状 —— 于是"快照记的是 A, 现在盘上生效的是 B"这种情况会被
1403
+ * 静默继续下去 (长任务的历史执行链就说不清了)。现在: 漂了就 `console.warn` 说清哪几个字段漂了,
1404
+ * 并把结论放进返回值 (`modelDrift`) 让调用方能上报; 一致时也有一句明确结论。
1405
+ * 判断失败 (读不到 Run 等) 不阻塞恢复。
1360
1406
  */
1361
1407
  async resumeRun(runId) {
1408
+ let modelDrift;
1409
+ let modelApplied;
1410
+ try {
1411
+ const { detectRunConfigDrift } = await import('../llm/model-selection.js');
1412
+ modelDrift = (await detectRunConfigDrift(runId)) || undefined;
1413
+ if (modelDrift?.drifted) {
1414
+ console.warn('[pi-sdk] 恢复时模型配置已偏离 Run 快照:', modelDrift.message);
1415
+ }
1416
+ else if (modelDrift) {
1417
+ console.log('[pi-sdk] 恢复前核对:', modelDrift.message);
1418
+ }
1419
+ }
1420
+ catch { /* 核对本身失败不阻塞恢复 */ }
1362
1421
  const prep = await prepareResume(runId);
1363
1422
  if (!prep.ok || !prep.plan)
1364
- return { ok: false, reason: prep.reason };
1423
+ return { ok: false, reason: prep.reason, ...(modelDrift ? { modelDrift } : {}) };
1424
+ // 2026-09-26 (P6 × P7): 恢复一个在跑的 Run, 用的是**它自己那份快照**, 不是盘上现在的全局默认 ——
1425
+ // 否则这次恢复就是"带着漂移继续跑", 之后再存快照会把漂移固化成历史。
1426
+ // 只在"漂了"或"核对不了"时重装 (一致时运行时本来就是对的, 重装是白跑一趟)。
1427
+ // 装配只有一处实现: `applyRunModelConfigToRuntime` → `installRuntime` (这里不自己 initMinimax)。
1428
+ if (modelDrift && (modelDrift.drifted || !modelDrift.verified)) {
1429
+ try {
1430
+ const { applyRunModelConfigToRuntime } = await import('../llm/model-selection.js');
1431
+ modelApplied = await applyRunModelConfigToRuntime(modelDrift.snapshot);
1432
+ console.log(`[pi-sdk] 已按 Run 快照装配运行时: ${modelApplied.provider}/${modelApplied.model} (configHash ${String(modelApplied.configHash).slice(0, 12)})`);
1433
+ }
1434
+ catch (e) {
1435
+ // 装配失败不阻塞恢复 (如实说; 调用方可以从返回值里看出没装成)
1436
+ console.warn('[pi-sdk] 按 Run 快照装配运行时失败, 保持当前运行时:', String(e?.message || e).slice(0, 160));
1437
+ }
1438
+ }
1365
1439
  this.resumeRunId = runId;
1366
1440
  this.resumePlan = prep.plan;
1367
1441
  this.currentGoalId = prep.plan.goalId || this.currentGoalId;
1368
1442
  try {
1369
1443
  const reply = await this.prompt(buildResumeInstruction(prep.plan), {});
1370
- return { ok: true, reply };
1444
+ return { ok: true, reply, ...(modelDrift ? { modelDrift } : {}), ...(modelApplied ? { modelApplied } : {}) };
1371
1445
  }
1372
1446
  finally {
1373
1447
  this.resumeRunId = '';
@@ -1437,7 +1511,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1437
1511
  const loopActionLog = [];
1438
1512
  // 发送循环开始的事件
1439
1513
  if (onStream) {
1440
- onStream({ type: 'status', content: '🔄 开始 ReAct 循环...', tool: 'system' });
1514
+ onStream({ type: 'status', internal: true, content: '🔄 开始 ReAct 循环...', tool: 'system' });
1441
1515
  }
1442
1516
  // React Harness: 循环开始 (重置 turn 计数 + 触发 harness sessionStart)
1443
1517
  // 失败静默 (fail-open), 不阻塞主循环
@@ -1469,7 +1543,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1469
1543
  }
1470
1544
  const doneN = this.resumePlan?.completedSteps.length ?? 0;
1471
1545
  const guards = this.resumePlan?.replayGuards.length ?? 0;
1472
- onStream?.({ type: 'status', content: `♻️ 从 checkpoint 恢复运行 ${this.currentRunId} (已完成 ${doneN} 步, 非幂等重放守卫 ${guards} 条)`, tool: 'harness' });
1546
+ onStream?.({ type: 'status', internal: true, content: `♻️ 从 checkpoint 恢复运行 ${this.currentRunId} (已完成 ${doneN} 步, 非幂等重放守卫 ${guards} 条)`, tool: 'harness' });
1473
1547
  }
1474
1548
  else {
1475
1549
  // 2026-09-16 (M2): 目标绑定 —— 有 goalId 就在该 Goal 下执行; 没有就建 Goal 再建 Run。
@@ -1512,6 +1586,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1512
1586
  goalId: boundGoalId || undefined,
1513
1587
  channelId: this.currentChannelId || undefined,
1514
1588
  agentId: this.currentAgentId || undefined,
1589
+ modelConfig: await this.runModelSnapshot(),
1515
1590
  });
1516
1591
  this.currentRunId = rec.runId;
1517
1592
  this.lastRunId = rec.runId;
@@ -1520,7 +1595,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1520
1595
  // Run → Goal 反查链: runId → goalId → objective / success criteria
1521
1596
  await attachRun(this.currentGoalId, rec.runId).catch((err) => console.warn('[PiAgent] attachRun 失败:', err?.message));
1522
1597
  }
1523
- onStream?.({ type: 'status', content: `🧷 运行已登记 (run=${rec.runId}${this.currentGoalId ? `, goal=${this.currentGoalId}` : ''}, 预算 ${rec.budget.maxSteps} 步 / ${Math.round(rec.budget.deadlineMs / 60000)} 分钟)`, tool: 'harness' });
1598
+ onStream?.({ type: 'status', internal: true, content: `🧷 运行已登记 (run=${rec.runId}${this.currentGoalId ? `, goal=${this.currentGoalId}` : ''}, 预算 ${rec.budget.maxSteps} 步 / ${Math.round(rec.budget.deadlineMs / 60000)} 分钟)`, tool: 'harness' });
1524
1599
  }
1525
1600
  catch (err) {
1526
1601
  // 核心写失败: 不再 warn 后继续 —— 没有运行记录就不执行 (strict 模式默认如此)
@@ -1638,7 +1713,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1638
1713
  if (shouldCompactBeforeIteration(estimatedTokensBefore, compactThreshold)) {
1639
1714
  const tokensBeforeCompact = estimatedTokensBefore;
1640
1715
  console.log(`[PiAgent] loop 入口 token ${tokensBeforeCompact} > ${compactThreshold}, 触发自动压缩`);
1641
- onStream?.({ type: 'status', content: `🗜️ loop 自动压缩 (token ${tokensBeforeCompact} > ${compactThreshold})`, tool: 'compactor' });
1716
+ onStream?.({ type: 'status', internal: true, content: `🗜️ loop 自动压缩 (token ${tokensBeforeCompact} > ${compactThreshold})`, tool: 'compactor' });
1642
1717
  try {
1643
1718
  await this.maybeAutoCompact(onStream, signal);
1644
1719
  }
@@ -1660,7 +1735,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1660
1735
  // 调试日志:显示每次循环开始
1661
1736
  console.log(`[PiAgent] 循环 ${iteration}/${this.MAX_REACT_ITERATIONS} 开始`);
1662
1737
  if (onStream) {
1663
- onStream({ type: 'status', content: `🔄 循环 ${iteration}/${this.MAX_REACT_ITERATIONS}`, tool: 'loop' });
1738
+ onStream({ type: 'status', internal: true, content: `🔄 循环 ${iteration}/${this.MAX_REACT_ITERATIONS}`, tool: 'loop' });
1664
1739
  }
1665
1740
  const context = this.buildContext();
1666
1741
  // M3.5 (2026-06-17): 也构造 messages 数组版本, 让 LLM 看到结构化 tool 角色
@@ -1849,7 +1924,10 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1849
1924
  }
1850
1925
  // 路径 B: 文本解析 (parseAllToolCalls 收集全部)
1851
1926
  if (toolCalls.length === 0) {
1852
- const knownTools = new Set(Array.from(this.tools.keys()));
1927
+ // 2026-09-26: 已知名集合 = 注册表原名 + 它们的 API 名 (净化后的).
1928
+ // 出网时工具面给的是 API 名 (见 pi-ai.ts 的唯一净化边界), LLM 在文本里
1929
+ // 回吐的也可能是 API 名 —— 只用原名集合过滤会把这类调用整个丢掉.
1930
+ const knownTools = expandKnownToolNames(this.tools.keys());
1853
1931
  toolCalls = parseAllToolCalls(reply, { tools: knownTools });
1854
1932
  }
1855
1933
  // 回退路径 C: 原生 parseToolCall (单个)
@@ -1858,6 +1936,12 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1858
1936
  if (single)
1859
1937
  toolCalls.push(single);
1860
1938
  }
1939
+ // 2026-09-26: **回程派发的唯一还原点** —— LLM 回吐的是 API 名 (净化过的), 这里
1940
+ // 还原成注册表真名再交给 this.tools.get(); 原名 (LLM 照 system prompt 抄的) 原样穿透.
1941
+ for (const tc of toolCalls) {
1942
+ if (typeof tc.name === 'string' && tc.name)
1943
+ tc.name = resolveApiToolName(tc.name);
1944
+ }
1861
1945
  // 给每个 toolCall 分配稳定 id
1862
1946
  for (const tc of toolCalls) {
1863
1947
  if (!tc.id) {
@@ -1882,7 +1966,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1882
1966
  if (onStream) {
1883
1967
  onStream({ type: 'tool', content: `🔧 调用工具 (${ti + 1}/${toolCalls.length}): ${toolCall.name}`, tool: toolCall.name });
1884
1968
  if (toolCall.args && Object.keys(toolCall.args).length > 0) {
1885
- onStream({ type: 'status', content: `📋 参数: ${JSON.stringify(toolCall.args)}`, tool: toolCall.name });
1969
+ onStream({ type: 'status', internal: true, content: `📋 参数: ${JSON.stringify(toolCall.args)}`, tool: toolCall.name });
1886
1970
  }
1887
1971
  onStream({
1888
1972
  type: 'step_start',
@@ -2012,7 +2096,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2012
2096
  const toolDurationMs = Date.now() - toolStart;
2013
2097
  if (replaySkip) {
2014
2098
  console.log(`[PiAgent] 恢复重放守卫: 跳过已完成的非幂等工具 ${toolCall.name}`);
2015
- onStream?.({ type: 'status', content: `🛡️ 恢复保护: ${toolCall.name} 此前已成功执行, 本次不重复执行`, tool: toolCall.name });
2099
+ onStream?.({ type: 'status', internal: true, content: `🛡️ 恢复保护: ${toolCall.name} 此前已成功执行, 本次不重复执行`, tool: toolCall.name });
2016
2100
  }
2017
2101
  console.log(`[PiAgent] 工具 ${toolCall.name} 执行完成: success=${result.success} (${toolDurationMs}ms)`);
2018
2102
  // 2026-09-16 (M3): 失败接线 —— 分类 + recovery 留痕 + 熔断 / 外部等待
@@ -2085,7 +2169,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2085
2169
  }
2086
2170
  if (onStream) {
2087
2171
  if (result.success) {
2088
- onStream({ type: 'status', content: `✅ ${toolCall.name} 执行成功`, tool: toolCall.name });
2172
+ onStream({ type: 'status', internal: true, content: `✅ ${toolCall.name} 执行成功`, tool: toolCall.name });
2089
2173
  if (result.output) {
2090
2174
  onStream({ type: 'tool', content: `📤 结果: ${result.output.substring(0, 200)}${result.output.length > 200 ? '...' : ''}`, tool: toolCall.name });
2091
2175
  }
@@ -2115,7 +2199,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2115
2199
  refineAttempts++;
2116
2200
  }
2117
2201
  if (onStream) {
2118
- onStream({ type: 'status', content: `🔄 工具执行完成,继续循环...`, tool: 'loop' });
2202
+ onStream({ type: 'status', internal: true, content: `🔄 工具执行完成,继续循环...`, tool: 'loop' });
2119
2203
  }
2120
2204
  }
2121
2205
  else {
@@ -2201,7 +2285,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2201
2285
  content: `[dive-into stop condition] 你之前已成功执行了 ${unreported} 个工具, 但当前回复里没把它们的结果告诉用户. 请基于已有的工具结果 (在 history 里) 写一个完整总结回复给用户, 用 <final gen> 结尾. 不要再调工具.`
2202
2286
  });
2203
2287
  if (onStream) {
2204
- onStream({ type: 'status', content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES})`, tool: 'system' });
2288
+ onStream({ type: 'status', internal: true, content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES})`, tool: 'system' });
2205
2289
  }
2206
2290
  continue;
2207
2291
  }
@@ -2214,7 +2298,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2214
2298
  content: `[dive-into stop condition] 已多次提示汇报工具结果仍未完成 (超过 ${MAX_UNREPORTED_RETRIES} 次). 现在直接基于你已知的信息写最终回复给用户, 用 <final gen> 结尾, 不要再调任何工具.`
2215
2299
  });
2216
2300
  if (onStream) {
2217
- onStream({ type: 'status', content: `🔄 工具结果汇报超限, 强制收尾`, tool: 'system' });
2301
+ onStream({ type: 'status', internal: true, content: `🔄 工具结果汇报超限, 强制收尾`, tool: 'system' });
2218
2302
  }
2219
2303
  }
2220
2304
  lastQualityScore = this.estimateResponseQuality(reply);
@@ -2244,7 +2328,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2244
2328
  console.log(`[PiAgent] review ${loopReviewCount}/${DEFAULT_MAX_REVIEWS}: LLM 想 final 但先对齐需求深挖一次`);
2245
2329
  this.messageHistory.push({ role: 'system', content: reviewDecision.hint });
2246
2330
  if (onStream) {
2247
- onStream({ type: 'status', content: `🔄 目标对齐 review ${loopReviewCount}/${DEFAULT_MAX_REVIEWS}: 深挖续跑`, tool: 'system' });
2331
+ onStream({ type: 'status', internal: true, content: `🔄 目标对齐 review ${loopReviewCount}/${DEFAULT_MAX_REVIEWS}: 深挖续跑`, tool: 'system' });
2248
2332
  }
2249
2333
  continue; // 让 LLM 看到 hint, 深挖或确认完成后再次 final
2250
2334
  }
@@ -2268,14 +2352,14 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2268
2352
  if (needsMoreWork && iteration < this.MAX_REACT_ITERATIONS) {
2269
2353
  console.log(`[PiAgent] 继续循环处理 (${iteration}/${this.MAX_REACT_ITERATIONS}): needsMoreWork=${needsMoreWork}, hasError=${hasError}, containsToolCallIntent=${containsToolCallIntent}`);
2270
2354
  if (onStream) {
2271
- onStream({ type: 'status', content: `🔄 继续处理,循环 ${iteration}...`, tool: 'loop' });
2355
+ onStream({ type: 'status', internal: true, content: `🔄 继续处理,循环 ${iteration}...`, tool: 'loop' });
2272
2356
  }
2273
2357
  continue;
2274
2358
  }
2275
2359
  // 否则把这个当作可能的最终回答
2276
2360
  finalResponse = reply;
2277
2361
  if (onStream) {
2278
- onStream({ type: 'status', content: `📝 提取最终回答,长度 ${reply.length}`, tool: 'system' });
2362
+ onStream({ type: 'status', internal: true, content: `📝 提取最终回答,长度 ${reply.length}`, tool: 'system' });
2279
2363
  }
2280
2364
  break;
2281
2365
  }
@@ -2293,7 +2377,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2293
2377
  }
2294
2378
  // 通知前端循环完成
2295
2379
  if (onStream) {
2296
- onStream({ type: 'status', content: `✅ 处理完成,共 ${iteration - 1} 次循环`, tool: 'system' });
2380
+ onStream({ type: 'status', internal: true, content: `✅ 处理完成,共 ${iteration - 1} 次循环`, tool: 'system' });
2297
2381
  }
2298
2382
  this.messageHistory.push({ role: 'assistant', content: finalResponse });
2299
2383
  // React Harness: 循环结束
@@ -2341,30 +2425,61 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2341
2425
  await recordDegradation({ kind: 'core', op: 'pi-sdk.finishRun', runId: this.currentRunId, message }).catch(() => { });
2342
2426
  onStream?.({ type: 'error', content: `⚠️ ${message}`, tool: 'harness' });
2343
2427
  }
2344
- // 2026-09-16 (M4): Goal 侧完成门 —— Run 结束 ≠ Goal 完成; 只有判据全满足 + 有证据 + 无未解决项才算
2428
+ // ★ 2026-09-25 (M0 接线冻结, 规则 ③ + ④): Run 结束**必过收尾漏斗**。
2429
+ //
2430
+ // 旧写法在这里另起了一套 Goal 侧收尾 (读证据 → `evaluateGoalCompletion` →
2431
+ // `completeGoalIfEligible` / `setUnresolved`): 它**不收尾** —— 不写 Memory、不生成 Skill 候选、
2432
+ // 不写权威 continuation、不留决策记录。于是同一条 Run 在 Supervisor 那里还会再收一次 (或反过来
2433
+ // 这条路径根本不收), 全仓就有了两套"这一轮跑完意味着什么"。那一套已删除。
2434
+ //
2435
+ // 现在: `closeRunOnce` (幂等) → 9 步收尾 → 产物落盘; 再由 Goal reducer 把"下一步是什么"
2436
+ // 落进 Goal (没有 Supervisor 宿主时 Runner 也要留下 continuation, 否则下一次还是从零开始)。
2345
2437
  if (this.currentGoalId) {
2346
2438
  try {
2347
- const full = await readRun(this.currentRunId).catch(() => null);
2348
- const ev = (full?.evidence || []).slice(-10);
2349
- if (ev.length)
2350
- await addEvidence(this.currentGoalId, ev);
2351
- const goal = await readGoal(this.currentGoalId);
2352
- if (goal) {
2353
- const verdict = evaluateGoalCompletion(goal);
2354
- if (verdict.complete) {
2355
- const done = await completeGoalIfEligible(this.currentGoalId);
2356
- onStream?.({ type: 'status', content: `🎯 目标已达成: ${done.reason} (goal=${this.currentGoalId})`, tool: 'harness' });
2357
- }
2358
- else {
2359
- // 不静默: 未达成的目标留在 active, 未解决项写清楚 (下次 prompt 会继续这个 Goal)
2360
- const items = verdict.missing.length ? verdict.missing : [`run=${this.currentRunId} 结束但目标未达成: ${verdict.reason}`];
2361
- await setUnresolved(this.currentGoalId, items);
2362
- onStream?.({ type: 'status', content: `🎯 目标仍在进行 (未判完成): ${verdict.reason}`, tool: 'harness' });
2363
- }
2439
+ const wiring = await import('./goal-flywheel-wiring.js');
2440
+ const { reduceGoalState } = await import('./goal-state-reducer.js');
2441
+ const nowIso = new Date().toISOString();
2442
+ const closed = await wiring.closeRunOnce({
2443
+ goalId: this.currentGoalId,
2444
+ runId: this.currentRunId,
2445
+ caller: 'runner',
2446
+ now: nowIso,
2447
+ finalReview: finalResponse ? String(finalResponse).slice(0, 2000) : '',
2448
+ });
2449
+ if (wiring.isRefusal(closed)) {
2450
+ onStream?.({ type: 'status', content: `⚠️ 收尾被拒 (不当作收过): ${closed.reason}`, tool: 'harness' });
2451
+ }
2452
+ else if (closed.outcome) {
2453
+ const view = closed.outcome;
2454
+ const applied = await reduceGoalState({
2455
+ goalId: this.currentGoalId,
2456
+ intent: 'closure_outcome',
2457
+ now: nowIso,
2458
+ by: 'runner',
2459
+ outcome: {
2460
+ goalStatus: wiring.goalStatusFromDecision(view.decision),
2461
+ continuation: wiring.toGoalStoreContinuation(view.continuation),
2462
+ reason: view.decision.reason,
2463
+ },
2464
+ runId: view.runId,
2465
+ });
2466
+ const line = view.decision.decision === 'complete'
2467
+ ? `🎯 目标已达成 (仍要过完成门): ${view.decision.reason}`
2468
+ : `🎯 目标仍在进行 (未判完成): ${view.decision.reason}`;
2469
+ onStream?.({
2470
+ type: 'status', internal: true,
2471
+ content: `${line}${applied.gateRejected ? ` [完成门拒绝: ${applied.gateRejected}]` : ''} `
2472
+ + `(收尾 ${view.steps} 步 · memory ${view.memories} · skill 候选 ${view.candidates} · goal=${this.currentGoalId})`,
2473
+ tool: 'harness',
2474
+ });
2475
+ }
2476
+ else {
2477
+ // 幂等: 别处 (或上一次) 已经收过尾 —— 事实读不回来就如实说, 不重收也不假装
2478
+ onStream?.({ type: 'status', content: `🎯 ${closed.reason}`, tool: 'harness' });
2364
2479
  }
2365
2480
  }
2366
2481
  catch (err) {
2367
- await recordDegradation({ kind: 'observational', op: 'pi-sdk.goalCompletion', runId: this.currentRunId, message: String(err?.message || err).slice(0, 160) }).catch(() => { });
2482
+ await recordDegradation({ kind: 'observational', op: 'pi-sdk.runClosure', runId: this.currentRunId, message: String(err?.message || err).slice(0, 160) }).catch(() => { });
2368
2483
  }
2369
2484
  }
2370
2485
  this.currentRunId = '';
@@ -2542,7 +2657,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2542
2657
  const estimated = this.estimateHistoryTokens();
2543
2658
  if (estimated > this.maxContextTokens() * 0.8) {
2544
2659
  console.warn(`[PiAgent] reactive compaction pre-check (${estimated} tokens > 80% threshold)`);
2545
- onStream?.({ type: 'status', content: '⚠️ reactive compaction 预检触发', tool: 'recovery' });
2660
+ onStream?.({ type: 'status', internal: true, content: '⚠️ reactive compaction 预检触发', tool: 'recovery' });
2546
2661
  try {
2547
2662
  const compacted = this.compressHistorySync(this.messageHistory);
2548
2663
  this.messageHistory = compacted;
@@ -2696,7 +2811,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2696
2811
  async maybeAutoCompact(onStream, signal) {
2697
2812
  if (this.messageHistory.length < 10)
2698
2813
  return; // 历史太短, 不值得压
2699
- onStream?.({ type: 'status', content: '🗜️ 评估是否需要压缩上下文...', tool: 'compactor' });
2814
+ onStream?.({ type: 'status', internal: true, content: '🗜️ 评估是否需要压缩上下文...', tool: 'compactor' });
2700
2815
  // 注入 LLM (用 getMinimax().chat, 与 judgment 注入门 / ReAct 循环同一来源)
2701
2816
  // 给 Context Collapse (虚拟投影) 和 Auto-Compact (摘要) 共用
2702
2817
  const llm = getMinimax();
@@ -2724,7 +2839,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2724
2839
  const savedTokens = Math.max(0, beforeTokens - afterTokens);
2725
2840
  cm.markCompressStart(beforeTokens);
2726
2841
  onStream?.({
2727
- type: 'status',
2842
+ type: 'status', internal: true,
2728
2843
  content: `🗜️ 上下文压缩: ${stagesApplied || 'no-op'} | 节省 ${saved} 条 / ${savedTokens.toLocaleString()} tokens (剩余 ${result.history.length}, collapse=${isContextCollapseEnabled() ? 'on' : 'off'})`,
2729
2844
  tool: 'compactor',
2730
2845
  });
@@ -24,14 +24,14 @@ export const RUN_TRANSITIONS = {
24
24
  queued: ['running', 'aborted', 'interrupted'],
25
25
  running: ['recovering', 'paused', 'awaiting_external', 'done', 'failed', 'aborted', 'interrupted', 'stalled', 'needs_human'],
26
26
  recovering: ['running', 'failed', 'aborted', 'needs_human', 'interrupted', 'stalled'],
27
- paused: ['running', 'aborted', 'interrupted'],
28
- awaiting_external: ['running', 'failed', 'aborted', 'interrupted', 'stalled'],
27
+ paused: ['running', 'aborted', 'interrupted', 'recovering'],
28
+ awaiting_external: ['running', 'failed', 'aborted', 'interrupted', 'stalled', 'recovering'],
29
29
  done: [],
30
30
  failed: [],
31
31
  aborted: [],
32
32
  interrupted: ['recovering', 'aborted'], // 允许"从 checkpoint 恢复"
33
33
  stalled: ['recovering', 'aborted', 'needs_human'],
34
- needs_human: ['running', 'aborted'],
34
+ needs_human: ['running', 'aborted', 'recovering'],
35
35
  };
36
36
  export function canTransition(from, to) {
37
37
  if (from === to)
@@ -39,6 +39,8 @@ export function canTransition(from, to) {
39
39
  return (RUN_TRANSITIONS[from] || []).includes(to);
40
40
  }
41
41
  export const MAX_HARNESS_EVENTS = 50;
42
+ /** 模型切换账本只留最近 N 条 (审计账本, 不是全量日志) */
43
+ export const MAX_MODEL_SWITCHES = 20;
42
44
  const DEFAULT_HARNESS = {
43
45
  maxSteps: Number(process.env.BOLLOON_RUN_MAX_STEPS || 60),
44
46
  deadlineMs: Number(process.env.BOLLOON_RUN_DEADLINE_MS || 30 * 60_000),
@@ -343,6 +345,7 @@ export async function startRun(opts) {
343
345
  steps: [],
344
346
  budget: { maxSteps: cfg.maxSteps, deadlineMs: cfg.deadlineMs },
345
347
  recovery: [],
348
+ modelConfig: opts.modelConfig,
346
349
  };
347
350
  await coreWrite('startRun', rec.runId, () => writeRun(rec));
348
351
  return rec;
@@ -453,6 +456,56 @@ export async function recordHarnessEvent(runId, evt) {
453
456
  await recordDegradation({ kind: 'observational', op: 'recordHarnessEvent', runId, message: `${evt.event}/${evt.kind} 写入失败: ${String(err?.message || err).slice(0, 150)}` });
454
457
  }
455
458
  }
459
+ /**
460
+ * 2026-09-26 (P7): 记一次模型切换决定 (Run 事件)。
461
+ *
462
+ * 与 `recordHarnessEvent` 同一纪律: 决策**已经**做出, 记账失败绝不反过来改决策 ——
463
+ * 所以这里只记降级 + 返回 `{ok:false}` 让调用方能如实上报, 不抛。
464
+ * 写两处事实: ① `modelSwitches` 账本 (机器可查的切换链); ② `harness` 事件 (人工审计的那本账)。
465
+ * 本次写入**不改** `modelConfig` —— Run 快照一旦定稿就不再改写。
466
+ */
467
+ export async function recordModelSwitch(runId, evt) {
468
+ const full = {
469
+ ts: evt.ts || new Date().toISOString(),
470
+ ...evt,
471
+ runId: evt.runId || runId,
472
+ };
473
+ try {
474
+ const rec = await withRunLock(runId, async () => {
475
+ const cur = await readRun(runId);
476
+ if (!cur)
477
+ return null;
478
+ cur.modelSwitches = cur.modelSwitches || [];
479
+ cur.modelSwitches.push(full);
480
+ if (cur.modelSwitches.length > MAX_MODEL_SWITCHES) {
481
+ cur.modelSwitches = cur.modelSwitches.slice(-MAX_MODEL_SWITCHES);
482
+ }
483
+ // 人工审计账本: 同一件事在 harness 事件里也留一条 (kind 按结果映射, 不美化)
484
+ cur.harness = cur.harness || [];
485
+ cur.harness.push({
486
+ ts: full.ts,
487
+ event: 'model.switch',
488
+ kind: full.outcome === 'switched' ? 'note' : 'deny',
489
+ reason: `${full.outcome} mode=${full.mode} source=${full.source} → ${full.to.provider}/${full.to.model}#${full.to.configHash} · 守卫 ${full.guardsCarried} 条 · ${full.reason}`.slice(0, 400),
490
+ runId,
491
+ goalId: full.goalId,
492
+ });
493
+ if (cur.harness.length > MAX_HARNESS_EVENTS)
494
+ cur.harness = cur.harness.slice(-MAX_HARNESS_EVENTS);
495
+ cur.updatedAt = new Date().toISOString();
496
+ await writeRun(cur);
497
+ return cur;
498
+ });
499
+ if (!rec)
500
+ return { ok: false, reason: `run 不存在: ${runId}` };
501
+ return { ok: true, record: rec };
502
+ }
503
+ catch (err) {
504
+ const reason = `模型切换事件写入失败: ${String(err?.message || err).slice(0, 160)}`;
505
+ await recordDegradation({ kind: 'observational', op: 'recordModelSwitch', runId, message: reason });
506
+ return { ok: false, reason };
507
+ }
508
+ }
456
509
  /** 防止"同一个工具 + 同一组参数"无限重试: 返回该指纹最近的连续失败次数 */
457
510
  export function repeatedFailureCount(rec, tool, argsDigest) {
458
511
  let n = 0;
@@ -572,6 +625,13 @@ function pidAlive(pid) {
572
625
  return e?.code === 'EPERM';
573
626
  }
574
627
  }
628
+ let onRunTerminal = null;
629
+ export function setOnRunTerminal(hook) {
630
+ onRunTerminal = hook;
631
+ }
632
+ export function getOnRunTerminal() {
633
+ return onRunTerminal;
634
+ }
575
635
  /**
576
636
  * 孤儿对账: 启动时把 pid 已死的 running 记录改判 interrupted。
577
637
  * 不做这一层的话, 重载后 UI/CLI 会显示"还在跑"的幽灵运行 —— 那是最典型的假状态。
@@ -588,11 +648,14 @@ export async function reconcileOrphans() {
588
648
  continue;
589
649
  }
590
650
  try {
591
- await finishRun(r.runId, {
651
+ const closed = await finishRun(r.runId, {
592
652
  status: 'interrupted',
593
653
  error: `进程 ${r.pid} 已不在 (刷新/重载/崩溃); 运行到此中断`,
594
654
  });
595
655
  interrupted.push(r.runId);
656
+ // 终止路径进唯一责任链 (拿不到 Goal 就不收, 不假装收过)
657
+ if (onRunTerminal)
658
+ await Promise.resolve(onRunTerminal(closed || r)).catch(() => null);
596
659
  }
597
660
  catch (err) {
598
661
  failed.push(r.runId);
@@ -615,11 +678,14 @@ export async function superviseRuns(now = Date.now()) {
615
678
  continue; // 交给 reconcileOrphans
616
679
  if (now - Date.parse(r.updatedAt) > cfg.staleMs) {
617
680
  try {
618
- await finishRun(r.runId, {
681
+ const closed = await finishRun(r.runId, {
619
682
  status: 'stalled',
620
683
  error: `超过 ${Math.round(cfg.staleMs / 1000)}s 没有新进展 (可能是工具卡住或模型长时间无响应)`,
621
684
  });
622
685
  stalled.push(r.runId);
686
+ // 同上: 失速也是一条终止路径
687
+ if (onRunTerminal)
688
+ await Promise.resolve(onRunTerminal(closed || r)).catch(() => null);
623
689
  }
624
690
  catch (err) {
625
691
  failed.push(r.runId);
@@ -12,7 +12,9 @@
12
12
  */
13
13
  import * as os from 'os';
14
14
  import { SkillsManager } from './skills-manager.js';
15
- import { readGoal, setContinuation, updateGoal, addEvidence, } from './goal-store.js';
15
+ import { readGoal, updateGoal, addEvidence, } from './goal-store.js';
16
+ // 2026-09-25 (M0 接线冻结, 规则 ②): Goal 状态与 continuation 变更只有一个漏斗
17
+ import { reduceGoalState } from './goal-state-reducer.js';
16
18
  import { recordDegradation } from './run-store.js';
17
19
  export function parseSkillSpecs(list = []) {
18
20
  const required = [];
@@ -109,15 +111,45 @@ export async function ensureGoalSkillsReady(goal, opts = {}) {
109
111
  /** 门禁不过 → 写清事实并把 Goal 交给人 (不启动 Run, 不伪造失败) */
110
112
  export async function blockGoalOnSkills(goalId, res) {
111
113
  const reason = res.reason || '技能未就绪';
112
- await setContinuation(goalId, {
113
- wakeReason: 'needs_human', autoContinue: false, needsExternal: undefined,
114
- skillReadiness: { ok: false, at: new Date().toISOString(), reason, missing: res.missing, drift: res.drift, degradations: res.degradations },
115
- });
116
- await updateGoal(goalId, { status: 'needs_human' }).catch(() => { });
117
- await addEvidence(goalId, [`技能门禁拦截: ${reason}`]).catch(() => { });
114
+ // 规则 ②: 状态与 continuation 都经 Goal reducer 写 (单一漏斗)
115
+ await reduceGoalState({
116
+ goalId,
117
+ intent: 'skill_gate_block',
118
+ now: new Date().toISOString(),
119
+ by: 'skill-readiness',
120
+ reason,
121
+ skillReadiness: {
122
+ ok: false, at: new Date().toISOString(), reason,
123
+ missing: res.missing, drift: res.drift, degradations: res.degradations,
124
+ },
125
+ }).catch(() => null);
118
126
  for (const d of res.degradations)
119
127
  await recordDegradation({ kind: 'observational', op: 'skill-readiness', message: d }).catch(() => { });
120
128
  }
129
+ /**
130
+ * 只**记事实**、不改状态: 把本地技能就绪检查的结论写进 `continuation.skillReadiness`。
131
+ *
132
+ * ★ 2026-09-25 (P5 验收修复): 飞轮裁决 = `delegate` 时本地技能门禁**不适用** (能力由子 Agent 凭合同
133
+ * 交付), 所以不能调 `blockGoalOnSkills` (那会把 Goal 打成 needs_human)。但"本地缺什么"必须仍然
134
+ * 可查 —— 原来只有 `blockGoalOnSkills` 写这个字段, 门禁一放行就没有任何记录了。
135
+ */
136
+ export async function recordSkillReadiness(goalId, res) {
137
+ // 规则 ②: continuation 也经 Goal reducer 写 (只记事实, 不改状态)
138
+ await reduceGoalState({
139
+ goalId,
140
+ intent: 'skill_gate_record',
141
+ now: new Date().toISOString(),
142
+ by: 'skill-readiness',
143
+ skillReadiness: {
144
+ ok: res.ok,
145
+ at: new Date().toISOString(),
146
+ reason: res.reason,
147
+ missing: res.missing,
148
+ drift: res.drift,
149
+ degradations: res.degradations,
150
+ },
151
+ }).catch(() => null);
152
+ }
121
153
  /** 人工批准技能升级: 重新冻结快照 (显式动作, 不隐式切换) */
122
154
  export async function approveSkillUpgrade(goalId, opts = {}) {
123
155
  const goal = await readGoal(goalId);
@@ -126,8 +158,18 @@ export async function approveSkillUpgrade(goalId, opts = {}) {
126
158
  const frozen = await freezeGoalSkills(goal, { ...opts, force: true });
127
159
  if (!frozen.ok)
128
160
  return { ok: false, reason: frozen.reason };
129
- await setContinuation(goalId, { skillReadiness: { ok: true, at: new Date().toISOString(), reason: '人工批准技能升级' }, wakeReason: 'active', autoContinue: true });
130
- await updateGoal(goalId, { status: 'active' }).catch(() => { });
131
- await addEvidence(goalId, [`技能升级已被人工批准: ${(frozen.snapshot || []).map((s) => `${s.name}@${s.version}`).join(', ')}`]).catch(() => { });
161
+ // ★ 规则 ⑥ (Skill 不许绕过 验证+快照+可回退 通道): 批准是**人**的动作, 但必须留下通道证据 ——
162
+ // 快照 (skillSnapshot, 上面 freezeGoalSkills 已写) + 版本 + 变更原因 + 可回退 (fromVersion 仍在
163
+ // Goal 上)。这里把"谁批的、为什么"连同新版本一起记进 Goal (走唯一漏斗, 规则 ②)。
164
+ const versions = (frozen.snapshot || []).map((s) => `${s.name}@${s.version}`);
165
+ const fromVersions = (goal.skillSnapshot || []).map((s) => `${s.name}@${s.version}`);
166
+ await reduceGoalState({
167
+ goalId,
168
+ intent: 'skill_upgrade_approved',
169
+ now: new Date().toISOString(),
170
+ by: 'human',
171
+ skillReadiness: { ok: true, at: new Date().toISOString(), reason: '人工批准技能升级' },
172
+ reason: `技能升级已被人工批准 (by=human; from=[${fromVersions.join(', ')}] → to=[${versions.join(', ')}]; 可回退: Goal.skillSnapshot 保留新版本快照, 内容哈希在 skills-manager 侧可核验)`,
173
+ }).catch(() => null);
132
174
  return { ok: true, snapshot: frozen.snapshot };
133
175
  }