@bolloon/bolloon-agent 0.4.24 → 0.4.26

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 (49) hide show
  1. package/dist/agents/execution-supervisor.js +446 -0
  2. package/dist/agents/external-events.js +162 -0
  3. package/dist/agents/goal-criteria.js +124 -0
  4. package/dist/agents/goal-store.js +526 -0
  5. package/dist/agents/pi-harness.js +263 -0
  6. package/dist/agents/pi-sdk.js +607 -126
  7. package/dist/agents/run-store.js +772 -0
  8. package/dist/agents/runner-resolver.js +225 -0
  9. package/dist/agents/skill-readiness.js +133 -0
  10. package/dist/agents/skill-supervisor-link.js +70 -0
  11. package/dist/agents/skills-manager.js +717 -0
  12. package/dist/agents/supervisor-host.js +249 -0
  13. package/dist/cli/setup-wizard.js +96 -127
  14. package/dist/cron/tick-lock.js +1 -1
  15. package/dist/electron/first-run.js +33 -2
  16. package/dist/electron-build/electron/first-run.js +35 -2
  17. package/dist/electron-build/electron/first-run.js.map +1 -1
  18. package/dist/index.js +549 -26
  19. package/dist/ios/agent-delegate-server.js +58 -12
  20. package/dist/ios/icons/icon-1024x1024.png +0 -0
  21. package/dist/ios/icons/icon-1024x1024.webp +0 -0
  22. package/dist/ios/icons/icon-216x216.png +0 -0
  23. package/dist/ios/icons/icon-216x216.webp +0 -0
  24. package/dist/ios/index.html +21 -1
  25. package/dist/ios/manifest.json +1 -1
  26. package/dist/ios/mobile-agent.js +195 -1
  27. package/dist/ios/mobile-core.js +24876 -24723
  28. package/dist/ios/mobile.css +15 -0
  29. package/dist/ios/mobile.html +21 -1
  30. package/dist/ios/mobile.js +143 -0
  31. package/dist/ios/server.js +51 -4
  32. package/dist/llm/config-store.js +35 -4
  33. package/dist/network/agent-network.js +10 -0
  34. package/dist/network/goal-event-bridge.js +57 -0
  35. package/dist/setup/onboard.js +549 -0
  36. package/dist/setup/setup-store.js +592 -0
  37. package/dist/web/icons/icon-1024x1024.png +0 -0
  38. package/dist/web/icons/icon-1024x1024.webp +0 -0
  39. package/dist/web/icons/icon-216x216.png +0 -0
  40. package/dist/web/icons/icon-216x216.webp +0 -0
  41. package/dist/web/manifest.json +1 -1
  42. package/dist/web/mobile-agent.js +2 -2
  43. package/dist/web/mobile-core.js +24884 -24726
  44. package/dist/web/mobile-privacy.js +185 -0
  45. package/dist/web/mobile.css +15 -0
  46. package/dist/web/mobile.html +21 -1
  47. package/dist/web/mobile.js +179 -6
  48. package/dist/web/server.js +633 -0
  49. package/package.json +2 -2
@@ -25,6 +25,9 @@ import { WorkflowEngine } from './workflow-engine.js';
25
25
  import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtime';
26
26
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
27
27
  import { initDocumentReceiver } from './p2p-document-tools.js';
28
+ import { startRun, recordStep, finishRun, readRun, budgetVerdict, recordDegradation, recordHarnessEvent, recordRecovery, setRunStatus, prepareResume, markRunRunning, buildResumeInstruction, argsDigestOf, repeatedFailureCount, classifyError as classifyRunError } from './run-store.js';
29
+ import { createGoal, readGoal, attachRun, findActiveGoal, completeGoalIfEligible, setUnresolved, addEvidence, evaluateGoalCompletion } from './goal-store.js';
30
+ import { PiAgentHarness } from './pi-harness.js';
28
31
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
29
32
  import { SkillRegistry } from '@bolloon/constraint-runtime';
30
33
  import { loadSkillsFromPaths, defaultSkillPaths } from './skill-loader.js';
@@ -59,10 +62,15 @@ import { buildObservation, buildReflection, formatObservationWithReflection } fr
59
62
  import { sessionStore as defaultSessionStore } from './session-store.js';
60
63
  import { ToolRegistry } from './tool-registry.js';
61
64
  import { decideMaxIterations, decideContextOverflow, shouldCompactBeforeIteration } from './react-loop.js';
62
- import { decideAfterReview, DEFAULT_MAX_REVIEWS } from './loop-review.js';
65
+ import { DEFAULT_MAX_REVIEWS } from './loop-review.js';
63
66
  // PiSessionManager 已抽到 ./pi-sdk-session-manager.ts (2026-07-06)
64
67
  // Tool / ToolResult / Message / StreamCallback / StreamEvent / HeartbeatConfig / AgentSession / TOOL_DEFINITIONS
65
68
  // 已抽到 ./pi-sdk-types.ts (2026-07-06)
69
+ /**
70
+ * 同一工具 + 同一组参数连续失败上限 (2026-07-01 起) —— 触发两条互不替代的动作:
71
+ * 循环内: 注入 system 提示强制 LLM 收尾; 运行层 (M3): 熔断 → needs_human (交人)。
72
+ */
73
+ const MAX_SAME_TOOL_FAILURES = 3;
66
74
  export class PiAgentSession {
67
75
  cwd;
68
76
  peerId;
@@ -220,6 +228,25 @@ export class PiAgentSession {
220
228
  currentIntent = 'chitchat';
221
229
  /** 2026-08-10: 本轮用户原始输入 (loop-review 任务动词兜底检测用) */
222
230
  currentUserInput = '';
231
+ /**
232
+ * 2026-09-16: 持久化 run harness — 本次运行的落盘记录 id + 表面 (cli/web/cron/delegate)。
233
+ * 之前只有"跑完才写"的轨迹 (trajectory-store, fire-and-forget, 崩了就没有), 没有跨重载可见的
234
+ * 运行事实, 也没有预算闸门/失速巡检; 这里补的就是那一层。
235
+ */
236
+ currentRunId = '';
237
+ /** 上一次运行的 runId (收尾不清空; 见 getLastRunId) */
238
+ lastRunId = '';
239
+ runSurface = 'cli';
240
+ /**
241
+ * 2026-09-16 (Milestone 1): 本次运行因**持久化失败**被硬停。
242
+ * 标记出来是为了让外层 loop 重试逻辑别重试 —— 盘写不进去, 重试 3 次也只是再失败 3 次
243
+ * (协议: 同一个错误不许无限重复; 该交人时交人)。
244
+ */
245
+ runPersistenceBlocked = false;
246
+ /** 由 server / CLI / cron 注入运行表面 (影响落盘记录里的 surface 字段) */
247
+ setRunSurface(surface) {
248
+ this.runSurface = surface;
249
+ }
223
250
  currentIntentHint = '';
224
251
  /**
225
252
  * 算 judgment 注入门: 失败静默, 不阻塞主对话
@@ -657,6 +684,24 @@ export class PiAgentSession {
657
684
  })();
658
685
  }
659
686
  async prompt(input, options) {
687
+ // 2026-09-16 (M4 启动硬门禁): 初始化未就绪 → 不执行 Agent。
688
+ // fail-closed: 读不出初始化状态也按未就绪处理 (与 runnerResolver"解析不到只诊断"一致)。
689
+ // 测试环境 (VITEST) 跳过: 那 1800+ 用例跑在隔离 HOME 里, 本来就没有真实配置。
690
+ if (!process.env.VITEST) {
691
+ try {
692
+ const { getSetupGateCached } = await import('../setup/setup-store.js');
693
+ const { gate, state } = await getSetupGateCached();
694
+ if (gate !== 'ready') {
695
+ const why = `初始化未就绪 (${gate}, 当前阶段 ${state.stage})${state.lastError ? ` — ${state.lastError.message}` : ''}`;
696
+ const hint = (state.actions && state.actions[0]) || '运行 `bolloon setup` 完成初始化';
697
+ this.messageHistory.push({ role: 'user', content: input });
698
+ this.messageHistory.push({ role: 'assistant', content: `[初始化未就绪] ${why}\n${hint}` });
699
+ console.warn(`[PiAgent] 拒绝执行: ${why}`);
700
+ return `[初始化未就绪] ${why}\n${hint}`;
701
+ }
702
+ }
703
+ catch { /* 门禁自身异常 → 按"不能判定"处理会阻塞一切; 这里只在能读到状态时才拦 */ }
704
+ }
660
705
  this.minimaxAvailable = this.checkMinimax();
