@bolloon/bolloon-agent 0.2.2 → 0.2.4

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 (134) hide show
  1. package/bin/bolloon-cli.cjs +15 -4
  2. package/bin/ipfs +0 -0
  3. package/dist/agents/parse-tool-call.js +304 -0
  4. package/dist/agents/pi-sdk.js +201 -291
  5. package/dist/agents/react-loop.js +120 -0
  6. package/dist/agents/session-store.js +157 -0
  7. package/dist/agents/tool-registry.js +136 -0
  8. package/dist/agents/workflow-pivot-loop.js +40 -0
  9. package/dist/bollharness/src/index.js +5 -0
  10. package/dist/bollharness/src/scripts/checks/check_adr_plan_numbering.js +6 -0
  11. package/dist/bollharness/src/scripts/checks/check_api_types.js +45 -0
  12. package/dist/bollharness/src/scripts/checks/check_artifact_link.js +146 -0
  13. package/dist/bollharness/src/scripts/checks/check_bridge_deps.js +6 -0
  14. package/dist/bollharness/src/scripts/checks/check_bugfix_binding.js +6 -0
  15. package/dist/bollharness/src/scripts/checks/check_bugfix_binding_ci.js +6 -0
  16. package/dist/bollharness/src/scripts/checks/check_doc_file_references.js +6 -0
  17. package/dist/bollharness/src/scripts/checks/check_doc_freshness.js +135 -0
  18. package/dist/bollharness/src/scripts/checks/check_doc_links.js +31 -0
  19. package/dist/bollharness/src/scripts/checks/check_file_existence_claims.js +6 -0
  20. package/dist/bollharness/src/scripts/checks/check_fragment_integrity.js +34 -0
  21. package/dist/bollharness/src/scripts/checks/check_hook_installed.js +63 -0
  22. package/dist/bollharness/src/scripts/checks/check_issue_closure.js +41 -0
  23. package/dist/bollharness/src/scripts/checks/check_mcp_parity.js +6 -0
  24. package/dist/bollharness/src/scripts/checks/check_security.js +48 -0
  25. package/dist/bollharness/src/scripts/checks/check_skill_parity.js +6 -0
  26. package/dist/bollharness/src/scripts/checks/check_versions.js +6 -0
  27. package/dist/bollharness/src/scripts/checks/finding.js +13 -0
  28. package/dist/bollharness/src/scripts/checks/next_decision_number.js +20 -0
  29. package/dist/bollharness/src/scripts/checks/regenerate_magic_docs.js +6 -0
  30. package/dist/bollharness/src/scripts/ci/detect_rebaseline_triggers.js +8 -0
  31. package/dist/bollharness/src/scripts/ci/scan_subprocess_cfg.js +8 -0
  32. package/dist/bollharness/src/scripts/ci/scan_verify_artifacts.js +8 -0
  33. package/dist/bollharness/src/scripts/ci/scan_yaml_schema.js +8 -0
  34. package/dist/bollharness/src/scripts/context_router.js +67 -0
  35. package/dist/bollharness/src/scripts/deploy-guard.js +157 -0
  36. package/dist/bollharness/src/scripts/guard-feedback.js +192 -0
  37. package/dist/bollharness/src/scripts/guard_router.js +158 -0
  38. package/dist/bollharness/src/scripts/hooks/_hook_output.js +6 -0
  39. package/dist/bollharness/src/scripts/hooks/auto-python3.js +6 -0
  40. package/dist/bollharness/src/scripts/hooks/deploy-progress-on-session-end.js +6 -0
  41. package/dist/bollharness/src/scripts/hooks/failure-analyzer.js +6 -0
  42. package/dist/bollharness/src/scripts/hooks/gate-judgment-inject.js +92 -0
  43. package/dist/bollharness/src/scripts/hooks/gate-transition-judgment.js +63 -0
  44. package/dist/bollharness/src/scripts/hooks/inbox-ack.js +6 -0
  45. package/dist/bollharness/src/scripts/hooks/inbox-inject-on-start.js +6 -0
  46. package/dist/bollharness/src/scripts/hooks/inbox-validate.js +6 -0
  47. package/dist/bollharness/src/scripts/hooks/inbox-write-ledger.js +6 -0
  48. package/dist/bollharness/src/scripts/hooks/initializer-agent.js +6 -0
  49. package/dist/bollharness/src/scripts/hooks/loop-detection.js +73 -0
  50. package/dist/bollharness/src/scripts/hooks/owner-guard.js +6 -0
  51. package/dist/bollharness/src/scripts/hooks/precompact.js +6 -0
  52. package/dist/bollharness/src/scripts/hooks/review-agent-gatekeeper.js +6 -0
  53. package/dist/bollharness/src/scripts/hooks/risk-tracker.js +108 -0
  54. package/dist/bollharness/src/scripts/hooks/sanitize-on-read.js +6 -0
  55. package/dist/bollharness/src/scripts/hooks/session-reflection.js +7 -0
  56. package/dist/bollharness/src/scripts/hooks/session-start-magic-docs.js +7 -0
  57. package/dist/bollharness/src/scripts/hooks/session-start-reset-risk.js +7 -0
  58. package/dist/bollharness/src/scripts/hooks/session-start-toolkit-reminder.js +7 -0
  59. package/dist/bollharness/src/scripts/hooks/stop-evaluator.js +157 -0
  60. package/dist/bollharness/src/scripts/hooks/tool-call-counter.js +6 -0
  61. package/dist/bollharness/src/scripts/hooks/trace-analyzer.js +10 -0
  62. package/dist/bollharness/src/scripts/install/install-trust-token.js +7 -0
  63. package/dist/bollharness/src/scripts/install/multi_project_registry.js +9 -0
  64. package/dist/bollharness/src/scripts/install/phase2_auto.js +21 -0
  65. package/dist/bollharness/src/scripts/install/pre_commit_installer.js +6 -0
  66. package/dist/bollharness/src/scripts/install/tier_selector.js +7 -0
  67. package/dist/bollharness/src/scripts/install/transcript_miner.js +7 -0
  68. package/dist/bollharness/src/scripts/lib/claim_patterns.js +10 -0
  69. package/dist/bollharness/src/scripts/lib/sanitize_patterns.js +12 -0
  70. package/dist/bollharness/src/scripts/sanitize.js +6 -0
  71. package/dist/constraint-runtime/src/tools/PolymarketSDK/cancelOrder.js +6 -0
  72. package/dist/constraint-runtime/src/tools/PolymarketSDK/createOrder.js +6 -0
  73. package/dist/constraint-runtime/src/tools/PolymarketSDK/getMarket.js +5 -0
  74. package/dist/constraint-runtime/src/tools/PolymarketSDK/getOrders.js +6 -0
  75. package/dist/constraint-runtime/src/tools/PolymarketSDK/listMarkets.js +4 -0
  76. package/dist/constraint-runtime/src/tools/SafeSDK/deploySafe.js +6 -0
  77. package/dist/constraint-runtime/src/tools/WalletTools/autoPay.js +29 -0
  78. package/dist/constraint-runtime/src/tools/WalletTools/createWallet.js +10 -0
  79. package/dist/constraint-runtime/src/tools/WalletTools/getBalance.js +13 -0
  80. package/dist/constraint-runtime/src/tools/WalletTools/importWallet.js +22 -0
  81. package/dist/constraint-runtime/src/tools/WalletTools/sendTransaction.js +25 -0
  82. package/dist/constraint-runtime/src/tools/WalletTools/signMessage.js +10 -0
  83. package/dist/constraint-runtime/src/tools/WalletTools/transferToken.js +25 -0
  84. package/dist/electron/config.js.map +1 -0
  85. package/dist/electron/dialogs.js.map +1 -0
  86. package/dist/electron/first-run.js.map +1 -0
  87. package/dist/electron/ipc.js.map +1 -0
  88. package/dist/electron/logger.js.map +1 -0
  89. package/dist/electron/main.js.map +1 -0
  90. package/dist/electron/menu.js.map +1 -0
  91. package/dist/electron/paths.js.map +1 -0
  92. package/dist/electron/server.js.map +1 -0
  93. package/dist/electron/tray.js.map +1 -0
  94. package/dist/electron/window.js.map +1 -0
  95. package/dist/electron-preload.js +29 -0
  96. package/dist/electron-preload.js.map +1 -0
  97. package/dist/electron.js +5 -0
  98. package/dist/electron.js.map +1 -0
  99. package/dist/llm/config-store.js +24 -8
  100. package/dist/llm/pi-ai.js +33 -19
  101. package/dist/web/server.js +16 -1
  102. package/dist/workflows/collaboration.js +374 -0
  103. package/dist/workflows/index.js +54 -0
  104. package/package.json +12 -3
  105. package/CLAUDE.md +0 -3
  106. package/build/icon.icns +0 -0
  107. package/build/icon.ico +0 -0
  108. package/build/icon.png +0 -0
  109. package/build/tray.png +0 -0
  110. package/build/trayTemplate.png +0 -0
  111. package/ios/App/App/AppDelegate.swift +0 -49
  112. package/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png +0 -0
  113. package/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -14
  114. package/ios/App/App/Assets.xcassets/Contents.json +0 -6
  115. package/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json +0 -23
  116. package/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png +0 -0
  117. package/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png +0 -0
  118. package/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png +0 -0
  119. package/ios/App/App/Base.lproj/LaunchScreen.storyboard +0 -32
  120. package/ios/App/App/Base.lproj/Main.storyboard +0 -19
  121. package/ios/App/App/Info.plist +0 -88
  122. package/ios/App/App.xcodeproj/project.pbxproj +0 -376
  123. package/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  124. package/ios/App/CapApp-SPM/Package.swift +0 -25
  125. package/ios/App/CapApp-SPM/README.md +0 -5
  126. package/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift +0 -1
  127. package/ios/debug.xcconfig +0 -1
  128. package/scripts/auto-evolve-oneshot.sh +0 -155
  129. package/scripts/auto-evolve-snapshot.sh +0 -136
  130. package/scripts/build-app-bundle.cjs +0 -170
  131. package/scripts/build-cli.js +0 -216
  132. package/scripts/detect-schema-changes.sh +0 -48
  133. package/scripts/lefthook-helper.sh +0 -69
  134. package/scripts/postinstall.js +0 -153
