@bolloon/bolloon-agent 0.2.1 → 0.2.3

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 (118) hide show
  1. package/bin/ipfs +0 -0
  2. package/dist/agents/judgment-protocol.js +479 -0
  3. package/dist/agents/parse-tool-call.js +304 -0
  4. package/dist/agents/pi-sdk.js +190 -267
  5. package/dist/agents/react-loop.js +86 -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 +2 -1
  93. package/dist/electron/server.js.map +1 -0
  94. package/dist/electron/tray.js.map +1 -0
  95. package/dist/electron/window.js.map +1 -0
  96. package/dist/electron-preload.js +29 -0
  97. package/dist/electron-preload.js.map +1 -0
  98. package/dist/electron.js +5 -0
  99. package/dist/electron.js.map +1 -0
  100. package/dist/llm/config-store.js +24 -8
  101. package/dist/llm/pi-ai.js +33 -19
  102. package/dist/workflows/collaboration.js +374 -0
  103. package/dist/workflows/index.js +54 -0
  104. package/package.json +18 -4
  105. package/.comm/README.md +0 -21
  106. package/.comm/default/2026-06-17T08-23-00-017Z-8a735de8.md +0 -7
  107. package/CLAUDE.md +0 -3
  108. package/build/icon.icns +0 -0
  109. package/build/icon.ico +0 -0
  110. package/build/icon.png +0 -0
  111. package/build/tray.png +0 -0
  112. package/build/trayTemplate.png +0 -0
  113. package/scripts/auto-evolve-oneshot.sh +0 -155
  114. package/scripts/auto-evolve-snapshot.sh +0 -136
  115. package/scripts/build-cli.js +0 -216
  116. package/scripts/detect-schema-changes.sh +0 -48
  117. package/scripts/lefthook-helper.sh +0 -69
  118. package/scripts/postinstall.js +0 -153
@@ -31,6 +31,9 @@ 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';
34
37
  const SHARED_SESSION_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions');
35
38
  const PERSONA_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