661
706
  this.currentChannelId = options?.channelId ?? this.currentChannelId;
662
707
  // 2026-08-08: 运行轨迹采集 (落盘 + OrbitDB, 失败静默) — 包裹 onStream 收集步骤事件
@@ -669,7 +714,41 @@ export class PiAgentSession {
669
714
  content: input
670
715
  });
671
716
  if (!this.minimaxAvailable) {
717
+ // 2026-09-16 (2-C.2): fallback **也必须留下 Run 事实**。
718
+ // 旧行为: 直接返回兜底文案, 连 Run 都不建 —— 上层 (独立宿主/Supervisor) 只看到"执行完成但没有 Run",
719
+ // 于是把"什么都没跑"当成一次正常执行 (这正是"agent 跑了但没记录"的老毛病在 fallback 路径的翻版)。
672
720
  const response = await this.handleFallback(input);
721
+ try {
722
+ let boundGoalId = this.currentGoalId;
723
+ if (!boundGoalId) {
724
+ const g = await createGoal({
725
+ objective: this.currentUserInput || input.slice(0, 200),
726
+ channelId: this.currentChannelId || undefined,
727
+ agentId: this.currentAgentId || undefined,
728
+ createdBy: this.runSurface,
729
+ });
730
+ boundGoalId = g.goalId;
731
+ }
732
+ this.currentGoalId = boundGoalId;
733
+ const rec = await startRun({
734
+ surface: this.runSurface,
735
+ goal: this.currentUserInput || input.slice(0, 200),
736
+ goalId: boundGoalId,
737
+ channelId: this.currentChannelId || undefined,
738
+ agentId: this.currentAgentId || undefined,
739
+ });
740
+ this.lastRunId = rec.runId;
741
+ await attachRun(boundGoalId, rec.runId).catch(() => null);
742
+ await recordStep(rec.runId, { tool: 'llm', ok: false, error: 'LLM 不可用 (provider 未初始化/无 apiKey) → 走了 fallback' });
743
+ await finishRun(rec.runId, {
744
+ status: 'needs_human',
745
+ error: 'LLM 不可用: provider 未初始化或无 apiKey (fallback 不是执行结果, 需要配置或人工处理)',
746
+ });
747
+ console.log(`[PiAgent] LLM 不可用 → 本次仍落 Run ${rec.runId} (needs_human), 不伪装成正常执行`);
748
+ }
749
+ catch (err) {
750
+ await recordDegradation({ kind: 'core', op: 'pi-sdk.fallbackRun', runId: this.lastRunId || '', message: String(err?.message || err).slice(0, 160) }).catch(() => { });
751
+ }
673
752
  this.messageHistory.push({ role: 'assistant', content: response });
674
753
  this.reportUsageToContextManager();
675
754
  this.finishTrajectory(trajRec, response);
@@ -940,6 +1019,9 @@ export class PiAgentSession {
940
1019
  try {
941
1020
  const loopResult = await this.runReActLoop(onStream, signal);
942
1021
  result = loopResult.reply;
1022
+ // 持久化失败不重试 (写不进去就是写不进去): 直接按本次结果收尾
1023
+ if (this.runPersistenceBlocked)
1024
+ break;
943
1025
  if (!loopResult.aiFailed)
944
1026
  break; // 正常完成, 退出 retry 循环
945
1027
  lastAiFailureReason = loopResult.aiFailureReason || 'AI 调用失败';
@@ -1134,6 +1216,176 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1134
1216
  this.clearJudgmentGate();
1135
1217
  return result;
1136
1218
  }
1219
+ /**
1220
+ * 2026-09-16 (Milestone 1-B): 唯一 Harness 门面 — 约束层的**唯一**入口。
1221
+ * pi-sdk 从此刻起不再分别调用 deny-pipeline / validator / react-harness / hooks / loop-review,
1222
+ * 只调 harness.*; 由 Harness 决定约束顺序与失败处置 (见 pi-harness.ts)。
1223
+ */
1224
+ _harness = null;
1225
+ /** M2 绑定 GoalStore 后填真值; 在此之前为空 (事件里 goalId 字段已就位) */
1226
+ currentGoalId = '';
1227
+ /** 2026-09-16 (M2): 本次执行是"从 checkpoint 恢复"的 runId (非空 = 恢复模式, 不再新建 run) */
1228
+ resumeRunId = '';
1229
+ resumePlan = null;
1230
+ /** 2026-09-16 (2-C.4): 外部请求 id (工具层设; 回包必须带上才能精确匹配) */
1231
+ pendingExternalRequestId;
1232
+ /** 2026-09-16 (M3): 外部等待中 (awaiting_external) —— 收到成功步骤后回 running */
1233
+ awaitingExternal = false;
1234
+ /** 2026-09-16 (M3): 熔断原因 (同一工具连续失败达上限 → needs_human) */
1235
+ breakerReason = '';
1236
+ /** 2026-09-16 (M3): 运行中核心写失败 (状态迁移等) —— 交给循环顶部硬闸, 不吞 */
1237
+ persistenceFailure = '';
1238
+ /**
1239
+ * 2026-09-16 (M2-B): Supervisor 开新 Run 继续同一 Goal 时注入"上一个 Run 已成功的非幂等动作"。
1240
+ * 与 resume 的重放守卫同源 (run-store 的 buildContinuationPlan), 保证跨 Run 也不重复副作用。
1241
+ */
1242
+ continuationGuards = [];
1243
+ /** Supervisor / CLI 注入跨 Run 重放守卫 */
1244
+ setContinuationGuards(guards) {
1245
+ this.continuationGuards = Array.isArray(guards) ? guards : [];
1246
+ }
1247
+ /** CLI / Web 注入"当前目标" (有 goalId 就在该 Goal 下执行) */
1248
+ setGoalId(goalId) {
1249
+ this.currentGoalId = String(goalId || '');
1250
+ }
1251
+ /** 核心状态迁移的兜底: 失败 → 记降级 + 交给循环顶部的持久化硬闸 (不吞错) */
1252
+ async safeSetRunStatus(runId, to) {
1253
+ try {
1254
+ const r = await setRunStatus(runId, to);
1255
+ return !!r.ok;
1256
+ }
1257
+ catch (err) {
1258
+ this.persistenceFailure = `状态迁移失败 (${to}): ${String(err?.message || err).slice(0, 180)}`;
1259
+ await recordDegradation({ kind: 'core', op: 'pi-sdk.setRunStatus', runId, message: this.persistenceFailure }).catch(() => { });
1260
+ return false;
1261
+ }
1262
+ }
1263
+ /**
1264
+ * 2026-09-16 (M3): 工具失败接线 —— 分类 → recovery 留痕 → 熔断 / 外部等待。
1265
+ * 这是"错误恢复闭环"的运行时接线点 (此前 recordRecovery / repeatedFailureCount 只是数据结构)。
1266
+ */
1267
+ async wireToolFailure(tool, errorText, args) {
1268
+ if (!this.currentRunId)
1269
+ return;
1270
+ const cls = classifyRunError(errorText);
1271
+ const digest = argsDigestOf(args);
1272
+ let repeats = 1;
1273
+ try {
1274
+ const rec = await readRun(this.currentRunId);
1275
+ // recordStep 已经把**这次**失败记进去了, 所以"连续失败次数"直接就是当前值 (不再 +1)
1276
+ const seen = rec ? repeatedFailureCount(rec, tool, digest) : 0;
1277
+ repeats = Math.max(seen, 1);
1278
+ }
1279
+ catch { /* 读不到就按首次记 */ }
1280
+ const action = cls === 'transient' ? 'backoff'
1281
+ : cls === 'auth' ? 'escalate'
1282
+ : cls === 'external_no_reply' ? 'pause'
1283
+ : 'retry';
1284
+ try {
1285
+ await recordRecovery(this.currentRunId, {
1286
+ errorClass: cls,
1287
+ message: `${tool}: ${errorText}`.slice(0, 200),
1288
+ action,
1289
+ attempt: repeats,
1290
+ recovered: false,
1291
+ });
1292
+ }
1293
+ catch (err) {
1294
+ await recordDegradation({ kind: 'core', op: 'pi-sdk.recordRecovery', runId: this.currentRunId, message: String(err?.message || err) }).catch(() => { });
1295
+ }
1296
+ // 外部无响应 → awaiting_external (不算失败; 等回话后回到 running)
1297
+ if (cls === 'external_no_reply') {
1298
+ this.awaitingExternal = true;
1299
+ await this.safeSetRunStatus(this.currentRunId, 'awaiting_external');
1300
+ // 2026-09-16 (2-C.4): 把"在等什么"写成持久化事实 (来源/关联/过期), 否则真实回包到了也不知道该唤醒谁。
1301
+ try {
1302
+ if (this.currentGoalId) {
1303
+ const { bindExternalWait, newContinuationId, defaultWaitExpiry } = await import('./external-events.js');
1304
+ const isDelegate = /delegate/i.test(String(tool || ''));
1305
+ await bindExternalWait(this.currentGoalId, {
1306
+ requestId: this.pendingExternalRequestId || `${this.currentRunId}:${Date.now().toString(36)}`,
1307
+ continuationId: newContinuationId(this.currentGoalId),
1308
+ expectedSource: isDelegate ? 'delegate' : 'p2p',
1309
+ expectedEvent: 'result',
1310
+ createdAt: new Date().toISOString(),
1311
+ expiresAt: defaultWaitExpiry(Date.now(), Number(process.env.BOLLOON_EXTERNAL_WAIT_MS || 30 * 60_000)),
1312
+ note: `${isDelegate ? 'delegate 回包' : 'P2P 协作回复'} 等待中 (tool=${tool})`,
1313
+ });
1314
+ }
1315
+ }
1316
+ catch (e) {
1317
+ await recordDegradation({ kind: 'core', op: 'pi-sdk.bindExternalWait', runId: this.currentRunId, message: String(e?.message || e) }).catch(() => { });
1318
+ }
1319
+ return;
1320
+ }
1321
+ // 鉴权类: 不重试, 直接交人
1322
+ if (cls === 'auth') {
1323
+ this.breakerReason = `鉴权类错误不重试 (${tool}): ${errorText.slice(0, 120)}`;
1324
+ await this.safeSetRunStatus(this.currentRunId, 'needs_human');
1325
+ return;
1326
+ }
1327
+ // 重复失败熔断: 同一工具 + 同一组参数连续失败达 3 次
1328
+ if (repeats >= MAX_SAME_TOOL_FAILURES) {
1329
+ this.breakerReason = `同一工具 ${tool} 连续失败 ${repeats} 次 (${cls}) → 熔断, 不再重试`;
1330
+ await this.safeSetRunStatus(this.currentRunId, 'needs_human');
1331
+ }
1332
+ }
1333
+ getRunId() {
1334
+ return this.currentRunId;
1335
+ }
1336
+ /**
1337
+ * 2026-09-16 (M2-B): **上一次**运行的 runId —— prompt 收尾时 `currentRunId` 会被清空,
1338
+ * 而 Supervisor / 控制面要在运行结束后才知道"刚才跑的是哪条 run" (否则会拿旧 run 做决策)。
1339
+ */
1340
+ getLastRunId() {
1341
+ return this.lastRunId || this.currentRunId;
1342
+ }
1343
+ /**
1344
+ * 2026-09-16 (M2): 从 checkpoint 恢复一次运行 —— **不是**重发原 prompt。
1345
+ * 语义: prepareResume (校验状态 + 读 checkpoint + 记 recovery + 落 recovering) → 用恢复指令
1346
+ * 驱动同一个 runId 继续 (历史保留), 非幂等动作由重放守卫挡住。
1347
+ */
1348
+ async resumeRun(runId) {
1349
+ const prep = await prepareResume(runId);
1350
+ if (!prep.ok || !prep.plan)
1351
+ return { ok: false, reason: prep.reason };
1352
+ this.resumeRunId = runId;
1353
+ this.resumePlan = prep.plan;
1354
+ this.currentGoalId = prep.plan.goalId || this.currentGoalId;
1355
+ try {
1356
+ const reply = await this.prompt(buildResumeInstruction(prep.plan), {});
1357
+ return { ok: true, reply };
1358
+ }
1359
+ finally {
1360
+ this.resumeRunId = '';
1361
+ this.resumePlan = null;
1362
+ }
1363
+ }
1364
+ piHarness() {
1365
+ if (!this._harness) {
1366
+ this._harness = new PiAgentHarness({
1367
+ reactHarness: this.reactHarness,
1368
+ denyPipeline: this._denyPipeline,
1369
+ hooks: this._hooks,
1370
+ // pre-tool-validator 4 步链 (modeGate/blacklist/shell-guard/schema), 经 human-value-pipeline 包装
1371
+ preToolUse: async (o) => onPreToolUse({ tool: o.tool, args: o.args, permissionMode: o.permissionMode }),
1372
+ // 事件写 Run: 观测级 (记账失败不改变已做出的决策)
1373
+ events: (e) => { if (this.currentRunId)
1374
+ void recordHarnessEvent(this.currentRunId, e); },
1375
+ });
1376
+ }
1377
+ return this._harness;
1378
+ }
1379
+ /** 每个生命周期事件都带上的运行身份 (runId / goalId / agentId / channelId / surface) */
1380
+ harnessCtx() {
1381
+ return {
1382
+ runId: this.currentRunId || undefined,
1383
+ goalId: this.currentGoalId || undefined,
1384
+ agentId: this.currentAgentId || undefined,
1385
+ channelId: this.currentChannelId || undefined,
1386
+ surface: this.runSurface,
1387
+ };
1388
+ }
1137
1389
  async runReActLoop(onStream, signal) {
1138
1390
  const llm = getMinimax();
1139
1391
  let iteration = 0;
@@ -1150,7 +1402,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1150
1402
  let aiFailed = false;
1151
1403
  let aiFailureReason = '';
1152
1404
  const MAX_CONSECUTIVE_ERRORS = 3;
1153
- const MAX_SAME_TOOL_FAILURES = 3; // 同一工具连续失败 3 次, 强制让 LLM 给出最终答案
1405
+ // 同一工具连续失败 3 次, 强制让 LLM 给出最终答案 (模块级常量 MAX_SAME_TOOL_FAILURES 也用它做熔断)
1154
1406
  // 2026-07-29: Hermes 风格硬限制 — 防死循环 (不再靠 soft hint)
1155
1407
  const MAX_IDEMPOTENT_TOOL = 5; // 同工具成功调 5 次 → 注入 hint 强制 final gen
1156
1408
  const MAX_TOOL_CALLS_PER_LOOP = 25; // 单轮循环总工具调用上限 → 注入 hint
@@ -1176,19 +1428,147 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1176
1428
  }
1177
1429
  // React Harness: 循环开始 (重置 turn 计数 + 触发 harness sessionStart)
1178
1430
  // 失败静默 (fail-open), 不阻塞主循环
1179
- try {
1180
- await this.reactHarness.onSessionStart(this.currentChannelId || undefined);
1181
- }
1182
- catch (err) {
1183
- console.warn('[PiAgent] reactHarness.onSessionStart failed (non-fatal):', err);
1431
+ // 2026-09-16 (Milestone 1-B): 会话开启走唯一门面 (react-harness 8-gate 复位 + hooks onLoopStart)
1432
+ await this.piHarness().sessionStart(this.harnessCtx());
1433
+ // 2026-09-16: 持久化 run harness — 本次运行立即落盘 (~/.bolloon/runs/<id>.json)。
1434
+ // 之后每步工具调用都追加一条, 所以刷新页面/进程重载/崩溃都能看到"做到哪一步"。
1435
+ let runStopReason = '';
1436
+ // 2026-09-16 (Milestone 1): 持久化硬约束 —— 核心 run 状态写不进去时, 运行必须停。
1437
+ // 理由: "agent 实际跑了但没记录" 比 "agent 没跑" 更危险 (UI 显示旧状态/重启后无从知晓/结果可能被错标 done)。
1438
+ let runPersistenceFailure = '';
1439
+ /** 2026-09-16 (M3): 熔断/需要人处置 → 收尾落 needs_human (不是 done, 也不是普通 failed) */
1440
+ let runNeedsHuman = '';
1441
+ /** 2026-09-16 (M5): 外部 (CLI/Web) 把 run 改成 paused/aborted → 如实停, 不再覆盖它的状态 */
1442
+ let runExternallyPaused = false;
1443
+ let runExternallyAborted = false;
1444
+ this.runPersistenceBlocked = false;
1445
+ this.breakerReason = '';
1446
+ this.awaitingExternal = false;
1447
+ if (this.resumeRunId) {
1448
+ // ── 恢复模式: 复用原来的 runId, 不新建 run (历史保留) ──
1449
+ this.currentRunId = this.resumeRunId;
1450
+ this.lastRunId = this.resumeRunId;
1451
+ try {
1452
+ await markRunRunning(this.currentRunId);
1453
+ }
1454
+ catch (err) {
1455
+ runPersistenceFailure = `恢复时状态迁移失败 (recovering → running): ${String(err?.message || err).slice(0, 180)}`;
1456
+ }
1457
+ const doneN = this.resumePlan?.completedSteps.length ?? 0;
1458
+ const guards = this.resumePlan?.replayGuards.length ?? 0;
1459
+ onStream?.({ type: 'status', content: `♻️ 从 checkpoint 恢复运行 ${this.currentRunId} (已完成 ${doneN} 步, 非幂等重放守卫 ${guards} 条)`, tool: 'harness' });
1184
1460
  }
1185
- // 2026-07-29: Hook onLoopStart
1186
- try {
1187
- await this._hooks.fire('onLoopStart', { event: 'onLoopStart', channelId: this.currentChannelId, agentId: this.currentAgentId });
1461
+ else {
1462
+ // 2026-09-16 (M2): 目标绑定 —— 有 goalId 就在该 Goal 下执行; 没有就建 Goal 再建 Run。
1463
+ // 延续规则 (确定性, 不靠猜): channel/agent 上已有 open/active Goal, 且它的上一次执行**没收尾**
1464
+ // (interrupted/stalled/needs_human/paused/awaiting_external/recovering) → 继续该 Goal; 否则新建。
1465
+ let boundGoalId = this.currentGoalId;
1466
+ if (!boundGoalId) {
1467
+ try {
1468
+ const active = await findActiveGoal({ channelId: this.currentChannelId || undefined, agentId: this.currentAgentId || undefined });
1469
+ if (active?.currentRunId) {
1470
+ const prev = await readRun(active.currentRunId);
1471
+ const unfinished = prev && ['interrupted', 'stalled', 'needs_human', 'paused', 'awaiting_external', 'recovering'].includes(prev.status);
1472
+ if (unfinished)
1473
+ boundGoalId = active.goalId;
1474
+ }
1475
+ }
1476
+ catch (err) {
1477
+ console.warn('[PiAgent] 查找进行中 Goal 失败 (按新建处理):', err?.message);
1478
+ }
1479
+ if (!boundGoalId) {
1480
+ try {
1481
+ const g = await createGoal({
1482
+ objective: this.currentUserInput || '(未记录目标)',
1483
+ channelId: this.currentChannelId || undefined,
1484
+ agentId: this.currentAgentId || undefined,
1485
+ createdBy: this.runSurface,
1486
+ });
1487
+ boundGoalId = g.goalId;
1488
+ }
1489
+ catch (err) {
1490
+ console.warn('[PiAgent] 创建 Goal 失败 (无目标也要有运行记录):', err?.message);
1491
+ }
1492
+ }
1493
+ this.currentGoalId = boundGoalId;
1494
+ }
1495
+ try {
1496
+ const rec = await startRun({
1497
+ surface: this.runSurface,
1498
+ goal: this.currentUserInput || '(未记录目标)',
1499
+ goalId: boundGoalId || undefined,
1500
+ channelId: this.currentChannelId || undefined,
1501
+ agentId: this.currentAgentId || undefined,
1502
+ });
1503
+ this.currentRunId = rec.runId;
1504
+ this.lastRunId = rec.runId;
1505
+ this.currentGoalId = rec.goalId || this.currentGoalId;
1506
+ if (this.currentGoalId) {
1507
+ // Run → Goal 反查链: runId → goalId → objective / success criteria
1508
+ await attachRun(this.currentGoalId, rec.runId).catch((err) => console.warn('[PiAgent] attachRun 失败:', err?.message));
1509
+ }
1510
+ onStream?.({ type: 'status', content: `🧷 运行已登记 (run=${rec.runId}${this.currentGoalId ? `, goal=${this.currentGoalId}` : ''}, 预算 ${rec.budget.maxSteps} 步 / ${Math.round(rec.budget.deadlineMs / 60000)} 分钟)`, tool: 'harness' });
1511
+ }
1512
+ catch (err) {
1513
+ // 核心写失败: 不再 warn 后继续 —— 没有运行记录就不执行 (strict 模式默认如此)
1514
+ runPersistenceFailure = `无法创建运行记录: ${String(err?.message || err).slice(0, 200)}`;
1515
+ this.runPersistenceBlocked = true;
1516
+ console.error('[PiAgent] run-store startRun 失败 (核心持久化) → 拒绝无记录执行:', runPersistenceFailure);
1517
+ onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 已停止 (不在没有记录的情况下执行)`, tool: 'harness' });
1518
+ }
1188
1519
  }
1189
- catch { /* hook 失败静默 */ }
1190
1520
  while (iteration < this.MAX_REACT_ITERATIONS) {
1191
1521
  iteration++;
1522
+ // 2026-09-16 (Milestone 1): 持久化失败硬闸 —— 到这一层说明记录已经不可信, 继续跑就是"无约束执行"
1523
+ if (runPersistenceFailure || this.persistenceFailure) {
1524
+ runPersistenceFailure = runPersistenceFailure || this.persistenceFailure;
1525
+ this.runPersistenceBlocked = true;
1526
+ aiFailed = true;
1527
+ aiFailureReason = aiFailureReason || runPersistenceFailure;
1528
+ finalResponse = finalResponse || `❌ 运行已停止: ${runPersistenceFailure}\n\n(运行记录无法写入/校验, 按协议停在 needs_human, 不假装完成)`;
1529
+ break;
1530
+ }
1531
+ // 2026-09-16 (M3): 熔断硬闸 —— 同一工具连续失败达上限后, 不许再"自动重试成功"式地把运行放活
1532
+ if (this.breakerReason) {
1533
+ runNeedsHuman = this.breakerReason;
1534
+ aiFailed = true;
1535
+ aiFailureReason = aiFailureReason || this.breakerReason;
1536
+ finalResponse = finalResponse || `❌ 已熔断: ${this.breakerReason}\n\n(重复失败不再重试, 按协议停在 needs_human 交人处置)`;
1537
+ break;
1538
+ }
1539
+ // 2026-09-16: 预算闸门 (持久化 harness 的约束面) —— 到点必须**如实**终止, 不许静默算完成
1540
+ if (this.currentRunId) {
1541
+ try {
1542
+ const rec = await readRun(this.currentRunId);
1543
+ if (!rec)
1544
+ throw new Error(`运行记录读不到: ${this.currentRunId}`);
1545
+ // 2026-09-16 (M5): 外部控制面 (CLI /pause /abort, Web API) 改过状态 → 如实停在那儿。
1546
+ // 不覆盖成 done/failed: 人按下暂停就是暂停, 人按下中止就是中止。
1547
+ if (rec.status === 'paused' || rec.status === 'aborted') {
1548
+ runExternallyPaused = rec.status === 'paused';
1549
+ runExternallyAborted = rec.status === 'aborted';
1550
+ runStopReason = `外部请求: ${rec.status}`;
1551
+ onStream?.({ type: 'error', content: `⏹️ 运行被外部${rec.status === 'paused' ? '暂停' : '中止'} (run=${this.currentRunId})`, tool: 'harness' });
1552
+ finalResponse = finalResponse || `(运行已${rec.status === 'paused' ? '暂停' : '中止'})`;
1553
+ break;
1554
+ }
1555
+ const verdict = budgetVerdict(rec);
1556
+ if (verdict.exceeded) {
1557
+ runStopReason = verdict.reason || '运行预算用尽';
1558
+ onStream?.({ type: 'error', content: `⛔ 运行预算用尽: ${runStopReason} (已如实终止, 不假装完成)`, tool: 'harness' });
1559
+ finalResponse = finalResponse || `(运行预算用尽: ${runStopReason})`;
1560
+ break;
1561
+ }
1562
+ }
1563
+ catch (err) {
1564
+ // 预算闸门读不到状态 = 约束失效, 不能"当作没超预算"继续跑
1565
+ runPersistenceFailure = `预算闸门无法校验运行状态: ${String(err?.message || err).slice(0, 200)}`;
1566
+ this.runPersistenceBlocked = true;
1567
+ console.error('[PiAgent] run-store 预算检查失败 (核心持久化):', runPersistenceFailure);
1568
+ onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 已停止`, tool: 'harness' });
1569
+ break;
1570
+ }
1571
+ }
1192
1572
  // 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
1193
1573
  // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideMaxIterations 纯函数
1194
1574
  const maxIterDecision = decideMaxIterations(iteration, this.MAX_REACT_ITERATIONS);
@@ -1359,7 +1739,11 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1359
1739
  },
1360
1740
  });