@@ -31,6 +31,10 @@ import { onPostToolUse } from '../bootstrap/lifecycle-hooks.js';
31
31
  import { budgetReduce, snip, microcompact } from '../context-compaction/index.js';
32
32
  // React Harness: 8-gate + 4-guard (防越权 / 防 prompt 注入)
33
33
  import { ReactHarness } from '../security/react-harness.js';
34
+ import { parseToolCall as parseToolCallImpl, isFinalResponse as isFinalResponseImpl, extractFinalAnswer as extractFinalAnswerImpl } from './parse-tool-call.js';
35
+ import { sessionStore as defaultSessionStore } from './session-store.js';
36
+ import { ToolRegistry } from './tool-registry.js';
37
+ import { decideMaxIterations, decideContextOverflow, shouldCompactBeforeIteration } from './react-loop.js';
34
38
  const SHARED_SESSION_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions');
35
39
  const PERSONA_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
36
40
  export class PiSessionManager {
@@ -344,11 +348,17 @@ class PiAgentSession {
344
348
  socialHeartbeat = null;
345
349
  messageHistory = [];
346
350
  tools = new Map();
351
+ /** 2026-06-30: tool registry 模块 — 独立 alias resolve, 测试可消融. */
352
+ _toolRegistry = new ToolRegistry();
347
353
  skillRegistry = new SkillRegistry();
348
354
  /** M2.4: 缓存 tool 列表, registerTools() 之后不变, runReActLoop 多次循环复用 */
349
355
  cachedToolDefinitions = '';
350
356
  /** M2.4: 缓存 persona section */
351
357
  cachedPersonaSection = '';
358
+ /** 2026-06-30: 持久化层 — 默认走 ~/.bolloon/sessions/cache/, 测试可注入临时目录. */
359
+ _sessionStore;
360
+ /** 构造期间 fire-and-forget 任务的 promise — whenReady() 等它 */
361
+ _readyPromise = null;
352
362
  // 2026-06-16 修: 父要求把 ReAct loop 上限放大到 "几乎无限", 靠自动压缩上下文 + fail-safe 兜底
353
363
  // 默认 10000 — 正常任务永远跑不到, 但作为防 LLM 死循环 / 防 OOM 的最后一道闸
354
364
  // 旧默认 100 写死导致中等复杂度任务 (10-50 个 tool call + 多步反思) 会被误杀
@@ -444,6 +454,8 @@ class PiAgentSession {
444
454
  this.peerId = config.peerId || 'local';
445
455
  this.identity = config.identityDoc || this.createDefaultIdentity();
446
456
  this.minimaxAvailable = this.checkMinimax();
457
+ // 2026-06-30: 持久化层可注入 — 测试传 tmpDir, 业务用默认 ~/.bolloon/sessions/cache/
458
+ this._sessionStore = config.sessionStore ?? defaultSessionStore;
447
459
  this.constraintLayer = new ConstraintLayer();
448
460
  this.workflowEngine = new WorkflowEngine(this.constraintLayer);
449
461
  this.sessionManager = new PiSessionManager(this.identity.did, this.cwd);
@@ -459,38 +471,37 @@ class PiAgentSession {
459
471
  // 之前 messageHistory 是空的, 服务重启后 LLM 看到的是新对话
460
472
  // 现在 loadSessionKey 形如 "channel-xxx:default" 走 ~/.bolloon/sessions/cache/<key>.json
461
473
  if (config.loadSessionKey) {
462
- this.hydrateMessageHistory(config.loadSessionKey, config.loadSessionMaxMessages ?? 30);
474
+ this._readyPromise = this.hydrateMessageHistory(config.loadSessionKey, config.loadSessionMaxMessages ?? 30).catch((err) => {
475
+ // 失败静默, 但不让 whenReady 永久 hang
476
+ console.warn(`[PiAgent] hydrateMessageHistory failed: ${err.message?.slice(0, 100)}`);
477
+ });
463
478
  }
464
479
  }
465
480
  /**
466
- * M2.3: session JSON 加载历史, 转成 messageHistory 格式
481
+ * 2026-06-30: 让外部 await 构造期间的 hydrate 完成.
482
+ * 解决 fire-and-forget 让 messageHistory 不可预测的问题.
483
+ * 不传 loadSessionKey 时立即返回.
484
+ */
485
+ whenReady() {
486
+ return this._readyPromise ?? Promise.resolve();
487
+ }
488
+ /**
489
+ * M2.3 (2026-06-30 重构): 从 SessionStore 加载历史, 转成 messageHistory 格式
467
490
  * - 失败静默 (历史加载失败不应该阻塞 agent 启动)
468
491
  * - 限制 max 条数, 防止 context 爆
469
- * - user 消息 role=user, ai 消息 role=assistant
470
- * - 跳过 metadata 中含 error (错误消息会污染 LLM)
492
+ * - 跳过错误消息 ([AI 服务调用失败] / [错误:...]) 不污染 LLM
493
+ * - 委托 SessionStore 完成 IO, 保证 save/load 路径对称
494
+ *
495
+ * 历史格式兼容旧 schema ({type, content}) 和新 schema (PersistedMessage[])
471
496
  */
472
497
  async hydrateMessageHistory(sessionKey, maxMessages) {
473
498
  try {
474
- const sessionPath = path.join(os.homedir(), '.bolloon', 'sessions', 'cache', `${sessionKey}.json`);
475
- const content = await fs.readFile(sessionPath, 'utf-8');
476
- const session = JSON.parse(content);
477
- const messages = Array.isArray(session?.messages) ? session.messages : [];
478
- // 保留最后 N 条, 转换 role 字段
479
- const tail = messages.slice(-maxMessages);
480
- const hydrated = [];
481
- for (const m of tail) {
482
- // 跳过错误的 AI 消息 (M1.2 之后, AI 错误时 reply 是 [AI 服务调用失败] 字符串, 不该进 history)
483
- if (m?.type === 'ai' && typeof m.content === 'string' && m.content.startsWith('[AI 服务调用失败]'))
484
- continue;
485
- if (m?.type === 'ai' && typeof m.content === 'string' && m.content.startsWith('[错误:'))
486
- continue;
487
- if (!m?.content)
488
- continue;
489
- const role = m.type === 'user' ? 'user' : m.type === 'ai' ? 'assistant' : null;
490
- if (!role)
491
- continue;
492
- hydrated.push({ role, content: String(m.content) });
499
+ const loaded = await this._sessionStore.loadMessages(sessionKey);
500
+ if (!loaded) {
501
+ console.log(`[PiAgent] hydrate: 没有 ${sessionKey} 的历史`);
502
+ return;
493
503
  }
504
+ const hydrated = this._filterToMessage(loaded).slice(-maxMessages);
494
505
  if (hydrated.length > 0) {
495
506
  this.messageHistory = hydrated;
496
507
  console.log(`[PiAgent] 从 ${sessionKey} 回灌 ${hydrated.length} 条历史`);
@@ -500,6 +511,84 @@ class PiAgentSession {
500
511
  console.warn(`[PiAgent] hydrateMessageHistory 失败 (non-fatal): ${err.message?.slice(0, 100)}`);
501
512
  }
502
513
  }
514
+ /**
515
+ * 2026-06-30: 把当前 messageHistory 持久化到 SessionStore.
516
+ * 公开方法 — claude code / 外部 harness 在每次 prompt 完成后调一下,
517
+ * 即可获得"重启 / 跨进程接续"的语义.
518
+ */
519
+ async saveCurrentSession(key) {
520
+ const persisted = this.messageHistory.map((m) => ({
521
+ role: m.role,
522
+ content: m.content,
523
+ toolCall: m.toolCall,
524
+ toolResult: m.toolResult,
525
+ toolCallId: m.toolCallId,
526
+ timestamp: Date.now(),
527
+ source: 'pi-session',
528
+ }));
529
+ await this._sessionStore.saveMessages(key, persisted);
530
+ }
531
+ /**
532
+ * 2026-06-30: 从 disk 拉历史覆盖当前 messageHistory.
533
+ * 返回加载条数 — 失败或空则返回 0.
534
+ * 与 loadSessionKey (构造时读) 不同: 这个是 session 已建好后再读.
535
+ */
536
+ async resumeSession(key, maxMessages = 30) {
537
+ const before = this.messageHistory.length;
538
+ await this.hydrateMessageHistory(key, maxMessages);
539
+ return this.messageHistory.length - before;
540
+ }
541
+ /**
542
+ * 2026-06-30: 读历史不修改 messageHistory.
543
+ * 给 claude code / 测试做"先看一下历史"用 — 不破坏当前会话.
544
+ * 返回 Message[] 数组 (空数组表示无历史).
545
+ */
546
+ async peekSessionHistory(key, maxMessages = 30) {
547
+ try {
548
+ const loaded = await this._sessionStore.loadMessages(key);
549
+ if (!loaded)
550
+ return [];
551
+ return this._filterToMessage(loaded).slice(-maxMessages);
552
+ }
553
+ catch {
554
+ return [];
555
+ }
556
+ }
557
+ /** hydrateMessageHistory 用的过滤逻辑 — 提到外面复用 */
558
+ _filterToMessage(loaded) {
559
+ const hydrated = [];
560
+ const VALID_ROLES = new Set(['user', 'assistant', 'tool', 'system']);
561
+ for (const m of loaded) {
562
+ // role 必须合法 (拒绝旧 schema {type:'user'} 没 role 字段的)
563
+ if (!VALID_ROLES.has(m.role))
564
+ continue;
565
+ // 跳过污染消息
566
+ if (typeof m.content === 'string' && m.content.startsWith('[AI 服务调用失败]'))
567
+ continue;
568
+ if (typeof m.content === 'string' && m.content.startsWith('[错误:'))
569
+ continue;
570
+ // 注意: '!m.content' 会跳过 content='' 的 tool call 消息 (assistant role + toolCall 字段),
571
+ // 这种是合法的 (LLM 输出只有 tool call, 没有正文) — 必须保留.
572
+ // 这里只跳过"无内容 + 也没 tool call/tool result"的废消息.
573
+ if (!m.content && !m.toolCall && !m.toolResult)
574
+ continue;
575
+ // 跳过空 tool role (tool result 占位但没有任何内容)
576
+ if (m.role === 'tool' && !m.toolResult)
577
+ continue;
578
+ hydrated.push({
579
+ role: m.role,
580
+ content: m.content ?? '',
581
+ toolCall: m.toolCall,
582
+ toolResult: m.toolResult,
583
+ toolCallId: m.toolCallId,
584
+ });
585
+ }
586
+ return hydrated;
587
+ }
588
+ /** 暴露 store 给测试 / 高级集成用. */
589
+ get sessionStoreInstance() {
590
+ return this._sessionStore;
591
+ }
503
592
  /**
504
593
  * 从 SKILL.md 目录加载 skills 进 skillRegistry.
505
594
  *
@@ -1617,6 +1706,11 @@ class PiAgentSession {
1617
1706
  // - 失败结果不缓存 (避免缓存 transient 错误)
1618
1707
  // - 缓存容量 200 条, 超过就清空
1619
1708
  this.wrapToolsWithIdempotency();
1709
+ // 2026-06-30: 镜像到 ToolRegistry — resolveToolName 走 registry 的 alias 表.
1710
+ // 被 wrap 的 tool (idempotency cache) 也直接拿来用, registry 不重复 wrap.
1711
+ for (const [name, tool] of this.tools.entries()) {
1712
+ this._toolRegistry.register(tool);
1713
+ }
1620
1714
  }
1621
1715
  /** M3.3: 工具结果缓存 — 防止 loop 重试时副作用 (写文件 / 改代码) 执行多次 */
1622
1716
  idempotencyCache = new Map();
@@ -2094,10 +2188,12 @@ ${this.getToolDefinitions()}
2094
2188
  while (iteration < this.MAX_REACT_ITERATIONS) {
2095
2189
  iteration++;
2096
2190
  // 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
2097
- if (iteration >= this.MAX_REACT_ITERATIONS) {
2191
+ // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideMaxIterations 纯函数
2192
+ const maxIterDecision = decideMaxIterations(iteration, this.MAX_REACT_ITERATIONS);
2193
+ if (maxIterDecision.shouldExit) {
2098
2194
  console.warn(`[PiAgent] 达到最大循环数 ${this.MAX_REACT_ITERATIONS}, 强制终止 (fail-safe)`);
2099
2195
  onStream?.({ type: 'error', content: `⏹️ 达到最大循环数 (${this.MAX_REACT_ITERATIONS}, fail-safe)`, tool: 'loop' });
2100
- finalResponse = finalResponse || '(本轮 ReAct 循环达到最大步数, 强制结束)';
2196
+ finalResponse = finalResponse || maxIterDecision.finalAnswer;
2101
2197
  break;
2102
2198
  }
2103
2199
  // 停止条件 2: signal.aborted (显式 abort / 用户中断)
@@ -2124,9 +2220,10 @@ ${this.getToolDefinitions()}
2124
2220
  }
2125
2221
  // 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
2126
2222
  // compact 失败走 C 路径: 不强行 break, 让现有 60K 阈值兜底 (后面有检查)
2223
+ // 2026-07-01 (v0.2.4 子任务 1): 触发判定走 shouldCompactBeforeIteration 纯函数
2127
2224
  const compactThreshold = this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * this.LOOP_COMPACT_RATIO;
2128
2225
  const estimatedTokensBefore = this.estimateHistoryTokens();
2129
- if (estimatedTokensBefore > compactThreshold) {
2226
+ if (shouldCompactBeforeIteration(estimatedTokensBefore, compactThreshold)) {
2130
2227
  const tokensBeforeCompact = estimatedTokensBefore;
2131
2228
  console.log(`[PiAgent] loop 入口 token ${tokensBeforeCompact} > ${compactThreshold}, 触发自动压缩`);
2132
2229
  onStream?.({ type: 'status', content: `🗜️ loop 自动压缩 (token ${tokensBeforeCompact} > ${compactThreshold})`, tool: 'compactor' });
@@ -2139,11 +2236,13 @@ ${this.getToolDefinitions()}
2139
2236
  }
2140
2237
  }
2141
2238
  // 停止条件 3: context overflow (compact 后还超, 强制终止)
2239
+ // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideContextOverflow 纯函数
2142
2240
  const estimatedTokens = this.estimateHistoryTokens();
2143
- if (estimatedTokens > this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD) {
2241
+ const overflowDecision = decideContextOverflow(estimatedTokens, this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD);
2242
+ if (overflowDecision.shouldExit) {
2144
2243
  console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`);
2145
2244
  onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`, tool: 'loop' });
2146
- finalResponse = finalResponse || '(本轮 ReAct 循环因上下文溢出终止)';
2245
+ finalResponse = finalResponse || overflowDecision.finalAnswer;
2147
2246
  break;
2148
2247
  }
2149
2248
  // 调试日志:显示每次循环开始
@@ -2202,12 +2301,16 @@ ${toolDefs}
2202
2301
  // 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
2203
2302
  const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream);
2204
2303
  const reply = (response.reply || '').trim();
2304
+ // 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
2305
+ const nativeToolCalls = response.toolCalls;
2205
2306
  // 2026-06-19 架构 fix: 不再因 [AI 服务调用失败] break
2206
2307
  // 旧逻辑: sentinel → aiFailed=true → break → 外层 retry 整个 loop (重置 history)
2207
2308
  // 新逻辑: 把错误当 tool_result push 进 history → 下一轮 LLM 看到错误能反思重试
2208
2309
  // 这是 dive-into 文档的"fail-open error recovery" — 错误进入 context, 不让 LLM 重复犯同样错
2209
2310
  if (reply.startsWith('[AI 服务调用失败]')) {
2210
2311
  console.log(`[PiAgent] 收到 AI 错误 sentinel, 推到 history 让 LLM 反思, 继续 loop`);
2312
+ console.log(`[sentinel DEBUG] 完整 reply: ${reply}`);
2313
+ console.log(`[sentinel DEBUG] 上一轮 messages 数量: ${Array.isArray(messages) ? messages.length : 'N/A'}, systemPrompt 长度: ${systemPrompt.length}`);
2211
2314
  aiFailureReason = reply.length > 200 ? reply.substring(0, 200) : reply;
2212
2315
  totalErrors++;
2213
2316
  consecutiveErrors++;
@@ -2234,9 +2337,10 @@ ${toolDefs}
2234
2337
  // 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
2235
2338
  // 现在: 先尝试解析 tool_call, 有就执行; 没有才检查是不是真正的 final gen
2236
2339
  const toolCall = this.parseToolCall(reply);
2237
- // 2026-06-19 修: 即便 reply <final gen>, 只要 parseToolCall 命中, 就执行工具
2238
- // (之前 isFinalResponse break 会先于 parseToolCall 触发, 思考块里有 final gen 字符串就误杀)
2340
+ // 2026-06-30 修: toolCall 分配稳定 id, 让后续 tool result 能引用同一个 id
2341
+ // OpenAI 协议要求 messages tool result 必须有对应的 tool_call_id, 否则 400
2239
2342
  if (toolCall) {
2343
+ toolCall.id = `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2240
2344
  this.messageHistory.push({
2241
2345
  role: 'assistant',
2242
2346
  content: reply,
@@ -2402,7 +2506,7 @@ ${toolDefs}
2402
2506
  catch (err) {
2403
2507
  console.warn('[PiAgent] reactHarness.postToolCall failed (non-fatal, allowing):', err);
2404
2508
  }
2405
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(result), toolResult: result });
2509
+ this.messageHistory.push({ role: 'tool', content: JSON.stringify(result), toolResult: result, toolCallId: toolCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` });
2406
2510
  this.logToHarness(toolCall.name, toolCall.args, result);
2407
2511
  // 通知前端工具执行结果
2408
2512
  if (onStream) {
@@ -2522,6 +2626,13 @@ ${toolDefs}
2522
2626
  // LLM 不能提前 final_gen — harness 自动注入"请汇报剩余工具结果" hint 再 continue
2523
2627
  // 这是 dive-into 文档"step 9 stop condition check" 的具体化:
2524
2628
  // stop condition = (有工具结果未汇报) ? continue : break
2629
+ // 检查回复是否包含工具结果内容(避免无限循环)
2630
+ const hasToolResultContent = this.successfulToolResults.some(r => reply.includes(r.tool) || reply.includes(r.outputPreview.substring(0, 50)));
2631
+ // 如果回复包含工具结果内容,清除 successfulToolResults
2632
+ if (hasToolResultContent) {
2633
+ console.log(`[PiAgent] 回复包含工具结果内容, 清除 successfulToolResults (${this.successfulToolResults.length} 个)`);
2634
+ this.successfulToolResults = [];
2635
+ }
2525
2636
  if (this.successfulToolResults.length > 0 && iteration < this.MAX_REACT_ITERATIONS) {
2526
2637
  const unreported = this.successfulToolResults.length;
2527
2638
  console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报, push hint 让其继续`);
@@ -2680,18 +2791,45 @@ Workspace root folder: ${this.cwd}
2680
2791
  for (const m of recentMessages) {
2681
2792
  const role = m.role;
2682
2793
  let content = m.content;
2683
- // tool role: toolResult 序列化 ( buildContext 一样)
2794
+ // 2026-06-30 修: OpenAI 协议 tool role 必须带 tool_call_id, 否则 minimax (OpenAI-compatible) 400
2795
+ // bolloon 之前把所有 tool result 包成 "[工具结果] ..." 当 user/assistant role 发, minimax 严格校验失败
2796
+ // 现在: 保留 role='tool' + 加 tool_call_id 字段 (用 messageHistory 里自己生成的 id)
2684
2797
  if (role === 'tool') {
2685
2798
  const result = m.toolResult ? JSON.stringify(m.toolResult) : content;
2686
2799
  content = `[工具结果] ${result}`;
2800
+ // MiniMax 等 API 不支持 tool role, 转为 user role
2801
+ out.push({ role: 'user', content });
2802
+ continue;
2687
2803
  }
2688
2804
  // system role (router hint 等) 直接保留
2689
2805
  if (role === 'system') {
2690
2806
  out.push({ role: 'system', content });
2691
2807
  continue;
2692
2808
  }
2693
- // assistant / user / tool 直接转
2694
- if (role === 'user' || role === 'assistant' || role === 'tool') {
2809
+ // 2026-06-30 修: assistant 消息如果带 toolCall (bolloon 内部对象), emit OpenAI 协议的 tool_calls 数组
2810
+ // minimax 严格要求 assistant 消息含 tool_calls 字段, 后续 tool result 才能引用 tool_call_id
2811
+ if (role === 'assistant') {
2812
+ const tc = m.toolCall;
2813
+ if (tc && tc.id) {
2814
+ out.push({
2815
+ role: 'assistant',
2816
+ content: content || '',
2817
+ tool_calls: [{
2818
+ id: tc.id,
2819
+ type: 'function',
2820
+ function: {
2821
+ name: tc.name,
2822
+ arguments: JSON.stringify(tc.args || {}),
2823
+ },
2824
+ }],
2825
+ });
2826
+ }
2827
+ else {
2828
+ out.push({ role, content });
2829
+ }
2830
+ continue;
2831
+ }
2832
+ if (role === 'user') {
2695
2833
  out.push({ role, content });
2696
2834
  }
2697
2835
  }
@@ -2778,7 +2916,8 @@ Workspace root folder: ${this.cwd}
2778
2916
  // M3.5 (2026-06-17): 传 messages 数组 (如果 contextOrMessages 是数组) 或字符串
2779
2917
  // 数组版让 LLM 看到结构化的 user/assistant/tool role, 而不是把 history 拼成单字符串
2780
2918
  const response = await llm.chat(contextOrMessages, systemPrompt, signal);
2781
- return { reply: response.reply || '' };
2919
+ // 2026-06-30: 透传 toolCalls (OpenAI 协议 native) 给上层, 让 assistant message 能 emit 真 id
2920
+ return { reply: response.reply || '', toolCalls: response.toolCalls };
2782
2921
  }
2783
2922
  catch (err) {
2784
2923
  // 用户主动 abort: 不重试, 立即抛
@@ -2904,225 +3043,22 @@ Workspace root folder: ${this.cwd}
2904
3043
  }
2905
3044
  }
2906
3045
  isFinalResponse(content) {
2907
- // 2026-06-19 修: 先剥离 <think>...</think> 思考块, 再判断 <final gen>.
2908
- // 之前用 includes('<final gen>') 误杀: LLM 思考块里说"先看看当前状态再 <final gen>"
2909
- // 也会被识别成终止信号, 工具调用直接跳过了.
2910
- const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, '');
2911
- return stripped.includes('<final gen>');
3046
+ // 2026-06-30: 抽到 ./parse-tool-call.ts 作为纯函数 这里只构建 ctx 并调用
3047
+ return isFinalResponseImpl(content, this._parseCtx());
2912
3048
  }
2913
3049
  extractFinalAnswer(content) {
2914
- // 提取 <final gen> 后的内容作为最终回答
2915
- const marker = '<final gen>';
2916
- const markerIndex = content.indexOf(marker);
2917
- if (markerIndex !== -1) {
2918
- const after = content.substring(markerIndex + marker.length).trim();
2919
- // v3 修复: 如果 <final gen> 之后是空, fallback 用 marker 之前的内容 (去掉 marker)
2920
- // 否则 LLM 写了 <final gen> 在末尾时, 用户看到空回复 + error
2921
- if (after) {
2922
- content = after;
2923
- }
2924
- else {
2925
- content = content.substring(0, markerIndex).trim();
2926
- }
2927
- }
2928
- // 移除任何 tool call 标记
2929
- let cleaned = content
2930
- .replace(/调用工具[::]\s*\w+\s*\([^)]*\)/g, '')
2931
- .replace(/使用工具[::]\s*\w+\s*\([^)]*\)/g, '')
2932
- .replace(/tool[_\w]*[::]\s*\w+\s*\([^)]*\)/gi, '')
2933
- .trim();
2934
- return cleaned;
3050
+ // 抽取实现已挪到 ./parse-tool-call.ts (纯函数, 易测)
3051
+ return extractFinalAnswerImpl(content);
3052
+ }
3053
+ _parseCtx() {
3054
+ return {
3055
+ tools: new Set(Array.from(this.tools.keys())),
3056
+ resolveAlias: (name) => this.resolveToolName(name),
3057
+ };
2935
3058
  }
2936
3059
  parseToolCall(content) {
2937
- // 2026-06-19 修: JSON function-call (OpenAI/Anthropic/Minimax-style) 优先 match[1] name 字段, match[2] 是 arguments/input 对象
2938
- // LLM 实际产出变体:
2939
- // {"name": "shell_exec", "arguments": {"command": "git", "args": ["status"]}} (OpenAI 标准)
2940
- // {"name": "shell_exec", "input": {"command": "git", "args": ["status"]}} (Anthropic/Minimax 风格)
2941
- // 常见包裹: ```json\n{...}\n``` (markdown code block), <tool_call>{...}</tool_call> (OpenAI Hermes)
2942
- const jsonPatterns = [
2943
- // markdown json code block + OpenAI <tool_call> 块, 同时匹配 arguments/input 字段
2944
- /(?:```(?:json|json5)?\s*\n?)?\{[\s\S]*?"name"\s*:\s*["'](\w+)["']\s*,\s*["']?(?:arguments|input)["']?\s*:\s*(\{[\s\S]*?\})\s*\}/,
2945
- ];
2946
- for (const p of jsonPatterns) {
2947
- const m = content.match(p);
2948
- console.log(`[parseToolCall DEBUG] jsonPattern match: ${m ? `name=${m[1]}, args=${JSON.stringify(m[2]).slice(0, 80)}` : 'null'}`);
2949
- if (m) {
2950
- const name = m[1];
2951
- let args = {};
2952
- try {
2953
- const parsed = JSON.parse(m[2]);
2954
- if (parsed && typeof parsed === 'object') {
2955
- args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
2956
- }
2957
- }
2958
- catch { /* 解析失败保持 args={} */ }
2959
- // 2026-06-19: 自动 split command 字段 — LLM 常把 "git status" 整体当 command
2960
- // shell_exec execute 要求 command="git", args="status" 拆分形式
2961
- // 这里检测 command 含空格且 args 字段空, 自动按空格 split
2962
- if (typeof args.command === 'string' && args.command.includes(' ') && !args.args) {
2963
- const parts = args.command.split(/\s+/);
2964
- args.command = parts[0];
2965
- args.args = parts.slice(1).join(' ');
2966
- }
2967
- const resolved = this.resolveToolName(name);
2968
- if (resolved) {
2969
- return { name: resolved, args };
2970
- }
2971
- }
2972
- }
2973
- // 2026-06-19 修: fallback — LLM 有时输出 <tool_name>shell_exec</tool_name> 这种伪 XML 标签
2974
- // 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配, 但 name="tool_name" 不在 tools 里
2975
- // 这里 detect 内容是否像 tool_call (有 <command>/<args>/<path> 子标签) 然后按 <command> 第一词推断工具
2976
- const xmlTagMatch = content.match(/<(\w+)>([\s\S]*?)<\/\1>/);
2977
- if (xmlTagMatch) {
2978
- const outerTag = xmlTagMatch[1];
2979
- const inner = xmlTagMatch[2];
2980
- // 如果外层标签不在 tools 里但有 <command>/<args> 子标签, 尝试按内容推断
2981
- if (!this.resolveToolName(outerTag)) {
2982
- const cmdMatch = inner.match(/<command>(\w+)<\/command>/);
2983
- if (cmdMatch) {
2984
- const cmd = cmdMatch[1];
2985
- // 推测工具: git/npx/npm/tsx → shell_exec; cat/head/tail → read_document
2986
- if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmd)) {
2987
- const args = {};
2988
- const argsMatch = inner.match(/<args>([\s\S]*?)<\/args>/);
2989
- if (argsMatch)
2990
- args.args = argsMatch[1].trim();
2991
- const cmdsMatch = inner.match(/<command>([\s\S]*?)<\/command>/);
2992
- if (cmdsMatch)
2993
- args.command = cmdsMatch[1].trim();
2994
- return { name: 'shell_exec', args };
2995
- }
2996
- }
2997
- }
2998
- }
2999
- // 2026-06-19 修: <tools:call name="X">...</tools:call> 嵌套格式
3000
- // LLM 实际产出变体: <tools:call name="shell_exec"><tools:call name="command">git</tools:call>...</tools:call>
3001
- const toolsCallMatch = content.match(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/);
3002
- if (toolsCallMatch) {
3003
- const name = toolsCallMatch[1];
3004
- const inner = toolsCallMatch[2];
3005
- const args = {};
3006
- const argTags = inner.matchAll(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/g);
3007
- for (const m of argTags) {
3008
- args[m[1]] = m[2].trim();
3009
- }
3010
- const resolved = this.resolveToolName(name);
3011
- if (resolved) {
3012
- return { name: resolved, args };
3013
- }
3014
- // 兜底: 如果外层 tool name 不在 tools, 但内层有 command, 推断 shell_exec
3015
- if (args.command) {
3016
- const cmdFirst = args.command.split(/\s+/)[0];
3017
- if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmdFirst)) {
3018
- return { name: 'shell_exec', args };
3019
- }
3020
- }
3021
- }
3022
- // 2026-06-19 修: 先剥离 <think>...</think> 思考块, 避免旧正则 `<(\w+)>([\s\S]*?)<\/\1>` 优先匹配到 <think>
3023
- // 之前 LLM 输出含 <think>...</think><invoke name="X">...</invoke> 时, <think> 先 match, name="think" 不在 tools → return null → 后续 <invoke> 没机会 match
3024
- const strippedContent = content.replace(/<think>[\s\S]*?<\/think>/g, '');
3025
- const patterns = [
3026
- // 2026-06-19 修: minimax/Hermes 自闭合 XML 格式 <invoke name="X">...</invoke>
3027
- // 实际 LLM 习惯: <invoke name="shell_exec"><command>sed</command><args>["-n","1060,1095p"]</args></invoke>
3028
- // 必须放在最前 — 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配到外层 <invoke> 但 name 是 "invoke" 而不是 "shell_exec"
3029
- new RegExp('<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>'),
3030
- // 2026-06-19 修: <function_calls> 包裹
3031
- new RegExp('<function_calls>[\\s\\S]*?<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>[\\s\\S]*?</function_calls>'),
3032
- /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
3033
- /使用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
3034
- /tool[_\w]*[::]\s*(\w+)\s*\(([^)]*)\)/i,
3035
- /(\w+)\s*\(\s*([^)]*)\s*\)/,
3036
- // 兼容 LLM 输出的对象字面量格式: {tool => "get_identity", args => {...}}
3037
- /\{\s*tool\s*=>\s*["'](\w+)["']\s*(?:,\s*args\s*=>\s*(\{[\s\S]*?\}))?\s*\}/,
3038
- // 兼容: tool => "get_identity" (无 args 包裹)
3039
- /\btool\s*=>\s*["'](\w+)["']/,
3040
- // 兼容: [TOOL_CALL] 块内 JSON 形式 {"name": "x", "args": {...}}
3041
- /\[TOOL_CALL\][\s\S]*?\{\s*"name"\s*:\s*"(\w+)"\s*,\s*"args"\s*:\s*(\{[\s\S]*?\})/i,
3042
- // 2026-06-19: 兼容 LLM 输出 "tool_name {json_args}" 形式 (单行, 无 name 字段)
3043
- // 实际输出: write_file {"path":"docs/test.md","content":"..."}
3044
- // 只在行首/新行匹配, 避免误匹配普通文本里的 "foo {...}"
3045
- /(?:^|\n)(\w+)\s+(\{[\s\S]*?\})(?=\n|$)/,
3046
- // 2026-06-15 修: 兼容 LLM 输出的 XML 格式 <tool_name>...<arg>value</arg>...</tool_name>
3047
- // 实际 LLM 习惯: <shell_exec>\n<command>ls</command>\n<args>["-la", "..."]</args>\n</shell_exec>
3048
- /<(\w+)>([\s\S]*?)<\/\1>/,
3049
- ];
3050
- for (const pattern of patterns) {
3051
- const match = strippedContent.match(pattern);
3052
- if (match) {
3053
- const name = match[1];
3054
- let args = {};
3055
- console.log(`[parseToolCall DEBUG] pattern matched: name=${name}, rawArgs=${(match[2] || '').slice(0, 100)}`);
3056
- const rawArgs = match[2] || '';
3057
- if (rawArgs && rawArgs.trim().startsWith('{')) {
3058
- // JSON 形式, 尝试解析
3059
- try {
3060
- const parsed = JSON.parse(rawArgs);
3061
- if (parsed && typeof parsed === 'object') {
3062
- args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
3063
- }
3064
- }
3065
- catch {
3066
- // 解析失败就当字符串处理
3067
- const argPairs = rawArgs.split(',').map(s => s.trim()).filter(Boolean);
3068
- for (const pair of argPairs) {
3069
- const [key, ...valueParts] = pair.split(':').map(s => s.trim().replace(/['"]/g, ''));
3070
- if (key)
3071
- args[key] = valueParts.join(':') || '';
3072
- }
3073
- }
3074
- }
3075
- else if (rawArgs && /<\w+>[\s\S]*<\/\w+>/.test(rawArgs)) {
3076
- // 2026-06-15 修: XML 格式, 解析内嵌子标签 <argname>value</argname>
3077
- // 例: <command>ls</command>\n<args>["-la","~/.bolloon/skills"]</args>
3078
- // 2026-06-19 修: 也支持 <parameter name="X">value</parameter> 形式 (OpenAI Hermes function_call)
3079
- const paramRe = /<parameter\s+name=["'](\w+)["']>([\s\S]*?)<\/parameter>/g;
3080
- let pMatch;
3081
- while ((pMatch = paramRe.exec(rawArgs)) !== null) {
3082
- const argName = pMatch[1];
3083
- const argValue = pMatch[2].trim();
3084
- if (argName && argValue) {
3085
- args[argName] = argValue;
3086
- }
3087
- }
3088
- // 如果没匹配到 <parameter>, 用普通 XML 子标签
3089
- if (Object.keys(args).length === 0) {
3090
- const xmlArgPattern = /<(\w+)>([\s\S]*?)<\/\1>/g;
3091
- let xmlMatch;
3092
- while ((xmlMatch = xmlArgPattern.exec(rawArgs)) !== null) {
3093
- const argName = xmlMatch[1];
3094
- const argValue = xmlMatch[2].trim();
3095
- if (argName && argValue) {
3096
- args[argName] = argValue;
3097
- }
3098
- }
3099
- }
3100
- }
3101
- else if (rawArgs) {
3102
- // 形参串, 形如 key: value, key2: value2
3103
- const argPairs = rawArgs.split(',').map(s => s.trim()).filter(Boolean);
3104
- for (const pair of argPairs) {
3105
- const [key, ...valueParts] = pair.split(':').map(s => s.trim().replace(/['"]/g, ''));
3106
- if (key)
3107
- args[key] = valueParts.join(':') || '';
3108
- }
3109
- }
3110
- if (this.tools.has(name)) {
3111
- // 2026-06-19: auto-split command field — shell_exec expects command='git', args='status' split
3112
- if (typeof args.command === 'string' && args.command.includes(' ') && !args.args) {
3113
- const parts = args.command.split(/\s+/);
3114
- args.command = parts[0];
3115
- args.args = parts.slice(1).join(' ');
3116
- }
3117
- return { name, args };
3118
- }
3119
- const resolved = this.resolveToolName(name);
3120
- if (resolved) {
3121
- return { name: resolved, args };
3122
- }
3123
- }
3124
- }
3125
- return null;
3060
+ // 2026-06-30: 抽到 ./parse-tool-call.ts 作为纯函数这里只构建 ctx 并调用
3061
+ return parseToolCallImpl(content, this._parseCtx());
3126
3062
  }
3127
3063
  // [debug-2026-06-19] 临时: 打印 parseToolCall 输入和返回
3128
3064
  _dbgParseToolCall(content) {
@@ -3384,47 +3320,15 @@ Workspace root folder: ${this.cwd}
3384
3320
  * bolloon 注册的是 read_document / edit_file / write_file / shell_exec / list_files
3385
3321
  * 返回 this.tools 里的标准名, 或 null 表示未识别
3386
3322
  */
3323
+ /**
3324
+ * 把 LLM 给的工具名 (可能大小写不一, 或者 Claude Code 风格的别名) 解析为
3325
+ * bolloon 注册的标准工具名.
3326
+ *
3327
+ * 2026-06-30: 委托给 ToolRegistry 模块 — alias 表在 tool-registry.ts 统一维护,
3328
+ * 这里只做 thin wrapper 保留 backward compat (private API 但其它地方可能用).
3329
+ */
3387
3330
  resolveToolName(name) {
3388
- if (this.tools.has(name))
3389
- return name;
3390
- const lower = name.toLowerCase();
3391
- const aliasMap = {
3392
- // Claude Code 风格 → bolloon 工具
3393
- read: 'read_file',
3394
- edit: 'edit_file',
3395
- write: 'write_file',
3396
- rm: 'delete_file',
3397
- mv: 'move_file',
3398
- bash: 'shell_exec',
3399
- shell: 'shell_exec',
3400
- sh: 'shell_exec',
3401
- // grep / glob 现在是独立工具
3402
- cat: 'read_file',
3403
- // 测试/编译 → 独立工具
3404
- test: 'vitest_run',
3405
- vitest: 'vitest_run',
3406
- typecheck: 'tsc_check',
3407
- tsc: 'tsc_check',
3408
- // git 操作
3409
- log: 'git_log',
3410
- show: 'git_show',
3411
- diff: 'git_diff',
3412
- commit: 'git_commit',
3413
- push: 'git_push',
3414
- branch: 'git_branch',
3415
- checkout: 'git_branch',
3416
- stash: 'git_stash',
3417
- // 任务管理 (todo)
3418
- todo_write: 'create_task',
3419
- todowrite: 'create_task',
3420
- task: 'create_task',
3421
- };
3422
- const aliased = aliasMap[lower];
3423
- if (aliased && this.tools.has(aliased))
3424
- return aliased;
3425
- if (this.tools.has(lower))
3426
- return lower;
3427
- return null;
3331
+ return this._toolRegistry.resolve(name);
3428
3332
  }
3429
3333
  /**
3430
3334
  * 2026-06-19: 注册 P2P 消息 listener, 把收到的消息存到 _inboxMessages 供 check_inbox 读.
@@ -3999,11 +3903,14 @@ export async function createAgentSession(config, forceNew) {
3999
3903
  const key = config.peerId;
4000
3904
  if (!forceNew && independentSessions.has(key)) {
4001
3905
  console.log(`[createAgentSession] 找到现有独立 session, key=${key}`);
4002
- return independentSessions.get(key);
3906
+ const existing = independentSessions.get(key);
3907
+ await existing.whenReady();
3908
+ return existing;
4003
3909
  }
4004
3910
  const session = new PiAgentSession(config);
4005
3911
  independentSessions.set(key, session);
4006
3912
  console.log(`[createAgentSession] 创建独立 session, key=${key}, DID=${incomingDid}`);
3913
+ await session.whenReady();
4007
3914
  return session;
4008
3915
  }
4009
3916
  // 如果指定了 forceNew 但没有 peerId,生成带时间戳的 key
@@ -4012,6 +3919,7 @@ export async function createAgentSession(config, forceNew) {
4012
3919
  const session = new PiAgentSession(config);
4013
3920
  independentSessions.set(key, session);
4014
3921
  console.log(`[createAgentSession] 创建强制新 session, key=${key}`);
3922
+ await session.whenReady();
4015
3923
  return session;
4016
3924
  }
4017
3925
  // 如果有新的 DID,强制重建 session
@@ -4031,11 +3939,13 @@ export async function createAgentSession(config, forceNew) {
4031
3939
  createdAt: Date.now()
4032
3940
  });
4033
3941
  }
3942
+ await sessionInstance.whenReady();
4034
3943
  return sessionInstance;
4035
3944
  }
4036
3945
  sessionInstance = new PiAgentSession(config);
4037
3946
  lastIdentityDid = config.identityDoc?.did || null;
4038
3947
  console.log(`[createAgentSession] 新建 session, DID=${lastIdentityDid}`);
3948
+ await sessionInstance.whenReady();
4039
3949
  return sessionInstance;
4040
3950
  }
4041
3951
  export function getAgentSession() {