@bolloon/bolloon-agent 0.5.5 → 0.6.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 (158) hide show
  1. package/dist/agents/execution-entry.js +10 -0
  2. package/dist/agents/goal-flywheel/wiring/seams.js +5 -1
  3. package/dist/agents/parse-tool-call.js +6 -1
  4. package/dist/agents/pi-sdk-session-factory.js +36 -3
  5. package/dist/agents/pi-sdk-tools.js +18 -8
  6. package/dist/agents/pi-sdk.js +749 -1390
  7. package/dist/agents/run-context.js +20 -0
  8. package/dist/agents/runner-resolver.js +3 -1
  9. package/dist/agents/tool-capability/index.js +51 -0
  10. package/dist/agents/workflow-pivot-loop.js +31 -10
  11. package/dist/agents/write-verify.js +60 -0
  12. package/dist/bollharness-integration/integration.js +8 -0
  13. package/dist/bollharness-integration/skill-adapter.js +32 -3
  14. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +5 -1
  15. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -1
  16. package/dist/electron-build/agents/parse-tool-call.js +6 -1
  17. package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
  18. package/dist/electron-build/agents/pi-sdk-session-factory.js +36 -3
  19. package/dist/electron-build/agents/pi-sdk-session-factory.js.map +1 -1
  20. package/dist/electron-build/agents/pi-sdk-tools.js +21 -11
  21. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  22. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  23. package/dist/electron-build/agents/pi-sdk.js +750 -1389
  24. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  25. package/dist/electron-build/agents/run-context.js +24 -0
  26. package/dist/electron-build/agents/run-context.js.map +1 -0
  27. package/dist/electron-build/agents/runner-resolver.js +3 -1
  28. package/dist/electron-build/agents/runner-resolver.js.map +1 -1
  29. package/dist/electron-build/agents/tool-capability/index.js +90 -0
  30. package/dist/electron-build/agents/tool-capability/index.js.map +1 -0
  31. package/dist/electron-build/agents/workflow-pivot-loop.js +31 -10
  32. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  33. package/dist/electron-build/agents/write-verify.js +99 -0
  34. package/dist/electron-build/agents/write-verify.js.map +1 -0
  35. package/dist/electron-build/bollharness-integration/integration.js +8 -0
  36. package/dist/electron-build/bollharness-integration/integration.js.map +1 -1
  37. package/dist/electron-build/bollharness-integration/skill-adapter.js +32 -3
  38. package/dist/electron-build/bollharness-integration/skill-adapter.js.map +1 -1
  39. package/dist/electron-build/kernel/channel-actor.js +339 -0
  40. package/dist/electron-build/kernel/channel-actor.js.map +1 -0
  41. package/dist/electron-build/kernel/control.js +102 -0
  42. package/dist/electron-build/kernel/control.js.map +1 -0
  43. package/dist/electron-build/kernel/model-runtime.js +508 -0
  44. package/dist/electron-build/kernel/model-runtime.js.map +1 -0
  45. package/dist/electron-build/kernel/run-lifecycle.js +146 -0
  46. package/dist/electron-build/kernel/run-lifecycle.js.map +1 -0
  47. package/dist/electron-build/kernel/transport.js +119 -0
  48. package/dist/electron-build/kernel/transport.js.map +1 -0
  49. package/dist/electron-build/llm/native-adapter.js +122 -0
  50. package/dist/electron-build/llm/native-adapter.js.map +1 -0
  51. package/dist/index.js +43 -19
  52. package/dist/ios/bolloon-web.json +3 -3
  53. package/dist/ios/client.js +13 -7
  54. package/dist/ios/mobile-core.js +7762 -6289
  55. package/dist/ios/mobile.js +64 -0
  56. package/dist/ios/routes-mobile-tasks.js +2 -1
  57. package/dist/ios/routes-tasks.js +16 -1
  58. package/dist/ios/routes-x402-info.js +28 -1
  59. package/dist/ios/server-storage.js +26 -5
  60. package/dist/ios/server.js +840 -862
  61. package/dist/ios/sw.js +1 -1
  62. package/dist/ios/ui/message-renderer.js +473 -29
  63. package/dist/ios/ui/step-timeline.js +13 -12
  64. package/dist/kernel/channel-actor.js +321 -0
  65. package/dist/kernel/control.js +95 -0
  66. package/dist/kernel/gate-scan.js +1350 -0
  67. package/dist/kernel/model-runtime.js +494 -0
  68. package/dist/kernel/plan-channel-actor.js +305 -0
  69. package/dist/kernel/plan-communication.js +163 -0
  70. package/dist/kernel/plan-constraint.js +152 -0
  71. package/dist/kernel/plan-deletion.js +32 -0
  72. package/dist/kernel/plan-harness.js +78 -0
  73. package/dist/kernel/plan-modelruntime.js +60 -0
  74. package/dist/kernel/plan-runcontext.js +163 -0
  75. package/dist/kernel/plan.js +149 -0
  76. package/dist/kernel/roster.js +244 -0
  77. package/dist/kernel/run-lifecycle.js +138 -0
  78. package/dist/kernel/transport.js +111 -0
  79. package/dist/llm/native-adapter.js +117 -0
  80. package/dist/web/bolloon-web.json +3 -3
  81. package/dist/web/client.js +12 -7
  82. package/dist/web/routes-tasks.js +16 -1
  83. package/dist/web/server.js +838 -862
  84. package/dist/web/sw.js +1 -1
  85. package/dist/web/ui/message-renderer.js +473 -29
  86. package/dist/web/ui/step-timeline.js +13 -12
  87. package/package.json +1 -1
  88. package/scripts/build-web.ts +74 -1
  89. package/dist/constraint-runtime/_archive_helper.d.ts +0 -1
  90. package/dist/constraint-runtime/_archive_helper.js +0 -9
  91. package/dist/constraint-runtime/assistant/index.d.ts +0 -11
  92. package/dist/constraint-runtime/assistant/index.js +0 -12
  93. package/dist/constraint-runtime/bootstrap/index.d.ts +0 -11
  94. package/dist/constraint-runtime/bootstrap/index.js +0 -12
  95. package/dist/constraint-runtime/bridge/index.d.ts +0 -11
  96. package/dist/constraint-runtime/bridge/index.js +0 -12
  97. package/dist/constraint-runtime/buddy/index.d.ts +0 -11
  98. package/dist/constraint-runtime/buddy/index.js +0 -12
  99. package/dist/constraint-runtime/cli/index.d.ts +0 -11
  100. package/dist/constraint-runtime/cli/index.js +0 -12
  101. package/dist/constraint-runtime/components/index.d.ts +0 -11
  102. package/dist/constraint-runtime/components/index.js +0 -12
  103. package/dist/constraint-runtime/constants/index.d.ts +0 -11
  104. package/dist/constraint-runtime/constants/index.js +0 -12
  105. package/dist/constraint-runtime/coordinator/index.d.ts +0 -11
  106. package/dist/constraint-runtime/coordinator/index.js +0 -12
  107. package/dist/constraint-runtime/cost_hook.d.ts +0 -2
  108. package/dist/constraint-runtime/cost_hook.js +0 -4
  109. package/dist/constraint-runtime/entrypoints/index.d.ts +0 -11
  110. package/dist/constraint-runtime/entrypoints/index.js +0 -12
  111. package/dist/constraint-runtime/execution_registry.d.ts +0 -20
  112. package/dist/constraint-runtime/execution_registry.js +0 -44
  113. package/dist/constraint-runtime/hooks/index.d.ts +0 -11
  114. package/dist/constraint-runtime/hooks/index.js +0 -12
  115. package/dist/constraint-runtime/ink.d.ts +0 -1
  116. package/dist/constraint-runtime/ink.js +0 -4
  117. package/dist/constraint-runtime/keybindings/index.d.ts +0 -11
  118. package/dist/constraint-runtime/keybindings/index.js +0 -12
  119. package/dist/constraint-runtime/memdir/index.d.ts +0 -11
  120. package/dist/constraint-runtime/memdir/index.js +0 -12
  121. package/dist/constraint-runtime/migrations/index.d.ts +0 -11
  122. package/dist/constraint-runtime/migrations/index.js +0 -12
  123. package/dist/constraint-runtime/moreright/index.d.ts +0 -11
  124. package/dist/constraint-runtime/moreright/index.js +0 -12
  125. package/dist/constraint-runtime/native_ts/index.d.ts +0 -11
  126. package/dist/constraint-runtime/native_ts/index.js +0 -12
  127. package/dist/constraint-runtime/output_styles/index.d.ts +0 -11
  128. package/dist/constraint-runtime/output_styles/index.js +0 -12
  129. package/dist/constraint-runtime/plugins/index.d.ts +0 -11
  130. package/dist/constraint-runtime/plugins/index.js +0 -12
  131. package/dist/constraint-runtime/port_manifest.d.ts +0 -7
  132. package/dist/constraint-runtime/port_manifest.js +0 -11
  133. package/dist/constraint-runtime/query.d.ts +0 -6
  134. package/dist/constraint-runtime/query.js +0 -1
  135. package/dist/constraint-runtime/remote/index.d.ts +0 -11
  136. package/dist/constraint-runtime/remote/index.js +0 -12
  137. package/dist/constraint-runtime/schemas/index.d.ts +0 -11
  138. package/dist/constraint-runtime/schemas/index.js +0 -12
  139. package/dist/constraint-runtime/screens/index.d.ts +0 -11
  140. package/dist/constraint-runtime/screens/index.js +0 -12
  141. package/dist/constraint-runtime/server/index.d.ts +0 -11
  142. package/dist/constraint-runtime/server/index.js +0 -12
  143. package/dist/constraint-runtime/services/index.d.ts +0 -11
  144. package/dist/constraint-runtime/services/index.js +0 -12
  145. package/dist/constraint-runtime/state/index.d.ts +0 -11
  146. package/dist/constraint-runtime/state/index.js +0 -12
  147. package/dist/constraint-runtime/system_init.d.ts +0 -1
  148. package/dist/constraint-runtime/system_init.js +0 -20
  149. package/dist/constraint-runtime/types/index.d.ts +0 -11
  150. package/dist/constraint-runtime/types/index.js +0 -12
  151. package/dist/constraint-runtime/upstream_proxy/index.d.ts +0 -11
  152. package/dist/constraint-runtime/upstream_proxy/index.js +0 -12
  153. package/dist/constraint-runtime/utils/index.d.ts +0 -11
  154. package/dist/constraint-runtime/utils/index.js +0 -12
  155. package/dist/constraint-runtime/vim/index.d.ts +0 -11
  156. package/dist/constraint-runtime/vim/index.js +0 -12
  157. package/dist/constraint-runtime/voice/index.d.ts +0 -11
  158. package/dist/constraint-runtime/voice/index.js +0 -12
@@ -13,16 +13,23 @@ import * as fs from 'fs/promises';
13
13
  import * as fsSync from 'fs';
14
14
  import * as os from 'os';
15
15
  import * as path from 'path';
16
- import { expandHomeArgs } from './tool-path-args.js';
16
+ import { createRunContext } from './run-context.js';
17
+ import { createPrivateActor } from '../kernel/channel-actor.js';
18
+ // K10 ①: 运行生命周期写入经内核端口 (与 kernel/control.ts 的控制面分开 —— 一个回答"谁命令我停",
19
+ // 一个回答"我这一轮发生了什么"; 失败语义也不同: 控制面被拒 → 409, 事实写不进 → 响亮失败)
20
+ import { submitRunLifecycle, assertLifecycleOk } from '../kernel/run-lifecycle.js';
21
+ // K10 ④: 通信发送入口经内核端口 (上层不再直接对某条传输说话; 换传输只改这一处注入)
22
+ import { submitTransport, transportPeersSync } from '../kernel/transport.js';
23
+ // K10 ①: run 状态迁移经内核**控制面**端口 (它本来就管"谁命令这条 run 改状态": 带 origin 审计 + 拒绝归一化)
24
+ import { submitRunControl } from '../kernel/control.js';
25
+ // K10 ②: 回合的模型经**内核运行时**只读取租约 (传输仍是 Pi 客户端 ⇒ 行为不变; 取不到如实回落)
26
+ import { ModelRuntime, snapshotFromSelection } from '../kernel/model-runtime.js';
17
27
  import { renderDelegateNotices, pushNotice, renderNoticeBlock } from './background-notices.js';
18
28
  import { runWithWriteOrigin } from './skill-ledger.js';
19
29
  import { createHash } from 'node:crypto';
20
30
  import { shouldReviewTask, runExperienceReview } from './experience-review.js';
21
31
  import { decideLessonSink, skillsFromDirs, logLessonSuggestions, routeLessonToSkill } from './lesson-to-skill.js';
22
32
  import { managedServices } from './managed-services.js';
23
- import { IterationBudget, isRefundableTool } from './iteration-budget.js';
24
- import { recordToolCall, argsFingerprint } from './tool-telemetry.js';
25
- import { capToolResult } from './tool-result-gate.js';
26
33
  import { renderToolListWithParams } from './tool-subset.js';
27
34
  import { codeWriteTarget, decideTypecheck, formatTypecheckResult } from './code-write-gate.js';
28
35
  // 2026-10-01: 身份解析必须**静态导入** —— 之前在函数体里用 require(), 而打包后是 ESM ⇒
@@ -44,12 +51,11 @@ import { ConstraintLayer } from './constraint-layer.js';
44
51
  import { WorkflowEngine } from './workflow-engine.js';
45
52
  import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtime';
46
53
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
54
+ import { nativeAdapterFromEnv } from '../llm/native-adapter.js'; // K9: 第二个 (非 Pi) 推理适配器
47
55
  import { initDocumentReceiver } from './p2p-document-tools.js';
48
- import { startRun, recordStep, finishRun, readRun, budgetVerdict, recordDegradation, recordHarnessEvent, recordRecovery, setRunStatus, prepareResume, markRunRunning, buildResumeInstruction, argsDigestOf, repeatedFailureCount, classifyError as classifyRunError } from './run-store.js';
49
- import { createGoal, attachRun, findActiveGoal } from './goal-store.js';
56
+ import { startRun, recordStep, finishRun, readRun, recordDegradation, recordHarnessEvent, recordRecovery, setRunStatus, prepareResume, buildResumeInstruction, argsDigestOf, repeatedFailureCount, classifyError as classifyRunError } from './run-store.js';
57
+ import { createGoal, attachRun } from './goal-store.js';
50
58
  import { captureRunModelConfig } from '../llm/model-selection.js';
51
- // 2026-09-26: 工具名出网净化 (pi-ai.ts 唯一边界) + 回程派发还原 (原名 ↔ API 名)
52
- import { resolveApiToolName, expandKnownToolNames } from '../llm/tool-name.js';
53
59
  // 2026-09-28 (前缀 KV 可命中): CURRENT TURN 注入标记 —— "当前轮是否已注入"的唯一判据
54
60
  import { CURRENT_TURN_MARKER } from '../llm/pi-ai.js';
55
61
  import { PiAgentHarness } from './pi-harness.js';
@@ -78,18 +84,14 @@ import { getInjectionMaxChars } from '../bootstrap/exhaust-scrubber.js';
78
84
  import { monitorAfterReply } from '../pi-ecosystem-judgment/monitor-gate.js';
79
85
  // Bootstrap 生命周期 hook (SessionStart / Stop / PreToolUse)
80
86
  import { onSessionStart, onStop, onPreToolUse } from '../pi-ecosystem-judgment/human-value-pipeline.js';
81
- import { onPostToolUse } from '../bootstrap/lifecycle-hooks.js';
82
87
  import { budgetReduce, snip, microcompact } from '../context-compaction/index.js';
83
88
  // React Harness: 8-gate + 4-guard (防越权 / 防 prompt 注入)
84
89
  import { ReactHarness } from '../security/react-harness.js';
85
90
  import { HooksEngine } from '../hooks/hooks-engine.js';
86
91
  import { DenyPipeline } from './deny-pipeline.js';
87
- import { parseToolCall as parseToolCallImpl, parseAllToolCalls, isFinalResponse as isFinalResponseImpl, extractFinalAnswer as extractFinalAnswerImpl } from './parse-tool-call.js';
88
- import { buildObservation, buildReflection, formatObservationWithReflection, classifyError } from './error-classifier.js';
92
+ import { parseToolCall as parseToolCallImpl, isFinalResponse as isFinalResponseImpl, extractFinalAnswer as extractFinalAnswerImpl } from './parse-tool-call.js';
89
93
  import { sessionStore as defaultSessionStore } from './session-store.js';
90
94
  import { ToolRegistry } from './tool-registry.js';
91
- import { decideMaxIterations, decideContextOverflow, shouldCompactBeforeIteration } from './react-loop.js';
92
- import { DEFAULT_MAX_REVIEWS } from './loop-review.js';
93
95
  // PiSessionManager 已抽到 ./pi-sdk-session-manager.ts (2026-07-06)
94
96
  // Tool / ToolResult / Message / StreamCallback / StreamEvent / HeartbeatConfig / AgentSession / TOOL_DEFINITIONS
95
97
  // 已抽到 ./pi-sdk-types.ts (2026-07-06)
@@ -147,32 +149,14 @@ export function writeBackCurrentTurnInto(history, wire) {
147
149
  return 0; // 写回失败绝不影响对话本身
148
150
  }
149
151
  }
150
- import { LoopStallState, observeToolCall, batchHint } from './tool-loop-guard.js';
151
152
  /**
152
- * 写操作"读回自证" (2026-10-01 优化 #4): 写类工具成功后**自动**核一次, 把事实拼进结果。
153
- * 为什么: 过程纪律写了"读回一次", 但**没人执行** ✗ ⇒ 现在由机制执行: "工具说成功 ≠ 任务成功"。
154
- * 只做**便宜**的核对(存在性/大小); 失败静默(核对不该把工具搞失败)。
153
+ * 写操作「读回自证」的实现已拆到 `./write-verify.js` (K10 ⑦):
154
+ * - 要接进 `pi-sdk-tools.ts` 的写类工具 ⇒ 留在这里会形成循环 import;
155
+ * - 原实现在函数体里用 `require('node:fs')`, 而产物是 ESM ⇒ 它此前**一次都没成功过**
156
+ * (只会被 catch 吞成 `[未核对] … require is not defined`)。
157
+ * 这里保留**同名转出**, 老的 import 点不受影响。
155
158
  */
156
- export function verifyWriteOutcome(toolName, args, cwd) {
157
- try {
158
- const fsMod = require('node:fs');
159
- const pathMod = require('node:path');
160
- const WRITE = new Set(['write_file', 'edit_file', 'mkdir', 'move_file', 'copy_file']);
161
- if (!WRITE.has(String(toolName)))
162
- return null;
163
- const rel = String(args?.path ?? args?.to ?? args?.dir ?? '').trim();
164
- if (!rel)
165
- return null;
166
- const abs = pathMod.isAbsolute(rel) ? rel : pathMod.resolve(cwd, rel);
167
- const st = fsMod.statSync(abs);
168
- if (st.isDirectory())
169
- return `[已核对] 目录存在: ${rel}`;
170
- return `[已核对] 文件已落盘: ${rel} (${st.size} 字节, ${new Date(st.mtimeMs).toISOString()})`;
171
- }
172
- catch (e) {
173
- return `[未核对] 读回失败: ${String(e?.message || e).slice(0, 80)} —— 别急着说"已完成", 先确认路径/权限`;
174
- }
175
- }
159
+ export { verifyWriteOutcome, withWriteVerified, WRITE_TOOLS } from './write-verify.js';
176
160
  /**
177
161
  * 过程纪律 (2026-10-01, 用户: 「智能体回复方式没有主动性 … 在过程里面更加主动考虑」)。
178
162
  *
@@ -215,7 +199,90 @@ export class PiAgentSession {
215
199
  sessionManager;
216
200
  agentsManager;
217
201
  socialHeartbeat = null;
218
- messageHistory = [];
202
+ /**
203
+ * **K5 第 4 步 (所有权转移)**: history 的**本体**。
204
+ * · 未绑定 actor ⇒ 本体在这里 (绑定前的暂存);
205
+ * · 绑定 actor 后 ⇒ 本体是 `actor.state.messageHistory`, 本地那份被**收养**并清空。
206
+ * 绑定由 `attachActor()` 做, 且**只在会话身份已知时**发生 (见 session factory) —— 没有身份就不归属。
207
+ */
208
+ /**
209
+ * **K5 第 4 步 — history 写入的唯一漏斗 (push)**。
210
+ * 为什么必须同步: 调用点遍布 ReAct 循环 / 工具回灌 / 压缩, 而且**写完立刻读** (length / 索引 / slice),
211
+ * 改成 await 会改变同拍可见性。所以这里有意识地**不排队** —— 归属收敛 + 可数 (门能断言"零处直写"),
212
+ * 并发安全由入口投递进 mailbox 负责 (K5 第 5 步)。
213
+ */
214
+ pushHistory(...msgs) {
215
+ for (const m of msgs) {
216
+ // 步骤⑧ 起**只有一条路径**: 写 actor (实例侧暂存字段已删除) —— 也彻底消除了"自递归"的可能
217
+ this.actor.appendMessageSync(m);
218
+ }
219
+ }
220
+ /** **K5 第 4 步 — history 写入漏斗 (pop)** —— 同 pushHistory 的性质 */
221
+ popHistory() {
222
+ return this.actor.popMessageSync();
223
+ }
224
+ /** **K5 第 4 步 — history 写入漏斗 (整体替换)**: hydrate 回灌 / 压缩后的整块赋值都走这里 */
225
+ /**
226
+ * **整体替换**历史 —— 唯一漏斗之一, 步骤⑧ 起是**唯一**的"种历史"入口 (公开)。
227
+ * 公开的理由: 上层/测试要种一份历史时必须走这里; 直接给对象赋一个新数组会**换掉数组身份**
228
+ * (actor 里那份仍是空的) —— 那是静默丢数据 (实测: 删掉 setter 后 `session.messageHistory = [...]`
229
+ * 变成写在一个凭空出现的自有属性上, 测试于是看到 0 条)。
230
+ */
231
+ replaceHistory(next) {
232
+ this.actor.replaceHistory(next);
233
+ }
234
+ /**
235
+ * **K5 步骤⑦ — 把一次性请求里的绑定落到位** (入口不再散着设字段: 只交一个请求)。
236
+ * 写入都走访问器 ⇒ 本体落进 `actor.state` (步骤③ 起就是这样); 没给的不覆盖 (与环境变量"只覆盖显式给出的"同秩)。
237
+ */
238
+ applyExecutionRequest(req) {
239
+ if (req.channelId)
240
+ this.actor.state.channelId = req.channelId;
241
+ if (req.agentId)
242
+ this.actor.state.agentId = req.agentId;
243
+ if (req.goalId)
244
+ this.actor.state.goalBinding = req.goalId;
245
+ if (req.resumeRunId)
246
+ this.resumeRunId = req.resumeRunId;
247
+ }
248
+ /**
249
+ * **K5 步骤⑦ — Pi 的唯一执行入口**: 只接收一次性 `ExecutionRequest`。
250
+ * 有 `onStream` ⇒ 走 `promptStream` (流式); 否则走 `prompt`。绑定由 `applyExecutionRequest` 落位。
251
+ * 入口侧只需: `deliverThroughActor(session, () => session.runExecution(req))`。
252
+ */
253
+ async runExecution(req) {
254
+ this.applyExecutionRequest(req);
255
+ if (req.onStream) {
256
+ return this.promptStream(req.input, req.onStream, req.signal, req.channelId);
257
+ }
258
+ return this.prompt(req.input, { signal: req.signal, channelId: req.channelId });
259
+ }
260
+ /**
261
+ * **K5 第 4 步**: 绑定 actor 并**收养**绑定前已存在的本地历史。
262
+ * 收养规则: actor 侧为空 且 本地非空 ⇒ 搬过去 (任何绑定时序都不丢历史); 收养后清空本地。
263
+ * 约束 (全量回归打出来的): 调用方必须用**会话身份**当注册键; 没有身份就**不要调**这个方法。
264
+ */
265
+ attachActor(actor) {
266
+ const prev = this.actor;
267
+ this.actor = actor;
268
+ if (!prev || prev === actor)
269
+ return;
270
+ // **收养**: 把上一个 actor (通常是"出生时的私有 actor") 的状态搬进新家。
271
+ // 新家已有值时不覆盖 —— 同一会话身份以先到者为准 (禁止"后到的调用把先建的状态冲掉")。
272
+ const from = prev.state;
273
+ const to = actor.state;
274
+ if (from.messageHistory.length > 0 && to.messageHistory.length === 0) {
275
+ to.messageHistory.push(...from.messageHistory);
276
+ }
277
+ if (from.channelId && !to.channelId)
278
+ to.channelId = from.channelId;
279
+ if (from.agentId && !to.agentId)
280
+ to.agentId = from.agentId;
281
+ if (from.goalBinding && !to.goalBinding)
282
+ to.goalBinding = from.goalBinding;
283
+ if (from.activeRun && !to.activeRun)
284
+ to.activeRun = from.activeRun;
285
+ }
219
286
  tools = new Map();
220
287
  /** 2026-06-30: tool registry 模块 — 独立 alias resolve, 测试可消融. */
221
288
  _toolRegistry = new ToolRegistry();