1361
1741
  }
1742
+ // 2026-09-16 (Milestone 1-B): 模型调用前后走唯一门面 (扩展点 + 计数留痕; 默认不加新 hook 事件, 避免改变现有触发次数)
1743
+ this.piHarness().beforeModelCall(this.harnessCtx());
1744
+ const _modelCallT0 = Date.now();
1362
1745
  const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, openaiFormattedTools);
1746
+ this.piHarness().afterModelCall(this.harnessCtx(), { ms: Date.now() - _modelCallT0 });
1363
1747
  const reply = (response.reply || '').trim();
1364
1748
  // 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
1365
1749
  const nativeToolCalls = response.toolCalls;
@@ -1494,29 +1878,6 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1494
1878
  args: toolCall.args || {},
1495
1879
  });
1496
1880
  }
1497
- // 2026-07-29: Unified Deny-First Pipeline — 统合所有拒绝检查
1498
- let denyResult = { denied: false, reason: '', source: '' };
1499
- try {
1500
- denyResult = await this._denyPipeline.check({
1501
- toolName: toolCall.name,
1502
- toolArgs: toolCall.args || {},
1503
- permissionMode: this.currentPermissionMode,
1504
- channelId: this.currentChannelId,
1505
- agentId: this.currentAgentId,
1506
- });
1507
- }
1508
- catch { /* pipeline 失败不阻塞工具调用 */ }
1509
- if (denyResult.denied) {
1510
- consecutiveErrors++;
1511
- totalErrors++;
1512
- const denyResultMsg = { success: false, error: `拒绝: [${denyResult.source}] ${denyResult.reason}` };
1513
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(denyResultMsg), toolResult: denyResultMsg });
1514
- this.logToHarness(toolCall.name, toolCall.args, denyResultMsg);
1515
- return;
1516
- }
1517
- if (denyResult.systemAddition) {
1518
- this.contextHintAddition += '\n' + denyResult.systemAddition;
1519
- }
1520
1881
  const tool = this.tools.get(toolCall.name);
