@bolloon/bolloon-agent 0.6.0 → 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 (72) 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/pi-sdk-tools.js +18 -8
  4. package/dist/agents/pi-sdk.js +472 -1263
  5. package/dist/agents/runner-resolver.js +2 -1
  6. package/dist/agents/tool-capability/index.js +51 -0
  7. package/dist/agents/workflow-pivot-loop.js +31 -10
  8. package/dist/agents/write-verify.js +60 -0
  9. package/dist/bollharness-integration/integration.js +8 -0
  10. package/dist/bollharness-integration/skill-adapter.js +32 -3
  11. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +5 -1
  12. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -1
  13. package/dist/electron-build/agents/pi-sdk-tools.js +21 -11
  14. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  15. package/dist/electron-build/agents/pi-sdk.js +473 -1262
  16. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  17. package/dist/electron-build/agents/runner-resolver.js +2 -1
  18. package/dist/electron-build/agents/runner-resolver.js.map +1 -1
  19. package/dist/electron-build/agents/tool-capability/index.js +90 -0
  20. package/dist/electron-build/agents/tool-capability/index.js.map +1 -0
  21. package/dist/electron-build/agents/workflow-pivot-loop.js +31 -10
  22. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  23. package/dist/electron-build/agents/write-verify.js +99 -0
  24. package/dist/electron-build/agents/write-verify.js.map +1 -0
  25. package/dist/electron-build/bollharness-integration/integration.js +8 -0
  26. package/dist/electron-build/bollharness-integration/integration.js.map +1 -1
  27. package/dist/electron-build/bollharness-integration/skill-adapter.js +32 -3
  28. package/dist/electron-build/bollharness-integration/skill-adapter.js.map +1 -1
  29. package/dist/electron-build/kernel/channel-actor.js +25 -0
  30. package/dist/electron-build/kernel/channel-actor.js.map +1 -1
  31. package/dist/electron-build/kernel/control.js +102 -0
  32. package/dist/electron-build/kernel/control.js.map +1 -0
  33. package/dist/electron-build/kernel/model-runtime.js +508 -0
  34. package/dist/electron-build/kernel/model-runtime.js.map +1 -0
  35. package/dist/electron-build/kernel/run-lifecycle.js +146 -0
  36. package/dist/electron-build/kernel/run-lifecycle.js.map +1 -0
  37. package/dist/electron-build/kernel/transport.js +119 -0
  38. package/dist/electron-build/kernel/transport.js.map +1 -0
  39. package/dist/electron-build/llm/native-adapter.js +122 -0
  40. package/dist/electron-build/llm/native-adapter.js.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/ios/bolloon-web.json +3 -3
  43. package/dist/ios/client.js +13 -7
  44. package/dist/ios/mobile-core.js +7762 -6289
  45. package/dist/ios/mobile.js +64 -0
  46. package/dist/ios/routes-mobile-tasks.js +2 -1
  47. package/dist/ios/routes-tasks.js +16 -1
  48. package/dist/ios/routes-x402-info.js +28 -1
  49. package/dist/ios/server-storage.js +26 -5
  50. package/dist/ios/server.js +840 -862
  51. package/dist/ios/sw.js +1 -1
  52. package/dist/ios/ui/message-renderer.js +473 -29
  53. package/dist/ios/ui/step-timeline.js +13 -12
  54. package/dist/kernel/channel-actor.js +24 -0
  55. package/dist/kernel/gate-scan.js +318 -4
  56. package/dist/kernel/model-runtime.js +22 -0
  57. package/dist/kernel/plan-channel-actor.js +12 -5
  58. package/dist/kernel/plan-communication.js +163 -0
  59. package/dist/kernel/plan-constraint.js +2 -15
  60. package/dist/kernel/plan-deletion.js +1 -1
  61. package/dist/kernel/plan-harness.js +78 -0
  62. package/dist/kernel/plan.js +1 -5
  63. package/dist/kernel/roster.js +13 -11
  64. package/dist/kernel/run-lifecycle.js +138 -0
  65. package/dist/kernel/transport.js +111 -0
  66. package/dist/llm/native-adapter.js +117 -0
  67. package/dist/web/bolloon-web.json +3 -3
  68. package/dist/web/client.js +9 -5
  69. package/dist/web/routes-tasks.js +12 -1
  70. package/dist/web/server.js +802 -866
  71. package/dist/web/sw.js +1 -1
  72. package/package.json +1 -1
@@ -15,16 +15,21 @@ import * as os from 'os';
15
15
  import * as path from 'path';
16
16
  import { createRunContext } from './run-context.js';
17
17
  import { createPrivateActor } from '../kernel/channel-actor.js';
18
- import { expandHomeArgs } from './tool-path-args.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';
19
27
  import { renderDelegateNotices, pushNotice, renderNoticeBlock } from './background-notices.js';
20
28
  import { runWithWriteOrigin } from './skill-ledger.js';
21
29
  import { createHash } from 'node:crypto';
22
30
  import { shouldReviewTask, runExperienceReview } from './experience-review.js';
23
31
  import { decideLessonSink, skillsFromDirs, logLessonSuggestions, routeLessonToSkill } from './lesson-to-skill.js';
24
32
  import { managedServices } from './managed-services.js';
25
- import { IterationBudget, isRefundableTool } from './iteration-budget.js';
26
- import { recordToolCall, argsFingerprint } from './tool-telemetry.js';
27
- import { capToolResult } from './tool-result-gate.js';
28
33
  import { renderToolListWithParams } from './tool-subset.js';
29
34
  import { codeWriteTarget, decideTypecheck, formatTypecheckResult } from './code-write-gate.js';
30
35
  // 2026-10-01: 身份解析必须**静态导入** —— 之前在函数体里用 require(), 而打包后是 ESM ⇒
@@ -46,12 +51,11 @@ import { ConstraintLayer } from './constraint-layer.js';
46
51
  import { WorkflowEngine } from './workflow-engine.js';
47
52
  import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtime';
48
53
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
54
+ import { nativeAdapterFromEnv } from '../llm/native-adapter.js'; // K9: 第二个 (非 Pi) 推理适配器
49
55
  import { initDocumentReceiver } from './p2p-document-tools.js';
50
- import { startRun, recordStep, finishRun, readRun, budgetVerdict, recordDegradation, recordHarnessEvent, recordRecovery, setRunStatus, prepareResume, markRunRunning, buildResumeInstruction, argsDigestOf, repeatedFailureCount, classifyError as classifyRunError } from './run-store.js';
51
- 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';
52
58
  import { captureRunModelConfig } from '../llm/model-selection.js';
53
- // 2026-09-26: 工具名出网净化 (pi-ai.ts 唯一边界) + 回程派发还原 (原名 ↔ API 名)
54
- import { resolveApiToolName, expandKnownToolNames } from '../llm/tool-name.js';
55
59
  // 2026-09-28 (前缀 KV 可命中): CURRENT TURN 注入标记 —— "当前轮是否已注入"的唯一判据
56
60
  import { CURRENT_TURN_MARKER } from '../llm/pi-ai.js';
57
61
  import { PiAgentHarness } from './pi-harness.js';
@@ -80,18 +84,14 @@ import { getInjectionMaxChars } from '../bootstrap/exhaust-scrubber.js';
80
84
  import { monitorAfterReply } from '../pi-ecosystem-judgment/monitor-gate.js';
81
85
  // Bootstrap 生命周期 hook (SessionStart / Stop / PreToolUse)
82
86
  import { onSessionStart, onStop, onPreToolUse } from '../pi-ecosystem-judgment/human-value-pipeline.js';
83
- import { onPostToolUse } from '../bootstrap/lifecycle-hooks.js';
84
87
  import { budgetReduce, snip, microcompact } from '../context-compaction/index.js';
85
88
  // React Harness: 8-gate + 4-guard (防越权 / 防 prompt 注入)
86
89
  import { ReactHarness } from '../security/react-harness.js';
87
90
  import { HooksEngine } from '../hooks/hooks-engine.js';
88
91
  import { DenyPipeline } from './deny-pipeline.js';
89
- import { parseToolCall as parseToolCallImpl, parseAllToolCalls, isFinalResponse as isFinalResponseImpl, extractFinalAnswer as extractFinalAnswerImpl } from './parse-tool-call.js';
90
- 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';
91
93
  import { sessionStore as defaultSessionStore } from './session-store.js';
92
94
  import { ToolRegistry } from './tool-registry.js';
93
- import { decideMaxIterations, decideContextOverflow, shouldCompactBeforeIteration } from './react-loop.js';
94
- import { DEFAULT_MAX_REVIEWS } from './loop-review.js';
95
95
  // PiSessionManager 已抽到 ./pi-sdk-session-manager.ts (2026-07-06)
96
96
  // Tool / ToolResult / Message / StreamCallback / StreamEvent / HeartbeatConfig / AgentSession / TOOL_DEFINITIONS
97
97
  // 已抽到 ./pi-sdk-types.ts (2026-07-06)
@@ -149,32 +149,14 @@ export function writeBackCurrentTurnInto(history, wire) {
149
149
  return 0; // 写回失败绝不影响对话本身
150
150
  }
151
151
  }
152
- import { LoopStallState, observeToolCall, batchHint } from './tool-loop-guard.js';
153
152
  /**
154
- * 写操作"读回自证" (2026-10-01 优化 #4): 写类工具成功后**自动**核一次, 把事实拼进结果。
155
- * 为什么: 过程纪律写了"读回一次", 但**没人执行** ✗ ⇒ 现在由机制执行: "工具说成功 ≠ 任务成功"。
156
- * 只做**便宜**的核对(存在性/大小); 失败静默(核对不该把工具搞失败)。
153
+ * 写操作「读回自证」的实现已拆到 `./write-verify.js` (K10 ⑦):
154
+ * - 要接进 `pi-sdk-tools.ts` 的写类工具 ⇒ 留在这里会形成循环 import;
155
+ * - 原实现在函数体里用 `require('node:fs')`, 而产物是 ESM ⇒ 它此前**一次都没成功过**
156
+ * (只会被 catch 吞成 `[未核对] … require is not defined`)。
157
+ * 这里保留**同名转出**, 老的 import 点不受影响。
157
158
  */
158
- export function verifyWriteOutcome(toolName, args, cwd) {
159
- try {
160
- const fsMod = require('node:fs');
161
- const pathMod = require('node:path');
162
- const WRITE = new Set(['write_file', 'edit_file', 'mkdir', 'move_file', 'copy_file']);
163
- if (!WRITE.has(String(toolName)))
164
- return null;
165
- const rel = String(args?.path ?? args?.to ?? args?.dir ?? '').trim();
166
- if (!rel)
167
- return null;
168
- const abs = pathMod.isAbsolute(rel) ? rel : pathMod.resolve(cwd, rel);
169
- const st = fsMod.statSync(abs);
170
- if (st.isDirectory())
171
- return `[已核对] 目录存在: ${rel}`;
172
- return `[已核对] 文件已落盘: ${rel} (${st.size} 字节, ${new Date(st.mtimeMs).toISOString()})`;
173
- }
174
- catch (e) {
175
- return `[未核对] 读回失败: ${String(e?.message || e).slice(0, 80)} —— 别急着说"已完成", 先确认路径/权限`;
176
- }
177
- }
159
+ export { verifyWriteOutcome, withWriteVerified, WRITE_TOOLS } from './write-verify.js';
178
160
  /**
179
161
  * 过程纪律 (2026-10-01, 用户: 「智能体回复方式没有主动性 … 在过程里面更加主动考虑」)。
180
162
  *
@@ -395,7 +377,7 @@ export class PiAgentSession {
395
377
  harnessEnabled = false;
396
378
  /** 8-gate + 4-guard 集中调度 (防越权 / 防 prompt 注入) */
397
379
  reactHarness = new ReactHarness();
398
- usePivotLoop = false;
380
+ usePivotLoop = true; // K4-B (2026-10-02): 默认切到 pivot —— 所有入口只跑**一套** loop (老 ReAct loop 待删)
399
381
  pivotLoopConfig;
400
382
  /** P2: 当前会话的 permission mode (每次 promptStream 入口解析) */
401
383
  currentPermissionMode = 'default';
@@ -638,7 +620,7 @@ export class PiAgentSession {
638
620
  this.workflowEngine = new WorkflowEngine(this.constraintLayer);
639
621
  this.sessionManager = new PiSessionManager(this.identity.did, this.cwd, this.actor.state.agentId);
640
622
  this.agentsManager = new DiscoveredAgentsManager();
641
- this.usePivotLoop = config.usePivotLoop ?? false;
623
+ this.usePivotLoop = config.usePivotLoop ?? true; // K4-B: 默认 true (K4-B 之前是 false ⇒ CLI 走老 loop, 与 web 分叉)
642
624
  this.pivotLoopConfig = config.pivotLoopConfig;
643
625
  this.initSession();
644
626
  initDocumentReceiver();
@@ -1152,7 +1134,11 @@ export class PiAgentSession {
1152
1134
  role: 'user',
1153
1135
  content: input
1154
1136
  });