36
39
  export class PiSessionManager {
@@ -344,11 +347,17 @@ class PiAgentSession {
344
347
  socialHeartbeat = null;
345
348
  messageHistory = [];
346
349
  tools = new Map();
350
+ /** 2026-06-30: tool registry 模块 — 独立 alias resolve, 测试可消融. */
351
+ _toolRegistry = new ToolRegistry();
347
352
  skillRegistry = new SkillRegistry();
348
353
  /** M2.4: 缓存 tool 列表, registerTools() 之后不变, runReActLoop 多次循环复用 */
349
354
  cachedToolDefinitions = '';
350
355
  /** M2.4: 缓存 persona section */
351
356
  cachedPersonaSection = '';
357
+ /** 2026-06-30: 持久化层 — 默认走 ~/.bolloon/sessions/cache/, 测试可注入临时目录. */
358
+ _sessionStore;
359
+ /** 构造期间 fire-and-forget 任务的 promise — whenReady() 等它 */
360
+ _readyPromise = null;
352
361
  // 2026-06-16 修: 父要求把 ReAct loop 上限放大到 "几乎无限", 靠自动压缩上下文 + fail-safe 兜底
353
362
  // 默认 10000 — 正常任务永远跑不到, 但作为防 LLM 死循环 / 防 OOM 的最后一道闸
354
363
  // 旧默认 100 写死导致中等复杂度任务 (10-50 个 tool call + 多步反思) 会被误杀
@@ -444,6 +453,8 @@ class PiAgentSession {
444
453
  this.peerId = config.peerId || 'local';
445
454
  this.identity = config.identityDoc || this.createDefaultIdentity();
446
455
  this.minimaxAvailable = this.checkMinimax();
456
+ // 2026-06-30: 持久化层可注入 — 测试传 tmpDir, 业务用默认 ~/.bolloon/sessions/cache/
457
+ this._sessionStore = config.sessionStore ?? defaultSessionStore;
447
458
  this.constraintLayer = new ConstraintLayer();
448
459
  this.workflowEngine = new WorkflowEngine(this.constraintLayer);
449
460
  this.sessionManager = new PiSessionManager(this.identity.did, this.cwd);
@@ -459,38 +470,37 @@ class PiAgentSession {
459
470
  // 之前 messageHistory 是空的, 服务重启后 LLM 看到的是新对话
460
471
  // 现在 loadSessionKey 形如 "channel-xxx:default" 走 ~/.bolloon/sessions/cache/<key>.json
461
472
  if (config.loadSessionKey) {
462
- this.hydrateMessageHistory(config.loadSessionKey, config.loadSessionMaxMessages ?? 30);
473
+ this._readyPromise = this.hydrateMessageHistory(config.loadSessionKey, config.loadSessionMaxMessages ?? 30).catch((err) => {
474
+ // 失败静默, 但不让 whenReady 永久 hang
475
+ console.warn(`[PiAgent] hydrateMessageHistory failed: ${err.message?.slice(0, 100)}`);
476
+ });
463
477
  }
464
478
  }
465
479
  /**
466
- * M2.3: session JSON 加载历史, 转成 messageHistory 格式
480
+ * 2026-06-30: 让外部 await 构造期间的 hydrate 完成.
481
+ * 解决 fire-and-forget 让 messageHistory 不可预测的问题.
482
+ * 不传 loadSessionKey 时立即返回.
483
+ */
484
+ whenReady() {
485
+ return this._readyPromise ?? Promise.resolve();
486
+ }
487
+ /**
488
+ * M2.3 (2026-06-30 重构): 从 SessionStore 加载历史, 转成 messageHistory 格式
467
489
  * - 失败静默 (历史加载失败不应该阻塞 agent 启动)
468
490
  * - 限制 max 条数, 防止 context 爆
469
- * - user 消息 role=user, ai 消息 role=assistant
470
- * - 跳过 metadata 中含 error (错误消息会污染 LLM)
491
+ * - 跳过错误消息 ([AI 服务调用失败] / [错误:...]) 不污染 LLM
492
+ * - 委托 SessionStore 完成 IO, 保证 save/load 路径对称
493
+ *
494
+ * 历史格式兼容旧 schema ({type, content}) 和新 schema (PersistedMessage[])
471
495
  */
472
496
  async hydrateMessageHistory(sessionKey, maxMessages) {
473
497
  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) });
498
+ const loaded = await this._sessionStore.loadMessages(sessionKey);
499
+ if (!loaded) {
500
+ console.log(`[PiAgent] hydrate: 没有 ${sessionKey} 的历史`);
501
+ return;
493
502
  }
503
+ const hydrated = this._filterToMessage(loaded).slice(-maxMessages);
494
504
  if (hydrated.length > 0) {
495
505
  this.messageHistory = hydrated;
496
506
  console.log(`[PiAgent] 从 ${sessionKey} 回灌 ${hydrated.length} 条历史`);
@@ -500,6 +510,84 @@ class PiAgentSession {
500
510
  console.warn(`[PiAgent] hydrateMessageHistory 失败 (non-fatal): ${err.message?.slice(0, 100)}`);
501
511
  }
502
512
  }
513
+ /**
514
+ * 2026-06-30: 把当前 messageHistory 持久化到 SessionStore.
515
+ * 公开方法 — claude code / 外部 harness 在每次 prompt 完成后调一下,
516
+ * 即可获得"重启 / 跨进程接续"的语义.
517
+ */
518
+ async saveCurrentSession(key) {
519
+ const persisted = this.messageHistory.map((m) => ({
520
+ role: m.role,
521
+ content: m.content,
522
+ toolCall: m.toolCall,
523
+ toolResult: m.toolResult,
524
+ toolCallId: m.toolCallId,
525
+ timestamp: Date.now(),
526
+ source: 'pi-session',
527
+ }));
528
+ await this._sessionStore.saveMessages(key, persisted);
529
+ }
530
+ /**
531
+ * 2026-06-30: 从 disk 拉历史覆盖当前 messageHistory.
532
+ * 返回加载条数 — 失败或空则返回 0.
533
+ * 与 loadSessionKey (构造时读) 不同: 这个是 session 已建好后再读.
534
+ */
535
+ async resumeSession(key, maxMessages = 30) {
536
+ const before = this.messageHistory.length;
537
+ await this.hydrateMessageHistory(key, maxMessages);
538
+ return this.messageHistory.length - before;
539
+ }
540
+ /**
541
+ * 2026-06-30: 读历史不修改 messageHistory.
542
+ * 给 claude code / 测试做"先看一下历史"用 — 不破坏当前会话.
543
+ * 返回 Message[] 数组 (空数组表示无历史).
544
+ */
545
+ async peekSessionHistory(key, maxMessages = 30) {
546
+ try {
547
+ const loaded = await this._sessionStore.loadMessages(key);
548
+ if (!loaded)
549
+ return [];
550
+ return this._filterToMessage(loaded).slice(-maxMessages);
551
+ }
552
+ catch {
553
+ return [];
554
+ }
555
+ }
556
+ /** hydrateMessageHistory 用的过滤逻辑 — 提到外面复用 */
557
+ _filterToMessage(loaded) {
558
+ const hydrated = [];
559
+ const VALID_ROLES = new Set(['user', 'assistant', 'tool', 'system']);
560
+ for (const m of loaded) {
561
+ // role 必须合法 (拒绝旧 schema {type:'user'} 没 role 字段的)
562
+ if (!VALID_ROLES.has(m.role))
563
+ continue;
564
+ // 跳过污染消息
565
+ if (typeof m.content === 'string' && m.content.startsWith('[AI 服务调用失败]'))
566
+ continue;
567
+ if (typeof m.content === 'string' && m.content.startsWith('[错误:'))
568
+ continue;
569
+ // 注意: '!m.content' 会跳过 content='' 的 tool call 消息 (assistant role + toolCall 字段),
570
+ // 这种是合法的 (LLM 输出只有 tool call, 没有正文) — 必须保留.
571
+ // 这里只跳过"无内容 + 也没 tool call/tool result"的废消息.
572
+ if (!m.content && !m.toolCall && !m.toolResult)
573
+ continue;
574
+ // 跳过空 tool role (tool result 占位但没有任何内容)
575
+ if (m.role === 'tool' && !m.toolResult)
576
+ continue;
577
+ hydrated.push({
578
+ role: m.role,
579
+ content: m.content ?? '',
580
+ toolCall: m.toolCall,
581
+ toolResult: m.toolResult,
582
+ toolCallId: m.toolCallId,
583
+ });
584
+ }
585
+ return hydrated;
586
+ }
587
+ /** 暴露 store 给测试 / 高级集成用. */
588
+ get sessionStoreInstance() {
589
+ return this._sessionStore;
590
+ }
503
591
  /**
504
592
  * 从 SKILL.md 目录加载 skills 进 skillRegistry.
505
593
  *
@@ -1617,6 +1705,11 @@ class PiAgentSession {
1617
1705
  // - 失败结果不缓存 (避免缓存 transient 错误)
1618
1706
  // - 缓存容量 200 条, 超过就清空
1619
1707
  this.wrapToolsWithIdempotency();
1708
+ // 2026-06-30: 镜像到 ToolRegistry — resolveToolName 走 registry 的 alias 表.
1709
+ // 被 wrap 的 tool (idempotency cache) 也直接拿来用, registry 不重复 wrap.
1710
+ for (const [name, tool] of this.tools.entries()) {
1711
+ this._toolRegistry.register(tool);
1712
+ }
1620
1713
  }
1621
1714
  /** M3.3: 工具结果缓存 — 防止 loop 重试时副作用 (写文件 / 改代码) 执行多次 */
1622
1715
  idempotencyCache = new Map();
@@ -2202,12 +2295,16 @@ ${toolDefs}
2202
2295
  // 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
2203
2296
  const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream);