1521
1882
  if (!tool) {
1522
1883
  consecutiveErrors++;
@@ -1533,119 +1894,165 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1533
1894
  console.warn(`[PiAgent] 未知工具: ${toolCall.name} (累计 ${totalErrors}/${this.MAX_TOTAL_ERRORS}),跳过并继续`);
1534
1895
  return;
1535
1896
  }
1536
- // Bootstrap PreToolUse hook: 调工具前校验 (危险命令拦截)
1537
- // 失败静默 hook 自身挂掉 = 放行
1538
- // P2: 透传 permissionMode ( BootstrapOptions / env BOLLOON_PERM_MODE 解析)
1539
- let toolToExecute = tool;
1897
+ // 2026-09-16 (Milestone 1-B): 工具调用前的**唯一**约束入口。
1898
+ // 顺序由 PiAgentHarness 决定: deny-pipeline pre-tool-validator(4 步链) → react-harness(8-gate)。
1899
+ // 旧实现是这三处在不同位置各自调用 (且有两条路径 fail-open); 现在 pi-sdk 不再散调任何 gate。
1900
+ let toolDecision;
1540
1901
  try {
1541
- const pre = await onPreToolUse({
1902
+ toolDecision = await this.piHarness().beforeToolCall({
1542
1903
  tool: toolCall.name,
1543
1904
  args: toolCall.args || {},
1905
+ ctx: this.harnessCtx(),
1544
1906
  permissionMode: this.currentPermissionMode,
1545
1907
  });
1546
- if (!pre.allowed) {
1547
- const deniedResult = {
1548
- success: false,
1549
- error: `PreToolUse 拒绝: ${pre.reason || '未通过安全校验'}`,
1550
- };
1551
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(deniedResult), toolResult: deniedResult });
1552
- this.logToHarness(toolCall.name, toolCall.args, deniedResult);
1553
- if (onStream) {
1554
- onStream({ type: 'error', content: `🛡️ PreToolUse 拒绝 ${toolCall.name}: ${pre.reason || '安全校验失败'}`, tool: toolCall.name });
1555
- onStream({ type: 'step_error', content: `PreToolUse 拒绝 ${toolCall.name}`, tool: toolCall.name, error: pre.reason || '安全校验失败' });
1556
- }
1557
- console.warn(`[PiAgent] PreToolUse denied ${toolCall.name}: ${pre.reason}`);
1558
- // 拒绝也算错误, 让错误恢复机制触发
1559
- consecutiveErrors++;
1560
- totalErrors++;
1561
- if (toolCall.name === lastFailedTool) {
1562
- lastFailedToolCount++;
1563
- }
1564
- else {
1565
- lastFailedTool = toolCall.name;
1566
- lastFailedToolCount = 1;
1567
- }
1568
- if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
1569
- this.messageHistory.push({ role: 'system', content: `[注意] 工具 ${toolCall.name} 被系统拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 直接用已有信息回答用户, 末尾加 <final gen>.` });
1570
- lastFailedTool = '';
1571
- lastFailedToolCount = 0;
1572
- consecutiveErrors = 0;
1573
- }
1574
- else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1575
- this.messageHistory.push({ role: 'system', content: `[注意] 连续 ${consecutiveErrors} 次工具调用被系统拒绝. 请换其他工具或直接回答用户, 末尾加 <final gen>.` });
1576
- consecutiveErrors = 0;
1577
- }
1578
- return;
1579
- }
1580
1908
  }