1155
- 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()) {
1156
1142
  // 2026-09-16 (2-C.2): fallback **也必须留下 Run 事实**。
1157
1143
  // 旧行为: 直接返回兜底文案, 连 Run 都不建 —— 上层 (独立宿主/Supervisor) 只看到"执行完成但没有 Run",
1158
1144
  // 于是把"什么都没跑"当成一次正常执行 (这正是"agent 跑了但没记录"的老毛病在 fallback 路径的翻版)。
@@ -1169,21 +1155,33 @@ export class PiAgentSession {
1169
1155
  boundGoalId = g.goalId;
1170
1156
  }
1171
1157
  this.actor.state.goalBinding = boundGoalId;
1172
- const rec = await startRun({
1173
- surface: this.runSurface,
1174
- goal: this.currentUserInput || input.slice(0, 200),
1175
- goalId: boundGoalId,
1176
- channelId: this.actor.state.channelId || undefined,
1177
- agentId: this.actor.state.agentId || undefined,
1178
- modelConfig: await this.runModelSnapshot(),
1179
- });
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;
1180
1173
  this.lastRunId = rec.runId;
1181
1174
  await attachRun(boundGoalId, rec.runId).catch(() => null);
1182
- await recordStep(rec.runId, { tool: 'llm', ok: false, error: 'LLM 不可用 (provider 未初始化/无 apiKey) → 走了 fallback' });
1183
- await finishRun(rec.runId, {
1184
- status: 'needs_human',
1185
- error: 'LLM 不可用: provider 未初始化或无 apiKey (fallback 不是执行结果, 需要配置或人工处理)',
1186
- });
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);
1187
1185
  // ★ M0: "工具/权限不可用"也是一条终止路径 → 同样进收尾漏斗 (规则 ④)。
1188
1186
  const wiring = await import('./goal-flywheel-wiring.js');