@@ -310,7 +377,7 @@ export class PiAgentSession {
310
377
  harnessEnabled = false;
311
378
  /** 8-gate + 4-guard 集中调度 (防越权 / 防 prompt 注入) */
312
379
  reactHarness = new ReactHarness();
313
- usePivotLoop = false;
380
+ usePivotLoop = true; // K4-B (2026-10-02): 默认切到 pivot —— 所有入口只跑**一套** loop (老 ReAct loop 待删)
314
381
  pivotLoopConfig;
315
382
  /** P2: 当前会话的 permission mode (每次 promptStream 入口解析) */
316
383
  currentPermissionMode = 'default';
@@ -378,11 +445,29 @@ export class PiAgentSession {
378
445
  // 拼到 systemPrompt 末尾, 别再混进 user message
379
446
  contextHintAddition = '';
380
447
  /**
381
- * 当前 onStream 引用 + abort signal (computeJudgmentGate 需要 onStream 广播 phase)
382
- * 每次 prompt / promptStream / promptWithPivotLoop 入口设置, 用完即清
448
+ * **K2 收尾 (leo 2026-10-02 口径)**: **唯一一处**把"已有的恢复身份"播种进 RunContext。
449
+ *
450
+ * · `currentRunId` 属 **run-boundary**, 不是循环内部的可变 RunContext 状态 ⇒ 只允许这一次显式播种读取;
451
+ * · 冻结 `CURRENT_RUN_ID_SEED_READS = 1` (见 src/kernel/plan-runcontext.ts), 由门强制;
452
+ * · 只发生在**进入一次 Run 时**; 不得在循环中新增读取点; 不得成为新的写入口;
453
+ * · K5 Channel Actor 完成后改为 `createRunContext({ runId: request.resumeRunId })`, 届时删除 `currentRunId` 字段本体。
454
+ */
455
+ seedRunContext(extra = {}) {
456
+ return createRunContext({ runId: this.actor.state.activeRun, ...extra });
457
+ }
458
+ /**
459
+ * **K5 迁移中**: 本会话所属 channel 的 Actor (由 session factory 在创建时绑定)。
460
+ * 现在只做**归属** —— 会话状态 (messageHistory / channelId / agentId / goalId) **仍然**在实例字段上,
461
+ * 逐项迁入 Actor 见 `src/kernel/plan-channel-actor.ts` 的 8 步。
383
462
  */
384
- currentOnStream = null;
385
- currentSignal = null;
463
+ actor;
464
+ /**
465
+ * **K2 迁移中**: 一次 Run 的显式状态载体 (见 src/agents/run-context.ts)。
466
+ * 入口 (prompt / promptStream / promptWithPivotLoop) 用 createRunContext() 快照一次, 用完即清。
467
+ * **已外置**: `eventSink` (原 currentOnStream) · `abortSignal` (原 currentSignal)。
468
+ * 其余 6 个字段仍以本类实例字段为准, 逐格迁移。
469
+ */
470
+ runCtx = createRunContext();
386
471
  /** Bootstrap SessionStart 拼的 system prompt 片段 (用完即清) */
387
472
  bootstrapAddition = '';
388
473
  /** 2026-08-12 (Task3 认知卸载): 工具选择与认知卸载指南 — 注入 system prompt, 降低"模型不触发 write/edit/read"率. */
@@ -396,12 +481,17 @@ export class PiAgentSession {
396
481
  - 每步只选一个最合适的工具, 先读后写, 写完验证 (跑 tsc/vitest 或读回文件).`;
397
482
  /** 当前 prompt 开始时间 (供 Stop hook 计算 durationMs) */
398
483
  promptStartTime = 0;
399
- /** 当前 channel id (由 getAgentForChannel / prompt 4 参注入, 供 hook / log 使用) */
400
- currentChannelId = '';
401
- /** 2026-07-04: 当前 agentId (server.ts 通过 createAgentSession 选项注入), 供 onSessionStart 加载 persona docs */
402
- currentAgentId = '';
403
- /** M2.2 (2026-06-17): 当前轮的用户请求 intent, runReActLoop 拼 systemPrompt 时会读这个 */
404
- currentIntent = 'chitchat';
484
+ /**
485
+ * **K5 步骤③ (会话绑定迁入 Actor)**: 三处绑定 (channelId / agentId / goalBinding) 的本体住进
486
+ * `actor.state`, 实例上只留**绑定前的暂存** (`_channelId` 等), 由 `attachActor()` 收养后清空。
487
+ * 用访问器而不是改调用点: 写入点 (prompt 入口 / Goal 绑定 / snapshot) 分散且语义各异, 逐个改风险大;
488
+ * 访问器让所有读写自动落到 actor (同一个值对象), 调用点零改动。
489
+ * 注意: 这三个字段的**访问数不变** (台账按 "self.currentX" 形态计数) —— 迁的是**所有权**, 不是删访问。
490
+ * 本注释刻意不写出那种带 `this.` 前缀的字面形态: 计数口径只剥 `//` 行注释, 块注释里的同形串会被算进去
491
+ * (曾因此把 currentChannelId 的计数虚增 1, 被门照出)。
492
+ */
493
+ // **K5 步骤⑧ 批次4**: channelId 的访问器已删 —— 本体 `actor.state.channelId`, 直接影响本体。
494
+ // M2.2 intent 已外置到 runCtx.intent (K2); 拼 systemPrompt 时读 this.runCtx.intent
405
495
  /** 2026-08-10: 本轮用户原始输入 (loop-review 任务动词兜底检测用) */
406
496
  currentUserInput = '';
407
497
  /**
@@ -409,7 +499,16 @@ export class PiAgentSession {
409
499
  * 之前只有"跑完才写"的轨迹 (trajectory-store, fire-and-forget, 崩了就没有), 没有跨重载可见的
410
500
  * 运行事实, 也没有预算闸门/失速巡检; 这里补的就是那一层。
411
501
  */
412
- currentRunId = '';
502
+ /**
503
+ * **K5 步骤⑥ (Run 身份归属 Actor)**: 本体是 `actor.state.activeRun`。
504
+ * · 未绑定 actor ⇒ 住在这里 (`_runId`, 绑定前的暂存);
505
+ * · 绑定后 ⇒ 读写全落 actor (同一个值), 由 `attachActor()` 收养绑定前的值。
506
+ * 语义: "本会话当前活跃的 Run" —— 一个会话身份同时只有一个活跃 Run ✓ 与 Actor 的粒度一致。
507
+ * 注意: 访问数**不变** —— 迁的是**所有权**不是删访问 (与步骤③同理)。
508
+ * 本注释刻意不写出"带 self. 前缀 + 该字段名"的那种字面形态: K2 的计数只剥 `//` 行注释,
509
+ * 块注释里的同形串会被算进去 (同类踩过三次, 每次都被门照出)。
510
+ */
511
+ // **K5 步骤⑧ 批次5 (收尾)**: currentRunId 访问器已删 —— Run 身份的本体是 `actor.state.activeRun`, 直接读写本体。
413
512
  /** 上一次运行的 runId (收尾不清空; 见 getLastRunId) */
414
513
  lastRunId = '';
415
514
  runSurface = 'cli';
@@ -426,14 +525,14 @@ export class PiAgentSession {
426
525
  currentIntentHint = '';
427
526
  /**
428
527
  * 算 judgment 注入门: 失败静默, 不阻塞主对话
429
- * 期间通过 currentOnStream 广播 phase 事件, 前端可显示 "正在检索判断力..." 状态
528
+ * 期间通过 runCtx.eventSink 广播 phase 事件, 前端可显示 "正在检索判断力..." 状态
430
529
  * 调用方负责用完即清 (judgmentGateAddition='')
431
530
  */
432
531
  async computeJudgmentGate(input) {
433
532
  const safePhase = (phase, extra = {}) => {
434
533
  try {
435
- if (this.currentOnStream) {
436
- this.currentOnStream({ type: 'phase', phase, ...extra, content: '' });
534
+ if (this.runCtx.eventSink) {
535
+ this.runCtx.eventSink({ type: 'phase', phase, ...extra, content: '' });
437
536
  }
438
537
  }
439
538
  catch { /* 静默 */ }
@@ -475,19 +574,23 @@ export class PiAgentSession {
475
574
  this.judgmentGateNegativeUsedIds = [];
476
575
  }
477
576
  constructor(config) {
577
+ // **K5 步骤⑧**: actor 从出生就在 —— 工厂通常在**构造前**就把身份 actor 注进来 (`config.actor`);
578
+ // 没有身份时用一份**私有 actor** (不注册 ⇒ 谁也拿不到, 隔离性优先)。
579
+ // ⇒ 实例侧不再需要"绑定前暂存"字段 (那些字段本步删除)。
580
+ this.actor = config.actor ?? createPrivateActor();
478
581
  this.cwd = config.cwd;
479
582
  this.peerId = config.peerId || 'local';
480
583
  this.identity = config.identityDoc || this.createDefaultIdentity();
481
584
  this.minimaxAvailable = this.checkMinimax();
482
585
  // 2026-07-04: 透传 agentId (server.ts 通过 createAgentSession 选项注入)
483
- this.currentAgentId = config.agentId || '';
586
+ this.actor.state.agentId = config.agentId || '';
484
587
  // 2026-10-01 **身份自愈** (用户实测: get_identity 返回 "DID: " 空 + 各 channel 名字串台):
485
588
  // 工具上下文里的身份来自 this.identity, 而它可能是 config.identityDoc 与 session 实例身份的
486
589
  // 合并结果 —— **空 did 会盖掉真 did** ✗; 名字也可能缺失或被别处覆盖。
487
590
  // 规矩: 缺 did / 名字时, 按 **currentAgentId** 从真身份生成器补齐 (did:key + 落盘密钥),
488
591
  // 名字优先取该 agent 自己的 persona.json。绝不回落到全局/用户身份名。
489
592
  try {
490
- if (this.currentAgentId) {
593
+ if (this.actor.state.agentId) {
491
594
  const cur = this.identity || {};
492
595
  const curDid = String(cur.did || '');
493
596
  const badDid = !curDid || /^did:(local|pi):/i.test(curDid);
@@ -495,17 +598,17 @@ export class PiAgentSession {
495
598
  // 2026-10-01 加固: **did 与本人密钥不符** 也算坏 —— 用户实测 get_identity 返回一个
496
599
  // 数据里根本不存在的 did (按 peerId 当 scope 新造的), 而它非空、非假值 ⇒ 上一版判据漏过 ✗。
497
600
  // 规矩: 只要 currentAgentId 有值, 身份的 did 就必须等于该 agent 自己的密钥 did。
498
- const mine = loadOrCreateAgentIdentity(this.currentAgentId);
601
+ const mine = loadOrCreateAgentIdentity(this.actor.state.agentId);
499
602
  const mismatch = !!mine?.did && !!curDid && curDid !== mine.did;
500
603
  if (badDid || missingName || mismatch) {
501
604
  if (badDid || mismatch) {
502
- console.warn(`[identity] 身份纠正: ${this.currentAgentId} 的 did ${curDid ? curDid.slice(0, 26) : '(空)'} ⇒ ${mine.did.slice(0, 26)} (以该 agent 自己的密钥为准)`);
605
+ console.warn(`[identity] 身份纠正: ${this.actor.state.agentId} 的 did ${curDid ? curDid.slice(0, 26) : '(空)'} ⇒ ${mine.did.slice(0, 26)} (以该 agent 自己的密钥为准)`);
503
606
  }
504
607
  // 2026-10-01: 同样必须**原地改** —— 这段在 registerTools() **之后**跑, 工具上下文已经按引用
505
608
  // 捕获了 this.identity; 换对象 ⇒ 工具看不到纠正结果 ✗ (同类 bug, 一并修)
506
609
  Object.assign(this.identity, {
507
610
  ...(mine?.did ? { did: mine.did, publicKey: mine.publicKey || cur.publicKey } : {}),
508
- ...(missingName ? { name: agentPersonaName(this.currentAgentId) || this.currentAgentId } : {}),
611
+ ...(missingName ? { name: agentPersonaName(this.actor.state.agentId) || this.actor.state.agentId } : {}),
509
612
  });
510
613
  }
511
614
  }
@@ -515,9 +618,9 @@ export class PiAgentSession {
515
618
  this._sessionStore = config.sessionStore ?? defaultSessionStore;
516
619
  this.constraintLayer = new ConstraintLayer();
517
620
  this.workflowEngine = new WorkflowEngine(this.constraintLayer);
518
- this.sessionManager = new PiSessionManager(this.identity.did, this.cwd, this.currentAgentId);
621
+ this.sessionManager = new PiSessionManager(this.identity.did, this.cwd, this.actor.state.agentId);
519
622
  this.agentsManager = new DiscoveredAgentsManager();
520
- this.usePivotLoop = config.usePivotLoop ?? false;
623
+ this.usePivotLoop = config.usePivotLoop ?? true; // K4-B: 默认 true (K4-B 之前是 false ⇒ CLI 走老 loop, 与 web 分叉)
521
624
  this.pivotLoopConfig = config.pivotLoopConfig;
522
625
  this.initSession();
523
626
  initDocumentReceiver();
@@ -601,6 +704,18 @@ export class PiAgentSession {
601
704
  */
602
705
  async hydrateMessageHistory(sessionKey, maxMessages) {
603
706
  try {
707
+ // **K5 第 4 步**: 实现体 (load → filter → 截断 → 替换) 已搬进 Actor —— 走邮箱 ⇒ 与 append/persist 串行。
708
+ // 业务侧只交两个**纯回调** (内核因此不必 import 业务模块); 未绑定 actor 的会话走下面原路径 (行为不变)。
709
+ if (this.actor) {
710
+ const n = await this.actor.hydrateHistory({
711
+ load: () => this._sessionStore.loadMessages(sessionKey),
712
+ filter: (loaded) => this._filterToMessage(loaded),
713
+ maxMessages,
714
+ });
715
+ if (n > 0)
716
+ console.log(`[PiAgent] 从 ${sessionKey} 回灌 ${n} 条历史 (经 Channel Actor)`);
717
+ return;
718
+ }
604
719
  const loaded = await this._sessionStore.loadMessages(sessionKey);
605
720
  if (!loaded) {
606
721
  console.log(`[PiAgent] hydrate: 没有 ${sessionKey} 的历史`);
@@ -608,7 +723,7 @@ export class PiAgentSession {
608
723
  }
609
724
  const hydrated = this._filterToMessage(loaded).slice(-maxMessages);
610
725
  if (hydrated.length > 0) {
611
- this.messageHistory = hydrated;
726
+ this.replaceHistory(hydrated);
612
727
  console.log(`[PiAgent] 从 ${sessionKey} 回灌 ${hydrated.length} 条历史`);
613
728
  }
614
729
  }
@@ -628,7 +743,10 @@ export class PiAgentSession {
628
743
  * 即可获得"重启 / 跨进程接续"的语义.
629
744
  */
630
745
  async saveCurrentSession(key) {
631
- const persisted = this.messageHistory.map((m) => ({
746
+ // **K5 第 4 步**: 取数拍走 Actor 的快照 (走邮箱 ⇒ 与 append 串行, 不会抓到"边写边读"的半截状态);
747
+ // 未绑定 actor 的会话走原路径 (行为不变)。
748
+ const source = this.actor ? await this.actor.historySnapshot() : this.actor.state.messageHistory;
749
+ const persisted = source.map((m) => ({
632
750
  role: m.role,
633
751
  content: m.content,
634
752
  toolCall: m.toolCall,
@@ -645,9 +763,9 @@ export class PiAgentSession {
645
763
  * 与 loadSessionKey (构造时读) 不同: 这个是 session 已建好后再读.
646
764
  */
647
765
  async resumeSession(key, maxMessages = 30) {
648
- const before = this.messageHistory.length;
766
+ const before = this.actor.state.messageHistory.length;
649
767
  await this.hydrateMessageHistory(key, maxMessages);
650
- return this.messageHistory.length - before;
768
+ return this.actor.state.messageHistory.length - before;
651
769
  }
652
770
  /**
653
771
  * 2026-06-30: 读历史不修改 messageHistory.
@@ -819,7 +937,7 @@ export class PiAgentSession {
819
937
  const { CHANNELS_PATH } = await import('../web/server-types.js');
820
938
  const { loadChannels } = await import('../web/server-storage.js');
821
939
  const channels = await loadChannels();
822
- const ch = channels.find((c) => c.id === this.currentChannelId);
940
+ const ch = channels.find((c) => c.id === this.actor.state.channelId);
823
941
  if (ch && ch.encryptedPrivateKey && ch.encryptedPrivateKeyIv && ch.walletAddress) {
824
942
  return {
825
943
  encryptedPrivateKey: ch.encryptedPrivateKey,
@@ -894,7 +1012,7 @@ export class PiAgentSession {
894
1012
  // 2026-10-01: 原先自造 `did:pi:<peerId>` —— **不是有效 DID** (仓里 server.ts 自己都把 did:pi:
895
1013
  // 当"待升级"占位; 用户实测 get_identity 报 did:pi:ch_1785668060213)。
896
1014
  // 改用仓里既有的真身份生成器 (agent-identity.loadOrCreateAgentIdentity, 同步, 产 did:key + 落盘密钥)。
897
- const scope = this.currentAgentId || this.peerId || 'default';
1015
+ const scope = this.actor.state.agentId || this.peerId || 'default';
898
1016
  try {
899
1017
  const real = loadOrCreateAgentIdentity(scope);
900
1018
  return {
@@ -953,9 +1071,9 @@ export class PiAgentSession {
953
1071
  const did = await resolveUserDid();
954
1072
  const model = this.llmConfig?.model || this.model || '';
955
1073
  return new TrajectoryRecorder({
956
- agentId: this.currentAgentId || 'default',
1074
+ agentId: this.actor.state.agentId || 'default',
957
1075
  input,
958
- channelId: channelId || this.currentChannelId,
1076
+ channelId: channelId || this.actor.state.channelId,
959
1077
  did: did || undefined,
960
1078
  model: typeof model === 'string' && model ? model : undefined,
961
1079
  });
@@ -997,8 +1115,8 @@ export class PiAgentSession {
997
1115
  if (gate !== 'ready') {
998
1116
  const why = `初始化未就绪 (${gate}, 当前阶段 ${state.stage})${state.lastError ? ` — ${state.lastError.message}` : ''}`;
999
1117
  const hint = (state.actions && state.actions[0]) || '运行 `bolloon setup` 完成初始化';
1000
- this.messageHistory.push({ role: 'user', content: input });
1001
- this.messageHistory.push({ role: 'assistant', content: `[初始化未就绪] ${why}\n${hint}` });
1118
+ this.pushHistory({ role: 'user', content: input });
1119
+ this.pushHistory({ role: 'assistant', content: `[初始化未就绪] ${why}\n${hint}` });
1002
1120
  console.warn(`[PiAgent] 拒绝执行: ${why}`);
1003
1121
  return `[初始化未就绪] ${why}\n${hint}`;
1004
1122
  }
@@ -1006,48 +1124,64 @@ export class PiAgentSession {
1006
1124
  catch { /* 门禁自身异常 → 按"不能判定"处理会阻塞一切; 这里只在能读到状态时才拦 */ }
1007
1125
  }
1008
1126
  this.minimaxAvailable = this.checkMinimax();
1009
- this.currentChannelId = options?.channelId ?? this.currentChannelId;
1127
+ this.actor.state.channelId = options?.channelId ?? this.actor.state.channelId;
1010
1128
  // 2026-08-08: 运行轨迹采集 (落盘 + OrbitDB, 失败静默) — 包裹 onStream 收集步骤事件
1011
1129
  const trajRec = await this.createTrajectoryRecorder(input, options?.channelId);
1012
1130
  if (trajRec && options?.onStream) {
1013
1131
  options = { ...options, onStream: this.wrapTrajectoryStream(options.onStream, trajRec) };
1014
1132
  }
1015
- this.messageHistory.push({
1133
+ this.pushHistory({
1016
1134
  role: 'user',
1017
1135
  content: input
1018
1136
  });
1019
- if (!this.minimaxAvailable) {
1137
+ // K9 (2026-10-02): 同 `promptWithPivotLoop` —— **回合入口的可用性判据必须问当前适配器**。
1138
+ // 这类 `!minimaxAvailable` 闸一共 6 处: 1223(prompt)/1466(promptStream)/1714(pivot, 已修)
1139
+ // 是**回合入口** ⇒ 全部改用 `inferenceAvailable()`; 其余三处是 Pi 自己的文档类功能
1140
+ // (`suggestRename`/`summarizeDocument`/`improveDocument`), 不在 `LLMInterface` 面内 ⇒ 如实保留。
1141
+ if (!this.inferenceAvailable()) {
1020
1142
  // 2026-09-16 (2-C.2): fallback **也必须留下 Run 事实**。
1021
1143
  // 旧行为: 直接返回兜底文案, 连 Run 都不建 —— 上层 (独立宿主/Supervisor) 只看到"执行完成但没有 Run",
1022
1144
  // 于是把"什么都没跑"当成一次正常执行 (这正是"agent 跑了但没记录"的老毛病在 fallback 路径的翻版)。
1023
1145
  const response = await this.handleFallback(input);
1024
1146
  try {
1025
- let boundGoalId = this.currentGoalId;
1147
+ let boundGoalId = this.actor.state.goalBinding;
1026
1148
  if (!boundGoalId) {
1027
1149
  const g = await createGoal({
1028
1150
  objective: this.currentUserInput || input.slice(0, 200),
1029
- channelId: this.currentChannelId || undefined,
1030
- agentId: this.currentAgentId || undefined,
1151
+ channelId: this.actor.state.channelId || undefined,
1152
+ agentId: this.actor.state.agentId || undefined,
1031
1153
  createdBy: this.runSurface,
1032
1154
  });
1033
1155
  boundGoalId = g.goalId;
1034
1156
  }
1035
- this.currentGoalId = boundGoalId;
1036
- const rec = await startRun({
1037
- surface: this.runSurface,
1038
- goal: this.currentUserInput || input.slice(0, 200),
1039
- goalId: boundGoalId,
1040
- channelId: this.currentChannelId || undefined,
1041
- agentId: this.currentAgentId || undefined,
1042
- modelConfig: await this.runModelSnapshot(),
1043
- });
1157
+ this.actor.state.goalBinding = boundGoalId;
1158
+ // K10 ①: 三处写入均经内核端口; 写不进就 assertLifecycleOk 抛 (run-store strict: 写不进 = 这次运行在事实层面不存在)
1159
+ const startOut = await submitRunLifecycle({
1160
+ op: 'start-run',
1161
+ origin: 'pi-session',
1162
+ payload: {
1163
+ surface: this.runSurface,
1164
+ goal: this.currentUserInput || input.slice(0, 200),
1165
+ goalId: boundGoalId,
1166
+ channelId: this.actor.state.channelId || undefined,
1167
+ agentId: this.actor.state.agentId || undefined,
1168
+ modelConfig: await this.runModelSnapshot(),
1169
+ },
1170
+ }, { startRun: startRun });
1171
+ assertLifecycleOk(startOut);
1172
+ const rec = startOut.result;
1044
1173
  this.lastRunId = rec.runId;
1045
1174
  await attachRun(boundGoalId, rec.runId).catch(() => null);
1046
- await recordStep(rec.runId, { tool: 'llm', ok: false, error: 'LLM 不可用 (provider 未初始化/无 apiKey) → 走了 fallback' });
1047
- await finishRun(rec.runId, {
1048
- status: 'needs_human',
1049
- error: 'LLM 不可用: provider 未初始化或无 apiKey (fallback 不是执行结果, 需要配置或人工处理)',
1050
- });
1175
+ const stepOut = await submitRunLifecycle({
1176
+ op: 'record-step',
1177
+ origin: 'pi-session',
1178
+ runId: rec.runId,
1179
+ payload: { tool: 'llm', ok: false, error: 'LLM 不可用 (provider 未初始化/无 apiKey) → 走了 fallback' },
1180
+ }, { recordStep: recordStep });
1181
+ assertLifecycleOk(stepOut);
1182
+ // 收尾漏斗门的登记项按**单行**匹配 (seams.ts:564 `ls.find(...)`) ⇒ 这次调用刻意写成一行, 好让登记项能钉住它
1183
+ const finOut = await submitRunLifecycle({ op: 'finish-run', origin: 'pi-session', runId: rec.runId, payload: { status: 'needs_human', error: 'LLM 不可用: provider 未初始化或无 apiKey (fallback 不是执行结果, 需要配置或人工处理)' } }, { finishRun: finishRun });
1184
+ assertLifecycleOk(finOut);
1051
1185
  // ★ M0: "工具/权限不可用"也是一条终止路径 → 同样进收尾漏斗 (规则 ④)。
1052
1186
  const wiring = await import('./goal-flywheel-wiring.js');
1053
1187
  const closed = await wiring.closeRunOnce({
@@ -1077,25 +1211,27 @@ export class PiAgentSession {
1077
1211
  catch (err) {
1078
1212
  await recordDegradation({ kind: 'core', op: 'pi-sdk.fallbackRun', runId: this.lastRunId || '', message: String(err?.message || err).slice(0, 160) }).catch(() => { });
1079
1213
  }
1080
- this.messageHistory.push({ role: 'assistant', content: response });
1214
+ this.pushHistory({ role: 'assistant', content: response });
1081
1215
  this.reportUsageToContextManager();
1082
1216
  this.finishTrajectory(trajRec, response);
1083
1217
  return response;
1084
1218
  }
1085
1219
  // P0 注入门
1086
- this.currentSignal = options?.signal ?? null;
1087
- this.currentOnStream = options?.onStream ?? null;
1220
+ // K2: eventSink + abortSignal 已外置, 入口一次建好本轮 Context。
1221
+ // 其余字段**不在这里抄一份** —— 抄写会新增对旧实例字段的读, 违反方向判据
1222
+ // 「新层出现后旧写口调用点数只许不变或减少」。逐字段迁移时再各自搬进来。
1223
+ this.runCtx = this.seedRunContext({ eventSink: options?.onStream ?? null, abortSignal: options?.signal ?? null });
1088
1224
  await this.computeJudgmentGate(input);
1089
1225
  // M2.2 (2026-06-17): intent 分类 — prompt() 路径也跑 (跟 promptStream 对齐)
1090
1226
  try {
1091
1227
  const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1092
- this.currentIntent = classifyIntent(input);
1093
- this.currentIntentHint = intentHint(this.currentIntent);
1228
+ this.runCtx.intent = classifyIntent(input);
1229
+ this.currentIntentHint = intentHint(this.runCtx.intent);
1094
1230
  this.currentUserInput = input;
1095
1231
  }
1096
1232
  catch (err) {
1097
1233
  console.warn('[PiAgent] classifyIntent in prompt() failed:', err);
1098
- this.currentIntent = 'chitchat';
1234
+ this.runCtx.intent = 'chitchat';
1099
1235
  this.currentIntentHint = '';
1100
1236
  }
1101
1237
  // P2: 解析当前 permission mode
@@ -1107,43 +1243,22 @@ export class PiAgentSession {
1107
1243
  console.warn('[PiAgent] resolvePermissionMode failed (non-fatal):', err);
1108
1244
  this.currentPermissionMode = 'default';
1109
1245
  }
1110
- // M3.1 (2026-06-17): 跟 promptStream 一样, usePivotLoop 时走 pivotLoop 路径
1111
- // 之前 prompt() 永远跑老 runReActLoop, CLI/web 行为不一致
1112
- if (this.usePivotLoop) {
1113
- try {
1114
- const lr = await this.promptWithPivotLoop(input, undefined, options?.channelId);
1115
- this.finishTrajectory(trajRec, lr.response || '');
1116
- return lr.response || '';
1117
- }
1118
- finally {
1119
- if (this.judgmentGateUsedIds.length > 0) {
1120
- recordJudgmentUsage(this.judgmentGateUsedIds, { userInput: input }).catch((err) => console.warn('[PiAgent] recordJudgmentUsage failed:', err));
1121
- }
1122
- this.clearJudgmentGate();
1123
- this.currentSignal = null;
1124
- this.currentOnStream = null;
1125
- this.reportUsageToContextManager();
1126
- }
1127
- }
1246
+ // K4-B (2026-10-02): **这条路已经只有一条** —— 原先这里有个 `if (this.usePivotLoop) { ... return ... }`
1247
+ // 的提前返回分支, 它的 finally 只是**子集** (只记正极性用法), 而且**少跑了**:
1248
+ // 回合后复盘 `runExperienceReview` · `monitorAfterReply`/`onStop` · `finishTrajectory(reply, ok|error)`
1249
+ // · 负极性用法记账 · `bootstrapAddition`/`promptStartTime` 清理。现在统一走下面那条完整收尾。
1128
1250
  try {
1129
- // 2026-06-16: runReActLoop 现在返回 { reply, aiFailed, aiFailureReason } — 这里只需 reply 字符串
1130
- const loopResult = await this.runReActLoop(this.currentOnStream ?? undefined, options?.signal);
1131
- // 2026-10-01: **本回合改过 TS ⇒ 收尾自动类型检查** (一轮一次) —— 复用已有的 tsc_check 工具
1132
- if (decideTypecheck(this.tsTouchedThisTurn, this.typecheckRanThisTurn)) {
1133
- this.typecheckRanThisTurn = true;
1134
- try {
1135
- const tscTool = this.tools.get('tsc_check');
1136
- if (tscTool?.execute) {
1137
- const r = await tscTool.execute({});
1138
- const line = formatTypecheckResult(r?.success !== false && !/error TS\d+/.test(String(r?.output || '')), String(r?.output || ''));
1139
- this.currentOnStream?.({ type: 'status', content: `🔎 ${line}`, tool: 'system' });
1140
- }
1141
- }
1142
- catch (e) {
1143
- this.currentOnStream?.({ type: 'status', content: `🔎 类型检查没能跑起来: ${String(e?.message || e).slice(0, 100)} (改动已落盘, 记得自己跑一次)`, tool: 'system' });
1144
- }
1145
- this.tsTouchedThisTurn = [];
1146
- }
1251
+ // K4-B (2026-10-02): **合并两套 loop ⇒ 只剩 pivot 这一套** (老 `runReActLoop` 已删除)。
1252
+ // 这里保留一个与原签名同形的 `loopResult`, 让下面那段**完整收尾**(自检 → 复盘 → 轨迹 → 记账)
1253
+ // 原样继续工作, 不搬家。
1254
+ const lr = await this.promptWithPivotLoop(input, undefined, options?.channelId);
1255
+ const loopResult = {
1256
+ reply: lr.response || '',
1257
+ aiFailed: !lr.success,
1258
+ aiFailureReason: String(lr.exitReason || ''),
1259
+ };
1260
+ // K4-B (2026-10-02): 收尾自检抽成方法, 两条 loop 路径都调 (原先只在老 loop 之后 ⇒ 生产 pivot 路径从没跑过)
1261
+ await this.runTurnEndTypecheck();
1147
1262
  // 2026-10-01: **回合后自审 (纯旁路)** —— 沉淀可复用经验, 对应"结束后沉淀"那一段。
1148
1263
  // 纪律: fire-and-forget (不阻塞主回合) · 只写 ~/.bolloon/experience/ (**不碰主对话/prompt 缓存**)
1149
1264
  // · 节流 (默认 10 分钟/agent) · 失败只记一行日志, 绝不影响本回合结果。
@@ -1172,7 +1287,7 @@ export class PiAgentSession {
1172
1287
  }
1173
1288
  catch { /* 落日志失败不影响主流程 */ }
1174
1289
  try {
1175
- this.currentOnStream?.({ type: 'status', content: `📚 复盘: ${m}`, tool: 'system' });
1290
+ this.runCtx.eventSink?.({ type: 'status', content: `📚 复盘: ${m}`, tool: 'system' });
1176
1291
  }
1177
1292
  catch { /* 上屏失败不打断 */ }
1178
1293
  };
@@ -1239,16 +1354,15 @@ export class PiAgentSession {
1239
1354
  recordJudgmentUsage(this.judgmentGateNegativeUsedIds, { userInput: input, polarity: 'negative' }).catch((err) => console.warn('[PiAgent] recordJudgmentUsage (negative) failed:', err));
1240
1355
  }
1241
1356
  this.clearJudgmentGate();
1242
- this.currentSignal = null;
1243
- this.currentOnStream = null;
1357
+ this.runCtx = createRunContext(); // K2: 用完即清 (换新 Context, 不继承残留);
1244
1358
  this.reportUsageToContextManager();
1245
1359
  }
1246
1360
  }
1247
1361
  async promptStream(input, onStream, signal, channelId) {
1248
1362
  console.log(`[PiAgent.promptStream] ENTRY, channelId=${channelId}, input chars=${input.length}`);
1249
1363
  this.minimaxAvailable = this.checkMinimax();
1250
- console.log(`[PiAgent.promptStream] minimaxAvailable=${this.minimaxAvailable}`);
1251
- this.currentChannelId = channelId ?? this.currentChannelId;
1364
+ console.log(`[PiAgent.promptStream] minimaxAvailable=${this.minimaxAvailable} nativeAdapter=${process.env.BOLLOON_NATIVE_ADAPTER === '1'} usable=${this.inferenceAvailable()}`);
1365
+ this.actor.state.channelId = channelId ?? this.actor.state.channelId;
1252
1366
  // 2026-08-08: 运行轨迹采集 (落盘 + OrbitDB, 失败静默) — 包裹 onStream 收集步骤事件
1253
1367
  const trajRec = await this.createTrajectoryRecorder(input, channelId);
1254
1368
  if (trajRec)
@@ -1261,7 +1375,7 @@ export class PiAgentSession {
1261
1375
  const userText = markerMatch ? markerMatch[1].trim() : input;
1262
1376
  const contextHint = markerMatch ? input.replace(markerMatch[0], '').trim() : '';
1263
1377
  console.log(`[PiAgent.promptStream] marker matched=${!!markerMatch}, userText chars=${userText.length}, contextHint chars=${contextHint.length}`);
1264
- this.messageHistory.push({
1378
+ this.pushHistory({
1265
1379
  role: 'user',
1266
1380
  content: userText
1267
1381
  });
@@ -1271,38 +1385,40 @@ export class PiAgentSession {
1271
1385
  // 让 agent 能"回忆起"之前 session 的记忆 (自动获取之前 session), 而非只靠启动时批量压缩.
1272
1386
  try {
1273
1387
  const { recallMemory } = await import('./memory-recall.js');
1274
- const recalled = await recallMemory({ query: userText, agentId: this.currentAgentId || this.peerId || '' });
1388
+ const recalled = await recallMemory({ query: userText, agentId: this.actor.state.agentId || this.peerId || '' });
1275
1389
  if (recalled) {
1276
1390
  this.contextHintAddition = [this.contextHintAddition, recalled].filter(Boolean).join('\n\n');
1277
1391
  }
1278
1392
  }
1279
1393
  catch { /* 记忆召回失败静默 (增强层) */ }
1280
1394
  onStream({ type: 'thinking', content: '🤔 开始思考...' });
1281
- if (!this.minimaxAvailable) {
1395
+ // K9: 流式入口同样问**当前适配器** (web/手机端就走这条路 —— 只修 pivot 那道闸时,
1396
+ // CLI 通了、web 仍然静默空回复, 现象是"适配器被选中但这条路一次没跑")。
1397
+ if (!this.inferenceAvailable()) {
1282
1398
  const response = await this.handleFallback(userText);
1283
- this.messageHistory.push({ role: 'assistant', content: response });
1399
+ this.pushHistory({ role: 'assistant', content: response });
1284
1400
  onStream({ type: 'done', content: '' });
1285
1401
  this.reportUsageToContextManager();
1286
1402
  this.finishTrajectory(trajRec, response);
1287
1403
  return response;
1288
1404
  }
1289
- // P0 注入门: 缓存 onStream + signal, computeJudgmentGate 用 currentOnStream 广播 phase
1290
- this.currentOnStream = onStream;
1291
- this.currentSignal = signal ?? null;
1405
+ // P0 注入门: 缓存 onStream + signal, computeJudgmentGate 用 runCtx.eventSink 广播 phase
1406
+ // K2: eventSink + abortSignal 一起建进本轮 Context (只搬已外置的字段, 不抄未迁移的)
1407
+ this.runCtx = this.seedRunContext({ eventSink: onStream, abortSignal: signal ?? null });
1292
1408
  await this.computeJudgmentGate(userText);
1293
1409
  // M2.2 (2026-06-17): intent 分类 — 0 LLM 成本, 5 行 keyword 匹配
1294
1410
  try {
1295
1411
  const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1296
- this.currentIntent = classifyIntent(userText);
1297
- this.currentIntentHint = intentHint(this.currentIntent);
1412
+ this.runCtx.intent = classifyIntent(userText);
1413
+ this.currentIntentHint = intentHint(this.runCtx.intent);
1298
1414
  this.currentUserInput = userText;
1299
- if (this.currentIntent !== 'chitchat') {
1300
- onStream({ type: 'phase', phase: 'intent_classified', detail: this.currentIntent, content: '' });
1415
+ if (this.runCtx.intent !== 'chitchat') {
1416
+ onStream({ type: 'phase', phase: 'intent_classified', detail: this.runCtx.intent, content: '' });
1301
1417
  }
1302
1418
  }
1303
1419
  catch (err) {
1304
1420
  console.warn('[PiAgent] classifyIntent failed (non-fatal):', err);
1305
- this.currentIntent = 'chitchat';
1421
+ this.runCtx.intent = 'chitchat';
1306
1422
  this.currentIntentHint = '';
1307
1423
  }
1308
1424
  // P1.1: 异步跑 Auto-Compact (LLM 摘要, 仅在 budget 超限时触发, 失败静默)
@@ -1319,8 +1435,8 @@ export class PiAgentSession {
1319
1435
  let bootstrapAddition = '';
1320
1436
  try {
1321
1437
  const ss = await onSessionStart({
1322
- channelId: this.currentChannelId || undefined,
1323
- agentId: this.currentAgentId || undefined,
1438
+ channelId: this.actor.state.channelId || undefined,
1439
+ agentId: this.actor.state.agentId || undefined,
1324
1440
  });
1325
1441
  bootstrapAddition = ss.systemAddition || '';
1326
1442
  }
@@ -1329,10 +1445,10 @@ export class PiAgentSession {
1329
1445
  }
1330
1446
  // 2026-07-07 P1-B: 注入最近 5 条项目事件日志 (L2) — 让 LLM 知道项目状态/feature 变化
1331
1447
  // 失败静默, append 到 bootstrapAddition 末尾 (超 800 字截断)
1332
- if (this.currentChannelId) {
1448
+ if (this.actor.state.channelId) {
1333
1449
  try {
1334
1450
  const { getRecentEvents } = await import('../bootstrap/event-log.js');
1335
- const events = await getRecentEvents(this.currentChannelId, 5);
1451
+ const events = await getRecentEvents(this.actor.state.channelId, 5);
1336
1452
  if (events.length > 0) {
1337
1453
  const eventBlock = [
1338
1454
  '## 最近项目事件 (最近 5 条, 倒序)',
@@ -1347,7 +1463,7 @@ export class PiAgentSession {
1347
1463
  // 2026-07-07 P2-C: 注入项目当前状态 (L3) — 目标/约束/待办/已完成
1348
1464
  try {
1349
1465
  const { readState, formatStateForPrompt } = await import('../bootstrap/project-state.js');
1350
- const state = await readState({ channelId: this.currentChannelId });
1466
+ const state = await readState({ channelId: this.actor.state.channelId });
1351
1467
  const stateText = formatStateForPrompt(state);
1352
1468
  if (stateText) {
1353
1469
  bootstrapAddition = (bootstrapAddition + '\n\n' + stateText).slice(-2500);
@@ -1359,7 +1475,7 @@ export class PiAgentSession {
1359
1475
  // 2026-07-07 P2-C: 向量检索 top-3 (L4) — 按当前 channelId + userText 找历史相关片段
1360
1476
  try {
1361
1477
  const { searchIndex } = await import('../bootstrap/vector-index.js');
1362
- const indexName = `channel-${this.currentChannelId}`;
1478
+ const indexName = `channel-${this.actor.state.channelId}`;
1363
1479
  const results = await searchIndex({
1364
1480
  indexName,
1365
1481
  query: userText,
@@ -1388,53 +1504,11 @@ export class PiAgentSession {
1388
1504
  this.currentPermissionMode = 'default';
1389
1505
  }
1390
1506
  this.promptStartTime = Date.now();
1391
- // M3.1 (2026-06-17): 走 WorkflowPivotLoop (usePivotLoop: true)
1392
- // pivot loop 自带 quality scoring / 30 iter cap / complexity analysis — 比老 runReActLoop 鲁棒
1393
- if (this.usePivotLoop) {
1394
- let pivotResult = '';
1395
- try {
1396
- const lr = await this.promptWithPivotLoop(userText, undefined, channelId);
1397
- pivotResult = lr.response || '';
1398
- onStream({ type: 'done', content: '' });
1399
- }
1400
- catch (err) {
1401
- if (signal?.aborted || err?.name === 'AbortError') {
1402
- console.log(`[chat] pivot aborted channel=${channelId}`);
1403
- }
1404
- else {
1405
- console.error(`[chat] pivot 失败 channel=${channelId}:`, err);
1406
- pivotResult = `[错误: pivot loop 失败] ${String(err?.message || err).slice(0, 300)}`;
1407
- try {
1408
- onStream({ type: 'error', content: pivotResult, tool: 'system' });
1409
- }
1410
- catch { }
1411
- }
1412
- }
1413
- finally {
1414
- if (this.judgmentGateUsedIds.length > 0) {
1415
- try {
1416
- onStream({ type: 'used_judgments', usedIds: this.judgmentGateUsedIds, content: '' });
1417
- }
1418
- catch { }
1419
- }
1420
- monitorAfterReply(userText, pivotResult);
1421
- const stopStartTime = this.promptStartTime || Date.now();
1422
- onStop({
1423
- channelId: this.currentChannelId || 'unknown',
1424
- durationMs: Date.now() - stopStartTime,
1425
- usedJudgmentIds: [...this.judgmentGateUsedIds],
1426
- }).catch((err) => console.warn('[PiAgent] onStop failed:', err));
1427
- this.clearJudgmentGate();
1428
- this.currentOnStream = null;
1429
- this.currentSignal = null;
1430
- this.bootstrapAddition = '';
1431
- this.contextHintAddition = '';
1432
- this.promptStartTime = 0;
1433
- this.reportUsageToContextManager();
1434
- }
1435
- this.finishTrajectory(trajRec, pivotResult);
1436
- return pivotResult;
1437
- }
1507
+ // K4-B (2026-10-02): **流式路径也只剩一条** —— 原先这里有个 pivot 提前 return 分支,
1508
+ // 它自己的 finally 是**子集** (且少了 `contextHintAddition` 清理)。现在统一走下面的
1509
+ // "外层重试 + 完整收尾", 而重试体里跑的是 pivot (老 `runReActLoop` 已删除)。
1510
+ // 重试语义**刻意保留**: 临时网络抖动/配额瞬时超限可自愈, 且状态文案被
1511
+ // `reply-hygiene.test.ts` 与 `web-loop-status-bar.spec.ts` 两个用例锁着。
1438
1512
  // 2026-06-16: loop 自动重试 — runReActLoop 内部遇到 [AI 服务调用失败] sentinel 时,
1439
1513
  // 会设 aiFailed=true 并提前 break. 这里在外层重跑整个 loop (不是单次 LLM 调用),
1440
1514
  // 临时网络抖动 / 配额瞬时超限可自愈. 最多 3 次, 指数退避 1s/2s/4s.
@@ -1445,19 +1519,20 @@ export class PiAgentSession {
1445
1519
  let lastAiFailureReason = '';
1446
1520
  while (attempt <= MAX_LOOP_RETRIES) {
1447
1521
  try {
1448
- const loopResult = await this.runReActLoop(onStream, signal);
1449
- result = loopResult.reply;
1522
+ const lr = await this.promptWithPivotLoop(userText, undefined, channelId);
1523
+ result = lr.response || '';
1524
+ // K4-B: 收尾自检在**这条**路径上也要跑 (原先只有 pivot 分支跑, 而 pivot 分支已被合并掉)
1525
+ await this.runTurnEndTypecheck();
1450
1526
  // 持久化失败不重试 (写不进去就是写不进去): 直接按本次结果收尾
1451
1527
  if (this.runPersistenceBlocked)
1452
1528
  break;
1453
- if (!loopResult.aiFailed)
1529
+ if (lr.success)
1454
1530
  break; // 正常完成, 退出 retry 循环
1455
- lastAiFailureReason = loopResult.aiFailureReason || 'AI 调用失败';
1531
+ lastAiFailureReason = String(lr.exitReason || 'AI 调用失败');
1456
1532
  }
1457
1533
  catch (err) {
1458
1534
  // abort 失败: 视作"已中断", 抛错让上层用 partial 兜底
1459
- this.currentOnStream = null;
1460
- this.currentSignal = null;
1535
+ this.runCtx = createRunContext(); // K2: 用完即清 (换新 Context, 不继承残留);
1461
1536
  throw err;
1462
1537
  }
1463
1538
  attempt++;
@@ -1495,8 +1570,8 @@ export class PiAgentSession {
1495
1570
  // 因为 messageHistory.push({role:'user'}) 在 promptStream 顶部已经做过, 重跑 runReActLoop 不会重复 push,
1496
1571
  // 但 assistant 失败那条也别留 (留了会污染下一轮 LLM context).
1497
1572
  // 简化: 重试前 pop 一次 assistant (如果最后一条是 assistant)
1498
- if (this.messageHistory.length > 0 && this.messageHistory[this.messageHistory.length - 1].role === 'assistant') {
1499
- this.messageHistory.pop();
1573
+ if (this.actor.state.messageHistory.length > 0 && this.actor.state.messageHistory[this.actor.state.messageHistory.length - 1].role === 'assistant') {
1574
+ this.popHistory();
1500
1575
  }
1501
1576
  }
1502
1577
  onStream({ type: 'done', content: '' });
@@ -1514,24 +1589,130 @@ export class PiAgentSession {
1514
1589
  // Bootstrap Stop hook: fire-and-forget 写本次 session 摘要
1515
1590
  const stopStartTime = this.promptStartTime || Date.now();
1516
1591
  onStop({
1517
- channelId: this.currentChannelId || 'unknown',
1592
+ channelId: this.actor.state.channelId || 'unknown',
1518
1593
  durationMs: Date.now() - stopStartTime,
1519
1594
  usedJudgmentIds: [...this.judgmentGateUsedIds],
1520
1595
  }).catch((err) => console.warn('[PiAgent] onStop failed:', err));
1521
1596
  // 用完即清, 避免污染下一轮
1522
1597
  this.clearJudgmentGate();
1523
- this.currentOnStream = null;
1598
+ this.runCtx = createRunContext(); // K2: 用完即清 (换新 Context, 不继承残留);
1524
1599
  this.reportUsageToContextManager();
1525
- this.currentSignal = null;
1526
1600
  this.bootstrapAddition = '';
1601
+ this.contextHintAddition = ''; // K4-B: 与被合并掉的那条分支对齐 (它清了这条, 老尾段漏了)
1527
1602
  this.promptStartTime = 0;
1528
1603
  // 2026-08-08: 轨迹收尾 — 落盘 + OrbitDB (失败静默, 不阻塞回复)
1529
1604
  this.finishTrajectory(trajRec, result);
1530
1605
  return result;
1531
1606
  }
1607
+ /**
1608
+ * K9 (2026-10-02): 推理适配器的**唯一选择点** —— "用哪个适配器"只在这里决定一次。
1609
+ *
1610
+ * `BOLLOON_NATIVE_ADAPTER=1` ⇒ 用**非 Pi** 的 native 适配器 (`src/llm/native-adapter.ts`,
1611
+ * 源码里不 import 任何 Pi 模块, 只用 fetch 直连 OpenAI 兼容端点);
1612
+ * 否则 ⇒ Pi 侧模型 (原行为, 默认不变)。
1613
+ *
1614
+ * 关键: 上层 (pivot loop / Harness 门 / Run / Channel) **一行都不用改** ——
1615
+ * 这正是 K10 撤换判据里「**出现第二个推理适配器能过同一套门**」要证的东西。
1616
+ */
1617
+ /** K9: 当前适配器是否可用 (native 开关打开且拿到 key ⇒ 用它自己的判据, 不再借 Pi 的) */
1618
+ inferenceAvailable() {
1619
+ if (process.env.BOLLOON_NATIVE_ADAPTER === '1' && nativeAdapterFromEnv())
1620
+ return true;
1621
+ return this.minimaxAvailable;
1622
+ }
1623
+ // ── K10 ②: 内核模型运行时的接缝 ────────────────────────────────────────────
1624
+ /** 进程内的内核运行时 (懒建)。传输由 `openPiConnection` 注入 ⇒ 真传输仍是 Pi 的客户端 */
1625
+ runtimeSingleton;
1626
+ kernelModelRuntime() {
1627
+ if (!this.runtimeSingleton) {
1628
+ this.runtimeSingleton = new ModelRuntime({ openConnection: async (snapshot) => this.openPiConnection(snapshot) }, 30_000, 4);
1629
+ }
1630
+ return this.runtimeSingleton;
1631
+ }
1632
+ /** 端口实现: "开一条连接" = 拿 Pi 的 LLM 客户端 (进程级单例, 由 installRuntime 装配) */
1633
+ async openPiConnection(snapshot) {
1634
+ const client = getMinimax();
1635
+ return {
1636
+ id: `${snapshot.provider}:${snapshot.model}`,
1637
+ call: async (req) => {
1638
+ try {
1639
+ const raw = await client.chat(req.context, req.systemPrompt, req.signal, req.tools, req.purpose, req.source);
1640
+ return { ok: true, provider: snapshot.provider, raw };
1641
+ }
1642
+ catch (err) {
1643
+ return { ok: false, provider: snapshot.provider, error: String(err?.message ?? err).slice(0, 300) };
1644
+ }
1645
+ },
1646
+ // Pi 客户端是**进程级单例** ⇒ 不随连接关闭 (连接池负责复用/计数)
1647
+ close: async () => { },
1648
+ };
1649
+ }
1650
+ /**
1651
+ * K10 ②: 用内核运行时取一个**租约**并适配成 `LLMInterface`。
1652
+ * 拿不到有效快照 / acquire 失败 ⇒ 返回 null ⇒ 调用方**如实记**并回落 Pi 直连 (不假装走了内核)。
1653
+ */
1654
+ async kernelLeaseAdapter() {
1655
+ let snapshot = null;
1656
+ try {
1657
+ const eff = await captureRunModelConfig();
1658
+ snapshot = snapshotFromSelection({ provider: eff.provider, model: eff.model, baseUrl: eff.baseUrl });
1659
+ }
1660
+ catch {
1661
+ return null;
1662
+ }
1663
+ if (!snapshot)
1664
+ return null;
1665
+ // ⚠️ 踩过的坑 (2026-10-02): 一开始在**适配器创建时**取一个租约、每次 chat 后 `lease.release()`
1666
+ // ⇒ 第二次 chat 撞上「租约已归还」(model-runtime.ts:278 的守卫) ⇒ 适配器抛错 ⇒ 回合在第 2 轮就死
1667
+ // (实测: k7-tsc-tool-e2e 允许路/拒绝路判红, 而强制 Pi 直连全绿)。
1668
+ // 正确姿势: **每次调用现取租约** (连接由池复用, 取租约几乎零成本), 用完立刻归还。
1669
+ const runtime = this.kernelModelRuntime();
1670
+ return {
1671
+ chat: async (context, systemPrompt, signal, tools, purpose, source) => {
1672
+ let lease;
1673
+ try {
1674
+ lease = await runtime.acquire(snapshot);
1675
+ }
1676
+ catch (err) {
1677
+ throw new Error(`[kernel-model-runtime] acquire 失败: ${String(err?.message ?? err).slice(0, 160)}`);
1678
+ }
1679
+ try {
1680
+ const res = await lease.call({ context, systemPrompt, signal, tools, purpose, source }, { signal });
1681
+ if (!res.ok)
1682
+ throw new Error(res.error || '内核模型调用失败');
1683
+ return res.raw;
1684
+ }
1685
+ finally {
1686
+ lease.release(); // 归还租约 (连接由池管理, 不关)
1687
+ }
1688
+ },
1689
+ };
1690
+ }
1691
+ async inferenceAdapter() {
1692
+ if (process.env.BOLLOON_NATIVE_ADAPTER === '1') {
1693
+ const a = nativeAdapterFromEnv();
1694
+ if (a) {
1695
+ console.log(`[PiAgent] 推理适配器 = ${a.providerId} (非 Pi: fetch 直连, 不 import Pi)`);
1696
+ return a;
1697
+ }
1698
+ console.warn('[PiAgent] BOLLOON_NATIVE_ADAPTER=1 但没拿到 key ⇒ 回落 Pi 适配器 (不假装切换成功)');
1699
+ }
1700
+ // K10 ②: 先向**内核运行时**取租约 (只读 `acquire(snapshot)`) —— 超时/取消/退避/熔断/回退/记账由内核负责;
1701
+ // 传输仍是 Pi 客户端 (端口注入) ⇒ 行为不变。取不到就**如实记**并回落 Pi 直连 (不假装走了内核)。
1702
+ const leased = await this.kernelLeaseAdapter();
1703
+ if (leased) {
1704
+ console.log('[PiAgent] 推理适配器 = kernel-model-runtime (只读 acquire 租约)');
1705
+ return leased;
1706
+ }
1707
+ console.warn('[PiAgent] 内核租约不可用 ⇒ 回落 Pi 直连 (如实记, 不假装走了内核)');
1708
+ return getMinimax();
1709
+ }
1532
1710
  async promptWithPivotLoop(input, config, channelId) {
1533
- this.currentChannelId = channelId ?? this.currentChannelId;
1534
- if (!this.minimaxAvailable) {
1711
+ this.actor.state.channelId = channelId ?? this.actor.state.channelId;
1712
+ // K9 (2026-10-02): **可用性判据必须问"当前适配器"**, 不能无条件问 Pi ——
1713
+ // 原先这里是 `if (!this.minimaxAvailable)`, 于是"换成第二个(非 Pi)适配器"仍然被 **Pi 的可用性**卡死
1714
+ // (实跑现象: 适配器被选中、但 loop 一次都没跑、回复为空)。这类"隐式仍依赖 Pi"的点正是 K10 要清的。
1715
+ if (!this.inferenceAvailable()) {
1535
1716
  const response = await this.handleFallback(input);
1536
1717
  return {
1537
1718
  success: false,
@@ -1551,8 +1732,100 @@ export class PiAgentSession {
1551
1732
  }
1552
1733
  };
1553
1734
  }
1554
- const llm = getMinimax();
1555
- const loopConfig = config || this.pivotLoopConfig || createDefaultPivotConfig();
1735
+ const llm = await this.inferenceAdapter(); // K10 ②: 可能要向内核运行时取租约 (异步)
1736
+ // K4-B (2026-10-02): **每次模型调用也要过门面** —— `beforeModelCall`/`afterModelCall` 原先**只**由老
1737
+ // `runReActLoop` 调用; 老 loop 删除后全仓零调用者, 而 pivot (生产: web/CLI 现在都走它) **从来没调过**
1738
+ // ⇒ 模型调用的前后留痕一直是断的。这里把门面包在 `llm` **外面** ⇒ pivot 内部一行不用改。
1739
+ // ⚠️ 踩过的坑 (2026-10-02, 当场复现): 一开始把 `llm` 换成**纯函数**包一层 ⇒ 第 1 轮就空回复。
1740
+ // 原因: pivot 调的是 **`llm.chat(...)`** (对象方法), 不是 `llm(...)` ⇒ 换掉对象等于把 `.chat` 抹了。
1741
+ // 正确做法: 以**原型继承**造一个代理对象, 只覆写 `chat`, 其余成员照旧从原型上取。
1742
+ const llmWithHarness = Object.create(llm);
1743
+ llmWithHarness.chat = async (...args) => {
1744
+ try {
1745
+ this.piHarness().beforeModelCall(this.harnessCtx());
1746
+ }
1747
+ catch { /* 门面异常不改变模型调用本身 */ }
1748
+ const __modelT0 = Date.now();
1749
+ try {
1750
+ return await llm.chat(...args);
1751
+ }
1752
+ finally {
1753
+ try {
1754
+ this.piHarness().afterModelCall(this.harnessCtx(), { ms: Date.now() - __modelT0 });
1755
+ }
1756
+ catch { /* 同上 */ }
1757
+ }
1758
+ };
1759
+ const baseConfig = config || this.pivotLoopConfig || createDefaultPivotConfig();
1760
+ // 2026-10-02 (K7 第二步 b): pivot loop 的工具执行**必须与主路径同一个门**。
1761
+ // 接线前: pivot loop 在 workflow-pivot-loop.ts:613 直调 tool.execute —— 那条路径上
1762
+ // 没有任何 harness 调用 (K7 台账里的**真旁路** ①); 主路径则在 runReActLoop 内先过
1763
+ // `beforeToolCall` (顺序: deny → pre-tool-validator(4 步链) → react-harness(8-gate))。
1764
+ // 这里把同一个判定包成端口注入 ⇒ 两处执行共用一扇门。
1765
+ // **fail-closed**: 门面抛错 ⇒ 拒绝执行 (绝不回落成直调; 旧实现有过两条 fail-open 路径)。
1766
+ const loopConfig = {
1767
+ ...baseConfig,
1768
+ guardedExecute: async (tool, args) => {
1769
+ let decision;
1770
+ try {
1771
+ decision = await this.piHarness().beforeToolCall({
1772
+ tool: tool.name,
1773
+ args,
1774
+ ctx: this.harnessCtx(),
1775
+ permissionMode: this.currentPermissionMode,
1776
+ });
1777
+ }
1778
+ catch (err) {
1779
+ return {
1780
+ success: false,
1781
+ error: `拒绝: [harness-error] Harness 门面异常: ${String(err?.message || err).slice(0, 150)}`,
1782
+ };
1783
+ }
1784
+ if (!decision.allow) {
1785
+ return {
1786
+ success: false,
1787
+ error: `拒绝: [${decision.rejectedBy || decision.source || 'unknown'}] ${decision.reason}`,
1788
+ };
1789
+ }
1790
+ // K4-B (2026-10-02): **改过 TS 就记账** —— 这笔账原先只记在**老 loop 的工具分发**里,
1791
+ // pivot 路径没有 ⇒ 默认切 pivot 后 pivot 里编辑了 TS 却不记账 ⇒ 收尾自检永远不触发
1792
+ // (与"自检只挂在老 loop"同族的缺陷: 同一件事只在一套 loop 里实现)。
1793
+ // 补在 pivot 的**唯一执行点**上 ⇒ 两条 loop 口径一致。
1794
+ {
1795
+ const tsFile = codeWriteTarget(tool.name, args);
1796
+ if (tsFile && !this.tsTouchedThisTurn.includes(tsFile))
1797
+ this.tsTouchedThisTurn.push(tsFile);
1798
+ }
1799
+ const execResult = await tool.execute(args);
1800
+ // K4-B (2026-10-02): **工具执行后的门面阶段必须跟上** —— `afterToolCall` (输出门:
1801
+ // 敏感信息拦截 / router hint) 原先**只**在老 `runReActLoop` 的工具分发里调用;
1802
+ // 老 loop 删除后生产里**一个调用者都没有** ⇒ 输出门形同停用 (安全回归)。补在 pivot 的唯一执行点上。
1803
+ // 门面内部对"gate 自己失败"的策略是**放行但留痕** (输出已产生, 拦不住源头) ⇒ 这里同样不改写结论。
1804
+ try {
1805
+ const after = await this.piHarness().afterToolCall({
1806
+ tool: tool.name,
1807
+ output: String(execResult?.output ?? ''),
1808
+ ctx: this.harnessCtx(),
1809
+ ok: execResult?.success !== false,
1810
+ });
1811
+ if (after?.outputBlocked) {
1812
+ // API 契约: "输出被 gate 拦下 ⇒ 调用方要把 result.output 换掉"
1813
+ const blocked = `[输出已被门拦下: ${after.outputBlocked.reason}] (原始输出未交付)`;
1814
+ if (execResult && typeof execResult === 'object')
1815
+ execResult.output = blocked;
1816
+ }
1817
+ if (after?.routeHint?.systemAddition) {
1818
+ // 差量如实记: 老 loop 把它拼进**下一轮的 system prompt**; pivot 没有对应的注入端口,
1819
+ // 这里改拼进**工具结果文本** —— 模型同样能在下一步看到 (送达渠道不同, 效果等价)。
1820
+ const hint = `\n[路由提示] ${after.routeHint.systemAddition}`;
1821
+ if (execResult && typeof execResult === 'object')
1822
+ execResult.output = String(execResult.output ?? '') + hint;
1823
+ }
1824
+ }
1825
+ catch { /* 后置阶段失败不改变已做出的执行 (门面内部已按策略留痕) */ }
1826
+ return execResult;
1827
+ },
1828
+ };
1556
1829
  const loop = new WorkflowPivotLoop(loopConfig);
1557
1830
  for (const tool of this.tools.values()) {
1558
1831
  loop.registerTool(tool);
@@ -1562,8 +1835,8 @@ export class PiAgentSession {
1562
1835
  // M2.2 (2026-06-17): intent 分类 — pivot loop 也要拿到 hint
1563
1836
  try {
1564
1837
  const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1565
- this.currentIntent = classifyIntent(input);
1566
- this.currentIntentHint = intentHint(this.currentIntent);
1838
+ this.runCtx.intent = classifyIntent(input);
1839
+ this.currentIntentHint = intentHint(this.runCtx.intent);
1567
1840
  this.currentUserInput = input;
1568
1841
  }
1569
1842
  catch (err) {
@@ -1610,21 +1883,21 @@ ${await this.renderActivePlansSection()}
1610
1883
  - 工具调用前可以简短思考 (1-2 句话), 但**不要写长篇 thinking** (会撞 max_tokens)
1611
1884
  - 工具调用后必须等结果, 不要在同一个回复里继续输出
1612
1885
  - <final gen> 只在**真完成所有任务**时输出, 不要在工具调用前/中输出${this.judgmentGateAddition}${this.contextHintAddition}`;
1613
- // 2026-06-15: 把 currentOnStream 传给 loop, 让 step-timeline 在 pivot 循环里也能 emit step_start/done
1886
+ // 2026-06-15: 把 runCtx.eventSink 传给 loop, 让 step-timeline 在 pivot 循环里也能 emit step_start/done
1614
1887
  // 之前 loop.execute() 不接 streamCallback, 导致 step-timeline 只能看到老 runReActLoop 路径
1615
1888
  // promptWithPivotLoop 路径 0 step events — UI 显示 timeline 但永远是空
1616
1889
  // 2026-06-17: 透传 signal 让 abort 工作 — loop.execute() 当前不接 signal 参数,
1617
1890
  // 所以 abort 行为通过 this.currentSignal 共享给 loop 内部读 (后续 M3.2 接 task plan 时一起加)
1618
1891
  // 2026-07-06: pivot 内 token 累计到 ~70% 时回调 (workflow-pivot-loop.ts line 270+).
1619
- // pivot 的 messageHistory 是 process-local, 不和 pi-sdk 的 this.messageHistory 同步.
1892
+ // pivot 的 messageHistory 是 process-local, 不和 pi-sdk 的 actor 的 messageHistory 同步.
1620
1893
  // 真正折叠需要把 pi-sdk 历史灌回 pivot 的 history 数组 — 侵入较大.
1621
1894
  // 当前 priority: 临时传空实现, 让 budget 公式放够 (workflow-pivot-loop.ts line 220)
1622
1895
  // 不再撞预算. 这条路径留作技术债.
1623
1896
  // 2026-07-17 Bug 1 修: 注入 messageHistory (hydrateMessageHistory 从 session JSON 回灌的) 到 system prompt
1624
- // pivot loop execute() 内部自己维护 messageHistory, 跟 pi-sdk 的 this.messageHistory 隔离,
1897
+ // pivot loop execute() 内部自己维护 messageHistory, 跟 pi-sdk 的 actor 的 messageHistory 隔离,
1625
1898
  // 不注入的话 LLM 看不到历史对话, 每次都是新对话.
1626
1899
  const historyLines = [];
1627
- const historyToInject = this.messageHistory.slice(-20, -1);
1900
+ const historyToInject = this.actor.state.messageHistory.slice(-20, -1);
1628
1901
  for (const m of historyToInject) {
1629
1902
  const roleLabel = m.role === 'user' ? '用户' : m.role === 'assistant' ? '你' : m.role === 'tool' ? '工具结果' : m.role;
1630
1903
  const text = (m.content || '').slice(0, 2000);
@@ -1637,9 +1910,37 @@ ${await this.renderActivePlansSection()}
1637
1910
  const onCompact = async () => {
1638
1911
  // no-op (best-effort hook for future pi-sdk/pivot history sync)
1639
1912
  };
1640
- const result = await loop.execute(input, llm, systemPrompt + historyBlock, this.currentOnStream ?? undefined, this.currentSignal ?? undefined, onCompact);
1913
+ // K4-B (2026-10-02): **run 生命周期也要过门面** —— `sessionStart`/`sessionEnd` 原先只由老
1914
+ // `runReActLoop` 调用 (删除后全仓零调用者), 而 pivot (生产) 从来没调过 ⇒ 这里恢复。
1915
+ await this.piHarness().sessionStart(this.harnessCtx());
1916
+ let result;
1917
+ try {
1918
+ result = await loop.execute(input, llmWithHarness, systemPrompt + historyBlock, this.runCtx.eventSink ?? undefined, this.runCtx.abortSignal ?? undefined, onCompact);
1919
+ }
1920
+ finally {
1921
+ try {
1922
+ await this.piHarness().sessionEnd(this.harnessCtx());
1923
+ }
1924
+ catch { /* 收尾留痕失败不改变本次结论 */ }
1925
+ }
1926
+ // K4-B (2026-10-02): `reviewFinal` (收尾目标对齐审查) 原先同样只在老 loop 里调 ⇒ 恢复**调用与留痕**。
1927
+ // ⚠️ 如实记边界: pivot 的收尾仍由它自己的质量判定决定, 这里**不**按 review 的返回改流程
1928
+ // (老 loop 会据它自动续跑一轮); 且这里给的是 pivot 手上**真有的**数据 (工具调用计数),
1929
+ // `completedTools`/`actionLog` 的逐条清单 pivot 侧目前没有 ⇒ 传空并留 TODO, **不编数据**。
1930
+ try {
1931
+ const reviewDecision = this.piHarness().reviewFinal({
1932
+ reviewsDone: 0,
1933
+ userIntent: this.currentUserInput,
1934
+ completedTools: [],
1935
+ actionLog: [],
1936
+ runId: this.actor.state.activeRun || undefined,
1937
+ goalId: this.actor.state.goalBinding || undefined,
1938
+ });
1939
+ console.log(`[PiAgent] reviewFinal (K4-B 恢复调用, 判定不参与流程): ${JSON.stringify(reviewDecision).slice(0, 160)}`);
1940
+ }
1941
+ catch { /* 审查异常不改变本次结论 (门面内部对"审查失效"自有留痕策略) */ }
1641
1942
  if (result.response) {
1642
- this.messageHistory.push({ role: 'assistant', content: result.response });
1943
+ this.pushHistory({ role: 'assistant', content: result.response });
1643
1944
  }
1644
1945
  // 回溯 + 清场
1645
1946
  if (this.judgmentGateUsedIds.length > 0) {
@@ -1655,7 +1956,9 @@ ${await this.renderActivePlansSection()}
1655
1956
  */
1656
1957
  _harness = null;
1657
1958
  /** M2 绑定 GoalStore 后填真值; 在此之前为空 (事件里 goalId 字段已就位) */
1658
- currentGoalId = '';
1959
+ // **K5 步骤③/⑧**: 会话的 Goal 默认绑定本体在 `actor.state.goalBinding` (访问器已删, 直接读本体)。
1960
+ // leo 口径不变: 这只是默认值; 每次执行开始必须把最终绑定写进 RunContext/Run 记录,
1961
+ // 运行中重绑必须走显式 Goal Binding 操作, 不许靠裸字段隐式生效。
1659
1962
  /** 2026-09-16 (M2): 本次执行是"从 checkpoint 恢复"的 runId (非空 = 恢复模式, 不再新建 run) */
1660
1963
  resumeRunId = '';
1661
1964
  resumePlan = null;
@@ -1678,32 +1981,47 @@ ${await this.renderActivePlansSection()}
1678
1981
  }
1679
1982
  /** CLI / Web 注入"当前目标" (有 goalId 就在该 Goal 下执行) */
1680
1983
  setGoalId(goalId) {
1681
- this.currentGoalId = String(goalId || '');
1984
+ this.actor.state.goalBinding = String(goalId || '');
1682
1985
  }
1683
- /** 核心状态迁移的兜底: 失败 → 记降级 + 交给循环顶部的持久化硬闸 (不吞错) */
1986
+ /**
1987
+ * K10 ①: 控制面的**注入面** —— 写原语来自 run-store, 但调用一律经 `submitRunControl`
1988
+ * (与 `transportPorts()` 同一个模式: 换实现只改这一处, 上层不直接对 store 说话)。
1989
+ */
1990
+ runControlPorts() {
1991
+ return {
1992
+ setRunStatus: setRunStatus,
1993
+ recordRecovery: recordRecovery,
1994
+ };
1995
+ }
1996
+ /**
1997
+ * 核心状态迁移的兜底: 失败 → 记降级 + 交给循环顶部的持久化硬闸 (不吞错)。
1998
+ * K10 ①: 迁移动作经内核控制面; **口径不变** ——
1999
+ * `{ok:false}` 且原因是"端口拒绝"(状态迁移不合法) ⇒ 只返回 false (那不是持久化故障);
2000
+ * 未注入 / 抛错 ⇒ 才算持久化失败, 记降级 (与旧实现同款)。
2001
+ */
1684
2002
  async safeSetRunStatus(runId, to) {
1685
- try {
1686
- const r = await setRunStatus(runId, to);
1687
- return !!r.ok;
1688
- }
1689
- catch (err) {
1690
- this.persistenceFailure = `状态迁移失败 (${to}): ${String(err?.message || err).slice(0, 180)}`;
2003
+ const out = await submitRunControl({ kind: 'set-run-status', origin: 'pi-session', runId, payload: { status: to } }, this.runControlPorts());
2004
+ if (out.ok)
2005
+ return true;
2006
+ const detail = String(out.detail || '未知原因');
2007
+ if (!detail.startsWith('端口拒绝')) {
2008
+ this.persistenceFailure = `状态迁移失败 (${to}): ${detail.slice(0, 180)}`;
1691
2009
  await recordDegradation({ kind: 'core', op: 'pi-sdk.setRunStatus', runId, message: this.persistenceFailure }).catch(() => { });
1692
- return false;
1693
2010
  }
2011
+ return false;
1694
2012
  }
1695
2013
  /**
1696
2014
  * 2026-09-16 (M3): 工具失败接线 —— 分类 → recovery 留痕 → 熔断 / 外部等待。
1697
2015
  * 这是"错误恢复闭环"的运行时接线点 (此前 recordRecovery / repeatedFailureCount 只是数据结构)。
1698
2016
  */
1699
2017
  async wireToolFailure(tool, errorText, args) {
1700
- if (!this.currentRunId)
2018
+ if (!this.actor.state.activeRun)
1701
2019
  return;
1702
2020
  const cls = classifyRunError(errorText);
1703
2021
  const digest = argsDigestOf(args);
1704
2022
  let repeats = 1;
1705
2023
  try {
1706
- const rec = await readRun(this.currentRunId);
2024
+ const rec = await readRun(this.actor.state.activeRun);
1707
2025
  // recordStep 已经把**这次**失败记进去了, 所以"连续失败次数"直接就是当前值 (不再 +1)
1708
2026
  const seen = rec ? repeatedFailureCount(rec, tool, digest) : 0;
1709
2027
  repeats = Math.max(seen, 1);
@@ -1714,7 +2032,7 @@ ${await this.renderActivePlansSection()}
1714
2032
  : cls === 'external_no_reply' ? 'pause'
1715
2033
  : 'retry';
1716
2034
  try {
1717
- await recordRecovery(this.currentRunId, {
2035
+ await recordRecovery(this.actor.state.activeRun, {
1718
2036
  errorClass: cls,
1719
2037
  message: `${tool}: ${errorText}`.slice(0, 200),
1720
2038
  action,
@@ -1723,20 +2041,20 @@ ${await this.renderActivePlansSection()}
1723
2041
  });
1724
2042
  }
1725
2043
  catch (err) {
1726
- await recordDegradation({ kind: 'core', op: 'pi-sdk.recordRecovery', runId: this.currentRunId, message: String(err?.message || err) }).catch(() => { });
2044
+ await recordDegradation({ kind: 'core', op: 'pi-sdk.recordRecovery', runId: this.actor.state.activeRun, message: String(err?.message || err) }).catch(() => { });
1727
2045
  }
1728
2046
  // 外部无响应 → awaiting_external (不算失败; 等回话后回到 running)
1729
2047
  if (cls === 'external_no_reply') {
1730
2048
  this.awaitingExternal = true;
1731
- await this.safeSetRunStatus(this.currentRunId, 'awaiting_external');
2049
+ await this.safeSetRunStatus(this.actor.state.activeRun, 'awaiting_external');
1732
2050
  // 2026-09-16 (2-C.4): 把"在等什么"写成持久化事实 (来源/关联/过期), 否则真实回包到了也不知道该唤醒谁。
1733
2051
  try {
1734
- if (this.currentGoalId) {
2052
+ if (this.actor.state.goalBinding) {
1735
2053
  const { bindExternalWait, newContinuationId, defaultWaitExpiry } = await import('./external-events.js');
1736
2054
  const isDelegate = /delegate/i.test(String(tool || ''));
1737
- await bindExternalWait(this.currentGoalId, {
1738
- requestId: this.pendingExternalRequestId || `${this.currentRunId}:${Date.now().toString(36)}`,
1739
- continuationId: newContinuationId(this.currentGoalId),
2055
+ await bindExternalWait(this.actor.state.goalBinding, {
2056
+ requestId: this.pendingExternalRequestId || `${this.actor.state.activeRun}:${Date.now().toString(36)}`,
2057
+ continuationId: newContinuationId(this.actor.state.goalBinding),
1740
2058
  expectedSource: isDelegate ? 'delegate' : 'p2p',
1741
2059
  expectedEvent: 'result',
1742
2060
  createdAt: new Date().toISOString(),
@@ -1746,31 +2064,31 @@ ${await this.renderActivePlansSection()}
1746
2064
  }
1747
2065
  }
1748
2066
  catch (e) {
1749
- await recordDegradation({ kind: 'core', op: 'pi-sdk.bindExternalWait', runId: this.currentRunId, message: String(e?.message || e) }).catch(() => { });
2067
+ await recordDegradation({ kind: 'core', op: 'pi-sdk.bindExternalWait', runId: this.actor.state.activeRun, message: String(e?.message || e) }).catch(() => { });
1750
2068
  }
1751
2069
  return;
1752
2070
  }
1753
2071
  // 鉴权类: 不重试, 直接交人
1754
2072
  if (cls === 'auth') {
1755
2073
  this.breakerReason = `鉴权类错误不重试 (${tool}): ${errorText.slice(0, 120)}`;
1756
- await this.safeSetRunStatus(this.currentRunId, 'needs_human');
2074
+ await this.safeSetRunStatus(this.actor.state.activeRun, 'needs_human');
1757
2075
  return;
1758
2076
  }
1759
2077
  // 重复失败熔断: 同一工具 + 同一组参数连续失败达 3 次
1760
2078
  if (repeats >= MAX_SAME_TOOL_FAILURES) {
1761
2079
  this.breakerReason = `同一工具 ${tool} 连续失败 ${repeats} 次 (${cls}) → 熔断, 不再重试`;
1762
- await this.safeSetRunStatus(this.currentRunId, 'needs_human');
2080
+ await this.safeSetRunStatus(this.actor.state.activeRun, 'needs_human');
1763
2081
  }
1764
2082
  }
1765
2083
  getRunId() {
1766
- return this.currentRunId;
2084
+ return this.actor.state.activeRun;
1767
2085
  }
1768
2086
  /**
1769
2087
  * 2026-09-16 (M2-B): **上一次**运行的 runId —— prompt 收尾时 `currentRunId` 会被清空,
1770
2088
  * 而 Supervisor / 控制面要在运行结束后才知道"刚才跑的是哪条 run" (否则会拿旧 run 做决策)。
1771
2089
  */
1772
2090
  getLastRunId() {
1773
- return this.lastRunId || this.currentRunId;
2091
+ return this.lastRunId || this.actor.state.activeRun;
1774
2092
  }
1775
2093
  /**
1776
2094
  * 2026-09-16 (M2): 从 checkpoint 恢复一次运行 —— **不是**重发原 prompt。
@@ -1817,7 +2135,7 @@ ${await this.renderActivePlansSection()}
1817
2135
  }
1818
2136
  this.resumeRunId = runId;
1819
2137
  this.resumePlan = prep.plan;
1820
- this.currentGoalId = prep.plan.goalId || this.currentGoalId;
2138
+ this.actor.state.goalBinding = prep.plan.goalId || this.actor.state.goalBinding;
1821
2139
  try {
1822
2140
  const reply = await this.prompt(buildResumeInstruction(prep.plan), {});
1823
2141
  return { ok: true, reply, ...(modelDrift ? { modelDrift } : {}), ...(modelApplied ? { modelApplied } : {}) };
@@ -1827,6 +2145,27 @@ ${await this.renderActivePlansSection()}
1827
2145
  this.resumePlan = null;
1828
2146
  }
1829
2147
  }
2148
+ /**
2149
+ * 2026-10-02 (K7): 把**本会话的 Harness 判定**包成 skill 门, 供 `BollharnessIntegration.setSkillGuard` 注入。
2150
+ * - 只返回**判定**, 不执行 skill ⇒ "允许后执行"留在 SkillAdapter 的**唯一**执行点 (恰一次)。
2151
+ * - 身份 (runId/goalId/agentId/channelId/surface) 由 `harnessCtx()` 带齐。
2152
+ * - 不在这里 catch: 门面抛错由 adapter 侧 **fail-closed** 处理 (拒执行, 绝不回落 registry)。
2153
+ */
2154
+ createSkillGuard() {
2155
+ return async (name, params) => {
2156
+ const decision = await this.piHarness().beforeToolCall({
2157
+ tool: `skill:${name}`,
2158
+ args: params,
2159
+ ctx: this.harnessCtx(),
2160
+ permissionMode: this.currentPermissionMode,
2161
+ });
2162
+ return {
2163
+ allow: decision.allow,
2164
+ reason: decision.reason,
2165
+ rejectedBy: decision.rejectedBy || decision.source,
2166
+ };
2167
+ };
2168
+ }
1830
2169
  piHarness() {
1831
2170
  if (!this._harness) {
1832
2171
  this._harness = new PiAgentHarness({
@@ -1836,8 +2175,8 @@ ${await this.renderActivePlansSection()}
1836
2175
  // pre-tool-validator 4 步链 (modeGate/blacklist/shell-guard/schema), 经 human-value-pipeline 包装
1837
2176
  preToolUse: async (o) => onPreToolUse({ tool: o.tool, args: o.args, permissionMode: o.permissionMode }),
1838
2177
  // 事件写 Run: 观测级 (记账失败不改变已做出的决策)
1839
- events: (e) => { if (this.currentRunId)
1840
- void recordHarnessEvent(this.currentRunId, e); },
2178
+ events: (e) => { if (this.actor.state.activeRun)
2179
+ void recordHarnessEvent(this.actor.state.activeRun, e); },
1841
2180
  });
1842
2181
  }
1843
2182
  return this._harness;
@@ -1845,1134 +2184,69 @@ ${await this.renderActivePlansSection()}
1845
2184
  /** 每个生命周期事件都带上的运行身份 (runId / goalId / agentId / channelId / surface) */
1846
2185
  harnessCtx() {
1847
2186
  return {
1848
- runId: this.currentRunId || undefined,
1849
- goalId: this.currentGoalId || undefined,
1850
- agentId: this.currentAgentId || undefined,
1851
- channelId: this.currentChannelId || undefined,
2187
+ runId: this.actor.state.activeRun || undefined,
2188
+ goalId: this.actor.state.goalBinding || undefined,
2189
+ agentId: this.actor.state.agentId || undefined,
2190
+ channelId: this.actor.state.channelId || undefined,
1852
2191
  surface: this.runSurface,
1853
2192
  };
1854
2193
  }
1855
- async runReActLoop(onStream, signal) {
1856
- const llm = getMinimax();
1857
- let iteration = 0;
1858
- let finalResponse = '';
1859
- let lastQualityScore = 0;
1860
- let refineAttempts = 0;
1861
- let consecutiveErrors = 0;
1862
- // 2026-06-16 新增: 累计错误数 (跨工具, 兜底防 LLM 轮换工具名死循环)
1863
- let totalErrors = 0;
1864
- let lastFailedTool = ''; // 跟踪最近一次失败的 tool name
1865
- let lastFailedToolCount = 0; // 最近失败工具的连续失败次数
1866
- // 2026-06-16: AI sentinel 标志 — runReActLoop 返回 aiFailed=true,
1867
- // promptStream 据此自动重跑整个 loop 最多 N 次 (不是单次 LLM 重试)
1868
- let aiFailed = false;
1869
- let aiFailureReason = '';
1870
- const MAX_CONSECUTIVE_ERRORS = 3;
1871
- // 同一工具连续失败 3 次, 强制让 LLM 给出最终答案 (模块级常量 MAX_SAME_TOOL_FAILURES 也用它做熔断)
1872
- // 2026-07-29: Hermes 风格硬限制 — 防死循环 (不再靠 soft hint)
1873
- const MAX_IDEMPOTENT_TOOL = 5; // 同工具成功调 5 次 → 注入 hint 强制 final gen
1874
- const MAX_TOOL_CALLS_PER_LOOP = 25; // 单轮循环总工具调用上限 → 注入 hint
1875
- let totalToolCallsThisLoop = 0;
1876
- const lastNTools = []; // 最近 MAX_IDEMPOTENT_TOOL 次工具名, 检测重复
1877
- // 2026-10-01: 工具停滞观测状态 (分类 + 温和引导 + 重复结果引用) —— 见 agents/tool-loop-guard.ts
1878
- // 与上面的"同工具 5 次就提示"不同: 它按 (工具名 + 参数 + 结果) 签名判定, 并抓 A→B→A→B 的循环
1879
- const stallState = new LoopStallState();
1880
- // 2026-08-10: unreported 循环逃生门 — LLM 反复不把工具结果写进回复时, 3 次后强制 final (不死板)
1881
- const MAX_UNREPORTED_RETRIES = 3;
1882
- let unreportedRetries = 0;
1883
- // 2026-08-10: 工具失败时的终端逃生引导 (shell_exec 白名单命令可诊断环境/推进任务)
1884
- const SHELL_ESCAPE_HINT = ' [逃生] 若工具无法响应/报错, 可用 shell_exec 跑终端命令诊断 (白名单: ls/cat/head/tail/pwd/git status/npm run test 等), 或调整参数换一种方式完成; 不要重复调用同一失败工具.';
1885
- // 2026-08-08: final 前 review 续跑 — 目标对齐 + 需求深挖 (见 loop-review.ts)
1886
- // 不潦草收尾: LLM 想 <final gen> 时先跑 1-2 次 review, 达成用户需求才放行.
1887
- // 上限=2 次 (用户要求"运行一两次"), 结束后按用户需求为准.
1888
- let loopReviewCount = 0;
1889
- const loopReviewCompletedTools = new Set();
1890
- // 2026-08-09: 本轮行动日志 — 每轮工具执行都记录 (args + 结果摘要),
1891
- // final 前 review 用逐条核查目标; 也注入 system prompt 让 LLM 看到连续进度
1892
- // (防"每轮都像重启" — 之前 LLM 看不到自己已完成什么, 容易重复 react)
1893
- const loopActionLog = [];
1894
- // 发送循环开始的事件
1895
- if (onStream) {
1896
- onStream({ type: 'status', internal: true, content: '🔄 开始 ReAct 循环...', tool: 'system' });
1897
- }
1898
- // React Harness: 循环开始 (重置 turn 计数 + 触发 harness sessionStart)
1899
- // 失败静默 (fail-open), 不阻塞主循环
1900
- // 2026-09-16 (Milestone 1-B): 会话开启走唯一门面 (react-harness 8-gate 复位 + hooks onLoopStart)
1901
- await this.piHarness().sessionStart(this.harnessCtx());
1902
- // 2026-09-16: 持久化 run harness — 本次运行立即落盘 (~/.bolloon/runs/<id>.json)。
1903
- // 之后每步工具调用都追加一条, 所以刷新页面/进程重载/崩溃都能看到"做到哪一步"。
1904
- let runStopReason = '';
1905
- // 2026-09-16 (Milestone 1): 持久化硬约束 —— 核心 run 状态写不进去时, 运行必须停。
1906
- // 理由: "agent 实际跑了但没记录" 比 "agent 没跑" 更危险 (UI 显示旧状态/重启后无从知晓/结果可能被错标 done)。
1907
- let runPersistenceFailure = '';
1908
- /** 2026-09-16 (M3): 熔断/需要人处置 → 收尾落 needs_human (不是 done, 也不是普通 failed) */
1909
- let runNeedsHuman = '';
1910
- /** 2026-09-16 (M5): 外部 (CLI/Web) 把 run 改成 paused/aborted → 如实停, 不再覆盖它的状态 */
1911
- let runExternallyPaused = false;
1912
- let runExternallyAborted = false;
1913
- this.runPersistenceBlocked = false;
1914
- this.breakerReason = '';
1915
- this.awaitingExternal = false;
1916
- if (this.resumeRunId) {
1917
- // ── 恢复模式: 复用原来的 runId, 不新建 run (历史保留) ──
1918
- this.currentRunId = this.resumeRunId;
1919
- this.lastRunId = this.resumeRunId;
2194
+ /**
2195
+ * 回合收尾的**受门类型自检** (K7 → K4-B 抽成方法)。
2196
+ *
2197
+ * 为什么必须抽出来: 它原先**只写在老 `runReActLoop` 之后** —— 而生产里 web 走的是 pivot 路径
2198
+ * (`usePivotLoop: true`, `promptWithPivotLoop` 提前 return) ⇒ 这条"系统自检过门"在**生产根本没触发过**。
2199
+ * 抽成方法后两条 loop 路径都调它 (一次一轮, 由 `typecheckRanThisTurn` 保证)。
2200
+ */
2201
+ async runTurnEndTypecheck() {
2202
+ // 2026-10-01: **本回合改过 TS ⇒ 收尾自动类型检查** (一轮一次) —— 复用已有的 tsc_check 工具
2203
+ if (decideTypecheck(this.tsTouchedThisTurn, this.typecheckRanThisTurn)) {
2204
+ this.typecheckRanThisTurn = true;
1920
2205
  try {
1921
- await markRunRunning(this.currentRunId);
1922
- }
1923
- catch (err) {
1924
- runPersistenceFailure = `恢复时状态迁移失败 (recovering → running): ${String(err?.message || err).slice(0, 180)}`;
1925
- }
1926
- const doneN = this.resumePlan?.completedSteps.length ?? 0;
1927
- const guards = this.resumePlan?.replayGuards.length ?? 0;
1928
- onStream?.({ type: 'status', internal: true, content: `♻️ 从 checkpoint 恢复运行 ${this.currentRunId} (已完成 ${doneN} 步, 非幂等重放守卫 ${guards} 条)`, tool: 'harness' });
1929
- }
1930
- else {
1931
- // 2026-09-16 (M2): 目标绑定 —— 有 goalId 就在该 Goal 下执行; 没有就建 Goal 再建 Run。
1932
- // 延续规则 (确定性, 不靠猜): 该 channel/agent 上已有 open/active Goal, 且它的上一次执行**没收尾**
1933
- // (interrupted/stalled/needs_human/paused/awaiting_external/recovering) → 继续该 Goal; 否则新建。
1934
- let boundGoalId = this.currentGoalId;
1935
- if (!boundGoalId) {
1936
- try {
1937
- const active = await findActiveGoal({ channelId: this.currentChannelId || undefined, agentId: this.currentAgentId || undefined });
1938
- if (active?.currentRunId) {
1939
- const prev = await readRun(active.currentRunId);
1940
- const unfinished = prev && ['interrupted', 'stalled', 'needs_human', 'paused', 'awaiting_external', 'recovering'].includes(prev.status);
1941
- if (unfinished)
1942
- boundGoalId = active.goalId;
1943
- }
1944
- }
1945
- catch (err) {
1946
- console.warn('[PiAgent] 查找进行中 Goal 失败 (按新建处理):', err?.message);
1947
- }
1948
- if (!boundGoalId) {
2206
+ const tscTool = this.tools.get('tsc_check');
2207
+ if (tscTool?.execute) {
2208
+ // 2026-10-02 (K7): **系统自检也过门** —— 统一成"任何工具执行都走同一扇 Harness 门"。
2209
+ // 这里不是模型发起的工具调用 (args 恒空, 是本回合改过 TS 后的收尾自检), 所以**更**不能例外:
2210
+ // 例外一旦靠"没人知道它绕过"活着, 门就不再是唯一的执行咽喉。
2211
+ // 语义: 被拒或门抛错 ⇒ **不执行** (fail-closed), 且**可见**地报出来 (拒绝不许静默)。
2212
+ let tscAllowed = true;
2213
+ let tscWhy = '';
1949
2214
  try {
1950
- const g = await createGoal({
1951
- objective: this.currentUserInput || '(未记录目标)',
1952
- channelId: this.currentChannelId || undefined,
1953
- agentId: this.currentAgentId || undefined,
1954
- createdBy: this.runSurface,
1955
- });
1956
- boundGoalId = g.goalId;
1957
- }
1958
- catch (err) {
1959
- console.warn('[PiAgent] 创建 Goal 失败 (无目标也要有运行记录):', err?.message);
1960
- }
1961
- }
1962
- this.currentGoalId = boundGoalId;
1963
- }
1964
- try {
1965
- const rec = await startRun({
1966
- surface: this.runSurface,
1967
- goal: this.currentUserInput || '(未记录目标)',
1968
- goalId: boundGoalId || undefined,
1969
- channelId: this.currentChannelId || undefined,
1970
- agentId: this.currentAgentId || undefined,
1971
- modelConfig: await this.runModelSnapshot(),
1972
- });
1973
- this.currentRunId = rec.runId;
1974
- this.lastRunId = rec.runId;
1975
- this.currentGoalId = rec.goalId || this.currentGoalId;
1976
- if (this.currentGoalId) {
1977
- // Run → Goal 反查链: runId → goalId → objective / success criteria
1978
- await attachRun(this.currentGoalId, rec.runId).catch((err) => console.warn('[PiAgent] attachRun 失败:', err?.message));
1979
- }
1980
- 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' });
1981
- }
1982
- catch (err) {
1983
- // 核心写失败: 不再 warn 后继续 —— 没有运行记录就不执行 (strict 模式默认如此)
1984
- runPersistenceFailure = `无法创建运行记录: ${String(err?.message || err).slice(0, 200)}`;
1985
- this.runPersistenceBlocked = true;
1986
- console.error('[PiAgent] run-store startRun 失败 (核心持久化) → 拒绝无记录执行:', runPersistenceFailure);
1987
- onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 已停止 (不在没有记录的情况下执行)`, tool: 'harness' });
1988
- }
1989
- }
1990
- while (iteration < this.MAX_REACT_ITERATIONS) {
1991
- iteration++;
1992
- // 2026-09-16 (Milestone 1): 持久化失败硬闸 —— 到这一层说明记录已经不可信, 继续跑就是"无约束执行"
1993
- if (runPersistenceFailure || this.persistenceFailure) {
1994
- runPersistenceFailure = runPersistenceFailure || this.persistenceFailure;
1995
- this.runPersistenceBlocked = true;
1996
- aiFailed = true;
1997
- aiFailureReason = aiFailureReason || runPersistenceFailure;
1998
- finalResponse = finalResponse || `❌ 运行已停止: ${runPersistenceFailure}\n\n(运行记录无法写入/校验, 按协议停在 needs_human, 不假装完成)`;
1999
- break;
2000
- }
2001
- // 2026-09-16 (M3): 熔断硬闸 —— 同一工具连续失败达上限后, 不许再"自动重试成功"式地把运行放活
2002
- if (this.breakerReason) {
2003
- runNeedsHuman = this.breakerReason;
2004
- aiFailed = true;
2005
- aiFailureReason = aiFailureReason || this.breakerReason;
2006
- finalResponse = finalResponse || `❌ 已熔断: ${this.breakerReason}\n\n(重复失败不再重试, 按协议停在 needs_human 交人处置)`;
2007
- break;
2008
- }
2009
- // 2026-09-16: 预算闸门 (持久化 harness 的约束面) —— 到点必须**如实**终止, 不许静默算完成
2010
- if (this.currentRunId) {
2011
- try {
2012
- const rec = await readRun(this.currentRunId);
2013
- if (!rec)
2014
- throw new Error(`运行记录读不到: ${this.currentRunId}`);
2015
- // 2026-09-16 (M5): 外部控制面 (CLI /pause /abort, Web API) 改过状态 → 如实停在那儿。
2016
- // 不覆盖成 done/failed: 人按下暂停就是暂停, 人按下中止就是中止。
2017
- if (rec.status === 'paused' || rec.status === 'aborted') {
2018
- runExternallyPaused = rec.status === 'paused';
2019
- runExternallyAborted = rec.status === 'aborted';
2020
- runStopReason = `外部请求: ${rec.status}`;
2021
- onStream?.({ type: 'error', content: `⏹️ 运行被外部${rec.status === 'paused' ? '暂停' : '中止'} (run=${this.currentRunId})`, tool: 'harness' });
2022
- finalResponse = finalResponse || `(运行已${rec.status === 'paused' ? '暂停' : '中止'})`;
2023
- break;
2024
- }
2025
- const verdict = budgetVerdict(rec);
2026
- if (verdict.exceeded) {
2027
- runStopReason = verdict.reason || '运行预算用尽';
2028
- onStream?.({ type: 'error', content: `⛔ 运行预算用尽: ${runStopReason} (已如实终止, 不假装完成)`, tool: 'harness' });
2029
- finalResponse = finalResponse || `(运行预算用尽: ${runStopReason})`;
2030
- break;
2031
- }
2032
- }
2033
- catch (err) {
2034
- // 预算闸门读不到状态 = 约束失效, 不能"当作没超预算"继续跑
2035
- runPersistenceFailure = `预算闸门无法校验运行状态: ${String(err?.message || err).slice(0, 200)}`;
2036
- this.runPersistenceBlocked = true;
2037
- console.error('[PiAgent] run-store 预算检查失败 (核心持久化):', runPersistenceFailure);
2038
- onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 已停止`, tool: 'harness' });
2039
- break;
2040
- }
2041
- }
2042
- // 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
2043
- // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideMaxIterations 纯函数
2044
- // 2026-10-01 (落实②: 可退还的迭代预算): 惩罚零碎调用, **奖励批处理** ——
2045
- // 程序化工具(execute_code, 一次能顶多次)调用后归还一次迭代 ⇒ 有效寿命被延长。
2046
- // 退出条件仍由 decideMaxIterations 决定(读**净**用量), 不做硬刹车。
2047
- if (iteration === 0 || !this.iterBudget)
2048
- this.iterBudget = new IterationBudget(this.MAX_REACT_ITERATIONS);
2049
- const iterBudget = this.iterBudget;
2050
- iterBudget.consume();
2051
- if (iterBudget.warn(0.8) && !this.iterBudgetWarned) {
2052
- this.iterBudgetWarned = true;
2053
- onStream?.({ type: 'status', internal: true, content: `⏳ 迭代预算已用 ${iterBudget.describe()} (批量工具会退还)` });
2054
- }
2055
- const maxIterDecision = decideMaxIterations(iterBudget.used, iterBudget.maxTotal);
2056
- if (maxIterDecision.shouldExit) {
2057
- console.warn(`[PiAgent] 达到最大循环数 ${this.MAX_REACT_ITERATIONS}, 强制终止 (fail-safe)`);
2058
- onStream?.({ type: 'error', content: `⏹️ 达到最大循环数 (${this.MAX_REACT_ITERATIONS}, fail-safe)`, tool: 'loop' });
2059
- finalResponse = finalResponse || maxIterDecision.finalAnswer;
2060
- break;
2061
- }
2062
- // 停止条件 2: signal.aborted (显式 abort / 用户中断)
2063
- if (signal?.aborted) {
2064
- console.warn('[PiAgent] runReActLoop aborted by signal');
2065
- onStream?.({ type: 'error', content: '⏹️ 用户中断', tool: 'loop' });
2066
- finalResponse = finalResponse || '(用户中断)';
2067
- break;
2068
- }
2069
- // 2026-07-29: Hermes 风格硬限制 (idempotent tool / total call cap)
2070
- if (totalToolCallsThisLoop >= MAX_TOOL_CALLS_PER_LOOP) {
2071
- console.warn(`[PiAgent] 单轮工具调用已达 ${MAX_TOOL_CALLS_PER_LOOP}, 注入 hint 让 LLM 总结`);
2072
- onStream?.({ type: 'error', content: `⏹️ 工具调用已达上限 (${MAX_TOOL_CALLS_PER_LOOP}), 请基于已有结果回答`, tool: 'loop' });
2073
- this.messageHistory.push({ role: 'system', content: `[注意] 你已连续调用 ${MAX_TOOL_CALLS_PER_LOOP} 次工具。请基于已有结果直接回答用户, 不要再次调用任何工具。在回答末尾加 <final gen> 标记结束。` });
2074
- totalToolCallsThisLoop = 0; // 重置计数器, 只防连续死循环
2075
- }
2076
- if (lastNTools.length >= MAX_IDEMPOTENT_TOOL && new Set(lastNTools).size === 1) {
2077
- const repeatedTool = lastNTools[0];
2078
- console.warn(`[PiAgent] 同工具 ${repeatedTool} 连续成功调 ${MAX_IDEMPOTENT_TOOL} 次, 注入 hint 让 LLM 总结`);
2079
- onStream?.({ type: 'error', content: `⏹️ 工具 ${repeatedTool} 重复调用 ${MAX_IDEMPOTENT_TOOL} 次, 请基于已有结果回答`, tool: 'loop' });
2080
- this.messageHistory.push({ role: 'system', content: `[注意] 你已连续 ${MAX_IDEMPOTENT_TOOL} 次调用 ${repeatedTool}。请基于已有结果直接回答用户, 不要再次调用任何工具。在回答末尾加 <final gen> 标记结束。` });
2081
- lastNTools.length = 0; // 重置计数器
2082
- // 不 break — 让 LLM 在下一轮用已有信息回答
2083
- }
2084
- // 2026-06-16 新增: 累计错误兜底 — 跨工具, 防 LLM 轮换工具名绕过 MAX_SAME_TOOL_FAILURES
2085
- if (totalErrors >= this.MAX_TOTAL_ERRORS) {
2086
- console.warn(`[PiAgent] 累计错误 ${totalErrors} >= ${this.MAX_TOTAL_ERRORS}, 强制终止 (防死循环)`);
2087
- onStream?.({ type: 'error', content: `⛔ 累计 ${totalErrors} 次错误, 强制终止 (防止 LLM 死循环)`, tool: 'loop' });
2088
- // 2026-06-19: 即使 LLM 一直失败, 也汇总之前成功执行的 tool result 给用户
2089
- if (this.successfulToolResults.length > 0) {
2090
- finalResponse = `✅ 之前步骤成功执行了 ${this.successfulToolResults.length} 个工具 (但 LLM 后续 ${totalErrors} 次调用失败):\n` +
2091
- this.successfulToolResults.map((r, i) => ` ${i + 1}. ${r.tool}: ${r.outputPreview}`).join('\n') +
2092
- `\n\n⚠️ (LLM 连续失败, 可能是上游限流/网络问题, 工具已成功执行但 LLM 没能继续总结)`;
2093
- }
2094
- else {
2095
- finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
2096
- }
2097
- break;
2098
- }
2099
- // 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
2100
- // compact 失败走 C 路径: 不强行 break, 让现有 60K 阈值兜底 (后面有检查)
2101
- // 2026-07-01 (v0.2.4 子任务 1): 触发判定走 shouldCompactBeforeIteration 纯函数
2102
- const compactThreshold = this.maxContextTokens() * this.LOOP_COMPACT_RATIO;
2103
- const estimatedTokensBefore = this.estimateHistoryTokens();
2104
- // 2026-08-06: 每轮上报 usage 到 ContextManager (CLI/Web 状态栏数据源, warning 事件触发点)
2105
- getContextManager().updateUsage(estimatedTokensBefore);
2106
- if (shouldCompactBeforeIteration(estimatedTokensBefore, compactThreshold)) {
2107
- const tokensBeforeCompact = estimatedTokensBefore;
2108
- console.log(`[PiAgent] loop 入口 token ${tokensBeforeCompact} > ${compactThreshold}, 触发自动压缩`);
2109
- onStream?.({ type: 'status', internal: true, content: `🗜️ loop 自动压缩 (token ${tokensBeforeCompact} > ${compactThreshold})`, tool: 'compactor' });
2110
- try {
2111
- await this.maybeAutoCompact(onStream, signal);
2112
- }
2113
- catch (compactErr) {
2114
- // C 路径: compact 失败不 break, 让 token 阈值检查兜底
2115
- console.warn(`[PiAgent] loop 内 maybeAutoCompact 失败 (non-fatal, 继续走 token 阈值):`, compactErr);
2116
- }
2117
- }
2118
- // 停止条件 3: context overflow (compact 后还超, 强制终止)
2119
- // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideContextOverflow 纯函数
2120
- const estimatedTokens = this.estimateHistoryTokens();
2121
- const overflowDecision = decideContextOverflow(estimatedTokens, this.maxContextTokens());
2122
- if (overflowDecision.shouldExit) {
2123
- console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.maxContextTokens()})`);
2124
- onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.maxContextTokens()})`, tool: 'loop' });
2125
- finalResponse = finalResponse || overflowDecision.finalAnswer;
2126
- break;
2127
- }
2128
- // 调试日志:显示每次循环开始
2129
- console.log(`[PiAgent] 循环 ${iteration}/${this.MAX_REACT_ITERATIONS} 开始`);
2130
- if (onStream) {
2131
- onStream({ type: 'status', internal: true, content: `🔄 循环 ${iteration}/${this.MAX_REACT_ITERATIONS}`, tool: 'loop' });
2132
- }
2133
- const context = this.buildContext();
2134
- // M3.5 (2026-06-17): 也构造 messages 数组版本, 让 LLM 看到结构化 tool 角色
2135
- // buildContext() 把 history 序列化成字符串 — LLM 看不到 tool 调用的真实结果
2136
- // 新版用 messages 数组直接喂给 LLM, 保留 role 语义 (user/assistant/tool/system)
2137
- const messages = this.buildMessages();
2138
- const toolDefs = this.getToolDefinitions();
2139
- // 动态构建 refine 上下文
2140
- let refineContext = '';
2141
- if (refineAttempts > 0 && lastQualityScore < this.QUALITY_THRESHOLD) {
2142
- refineContext = `\n【改进提示】上轮结果质量分 ${(lastQualityScore * 10).toFixed(1)}/10,请改进回答。`;
2143
- }
2144
- // 连续错误时的额外提示
2145
- if (consecutiveErrors > 0) {
2146
- refineContext += `\n【错误提示】上轮发生 ${consecutiveErrors} 次错误,请重新分析问题或换一种方式处理。`;
2147
- }
2148
- // M2.4: persona section 缓存 — persona 在 loadPersona() 时一次设定, 此后不变
2149
- if (!this.cachedPersonaSection && this.persona) {
2150
- this.cachedPersonaSection = `
2151
- 角色描述: ${this.persona.description || '无'}
2152
- 性格特点: ${this.persona.personality || '无'}
2153
- 问候语: ${this.persona.greeting || '无'}
2154
- `;
2155
- }
2156
- const personaSection = this.cachedPersonaSection;
2157
- // 2026-08-09: 循环进度段 — 让 LLM 看到本轮已完成的动作 (连续进度, 不重启)
2158
- // Hermes 式 Agent Runtime: 循环是状态机, LLM 每次看到的是"第 N 步 + 已完成 X"
2159
- // (之前每轮都是全新上下文, LLM 不知道做过什么 → 重复 react / 衔接差)
2160
- let loopProgressSection = '';
2161
- if (loopActionLog.length > 0) {
2162
- const actionLines = loopActionLog
2163
- .map((a, i) => {
2164
- const args = a.argsPreview ? `(${a.argsPreview.slice(0, 60)})` : '';
2165
- const res = a.success ? '✓' : '✗';
2166
- return ` ${i + 1}. ${res} ${a.tool}${args}`;
2167
- })
2168
- .join('\n');
2169
- loopProgressSection = `\n【本轮循环进度】你已完成以下 ${loopActionLog.length} 个动作, 这是连续执行的同一轮任务:\n${actionLines}\n请基于已有结果继续推进, 不要重复执行上面已成功的动作. 全部完成后用 <final gen> 结束.\n`;
2170
- }
2171
- // 2026-09-28 (前缀 KV 可命中): `refineContext` / `loopProgressSection` 是**每轮都在变**的段
2172
- // (质量分 / 连续错误数 / 本轮已完成动作). 留在 system 里 = 每轮把 IMMUTABLE PREFIX 打碎 =
2173
- // 服务端前缀 KV 永远 miss. 移到 CURRENT TURN 区 (注入到最后一条 user 前部) —— 文本一字未改, 只换位置.
2174
- const currentTurnContext = `${refineContext}${loopProgressSection}`;
2175
- const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
2176
- 当前工作目录: ${this.cwd}
2177
- 当前身份: ${this.identity.name} (${this.identity.did})
2178
- ${this.currentIntentHint}
2179
-
2180
- ${toolDefs}
2181
-
2182
- ${PiAgentSession.TOOL_SELECTION_GUIDE}
2183
-
2184
- ${PROACTIVE_WORK_DISCIPLINE}
2185
-
2186
- ${await this.renderActivePlansSection()}
2187
-
2188
- 工作模式:
2189
- 1. 理解用户自然语言请求
2190
- 2. 分析需要哪些工具来完成
2191
- 3. 按顺序调用工具并观察结果
2192
- 4. 根据观察结果决定下一步
2193
- 5. 最终给出完整回答
2194
-
2195
- 重要:
2196
- - 每次只调用一个工具
2197
- - 仔细分析工具返回结果
2198
- - 当任务完成时,必须在回答末尾添加 <final gen> 标记表示结束
2199
- - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}${this.contextHintAddition}`;
2200
- // 3 个恢复机制 (Claude Code 论文 9-step pipeline 内部):
2201
- // 1. max output token 升级 (最多 3 次, 每次 maxOutputTokens 翻倍)
2202
- // 2. reactive compaction (prompt 估算超阈值, 跑压缩)
2203
- // 3. prompt-too-long (LLM 报错 4xxx token 错误, 跑 reactive compaction 再试 1 次)
2204
- // 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
2205
- // Bug 5: pass tool IDs for native OpenAI tool calling — 2026-07-29: 过滤拒绝工具
2206
- const toolIds = Array.from(this.tools.keys()).filter(n => !this._deniedToolNames.has(n));
2207
- // 2026-07-29: 从 this.tools Map 生成 OpenAI 原生 tools 格式 (含参数 schema)
2208
- const openaiFormattedTools = [];
2209
- for (const [name, tool] of this.tools) {
2210
- if (this._deniedToolNames.has(name))
2211
- continue;
2212
- const params = tool.parameters || {};
2213
- const properties = {};
2214
- const required = [];
2215
- for (const [pName, pDesc] of Object.entries(params)) {
2216
- properties[pName] = { type: 'string', description: String(pDesc) };
2217
- if (String(pDesc).includes('必填'))
2218
- required.push(pName);
2219
- }
2220
- openaiFormattedTools.push({
2221
- type: 'function',
2222
- function: {
2223
- name,
2224
- description: tool.description || name,
2225
- parameters: { type: 'object', properties, required },
2226
- },
2227
- });
2228
- }
2229
- // 2026-09-16 (Milestone 1-B): 模型调用前后走唯一门面 (扩展点 + 计数留痕; 默认不加新 hook 事件, 避免改变现有触发次数)
2230
- this.piHarness().beforeModelCall(this.harnessCtx());
2231
- const _modelCallT0 = Date.now();
2232
- const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, openaiFormattedTools, currentTurnContext);
2233
- // 2026-09-28 (前缀 KV 可命中): 把注入了 CURRENT TURN 的当前轮**原样写回自己的 messageHistory** ——
2234
- // buildMessages() 每轮重建全新对象, 不写回的话下一轮那条 user 就退回注入前, 前缀从那里分叉.
2235
- // 用 chat() 回带的 wire (真正发出去的那一份), 而不是把 messages 再拼一遍.
2236
- this.writeBackCurrentTurn(response.messages);
2237
- this.piHarness().afterModelCall(this.harnessCtx(), { ms: Date.now() - _modelCallT0 });
2238
- const reply = (response.reply || '').trim();
2239
- // 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
2240
- const nativeToolCalls = response.toolCalls;
2241
- // 2026-06-19 架构 fix: 不再因 [AI 服务调用失败] break
2242
- // 旧逻辑: sentinel → aiFailed=true → break → 外层 retry 整个 loop (重置 history)
2243
- // 新逻辑: 把错误当 tool_result push 进 history → 下一轮 LLM 看到错误能反思重试
2244
- // 这是 dive-into 文档的"fail-open error recovery" — 错误进入 context, 不让 LLM 重复犯同样错
2245
- // 2026-07-06: 对不可恢复的 API 错误直接终止, 不再无限重试
2246
- if (reply.startsWith('[AI 服务调用失败]')) {
2247
- console.log(`[PiAgent] 收到 AI 错误 sentinel`);
2248
- console.log(`[sentinel DEBUG] 完整 reply: ${reply}`);
2249
- console.log(`[sentinel DEBUG] 上一轮 messages 数量: ${Array.isArray(messages) ? messages.length : 'N/A'}, systemPrompt 长度: ${systemPrompt.length}`);
2250
- aiFailureReason = reply.length > 200 ? reply.substring(0, 200) : reply;
2251
- totalErrors++;
2252
- consecutiveErrors++;
2253
- // 2026-07-06: 检测不可恢复的 API 错误 — 这些错误 LLM 无法通过反思修复, 重试无意义
2254
- const isFatalApiError = reply.includes('chat content is empty') ||
2255
- reply.includes('invalid params') ||
2256
- reply.includes('401') ||
2257
- reply.includes('403') ||
2258
- reply.includes('quota') ||
2259
- reply.includes('rate limit') ||
2260
- reply.includes('API key') ||
2261
- reply.includes('authentication') ||
2262
- reply.includes('unauthorized');
2263
- if (isFatalApiError) {
2264
- console.log(`[PiAgent] 检测到不可恢复的 API 错误, 终止 loop: ${aiFailureReason}`);
2265
- if (onStream) {
2266
- onStream({ type: 'error', content: `⛔ API 错误无法恢复: ${aiFailureReason}`, tool: 'system' });
2267
- }
2268
- finalResponse = `❌ AI 服务调用失败: ${aiFailureReason}\n\n这是一个底层 API 错误, 不是任务本身的问题。请检查 API 配置或稍后重试。`;
2269
- aiFailed = true;
2270
- break;
2271
- }
2272
- // 连续错误过多也终止, 防止 LLM 陷入死循环
2273
- if (consecutiveErrors >= 3) {
2274
- console.log(`[PiAgent] 连续 ${consecutiveErrors} 次 AI 错误, 终止 loop`);
2275
- if (onStream) {
2276
- onStream({ type: 'error', content: `⛔ 连续 ${consecutiveErrors} 次 AI 错误, 终止循环`, tool: 'system' });
2277
- }
2278
- finalResponse = `❌ AI 连续调用失败 ${consecutiveErrors} 次, 已终止。\n\n失败原因: ${aiFailureReason}\n\n请检查 API 配置或简化任务后重试。`;
2279
- aiFailed = true;
2280
- break;
2281
- }
2282
- // 把错误当成 tool 结果 push 进 history, 这样下一轮 LLM 看到错误能调整
2283
- this.messageHistory.push({
2284
- role: 'system',
2285
- content: `[Loop 错误恢复 ${totalErrors}/${this.MAX_TOTAL_ERRORS}] ${aiFailureReason}\n\n请基于上轮工具结果继续完成任务, 不要重复调用同一失败操作. 如果工具已成功执行, 请基于 result.output 给用户总结; 如果工具失败, 请换其他方式或重试.`
2286
- });
2287
- if (onStream) {
2288
- onStream({ type: 'status', content: `⚠️ AI 调用失败 ${totalErrors}/${this.MAX_TOTAL_ERRORS}, 已 push 错误到 history 让 LLM 反思`, tool: 'system' });
2289
- }
2290
- // 退避 2s 后继续 — 临时上游限流避开, 不让 loop 终止
2291
- await new Promise(resolve => setTimeout(resolve, 2000));
2292
- // 关键: 不设 aiFailed=true, 让外层不重试整个 loop (重置 history), 继续内层循环
2293
- continue;
2294
- }
2295
- console.log(`[PiAgent] LLM 回复长度: ${reply.length}, 内容预览: "${reply.substring(0, 80)}..."`);
2296
- console.log(`[PiAgent] LLM 完整回复:\n${reply}`);
2297
- // 通知前端:收到 LLM 回复 (2026-08-09: 不再截断 100 字符 — 前端流式渲染完整内容,
2298
- // 配合 Hermes 式回复框: 加载中显示完整文本, 完成后封闭底框)
2299
- if (onStream) {
2300
- onStream({ type: 'token', content: reply });
2301
- }
2302
- // 2026-06-19 架构 fix: parseToolCall 优先于 isFinalResponse
2303
- // 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
2304
- // 现在: 先尝试解析 tool_call, 有就执行; 没有才检查是不是真正的 final gen
2305
- // Bug 5 (2026-07-17): 优先用 LLM 的 native tool_calls (response.toolCalls), 再回退到文本解析
2306
- // deepseek-v4-flash 用 OpenAI 协议 tools 时, 会真返回结构化 tool_calls 数组
2307
- // 之前 nativeToolCalls 被读了不用, 只查 reply 文本, 导致 LLM 明明选了工具但代码找不到
2308
- // 2026-07-28: 修复多工具调用 — 收集 ALL tool calls, 顺序执行后一次性返回
2309
- let toolCalls = [];
2310
- // 路径 A: native OpenAI 协议 tool_calls (可能多个)
2311
- if (nativeToolCalls && nativeToolCalls.length > 0) {
2312
- for (const nc of nativeToolCalls) {
2313
- try {
2314
- const args = typeof nc.function?.arguments === 'string'
2315
- ? JSON.parse(nc.function.arguments)
2316
- : (nc.function?.arguments || {});
2317
- toolCalls.push({
2318
- name: nc.function?.name,
2319
- args,
2320
- id: nc.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2321
- });
2322
- }
2323
- catch (err) {
2324
- console.warn(`[PiAgent] 解析 native tool_call 失败: ${err.message?.slice(0, 100)}`);
2325
- }
2326
- }
2327
- }
2328
- // 路径 B: 文本解析 (parseAllToolCalls 收集全部)
2329
- if (toolCalls.length === 0) {
2330
- // 2026-09-26: 已知名集合 = 注册表原名 + 它们的 API 名 (净化后的).
2331
- // 出网时工具面给的是 API 名 (见 pi-ai.ts 的唯一净化边界), LLM 在文本里
2332
- // 回吐的也可能是 API 名 —— 只用原名集合过滤会把这类调用整个丢掉.
2333
- const knownTools = expandKnownToolNames(this.tools.keys());
2334
- toolCalls = parseAllToolCalls(reply, { tools: knownTools });
2335
- }
2336
- // 回退路径 C: 原生 parseToolCall (单个)
2337
- if (toolCalls.length === 0) {
2338
- const single = this.parseToolCall(reply);
2339
- if (single)
2340
- toolCalls.push(single);
2341
- }
2342
- // 2026-09-26: **回程派发的唯一还原点** —— LLM 回吐的是 API 名 (净化过的), 这里
2343
- // 还原成注册表真名再交给 this.tools.get(); 原名 (LLM 照 system prompt 抄的) 原样穿透.
2344
- for (const tc of toolCalls) {
2345
- if (typeof tc.name === 'string' && tc.name)
2346
- tc.name = resolveApiToolName(tc.name);
2347
- }
2348
- // 给每个 toolCall 分配稳定 id
2349
- for (const tc of toolCalls) {
2350
- if (!tc.id) {
2351
- tc.id = `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2352
- }
2353
- }
2354
- if (toolCalls.length > 0) {
2355
- // 把原始 LLM 回复 push 进 history (仅一次)
2356
- this.messageHistory.push({
2357
- role: 'assistant',
2358
- content: reply,
2359
- toolCalls: toolCalls.length > 1 ? toolCalls : [toolCalls[0]],
2360
- // 2026-09-15: 思考模式思维链原样存回 (下一轮带 tools 的请求必须回带, 否则 deepseek 400)
2361
- reasoningContent: response?.reasoningContent,
2362
- });
2363
- // 2026-08-09: 并发执行本轮所有工具 (Hermes 式 Agent Runtime: 一轮内多工具并行,
2364
- // 一轮没跑完之前不中断 — 工具执行不检查 abort, 全部完成才 continue 下一轮)
2365
- // 旧实现顺序 for 循环, 一个工具等一个, 慢; 且多工具时 LLM 要等全部串完才能看到结果.
2366
- await Promise.all(toolCalls.map(async (toolCall, ti) => {
2367
- const isMulti = toolCalls.length > 1;
2368
- // 通知前端
2369
- if (onStream) {
2370
- onStream({ type: 'tool', content: `🔧 调用工具 (${ti + 1}/${toolCalls.length}): ${toolCall.name}`, tool: toolCall.name });
2371
- if (toolCall.args && Object.keys(toolCall.args).length > 0) {
2372
- onStream({ type: 'status', internal: true, content: `📋 参数: ${JSON.stringify(toolCall.args)}`, tool: toolCall.name });
2373
- }
2374
- onStream({
2375
- type: 'step_start',
2376
- content: `调用 ${toolCall.name}${isMulti ? ` (${ti + 1}/${toolCalls.length})` : ''}`,
2377
- tool: toolCall.name,
2378
- args: toolCall.args || {},
2379
- });
2380
- }
2381
- const tool = this.tools.get(toolCall.name);
2382
- if (!tool) {
2383
- consecutiveErrors++;
2384
- totalErrors++;
2385
- const errorResult = { success: false, error: `未知工具: ${toolCall.name}` };
2386
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(errorResult), toolResult: errorResult });
2387
- this.logToHarness(toolCall.name, toolCall.args, errorResult);
2388
- // 2026-07-28: 注入 Reflection 帮助 LLM 理解错误
2389
- const obs = buildObservation(toolCall.name, toolCall.args, errorResult);
2390
- const ref = buildReflection(toolCall.name, errorResult.error, totalErrors, lastFailedToolCount);
2391
- this.messageHistory.push({ role: 'system', content: formatObservationWithReflection(obs, ref) });
2392
- if (onStream)
2393
- onStream({ type: 'status', content: `💡 Reflection: ${obs.summary}`, tool: 'system' });
2394
- console.warn(`[PiAgent] 未知工具: ${toolCall.name} (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}),跳过并继续`);
2395
- return;
2396
- }
2397
- // 2026-09-16 (Milestone 1-B): 工具调用前的**唯一**约束入口。
2398
- // 顺序由 PiAgentHarness 决定: deny-pipeline → pre-tool-validator(4 步链) → react-harness(8-gate)。
2399
- // 旧实现是这三处在不同位置各自调用 (且有两条路径 fail-open); 现在 pi-sdk 不再散调任何 gate。
2400
- let toolDecision;
2401
- try {
2402
- toolDecision = await this.piHarness().beforeToolCall({
2403
- tool: toolCall.name,
2404
- args: toolCall.args || {},
2215
+ const d = await this.piHarness().beforeToolCall({
2216
+ tool: 'tsc_check',
2217
+ args: {},
2405
2218
  ctx: this.harnessCtx(),
2406
2219
  permissionMode: this.currentPermissionMode,
2407
2220
  });
2221
+ tscAllowed = d.allow;
2222
+ tscWhy = d.reason || d.rejectedBy || '';
2408
2223
  }
2409
- catch (err) {
2410
- // 门面自身抛错 = 核心约束失效 → fail-closed (不执行工具, 也不静默放行)
2411
- toolDecision = {
2412
- allow: false,
2413
- source: 'harness-error',
2414
- kind: 'core_constraint',
2415
- reason: `Harness 门面异常: ${String(err?.message || err).slice(0, 150)}`,
2416
- };
2417
- }
2418
- if (!toolDecision.allow) {
2419
- const src = toolDecision.source || 'unknown';
2420
- if (src === 'deny-pipeline') {
2421
- // 旧 deny-pipeline 分支: 不计连续失败计数, 文案 "拒绝: [source] reason"
2422
- consecutiveErrors++;
2423
- totalErrors++;
2424
- const denyResultMsg = { success: false, error: `拒绝: [${toolDecision.rejectedBy || 'deny-pipeline'}] ${toolDecision.reason}` };
2425
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(denyResultMsg), toolResult: denyResultMsg });
2426
- this.logToHarness(toolCall.name, toolCall.args, denyResultMsg);
2427
- return;
2428
- }
2429
- const isGateDeny = src === 'react-harness';
2430
- const isHarnessError = src === 'harness-error';
2431
- const deniedResult = {
2432
- success: false,
2433
- error: isGateDeny
2434
- ? `Harness gate 拒绝 (${toolDecision.rejectedBy}): ${toolDecision.reason || '未通过安全校验'}`
2435
- : `PreToolUse 拒绝: ${toolDecision.reason || '未通过安全校验'}`,
2436
- };
2437
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(deniedResult), toolResult: deniedResult });
2438
- this.logToHarness(toolCall.name, toolCall.args, deniedResult);
2439
- if (onStream) {
2440
- const gateName = isGateDeny ? `Harness ${toolDecision.rejectedBy}` : (isHarnessError ? '核心约束层' : 'PreToolUse');
2441
- onStream({ type: 'error', content: `🛡️ ${gateName} 拒绝 ${toolCall.name}: ${toolDecision.reason || '安全校验失败'}`, tool: toolCall.name });
2442
- onStream({ type: 'step_error', content: `${gateName} 拒绝 ${toolCall.name}`, tool: toolCall.name, error: toolDecision.reason || '安全校验失败' });
2443
- }
2444
- console.warn(`[PiAgent] 工具被拒 ${toolCall.name} (${src}${toolDecision.rejectedBy ? ':' + toolDecision.rejectedBy : ''}): ${toolDecision.reason}`);
2445
- consecutiveErrors++;
2446
- totalErrors++;
2447
- if (toolCall.name === lastFailedTool) {
2448
- lastFailedToolCount++;
2449
- }
2450
- else {
2451
- lastFailedTool = toolCall.name;
2452
- lastFailedToolCount = 1;
2453
- }
2454
- // 达到同一工具连续失败上限 / 连续错误上限时的引导语 (按拒绝来源保持原有文案)
2455
- const systemMaxMsg = isGateDeny
2456
- ? `[注意] 工具 ${toolCall.name} 被 Harness 拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 末尾加 <final gen>.`
2457
- : isHarnessError
2458
- ? `[注意] 工具 ${toolCall.name} 的约束校验层失效, 已按 fail-closed 阻止 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请换其他工具或直接回答用户, 末尾加 <final gen>.`
2459
- : `[注意] 工具 ${toolCall.name} 被系统拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 直接用已有信息回答用户, 末尾加 <final gen>.`;
2460
- const systemConsecMsg = isGateDeny
2461
- ? `[注意] 连续 ${consecutiveErrors} 次工具调用被 Harness 拒绝. 请换其他工具或直接回答.`
2462
- : isHarnessError
2463
- ? `[注意] 连续 ${consecutiveErrors} 次工具调用因约束层失效被阻止. 请换其他工具或直接回答用户, 末尾加 <final gen>.`
2464
- : `[注意] 连续 ${consecutiveErrors} 次工具调用被系统拒绝. 请换其他工具或直接回答用户, 末尾加 <final gen>.`;
2465
- if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
2466
- this.messageHistory.push({ role: 'system', content: systemMaxMsg });
2467
- lastFailedTool = '';
2468
- lastFailedToolCount = 0;
2469
- consecutiveErrors = 0;
2470
- }
2471
- else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2472
- this.messageHistory.push({ role: 'system', content: systemConsecMsg });
2473
- consecutiveErrors = 0;
2474
- }
2475
- return;
2476
- }
2477
- if (toolDecision.systemAddition) {
2478
- this.contextHintAddition += '\n' + toolDecision.systemAddition;
2479
- }
2480
- try {
2481
- const toolStart = Date.now();
2482
- // 2026-09-16 (M2): 恢复重放守卫 —— 中断前**已成功执行过的非幂等动作**恢复后不再重做,
2483
- // 直接复用当时的结果 (避免重复副作用: 重复写文件/重复提交/重复付款)。
2484
- // (M2-B: 守卫也覆盖"上一个 Run 已做过的非幂等动作" —— 跨 Run 续跑同样不许重做)
2485
- let replaySkip = null;
2486
- const guards = [
2487
- ...(this.resumePlan?.replayGuards || []),
2488
- ...(this.continuationGuards || []),
2489
- ];
2490
- if (guards.length) {
2491
- const d = argsDigestOf(toolCall.args);
2492
- const hit = guards.find((g) => g.tool === toolCall.name && (!g.argsDigest || !d || g.argsDigest === d));
2493
- if (hit)
2494
- replaySkip = hit.summary;
2495
- }
2496
- let result = replaySkip
2497
- ? { success: true, output: `[恢复保护] ${toolCall.name} 在中断前已成功执行过, 本次不重复执行 (避免重复副作用)。当时结果: ${replaySkip}`, _replaySkipped: true }
2498
- : await (toolCall.__t0 = Date.now(), tool.execute(expandHomeArgs(toolCall.args)));
2499
- const toolDurationMs = Date.now() - toolStart;
2500
- if (replaySkip) {
2501
- console.log(`[PiAgent] 恢复重放守卫: 跳过已完成的非幂等工具 ${toolCall.name}`);
2502
- onStream?.({ type: 'status', internal: true, content: `🛡️ 恢复保护: ${toolCall.name} 此前已成功执行, 本次不重复执行`, tool: toolCall.name });
2503
- }
2504
- console.log(`[PiAgent] 工具 ${toolCall.name} 执行完成: success=${result.success} (${toolDurationMs}ms)`);
2505
- // 2026-09-16 (M3): 失败接线 —— 分类 + recovery 留痕 + 熔断 / 外部等待
2506
- if (!result.success && this.currentRunId && !replaySkip) {
2507
- await this.wireToolFailure(toolCall.name, String(result.error || ''), toolCall.args);
2508
- }
2509
- else if (result.success && this.currentRunId && this.awaitingExternal) {
2510
- // 外部回话了: awaiting_external → running (不是"恢复完成", 只是等待结束)
2511
- this.awaitingExternal = false;
2512
- await this.safeSetRunStatus(this.currentRunId, 'running');
2513
- }
2514
- // 2026-09-16: 持久化 run harness — 每步工具调用立即落盘 (崩在这里也能看到做到哪步)
2515
- if (this.currentRunId) {
2516
- try {
2517
- await recordStep(this.currentRunId, {
2518
- tool: toolCall.name,
2519
- ok: !!result.success,
2520
- ms: toolDurationMs,
2521
- args: toolCall.args,
2522
- summary: String(result.output || '').slice(0, 200),
2523
- error: result.error ? String(result.error) : undefined,
2524
- });
2525
- }
2526
- catch (err) {
2527
- // 核心写失败 → 本轮工具批跑完即停 (循环顶部硬闸), 不再 warn 后继续
2528
- runPersistenceFailure = `工具步骤写盘失败 (${toolCall.name}): ${String(err?.message || err).slice(0, 200)}`;
2529
- this.runPersistenceBlocked = true;
2530
- console.error('[PiAgent] run-store recordStep 失败 (核心持久化):', runPersistenceFailure);
2531
- onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 本轮结束后停止`, tool: 'harness' });
2532
- }
2533
- }
2534
- try {
2535
- await onPostToolUse({ tool: toolCall.name, args: toolCall.args || {}, result: { success: result.success, output: result.output?.substring(0, 500), error: result.error }, durationMs: toolDurationMs });
2536
- }
2537
- catch (postErr) {
2538
- console.warn('[PiAgent] onPostToolUse failed (non-fatal):', postErr);
2539
- }
2540
- // 2026-09-16 (Milestone 1-B): 工具调用后的唯一入口 (router hint + 输出 gate 一起判定)
2541
- const after = await this.piHarness().afterToolCall({
2542
- tool: toolCall.name,
2543
- output: String(result.output || ''),
2544
- ctx: this.harnessCtx(),
2545
- ok: !!result.success,
2546
- });
2547
- if (after.routeHint?.systemAddition) {
2548
- this.messageHistory.push({ role: 'system', content: `[Harness Router Hint: ${after.routeHint.reason}]\n${after.routeHint.systemAddition}` });
2549
- }
2550
- if (after.outputBlocked) {
2551
- if (onStream) {
2552
- onStream({ type: 'error', content: `🛡️ Harness output 拒绝 ${toolCall.name}: ${after.outputBlocked.reason}`, tool: toolCall.name });
2553
- }
2554
- console.warn(`[PiAgent] Harness output denied ${toolCall.name}: ${after.outputBlocked.reason}`);
2555
- result = { ...result, output: `[harness output gate: 输出含敏感内容, 已屏蔽. 原因: ${after.outputBlocked.reason}]`, _harnessDenied: true };
2556
- }
2557
- // 2026-10-01: 落库前做一次停滞观测 —— 命中的是"引导/引用", 不是"拒绝执行":
2558
- // 完全相同的返回从第 2 次起折叠成引用 (省上下文); 连续 3 次同参数同结果或检测到循环 ⇒
2559
- // 在结果尾部追加一条系统提示 (保留模型的选择权, 不硬停 —— 硬停那条已被用户否决)。
2560
- try {
2561
- const obs = observeToolCall(stallState, {
2562
- toolName: toolCall.name,
2563
- sameSignatureBefore: this.lastToolSig === argsFingerprint(toolCall.args),
2564
- args: toolCall.args,
2565
- resultText: String(result.output || (result.success ? '' : String(result.error || ''))),
2566
- ok: !!result.success,
2567
- seenResultBefore: stallState.hasSeenResult(String(result.output || '')),
2568
- });
2569
- if (obs.stub) {
2570
- result = { ...result, output: obs.stub, _stubbed: true };
2571
- }
2572
- else if (obs.notice) {
2573
- result = { ...result, output: `${String(result.output || '')}\n\n${obs.notice}` };
2574
- }
2575
- if (obs.action === 'warn') {
2576
- console.warn(`[PiAgent] 工具停滞引导 (${obs.code}, 第 ${obs.count} 次): ${toolCall.name}`);
2577
- onStream?.({ type: 'status', internal: true, content: `🩺 检测到重复调用 ${toolCall.name} (${obs.code}), 已提示模型换法`, tool: 'loop' });
2578
- }
2579
- }
2580
- catch { /* 观测失败绝不影响主路径 */ }
2581
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(result), toolResult: result, toolCallId: toolCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` });
2582
- // 2026-10-01 (用户: 「为什么这么慢」): 量到的事实 —— 每次 LLM 往返平均 3338ms,
2583
- // 而"一轮发多个工具"(toolCalls>1) 只占 12/63 ✗ ⇒ 回合时长 ≈ 往返次数 × 3.3 秒 ✓。
2584
- // 本回合**第一次**且**只发一个**工具时, 附一句带代价的提醒(不是口号 ✓), 只提一次免得刷屏。
2585
- if (toolCalls.length === 1 && this.successfulToolResults.length <= 1) { // 本回合头一次(免得每轮刷屏)
2586
- const __batchHint = batchHint(toolCall.name, 1);
2587
- if (__batchHint)
2588
- this.messageHistory.push({ role: 'system', content: __batchHint.trim() });
2589
- }
2590
- this.logToHarness(toolCall.name, toolCall.args, result);
2591
- // 2026-08-09: 记录到本轮行动日志 (循环进度 + final 前目标核查用)
2592
- // 去重: 同一工具同 args 连续成功只记一次 (防 LLM 重复 react 刷屏)
2593
- const argsPreview = JSON.stringify(toolCall.args || {}).slice(0, 120);
2594
- const isDup = loopActionLog.some((a) => a.tool === toolCall.name && a.argsPreview === argsPreview && a.success === !!result.success);
2595
- if (!isDup) {
2596
- loopActionLog.push({
2597
- tool: toolCall.name,
2598
- argsPreview,
2599
- resultPreview: result.success
2600
- ? String(result.output || '(无输出)').slice(0, 200)
2601
- : String(result.error || 'failed').slice(0, 200),
2602
- success: !!result.success,
2603
- });
2604
- }
2605
- if (onStream) {
2606
- if (result.success) {
2607
- onStream({ type: 'status', internal: true, content: `✅ ${toolCall.name} 执行成功`, tool: toolCall.name });
2608
- if (result.output) {
2609
- onStream({ type: 'tool', content: `📤 结果: ${result.output.substring(0, 200)}${result.output.length > 200 ? '...' : ''}`, tool: toolCall.name });
2610
- }
2611
- onStream({ type: 'step_done', content: `${toolCall.name} 执行成功`, tool: toolCall.name, success: true, output: result.output });
2612
- }
2613
- else {
2614
- onStream({ type: 'error', content: `❌ ${toolCall.name} 执行失败: ${result.error}`, tool: toolCall.name });
2615
- onStream({ type: 'step_error', content: `${toolCall.name} 执行失败`, tool: toolCall.name, error: result.error });
2616
- }
2617
- }
2618
- // 2026-10-01 (优化 #1/#3/#4): 工具结果的**进上下文闸** + **遥测** + **写操作读回自证**
2619
- {
2620
- const __ms = Date.now() - Number(toolCall.__t0 || Date.now());
2621
- try {
2622
- // #1 结果闸: 超上限 ⇒ 头尾 + 完整结果落文件(给路径) ⇒ 上下文不再被一条大输出长期占住
2623
- const capped = capToolResult(String(result.output ?? ''), { tool: toolCall.name });
2624
- if (capped.capped) {
2625
- result.output = capped.text;
2626
- console.warn(`[PiAgent] ${toolCall.name} 结果过长(${capped.originalChars})已截断` + (capped.spilledTo ? `, 完整内容: ${capped.spilledTo}` : ''));
2627
- }
2628
- // #4 写操作"读回自证": 写类工具成功后自动核一次(存在? 大小?) ⇒ "工具说成功≠任务成功"从规矩变成机制
2629
- // 2026-10-01: 改了 TS 源码就记账 ⇒ 回合收尾自动跑类型检查 (把"靠自觉"变成机制)
2630
- {
2631
- const tsFile = codeWriteTarget(toolCall.name, toolCall.args);
2632
- if (tsFile && !this.tsTouchedThisTurn.includes(tsFile))
2633
- this.tsTouchedThisTurn.push(tsFile);
2634
- }
2635
- const verified = verifyWriteOutcome(toolCall.name, toolCall.args, this.cwd);
2636
- if (verified)
2637
- result.output = `${String(result.output ?? '')}\n${verified}`;
2638
- // #3 遥测: 记一行(工具/指纹/耗时/成败/结果大小/是否与上一次同签名) ⇒ 重复率可算
2639
- recordToolCall({
2640
- tool: toolCall.name,
2641
- sig: argsFingerprint(toolCall.args),
2642
- ms: __ms,
2643
- ok: true,
2644
- resultChars: String(result.output ?? '').length,
2645
- prevSig: this.lastToolSig ?? null,
2646
- });
2647
- this.lastToolSig = argsFingerprint(toolCall.args);
2648
- }
2649
- catch { /* 任何优化项失败都不影响工具结果本身 */ }
2650
- }
2651
- if (result.success) {
2652
- consecutiveErrors = 0;
2653
- // 2026-07-29: Hermes 风格硬限制计数
2654
- totalToolCallsThisLoop++;
2655
- // 2026-10-01: 批处理工具(一次顶多次) ⇒ **退还**这次迭代 (奖励批处理, 压零碎调用)
2656
- if (this.iterBudget && isRefundableTool(toolCall.name)) {
2657
- this.iterBudget.refund();
2658
- console.warn(`[PiAgent] ${toolCall.name} 是批量工具 ⇒ 退还 1 次迭代 (现 ${this.iterBudget.describe()})`);
2659
- }
2660
- lastNTools.push(toolCall.name);
2661
- if (lastNTools.length > MAX_IDEMPOTENT_TOOL)
2662
- lastNTools.shift();
2663
- if (result.output) {
2664
- this.successfulToolResults.push({ tool: toolCall.name, outputPreview: result.output.substring(0, 200) + (result.output.length > 200 ? '...' : '') });
2665
- }
2666
- else {
2667
- this.successfulToolResults.push({ tool: toolCall.name, outputPreview: '(无输出)' });
2668
- }
2669
- loopReviewCompletedTools.add(toolCall.name);
2670
- lastQualityScore = this.estimateToolResultQuality(result);
2671
- if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
2672
- refineAttempts++;
2673
- }
2674
- if (onStream) {
2675
- onStream({ type: 'status', internal: true, content: `🔄 工具执行完成,继续循环...`, tool: 'loop' });
2676
- }
2677
- }
2678
- else {
2679
- consecutiveErrors++;
2680
- // 2026-10-01 (优化 #3): 失败也记一行(带错误类别) ⇒ "错工具率"从这类错误里看得出来
2681
- try {
2682
- const __cls = classifyError(String(result.error || '')).label || '未分类';
2683
- recordToolCall({ tool: toolCall.name, sig: argsFingerprint(toolCall.args), ms: 0,
2684
- ok: false, resultChars: 0, prevSig: this.lastToolSig ?? null, errorClass: __cls });
2685
- this.lastToolSig = argsFingerprint(toolCall.args);
2686
- }
2687
- catch { /* 遥测失败不影响 */ }
2688
- totalErrors++;
2689
- if (toolCall.name === lastFailedTool) {
2690
- lastFailedToolCount++;
2691
- }
2692
- else {
2693
- lastFailedTool = toolCall.name;
2694
- lastFailedToolCount = 1;
2695
- }
2696
- console.warn(`[PiAgent] 工具 ${toolCall.name} 执行失败 (${lastFailedToolCount}/${MAX_SAME_TOOL_FAILURES}, 累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}): ${result.error}`);
2697
- // 2026-07-28: 注入 Observation + Reflection 替代旧 hardcode 提示
2698
- const obs = buildObservation(toolCall.name, toolCall.args, { success: false, error: result.error });
2699
- const ref = buildReflection(toolCall.name, result.error, totalErrors, lastFailedToolCount);
2700
- this.messageHistory.push({ role: 'system', content: formatObservationWithReflection(obs, ref) + SHELL_ESCAPE_HINT });
2701
- if (onStream)
2702
- onStream({ type: 'status', content: `💡 Reflection: ${obs.summary} → ${ref[0]?.action || '放弃'}`, tool: 'system' });
2703
- if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
2704
- this.messageHistory.push({ role: 'system', content: `[注意] 工具 ${toolCall.name} 在这个上下文中不可用 (连续 ${MAX_SAME_TOOL_FAILURES} 次失败: ${result.error}). 请不要再次调用它, 直接用你已知的信息回答用户, 并在回答开头标记 <final gen>.` });
2705
- lastFailedTool = '';
2706
- lastFailedToolCount = 0;
2707
- consecutiveErrors = 0;
2708
- return;
2709
- }
2710
- if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2711
- this.messageHistory.push({ role: 'system', content: `[注意] 前面的工具调用连续失败。请尝试其他工具或换一种方式完成用户请求, 或用 <final gen> 给出最终回答.` });
2712
- consecutiveErrors = 0;
2713
- }
2714
- }
2715
- }
2716
- catch (execError) {
2717
- consecutiveErrors++;
2718
- totalErrors++;
2719
- const errorResult = { success: false, error: String(execError) };
2720
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(errorResult), toolResult: errorResult });
2721
- this.logToHarness(toolCall.name, toolCall.args, errorResult);
2722
- const obs = buildObservation(toolCall.name, toolCall.args, errorResult);
2723
- const ref = buildReflection(toolCall.name, errorResult.error, totalErrors, lastFailedToolCount);
2724
- this.messageHistory.push({ role: 'system', content: formatObservationWithReflection(obs, ref) + SHELL_ESCAPE_HINT });
2725
- if (onStream)
2726
- onStream({ type: 'status', content: `💡 Reflection: ${obs.summary}`, tool: 'system' });
2727
- console.error(`[PiAgent] 工具执行异常 (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}): ${execError}`);
2728
- }
2729
- })); // end Promise.all(toolCalls.map(async ...))
2730
- // 所有工具执行完毕后, continue while 循环, 让 LLM 看到结果
2731
- continue;
2732
- }
2733
- else {
2734
- // LLM 返回的不是 tool call 格式
2735
- this.messageHistory.push({
2736
- role: 'assistant',
2737
- content: reply,
2738
- reasoningContent: response?.reasoningContent,
2739
- });
2740
- // 通知前端收到非工具调用回复 (2026-08-09: 完整内容, 不再截断 150)
2741
- if (onStream) {
2742
- onStream({ type: 'token', content: reply });
2743
- }
2744
- // 2026-06-19 架构 fix: 只有 strip <think> 后才检查 isFinalResponse
2745
- // (parseToolCall 已先尝试, 既然没解析出 tool_call, 现在检查 final gen 是否真的在最终回答区)
2746
- if (this.isFinalResponse(reply)) {
2747
- // 2026-06-19 dive-into 风格修复: 如果还有 successful tool results 没汇报,
2748
- // LLM 不能提前 final_gen — harness 自动注入"请汇报剩余工具结果" hint 再 continue
2749
- // 这是 dive-into 文档"step 9 stop condition check" 的具体化:
2750
- // stop condition = (有工具结果未汇报) ? continue : break
2751
- // 检查回复是否包含工具结果内容(避免无限循环)
2752
- const hasToolResultContent = this.successfulToolResults.some(r => reply.includes(r.tool) || reply.includes(r.outputPreview.substring(0, 50)));
2753
- // 如果回复包含工具结果内容,清除 successfulToolResults
2754
- if (hasToolResultContent) {
2755
- console.log(`[PiAgent] 回复包含工具结果内容, 清除 successfulToolResults (${this.successfulToolResults.length} 个)`);
2756
- this.successfulToolResults = [];
2757
- }
2758
- // 2026-08-10: 逃生门 — decideUnreported: 未达上限 → 再提示一次; 超限 → 清空积压强制 final (防死循环)
2759
- const unreportedDecision = decideUnreported(this.successfulToolResults.length, unreportedRetries, MAX_UNREPORTED_RETRIES);
2760
- if (unreportedDecision === 'retry' && iteration < this.MAX_REACT_ITERATIONS) {
2761
- unreportedRetries++;
2762
- const unreported = this.successfulToolResults.length;
2763
- console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES}), push hint 让其继续`);
2764
- this.messageHistory.push({
2765
- role: 'system',
2766
- content: `[dive-into stop condition] 你之前已成功执行了 ${unreported} 个工具, 但当前回复里没把它们的结果告诉用户. 请基于已有的工具结果 (在 history 里) 写一个完整总结回复给用户, 用 <final gen> 结尾. 不要再调工具.`
2767
- });
2768
- if (onStream) {
2769
- onStream({ type: 'status', internal: true, content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES})`, tool: 'system' });
2770
- }
2771
- continue;
2772
- }
2773
- else if (unreportedDecision === 'force-final') {
2774
- // 反复提示仍未汇报超过上限 → 清空积压强制 final, 不再死循环
2775
- console.log(`[PiAgent] unreported 循环超限 (${unreportedRetries} 次), 清空积压强制 final`);
2776
- this.successfulToolResults = [];
2777
- this.messageHistory.push({
2778
- role: 'system',
2779
- content: `[dive-into stop condition] 已多次提示汇报工具结果仍未完成 (超过 ${MAX_UNREPORTED_RETRIES} 次). 现在直接基于你已知的信息写最终回复给用户, 用 <final gen> 结尾, 不要再调任何工具.`
2780
- });
2781
- if (onStream) {
2782
- onStream({ type: 'status', internal: true, content: `🔄 工具结果汇报超限, 强制收尾`, tool: 'system' });
2783
- }
2224
+ catch (gateErr) {
2225
+ tscAllowed = false;
2226
+ tscWhy = `harness-error: ${String(gateErr?.message || gateErr)}`;
2784
2227
  }
2785
- lastQualityScore = this.estimateResponseQuality(reply);
2786
- // 2026-07-29: 质量门 — 即使 LLM 声称完成, 质量太低也继续
2787
- if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
2788
- console.log(`[PiAgent] final gen 质量 ${lastQualityScore.toFixed(2)} < ${this.QUALITY_THRESHOLD}, 注入 refine hint`);
2789
- this.messageHistory.push({ role: 'system', content: `[质量检查] 你的回答质量评分为 ${(lastQualityScore * 10).toFixed(1)}/10, 低于 ${(this.QUALITY_THRESHOLD * 10).toFixed(1)}/10 阈值。请提供更完整、详细的回答, 包含工具调用获取到的具体信息, 末尾加 <final gen>。` });
2790
- refineAttempts++;
2791
- continue;
2792
- }
2793
- // 2026-08-08: final 前目标对齐 review — 不潦草收尾 (见 loop-review.ts)
2794
- // LLM 想 <final gen> 时, 先跑 1-2 次「目标对齐 + 需求深挖」review;
2795
- // 达成用户需求才放行真正结束. 达上限或无需深挖则以用户需求为准结束.
2796
- // 2026-09-16 (Milestone 1-B): 目标审查走唯一门面 (loop-review 是 Harness 的一个环节)
2797
- const reviewDecision = this.piHarness().reviewFinal({
2798
- reviewsDone: loopReviewCount,
2799
- // 2026-08-10: 传用户原始输入 (不是派生 intentHint) — LLM 对照原文自查完成度,
2800
- // 未完成 → 自动继续调工具 (自动触发后续步骤)
2801
- userIntent: this.currentUserInput,
2802
- completedTools: Array.from(loopReviewCompletedTools),
2803
- actionLog: loopActionLog,
2804
- runId: this.currentRunId || undefined,
2805
- goalId: this.currentGoalId || undefined,
2806
- }, DEFAULT_MAX_REVIEWS);
2807
- if (reviewDecision.kind === 'continue-review') {
2808
- loopReviewCount++;
2809
- console.log(`[PiAgent] review ${loopReviewCount}/${DEFAULT_MAX_REVIEWS}: LLM 想 final 但先对齐需求深挖一次`);
2810
- this.messageHistory.push({ role: 'system', content: reviewDecision.hint });
2811
- if (onStream) {
2812
- onStream({ type: 'status', internal: true, content: `🔄 目标对齐 review ${loopReviewCount}/${DEFAULT_MAX_REVIEWS}: 深挖续跑`, tool: 'system' });
2813
- }
2814
- continue; // 让 LLM 看到 hint, 深挖或确认完成后再次 final
2815
- }
2816
- finalResponse = this.extractFinalAnswer(reply);
2817
- break;
2818
- }
2819
- // 检查是否需要继续循环处理
2820
- // 更严格的判断:只有当回复明确表示需要更多信息时才继续
2821
- const containsToolCallIntent = reply.includes('调用工具') || reply.includes('tool(') ||
2822
- reply.includes('使用工具') || reply.includes('需要获取') || reply.includes('需要查看') ||
2823
- // 兼容 LLM 用对象字面量输出 tool call (上轮没解析成功时, 至少要继续)
2824
- reply.includes('tool =>') || reply.includes('[TOOL_CALL]') ||
2825
- // 2026-06-15 修: 兼容 LLM 用 XML 标签输出 tool call (<shell_exec>...</shell_exec>)
2826
- // 这时 parseToolCall 失败, 至少要让 loop 继续
2827
- /<\w+>[\s\S]*?<\/\w+>/.test(reply);
2828
- const hasError = ['不存在', '找不到', '无法找到', 'not found', 'does not exist',
2829
- '错误', 'error', '失败', 'failed'].some(k => reply.includes(k));
2830
- const isTooShort = reply.length < 50 && reply.length > 0;
2831
- const hasQuestion = reply.includes('?') && (reply.includes('怎么') || reply.includes('如何') || reply.includes('什么'));
2832
- const needsMoreWork = hasError || containsToolCallIntent || isTooShort || hasQuestion;
2833
- if (needsMoreWork && iteration < this.MAX_REACT_ITERATIONS) {
2834
- console.log(`[PiAgent] 继续循环处理 (${iteration}/${this.MAX_REACT_ITERATIONS}): needsMoreWork=${needsMoreWork}, hasError=${hasError}, containsToolCallIntent=${containsToolCallIntent}`);
2835
- if (onStream) {
2836
- onStream({ type: 'status', internal: true, content: `🔄 继续处理,循环 ${iteration}...`, tool: 'loop' });
2837
- }
2838
- continue;
2839
- }
2840
- // 否则把这个当作可能的最终回答
2841
- finalResponse = reply;
2842
- if (onStream) {
2843
- onStream({ type: 'status', internal: true, content: `📝 提取最终回答,长度 ${reply.length}`, tool: 'system' });
2844
- }
2845
- break;
2846
- }
2847
- }
2848
- if (!finalResponse) {
2849
- // 走到这里通常是 LLM 一直在调同一个不存在的工具, 没输出 <final gen>
2850
- // 把已知的失败信息也带回去, 让用户知道发生了什么
2851
- const reason = lastFailedTool
2852
- ? `(工具 ${lastFailedTool} 连续 ${MAX_SAME_TOOL_FAILURES} 次失败, 已放弃)`
2853
- : `(共 ${iteration - 1} 轮无最终输出)`;
2854
- finalResponse = `抱歉,任务未能完成 ${reason}。请换个方式提问,或明确告诉 agent 不要调用工具。`;
2855
- if (onStream) {
2856
- onStream({ type: 'error', content: `⚠️ 任务未完成: ${reason}`, tool: 'system' });
2857
- }
2858
- }
2859
- // 通知前端循环完成
2860
- if (onStream) {
2861
- onStream({ type: 'status', internal: true, content: `✅ 处理完成,共 ${iteration - 1} 次循环`, tool: 'system' });
2862
- }
2863
- this.messageHistory.push({ role: 'assistant', content: finalResponse });
2864
- // React Harness: 循环结束
2865
- // 2026-09-16 (Milestone 1-B): 会话收尾走唯一门面
2866
- await this.piHarness().sessionEnd(this.harnessCtx());
2867
- // 2026-09-16: 收尾落盘 — done / failed / aborted / needs_human 如实写回 (不留幽灵 running)
2868
- if (this.currentRunId && !runExternallyPaused && !runExternallyAborted) {
2869
- try {
2870
- // 2026-09-16 (M4): 完成门 —— "模型说完成"不等于"系统确认完成"。
2871
- // 证据 = 成功步骤的事实摘要; 末尾还有失败步骤没被后续成功覆盖 → 不许 done。
2872
- const recBefore = await readRun(this.currentRunId).catch(() => null);
2873
- const steps = recBefore?.steps || [];
2874
- const evidence = steps.filter((s) => s.ok).map((s) => `${s.tool}: ${(s.summary || '').slice(0, 120)}`).slice(-10);
2875
- const lastStep = steps[steps.length - 1];
2876
- const trailingFailure = !!lastStep && !lastStep.ok;
2877
- /** 有工具步骤却一条成功证据都没有 → 不算完成 (证据门槛, 不是文案) */
2878
- const noEvidence = steps.length > 0 && evidence.length === 0;
2879
- const errText = runPersistenceFailure || this.breakerReason || runNeedsHuman || runStopReason || aiFailureReason || '';
2880
- const cls = errText ? classifyRunError(errText) : 'unknown';
2881
- // 持久化失败 / 熔断 / 鉴权 → needs_human; 预算/中止 → aborted; 末尾仍失败或无证据 → failed (不许 done)
2882
- const status = runPersistenceFailure
2883
- ? 'needs_human'
2884
- : (this.breakerReason || runNeedsHuman)
2885
- ? 'needs_human'
2886
- : runStopReason
2887
- ? 'aborted'
2888
- : aiFailed
2889
- ? (cls === 'auth' ? 'needs_human' : 'failed')
2890
- : (trailingFailure || noEvidence)
2891
- ? 'failed'
2892
- : 'done';
2893
- await finishRun(this.currentRunId, {
2894
- status,
2895
- summary: finalResponse ? String(finalResponse).slice(0, 400) : undefined,
2896
- error: errText
2897
- || (trailingFailure ? `末尾步骤失败 (${lastStep.tool}): ${(lastStep.error || '').slice(0, 150)}` : undefined)
2898
- || (noEvidence ? '有工具步骤但没有任何成功证据: 不许判 done' : undefined),
2899
- evidence,
2900
- });
2901
- }
2902
- catch (err) {
2903
- // 终态也写不下去: 这是最坏情况 —— 除了留痕, 没有别的自愈手段, 所以必须显眼
2904
- const message = `收尾落盘失败 (状态无法写回, 记录会停在 running): ${String(err?.message || err).slice(0, 200)}`;
2905
- console.error('[PiAgent] run-store finishRun 失败 (核心持久化):', message);
2906
- await recordDegradation({ kind: 'core', op: 'pi-sdk.finishRun', runId: this.currentRunId, message }).catch(() => { });
2907
- onStream?.({ type: 'error', content: `⚠️ ${message}`, tool: 'harness' });
2908
- }
2909
- // ★ 2026-09-25 (M0 接线冻结, 规则 ③ + ④): Run 结束**必过收尾漏斗**。
2910
- //
2911
- // 旧写法在这里另起了一套 Goal 侧收尾 (读证据 → `evaluateGoalCompletion` →
2912
- // `completeGoalIfEligible` / `setUnresolved`): 它**不收尾** —— 不写 Memory、不生成 Skill 候选、
2913
- // 不写权威 continuation、不留决策记录。于是同一条 Run 在 Supervisor 那里还会再收一次 (或反过来
2914
- // 这条路径根本不收), 全仓就有了两套"这一轮跑完意味着什么"。那一套已删除。
2915
- //
2916
- // 现在: `closeRunOnce` (幂等) → 9 步收尾 → 产物落盘; 再由 Goal reducer 把"下一步是什么"
2917
- // 落进 Goal (没有 Supervisor 宿主时 Runner 也要留下 continuation, 否则下一次还是从零开始)。
2918
- if (this.currentGoalId) {
2919
- try {
2920
- const wiring = await import('./goal-flywheel-wiring.js');
2921
- const { reduceGoalState } = await import('./goal-state-reducer.js');
2922
- const nowIso = new Date().toISOString();
2923
- const closed = await wiring.closeRunOnce({
2924
- goalId: this.currentGoalId,
2925
- runId: this.currentRunId,
2926
- caller: 'runner',
2927
- now: nowIso,
2928
- finalReview: finalResponse ? String(finalResponse).slice(0, 2000) : '',
2929
- });
2930
- if (wiring.isRefusal(closed)) {
2931
- onStream?.({ type: 'status', content: `⚠️ 收尾被拒 (不当作收过): ${closed.reason}`, tool: 'harness' });
2932
- }
2933
- else if (closed.outcome) {
2934
- const view = closed.outcome;
2935
- const applied = await reduceGoalState({
2936
- goalId: this.currentGoalId,
2937
- intent: 'closure_outcome',
2938
- now: nowIso,
2939
- by: 'runner',
2940
- outcome: {
2941
- goalStatus: wiring.goalStatusFromDecision(view.decision),
2942
- continuation: wiring.toGoalStoreContinuation(view.continuation),
2943
- reason: view.decision.reason,
2944
- },
2945
- runId: view.runId,
2946
- });
2947
- const line = view.decision.decision === 'complete'
2948
- ? `🎯 目标已达成 (仍要过完成门): ${view.decision.reason}`
2949
- : `🎯 目标仍在进行 (未判完成): ${view.decision.reason}`;
2950
- onStream?.({
2951
- type: 'status', internal: true,
2952
- content: `${line}${applied.gateRejected ? ` [完成门拒绝: ${applied.gateRejected}]` : ''} `
2953
- + `(收尾 ${view.steps} 步 · memory ${view.memories} · skill 候选 ${view.candidates} · goal=${this.currentGoalId})`,
2954
- tool: 'harness',
2955
- });
2228
+ if (!tscAllowed) {
2229
+ this.runCtx.eventSink?.({ type: 'status', content: `🔎 类型检查被门拒绝, 未执行: ${tscWhy.slice(0, 120)}`, tool: 'system' });
2956
2230
  }
2957
2231
  else {
2958
- // 幂等: 别处 (或上一次) 已经收过尾 —— 事实读不回来就如实说, 不重收也不假装
2959
- onStream?.({ type: 'status', content: `🎯 ${closed.reason}`, tool: 'harness' });
2232
+ const r = await tscTool.execute({});
2233
+ const line = formatTypecheckResult(r?.success !== false && !/error TS\d+/.test(String(r?.output || '')), String(r?.output || ''));
2234
+ this.runCtx.eventSink?.({ type: 'status', content: `🔎 ${line}`, tool: 'system' });
2960
2235
  }
2961
2236
  }
2962
- catch (err) {
2963
- await recordDegradation({ kind: 'observational', op: 'pi-sdk.runClosure', runId: this.currentRunId, message: String(err?.message || err).slice(0, 160) }).catch(() => { });
2964
- }
2965
2237
  }
2966
- this.currentRunId = '';
2967
- }
2968
- else if (this.currentRunId) {
2969
- // 外部暂停/中止: 状态是人定的, 不覆盖 (paused 等 /resume; aborted 是终态)
2970
- onStream?.({ type: 'status', content: `⏹️ 运行状态保持为 ${runExternallyPaused ? 'paused' : 'aborted'} (由外部控制面决定)`, tool: 'harness' });
2971
- this.currentRunId = '';
2238
+ catch (e) {
2239
+ this.runCtx.eventSink?.({ type: 'status', content: `🔎 类型检查没能跑起来: ${String(e?.message || e).slice(0, 100)} (改动已落盘, 记得自己跑一次)`, tool: 'system' });
2240
+ }
2241
+ this.tsTouchedThisTurn = [];
2972
2242
  }
2973
- // 2026-06-16: 暴露 aiFailed 标志 — promptStream 据此决定是否自动重试整个 loop
2974
- return { reply: finalResponse, aiFailed, aiFailureReason: aiFailureReason || undefined };
2975
2243
  }
2244
+ // 2026-10-02 K4-B: 老 `runReActLoop` **已删除** (原 1123 行) —— 它曾经是"第二条 loop"
2245
+ // 并行存在的根源 (与 `WorkflowPivotLoop` 各跑一套, 行为按入口分叉: web=pivot / CLI=老 loop)。
2246
+ // 删除依据 (五条件): ① 唯一替代路径 = pivot (默认已切, 三处入口全走它) ② 全仓无引用 (只剩注释/测试名)
2247
+ // ③ 真跑覆盖 = CLI 非流式带工具改文件 ✓ · Web 流式改 TS + 自检 ✓ · 同通道并发 3 条 ✓ ④ 全量回归 ✓
2248
+ // ⑤ 可回滚提交点 = 本提交。
2249
+ // 它独有的记账 (TS 触达) 已上移到 pivot 的唯一执行点 `guardedExecute`, 口径归一。
2976
2250
  async deepThink(prompt) {
2977
2251
  const result = await this.thinkingEngine.think(prompt);
2978
2252
  let response = `深度思考完成(${result.depth}层):\n\n`;
@@ -3022,7 +2296,7 @@ ${await this.renderActivePlansSection()}
3022
2296
  // 失败静默: 任何 stage 抛错 → 走老 slice(-10) 逻辑
3023
2297
  //
3024
2298
  // P1.2: 如果 maybeAutoCompact 算过 Context Collapse 投影, 用 this.projectedHistory (读时投影, 非破坏)
3025
- const source = this.projectedHistory ?? this.messageHistory;
2299
+ const source = this.projectedHistory ?? this.actor.state.messageHistory;
3026
2300
  const recentMessages = this.compressHistorySync(source).slice(-10);
3027
2301
  return recentMessages.map(m => {
3028
2302
  if (m.role === 'user')
@@ -3049,7 +2323,7 @@ ${await this.renderActivePlansSection()}
3049
2323
  // 2026-08-06: 来源优先用 projectedHistory (Context Collapse 投影, 非破坏) —
3050
2324
  // 与 buildContext 一致; 之前只让字符串路径用投影, messages 数组路径被跳过,
3051
2325
  // 导致 LLM 实际看到的还是未压缩的历史.
3052
- const source = this.projectedHistory ?? this.messageHistory;
2326
+ const source = this.projectedHistory ?? this.actor.state.messageHistory;
3053
2327
  const WINDOW = 15;
3054
2328
  const out = [];
3055
2329
  // 2026-09-28 (前缀 KV 可命中): 带 CURRENT TURN 注入的条目要**原样回带**.
@@ -3128,11 +2402,11 @@ ${await this.renderActivePlansSection()}
3128
2402
  /**
3129
2403
  * 2026-09-28 (前缀 KV 可命中): 把 chat() 回带的当前轮写回自己的 messageHistory.
3130
2404
  * 逻辑在模块级 `writeBackCurrentTurnInto` (纯函数, 门可以直接驱它断言);
3131
- * 这里只负责接到 `this.messageHistory` 上, 并保证任何异常都静默 (写回失败不影响对话).
2405
+ * 这里只负责接到 `actor 的 messageHistory` 上, 并保证任何异常都静默 (写回失败不影响对话).
3132
2406
  */
3133
2407
  writeBackCurrentTurn(wire) {
3134
2408
  try {
3135
- return writeBackCurrentTurnInto(this.messageHistory, wire);
2409
+ return writeBackCurrentTurnInto(this.actor.state.messageHistory, wire);
3136
2410
  }
3137
2411
  catch (err) {
3138
2412
  console.warn('[PiAgent] writeBackCurrentTurn failed (silent):', err);
@@ -3146,7 +2420,7 @@ ${await this.renderActivePlansSection()}
3146
2420
  estimateHistoryTokens() {
3147
2421
  try {
3148
2422
  const { estimateTokens } = _piRequire('../context-compaction/index.js');
3149
- return estimateTokens(this.messageHistory);
2423
+ return estimateTokens(this.actor.state.messageHistory);
3150
2424
  }
3151
2425
  catch {
3152
2426
  return 0;
@@ -3169,8 +2443,10 @@ ${await this.renderActivePlansSection()}
3169
2443
  console.warn(`[PiAgent] reactive compaction pre-check (${estimated} tokens > 80% threshold)`);
3170
2444
  onStream?.({ type: 'status', internal: true, content: '⚠️ reactive compaction 预检触发', tool: 'recovery' });
3171
2445
  try {
3172
- const compacted = this.compressHistorySync(this.messageHistory);
3173
- this.messageHistory = compacted;
2446
+ // 同步压缩: 取快照与替换在**同一拍相邻两行** (中间没有 await) ⇒ 不存在"变换期间被追加"的窗口。
2447
+ // 若哪天这里插入 await, 必须改成 `actor.rebaseHistory(…, snapshotLen)` (见 channel-actor.ts)。
2448
+ const compacted = this.compressHistorySync(this.actor.state.messageHistory);
2449
+ this.replaceHistory(compacted);
3174
2450
  if (this.estimateHistoryTokens() > this.maxContextTokens() * 0.8) {
3175
2451
  await this.maybeAutoCompact(onStream, signal);
3176
2452
  }
@@ -3226,7 +2502,7 @@ ${await this.renderActivePlansSection()}
3226
2502
  try {
3227
2503
  const rc = String(response?.reasoningContent || '').trim();
3228
2504
  if (rc)
3229
- this.currentOnStream?.({ type: 'reasoning', content: rc });
2505
+ this.runCtx.eventSink?.({ type: 'reasoning', content: rc });
3230
2506
  }
3231
2507
  catch { /* 送不出去不影响主流程 */ } // 2026-06-30: 透传 toolCalls (OpenAI 协议 native) 给上层, 让 assistant message 能 emit 真 id
3232
2508
  return { reply: response.reply || '', toolCalls: response.toolCalls, messages: response.messages };
@@ -3266,8 +2542,8 @@ ${await this.renderActivePlansSection()}
3266
2542
  if (Array.isArray(contextOrMessages)) {
3267
2543
  contextOrMessages = this.buildContext();
3268
2544
  // 也清除最近一轮的 toolCalls, 防止再触发
3269
- if (this.messageHistory.length > 1) {
3270
- const last = this.messageHistory[this.messageHistory.length - 1];
2545
+ if (this.actor.state.messageHistory.length > 1) {
2546
+ const last = this.actor.state.messageHistory[this.actor.state.messageHistory.length - 1];
3271
2547
  if (last.role === 'assistant' && last.toolCalls) {
3272
2548
  delete last.toolCalls;
3273
2549
  }
@@ -3330,7 +2606,7 @@ ${await this.renderActivePlansSection()}
3330
2606
  * onStream 广播: 跟 computeJudgmentGate 风格一致 (phase 事件供 UI timeline 显示)
3331
2607
  */
3332
2608
  async maybeAutoCompact(onStream, signal) {
3333
- if (this.messageHistory.length < 10)
2609
+ if (this.actor.state.messageHistory.length < 10)
3334
2610
  return; // 历史太短, 不值得压
3335
2611
  onStream?.({ type: 'status', internal: true, content: '🗜️ 评估是否需要压缩上下文...', tool: 'compactor' });
3336
2612
  // 注入 LLM (用 getMinimax().chat, 与 judgment 注入门 / ReAct 循环同一来源)
@@ -3349,14 +2625,17 @@ ${await this.renderActivePlansSection()}
3349
2625
  const maxTokens = Math.max(4000, Math.round(cfg.maxTokens * cfg.compressionThreshold));
3350
2626
  const beforeTokens = this.estimateHistoryTokens();
3351
2627
  const { compactPipeline, isContextCollapseEnabled } = await import('../context-compaction/index.js');
3352
- const result = await compactPipeline(this.messageHistory, {
2628
+ // **K5 第 4 步**: 记下取快照时的长度 —— 压缩是 async, 从这一拍到 "落地" 之间是 await 窗口;
2629
+ // 期间 append 进来的消息必须由 `actor.rebaseHistory(…, snapshotLen)` 接回去, 否则会被整块替换丢掉。
2630
+ const snapshotLen = this.actor.state.messageHistory.length;
2631
+ const result = await compactPipeline(this.actor.state.messageHistory, {
3353
2632
  maxTokens,
3354
2633
  llmChat,
3355
2634
  collapseLlmChat: llmChat, // P1.2: Context Collapse 投影也用同一 LLM
3356
- cacheScope: this.currentChannelId || 'default',
2635
+ cacheScope: this.actor.state.channelId || 'default',
3357
2636
  });
3358
- if (result.compacted && result.history.length < this.messageHistory.length) {
3359
- const saved = this.messageHistory.length - result.history.length;
2637
+ if (result.compacted && result.history.length < this.actor.state.messageHistory.length) {
2638
+ const saved = this.actor.state.messageHistory.length - result.history.length;
3360
2639
  const stagesApplied = result.stages.filter((s) => s.applied).map((s) => s.stage).join(' → ');
3361
2640
  const afterTokens = this.estimateHistoryTokens();
3362
2641
  const savedTokens = Math.max(0, beforeTokens - afterTokens);
@@ -3374,7 +2653,17 @@ ${await this.renderActivePlansSection()}
3374
2653
  // messageHistory 不变 (非破坏)
3375
2654
  }
3376
2655
  else {
3377
- this.messageHistory = result.history; // 真破坏性更新
2656
+ // 真破坏性更新 —— **K5 第 4 步**: 绑定了 actor 就交它落地 (rebase: 保住变换期间的追加);
2657
+ // 未绑定的会话走原路径 (行为不变)。
2658
+ if (this.actor) {
2659
+ const { keptTail } = await this.actor.rebaseHistory(result.history, snapshotLen);
2660
+ if (keptTail > 0) {
2661
+ console.warn(`[PiAgent] 压缩落地: 保住变换期间新追加的 ${keptTail} 条消息 (快照后到达)`);
2662
+ }
2663
+ }
2664
+ else {
2665
+ this.replaceHistory(result.history);
2666
+ }
3378
2667
  this.projectedHistory = null;
3379
2668
  }
3380
2669
  // 2026-08-06: snapshot 记录 before/after + 摘要 (供恢复/调试/UI), 事件广播
@@ -3385,10 +2674,10 @@ ${await this.renderActivePlansSection()}
3385
2674
  afterTokens,
3386
2675
  summary: `压缩管道: ${summaryLine}; 节省 ${savedTokens} tokens / ${saved} 条消息`,
3387
2676
  preservedMemory: [
3388
- ...this.messageHistory.filter(m => m.role === 'user').slice(-3).map(m => (m.content || '').slice(0, 80)),
2677
+ ...this.actor.state.messageHistory.filter(m => m.role === 'user').slice(-3).map(m => (m.content || '').slice(0, 80)),
3389
2678
  ],
3390
- agentId: this.currentAgentId,
3391
- channelId: this.currentChannelId,
2679
+ agentId: this.actor.state.agentId,
2680
+ channelId: this.actor.state.channelId,
3392
2681
  });
3393
2682
  cm.markCompressComplete(snap);
3394
2683
  }
@@ -3754,21 +3043,46 @@ ${this.extractOperationsFromRef(operationsRef)}
3754
3043
  getOperationLogs() {
3755
3044
  return this.constraintLayer.getLogs();
3756
3045
  }
3046
+ /**
3047
+ * K10 ④: 通信端口的**注入面** —— 传输实现仍在本仓网络层 (`src/network/**`), 内核只认形状。
3048
+ * 换传输 (hyperswarm → iroh → …) 时只改这一处; 上层一律走 `submitTransport`。
3049
+ */
3050
+ transportPorts() {
3051
+ return {
3052
+ send: (peerId, kind, payload) => p2pNetwork.sendMessage(peerId, kind, payload),
3053
+ broadcast: (kind, payload) => p2pNetwork.broadcast(kind, payload),
3054
+ peers: () => p2pNetwork.getPeers(),
3055
+ peersSync: () => p2pNetwork.getPeers(),
3056
+ };
3057
+ }
3757
3058
  listPeers() {
3758
- const peers = p2pNetwork.getPeers();
3059
+ // K10 ④ 后半: 同步读也经端口 (`transportPeersSync`); 端口未注入 ⇒ 明确说"读不到", 不假装"没有对端"
3060
+ const peers = transportPeersSync(this.transportPorts());
3061
+ if (peers === null)
3062
+ return '(通信端口未注入: 读不到对端列表 —— 不是"没有对端", 是没接上)';
3759
3063
  if (peers.length === 0) {
3760
3064
  return '当前无连接的对等节点';
3761
3065
  }
3762
3066
  return `已连接节点 (${peers.length}):\n${peers.map(p => ` - ${p}`).join('\n')}`;
3763
3067
  }
3764
3068
  getPeers() {
3069
+ const peers = transportPeersSync(this.transportPorts());
3070
+ if (peers !== null)
3071
+ return peers;
3072
+ // 端口未注入时**回落到传输直读**并留痕: 同步读路径没有"拒绝"的余地, 静默返回空列表会被误读成"没有对端"
3073
+ console.warn('[kernel-transport] peersSync 端口未注入 ⇒ 回落直读传输 (K10 ④ 未接线的调用点)');
3765
3074
  return p2pNetwork.getPeers();
3766
3075
  }
3767
3076
  async sendMessage(peerId, message) {
3768
- await p2pNetwork.sendMessage(peerId, 'message', message);
3077
+ // K10 ④: 经内核通信端口。发送有副作用 ⇒ 未注入/被拒**一律响亮** (静默丢消息比报错更糟)
3078
+ const out = await submitTransport({ op: 'send', origin: 'pi-session', peerId, kind: 'message', payload: message }, this.transportPorts());
3079
+ if (!out.ok)
3080
+ throw new Error(`[kernel-transport] send 未发出: ${out.detail ?? '未知原因'}`);
3769
3081
  }
3770
3082
  async broadcast(message) {
3771
- await p2pNetwork.broadcast('message', message);
3083
+ const out = await submitTransport({ op: 'broadcast', origin: 'pi-session', kind: 'message', payload: message }, this.transportPorts());
3084
+ if (!out.ok)
3085
+ throw new Error(`[kernel-transport] broadcast 未发出: ${out.detail ?? '未知原因'}`);
3772
3086
  }
3773
3087
  getIdentity() {
3774
3088
  return { ...this.identity };
@@ -3783,7 +3097,7 @@ ${this.extractOperationsFromRef(operationsRef)}
3783
3097
  Object.assign(this.identity, updates);
3784
3098
  }
3785
3099
  setCurrentChannelId(channelId) {
3786
- this.currentChannelId = channelId;
3100
+ this.actor.state.channelId = channelId;
3787
3101
  }
3788
3102
  getSessionState() {
3789
3103
  return this.sessionManager.getState();
@@ -3798,12 +3112,12 @@ ${this.extractOperationsFromRef(operationsRef)}
3798
3112
  await this.sessionManager.savePersona(persona);
3799
3113
  // 2026-10-01: **写透** —— 有身份文档的 agent 不套 persona.json, 只写 JSON 等于没改
3800
3114
  // (用户报"每次让智能体改都是同一个") ⇒ 同步落进它自己的身份文档 (标记区内)
3801
- if (this.currentAgentId) {
3115
+ if (this.actor.state.agentId) {
3802
3116
  try {
3803
3117
  const { applyPersonaToDocs } = await import('../bootstrap/persona-init.js');
3804
- const wrote = await applyPersonaToDocs(this.currentAgentId, persona);
3118
+ const wrote = await applyPersonaToDocs(this.actor.state.agentId, persona);
3805
3119
  if (wrote.length)
3806
- console.warn(`[persona] 已把 ${this.currentAgentId} 的 persona 写进身份文档: ${wrote.join(' · ')}`);
3120
+ console.warn(`[persona] 已把 ${this.actor.state.agentId} 的 persona 写进身份文档: ${wrote.join(' · ')}`);
3807
3121
  }
3808
3122
  catch { /* 非致命 */ }
3809
3123
  }
@@ -3865,13 +3179,58 @@ ${this.extractOperationsFromRef(operationsRef)}
3865
3179
  this.socialHeartbeat = null;
3866
3180
  }
3867
3181
  }
3182
+ /**
3183
+ * **受门包装**的 skill 面 (2026-10-02 · leo 口径 (b): 原裸出口已收口)。
3184
+ *
3185
+ * 为什么不能返回裸 registry: 拿到裸 registry 就有**三条**绕过 Harness 的路径 ——
3186
+ * ① `registry.execute(n, p)` ② `registry.get(n).execute(p)` ③ `registry.list()[i].execute(p)`
3187
+ * (后两条藏在返回的 `Skill` 对象里, 只看方法名看不出来)。
3188
+ * 现在三条**都**经本会话唯一受门口: `executeSkill` ⇒ `createSkillGuard()` ⇒ `piHarness().beforeToolCall()`,
3189
+ * 执行落在 registry 的唯一调用点 (**恰一次**), 被拒 ⇒ **零执行** 且返回 `拒绝: [rejectedBy] reason`。
3190
+ *
3191
+ * ⚠️ **行为变更 (可能属破坏性)**: 旧用法 `.execute('x', p)` 在**被拒时不再执行**, 改为返回拒绝串
3192
+ * (与 `executeSkill` 同形)。方法名/签名不变 ⇒ **源码级兼容**; 但"被拒即不执行"是新约定。
3193
+ *
3194
+ * **管不到的一条 (如实记)**: 调用方**自己**在 `register(skill)` 时传入的那个 skill 对象仍有裸 `execute`
3195
+ * —— 那是调用方的对象, 不属本出口能管的面。收口管的是**本出口给出的任何引用**。
3196
+ */
3868
3197
  getSkillRegistry() {
3869
- return this.skillRegistry;
3198
+ // 任何被交出去的 Skill 引用, 其 execute 都换成受门版本 (堵 ②③ 两条隐藏路径)
3199
+ const guarded = (sk) => ({
3200
+ name: sk.name,
3201
+ description: sk.description,
3202
+ execute: (params) => this.executeSkill(sk.name, params),
3203
+ });
3204
+ return {
3205
+ register: (sk) => this.skillRegistry.register(sk),
3206
+ unregister: (name) => this.skillRegistry.unregister(name),
3207
+ has: (name) => this.skillRegistry.has(name),
3208
+ get: (name) => { const sk = this.skillRegistry.get(name); return sk ? guarded(sk) : undefined; },
3209
+ list: () => this.skillRegistry.list().map(guarded),
3210
+ execute: (name, params) => this.executeSkill(name, params),
3211
+ };
3870
3212
  }
3871
3213
  registerSkill(skill) {
3872
3214
  this.skillRegistry.register(skill);
3873
3215
  }
3216
+ /**
3217
+ * 2026-10-02 (K7) **唯一 skill 执行口**:
3218
+ * 判定一律经 Harness (`createSkillGuard()` ⇒ `piHarness().beforeToolCall`, 身份带 runId/goalId/agentId/channelId);
3219
+ * 执行落在本会话 registry 的**唯一**调用点 (**恰一次**)。
3220
+ * **fail-closed**: 门抛错 ⇒ 返回 `拒绝: [harness-error] …`, **绝不回落**直调 registry。
3221
+ * 公开签名不变 (仍返回 string) ⇒ 不破坏已发布契约 (leo 的方案 a: 保留 API, 内部降为经门转发)。
3222
+ */
3874
3223
  async executeSkill(name, params) {
3224
+ let decision;
3225
+ try {
3226
+ decision = await this.createSkillGuard()(name, params);
3227
+ }
3228
+ catch (guardErr) {
3229
+ return `拒绝: [harness-error] ${String(guardErr?.message ?? guardErr)}`;
3230
+ }
3231
+ if (!decision.allow) {
3232
+ return `拒绝: [${decision.rejectedBy || 'harness'}] ${decision.reason || '未说明理由'}`;
3233
+ }
3875
3234
  return this.skillRegistry.execute(name, params);
3876
3235
  }
3877
3236
  async addUserAction(content, importance) {