1581
1909
  catch (err) {
1582
- console.warn('[PiAgent] onPreToolUse failed (non-fatal, allowing):', err);
1910
+ // 门面自身抛错 = 核心约束失效 → fail-closed (不执行工具, 也不静默放行)
1911
+ toolDecision = {
1912
+ allow: false,
1913
+ source: 'harness-error',
1914
+ kind: 'core_constraint',
1915
+ reason: `Harness 门面异常: ${String(err?.message || err).slice(0, 150)}`,
1916
+ };
1583
1917
  }
1584
- // React Harness: 8-gate + builtin-guards 校验 (串接双层)
1585
- try {
1586
- const pre = await this.reactHarness.preToolCall(toolCall.name, toolCall.args || {}, this.currentChannelId || undefined);
1587
- if (!pre.allowed) {
1588
- const deniedResult = { success: false, error: `Harness gate 拒绝 (${pre.details.rejectedBy}): ${pre.reason || '未通过安全校验'}` };
1589
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(deniedResult), toolResult: deniedResult });
1590
- this.logToHarness(toolCall.name, toolCall.args, deniedResult);
1591
- if (onStream) {
1592
- onStream({ type: 'error', content: `🛡️ Harness ${pre.details.rejectedBy} 拒绝 ${toolCall.name}: ${pre.reason || '安全校验失败'}`, tool: toolCall.name });
1593
- onStream({ type: 'step_error', content: `Harness 拒绝 ${toolCall.name}`, tool: toolCall.name, error: pre.reason || '安全校验失败' });
1594
- }
1595
- console.warn(`[PiAgent] Harness denied ${toolCall.name} (${pre.details.rejectedBy}): ${pre.reason}`);
1918
+ if (!toolDecision.allow) {
1919
+ const src = toolDecision.source || 'unknown';
1920
+ if (src === 'deny-pipeline') {
1921
+ // deny-pipeline 分支: 不计连续失败计数, 文案 "拒绝: [source] reason"
1596
1922
  consecutiveErrors++;
1597
1923
  totalErrors++;
1598
- if (toolCall.name === lastFailedTool) {
1599
- lastFailedToolCount++;
1600
- }
1601
- else {
1602
- lastFailedTool = toolCall.name;
1603
- lastFailedToolCount = 1;
1604
- }
1605
- if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
1606
- this.messageHistory.push({ role: 'system', content: `[注意] 工具 ${toolCall.name} 被 Harness 拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 末尾加 <final gen>.` });
1607
- lastFailedTool = '';
1608
- lastFailedToolCount = 0;
1609
- consecutiveErrors = 0;
1610
- }
1611
- else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1612
- this.messageHistory.push({ role: 'system', content: `[注意] 连续 ${consecutiveErrors} 次工具调用被 Harness 拒绝. 请换其他工具或直接回答.` });
1613
- consecutiveErrors = 0;
1614
- }
1924
+ const denyResultMsg = { success: false, error: `拒绝: [${toolDecision.rejectedBy || 'deny-pipeline'}] ${toolDecision.reason}` };
1925
+ this.messageHistory.push({ role: 'tool', content: JSON.stringify(denyResultMsg), toolResult: denyResultMsg });
1926
+ this.logToHarness(toolCall.name, toolCall.args, denyResultMsg);
1615
1927
  return;