1189
1187
  const closed = await wiring.closeRunOnce({
@@ -1245,42 +1243,22 @@ export class PiAgentSession {
1245
1243
  console.warn('[PiAgent] resolvePermissionMode failed (non-fatal):', err);
1246
1244
  this.currentPermissionMode = 'default';
1247
1245
  }
1248
- // M3.1 (2026-06-17): 跟 promptStream 一样, usePivotLoop 时走 pivotLoop 路径
1249
- // 之前 prompt() 永远跑老 runReActLoop, CLI/web 行为不一致
1250
- if (this.usePivotLoop) {
1251
- try {
1252
- const lr = await this.promptWithPivotLoop(input, undefined, options?.channelId);
1253
- this.finishTrajectory(trajRec, lr.response || '');
1254
- return lr.response || '';
1255
- }
1256
- finally {
1257
- if (this.judgmentGateUsedIds.length > 0) {
1258
- recordJudgmentUsage(this.judgmentGateUsedIds, { userInput: input }).catch((err) => console.warn('[PiAgent] recordJudgmentUsage failed:', err));
1259
- }
1260
- this.clearJudgmentGate();
1261
- this.runCtx = createRunContext(); // K2: 用完即清 (换新 Context, 不继承残留);
1262
- this.reportUsageToContextManager();
1263
- }
1264
- }
1246
+ // K4-B (2026-10-02): **这条路已经只有一条** —— 原先这里有个 `if (this.usePivotLoop) { ... return ... }`
1247
+ // 的提前返回分支, 它的 finally 只是**子集** (只记正极性用法), 而且**少跑了**:
1248
+ // 回合后复盘 `runExperienceReview` · `monitorAfterReply`/`onStop` · `finishTrajectory(reply, ok|error)`
1249
+ // · 负极性用法记账 · `bootstrapAddition`/`promptStartTime` 清理。现在统一走下面那条完整收尾。
1265
1250
  try {
1266
- // 2026-06-16: runReActLoop 现在返回 { reply, aiFailed, aiFailureReason } — 这里只需 reply 字符串
1267
- const loopResult = await this.runReActLoop(this.runCtx.eventSink ?? undefined, options?.signal);
1268
- // 2026-10-01: **本回合改过 TS ⇒ 收尾自动类型检查** (一轮一次) —— 复用已有的 tsc_check 工具
1269
- if (decideTypecheck(this.tsTouchedThisTurn, this.typecheckRanThisTurn)) {
1270
- this.typecheckRanThisTurn = true;
1271
- try {
1272
- const tscTool = this.tools.get('tsc_check');
1273
- if (tscTool?.execute) {
1274
- const r = await tscTool.execute({});
1275
- const line = formatTypecheckResult(r?.success !== false && !/error TS\d+/.test(String(r?.output || '')), String(r?.output || ''));
1276
- this.runCtx.eventSink?.({ type: 'status', content: `🔎 ${line}`, tool: 'system' });
1277
- }
1278
- }
1279
- catch (e) {
1280
- this.runCtx.eventSink?.({ type: 'status', content: `🔎 类型检查没能跑起来: ${String(e?.message || e).slice(0, 100)} (改动已落盘, 记得自己跑一次)`, tool: 'system' });
1281
- }
1282
- this.tsTouchedThisTurn = [];
1283
- }
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();
1284
1262
  // 2026-10-01: **回合后自审 (纯旁路)** —— 沉淀可复用经验, 对应"结束后沉淀"那一段。
1285
1263
  // 纪律: fire-and-forget (不阻塞主回合) · 只写 ~/.bolloon/experience/ (**不碰主对话/prompt 缓存**)
1286
1264
  // · 节流 (默认 10 分钟/agent) · 失败只记一行日志, 绝不影响本回合结果。
@@ -1383,7 +1361,7 @@ export class PiAgentSession {
1383
1361
  async promptStream(input, onStream, signal, channelId) {
1384
1362
  console.log(`[PiAgent.promptStream] ENTRY, channelId=${channelId}, input chars=${input.length}`);
1385
1363
  this.minimaxAvailable = this.checkMinimax();
1386
- console.log(`[PiAgent.promptStream] minimaxAvailable=${this.minimaxAvailable}`);
1364
+ console.log(`[PiAgent.promptStream] minimaxAvailable=${this.minimaxAvailable} nativeAdapter=${process.env.BOLLOON_NATIVE_ADAPTER === '1'} usable=${this.inferenceAvailable()}`);
1387
1365
  this.actor.state.channelId = channelId ?? this.actor.state.channelId;
1388
1366
  // 2026-08-08: 运行轨迹采集 (落盘 + OrbitDB, 失败静默) — 包裹 onStream 收集步骤事件
1389
1367
  const trajRec = await this.createTrajectoryRecorder(input, channelId);
@@ -1414,7 +1392,9 @@ export class PiAgentSession {
1414
1392
  }
1415
1393
  catch { /* 记忆召回失败静默 (增强层) */ }
1416
1394
  onStream({ type: 'thinking', content: '🤔 开始思考...' });
1417
- if (!this.minimaxAvailable) {
1395
+ // K9: 流式入口同样问**当前适配器** (web/手机端就走这条路 —— 只修 pivot 那道闸时,
1396
+ // CLI 通了、web 仍然静默空回复, 现象是"适配器被选中但这条路一次没跑")。
1397
+ if (!this.inferenceAvailable()) {
1418
1398
  const response = await this.handleFallback(userText);
1419
1399
  this.pushHistory({ role: 'assistant', content: response });
1420
1400
  onStream({ type: 'done', content: '' });
@@ -1524,52 +1504,11 @@ export class PiAgentSession {
1524
1504
  this.currentPermissionMode = 'default';
1525
1505
  }
1526
1506
  this.promptStartTime = Date.now();
1527
- // M3.1 (2026-06-17): 走 WorkflowPivotLoop (usePivotLoop: true)
1528
- // pivot loop 自带 quality scoring / 30 iter cap / complexity analysis — 比老 runReActLoop 鲁棒
1529
- if (this.usePivotLoop) {
1530
- let pivotResult = '';
1531
- try {
1532
- const lr = await this.promptWithPivotLoop(userText, undefined, channelId);
1533
- pivotResult = lr.response || '';
1534
- onStream({ type: 'done', content: '' });
1535
- }
1536
- catch (err) {
1537
- if (signal?.aborted || err?.name === 'AbortError') {
1538
- console.log(`[chat] pivot aborted channel=${channelId}`);
1539
- }
1540
- else {
1541
- console.error(`[chat] pivot 失败 channel=${channelId}:`, err);
1542
- pivotResult = `[错误: pivot loop 失败] ${String(err?.message || err).slice(0, 300)}`;
1543
- try {
1544
- onStream({ type: 'error', content: pivotResult, tool: 'system' });
1545
- }
1546
- catch { }
1547
- }
1548
- }
1549
- finally {
1550
- if (this.judgmentGateUsedIds.length > 0) {
1551
- try {
1552
- onStream({ type: 'used_judgments', usedIds: this.judgmentGateUsedIds, content: '' });
1553
- }
1554
- catch { }
1555
- }
1556
- monitorAfterReply(userText, pivotResult);
1557
- const stopStartTime = this.promptStartTime || Date.now();
1558
- onStop({
1559
- channelId: this.actor.state.channelId || 'unknown',
1560
- durationMs: Date.now() - stopStartTime,
1561
- usedJudgmentIds: [...this.judgmentGateUsedIds],
1562
- }).catch((err) => console.warn('[PiAgent] onStop failed:', err));
1563
- this.clearJudgmentGate();
1564
- this.runCtx = createRunContext(); // K2: 用完即清 (换新 Context, 不继承残留);
1565
- this.bootstrapAddition = '';
1566
- this.contextHintAddition = '';
1567
- this.promptStartTime = 0;
1568
- this.reportUsageToContextManager();
1569
- }
1570
- this.finishTrajectory(trajRec, pivotResult);
1571
- return pivotResult;
1572
- }
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` 两个用例锁着。
1573
1512
  // 2026-06-16: loop 自动重试 — runReActLoop 内部遇到 [AI 服务调用失败] sentinel 时,
1574
1513
  // 会设 aiFailed=true 并提前 break. 这里在外层重跑整个 loop (不是单次 LLM 调用),
1575
1514
  // 临时网络抖动 / 配额瞬时超限可自愈. 最多 3 次, 指数退避 1s/2s/4s.
@@ -1580,14 +1519,16 @@ export class PiAgentSession {
1580
1519
  let lastAiFailureReason = '';
1581
1520
  while (attempt <= MAX_LOOP_RETRIES) {
1582
1521
  try {
1583
- const loopResult = await this.runReActLoop(onStream, signal);
1584
- 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();
1585
1526
  // 持久化失败不重试 (写不进去就是写不进去): 直接按本次结果收尾
1586
1527
  if (this.runPersistenceBlocked)
1587
1528
  break;
1588
- if (!loopResult.aiFailed)
1529
+ if (lr.success)
1589
1530
  break; // 正常完成, 退出 retry 循环
1590
- lastAiFailureReason = loopResult.aiFailureReason || 'AI 调用失败';
1531
+ lastAiFailureReason = String(lr.exitReason || 'AI 调用失败');
1591
1532
  }
1592
1533
  catch (err) {
1593
1534
  // abort 失败: 视作"已中断", 抛错让上层用 partial 兜底
@@ -1657,14 +1598,121 @@ export class PiAgentSession {
1657
1598
  this.runCtx = createRunContext(); // K2: 用完即清 (换新 Context, 不继承残留);
1658
1599
  this.reportUsageToContextManager();
1659
1600
  this.bootstrapAddition = '';
1601
+ this.contextHintAddition = ''; // K4-B: 与被合并掉的那条分支对齐 (它清了这条, 老尾段漏了)
1660
1602
  this.promptStartTime = 0;
1661
1603
  // 2026-08-08: 轨迹收尾 — 落盘 + OrbitDB (失败静默, 不阻塞回复)
1662
1604
  this.finishTrajectory(trajRec, result);
1663
1605
  return result;
1664
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
+ }
1665
1710
  async promptWithPivotLoop(input, config, channelId) {
1666
1711
  this.actor.state.channelId = channelId ?? this.actor.state.channelId;
1667
- if (!this.minimaxAvailable) {
1712
+ // K9 (2026-10-02): **可用性判据必须问"当前适配器"**, 不能无条件问 Pi ——
1713
+ // 原先这里是 `if (!this.minimaxAvailable)`, 于是"换成第二个(非 Pi)适配器"仍然被 **Pi 的可用性**卡死
1714
+ // (实跑现象: 适配器被选中、但 loop 一次都没跑、回复为空)。这类"隐式仍依赖 Pi"的点正是 K10 要清的。
1715
+ if (!this.inferenceAvailable()) {
1668
1716
  const response = await this.handleFallback(input);
1669
1717
  return {
1670
1718
  success: false,
@@ -1684,8 +1732,100 @@ export class PiAgentSession {
1684
1732
  }
1685
1733
  };
1686
1734
  }
1687
- const llm = getMinimax();
1688
- 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
+ };
1689
1829
  const loop = new WorkflowPivotLoop(loopConfig);
1690
1830
  for (const tool of this.tools.values()) {
1691
1831
  loop.registerTool(tool);
@@ -1770,7 +1910,35 @@ ${await this.renderActivePlansSection()}
1770
1910
  const onCompact = async () => {
1771
1911
  // no-op (best-effort hook for future pi-sdk/pivot history sync)
1772
1912
  };
1773
- const result = await loop.execute(input, llm, systemPrompt + historyBlock, this.runCtx.eventSink ?? undefined, this.runCtx.abortSignal ?? 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 { /* 审查异常不改变本次结论 (门面内部对"审查失效"自有留痕策略) */ }
1774
1942
  if (result.response) {
1775
1943
  this.pushHistory({ role: 'assistant', content: result.response });
1776
1944
  }
@@ -1815,17 +1983,32 @@ ${await this.renderActivePlansSection()}
1815
1983
  setGoalId(goalId) {
1816
1984
  this.actor.state.goalBinding = String(goalId || '');
1817
1985
  }
1818
- /** 核心状态迁移的兜底: 失败 → 记降级 + 交给循环顶部的持久化硬闸 (不吞错) */
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
+ */
1819
2002
  async safeSetRunStatus(runId, to) {
1820
- try {
1821
- const r = await setRunStatus(runId, to);
1822
- return !!r.ok;
1823
- }
1824
- catch (err) {
1825
- 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)}`;
1826
2009
  await recordDegradation({ kind: 'core', op: 'pi-sdk.setRunStatus', runId, message: this.persistenceFailure }).catch(() => { });
1827
- return false;
1828
2010
  }
2011
+ return false;
1829
2012
  }
1830
2013
  /**
1831
2014
  * 2026-09-16 (M3): 工具失败接线 —— 分类 → recovery 留痕 → 熔断 / 外部等待。
@@ -1962,6 +2145,27 @@ ${await this.renderActivePlansSection()}
1962
2145
  this.resumePlan = null;
1963
2146
  }
1964
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
+ }
1965
2169
  piHarness() {
1966
2170
  if (!this._harness) {
1967
2171
  this._harness = new PiAgentHarness({
@@ -1987,1127 +2191,62 @@ ${await this.renderActivePlansSection()}
1987
2191
  surface: this.runSurface,
1988
2192
  };
1989
2193
  }
1990
- async runReActLoop(onStream, signal) {
1991
- const llm = getMinimax();
1992
- let iteration = 0;
1993
- let finalResponse = '';
1994
- let lastQualityScore = 0;
1995
- let refineAttempts = 0;
1996
- let consecutiveErrors = 0;
1997
- // 2026-06-16 新增: 累计错误数 (跨工具, 兜底防 LLM 轮换工具名死循环)
1998
- let totalErrors = 0;
1999
- let lastFailedTool = ''; // 跟踪最近一次失败的 tool name
2000
- let lastFailedToolCount = 0; // 最近失败工具的连续失败次数
2001
- // 2026-06-16: AI sentinel 标志 — runReActLoop 返回 aiFailed=true,
2002
- // promptStream 据此自动重跑整个 loop 最多 N 次 (不是单次 LLM 重试)
2003
- let aiFailed = false;
2004
- let aiFailureReason = '';
2005
- const MAX_CONSECUTIVE_ERRORS = 3;
2006
- // 同一工具连续失败 3 次, 强制让 LLM 给出最终答案 (模块级常量 MAX_SAME_TOOL_FAILURES 也用它做熔断)
2007
- // 2026-07-29: Hermes 风格硬限制 — 防死循环 (不再靠 soft hint)
2008
- const MAX_IDEMPOTENT_TOOL = 5; // 同工具成功调 5 次 → 注入 hint 强制 final gen
2009
- const MAX_TOOL_CALLS_PER_LOOP = 25; // 单轮循环总工具调用上限 → 注入 hint
2010
- let totalToolCallsThisLoop = 0;
2011
- const lastNTools = []; // 最近 MAX_IDEMPOTENT_TOOL 次工具名, 检测重复
2012
- // 2026-10-01: 工具停滞观测状态 (分类 + 温和引导 + 重复结果引用) —— 见 agents/tool-loop-guard.ts
2013
- // 与上面的"同工具 5 次就提示"不同: 它按 (工具名 + 参数 + 结果) 签名判定, 并抓 A→B→A→B 的循环
2014
- const stallState = new LoopStallState();
2015
- // 2026-08-10: unreported 循环逃生门 — LLM 反复不把工具结果写进回复时, 3 次后强制 final (不死板)
2016
- const MAX_UNREPORTED_RETRIES = 3;
2017
- let unreportedRetries = 0;
2018
- // 2026-08-10: 工具失败时的终端逃生引导 (shell_exec 白名单命令可诊断环境/推进任务)
2019
- const SHELL_ESCAPE_HINT = ' [逃生] 若工具无法响应/报错, 可用 shell_exec 跑终端命令诊断 (白名单: ls/cat/head/tail/pwd/git status/npm run test 等), 或调整参数换一种方式完成; 不要重复调用同一失败工具.';
2020
- // 2026-08-08: final 前 review 续跑 — 目标对齐 + 需求深挖 (见 loop-review.ts)
2021
- // 不潦草收尾: LLM 想 <final gen> 时先跑 1-2 次 review, 达成用户需求才放行.
2022
- // 上限=2 次 (用户要求"运行一两次"), 结束后按用户需求为准.
2023
- let loopReviewCount = 0;
2024
- const loopReviewCompletedTools = new Set();
2025
- // 2026-08-09: 本轮行动日志 — 每轮工具执行都记录 (args + 结果摘要),
2026
- // final 前 review 用逐条核查目标; 也注入 system prompt 让 LLM 看到连续进度
2027
- // (防"每轮都像重启" — 之前 LLM 看不到自己已完成什么, 容易重复 react)
2028
- const loopActionLog = [];
2029
- // 发送循环开始的事件
2030
- if (onStream) {
2031
- onStream({ type: 'status', internal: true, content: '🔄 开始 ReAct 循环...', tool: 'system' });
2032
- }
2033
- // React Harness: 循环开始 (重置 turn 计数 + 触发 harness sessionStart)
2034
- // 失败静默 (fail-open), 不阻塞主循环
2035
- // 2026-09-16 (Milestone 1-B): 会话开启走唯一门面 (react-harness 8-gate 复位 + hooks onLoopStart)
2036
- await this.piHarness().sessionStart(this.harnessCtx());
2037
- // 2026-09-16: 持久化 run harness — 本次运行立即落盘 (~/.bolloon/runs/<id>.json)。
2038
- // 之后每步工具调用都追加一条, 所以刷新页面/进程重载/崩溃都能看到"做到哪一步"。
2039
- let runStopReason = '';
2040
- // 2026-09-16 (Milestone 1): 持久化硬约束 —— 核心 run 状态写不进去时, 运行必须停。
2041
- // 理由: "agent 实际跑了但没记录" 比 "agent 没跑" 更危险 (UI 显示旧状态/重启后无从知晓/结果可能被错标 done)。
2042
- let runPersistenceFailure = '';
2043
- /** 2026-09-16 (M3): 熔断/需要人处置 → 收尾落 needs_human (不是 done, 也不是普通 failed) */
2044
- let runNeedsHuman = '';
2045
- /** 2026-09-16 (M5): 外部 (CLI/Web) 把 run 改成 paused/aborted → 如实停, 不再覆盖它的状态 */
2046
- let runExternallyPaused = false;
2047
- let runExternallyAborted = false;
2048
- this.runPersistenceBlocked = false;
2049
- this.breakerReason = '';
2050
- this.awaitingExternal = false;
2051
- if (this.resumeRunId) {
2052
- // ── 恢复模式: 复用原来的 runId, 不新建 run (历史保留) ──
2053
- this.actor.state.activeRun = this.resumeRunId;
2054
- 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;
2055
2205
  try {
2056
- await markRunRunning(this.actor.state.activeRun);
2057
- }
2058
- catch (err) {
2059
- runPersistenceFailure = `恢复时状态迁移失败 (recovering → running): ${String(err?.message || err).slice(0, 180)}`;
2060
- }
2061
- const doneN = this.resumePlan?.completedSteps.length ?? 0;
2062
- const guards = this.resumePlan?.replayGuards.length ?? 0;
2063
- onStream?.({ type: 'status', internal: true, content: `♻️ 从 checkpoint 恢复运行 ${this.actor.state.activeRun} (已完成 ${doneN} 步, 非幂等重放守卫 ${guards} 条)`, tool: 'harness' });
2064
- }
2065
- else {
2066
- // 2026-09-16 (M2): 目标绑定 —— 有 goalId 就在该 Goal 下执行; 没有就建 Goal 再建 Run。
2067
- // 延续规则 (确定性, 不靠猜): 该 channel/agent 上已有 open/active Goal, 且它的上一次执行**没收尾**
2068
- // (interrupted/stalled/needs_human/paused/awaiting_external/recovering) → 继续该 Goal; 否则新建。
2069
- let boundGoalId = this.actor.state.goalBinding;
2070
- if (!boundGoalId) {
2071
- try {
2072
- const active = await findActiveGoal({ channelId: this.actor.state.channelId || undefined, agentId: this.actor.state.agentId || undefined });
2073
- if (active?.currentRunId) {
2074
- const prev = await readRun(active.currentRunId);
2075
- const unfinished = prev && ['interrupted', 'stalled', 'needs_human', 'paused', 'awaiting_external', 'recovering'].includes(prev.status);
2076
- if (unfinished)
2077
- boundGoalId = active.goalId;
2078
- }
2079
- }
2080
- catch (err) {
2081
- console.warn('[PiAgent] 查找进行中 Goal 失败 (按新建处理):', err?.message);
2082
- }
2083
- 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 = '';
2084
2214
  try {
2085
- const g = await createGoal({
2086
- objective: this.currentUserInput || '(未记录目标)',
2087
- channelId: this.actor.state.channelId || undefined,
2088
- agentId: this.actor.state.agentId || undefined,
2089
- createdBy: this.runSurface,
2090
- });
2091
- boundGoalId = g.goalId;
2092
- }
2093
- catch (err) {
2094
- console.warn('[PiAgent] 创建 Goal 失败 (无目标也要有运行记录):', err?.message);
2095
- }
2096
- }
2097
- this.actor.state.goalBinding = boundGoalId;
2098
- }
2099
- try {
2100
- const rec = await startRun({
2101
- surface: this.runSurface,
2102
- goal: this.currentUserInput || '(未记录目标)',
2103
- goalId: boundGoalId || undefined,
2104
- channelId: this.actor.state.channelId || undefined,
2105
- agentId: this.actor.state.agentId || undefined,
2106
- modelConfig: await this.runModelSnapshot(),
2107
- });
2108
- this.actor.state.activeRun = rec.runId;
2109
- this.lastRunId = rec.runId;
2110
- this.actor.state.goalBinding = rec.goalId || this.actor.state.goalBinding;
2111
- if (this.actor.state.goalBinding) {
2112
- // Run → Goal 反查链: runId → goalId → objective / success criteria
2113
- await attachRun(this.actor.state.goalBinding, rec.runId).catch((err) => console.warn('[PiAgent] attachRun 失败:', err?.message));
2114
- }
2115
- onStream?.({ type: 'status', internal: true, content: `🧷 运行已登记 (run=${rec.runId}${this.actor.state.goalBinding ? `, goal=${this.actor.state.goalBinding}` : ''}, 预算 ${rec.budget.maxSteps} 步 / ${Math.round(rec.budget.deadlineMs / 60000)} 分钟)`, tool: 'harness' });
2116
- }
2117
- catch (err) {
2118
- // 核心写失败: 不再 warn 后继续 —— 没有运行记录就不执行 (strict 模式默认如此)
2119
- runPersistenceFailure = `无法创建运行记录: ${String(err?.message || err).slice(0, 200)}`;
2120
- this.runPersistenceBlocked = true;
2121
- console.error('[PiAgent] run-store startRun 失败 (核心持久化) → 拒绝无记录执行:', runPersistenceFailure);
2122
- onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 已停止 (不在没有记录的情况下执行)`, tool: 'harness' });
2123
- }
2124
- }
2125
- while (iteration < this.MAX_REACT_ITERATIONS) {
2126
- iteration++;
2127
- // 2026-09-16 (Milestone 1): 持久化失败硬闸 —— 到这一层说明记录已经不可信, 继续跑就是"无约束执行"
2128
- if (runPersistenceFailure || this.persistenceFailure) {
2129
- runPersistenceFailure = runPersistenceFailure || this.persistenceFailure;
2130
- this.runPersistenceBlocked = true;
2131
- aiFailed = true;
2132
- aiFailureReason = aiFailureReason || runPersistenceFailure;
2133
- finalResponse = finalResponse || `❌ 运行已停止: ${runPersistenceFailure}\n\n(运行记录无法写入/校验, 按协议停在 needs_human, 不假装完成)`;
2134
- break;
2135
- }
2136
- // 2026-09-16 (M3): 熔断硬闸 —— 同一工具连续失败达上限后, 不许再"自动重试成功"式地把运行放活
2137
- if (this.breakerReason) {
2138
- runNeedsHuman = this.breakerReason;
2139
- aiFailed = true;
2140
- aiFailureReason = aiFailureReason || this.breakerReason;
2141
- finalResponse = finalResponse || `❌ 已熔断: ${this.breakerReason}\n\n(重复失败不再重试, 按协议停在 needs_human 交人处置)`;
2142
- break;
2143
- }
2144
- // 2026-09-16: 预算闸门 (持久化 harness 的约束面) —— 到点必须**如实**终止, 不许静默算完成
2145
- if (this.actor.state.activeRun) {
2146
- try {
2147
- const rec = await readRun(this.actor.state.activeRun);
2148
- if (!rec)
2149
- throw new Error(`运行记录读不到: ${this.actor.state.activeRun}`);
2150
- // 2026-09-16 (M5): 外部控制面 (CLI /pause /abort, Web API) 改过状态 → 如实停在那儿。
2151
- // 不覆盖成 done/failed: 人按下暂停就是暂停, 人按下中止就是中止。
2152
- if (rec.status === 'paused' || rec.status === 'aborted') {
2153
- runExternallyPaused = rec.status === 'paused';
2154
- runExternallyAborted = rec.status === 'aborted';
2155
- runStopReason = `外部请求: ${rec.status}`;
2156
- onStream?.({ type: 'error', content: `⏹️ 运行被外部${rec.status === 'paused' ? '暂停' : '中止'} (run=${this.actor.state.activeRun})`, tool: 'harness' });
2157
- finalResponse = finalResponse || `(运行已${rec.status === 'paused' ? '暂停' : '中止'})`;
2158
- break;
2159
- }
2160
- const verdict = budgetVerdict(rec);
2161
- if (verdict.exceeded) {
2162
- runStopReason = verdict.reason || '运行预算用尽';
2163
- onStream?.({ type: 'error', content: `⛔ 运行预算用尽: ${runStopReason} (已如实终止, 不假装完成)`, tool: 'harness' });
2164
- finalResponse = finalResponse || `(运行预算用尽: ${runStopReason})`;
2165
- break;
2166
- }
2167
- }
2168
- catch (err) {
2169
- // 预算闸门读不到状态 = 约束失效, 不能"当作没超预算"继续跑
2170
- runPersistenceFailure = `预算闸门无法校验运行状态: ${String(err?.message || err).slice(0, 200)}`;
2171
- this.runPersistenceBlocked = true;
2172
- console.error('[PiAgent] run-store 预算检查失败 (核心持久化):', runPersistenceFailure);
2173
- onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 已停止`, tool: 'harness' });
2174
- break;
2175
- }
2176
- }
2177
- // 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
2178
- // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideMaxIterations 纯函数
2179
- // 2026-10-01 (落实②: 可退还的迭代预算): 惩罚零碎调用, **奖励批处理** ——
2180
- // 程序化工具(execute_code, 一次能顶多次)调用后归还一次迭代 ⇒ 有效寿命被延长。
2181
- // 退出条件仍由 decideMaxIterations 决定(读**净**用量), 不做硬刹车。
2182
- if (iteration === 0 || !this.iterBudget)
2183
- this.iterBudget = new IterationBudget(this.MAX_REACT_ITERATIONS);
2184
- const iterBudget = this.iterBudget;
2185
- iterBudget.consume();
2186
- if (iterBudget.warn(0.8) && !this.iterBudgetWarned) {
2187
- this.iterBudgetWarned = true;
2188
- onStream?.({ type: 'status', internal: true, content: `⏳ 迭代预算已用 ${iterBudget.describe()} (批量工具会退还)` });
2189
- }
2190
- const maxIterDecision = decideMaxIterations(iterBudget.used, iterBudget.maxTotal);
2191
- if (maxIterDecision.shouldExit) {
2192
- console.warn(`[PiAgent] 达到最大循环数 ${this.MAX_REACT_ITERATIONS}, 强制终止 (fail-safe)`);
2193
- onStream?.({ type: 'error', content: `⏹️ 达到最大循环数 (${this.MAX_REACT_ITERATIONS}, fail-safe)`, tool: 'loop' });
2194
- finalResponse = finalResponse || maxIterDecision.finalAnswer;
2195
- break;
2196
- }
2197
- // 停止条件 2: signal.aborted (显式 abort / 用户中断)
2198
- if (signal?.aborted) {
2199
- console.warn('[PiAgent] runReActLoop aborted by signal');
2200
- onStream?.({ type: 'error', content: '⏹️ 用户中断', tool: 'loop' });
2201
- finalResponse = finalResponse || '(用户中断)';
2202
- break;
2203
- }
2204
- // 2026-07-29: Hermes 风格硬限制 (idempotent tool / total call cap)
2205
- if (totalToolCallsThisLoop >= MAX_TOOL_CALLS_PER_LOOP) {
2206
- console.warn(`[PiAgent] 单轮工具调用已达 ${MAX_TOOL_CALLS_PER_LOOP}, 注入 hint 让 LLM 总结`);
2207
- onStream?.({ type: 'error', content: `⏹️ 工具调用已达上限 (${MAX_TOOL_CALLS_PER_LOOP}), 请基于已有结果回答`, tool: 'loop' });
2208
- this.pushHistory({ role: 'system', content: `[注意] 你已连续调用 ${MAX_TOOL_CALLS_PER_LOOP} 次工具。请基于已有结果直接回答用户, 不要再次调用任何工具。在回答末尾加 <final gen> 标记结束。` });
2209
- totalToolCallsThisLoop = 0; // 重置计数器, 只防连续死循环
2210
- }
2211
- if (lastNTools.length >= MAX_IDEMPOTENT_TOOL && new Set(lastNTools).size === 1) {
2212
- const repeatedTool = lastNTools[0];
2213
- console.warn(`[PiAgent] 同工具 ${repeatedTool} 连续成功调 ${MAX_IDEMPOTENT_TOOL} 次, 注入 hint 让 LLM 总结`);
2214
- onStream?.({ type: 'error', content: `⏹️ 工具 ${repeatedTool} 重复调用 ${MAX_IDEMPOTENT_TOOL} 次, 请基于已有结果回答`, tool: 'loop' });
2215
- this.pushHistory({ role: 'system', content: `[注意] 你已连续 ${MAX_IDEMPOTENT_TOOL} 次调用 ${repeatedTool}。请基于已有结果直接回答用户, 不要再次调用任何工具。在回答末尾加 <final gen> 标记结束。` });
2216
- lastNTools.length = 0; // 重置计数器
2217
- // 不 break — 让 LLM 在下一轮用已有信息回答
2218
- }
2219
- // 2026-06-16 新增: 累计错误兜底 — 跨工具, 防 LLM 轮换工具名绕过 MAX_SAME_TOOL_FAILURES
2220
- if (totalErrors >= this.MAX_TOTAL_ERRORS) {
2221
- console.warn(`[PiAgent] 累计错误 ${totalErrors} >= ${this.MAX_TOTAL_ERRORS}, 强制终止 (防死循环)`);
2222
- onStream?.({ type: 'error', content: `⛔ 累计 ${totalErrors} 次错误, 强制终止 (防止 LLM 死循环)`, tool: 'loop' });
2223
- // 2026-06-19: 即使 LLM 一直失败, 也汇总之前成功执行的 tool result 给用户
2224
- if (this.successfulToolResults.length > 0) {
2225
- finalResponse = `✅ 之前步骤成功执行了 ${this.successfulToolResults.length} 个工具 (但 LLM 后续 ${totalErrors} 次调用失败):\n` +
2226
- this.successfulToolResults.map((r, i) => ` ${i + 1}. ${r.tool}: ${r.outputPreview}`).join('\n') +
2227
- `\n\n⚠️ (LLM 连续失败, 可能是上游限流/网络问题, 工具已成功执行但 LLM 没能继续总结)`;
2228
- }
2229
- else {
2230
- finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
2231
- }
2232
- break;
2233
- }
2234
- // 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
2235
- // compact 失败走 C 路径: 不强行 break, 让现有 60K 阈值兜底 (后面有检查)
2236
- // 2026-07-01 (v0.2.4 子任务 1): 触发判定走 shouldCompactBeforeIteration 纯函数
2237
- const compactThreshold = this.maxContextTokens() * this.LOOP_COMPACT_RATIO;
2238
- const estimatedTokensBefore = this.estimateHistoryTokens();
2239
- // 2026-08-06: 每轮上报 usage 到 ContextManager (CLI/Web 状态栏数据源, warning 事件触发点)
2240
- getContextManager().updateUsage(estimatedTokensBefore);
2241
- if (shouldCompactBeforeIteration(estimatedTokensBefore, compactThreshold)) {
2242
- const tokensBeforeCompact = estimatedTokensBefore;
2243
- console.log(`[PiAgent] loop 入口 token ${tokensBeforeCompact} > ${compactThreshold}, 触发自动压缩`);
2244
- onStream?.({ type: 'status', internal: true, content: `🗜️ loop 自动压缩 (token ${tokensBeforeCompact} > ${compactThreshold})`, tool: 'compactor' });
2245
- try {
2246
- await this.maybeAutoCompact(onStream, signal);
2247
- }
2248
- catch (compactErr) {
2249
- // C 路径: compact 失败不 break, 让 token 阈值检查兜底
2250
- console.warn(`[PiAgent] loop 内 maybeAutoCompact 失败 (non-fatal, 继续走 token 阈值):`, compactErr);
2251
- }
2252
- }
2253
- // 停止条件 3: context overflow (compact 后还超, 强制终止)
2254
- // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideContextOverflow 纯函数
2255
- const estimatedTokens = this.estimateHistoryTokens();
2256
- const overflowDecision = decideContextOverflow(estimatedTokens, this.maxContextTokens());
2257
- if (overflowDecision.shouldExit) {
2258
- console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.maxContextTokens()})`);
2259
- onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.maxContextTokens()})`, tool: 'loop' });
2260
- finalResponse = finalResponse || overflowDecision.finalAnswer;
2261
- break;
2262
- }
2263
- // 调试日志:显示每次循环开始
2264
- console.log(`[PiAgent] 循环 ${iteration}/${this.MAX_REACT_ITERATIONS} 开始`);
2265
- if (onStream) {
2266
- onStream({ type: 'status', internal: true, content: `🔄 循环 ${iteration}/${this.MAX_REACT_ITERATIONS}`, tool: 'loop' });
2267
- }
2268
- const context = this.buildContext();
2269
- // M3.5 (2026-06-17): 也构造 messages 数组版本, 让 LLM 看到结构化 tool 角色
2270
- // buildContext() 把 history 序列化成字符串 — LLM 看不到 tool 调用的真实结果
2271
- // 新版用 messages 数组直接喂给 LLM, 保留 role 语义 (user/assistant/tool/system)
2272
- const messages = this.buildMessages();
2273
- const toolDefs = this.getToolDefinitions();
2274
- // 动态构建 refine 上下文
2275
- let refineContext = '';
2276
- if (refineAttempts > 0 && lastQualityScore < this.QUALITY_THRESHOLD) {
2277
- refineContext = `\n【改进提示】上轮结果质量分 ${(lastQualityScore * 10).toFixed(1)}/10,请改进回答。`;
2278
- }
2279
- // 连续错误时的额外提示
2280
- if (consecutiveErrors > 0) {
2281
- refineContext += `\n【错误提示】上轮发生 ${consecutiveErrors} 次错误,请重新分析问题或换一种方式处理。`;
2282
- }
2283
- // M2.4: persona section 缓存 — persona 在 loadPersona() 时一次设定, 此后不变
2284
- if (!this.cachedPersonaSection && this.persona) {
2285
- this.cachedPersonaSection = `
2286
- 角色描述: ${this.persona.description || '无'}
2287
- 性格特点: ${this.persona.personality || '无'}
2288
- 问候语: ${this.persona.greeting || '无'}
2289
- `;
2290
- }
2291
- const personaSection = this.cachedPersonaSection;
2292
- // 2026-08-09: 循环进度段 — 让 LLM 看到本轮已完成的动作 (连续进度, 不重启)
2293
- // Hermes 式 Agent Runtime: 循环是状态机, LLM 每次看到的是"第 N 步 + 已完成 X"
2294
- // (之前每轮都是全新上下文, LLM 不知道做过什么 → 重复 react / 衔接差)
2295
- let loopProgressSection = '';
2296
- if (loopActionLog.length > 0) {
2297
- const actionLines = loopActionLog
2298
- .map((a, i) => {
2299
- const args = a.argsPreview ? `(${a.argsPreview.slice(0, 60)})` : '';
2300
- const res = a.success ? '✓' : '✗';
2301
- return ` ${i + 1}. ${res} ${a.tool}${args}`;
2302
- })
2303
- .join('\n');
2304
- loopProgressSection = `\n【本轮循环进度】你已完成以下 ${loopActionLog.length} 个动作, 这是连续执行的同一轮任务:\n${actionLines}\n请基于已有结果继续推进, 不要重复执行上面已成功的动作. 全部完成后用 <final gen> 结束.\n`;
2305
- }
2306
- // 2026-09-28 (前缀 KV 可命中): `refineContext` / `loopProgressSection` 是**每轮都在变**的段
2307
- // (质量分 / 连续错误数 / 本轮已完成动作). 留在 system 里 = 每轮把 IMMUTABLE PREFIX 打碎 =
2308
- // 服务端前缀 KV 永远 miss. 移到 CURRENT TURN 区 (注入到最后一条 user 前部) —— 文本一字未改, 只换位置.
2309
- const currentTurnContext = `${refineContext}${loopProgressSection}`;
2310
- const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
2311
- 当前工作目录: ${this.cwd}
2312
- 当前身份: ${this.identity.name} (${this.identity.did})
2313
- ${this.currentIntentHint}
2314
-
2315
- ${toolDefs}
2316
-
2317
- ${PiAgentSession.TOOL_SELECTION_GUIDE}
2318
-
2319
- ${PROACTIVE_WORK_DISCIPLINE}
2320
-
2321
- ${await this.renderActivePlansSection()}
2322
-
2323
- 工作模式:
2324
- 1. 理解用户自然语言请求
2325
- 2. 分析需要哪些工具来完成
2326
- 3. 按顺序调用工具并观察结果
2327
- 4. 根据观察结果决定下一步
2328
- 5. 最终给出完整回答
2329
-
2330
- 重要:
2331
- - 每次只调用一个工具
2332
- - 仔细分析工具返回结果
2333
- - 当任务完成时,必须在回答末尾添加 <final gen> 标记表示结束
2334
- - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}${this.contextHintAddition}`;
2335
- // 3 个恢复机制 (Claude Code 论文 9-step pipeline 内部):
2336
- // 1. max output token 升级 (最多 3 次, 每次 maxOutputTokens 翻倍)
2337
- // 2. reactive compaction (prompt 估算超阈值, 跑压缩)
2338
- // 3. prompt-too-long (LLM 报错 4xxx token 错误, 跑 reactive compaction 再试 1 次)
2339
- // 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
2340
- // Bug 5: pass tool IDs for native OpenAI tool calling — 2026-07-29: 过滤拒绝工具
2341
- const toolIds = Array.from(this.tools.keys()).filter(n => !this._deniedToolNames.has(n));
2342
- // 2026-07-29: 从 this.tools Map 生成 OpenAI 原生 tools 格式 (含参数 schema)
2343
- const openaiFormattedTools = [];
2344
- for (const [name, tool] of this.tools) {
2345
- if (this._deniedToolNames.has(name))
2346
- continue;
2347
- const params = tool.parameters || {};
2348
- const properties = {};
2349
- const required = [];
2350
- for (const [pName, pDesc] of Object.entries(params)) {
2351
- properties[pName] = { type: 'string', description: String(pDesc) };
2352
- if (String(pDesc).includes('必填'))
2353
- required.push(pName);
2354
- }
2355
- openaiFormattedTools.push({
2356
- type: 'function',
2357
- function: {
2358
- name,
2359
- description: tool.description || name,
2360
- parameters: { type: 'object', properties, required },
2361
- },
2362
- });
2363
- }
2364
- // 2026-09-16 (Milestone 1-B): 模型调用前后走唯一门面 (扩展点 + 计数留痕; 默认不加新 hook 事件, 避免改变现有触发次数)
2365
- this.piHarness().beforeModelCall(this.harnessCtx());
2366
- const _modelCallT0 = Date.now();
2367
- const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, openaiFormattedTools, currentTurnContext);
2368
- // 2026-09-28 (前缀 KV 可命中): 把注入了 CURRENT TURN 的当前轮**原样写回自己的 messageHistory** ——
2369
- // buildMessages() 每轮重建全新对象, 不写回的话下一轮那条 user 就退回注入前, 前缀从那里分叉.
2370
- // 用 chat() 回带的 wire (真正发出去的那一份), 而不是把 messages 再拼一遍.
2371
- this.writeBackCurrentTurn(response.messages);
2372
- this.piHarness().afterModelCall(this.harnessCtx(), { ms: Date.now() - _modelCallT0 });
2373
- const reply = (response.reply || '').trim();
2374
- // 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
2375
- const nativeToolCalls = response.toolCalls;
2376
- // 2026-06-19 架构 fix: 不再因 [AI 服务调用失败] break
2377
- // 旧逻辑: sentinel → aiFailed=true → break → 外层 retry 整个 loop (重置 history)
2378
- // 新逻辑: 把错误当 tool_result push 进 history → 下一轮 LLM 看到错误能反思重试
2379
- // 这是 dive-into 文档的"fail-open error recovery" — 错误进入 context, 不让 LLM 重复犯同样错
2380
- // 2026-07-06: 对不可恢复的 API 错误直接终止, 不再无限重试
2381
- if (reply.startsWith('[AI 服务调用失败]')) {
2382
- console.log(`[PiAgent] 收到 AI 错误 sentinel`);
2383
- console.log(`[sentinel DEBUG] 完整 reply: ${reply}`);
2384
- console.log(`[sentinel DEBUG] 上一轮 messages 数量: ${Array.isArray(messages) ? messages.length : 'N/A'}, systemPrompt 长度: ${systemPrompt.length}`);
2385
- aiFailureReason = reply.length > 200 ? reply.substring(0, 200) : reply;
2386
- totalErrors++;
2387
- consecutiveErrors++;
2388
- // 2026-07-06: 检测不可恢复的 API 错误 — 这些错误 LLM 无法通过反思修复, 重试无意义
2389
- const isFatalApiError = reply.includes('chat content is empty') ||
2390
- reply.includes('invalid params') ||
2391
- reply.includes('401') ||
2392
- reply.includes('403') ||
2393
- reply.includes('quota') ||
2394
- reply.includes('rate limit') ||
2395
- reply.includes('API key') ||
2396
- reply.includes('authentication') ||
2397
- reply.includes('unauthorized');
2398
- if (isFatalApiError) {
2399
- console.log(`[PiAgent] 检测到不可恢复的 API 错误, 终止 loop: ${aiFailureReason}`);
2400
- if (onStream) {
2401
- onStream({ type: 'error', content: `⛔ API 错误无法恢复: ${aiFailureReason}`, tool: 'system' });
2402
- }
2403
- finalResponse = `❌ AI 服务调用失败: ${aiFailureReason}\n\n这是一个底层 API 错误, 不是任务本身的问题。请检查 API 配置或稍后重试。`;
2404
- aiFailed = true;
2405
- break;
2406
- }
2407
- // 连续错误过多也终止, 防止 LLM 陷入死循环
2408
- if (consecutiveErrors >= 3) {
2409
- console.log(`[PiAgent] 连续 ${consecutiveErrors} 次 AI 错误, 终止 loop`);
2410
- if (onStream) {
2411
- onStream({ type: 'error', content: `⛔ 连续 ${consecutiveErrors} 次 AI 错误, 终止循环`, tool: 'system' });
2412
- }
2413
- finalResponse = `❌ AI 连续调用失败 ${consecutiveErrors} 次, 已终止。\n\n失败原因: ${aiFailureReason}\n\n请检查 API 配置或简化任务后重试。`;
2414
- aiFailed = true;
2415
- break;
2416
- }
2417
- // 把错误当成 tool 结果 push 进 history, 这样下一轮 LLM 看到错误能调整
2418
- this.pushHistory({
2419
- role: 'system',
2420
- content: `[Loop 错误恢复 ${totalErrors}/${this.MAX_TOTAL_ERRORS}] ${aiFailureReason}\n\n请基于上轮工具结果继续完成任务, 不要重复调用同一失败操作. 如果工具已成功执行, 请基于 result.output 给用户总结; 如果工具失败, 请换其他方式或重试.`
2421
- });
2422
- if (onStream) {
2423
- onStream({ type: 'status', content: `⚠️ AI 调用失败 ${totalErrors}/${this.MAX_TOTAL_ERRORS}, 已 push 错误到 history 让 LLM 反思`, tool: 'system' });
2424
- }
2425
- // 退避 2s 后继续 — 临时上游限流避开, 不让 loop 终止
2426
- await new Promise(resolve => setTimeout(resolve, 2000));
2427
- // 关键: 不设 aiFailed=true, 让外层不重试整个 loop (重置 history), 继续内层循环
2428
- continue;
2429
- }
2430
- console.log(`[PiAgent] LLM 回复长度: ${reply.length}, 内容预览: "${reply.substring(0, 80)}..."`);
2431
- console.log(`[PiAgent] LLM 完整回复:\n${reply}`);
2432
- // 通知前端:收到 LLM 回复 (2026-08-09: 不再截断 100 字符 — 前端流式渲染完整内容,
2433
- // 配合 Hermes 式回复框: 加载中显示完整文本, 完成后封闭底框)
2434
- if (onStream) {
2435
- onStream({ type: 'token', content: reply });
2436
- }
2437
- // 2026-06-19 架构 fix: parseToolCall 优先于 isFinalResponse
2438
- // 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
2439
- // 现在: 先尝试解析 tool_call, 有就执行; 没有才检查是不是真正的 final gen
2440
- // Bug 5 (2026-07-17): 优先用 LLM 的 native tool_calls (response.toolCalls), 再回退到文本解析
2441
- // deepseek-v4-flash 用 OpenAI 协议 tools 时, 会真返回结构化 tool_calls 数组
2442
- // 之前 nativeToolCalls 被读了不用, 只查 reply 文本, 导致 LLM 明明选了工具但代码找不到
2443
- // 2026-07-28: 修复多工具调用 — 收集 ALL tool calls, 顺序执行后一次性返回
2444
- let toolCalls = [];
2445
- // 路径 A: native OpenAI 协议 tool_calls (可能多个)
2446
- if (nativeToolCalls && nativeToolCalls.length > 0) {
2447
- for (const nc of nativeToolCalls) {
2448
- try {
2449
- const args = typeof nc.function?.arguments === 'string'
2450
- ? JSON.parse(nc.function.arguments)
2451
- : (nc.function?.arguments || {});
2452
- toolCalls.push({
2453
- name: nc.function?.name,
2454
- args,
2455
- id: nc.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2456
- });
2457
- }
2458
- catch (err) {
2459
- console.warn(`[PiAgent] 解析 native tool_call 失败: ${err.message?.slice(0, 100)}`);
2460
- }
2461
- }
2462
- }
2463
- // 路径 B: 文本解析 (parseAllToolCalls 收集全部)
2464
- if (toolCalls.length === 0) {
2465
- // 2026-09-26: 已知名集合 = 注册表原名 + 它们的 API 名 (净化后的).
2466
- // 出网时工具面给的是 API 名 (见 pi-ai.ts 的唯一净化边界), LLM 在文本里
2467
- // 回吐的也可能是 API 名 —— 只用原名集合过滤会把这类调用整个丢掉.
2468
- const knownTools = expandKnownToolNames(this.tools.keys());
2469
- toolCalls = parseAllToolCalls(reply, { tools: knownTools });
2470
- }
2471
- // 回退路径 C: 原生 parseToolCall (单个)
2472
- if (toolCalls.length === 0) {
2473
- const single = this.parseToolCall(reply);
2474
- if (single)
2475
- toolCalls.push(single);
2476
- }
2477
- // 2026-09-26: **回程派发的唯一还原点** —— LLM 回吐的是 API 名 (净化过的), 这里
2478
- // 还原成注册表真名再交给 this.tools.get(); 原名 (LLM 照 system prompt 抄的) 原样穿透.
2479
- for (const tc of toolCalls) {
2480
- if (typeof tc.name === 'string' && tc.name)
2481
- tc.name = resolveApiToolName(tc.name);
2482
- }
2483
- // 给每个 toolCall 分配稳定 id
2484
- for (const tc of toolCalls) {
2485
- if (!tc.id) {
2486
- tc.id = `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2487
- }
2488
- }
2489
- if (toolCalls.length > 0) {
2490
- // 把原始 LLM 回复 push 进 history (仅一次)
2491
- this.pushHistory({
2492
- role: 'assistant',
2493
- content: reply,
2494
- toolCalls: toolCalls.length > 1 ? toolCalls : [toolCalls[0]],
2495
- // 2026-09-15: 思考模式思维链原样存回 (下一轮带 tools 的请求必须回带, 否则 deepseek 400)
2496
- reasoningContent: response?.reasoningContent,
2497
- });
2498
- // 2026-08-09: 并发执行本轮所有工具 (Hermes 式 Agent Runtime: 一轮内多工具并行,
2499
- // 一轮没跑完之前不中断 — 工具执行不检查 abort, 全部完成才 continue 下一轮)
2500
- // 旧实现顺序 for 循环, 一个工具等一个, 慢; 且多工具时 LLM 要等全部串完才能看到结果.
2501
- await Promise.all(toolCalls.map(async (toolCall, ti) => {
2502
- const isMulti = toolCalls.length > 1;
2503
- // 通知前端
2504
- if (onStream) {
2505
- onStream({ type: 'tool', content: `🔧 调用工具 (${ti + 1}/${toolCalls.length}): ${toolCall.name}`, tool: toolCall.name });
2506
- if (toolCall.args && Object.keys(toolCall.args).length > 0) {
2507
- onStream({ type: 'status', internal: true, content: `📋 参数: ${JSON.stringify(toolCall.args)}`, tool: toolCall.name });
2508
- }
2509
- onStream({
2510
- type: 'step_start',
2511
- content: `调用 ${toolCall.name}${isMulti ? ` (${ti + 1}/${toolCalls.length})` : ''}`,
2512
- tool: toolCall.name,
2513
- args: toolCall.args || {},
2514
- });
2515
- }
2516
- const tool = this.tools.get(toolCall.name);
2517
- if (!tool) {
2518
- consecutiveErrors++;
2519
- totalErrors++;
2520
- const errorResult = { success: false, error: `未知工具: ${toolCall.name}` };
2521
- this.pushHistory({ role: 'tool', content: JSON.stringify(errorResult), toolResult: errorResult });
2522
- this.logToHarness(toolCall.name, toolCall.args, errorResult);
2523
- // 2026-07-28: 注入 Reflection 帮助 LLM 理解错误
2524
- const obs = buildObservation(toolCall.name, toolCall.args, errorResult);
2525
- const ref = buildReflection(toolCall.name, errorResult.error, totalErrors, lastFailedToolCount);
2526
- this.pushHistory({ role: 'system', content: formatObservationWithReflection(obs, ref) });
2527
- if (onStream)
2528
- onStream({ type: 'status', content: `💡 Reflection: ${obs.summary}`, tool: 'system' });
2529
- console.warn(`[PiAgent] 未知工具: ${toolCall.name} (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}),跳过并继续`);
2530
- return;
2531
- }
2532
- // 2026-09-16 (Milestone 1-B): 工具调用前的**唯一**约束入口。
2533
- // 顺序由 PiAgentHarness 决定: deny-pipeline → pre-tool-validator(4 步链) → react-harness(8-gate)。
2534
- // 旧实现是这三处在不同位置各自调用 (且有两条路径 fail-open); 现在 pi-sdk 不再散调任何 gate。
2535
- let toolDecision;
2536
- try {
2537
- toolDecision = await this.piHarness().beforeToolCall({
2538
- tool: toolCall.name,
2539
- args: toolCall.args || {},
2215
+ const d = await this.piHarness().beforeToolCall({
2216
+ tool: 'tsc_check',
2217
+ args: {},
2540
2218
  ctx: this.harnessCtx(),
2541
2219
  permissionMode: this.currentPermissionMode,
2542
2220
  });
2221
+ tscAllowed = d.allow;
2222
+ tscWhy = d.reason || d.rejectedBy || '';
2543
2223
  }
2544
- catch (err) {
2545
- // 门面自身抛错 = 核心约束失效 → fail-closed (不执行工具, 也不静默放行)
2546
- toolDecision = {
2547
- allow: false,
2548
- source: 'harness-error',
2549
- kind: 'core_constraint',
2550
- reason: `Harness 门面异常: ${String(err?.message || err).slice(0, 150)}`,
2551
- };
2552
- }
2553
- if (!toolDecision.allow) {
2554
- const src = toolDecision.source || 'unknown';
2555
- if (src === 'deny-pipeline') {
2556
- // 旧 deny-pipeline 分支: 不计连续失败计数, 文案 "拒绝: [source] reason"
2557
- consecutiveErrors++;
2558
- totalErrors++;
2559
- const denyResultMsg = { success: false, error: `拒绝: [${toolDecision.rejectedBy || 'deny-pipeline'}] ${toolDecision.reason}` };
2560
- this.pushHistory({ role: 'tool', content: JSON.stringify(denyResultMsg), toolResult: denyResultMsg });
2561
- this.logToHarness(toolCall.name, toolCall.args, denyResultMsg);
2562
- return;
2563
- }
2564
- const isGateDeny = src === 'react-harness';
2565
- const isHarnessError = src === 'harness-error';
2566
- const deniedResult = {
2567
- success: false,
2568
- error: isGateDeny
2569
- ? `Harness gate 拒绝 (${toolDecision.rejectedBy}): ${toolDecision.reason || '未通过安全校验'}`
2570
- : `PreToolUse 拒绝: ${toolDecision.reason || '未通过安全校验'}`,
2571
- };
2572
- this.pushHistory({ role: 'tool', content: JSON.stringify(deniedResult), toolResult: deniedResult });
2573
- this.logToHarness(toolCall.name, toolCall.args, deniedResult);
2574
- if (onStream) {
2575
- const gateName = isGateDeny ? `Harness ${toolDecision.rejectedBy}` : (isHarnessError ? '核心约束层' : 'PreToolUse');
2576
- onStream({ type: 'error', content: `🛡️ ${gateName} 拒绝 ${toolCall.name}: ${toolDecision.reason || '安全校验失败'}`, tool: toolCall.name });
2577
- onStream({ type: 'step_error', content: `${gateName} 拒绝 ${toolCall.name}`, tool: toolCall.name, error: toolDecision.reason || '安全校验失败' });
2578
- }
2579
- console.warn(`[PiAgent] 工具被拒 ${toolCall.name} (${src}${toolDecision.rejectedBy ? ':' + toolDecision.rejectedBy : ''}): ${toolDecision.reason}`);
2580
- consecutiveErrors++;
2581
- totalErrors++;
2582
- if (toolCall.name === lastFailedTool) {
2583
- lastFailedToolCount++;
2584
- }
2585
- else {
2586
- lastFailedTool = toolCall.name;
2587
- lastFailedToolCount = 1;
2588
- }
2589
- // 达到同一工具连续失败上限 / 连续错误上限时的引导语 (按拒绝来源保持原有文案)
2590
- const systemMaxMsg = isGateDeny
2591
- ? `[注意] 工具 ${toolCall.name} 被 Harness 拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 末尾加 <final gen>.`
2592
- : isHarnessError
2593
- ? `[注意] 工具 ${toolCall.name} 的约束校验层失效, 已按 fail-closed 阻止 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请换其他工具或直接回答用户, 末尾加 <final gen>.`
2594
- : `[注意] 工具 ${toolCall.name} 被系统拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 直接用已有信息回答用户, 末尾加 <final gen>.`;
2595
- const systemConsecMsg = isGateDeny
2596
- ? `[注意] 连续 ${consecutiveErrors} 次工具调用被 Harness 拒绝. 请换其他工具或直接回答.`
2597
- : isHarnessError
2598
- ? `[注意] 连续 ${consecutiveErrors} 次工具调用因约束层失效被阻止. 请换其他工具或直接回答用户, 末尾加 <final gen>.`
2599
- : `[注意] 连续 ${consecutiveErrors} 次工具调用被系统拒绝. 请换其他工具或直接回答用户, 末尾加 <final gen>.`;
2600
- if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
2601
- this.pushHistory({ role: 'system', content: systemMaxMsg });
2602
- lastFailedTool = '';
2603
- lastFailedToolCount = 0;
2604
- consecutiveErrors = 0;
2605
- }
2606
- else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2607
- this.pushHistory({ role: 'system', content: systemConsecMsg });
2608
- consecutiveErrors = 0;
2609
- }
2610
- return;
2611
- }
2612
- if (toolDecision.systemAddition) {
2613
- this.contextHintAddition += '\n' + toolDecision.systemAddition;
2614
- }
2615
- try {
2616
- const toolStart = Date.now();
2617
- // 2026-09-16 (M2): 恢复重放守卫 —— 中断前**已成功执行过的非幂等动作**恢复后不再重做,
2618
- // 直接复用当时的结果 (避免重复副作用: 重复写文件/重复提交/重复付款)。
2619
- // (M2-B: 守卫也覆盖"上一个 Run 已做过的非幂等动作" —— 跨 Run 续跑同样不许重做)
2620
- let replaySkip = null;
2621
- const guards = [
2622
- ...(this.resumePlan?.replayGuards || []),
2623
- ...(this.continuationGuards || []),
2624
- ];
2625
- if (guards.length) {
2626
- const d = argsDigestOf(toolCall.args);
2627
- const hit = guards.find((g) => g.tool === toolCall.name && (!g.argsDigest || !d || g.argsDigest === d));
2628
- if (hit)
2629
- replaySkip = hit.summary;
2630
- }
2631
- let result = replaySkip
2632
- ? { success: true, output: `[恢复保护] ${toolCall.name} 在中断前已成功执行过, 本次不重复执行 (避免重复副作用)。当时结果: ${replaySkip}`, _replaySkipped: true }
2633
- : await (toolCall.__t0 = Date.now(), tool.execute(expandHomeArgs(toolCall.args)));
2634
- const toolDurationMs = Date.now() - toolStart;
2635
- if (replaySkip) {
2636
- console.log(`[PiAgent] 恢复重放守卫: 跳过已完成的非幂等工具 ${toolCall.name}`);
2637
- onStream?.({ type: 'status', internal: true, content: `🛡️ 恢复保护: ${toolCall.name} 此前已成功执行, 本次不重复执行`, tool: toolCall.name });
2638
- }
2639
- console.log(`[PiAgent] 工具 ${toolCall.name} 执行完成: success=${result.success} (${toolDurationMs}ms)`);
2640
- // 2026-09-16 (M3): 失败接线 —— 分类 + recovery 留痕 + 熔断 / 外部等待
2641
- if (!result.success && this.actor.state.activeRun && !replaySkip) {
2642
- await this.wireToolFailure(toolCall.name, String(result.error || ''), toolCall.args);
2643
- }
2644
- else if (result.success && this.actor.state.activeRun && this.awaitingExternal) {
2645
- // 外部回话了: awaiting_external → running (不是"恢复完成", 只是等待结束)
2646
- this.awaitingExternal = false;
2647
- await this.safeSetRunStatus(this.actor.state.activeRun, 'running');
2648
- }
2649
- // 2026-09-16: 持久化 run harness — 每步工具调用立即落盘 (崩在这里也能看到做到哪步)
2650
- if (this.actor.state.activeRun) {
2651
- try {
2652
- await recordStep(this.actor.state.activeRun, {
2653
- tool: toolCall.name,
2654
- ok: !!result.success,
2655
- ms: toolDurationMs,
2656
- args: toolCall.args,
2657
- summary: String(result.output || '').slice(0, 200),
2658
- error: result.error ? String(result.error) : undefined,
2659
- });
2660
- }
2661
- catch (err) {
2662
- // 核心写失败 → 本轮工具批跑完即停 (循环顶部硬闸), 不再 warn 后继续
2663
- runPersistenceFailure = `工具步骤写盘失败 (${toolCall.name}): ${String(err?.message || err).slice(0, 200)}`;
2664
- this.runPersistenceBlocked = true;
2665
- console.error('[PiAgent] run-store recordStep 失败 (核心持久化):', runPersistenceFailure);
2666
- onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 本轮结束后停止`, tool: 'harness' });
2667
- }
2668
- }
2669
- try {
2670
- await onPostToolUse({ tool: toolCall.name, args: toolCall.args || {}, result: { success: result.success, output: result.output?.substring(0, 500), error: result.error }, durationMs: toolDurationMs });
2671
- }
2672
- catch (postErr) {
2673
- console.warn('[PiAgent] onPostToolUse failed (non-fatal):', postErr);
2674
- }
2675
- // 2026-09-16 (Milestone 1-B): 工具调用后的唯一入口 (router hint + 输出 gate 一起判定)
2676
- const after = await this.piHarness().afterToolCall({
2677
- tool: toolCall.name,
2678
- output: String(result.output || ''),
2679
- ctx: this.harnessCtx(),
2680
- ok: !!result.success,
2681
- });
2682
- if (after.routeHint?.systemAddition) {
2683
- this.pushHistory({ role: 'system', content: `[Harness Router Hint: ${after.routeHint.reason}]\n${after.routeHint.systemAddition}` });
2684
- }
2685
- if (after.outputBlocked) {
2686
- if (onStream) {
2687
- onStream({ type: 'error', content: `🛡️ Harness output 拒绝 ${toolCall.name}: ${after.outputBlocked.reason}`, tool: toolCall.name });
2688
- }
2689
- console.warn(`[PiAgent] Harness output denied ${toolCall.name}: ${after.outputBlocked.reason}`);
2690
- result = { ...result, output: `[harness output gate: 输出含敏感内容, 已屏蔽. 原因: ${after.outputBlocked.reason}]`, _harnessDenied: true };
2691
- }
2692
- // 2026-10-01: 落库前做一次停滞观测 —— 命中的是"引导/引用", 不是"拒绝执行":
2693
- // 完全相同的返回从第 2 次起折叠成引用 (省上下文); 连续 3 次同参数同结果或检测到循环 ⇒
2694
- // 在结果尾部追加一条系统提示 (保留模型的选择权, 不硬停 —— 硬停那条已被用户否决)。
2695
- try {
2696
- const obs = observeToolCall(stallState, {
2697
- toolName: toolCall.name,
2698
- sameSignatureBefore: this.lastToolSig === argsFingerprint(toolCall.args),
2699
- args: toolCall.args,
2700
- resultText: String(result.output || (result.success ? '' : String(result.error || ''))),
2701
- ok: !!result.success,
2702
- seenResultBefore: stallState.hasSeenResult(String(result.output || '')),
2703
- });
2704
- if (obs.stub) {
2705
- result = { ...result, output: obs.stub, _stubbed: true };
2706
- }
2707
- else if (obs.notice) {
2708
- result = { ...result, output: `${String(result.output || '')}\n\n${obs.notice}` };
2709
- }
2710
- if (obs.action === 'warn') {
2711
- console.warn(`[PiAgent] 工具停滞引导 (${obs.code}, 第 ${obs.count} 次): ${toolCall.name}`);
2712
- onStream?.({ type: 'status', internal: true, content: `🩺 检测到重复调用 ${toolCall.name} (${obs.code}), 已提示模型换法`, tool: 'loop' });
2713
- }
2714
- }
2715
- catch { /* 观测失败绝不影响主路径 */ }
2716
- this.pushHistory({ role: 'tool', content: JSON.stringify(result), toolResult: result, toolCallId: toolCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` });
2717
- // 2026-10-01 (用户: 「为什么这么慢」): 量到的事实 —— 每次 LLM 往返平均 3338ms,
2718
- // 而"一轮发多个工具"(toolCalls>1) 只占 12/63 ✗ ⇒ 回合时长 ≈ 往返次数 × 3.3 秒 ✓。
2719
- // 本回合**第一次**且**只发一个**工具时, 附一句带代价的提醒(不是口号 ✓), 只提一次免得刷屏。
2720
- if (toolCalls.length === 1 && this.successfulToolResults.length <= 1) { // 本回合头一次(免得每轮刷屏)
2721
- const __batchHint = batchHint(toolCall.name, 1);
2722
- if (__batchHint)
2723
- this.pushHistory({ role: 'system', content: __batchHint.trim() });
2724
- }
2725
- this.logToHarness(toolCall.name, toolCall.args, result);
2726
- // 2026-08-09: 记录到本轮行动日志 (循环进度 + final 前目标核查用)
2727
- // 去重: 同一工具同 args 连续成功只记一次 (防 LLM 重复 react 刷屏)
2728
- const argsPreview = JSON.stringify(toolCall.args || {}).slice(0, 120);
2729
- const isDup = loopActionLog.some((a) => a.tool === toolCall.name && a.argsPreview === argsPreview && a.success === !!result.success);
2730
- if (!isDup) {
2731
- loopActionLog.push({
2732
- tool: toolCall.name,
2733
- argsPreview,
2734
- resultPreview: result.success
2735
- ? String(result.output || '(无输出)').slice(0, 200)
2736
- : String(result.error || 'failed').slice(0, 200),
2737
- success: !!result.success,
2738
- });
2739
- }
2740
- if (onStream) {
2741
- if (result.success) {
2742
- onStream({ type: 'status', internal: true, content: `✅ ${toolCall.name} 执行成功`, tool: toolCall.name });
2743
- if (result.output) {
2744
- onStream({ type: 'tool', content: `📤 结果: ${result.output.substring(0, 200)}${result.output.length > 200 ? '...' : ''}`, tool: toolCall.name });
2745
- }
2746
- onStream({ type: 'step_done', content: `${toolCall.name} 执行成功`, tool: toolCall.name, success: true, output: result.output });
2747
- }
2748
- else {
2749
- onStream({ type: 'error', content: `❌ ${toolCall.name} 执行失败: ${result.error}`, tool: toolCall.name });
2750
- onStream({ type: 'step_error', content: `${toolCall.name} 执行失败`, tool: toolCall.name, error: result.error });
2751
- }
2752
- }
2753
- // 2026-10-01 (优化 #1/#3/#4): 工具结果的**进上下文闸** + **遥测** + **写操作读回自证**
2754
- {
2755
- const __ms = Date.now() - Number(toolCall.__t0 || Date.now());
2756
- try {
2757
- // #1 结果闸: 超上限 ⇒ 头尾 + 完整结果落文件(给路径) ⇒ 上下文不再被一条大输出长期占住
2758
- const capped = capToolResult(String(result.output ?? ''), { tool: toolCall.name });
2759
- if (capped.capped) {
2760
- result.output = capped.text;
2761
- console.warn(`[PiAgent] ${toolCall.name} 结果过长(${capped.originalChars})已截断` + (capped.spilledTo ? `, 完整内容: ${capped.spilledTo}` : ''));
2762
- }
2763
- // #4 写操作"读回自证": 写类工具成功后自动核一次(存在? 大小?) ⇒ "工具说成功≠任务成功"从规矩变成机制
2764
- // 2026-10-01: 改了 TS 源码就记账 ⇒ 回合收尾自动跑类型检查 (把"靠自觉"变成机制)
2765
- {
2766
- const tsFile = codeWriteTarget(toolCall.name, toolCall.args);
2767
- if (tsFile && !this.tsTouchedThisTurn.includes(tsFile))
2768
- this.tsTouchedThisTurn.push(tsFile);
2769
- }
2770
- const verified = verifyWriteOutcome(toolCall.name, toolCall.args, this.cwd);
2771
- if (verified)
2772
- result.output = `${String(result.output ?? '')}\n${verified}`;
2773
- // #3 遥测: 记一行(工具/指纹/耗时/成败/结果大小/是否与上一次同签名) ⇒ 重复率可算
2774
- recordToolCall({
2775
- tool: toolCall.name,
2776
- sig: argsFingerprint(toolCall.args),
2777
- ms: __ms,
2778
- ok: true,
2779
- resultChars: String(result.output ?? '').length,
2780
- prevSig: this.lastToolSig ?? null,
2781
- });
2782
- this.lastToolSig = argsFingerprint(toolCall.args);
2783
- }
2784
- catch { /* 任何优化项失败都不影响工具结果本身 */ }
2785
- }
2786
- if (result.success) {
2787
- consecutiveErrors = 0;
2788
- // 2026-07-29: Hermes 风格硬限制计数
2789
- totalToolCallsThisLoop++;
2790
- // 2026-10-01: 批处理工具(一次顶多次) ⇒ **退还**这次迭代 (奖励批处理, 压零碎调用)
2791
- if (this.iterBudget && isRefundableTool(toolCall.name)) {
2792
- this.iterBudget.refund();
2793
- console.warn(`[PiAgent] ${toolCall.name} 是批量工具 ⇒ 退还 1 次迭代 (现 ${this.iterBudget.describe()})`);
2794
- }
2795
- lastNTools.push(toolCall.name);
2796
- if (lastNTools.length > MAX_IDEMPOTENT_TOOL)
2797
- lastNTools.shift();
2798
- if (result.output) {
2799
- this.successfulToolResults.push({ tool: toolCall.name, outputPreview: result.output.substring(0, 200) + (result.output.length > 200 ? '...' : '') });
2800
- }
2801
- else {
2802
- this.successfulToolResults.push({ tool: toolCall.name, outputPreview: '(无输出)' });
2803
- }
2804
- loopReviewCompletedTools.add(toolCall.name);
2805
- lastQualityScore = this.estimateToolResultQuality(result);
2806
- if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
2807
- refineAttempts++;
2808
- }
2809
- if (onStream) {
2810
- onStream({ type: 'status', internal: true, content: `🔄 工具执行完成,继续循环...`, tool: 'loop' });
2811
- }
2812
- }
2813
- else {
2814
- consecutiveErrors++;
2815
- // 2026-10-01 (优化 #3): 失败也记一行(带错误类别) ⇒ "错工具率"从这类错误里看得出来
2816
- try {
2817
- const __cls = classifyError(String(result.error || '')).label || '未分类';
2818
- recordToolCall({ tool: toolCall.name, sig: argsFingerprint(toolCall.args), ms: 0,
2819
- ok: false, resultChars: 0, prevSig: this.lastToolSig ?? null, errorClass: __cls });
2820
- this.lastToolSig = argsFingerprint(toolCall.args);
2821
- }
2822
- catch { /* 遥测失败不影响 */ }
2823
- totalErrors++;
2824
- if (toolCall.name === lastFailedTool) {
2825
- lastFailedToolCount++;
2826
- }
2827
- else {
2828
- lastFailedTool = toolCall.name;
2829
- lastFailedToolCount = 1;
2830
- }
2831
- console.warn(`[PiAgent] 工具 ${toolCall.name} 执行失败 (${lastFailedToolCount}/${MAX_SAME_TOOL_FAILURES}, 累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}): ${result.error}`);
2832
- // 2026-07-28: 注入 Observation + Reflection 替代旧 hardcode 提示
2833
- const obs = buildObservation(toolCall.name, toolCall.args, { success: false, error: result.error });
2834
- const ref = buildReflection(toolCall.name, result.error, totalErrors, lastFailedToolCount);
2835
- this.pushHistory({ role: 'system', content: formatObservationWithReflection(obs, ref) + SHELL_ESCAPE_HINT });
2836
- if (onStream)
2837
- onStream({ type: 'status', content: `💡 Reflection: ${obs.summary} → ${ref[0]?.action || '放弃'}`, tool: 'system' });
2838
- if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
2839
- this.pushHistory({ role: 'system', content: `[注意] 工具 ${toolCall.name} 在这个上下文中不可用 (连续 ${MAX_SAME_TOOL_FAILURES} 次失败: ${result.error}). 请不要再次调用它, 直接用你已知的信息回答用户, 并在回答开头标记 <final gen>.` });
2840
- lastFailedTool = '';
2841
- lastFailedToolCount = 0;
2842
- consecutiveErrors = 0;
2843
- return;
2844
- }
2845
- if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2846
- this.pushHistory({ role: 'system', content: `[注意] 前面的工具调用连续失败。请尝试其他工具或换一种方式完成用户请求, 或用 <final gen> 给出最终回答.` });
2847
- consecutiveErrors = 0;
2848
- }
2849
- }
2850
- }
2851
- catch (execError) {
2852
- consecutiveErrors++;
2853
- totalErrors++;
2854
- const errorResult = { success: false, error: String(execError) };
2855
- this.pushHistory({ role: 'tool', content: JSON.stringify(errorResult), toolResult: errorResult });
2856
- this.logToHarness(toolCall.name, toolCall.args, errorResult);
2857
- const obs = buildObservation(toolCall.name, toolCall.args, errorResult);
2858
- const ref = buildReflection(toolCall.name, errorResult.error, totalErrors, lastFailedToolCount);
2859
- this.pushHistory({ role: 'system', content: formatObservationWithReflection(obs, ref) + SHELL_ESCAPE_HINT });
2860
- if (onStream)
2861
- onStream({ type: 'status', content: `💡 Reflection: ${obs.summary}`, tool: 'system' });
2862
- console.error(`[PiAgent] 工具执行异常 (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}): ${execError}`);
2863
- }
2864
- })); // end Promise.all(toolCalls.map(async ...))
2865
- // 所有工具执行完毕后, continue while 循环, 让 LLM 看到结果
2866
- continue;
2867
- }
2868
- else {
2869
- // LLM 返回的不是 tool call 格式
2870
- this.pushHistory({
2871
- role: 'assistant',
2872
- content: reply,
2873
- reasoningContent: response?.reasoningContent,
2874
- });
2875
- // 通知前端收到非工具调用回复 (2026-08-09: 完整内容, 不再截断 150)
2876
- if (onStream) {
2877
- onStream({ type: 'token', content: reply });
2878
- }
2879
- // 2026-06-19 架构 fix: 只有 strip <think> 后才检查 isFinalResponse
2880
- // (parseToolCall 已先尝试, 既然没解析出 tool_call, 现在检查 final gen 是否真的在最终回答区)
2881
- if (this.isFinalResponse(reply)) {
2882
- // 2026-06-19 dive-into 风格修复: 如果还有 successful tool results 没汇报,
2883
- // LLM 不能提前 final_gen — harness 自动注入"请汇报剩余工具结果" hint 再 continue
2884
- // 这是 dive-into 文档"step 9 stop condition check" 的具体化:
2885
- // stop condition = (有工具结果未汇报) ? continue : break
2886
- // 检查回复是否包含工具结果内容(避免无限循环)
2887
- const hasToolResultContent = this.successfulToolResults.some(r => reply.includes(r.tool) || reply.includes(r.outputPreview.substring(0, 50)));
2888
- // 如果回复包含工具结果内容,清除 successfulToolResults
2889
- if (hasToolResultContent) {
2890
- console.log(`[PiAgent] 回复包含工具结果内容, 清除 successfulToolResults (${this.successfulToolResults.length} 个)`);
2891
- this.successfulToolResults = [];
2892
- }
2893
- // 2026-08-10: 逃生门 — decideUnreported: 未达上限 → 再提示一次; 超限 → 清空积压强制 final (防死循环)
2894
- const unreportedDecision = decideUnreported(this.successfulToolResults.length, unreportedRetries, MAX_UNREPORTED_RETRIES);
2895
- if (unreportedDecision === 'retry' && iteration < this.MAX_REACT_ITERATIONS) {
2896
- unreportedRetries++;
2897
- const unreported = this.successfulToolResults.length;
2898
- console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES}), push hint 让其继续`);
2899
- this.pushHistory({
2900
- role: 'system',
2901
- content: `[dive-into stop condition] 你之前已成功执行了 ${unreported} 个工具, 但当前回复里没把它们的结果告诉用户. 请基于已有的工具结果 (在 history 里) 写一个完整总结回复给用户, 用 <final gen> 结尾. 不要再调工具.`
2902
- });
2903
- if (onStream) {
2904
- onStream({ type: 'status', internal: true, content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结 (${unreportedRetries}/${MAX_UNREPORTED_RETRIES})`, tool: 'system' });
2905
- }
2906
- continue;
2907
- }
2908
- else if (unreportedDecision === 'force-final') {
2909
- // 反复提示仍未汇报超过上限 → 清空积压强制 final, 不再死循环
2910
- console.log(`[PiAgent] unreported 循环超限 (${unreportedRetries} 次), 清空积压强制 final`);
2911
- this.successfulToolResults = [];
2912
- this.pushHistory({
2913
- role: 'system',
2914
- content: `[dive-into stop condition] 已多次提示汇报工具结果仍未完成 (超过 ${MAX_UNREPORTED_RETRIES} 次). 现在直接基于你已知的信息写最终回复给用户, 用 <final gen> 结尾, 不要再调任何工具.`
2915
- });
2916
- if (onStream) {
2917
- onStream({ type: 'status', internal: true, content: `🔄 工具结果汇报超限, 强制收尾`, tool: 'system' });
2918
- }
2919
- }
2920
- lastQualityScore = this.estimateResponseQuality(reply);
2921
- // 2026-07-29: 质量门 — 即使 LLM 声称完成, 质量太低也继续
2922
- if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
2923
- console.log(`[PiAgent] final gen 质量 ${lastQualityScore.toFixed(2)} < ${this.QUALITY_THRESHOLD}, 注入 refine hint`);
2924
- this.pushHistory({ role: 'system', content: `[质量检查] 你的回答质量评分为 ${(lastQualityScore * 10).toFixed(1)}/10, 低于 ${(this.QUALITY_THRESHOLD * 10).toFixed(1)}/10 阈值。请提供更完整、详细的回答, 包含工具调用获取到的具体信息, 末尾加 <final gen>。` });
2925
- refineAttempts++;
2926
- continue;
2927
- }
2928
- // 2026-08-08: final 前目标对齐 review — 不潦草收尾 (见 loop-review.ts)
2929
- // LLM 想 <final gen> 时, 先跑 1-2 次「目标对齐 + 需求深挖」review;
2930
- // 达成用户需求才放行真正结束. 达上限或无需深挖则以用户需求为准结束.
2931
- // 2026-09-16 (Milestone 1-B): 目标审查走唯一门面 (loop-review 是 Harness 的一个环节)
2932
- const reviewDecision = this.piHarness().reviewFinal({
2933
- reviewsDone: loopReviewCount,
2934
- // 2026-08-10: 传用户原始输入 (不是派生 intentHint) — LLM 对照原文自查完成度,
2935
- // 未完成 → 自动继续调工具 (自动触发后续步骤)
2936
- userIntent: this.currentUserInput,
2937
- completedTools: Array.from(loopReviewCompletedTools),
2938
- actionLog: loopActionLog,
2939
- runId: this.actor.state.activeRun || undefined,
2940
- goalId: this.actor.state.goalBinding || undefined,
2941
- }, DEFAULT_MAX_REVIEWS);
2942
- if (reviewDecision.kind === 'continue-review') {
2943
- loopReviewCount++;
2944
- console.log(`[PiAgent] review ${loopReviewCount}/${DEFAULT_MAX_REVIEWS}: LLM 想 final 但先对齐需求深挖一次`);
2945
- this.pushHistory({ role: 'system', content: reviewDecision.hint });
2946
- if (onStream) {
2947
- onStream({ type: 'status', internal: true, content: `🔄 目标对齐 review ${loopReviewCount}/${DEFAULT_MAX_REVIEWS}: 深挖续跑`, tool: 'system' });
2948
- }
2949
- continue; // 让 LLM 看到 hint, 深挖或确认完成后再次 final
2224
+ catch (gateErr) {
2225
+ tscAllowed = false;
2226
+ tscWhy = `harness-error: ${String(gateErr?.message || gateErr)}`;
2950
2227
  }
2951
- finalResponse = this.extractFinalAnswer(reply);
2952
- break;
2953
- }
2954
- // 检查是否需要继续循环处理
2955
- // 更严格的判断:只有当回复明确表示需要更多信息时才继续
2956
- const containsToolCallIntent = reply.includes('调用工具') || reply.includes('tool(') ||
2957
- reply.includes('使用工具') || reply.includes('需要获取') || reply.includes('需要查看') ||
2958
- // 兼容 LLM 用对象字面量输出 tool call (上轮没解析成功时, 至少要继续)
2959
- reply.includes('tool =>') || reply.includes('[TOOL_CALL]') ||
2960
- // 2026-06-15 修: 兼容 LLM 用 XML 标签输出 tool call (<shell_exec>...</shell_exec>)
2961
- // 这时 parseToolCall 失败, 至少要让 loop 继续
2962
- /<\w+>[\s\S]*?<\/\w+>/.test(reply);
2963
- const hasError = ['不存在', '找不到', '无法找到', 'not found', 'does not exist',
2964
- '错误', 'error', '失败', 'failed'].some(k => reply.includes(k));
2965
- const isTooShort = reply.length < 50 && reply.length > 0;
2966
- const hasQuestion = reply.includes('?') && (reply.includes('怎么') || reply.includes('如何') || reply.includes('什么'));
2967
- const needsMoreWork = hasError || containsToolCallIntent || isTooShort || hasQuestion;
2968
- if (needsMoreWork && iteration < this.MAX_REACT_ITERATIONS) {
2969
- console.log(`[PiAgent] 继续循环处理 (${iteration}/${this.MAX_REACT_ITERATIONS}): needsMoreWork=${needsMoreWork}, hasError=${hasError}, containsToolCallIntent=${containsToolCallIntent}`);
2970
- if (onStream) {
2971
- onStream({ type: 'status', internal: true, content: `🔄 继续处理,循环 ${iteration}...`, tool: 'loop' });
2972
- }
2973
- continue;
2974
- }
2975
- // 否则把这个当作可能的最终回答
2976
- finalResponse = reply;
2977
- if (onStream) {
2978
- onStream({ type: 'status', internal: true, content: `📝 提取最终回答,长度 ${reply.length}`, tool: 'system' });
2979
- }
2980
- break;
2981
- }
2982
- }
2983
- if (!finalResponse) {
2984
- // 走到这里通常是 LLM 一直在调同一个不存在的工具, 没输出 <final gen>
2985
- // 把已知的失败信息也带回去, 让用户知道发生了什么
2986
- const reason = lastFailedTool
2987
- ? `(工具 ${lastFailedTool} 连续 ${MAX_SAME_TOOL_FAILURES} 次失败, 已放弃)`
2988
- : `(共 ${iteration - 1} 轮无最终输出)`;
2989
- finalResponse = `抱歉,任务未能完成 ${reason}。请换个方式提问,或明确告诉 agent 不要调用工具。`;
2990
- if (onStream) {
2991
- onStream({ type: 'error', content: `⚠️ 任务未完成: ${reason}`, tool: 'system' });
2992
- }
2993
- }
2994
- // 通知前端循环完成
2995
- if (onStream) {
2996
- onStream({ type: 'status', internal: true, content: `✅ 处理完成,共 ${iteration - 1} 次循环`, tool: 'system' });
2997
- }
2998
- this.pushHistory({ role: 'assistant', content: finalResponse });
2999
- // React Harness: 循环结束
3000
- // 2026-09-16 (Milestone 1-B): 会话收尾走唯一门面
3001
- await this.piHarness().sessionEnd(this.harnessCtx());
3002
- // 2026-09-16: 收尾落盘 — done / failed / aborted / needs_human 如实写回 (不留幽灵 running)
3003
- if (this.actor.state.activeRun && !runExternallyPaused && !runExternallyAborted) {
3004
- try {
3005
- // 2026-09-16 (M4): 完成门 —— "模型说完成"不等于"系统确认完成"。
3006
- // 证据 = 成功步骤的事实摘要; 末尾还有失败步骤没被后续成功覆盖 → 不许 done。
3007
- const recBefore = await readRun(this.actor.state.activeRun).catch(() => null);
3008
- const steps = recBefore?.steps || [];
3009
- const evidence = steps.filter((s) => s.ok).map((s) => `${s.tool}: ${(s.summary || '').slice(0, 120)}`).slice(-10);
3010
- const lastStep = steps[steps.length - 1];
3011
- const trailingFailure = !!lastStep && !lastStep.ok;
3012
- /** 有工具步骤却一条成功证据都没有 → 不算完成 (证据门槛, 不是文案) */
3013
- const noEvidence = steps.length > 0 && evidence.length === 0;
3014
- const errText = runPersistenceFailure || this.breakerReason || runNeedsHuman || runStopReason || aiFailureReason || '';
3015
- const cls = errText ? classifyRunError(errText) : 'unknown';
3016
- // 持久化失败 / 熔断 / 鉴权 → needs_human; 预算/中止 → aborted; 末尾仍失败或无证据 → failed (不许 done)
3017
- const status = runPersistenceFailure
3018
- ? 'needs_human'
3019
- : (this.breakerReason || runNeedsHuman)
3020
- ? 'needs_human'
3021
- : runStopReason
3022
- ? 'aborted'
3023
- : aiFailed
3024
- ? (cls === 'auth' ? 'needs_human' : 'failed')
3025
- : (trailingFailure || noEvidence)
3026
- ? 'failed'
3027
- : 'done';
3028
- await finishRun(this.actor.state.activeRun, {
3029
- status,
3030
- summary: finalResponse ? String(finalResponse).slice(0, 400) : undefined,
3031
- error: errText
3032
- || (trailingFailure ? `末尾步骤失败 (${lastStep.tool}): ${(lastStep.error || '').slice(0, 150)}` : undefined)
3033
- || (noEvidence ? '有工具步骤但没有任何成功证据: 不许判 done' : undefined),
3034
- evidence,
3035
- });
3036
- }
3037
- catch (err) {
3038
- // 终态也写不下去: 这是最坏情况 —— 除了留痕, 没有别的自愈手段, 所以必须显眼
3039
- const message = `收尾落盘失败 (状态无法写回, 记录会停在 running): ${String(err?.message || err).slice(0, 200)}`;
3040
- console.error('[PiAgent] run-store finishRun 失败 (核心持久化):', message);
3041
- await recordDegradation({ kind: 'core', op: 'pi-sdk.finishRun', runId: this.actor.state.activeRun, message }).catch(() => { });
3042
- onStream?.({ type: 'error', content: `⚠️ ${message}`, tool: 'harness' });
3043
- }
3044
- // ★ 2026-09-25 (M0 接线冻结, 规则 ③ + ④): Run 结束**必过收尾漏斗**。
3045
- //
3046
- // 旧写法在这里另起了一套 Goal 侧收尾 (读证据 → `evaluateGoalCompletion` →
3047
- // `completeGoalIfEligible` / `setUnresolved`): 它**不收尾** —— 不写 Memory、不生成 Skill 候选、
3048
- // 不写权威 continuation、不留决策记录。于是同一条 Run 在 Supervisor 那里还会再收一次 (或反过来
3049
- // 这条路径根本不收), 全仓就有了两套"这一轮跑完意味着什么"。那一套已删除。
3050
- //
3051
- // 现在: `closeRunOnce` (幂等) → 9 步收尾 → 产物落盘; 再由 Goal reducer 把"下一步是什么"
3052
- // 落进 Goal (没有 Supervisor 宿主时 Runner 也要留下 continuation, 否则下一次还是从零开始)。
3053
- if (this.actor.state.goalBinding) {
3054
- try {
3055
- const wiring = await import('./goal-flywheel-wiring.js');
3056
- const { reduceGoalState } = await import('./goal-state-reducer.js');
3057
- const nowIso = new Date().toISOString();
3058
- const closed = await wiring.closeRunOnce({
3059
- goalId: this.actor.state.goalBinding,
3060
- runId: this.actor.state.activeRun,
3061
- caller: 'runner',
3062
- now: nowIso,
3063
- finalReview: finalResponse ? String(finalResponse).slice(0, 2000) : '',
3064
- });
3065
- if (wiring.isRefusal(closed)) {
3066
- onStream?.({ type: 'status', content: `⚠️ 收尾被拒 (不当作收过): ${closed.reason}`, tool: 'harness' });
3067
- }
3068
- else if (closed.outcome) {
3069
- const view = closed.outcome;
3070
- const applied = await reduceGoalState({
3071
- goalId: this.actor.state.goalBinding,
3072
- intent: 'closure_outcome',
3073
- now: nowIso,
3074
- by: 'runner',
3075
- outcome: {
3076
- goalStatus: wiring.goalStatusFromDecision(view.decision),
3077
- continuation: wiring.toGoalStoreContinuation(view.continuation),
3078
- reason: view.decision.reason,
3079
- },
3080
- runId: view.runId,
3081
- });
3082
- const line = view.decision.decision === 'complete'
3083
- ? `🎯 目标已达成 (仍要过完成门): ${view.decision.reason}`
3084
- : `🎯 目标仍在进行 (未判完成): ${view.decision.reason}`;
3085
- onStream?.({
3086
- type: 'status', internal: true,
3087
- content: `${line}${applied.gateRejected ? ` [完成门拒绝: ${applied.gateRejected}]` : ''} `
3088
- + `(收尾 ${view.steps} 步 · memory ${view.memories} · skill 候选 ${view.candidates} · goal=${this.actor.state.goalBinding})`,
3089
- tool: 'harness',
3090
- });
2228
+ if (!tscAllowed) {
2229
+ this.runCtx.eventSink?.({ type: 'status', content: `🔎 类型检查被门拒绝, 未执行: ${tscWhy.slice(0, 120)}`, tool: 'system' });
3091
2230
  }
3092
2231
  else {
3093
- // 幂等: 别处 (或上一次) 已经收过尾 —— 事实读不回来就如实说, 不重收也不假装
3094
- 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' });
3095
2235
  }
3096
2236
  }
3097
- catch (err) {
3098
- await recordDegradation({ kind: 'observational', op: 'pi-sdk.runClosure', runId: this.actor.state.activeRun, message: String(err?.message || err).slice(0, 160) }).catch(() => { });
3099
- }
3100
2237
  }
3101
- this.actor.state.activeRun = '';
3102
- }
3103
- else if (this.actor.state.activeRun) {
3104
- // 外部暂停/中止: 状态是人定的, 不覆盖 (paused 等 /resume; aborted 是终态)
3105
- onStream?.({ type: 'status', content: `⏹️ 运行状态保持为 ${runExternallyPaused ? 'paused' : 'aborted'} (由外部控制面决定)`, tool: 'harness' });
3106
- this.actor.state.activeRun = '';
2238
+ catch (e) {
2239
+ this.runCtx.eventSink?.({ type: 'status', content: `🔎 类型检查没能跑起来: ${String(e?.message || e).slice(0, 100)} (改动已落盘, 记得自己跑一次)`, tool: 'system' });
2240
+ }
2241
+ this.tsTouchedThisTurn = [];
3107
2242
  }
3108
- // 2026-06-16: 暴露 aiFailed 标志 — promptStream 据此决定是否自动重试整个 loop
3109
- return { reply: finalResponse, aiFailed, aiFailureReason: aiFailureReason || undefined };
3110
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`, 口径归一。
3111
2250
  async deepThink(prompt) {
3112
2251
  const result = await this.thinkingEngine.think(prompt);
3113
2252
  let response = `深度思考完成(${result.depth}层):\n\n`;
@@ -3904,21 +3043,46 @@ ${this.extractOperationsFromRef(operationsRef)}
3904
3043
  getOperationLogs() {
3905
3044
  return this.constraintLayer.getLogs();
3906
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
+ }
3907
3058
  listPeers() {
3908
- const peers = p2pNetwork.getPeers();
3059
+ // K10 ④ 后半: 同步读也经端口 (`transportPeersSync`); 端口未注入 ⇒ 明确说"读不到", 不假装"没有对端"
3060
+ const peers = transportPeersSync(this.transportPorts());
3061
+ if (peers === null)
3062
+ return '(通信端口未注入: 读不到对端列表 —— 不是"没有对端", 是没接上)';
3909
3063
  if (peers.length === 0) {
3910
3064
  return '当前无连接的对等节点';
3911
3065
  }
3912
3066
  return `已连接节点 (${peers.length}):\n${peers.map(p => ` - ${p}`).join('\n')}`;
3913
3067
  }
3914
3068
  getPeers() {
3069
+ const peers = transportPeersSync(this.transportPorts());
3070
+ if (peers !== null)
3071
+ return peers;
3072
+ // 端口未注入时**回落到传输直读**并留痕: 同步读路径没有"拒绝"的余地, 静默返回空列表会被误读成"没有对端"
3073
+ console.warn('[kernel-transport] peersSync 端口未注入 ⇒ 回落直读传输 (K10 ④ 未接线的调用点)');
3915
3074
  return p2pNetwork.getPeers();
3916
3075
  }
3917
3076
  async sendMessage(peerId, message) {
3918
- 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 ?? '未知原因'}`);
3919
3081
  }
3920
3082
  async broadcast(message) {
3921
- 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 ?? '未知原因'}`);
3922
3086
  }
3923
3087
  getIdentity() {
3924
3088
  return { ...this.identity };
@@ -4015,13 +3179,58 @@ ${this.extractOperationsFromRef(operationsRef)}
4015
3179
  this.socialHeartbeat = null;
4016
3180
  }
4017
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
+ */
4018
3197
  getSkillRegistry() {
4019
- 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
+ };
4020
3212
  }
4021
3213
  registerSkill(skill) {
4022
3214
  this.skillRegistry.register(skill);
4023
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
+ */
4024
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
+ }
4025
3234
  return this.skillRegistry.execute(name, params);
4026
3235
  }
4027
3236
  async addUserAction(content, importance) {