2204
2297
  const reply = (response.reply || '').trim();
2298
+ // 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
2299
+ const nativeToolCalls = response.toolCalls;
2205
2300
  // 2026-06-19 架构 fix: 不再因 [AI 服务调用失败] break
2206
2301
  // 旧逻辑: sentinel → aiFailed=true → break → 外层 retry 整个 loop (重置 history)
2207
2302
  // 新逻辑: 把错误当 tool_result push 进 history → 下一轮 LLM 看到错误能反思重试
2208
2303
  // 这是 dive-into 文档的"fail-open error recovery" — 错误进入 context, 不让 LLM 重复犯同样错
2209
2304
  if (reply.startsWith('[AI 服务调用失败]')) {
2210
2305
  console.log(`[PiAgent] 收到 AI 错误 sentinel, 推到 history 让 LLM 反思, 继续 loop`);
2306
+ console.log(`[sentinel DEBUG] 完整 reply: ${reply}`);
2307
+ console.log(`[sentinel DEBUG] 上一轮 messages 数量: ${Array.isArray(messages) ? messages.length : 'N/A'}, systemPrompt 长度: ${systemPrompt.length}`);
2211
2308
  aiFailureReason = reply.length > 200 ? reply.substring(0, 200) : reply;
2212
2309
  totalErrors++;
2213
2310
  consecutiveErrors++;
@@ -2234,9 +2331,10 @@ ${toolDefs}
2234
2331
  // 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
2235
2332
  // 现在: 先尝试解析 tool_call, 有就执行; 没有才检查是不是真正的 final gen
2236
2333
  const toolCall = this.parseToolCall(reply);
2237
- // 2026-06-19 修: 即便 reply <final gen>, 只要 parseToolCall 命中, 就执行工具
2238
- // (之前 isFinalResponse break 会先于 parseToolCall 触发, 思考块里有 final gen 字符串就误杀)
2334
+ // 2026-06-30 修: toolCall 分配稳定 id, 让后续 tool result 能引用同一个 id
2335
+ // OpenAI 协议要求 messages tool result 必须有对应的 tool_call_id, 否则 400
2239
2336
  if (toolCall) {
2337
+ toolCall.id = `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2240
2338
  this.messageHistory.push({
2241
2339
  role: 'assistant',
2242
2340
  content: reply,
@@ -2402,7 +2500,7 @@ ${toolDefs}
2402
2500
  catch (err) {
2403
2501
  console.warn('[PiAgent] reactHarness.postToolCall failed (non-fatal, allowing):', err);
2404
2502
  }
2405
- this.messageHistory.push({ role: 'tool', content: JSON.stringify(result), toolResult: result });
2503
+ 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
2504
  this.logToHarness(toolCall.name, toolCall.args, result);
2407
2505
  // 通知前端工具执行结果
2408
2506
  if (onStream) {
@@ -2522,6 +2620,13 @@ ${toolDefs}
2522
2620
  // LLM 不能提前 final_gen — harness 自动注入"请汇报剩余工具结果" hint 再 continue
2523
2621
  // 这是 dive-into 文档"step 9 stop condition check" 的具体化:
2524
2622
  // stop condition = (有工具结果未汇报) ? continue : break
2623
+ // 检查回复是否包含工具结果内容(避免无限循环)
2624
+ const hasToolResultContent = this.successfulToolResults.some(r => reply.includes(r.tool) || reply.includes(r.outputPreview.substring(0, 50)));
2625
+ // 如果回复包含工具结果内容,清除 successfulToolResults
2626
+ if (hasToolResultContent) {
2627
+ console.log(`[PiAgent] 回复包含工具结果内容, 清除 successfulToolResults (${this.successfulToolResults.length} 个)`);
2628
+ this.successfulToolResults = [];
2629
+ }
2525
2630
  if (this.successfulToolResults.length > 0 && iteration < this.MAX_REACT_ITERATIONS) {
2526
2631
  const unreported = this.successfulToolResults.length;
2527
2632
  console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报, push hint 让其继续`);
@@ -2680,18 +2785,45 @@ Workspace root folder: ${this.cwd}
2680
2785
  for (const m of recentMessages) {
2681
2786
  const role = m.role;
2682
2787
  let content = m.content;
2683
- // tool role: toolResult 序列化 ( buildContext 一样)
2788
+ // 2026-06-30 修: OpenAI 协议 tool role 必须带 tool_call_id, 否则 minimax (OpenAI-compatible) 400
2789
+ // bolloon 之前把所有 tool result 包成 "[工具结果] ..." 当 user/assistant role 发, minimax 严格校验失败
2790
+ // 现在: 保留 role='tool' + 加 tool_call_id 字段 (用 messageHistory 里自己生成的 id)
2684
2791
  if (role === 'tool') {
2685
2792
  const result = m.toolResult ? JSON.stringify(m.toolResult) : content;
2686
2793
  content = `[工具结果] ${result}`;
2794
+ // MiniMax 等 API 不支持 tool role, 转为 user role
2795
+ out.push({ role: 'user', content });
2796
+ continue;
2687
2797
  }
2688
2798
  // system role (router hint 等) 直接保留
2689
2799
  if (role === 'system') {
2690
2800
  out.push({ role: 'system', content });
2691
2801
  continue;
2692
2802
  }
2693
- // assistant / user / tool 直接转
2694
- if (role === 'user' || role === 'assistant' || role === 'tool') {
2803
+ // 2026-06-30 修: assistant 消息如果带 toolCall (bolloon 内部对象), emit OpenAI 协议的 tool_calls 数组
2804
+ // minimax 严格要求 assistant 消息含 tool_calls 字段, 后续 tool result 才能引用 tool_call_id
2805
+ if (role === 'assistant') {
2806
+ const tc = m.toolCall;
2807
+ if (tc && tc.id) {
2808
+ out.push({
2809
+ role: 'assistant',
2810
+ content: content || '',
2811
+ tool_calls: [{
2812
+ id: tc.id,
2813
+ type: 'function',
2814
+ function: {
2815
+ name: tc.name,
2816
+ arguments: JSON.stringify(tc.args || {}),
2817
+ },
2818
+ }],
2819
+ });
2820
+ }
2821
+ else {
2822
+ out.push({ role, content });
2823
+ }
2824
+ continue;
2825
+ }
2826
+ if (role === 'user') {
2695
2827
  out.push({ role, content });
2696
2828
  }
2697
2829
  }
@@ -2778,7 +2910,8 @@ Workspace root folder: ${this.cwd}
2778
2910
  // M3.5 (2026-06-17): 传 messages 数组 (如果 contextOrMessages 是数组) 或字符串
2779
2911
  // 数组版让 LLM 看到结构化的 user/assistant/tool role, 而不是把 history 拼成单字符串
2780
2912
  const response = await llm.chat(contextOrMessages, systemPrompt, signal);
2781
- return { reply: response.reply || '' };
2913
+ // 2026-06-30: 透传 toolCalls (OpenAI 协议 native) 给上层, 让 assistant message 能 emit 真 id
2914
+ return { reply: response.reply || '', toolCalls: response.toolCalls };
2782
2915
  }
2783
2916
  catch (err) {
2784
2917
  // 用户主动 abort: 不重试, 立即抛
@@ -2904,206 +3037,22 @@ Workspace root folder: ${this.cwd}
2904
3037
  }
2905
3038
  }