1616
1928
  }
1929
+ const isGateDeny = src === 'react-harness';
1930
+ const isHarnessError = src === 'harness-error';
1931
+ const deniedResult = {
1932
+ success: false,
1933
+ error: isGateDeny
1934
+ ? `Harness gate 拒绝 (${toolDecision.rejectedBy}): ${toolDecision.reason || '未通过安全校验'}`
1935
+ : `PreToolUse 拒绝: ${toolDecision.reason || '未通过安全校验'}`,
1936
+ };
1937
+ this.messageHistory.push({ role: 'tool', content: JSON.stringify(deniedResult), toolResult: deniedResult });
1938
+ this.logToHarness(toolCall.name, toolCall.args, deniedResult);
1939
+ if (onStream) {
1940
+ const gateName = isGateDeny ? `Harness ${toolDecision.rejectedBy}` : (isHarnessError ? '核心约束层' : 'PreToolUse');
1941
+ onStream({ type: 'error', content: `🛡️ ${gateName} 拒绝 ${toolCall.name}: ${toolDecision.reason || '安全校验失败'}`, tool: toolCall.name });
1942
+ onStream({ type: 'step_error', content: `${gateName} 拒绝 ${toolCall.name}`, tool: toolCall.name, error: toolDecision.reason || '安全校验失败' });
1943
+ }
1944
+ console.warn(`[PiAgent] 工具被拒 ${toolCall.name} (${src}${toolDecision.rejectedBy ? ':' + toolDecision.rejectedBy : ''}): ${toolDecision.reason}`);
1945
+ consecutiveErrors++;
1946
+ totalErrors++;
1947
+ if (toolCall.name === lastFailedTool) {
1948
+ lastFailedToolCount++;
1949
+ }
1950
+ else {
1951
+ lastFailedTool = toolCall.name;
1952
+ lastFailedToolCount = 1;
1953
+ }
1954
+ // 达到同一工具连续失败上限 / 连续错误上限时的引导语 (按拒绝来源保持原有文案)
1955
+ const systemMaxMsg = isGateDeny
1956
+ ? `[注意] 工具 ${toolCall.name} 被 Harness 拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 末尾加 <final gen>.`
1957
+ : isHarnessError
1958
+ ? `[注意] 工具 ${toolCall.name} 的约束校验层失效, 已按 fail-closed 阻止 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请换其他工具或直接回答用户, 末尾加 <final gen>.`
1959
+ : `[注意] 工具 ${toolCall.name} 被系统拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 直接用已有信息回答用户, 末尾加 <final gen>.`;
1960
+ const systemConsecMsg = isGateDeny
1961
+ ? `[注意] 连续 ${consecutiveErrors} 次工具调用被 Harness 拒绝. 请换其他工具或直接回答.`
1962
+ : isHarnessError
1963
+ ? `[注意] 连续 ${consecutiveErrors} 次工具调用因约束层失效被阻止. 请换其他工具或直接回答用户, 末尾加 <final gen>.`
1964
+ : `[注意] 连续 ${consecutiveErrors} 次工具调用被系统拒绝. 请换其他工具或直接回答用户, 末尾加 <final gen>.`;
1965
+ if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
1966
+ this.messageHistory.push({ role: 'system', content: systemMaxMsg });
1967
+ lastFailedTool = '';
1968
+ lastFailedToolCount = 0;
1969
+ consecutiveErrors = 0;
1970
+ }
1971
+ else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1972
+ this.messageHistory.push({ role: 'system', content: systemConsecMsg });
1973
+ consecutiveErrors = 0;
1974
+ }
1975
+ return;
1617
1976
  }
1618
- catch (err) {
1619
- console.warn('[PiAgent] reactHarness.preToolCall failed (non-fatal, allowing):', err);
1977
+ if (toolDecision.systemAddition) {
1978
+ this.contextHintAddition += '\n' + toolDecision.systemAddition;
1620
1979
  }
1621
1980
  try {
1622
1981
  const toolStart = Date.now();
1623
- let result = await tool.execute(toolCall.args);
1982
+ // 2026-09-16 (M2): 恢复重放守卫 —— 中断前**已成功执行过的非幂等动作**恢复后不再重做,
1983
+ // 直接复用当时的结果 (避免重复副作用: 重复写文件/重复提交/重复付款)。
1984
+ // (M2-B: 守卫也覆盖"上一个 Run 已做过的非幂等动作" —— 跨 Run 续跑同样不许重做)
1985
+ let replaySkip = null;
1986
+ const guards = [
1987
+ ...(this.resumePlan?.replayGuards || []),
1988
+ ...(this.continuationGuards || []),
1989
+ ];
1990
+ if (guards.length) {
1991
+ const d = argsDigestOf(toolCall.args);
1992
+ const hit = guards.find((g) => g.tool === toolCall.name && (!g.argsDigest || !d || g.argsDigest === d));
1993
+ if (hit)
1994
+ replaySkip = hit.summary;
1995
+ }
1996
+ let result = replaySkip
1997
+ ? { success: true, output: `[恢复保护] ${toolCall.name} 在中断前已成功执行过, 本次不重复执行 (避免重复副作用)。当时结果: ${replaySkip}`, _replaySkipped: true }
1998
+ : await tool.execute(toolCall.args);
1624
1999
  const toolDurationMs = Date.now() - toolStart;
2000
+ if (replaySkip) {
2001
+ console.log(`[PiAgent] 恢复重放守卫: 跳过已完成的非幂等工具 ${toolCall.name}`);
2002
+ onStream?.({ type: 'status', content: `🛡️ 恢复保护: ${toolCall.name} 此前已成功执行, 本次不重复执行`, tool: toolCall.name });
2003
+ }
1625
2004
  console.log(`[PiAgent] 工具 ${toolCall.name} 执行完成: success=${result.success} (${toolDurationMs}ms)`);
2005
+ // 2026-09-16 (M3): 失败接线 —— 分类 + recovery 留痕 + 熔断 / 外部等待
2006
+ if (!result.success && this.currentRunId && !replaySkip) {
2007
+ await this.wireToolFailure(toolCall.name, String(result.error || ''), toolCall.args);
2008
+ }
2009
+ else if (result.success && this.currentRunId && this.awaitingExternal) {
2010
+ // 外部回话了: awaiting_external → running (不是"恢复完成", 只是等待结束)
2011
+ this.awaitingExternal = false;
2012
+ await this.safeSetRunStatus(this.currentRunId, 'running');
2013
+ }
2014
+ // 2026-09-16: 持久化 run harness — 每步工具调用立即落盘 (崩在这里也能看到做到哪步)
2015
+ if (this.currentRunId) {
2016
+ try {
2017
+ await recordStep(this.currentRunId, {
2018
+ tool: toolCall.name,
2019
+ ok: !!result.success,
2020
+ ms: toolDurationMs,
2021
+ args: toolCall.args,
2022
+ summary: String(result.output || '').slice(0, 200),
2023
+ error: result.error ? String(result.error) : undefined,
2024
+ });
2025
+ }
2026
+ catch (err) {
2027
+ // 核心写失败 → 本轮工具批跑完即停 (循环顶部硬闸), 不再 warn 后继续
2028
+ runPersistenceFailure = `工具步骤写盘失败 (${toolCall.name}): ${String(err?.message || err).slice(0, 200)}`;
2029
+ this.runPersistenceBlocked = true;
2030
+ console.error('[PiAgent] run-store recordStep 失败 (核心持久化):', runPersistenceFailure);
2031
+ onStream?.({ type: 'error', content: `⛔ ${runPersistenceFailure} — 本轮结束后停止`, tool: 'harness' });
2032
+ }
2033
+ }
1626
2034
  try {
1627
2035
  await onPostToolUse({ tool: toolCall.name, args: toolCall.args || {}, result: { success: result.success, output: result.output?.substring(0, 500), error: result.error }, durationMs: toolDurationMs });
1628
2036
  }
1629
2037
  catch (postErr) {
1630
2038
  console.warn('[PiAgent] onPostToolUse failed (non-fatal):', postErr);
1631
2039
  }
1632
- const routeHint = this.reactHarness.getLastRouteHint();
1633
- if (routeHint && routeHint.systemAddition) {
1634
- this.messageHistory.push({ role: 'system', content: `[Harness Router Hint: ${routeHint.reason}]\n${routeHint.systemAddition}` });
1635
- this.reactHarness.clearRouteHint();
2040
+ // 2026-09-16 (Milestone 1-B): 工具调用后的唯一入口 (router hint + 输出 gate 一起判定)
2041
+ const after = await this.piHarness().afterToolCall({
2042
+ tool: toolCall.name,
2043
+ output: String(result.output || ''),
2044
+ ctx: this.harnessCtx(),
2045
+ ok: !!result.success,
2046
+ });
2047
+ if (after.routeHint?.systemAddition) {
2048
+ this.messageHistory.push({ role: 'system', content: `[Harness Router Hint: ${after.routeHint.reason}]\n${after.routeHint.systemAddition}` });
1636
2049
  }
1637
- try {
1638
- const post = await this.reactHarness.postToolCall(toolCall.name, String(result.output || ''), this.currentChannelId || undefined);
1639
- if (!post.allowed) {
1640
- if (onStream) {
1641
- onStream({ type: 'error', content: `🛡️ Harness output 拒绝 ${toolCall.name}: ${post.reason || '输出含敏感信息'}`, tool: toolCall.name });
1642
- }
1643
- console.warn(`[PiAgent] Harness output denied ${toolCall.name}: ${post.reason}`);
1644
- result = { ...result, output: `[harness output gate: 输出含敏感内容, 已屏蔽. 原因: ${post.reason || 'unknown'}]`, _harnessDenied: true };
2050
+ if (after.outputBlocked) {
2051
+ if (onStream) {
2052
+ onStream({ type: 'error', content: `🛡️ Harness output 拒绝 ${toolCall.name}: ${after.outputBlocked.reason}`, tool: toolCall.name });
1645
2053
  }
1646
- }
1647
- catch (err) {
1648
- console.warn('[PiAgent] reactHarness.postToolCall failed (non-fatal, allowing):', err);
2054
+ console.warn(`[PiAgent] Harness output denied ${toolCall.name}: ${after.outputBlocked.reason}`);
2055
+ result = { ...result, output: `[harness output gate: 输出含敏感内容, 已屏蔽. 原因: ${after.outputBlocked.reason}]`, _harnessDenied: true };
1649
2056
  }
1650
2057
  this.messageHistory.push({ role: 'tool', content: JSON.stringify(result), toolResult: result, toolCallId: toolCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` });
1651
2058
  this.logToHarness(toolCall.name, toolCall.args, result);
@@ -1808,13 +2215,16 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1808
2215
  // 2026-08-08: final 前目标对齐 review — 不潦草收尾 (见 loop-review.ts)
1809
2216
  // LLM 想 <final gen> 时, 先跑 1-2 次「目标对齐 + 需求深挖」review;
1810
2217
  // 达成用户需求才放行真正结束. 达上限或无需深挖则以用户需求为准结束.
1811
- const reviewDecision = decideAfterReview({
2218
+ // 2026-09-16 (Milestone 1-B): 目标审查走唯一门面 (loop-review 是 Harness 的一个环节)
2219
+ const reviewDecision = this.piHarness().reviewFinal({
1812
2220
  reviewsDone: loopReviewCount,
1813
2221
  // 2026-08-10: 传用户原始输入 (不是派生 intentHint) — LLM 对照原文自查完成度,
1814
2222
  // 未完成 → 自动继续调工具 (自动触发后续步骤)
1815
2223
  userIntent: this.currentUserInput,
1816
2224
  completedTools: Array.from(loopReviewCompletedTools),
1817
2225
  actionLog: loopActionLog,
2226
+ runId: this.currentRunId || undefined,
2227
+ goalId: this.currentGoalId || undefined,
1818
2228
  }, DEFAULT_MAX_REVIEWS);
1819
2229
  if (reviewDecision.kind === 'continue-review') {
1820
2230
  loopReviewCount++;
@@ -1874,11 +2284,82 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1874
2284
  }
1875
2285
  this.messageHistory.push({ role: 'assistant', content: finalResponse });
1876
2286
  // React Harness: 循环结束
1877
- try {
1878
- await this.reactHarness.onSessionEnd();
2287
+ // 2026-09-16 (Milestone 1-B): 会话收尾走唯一门面
2288
+ await this.piHarness().sessionEnd(this.harnessCtx());
2289
+ // 2026-09-16: 收尾落盘 — done / failed / aborted / needs_human 如实写回 (不留幽灵 running)
2290
+ if (this.currentRunId && !runExternallyPaused && !runExternallyAborted) {
2291
+ try {
2292
+ // 2026-09-16 (M4): 完成门 —— "模型说完成"不等于"系统确认完成"。
2293
+ // 证据 = 成功步骤的事实摘要; 末尾还有失败步骤没被后续成功覆盖 → 不许 done。
2294
+ const recBefore = await readRun(this.currentRunId).catch(() => null);
2295
+ const steps = recBefore?.steps || [];
2296
+ const evidence = steps.filter((s) => s.ok).map((s) => `${s.tool}: ${(s.summary || '').slice(0, 120)}`).slice(-10);
2297
+ const lastStep = steps[steps.length - 1];
2298
+ const trailingFailure = !!lastStep && !lastStep.ok;
2299
+ /** 有工具步骤却一条成功证据都没有 → 不算完成 (证据门槛, 不是文案) */
2300
+ const noEvidence = steps.length > 0 && evidence.length === 0;
2301
+ const errText = runPersistenceFailure || this.breakerReason || runNeedsHuman || runStopReason || aiFailureReason || '';
2302
+ const cls = errText ? classifyRunError(errText) : 'unknown';
2303
+ // 持久化失败 / 熔断 / 鉴权 → needs_human; 预算/中止 → aborted; 末尾仍失败或无证据 → failed (不许 done)
2304
+ const status = runPersistenceFailure
2305
+ ? 'needs_human'
2306
+ : (this.breakerReason || runNeedsHuman)
2307
+ ? 'needs_human'
2308
+ : runStopReason
2309
+ ? 'aborted'
2310
+ : aiFailed
2311
+ ? (cls === 'auth' ? 'needs_human' : 'failed')
2312
+ : (trailingFailure || noEvidence)
2313
+ ? 'failed'
2314
+ : 'done';
2315
+ await finishRun(this.currentRunId, {
2316
+ status,
2317
+ summary: finalResponse ? String(finalResponse).slice(0, 400) : undefined,
2318
+ error: errText
2319
+ || (trailingFailure ? `末尾步骤失败 (${lastStep.tool}): ${(lastStep.error || '').slice(0, 150)}` : undefined)
2320
+ || (noEvidence ? '有工具步骤但没有任何成功证据: 不许判 done' : undefined),
2321
+ evidence,
2322
+ });
2323
+ }
2324
+ catch (err) {
2325
+ // 终态也写不下去: 这是最坏情况 —— 除了留痕, 没有别的自愈手段, 所以必须显眼
2326
+ const message = `收尾落盘失败 (状态无法写回, 记录会停在 running): ${String(err?.message || err).slice(0, 200)}`;
2327
+ console.error('[PiAgent] run-store finishRun 失败 (核心持久化):', message);
2328
+ await recordDegradation({ kind: 'core', op: 'pi-sdk.finishRun', runId: this.currentRunId, message }).catch(() => { });
2329
+ onStream?.({ type: 'error', content: `⚠️ ${message}`, tool: 'harness' });
2330
+ }
2331
+ // 2026-09-16 (M4): Goal 侧完成门 —— Run 结束 ≠ Goal 完成; 只有判据全满足 + 有证据 + 无未解决项才算
2332
+ if (this.currentGoalId) {
2333
+ try {
2334
+ const full = await readRun(this.currentRunId).catch(() => null);
2335
+ const ev = (full?.evidence || []).slice(-10);
2336
+ if (ev.length)
2337
+ await addEvidence(this.currentGoalId, ev);
2338
+ const goal = await readGoal(this.currentGoalId);
2339
+ if (goal) {
2340
+ const verdict = evaluateGoalCompletion(goal);
2341
+ if (verdict.complete) {
2342
+ const done = await completeGoalIfEligible(this.currentGoalId);
2343
+ onStream?.({ type: 'status', content: `🎯 目标已达成: ${done.reason} (goal=${this.currentGoalId})`, tool: 'harness' });
2344
+ }
2345
+ else {
2346
+ // 不静默: 未达成的目标留在 active, 未解决项写清楚 (下次 prompt 会继续这个 Goal)
2347
+ const items = verdict.missing.length ? verdict.missing : [`run=${this.currentRunId} 结束但目标未达成: ${verdict.reason}`];
2348
+ await setUnresolved(this.currentGoalId, items);
2349
+ onStream?.({ type: 'status', content: `🎯 目标仍在进行 (未判完成): ${verdict.reason}`, tool: 'harness' });
2350
+ }
2351
+ }
2352
+ }
2353
+ catch (err) {
2354
+ await recordDegradation({ kind: 'observational', op: 'pi-sdk.goalCompletion', runId: this.currentRunId, message: String(err?.message || err).slice(0, 160) }).catch(() => { });
2355
+ }
2356
+ }
2357
+ this.currentRunId = '';
1879
2358
  }
1880
- catch (err) {
1881
- console.warn('[PiAgent] reactHarness.onSessionEnd failed (non-fatal):', err);
2359
+ else if (this.currentRunId) {
2360
+ // 外部暂停/中止: 状态是人定的, 不覆盖 (paused 等 /resume; aborted 是终态)
2361
+ onStream?.({ type: 'status', content: `⏹️ 运行状态保持为 ${runExternallyPaused ? 'paused' : 'aborted'} (由外部控制面决定)`, tool: 'harness' });
2362
+ this.currentRunId = '';
1882
2363
  }
1883
2364
  // 2026-06-16: 暴露 aiFailed 标志 — promptStream 据此决定是否自动重试整个 loop
1884
2365
  return { reply: finalResponse, aiFailed, aiFailureReason: aiFailureReason || undefined };