2906
3039
  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>');
3040
+ // 2026-06-30: 抽到 ./parse-tool-call.ts 作为纯函数 这里只构建 ctx 并调用
3041
+ return isFinalResponseImpl(content, this._parseCtx());
2912
3042
  }
2913
3043
  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;
3044
+ // 抽取实现已挪到 ./parse-tool-call.ts (纯函数, 易测)
3045
+ return extractFinalAnswerImpl(content);
3046
+ }
3047
+ _parseCtx() {
3048
+ return {
3049
+ tools: new Set(Array.from(this.tools.keys())),
3050
+ resolveAlias: (name) => this.resolveToolName(name),
3051
+ };
2935
3052
  }
2936
3053
  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
- if (m) {
2949
- const name = m[1];
2950
- let args = {};
2951
- try {
2952
- const parsed = JSON.parse(m[2]);
2953
- if (parsed && typeof parsed === 'object') {
2954
- args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
2955
- }
2956
- }
2957
- catch { /* 解析失败保持 args={} */ }
2958
- const resolved = this.resolveToolName(name);
2959
- if (resolved) {
2960
- return { name: resolved, args };
2961
- }
2962
- }
2963
- }
2964
- // 2026-06-19 修: fallback — LLM 有时输出 <tool_name>shell_exec</tool_name> 这种伪 XML 标签
2965
- // 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配, 但 name="tool_name" 不在 tools 里
2966
- // 这里 detect 内容是否像 tool_call (有 <command>/<args>/<path> 子标签) 然后按 <command> 第一词推断工具
2967
- const xmlTagMatch = content.match(/<(\w+)>([\s\S]*?)<\/\1>/);
2968
- if (xmlTagMatch) {
2969
- const outerTag = xmlTagMatch[1];
2970
- const inner = xmlTagMatch[2];
2971
- // 如果外层标签不在 tools 里但有 <command>/<args> 子标签, 尝试按内容推断
2972
- if (!this.resolveToolName(outerTag)) {
2973
- const cmdMatch = inner.match(/<command>(\w+)<\/command>/);
2974
- if (cmdMatch) {
2975
- const cmd = cmdMatch[1];
2976
- // 推测工具: git/npx/npm/tsx → shell_exec; cat/head/tail → read_document
2977
- if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmd)) {
2978
- const args = {};
2979
- const argsMatch = inner.match(/<args>([\s\S]*?)<\/args>/);
2980
- if (argsMatch)
2981
- args.args = argsMatch[1].trim();
2982
- const cmdsMatch = inner.match(/<command>([\s\S]*?)<\/command>/);
2983
- if (cmdsMatch)
2984
- args.command = cmdsMatch[1].trim();
2985
- return { name: 'shell_exec', args };
2986
- }
2987
- }
2988
- }
2989
- }
2990
- // 2026-06-19 修: <tools:call name="X">...</tools:call> 嵌套格式
2991
- // LLM 实际产出变体: <tools:call name="shell_exec"><tools:call name="command">git</tools:call>...</tools:call>
2992
- const toolsCallMatch = content.match(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/);
2993
- if (toolsCallMatch) {
2994
- const name = toolsCallMatch[1];
2995
- const inner = toolsCallMatch[2];
2996
- const args = {};
2997
- const argTags = inner.matchAll(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/g);
2998
- for (const m of argTags) {
2999
- args[m[1]] = m[2].trim();
3000
- }
3001
- const resolved = this.resolveToolName(name);
3002
- if (resolved) {
3003
- return { name: resolved, args };
3004
- }
3005
- // 兜底: 如果外层 tool name 不在 tools, 但内层有 command, 推断 shell_exec
3006
- if (args.command) {
3007
- const cmdFirst = args.command.split(/\s+/)[0];
3008
- if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmdFirst)) {
3009
- return { name: 'shell_exec', args };
3010
- }
3011
- }
3012
- }
3013
- const patterns = [
3014
- // 2026-06-19 修: minimax/Hermes 自闭合 XML 格式 <invoke name="X">...</invoke>
3015
- // 实际 LLM 习惯: <invoke name="shell_exec"><command>sed</command><args>["-n","1060,1095p"]</args></invoke>
3016
- // 必须放在最前 — 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配到外层 <invoke> 但 name 是 "invoke" 而不是 "shell_exec"
3017
- new RegExp('<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>'),
3018
- // 2026-06-19 修: <function_calls> 包裹
3019
- new RegExp('<function_calls>[\\s\\S]*?<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>[\\s\\S]*?</function_calls>'),
3020
- /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
3021
- /使用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
3022
- /tool[_\w]*[::]\s*(\w+)\s*\(([^)]*)\)/i,
3023
- /(\w+)\s*\(\s*([^)]*)\s*\)/,
3024
- // 兼容 LLM 输出的对象字面量格式: {tool => "get_identity", args => {...}}
3025
- /\{\s*tool\s*=>\s*["'](\w+)["']\s*(?:,\s*args\s*=>\s*(\{[\s\S]*?\}))?\s*\}/,
3026
- // 兼容: tool => "get_identity" (无 args 包裹)
3027
- /\btool\s*=>\s*["'](\w+)["']/,
3028
- // 兼容: [TOOL_CALL] 块内 JSON 形式 {"name": "x", "args": {...}}
3029
- /\[TOOL_CALL\][\s\S]*?\{\s*"name"\s*:\s*"(\w+)"\s*,\s*"args"\s*:\s*(\{[\s\S]*?\})/i,
3030
- // 2026-06-19: 兼容 LLM 输出 "tool_name {json_args}" 形式 (单行, 无 name 字段)
3031
- // 实际输出: write_file {"path":"docs/test.md","content":"..."}
3032
- // 只在行首/新行匹配, 避免误匹配普通文本里的 "foo {...}"
3033
- /(?:^|\n)(\w+)\s+(\{[\s\S]*?\})(?=\n|$)/,
3034
- // 2026-06-15 修: 兼容 LLM 输出的 XML 格式 <tool_name>...<arg>value</arg>...</tool_name>
3035
- // 实际 LLM 习惯: <shell_exec>\n<command>ls</command>\n<args>["-la", "..."]</args>\n</shell_exec>
3036
- /<(\w+)>([\s\S]*?)<\/\1>/,
3037
- ];
3038
- for (const pattern of patterns) {
3039
- const match = content.match(pattern);
3040
- if (match) {
3041
- const name = match[1];
3042
- let args = {};
3043
- const rawArgs = match[2] || '';
3044
- if (rawArgs && rawArgs.trim().startsWith('{')) {
3045
- // JSON 形式, 尝试解析
3046
- try {
3047
- const parsed = JSON.parse(rawArgs);
3048
- if (parsed && typeof parsed === 'object') {
3049
- args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
3050
- }
3051
- }
3052
- catch {
3053
- // 解析失败就当字符串处理
3054
- const argPairs = rawArgs.split(',').map(s => s.trim()).filter(Boolean);
3055
- for (const pair of argPairs) {
3056
- const [key, ...valueParts] = pair.split(':').map(s => s.trim().replace(/['"]/g, ''));
3057
- if (key)
3058
- args[key] = valueParts.join(':') || '';
3059
- }
3060
- }
3061
- }
3062
- else if (rawArgs && /<\w+>[\s\S]*<\/\w+>/.test(rawArgs)) {
3063
- // 2026-06-15 修: XML 格式, 解析内嵌子标签 <argname>value</argname>
3064
- // 例: <command>ls</command>\n<args>["-la","~/.bolloon/skills"]</args>
3065
- // 2026-06-19 修: 也支持 <parameter name="X">value</parameter> 形式 (OpenAI Hermes function_call)
3066
- const paramRe = /<parameter\s+name=["'](\w+)["']>([\s\S]*?)<\/parameter>/g;
3067
- let pMatch;
3068
- while ((pMatch = paramRe.exec(rawArgs)) !== null) {
3069
- const argName = pMatch[1];
3070
- const argValue = pMatch[2].trim();
3071
- if (argName && argValue) {
3072
- args[argName] = argValue;
3073
- }
3074
- }
3075
- // 如果没匹配到 <parameter>, 用普通 XML 子标签
3076
- if (Object.keys(args).length === 0) {
3077
- const xmlArgPattern = /<(\w+)>([\s\S]*?)<\/\1>/g;
3078
- let xmlMatch;
3079
- while ((xmlMatch = xmlArgPattern.exec(rawArgs)) !== null) {
3080
- const argName = xmlMatch[1];
3081
- const argValue = xmlMatch[2].trim();
3082
- if (argName && argValue) {
3083
- args[argName] = argValue;
3084
- }
3085
- }
3086
- }
3087
- }
3088
- else if (rawArgs) {
3089
- // 形参串, 形如 key: value, key2: value2
3090
- const argPairs = rawArgs.split(',').map(s => s.trim()).filter(Boolean);
3091
- for (const pair of argPairs) {
3092
- const [key, ...valueParts] = pair.split(':').map(s => s.trim().replace(/['"]/g, ''));
3093
- if (key)
3094
- args[key] = valueParts.join(':') || '';
3095
- }
3096
- }
3097
- if (this.tools.has(name)) {
3098
- return { name, args };
3099
- }
3100
- const resolved = this.resolveToolName(name);
3101
- if (resolved) {
3102
- return { name: resolved, args };
3103
- }
3104
- }
3105
- }
3106
- return null;
3054
+ // 2026-06-30: 抽到 ./parse-tool-call.ts 作为纯函数这里只构建 ctx 并调用
3055
+ return parseToolCallImpl(content, this._parseCtx());
3107
3056
  }
3108
3057
  // [debug-2026-06-19] 临时: 打印 parseToolCall 输入和返回
3109
3058
  _dbgParseToolCall(content) {
@@ -3365,47 +3314,15 @@ Workspace root folder: ${this.cwd}
3365
3314
  * bolloon 注册的是 read_document / edit_file / write_file / shell_exec / list_files
3366
3315
  * 返回 this.tools 里的标准名, 或 null 表示未识别
3367
3316
  */
3317
+ /**
3318
+ * 把 LLM 给的工具名 (可能大小写不一, 或者 Claude Code 风格的别名) 解析为
3319
+ * bolloon 注册的标准工具名.
3320
+ *
3321
+ * 2026-06-30: 委托给 ToolRegistry 模块 — alias 表在 tool-registry.ts 统一维护,
3322
+ * 这里只做 thin wrapper 保留 backward compat (private API 但其它地方可能用).
3323
+ */
3368
3324
  resolveToolName(name) {
3369
- if (this.tools.has(name))
3370
- return name;
3371
- const lower = name.toLowerCase();
3372
- const aliasMap = {
3373
- // Claude Code 风格 → bolloon 工具
3374
- read: 'read_file',
3375
- edit: 'edit_file',
3376
- write: 'write_file',
3377
- rm: 'delete_file',
3378
- mv: 'move_file',
3379
- bash: 'shell_exec',
3380
- shell: 'shell_exec',
3381
- sh: 'shell_exec',
3382
- // grep / glob 现在是独立工具
3383
- cat: 'read_file',
3384
- // 测试/编译 → 独立工具
3385
- test: 'vitest_run',
3386
- vitest: 'vitest_run',
3387
- typecheck: 'tsc_check',
3388
- tsc: 'tsc_check',
3389
- // git 操作
3390
- log: 'git_log',
3391
- show: 'git_show',
3392
- diff: 'git_diff',
3393
- commit: 'git_commit',
3394
- push: 'git_push',
3395
- branch: 'git_branch',
3396
- checkout: 'git_branch',
3397
- stash: 'git_stash',
3398
- // 任务管理 (todo)
3399
- todo_write: 'create_task',
3400
- todowrite: 'create_task',
3401
- task: 'create_task',
3402
- };
3403
- const aliased = aliasMap[lower];
3404
- if (aliased && this.tools.has(aliased))
3405
- return aliased;
3406
- if (this.tools.has(lower))
3407
- return lower;
3408
- return null;
3325
+ return this._toolRegistry.resolve(name);
3409
3326
  }
3410
3327
  /**
3411
3328
  * 2026-06-19: 注册 P2P 消息 listener, 把收到的消息存到 _inboxMessages 供 check_inbox 读.
@@ -3980,11 +3897,14 @@ export async function createAgentSession(config, forceNew) {
3980
3897
  const key = config.peerId;
3981
3898
  if (!forceNew && independentSessions.has(key)) {
3982
3899
  console.log(`[createAgentSession] 找到现有独立 session, key=${key}`);
3983
- return independentSessions.get(key);
3900
+ const existing = independentSessions.get(key);
3901
+ await existing.whenReady();
3902
+ return existing;
3984
3903
  }
3985
3904
  const session = new PiAgentSession(config);
3986
3905
  independentSessions.set(key, session);
3987
3906
  console.log(`[createAgentSession] 创建独立 session, key=${key}, DID=${incomingDid}`);
3907
+ await session.whenReady();
3988
3908
  return session;
3989
3909
  }
3990
3910
  // 如果指定了 forceNew 但没有 peerId,生成带时间戳的 key
@@ -3993,6 +3913,7 @@ export async function createAgentSession(config, forceNew) {
3993
3913
  const session = new PiAgentSession(config);
3994
3914
  independentSessions.set(key, session);
3995
3915
  console.log(`[createAgentSession] 创建强制新 session, key=${key}`);
3916
+ await session.whenReady();
3996
3917
  return session;
3997
3918
  }
3998
3919
  // 如果有新的 DID,强制重建 session
@@ -4012,11 +3933,13 @@ export async function createAgentSession(config, forceNew) {
4012
3933
  createdAt: Date.now()
4013
3934
  });
4014
3935
  }
3936
+ await sessionInstance.whenReady();
4015
3937
  return sessionInstance;
4016
3938
  }
4017
3939
  sessionInstance = new PiAgentSession(config);
4018
3940
  lastIdentityDid = config.identityDoc?.did || null;
4019
3941
  console.log(`[createAgentSession] 新建 session, DID=${lastIdentityDid}`);
3942
+ await sessionInstance.whenReady();
4020
3943
  return sessionInstance;
4021
3944
  }
4022
3945
  export function getAgentSession() {