@bolloon/bolloon-agent 0.1.40 → 0.1.42

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 (38) hide show
  1. package/.comm/README.md +21 -0
  2. package/.comm/default/2026-06-17T08-23-00-017Z-8a735de8.md +7 -0
  3. package/CLAUDE.md +3 -0
  4. package/dist/agents/intent-classifier.js +99 -0
  5. package/dist/agents/pi-sdk.js +1364 -58
  6. package/dist/agents/pre-tool-validator.js +2 -1
  7. package/dist/agents/shell-guard.js +9 -3
  8. package/dist/agents/shell-tool.js +28 -13
  9. package/dist/agents/task-state.js +224 -0
  10. package/dist/agents/workflow-pivot-loop.js +30 -0
  11. package/dist/bollharness-integration/gate-state-machine.js +13 -0
  12. package/dist/bollharness-integration/integration.js +71 -0
  13. package/dist/bollharness-integration/skill-adapter.js +52 -127
  14. package/dist/bootstrap/context-hierarchy.js +6 -4
  15. package/dist/cli/interface.js +28 -0
  16. package/dist/documents/reader.js +14 -0
  17. package/dist/git-transport/chat-render.js +155 -0
  18. package/dist/git-transport/chat-repo.js +370 -0
  19. package/dist/git-transport/chat-types.js +23 -0
  20. package/dist/git-transport/chat-watch.js +102 -0
  21. package/dist/index.js +471 -25
  22. package/dist/llm/pi-ai.js +103 -27
  23. package/dist/network/local-inbox-bus.js +73 -0
  24. package/dist/network/p2p-direct.js +3 -4
  25. package/dist/pi-ecosystem-judgment/adaptive-scan.js +6 -2
  26. package/dist/pi-ecosystem-judgment/causal-judge.js +3 -3
  27. package/dist/pi-ecosystem-judgment/index.js +1 -1
  28. package/dist/security/tool-gate.js +11 -0
  29. package/dist/web/client.js +2876 -3770
  30. package/dist/web/components/p2p/index.js +226 -264
  31. package/dist/web/server.js +83 -15
  32. package/dist/web/ui/message-renderer.js +326 -442
  33. package/dist/web/ui/step-timeline.js +255 -351
  34. package/package.json +2 -2
  35. package/scripts/lefthook-helper.sh +69 -0
  36. package/dist/web/components/p2p/P2PModal.js +0 -188
  37. package/dist/web/components/p2p/p2p-modal.js +0 -657
  38. package/dist/web/components/p2p/p2p-tools.js +0 -248
@@ -15,7 +15,7 @@ import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtim
15
15
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
16
16
  import { p2pDocumentTools, initDocumentReceiver } from './p2p-document-tools.js';
17
17
  import { shellExec } from './shell-tool.js';
18
- import { getBranchPrefix, getCooldownMs } from './shell-guard.js';
18
+ import { getBranchPrefix, getCooldownMs, checkWritePath } from './shell-guard.js';
19
19
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
20
20
  import { getGlobalSharedContext } from '../social/global-shared-context.js';
21
21
  import { Session, SkillRegistry, saveSession, loadSession } from '@bolloon/constraint-runtime';
@@ -345,6 +345,10 @@ class PiAgentSession {
345
345
  messageHistory = [];
346
346
  tools = new Map();
347
347
  skillRegistry = new SkillRegistry();
348
+ /** M2.4: 缓存 tool 列表, registerTools() 之后不变, runReActLoop 多次循环复用 */
349
+ cachedToolDefinitions = '';
350
+ /** M2.4: 缓存 persona section */
351
+ cachedPersonaSection = '';
348
352
  // 2026-06-16 修: 父要求把 ReAct loop 上限放大到 "几乎无限", 靠自动压缩上下文 + fail-safe 兜底
349
353
  // 默认 10000 — 正常任务永远跑不到, 但作为防 LLM 死循环 / 防 OOM 的最后一道闸
350
354
  // 旧默认 100 写死导致中等复杂度任务 (10-50 个 tool call + 多步反思) 会被误杀
@@ -356,6 +360,10 @@ class PiAgentSession {
356
360
  /** 2026-06-16 新增: 累计错误总数兜底 (不管是否同工具, 累计 N 次就强制退出)
357
361
  * 防 LLM 轮换工具名绕开 MAX_SAME_TOOL_FAILURES 的死循环攻击 */
358
362
  MAX_TOTAL_ERRORS = 20;
363
+ /** 2026-06-19: 记录 loop 内成功执行的工具结果, 失败退出时汇总给用户 */
364
+ successfulToolResults = [];
365
+ /** 2026-06-19: Agent Mesh 通信 — 本地 + 远端 inbox 缓存, 给 check_inbox 工具读 */
366
+ _inboxMessages = [];
359
367
  /** 2026-06-16 新增: loop 内自动压缩触发阈值 (相对 60K 阈值的比例) */
360
368
  LOOP_COMPACT_RATIO = 0.8;
361
369
  /** P1: max output token 升级重试 (LLM 截断时重试, 最多 3 次) */
@@ -378,6 +386,9 @@ class PiAgentSession {
378
386
  */
379
387
  judgmentGateAddition = '';
380
388
  judgmentGateUsedIds = [];
389
+ // 2026-06-18: 来自 web server markedPrompt 外的 contextHint (channel/judgment/distill/remote channels),
390
+ // 拼到 systemPrompt 末尾, 别再混进 user message
391
+ contextHintAddition = '';
381
392
  /**
382
393
  * 当前 onStream 引用 + abort signal (computeJudgmentGate 需要 onStream 广播 phase)
383
394
  * 每次 prompt / promptStream / promptWithPivotLoop 入口设置, 用完即清
@@ -390,6 +401,9 @@ class PiAgentSession {
390
401
  promptStartTime = 0;
391
402
  /** 当前 channel id (由 getAgentForChannel / prompt 4 参注入, 供 hook / log 使用) */
392
403
  currentChannelId = '';
404
+ /** M2.2 (2026-06-17): 当前轮的用户请求 intent, runReActLoop 拼 systemPrompt 时会读这个 */
405
+ currentIntent = 'chitchat';
406
+ currentIntentHint = '';
393
407
  /**
394
408
  * 算 judgment 注入门: 失败静默, 不阻塞主对话
395
409
  * 期间通过 currentOnStream 广播 phase 事件, 前端可显示 "正在检索判断力..." 状态
@@ -441,6 +455,50 @@ class PiAgentSession {
441
455
  this.registerTools();
442
456
  this.loadSkills(config.skillsPaths);
443
457
  this.initHarness();
458
+ // M2.3 (2026-06-17): 重启后 LLM 恢复记忆 — 从 session JSON 加载历史到 messageHistory
459
+ // 之前 messageHistory 是空的, 服务重启后 LLM 看到的是新对话
460
+ // 现在 loadSessionKey 形如 "channel-xxx:default" 走 ~/.bolloon/sessions/cache/<key>.json
461
+ if (config.loadSessionKey) {
462
+ this.hydrateMessageHistory(config.loadSessionKey, config.loadSessionMaxMessages ?? 30);
463
+ }
464
+ }
465
+ /**
466
+ * M2.3: 从 session JSON 加载历史, 转成 messageHistory 格式
467
+ * - 失败静默 (历史加载失败不应该阻塞 agent 启动)
468
+ * - 限制 max 条数, 防止 context 爆
469
+ * - user 消息 role=user, ai 消息 role=assistant
470
+ * - 跳过 metadata 中含 error 的 (错误消息会污染 LLM)
471
+ */
472
+ async hydrateMessageHistory(sessionKey, maxMessages) {
473
+ 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) });
493
+ }
494
+ if (hydrated.length > 0) {
495
+ this.messageHistory = hydrated;
496
+ console.log(`[PiAgent] 从 ${sessionKey} 回灌 ${hydrated.length} 条历史`);
497
+ }
498
+ }
499
+ catch (err) {
500
+ console.warn(`[PiAgent] hydrateMessageHistory 失败 (non-fatal): ${err.message?.slice(0, 100)}`);
501
+ }
444
502
  }
445
503
  /**
446
504
  * 从 SKILL.md 目录加载 skills 进 skillRegistry.
@@ -621,6 +679,162 @@ class PiAgentSession {
621
679
  }
622
680
  }
623
681
  });
682
+ // 2026-06-19: 本地 + 远端智能体通信工具集 (Agent Mesh 通信层)
683
+ // 解决"另一个智能体发消息"的能力缺口:
684
+ // - 本地 channel: 走 PiSessionManager.getAllChannels / addMessage / getChannelMessages
685
+ // - 远端 P2P: 走 p2pNetwork.sendMessage
686
+ this._inboxMessages = [];
687
+ this._setupInboxListener();
688
+ // list_local_channels: 列出本 session 内所有 channel
689
+ this.tools.set('list_local_channels', {
690
+ name: 'list_local_channels',
691
+ description: '列出当前 PiAgentSession 注册的所有 channel. 每个 channel 是 bolloon 内的智能体会话, 可含 messages[] / peerDid(对端 DID) / peerName. 供 send_to_channel 选 channel_id.',
692
+ parameters: {},
693
+ execute: async () => {
694
+ const channels = this.sessionManager.getAllChannels();
695
+ if (channels.length === 0) {
696
+ return { success: true, output: '📭 当前 session 没有注册任何 channel (用 send_to_channel 留空 channel_id 会自动创建一个)' };
697
+ }
698
+ const lines = channels.map((ch, i) => {
699
+ const peer = ch.peerDid ? ` peer=${ch.peerDid.substring(0, 20)}` : (ch.peerName ? ` peer="${ch.peerName}"` : '');
700
+ const msgCount = ch.messages?.length || 0;
701
+ return ` ${i + 1}. ${ch.id} name="${ch.name}" msgs=${msgCount}${peer}`;
702
+ });
703
+ return { success: true, output: `📬 当前 session 有 ${channels.length} 个 channel:\n${lines.join('\n')}` };
704
+ }
705
+ });
706
+ // send_to_channel: 把消息发到指定 channel (本地 + 如果 channel 有关联 peerDid 也走 P2P 转发)
707
+ this.tools.set('send_to_channel', {
708
+ name: 'send_to_channel',
709
+ description: '发送消息到指定 channel. channel_id 留空会自动创建一个. 如果 channel 已关联 peerDid, 会同时通过 P2P 转发到对端 agent.',
710
+ parameters: { channel_id: '目标 channel id (留空自动创建)', message: '消息内容 (必填)', peer_did: '可选 创建新 channel 时绑定的对端 DID (e.g. did:key:...)' },
711
+ execute: async (args) => {
712
+ const message = String(args.message || '').trim();
713
+ if (!message)
714
+ return { success: false, error: 'message 必填' };
715
+ let channelId = String(args.channel_id || '').trim();
716
+ const peerDid = args.peer_did ? String(args.peer_did) : undefined;
717
+ if (!channelId) {
718
+ // 自动创建 channel
719
+ const ch = await this.sessionManager.getOrCreatePeerChannel(peerDid || 'auto-created', 'auto-created');
720
+ channelId = ch.id;
721
+ }
722
+ // 用 AgentSession 接口方法发消息 (它会自动设置 type/agentId/timestamp)
723
+ await this.sendSocialMessage(channelId, message);
724
+ // 如果 channel 关联了 peer, 走 P2P 转发
725
+ const ch = this.sessionManager.getAllChannels().find(c => c.id === channelId);
726
+ if (ch?.peerDid) {
727
+ try {
728
+ const peerId = ch.peerDid.replace(/^did:key:/, '').replace(/^did:pi:/, '');
729
+ await p2pNetwork.sendMessage(peerId, 'channel-message', JSON.stringify({ channelId, content: message, from: this.identity.did, timestamp: new Date().toISOString() }));
730
+ return { success: true, output: `📨 消息已存到 channel ${channelId} + P2P 转发到 ${ch.peerDid.substring(0, 30)}...` };
731
+ }
732
+ catch (e) {
733
+ return { success: true, output: `📨 消息已存到 channel ${channelId} (P2P 转发失败: ${String(e).slice(0, 100)})` };
734
+ }
735
+ }
736
+ return { success: true, output: `📨 消息已存到 channel ${channelId}` };
737
+ }
738
+ });
739
+ // check_channel_inbox: 读指定 channel 的所有 messages
740
+ this.tools.set('check_channel_inbox', {
741
+ name: 'check_channel_inbox',
742
+ description: '读取指定 channel 的所有 messages, 按时间排序. channel_id 必填, 来自 list_local_channels.',
743
+ parameters: { channel_id: '目标 channel id (必填)', max: '可选, 最多返回 N 条 (默认 50)' },
744
+ execute: async (args) => {
745
+ const channelId = String(args.channel_id || '').trim();
746
+ if (!channelId)
747
+ return { success: false, error: 'channel_id 必填' };
748
+ const max = Number(args.max) || 50;
749
+ const messages = await this.sessionManager.getChannelMessages(channelId);
750
+ const channel = this.sessionManager.getAllChannels().find(c => c.id === channelId);
751
+ const channelName = channel?.name || channelId;
752
+ if (messages.length === 0) {
753
+ return { success: true, output: `📭 channel "${channelName}" (${channelId}) 空, 0 条消息` };
754
+ }
755
+ const slice = messages.slice(-max);
756
+ const lines = slice.map((m, i) => {
757
+ const from = m.sender || m.from || m.fromDid || m.agentId || '?';
758
+ const ts = m.timestamp || m.createdAt || '?';
759
+ const content = (m.content || m.text || '').substring(0, 200);
760
+ return ` ${i + 1}. [${m.type || 'text'}] from=${from} time=${ts}\n ${content}`;
761
+ });
762
+ return { success: true, output: `📬 channel "${channelName}" (${channelId}) 有 ${slice.length} 条消息 (共 ${messages.length}):\n${lines.join('\n')}` };
763
+ }
764
+ });
765
+ // check_inbox: 兼容旧名, 实际走 check_channel_inbox
766
+ this.tools.set('check_inbox', {
767
+ name: 'check_inbox',
768
+ description: "兼容旧名: 默认读第一个 channel 的 messages. 推荐用 list_local_channels + check_channel_inbox(channel_id)",
769
+ parameters: { max: '可选, 最多返回 N 条 (默认 50)' },
770
+ execute: async (args) => {
771
+ const channels = this.sessionManager.getAllChannels();
772
+ if (channels.length === 0) {
773
+ return { success: true, output: '📭 当前 session 没有 channel, 0 条消息' };
774
+ }
775
+ const first = channels[0];
776
+ // 直接调 check_channel_inbox 复用逻辑
777
+ return await this.tools.get('check_channel_inbox').execute({ channel_id: first.id, max: String(args.max || 50) });
778
+ }
779
+ });
780
+ // send_to_peer: 发送结构化消息到指定 P2P 节点
781
+ this.tools.set('send_to_peer', {
782
+ name: 'send_to_peer',
783
+ description: '发送结构化消息到指定 P2P 节点 (远端 bolloon 实例). 对方会通过远端 channel 收到.',
784
+ parameters: { peer_id: '目标 P2P 节点 publicKey (用 list_peers 查)', message: '消息内容 (任意字符串)', type: '可选, 消息类型标签 (默认 agent-message)' },
785
+ execute: async (args) => {
786
+ const peerId = String(args.peer_id || '').trim();
787
+ const msg = String(args.message || '').trim();
788
+ const type = String(args.type || 'agent-message').trim();
789
+ if (!peerId)
790
+ return { success: false, error: 'peer_id 必填' };
791
+ if (!msg)
792
+ return { success: false, error: 'message 必填' };
793
+ try {
794
+ await p2pNetwork.sendMessage(peerId, type, msg);
795
+ return { success: true, output: `✅ 消息已发送到 ${peerId.substring(0, 16)}...` };
796
+ }
797
+ catch (e) {
798
+ return { success: false, error: `发送失败: ${String(e)}` };
799
+ }
800
+ }
801
+ });
802
+ // p2p_broadcast: 广播给所有 P2P 节点
803
+ this.tools.set('p2p_broadcast', {
804
+ name: 'p2p_broadcast',
805
+ description: '广播消息到所有连接的 P2P 节点',
806
+ parameters: { message: '消息内容', type: '可选, 消息类型标签 (默认 agent-broadcast)' },
807
+ execute: async (args) => {
808
+ const msg = String(args.message || '').trim();
809
+ const type = String(args.type || 'agent-broadcast').trim();
810
+ if (!msg)
811
+ return { success: false, error: 'message 必填' };
812
+ try {
813
+ await p2pNetwork.broadcast(type, msg);
814
+ return { success: true, output: `📡 已广播 type=${type}` };
815
+ }
816
+ catch (e) {
817
+ return { success: false, error: `广播失败: ${String(e)}` };
818
+ }
819
+ }
820
+ });
821
+ // agent_call: RPC 风格远端 agent 任务调用
822
+ this.tools.set('agent_call', {
823
+ name: 'agent_call',
824
+ description: 'RPC: 让远端 P2P agent 跑一个任务, 等待结果返回. 远端 agent 会基于 task 描述自主完成, 完成后回复. (RPC 结果回收机制待实现)',
825
+ parameters: { peer_id: '目标 P2P 节点', task: '任务描述 (远端 agent 收到的 prompt)', timeoutMs: '可选 超时 (ms, 默认 30000)' },
826
+ execute: async (args) => {
827
+ const peerId = String(args.peer_id || '').trim();
828
+ const task = String(args.task || '').trim();
829
+ if (!peerId)
830
+ return { success: false, error: 'peer_id 必填' };
831
+ if (!task)
832
+ return { success: false, error: 'task 必填' };
833
+ const requestId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
834
+ await p2pNetwork.sendMessage(peerId, 'agent-call', JSON.stringify({ requestId, task, from: this.identity?.did }));
835
+ return { success: true, output: `📞 RPC 任务已发送给 ${peerId.substring(0, 16)}...\n requestId=${requestId}` };
836
+ }
837
+ });
624
838
  this.tools.set('get_identity', {
625
839
  name: 'get_identity',
626
840
  description: '获取当前智能体身份信息',
@@ -743,7 +957,7 @@ class PiAgentSession {
743
957
  // 沙箱 cwd: .bolloon-shell-sandbox/
744
958
  this.tools.set('shell_exec', {
745
959
  name: 'shell_exec',
746
- description: '在沙箱里跑 shell 命令. 仅支持白名单内命令: git, npm, npx, tsx, tsc, vitest, cat, head, tail, ls, wc, echo, pwd, date, mkdir, touch. 禁止管道/重定向/rm -rf/sudo. 命中护栏黑名单会被拒.',
960
+ description: ' cwd (process.cwd(), 即 bolloon 当前工作目录) 跑 shell 命令. 仅支持白名单内命令: git, npm, npx, tsx, tsc, vitest, cat, head, tail, ls, wc, echo, pwd, date, mkdir, touch. 禁止管道/重定向/rm -rf/sudo. 命中护栏黑名单会被拒. 注意: cwd 是真正的 git repo 工作目录, git add/commit/push 等命令会作用于本仓库, 不是 sandbox.',
747
961
  parameters: { command: '可执行文件 (必填, 必须在白名单)', args: '参数数组, 逗号分隔', timeoutMs: '超时毫秒, 默认 30000' },
748
962
  execute: async (args) => {
749
963
  const cmd = String(args.command || '').trim();
@@ -816,17 +1030,617 @@ class PiAgentSession {
816
1030
  }
817
1031
  }
818
1032
  });
1033
+ // M2.1 (2026-06-17): 注册 4 个长期缺失的工具 — 让 agent 真正能"修改 + 提交"代码
1034
+ // 路径限制走 shell-guard 同款 allowlist (复用 checkWritePath / checkCommand)
1035
+ // 这些工具之前在 tool-gate 白名单 + tool-manifest 中存在, 但 pi-sdk 未注册 — 修复孤儿
1036
+ this.tools.set('write_file', {
1037
+ name: 'write_file',
1038
+ description: '写入一个文件. 路径必须在白名单 (src/web/*, src/agents/workflow-*, *.md, docs/**, src/test/**, src/agents/pi-sdk.ts 等). 大文件 (> 100KB) 会被拒. 命中护栏黑名单 (shell-guard.ts, package.json, .env, .git, .bolloon, dist) 会拒.',
1039
+ parameters: { path: '相对路径 (必填, 相对 cwd)', content: '文件内容 (必填)' },
1040
+ execute: async (args) => {
1041
+ const relPath = String(args.path || '').trim();
1042
+ const content = String(args.content ?? '');
1043
+ if (!relPath)
1044
+ return { success: false, error: 'path 必填' };
1045
+ if (content.length > 100_000)
1046
+ return { success: false, error: `内容过大 (${content.length} > 100000 字节), 请分块写` };
1047
+ // 路径检查: 复用 shell-guard 的 checkWritePath
1048
+ const pathResult = checkWritePath(relPath);
1049
+ if (!pathResult.allowed) {
1050
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1051
+ }
1052
+ try {
1053
+ const absPath = path.resolve(this.cwd, relPath);
1054
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
1055
+ await fs.writeFile(absPath, content, 'utf-8');
1056
+ return { success: true, output: `✅ wrote ${relPath} (${content.length} bytes)` };
1057
+ }
1058
+ catch (e) {
1059
+ return { success: false, error: `写文件失败: ${String(e)}` };
1060
+ }
1061
+ }
1062
+ });
1063
+ this.tools.set('edit_file', {
1064
+ name: 'edit_file',
1065
+ description: '编辑一个文件: 在 path 处查找 old_text, 替换为 new_text. 找不到 old_text 会失败 (避免静默不替换). 路径同样受护栏限制.',
1066
+ parameters: { path: '相对路径 (必填)', old_text: '要替换的文本 (必填, 全文匹配)', new_text: '新文本 (必填)' },
1067
+ execute: async (args) => {
1068
+ const relPath = String(args.path || '').trim();
1069
+ const oldText = String(args.old_text ?? '');
1070
+ const newText = String(args.new_text ?? '');
1071
+ if (!relPath)
1072
+ return { success: false, error: 'path 必填' };
1073
+ if (!oldText)
1074
+ return { success: false, error: 'old_text 必填' };
1075
+ const pathResult = checkWritePath(relPath);
1076
+ if (!pathResult.allowed) {
1077
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1078
+ }
1079
+ try {
1080
+ const absPath = path.resolve(this.cwd, relPath);
1081
+ const original = await fs.readFile(absPath, 'utf-8');
1082
+ if (!original.includes(oldText)) {
1083
+ return { success: false, error: `old_text 在 ${relPath} 中未找到, 拒绝静默写入. 请先用 read_document 读最新内容.` };
1084
+ }
1085
+ const updated = original.replace(oldText, newText);
1086
+ await fs.writeFile(absPath, updated, 'utf-8');
1087
+ return { success: true, output: `✅ edited ${relPath} (${oldText.length} → ${newText.length} 字节)` };
1088
+ }
1089
+ catch (e) {
1090
+ return { success: false, error: `编辑文件失败: ${String(e)}` };
1091
+ }
1092
+ }
1093
+ });
1094
+ this.tools.set('git_diff', {
1095
+ name: 'git_diff',
1096
+ description: '查看 git diff. 默认显示未提交改动 (staged + unstaged), 可指定 ref1..ref2 看两个 commit/分支之间的 diff. 输出会截到 8000 字符避免超长.',
1097
+ parameters: { range: '可选. e.g. "HEAD~3..HEAD" 或 "master..agent/feat-x". 省略则看未提交改动.' },
1098
+ execute: async (args) => {
1099
+ const range = String(args.range || '').trim();
1100
+ const argv = range ? ['diff', range] : ['diff'];
1101
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1102
+ if (result.deniedByGuard)
1103
+ return { success: false, error: result.error };
1104
+ if (!result.success)
1105
+ return { success: false, error: result.error, output: result.output };
1106
+ const out = (result.output || '').slice(0, 8000);
1107
+ return { success: true, output: out || '(空 diff — 没有未提交改动)' };
1108
+ }
1109
+ });
1110
+ this.tools.set('git_commit', {
1111
+ name: 'git_commit',
1112
+ description: 'git add -A + git commit. 提交信息由 LLM 提供. 不会 push — push 是 agent 显式调 git_commit_and_push 触发. 命中护栏 (push to master/main, force-push) 仍会被拒. 内部自动设 BOLLOON_AUTO_EVOLVE=1 让 pre-commit hook 跳过 vitest/tsc (auto-evolve 模式, CI 兜底).',
1113
+ parameters: { message: 'commit message (必填, 用 HEREDOC 多行)' },
1114
+ execute: async (args) => {
1115
+ const message = String(args.message || '').trim();
1116
+ if (!message)
1117
+ return { success: false, error: 'message 必填' };
1118
+ // M3.4 (2026-06-17): agent git_commit 自动设 BOLLOON_AUTO_EVOLVE=1, 让 lefthook pre-commit 跳过 vitest/tsc
1119
+ // 这样频繁 commit 不会被 30s+ 的 pre-commit 阻塞. CI 会兜底 (push 后 GitHub Actions 跑测试)
1120
+ // 先 add
1121
+ const addResult = await shellExec('git', ['add', '-A'], { timeoutMs: 10_000 });
1122
+ if (addResult.deniedByGuard)
1123
+ return { success: false, error: addResult.error };
1124
+ if (!addResult.success)
1125
+ return { success: false, error: `git add 失败: ${addResult.error}` };
1126
+ // 再 commit (message 用 -m, 避免 HEREDOC 注入)
1127
+ // Windows shell 不支持 inline env var, 改用 child_process 直接 spawn
1128
+ // 简单方案: 用 spawn + 临时设 env
1129
+ try {
1130
+ const { spawn: spawnFn } = await import('child_process');
1131
+ const env = { ...process.env, BOLLOON_AUTO_EVOLVE: '1' };
1132
+ const output = await new Promise((resolve, reject) => {
1133
+ const proc = spawnFn('git', ['commit', '-m', message], {
1134
+ cwd: this.cwd, env, stdio: ['ignore', 'pipe', 'pipe'],
1135
+ });
1136
+ let stdout = '';
1137
+ let stderr = '';
1138
+ proc.stdout.on('data', (d) => stdout += d.toString());
1139
+ proc.stderr.on('data', (d) => stderr += d.toString());
1140
+ proc.on('close', (code) => code === 0 ? resolve(stdout) : reject(new Error(stderr || `git commit exited ${code}`)));
1141
+ proc.on('error', reject);
1142
+ });
1143
+ return { success: true, output: `✅ committed: ${message.split('\n')[0]}\n${output}` };
1144
+ }
1145
+ catch (e) {
1146
+ return { success: false, error: `git commit 失败: ${String(e.message || e).slice(0, 500)}` };
1147
+ }
1148
+ }
1149
+ });
1150
+ // M3.4: git_push — 改完直接 push (Q3-B 决策, 修改了 pre-push hook 让它别卡)
1151
+ // 命中护栏 (push to master/main, force-push) 仍会被拒 — 这两条底线不变
1152
+ this.tools.set('git_push', {
1153
+ name: 'git_push',
1154
+ description: 'git push 当前分支到 origin. 命中护栏 (push to master/main, --force) 仍会被拒. 用于长期项目自动 commit + push 循环.',
1155
+ parameters: { remote: '可选, 默认 origin', branch: '可选, 默认当前分支' },
1156
+ execute: async (args) => {
1157
+ const remote = String(args.remote || 'origin').trim();
1158
+ const branch = String(args.branch || '').trim();
1159
+ const argv = branch ? ['push', remote, branch] : ['push', remote];
1160
+ const result = await shellExec('git', argv, { timeoutMs: 60_000 });
1161
+ if (result.deniedByGuard)
1162
+ return { success: false, error: result.error };
1163
+ if (!result.success)
1164
+ return { success: false, error: result.error, output: result.output };
1165
+ return { success: true, output: `✅ pushed to ${remote}${branch ? `/${branch}` : ''}\n${result.output || ''}` };
1166
+ }
1167
+ });
1168
+ // M3.4: git_branch — 创建/切换分支 (用于多任务并行隔离)
1169
+ this.tools.set('git_branch', {
1170
+ name: 'git_branch',
1171
+ description: 'git checkout -b <name> 或 git checkout <name>. 用于多任务并行隔离 — 改前先 checkout 到 agent/<task-id> 分支.',
1172
+ parameters: { name: '分支名 (必填, e.g. "agent/task-123")', create: '可选, "true" 表示创建新分支 (默认 false = 切到已有)' },
1173
+ execute: async (args) => {
1174
+ const name = String(args.name || '').trim();
1175
+ const create = String(args.create || 'false') === 'true';
1176
+ if (!name)
1177
+ return { success: false, error: 'name 必填' };
1178
+ const argv = create ? ['checkout', '-b', name] : ['checkout', name];
1179
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1180
+ if (result.deniedByGuard)
1181
+ return { success: false, error: result.error };
1182
+ if (!result.success)
1183
+ return { success: false, error: result.error, output: result.output };
1184
+ return { success: true, output: `✅ ${create ? 'created + checked out' : 'checked out'} ${name}\n${result.output || ''}` };
1185
+ }
1186
+ });
1187
+ // 2026-06-19: 实用代码修改工具集 (M4) — 补全 read/delete/mkdir/move + grep/git log/show
1188
+ // 这些是 LLM 自主完成代码修改的最小工具集, 之前只能通过 shell_exec 间接调用
1189
+ // 通用文件读取 (支持任意扩展名, 跟 read_document 区别是后者只支持 txt/md/pdf/docx)
1190
+ this.tools.set('read_file', {
1191
+ name: 'read_file',
1192
+ description: '读取任意文件内容 (相对 cwd). 与 read_document 区别: 不限制文件类型. 受 shell-guard 路径白名单保护.',
1193
+ parameters: { path: '相对路径 (必填, e.g. "src/agents/pi-sdk.ts")', startLine: '起始行号 (可选, 默认 0)', maxLines: '最大行数 (可选, 默认 500)' },
1194
+ execute: async (args) => {
1195
+ const relPath = String(args.path || '').trim();
1196
+ if (!relPath)
1197
+ return { success: false, error: 'path 必填' };
1198
+ const pathResult = checkWritePath(relPath);
1199
+ if (!pathResult.allowed)
1200
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1201
+ try {
1202
+ const absPath = path.resolve(this.cwd, relPath);
1203
+ const content = fsSync.readFileSync(absPath, 'utf-8');
1204
+ const start = Math.max(0, parseInt(String(args.startLine || '0')) || 0);
1205
+ const max = parseInt(String(args.maxLines || '500')) || 500;
1206
+ const lines = content.split('\n');
1207
+ const slice = lines.slice(start, start + max);
1208
+ return { success: true, output: `📄 ${relPath} (第 ${start + 1}-${start + slice.length} 行, 共 ${lines.length} 行):\n${slice.map((l, i) => `${String(start + i + 1).padStart(4)} | ${l}`).join('\n')}` };
1209
+ }
1210
+ catch (e) {
1211
+ return { success: false, error: `读取失败: ${e.message}` };
1212
+ }
1213
+ }
1214
+ });
1215
+ // 删除文件
1216
+ this.tools.set('delete_file', {
1217
+ name: 'delete_file',
1218
+ description: '删除一个文件. 受 shell-guard 路径白名单保护. 不可恢复, 调用前请确认.',
1219
+ parameters: { path: '相对路径 (必填)' },
1220
+ execute: async (args) => {
1221
+ const relPath = String(args.path || '').trim();
1222
+ if (!relPath)
1223
+ return { success: false, error: 'path 必填' };
1224
+ const pathResult = checkWritePath(relPath);
1225
+ if (!pathResult.allowed)
1226
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1227
+ try {
1228
+ const absPath = path.resolve(this.cwd, relPath);
1229
+ if (!fsSync.existsSync(absPath))
1230
+ return { success: false, error: `文件不存在: ${relPath}` };
1231
+ fsSync.unlinkSync(absPath);
1232
+ return { success: true, output: `✅ deleted ${relPath}` };
1233
+ }
1234
+ catch (e) {
1235
+ return { success: false, error: `删除失败: ${e.message}` };
1236
+ }
1237
+ }
1238
+ });
1239
+ // 创建目录
1240
+ this.tools.set('mkdir', {
1241
+ name: 'mkdir',
1242
+ description: '创建一个或多个目录. 自动 mkdir -p (父目录不存在也创建).',
1243
+ parameters: { path: '目录路径 (必填, 相对 cwd)' },
1244
+ execute: async (args) => {
1245
+ const relPath = String(args.path || '').trim();
1246
+ if (!relPath)
1247
+ return { success: false, error: 'path 必填' };
1248
+ const pathResult = checkWritePath(relPath);
1249
+ if (!pathResult.allowed)
1250
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1251
+ try {
1252
+ const absPath = path.resolve(this.cwd, relPath);
1253
+ fsSync.mkdirSync(absPath, { recursive: true });
1254
+ return { success: true, output: `✅ mkdir ${relPath}` };
1255
+ }
1256
+ catch (e) {
1257
+ return { success: false, error: `创建失败: ${e.message}` };
1258
+ }
1259
+ }
1260
+ });
1261
+ // 移动/重命名文件
1262
+ this.tools.set('move_file', {
1263
+ name: 'move_file',
1264
+ description: '移动或重命名文件. 源和目标路径都在白名单内才允许.',
1265
+ parameters: { from: '源路径 (必填)', to: '目标路径 (必填)' },
1266
+ execute: async (args) => {
1267
+ const from = String(args.from || '').trim();
1268
+ const to = String(args.to || '').trim();
1269
+ if (!from || !to)
1270
+ return { success: false, error: 'from 和 to 都必填' };
1271
+ const fromCheck = checkWritePath(from);
1272
+ if (!fromCheck.allowed)
1273
+ return { success: false, error: `from 路径被护栏拒: ${fromCheck.reason}` };
1274
+ const toCheck = checkWritePath(to);
1275
+ if (!toCheck.allowed)
1276
+ return { success: false, error: `to 路径被护栏拒: ${toCheck.reason}` };
1277
+ try {
1278
+ const fromAbs = path.resolve(this.cwd, from);
1279
+ const toAbs = path.resolve(this.cwd, to);
1280
+ if (!fsSync.existsSync(fromAbs))
1281
+ return { success: false, error: `源文件不存在: ${from}` };
1282
+ fsSync.mkdirSync(path.dirname(toAbs), { recursive: true });
1283
+ fsSync.renameSync(fromAbs, toAbs);
1284
+ return { success: true, output: `✅ ${from} → ${to}` };
1285
+ }
1286
+ catch (e) {
1287
+ return { success: false, error: `移动失败: ${e.message}` };
1288
+ }
1289
+ }
1290
+ });
1291
+ // 文件内搜索 (类似 grep)
1292
+ this.tools.set('grep_files', {
1293
+ name: 'grep_files',
1294
+ description: '在文件中搜索匹配 pattern 的行. 类似 grep -rn. 路径必须在白名单.',
1295
+ parameters: { pattern: '搜索 pattern (必填, 字符串, 不是正则)', path: '搜索目录 (可选, 默认 .)', filePattern: '文件名 glob (可选, e.g. "*.ts")' },
1296
+ execute: async (args) => {
1297
+ const pattern = String(args.pattern || '').trim();
1298
+ if (!pattern)
1299
+ return { success: false, error: 'pattern 必填' };
1300
+ const searchPath = String(args.path || '.').trim();
1301
+ const pathResult = checkWritePath(searchPath);
1302
+ if (!pathResult.allowed)
1303
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1304
+ try {
1305
+ const { execFile } = await import('child_process');
1306
+ const { promisify } = await import('util');
1307
+ const pExecFile = promisify(execFile);
1308
+ const argv = ['-rn', '--include=' + (args.filePattern || '*'), pattern, searchPath];
1309
+ const { stdout, stderr } = await pExecFile('grep', argv, { cwd: this.cwd, maxBuffer: 1024 * 1024 });
1310
+ const lines = stdout.split('\n').filter(Boolean).slice(0, 50);
1311
+ return { success: true, output: `🔍 grep "${pattern}" in ${searchPath} (${args.filePattern || '*'}, 最多 50 行):\n${lines.join('\n')}${lines.length === 50 ? '\n... (truncated)' : ''}` };
1312
+ }
1313
+ catch (e) {
1314
+ if (e.code === 1)
1315
+ return { success: true, output: `🔍 grep "${pattern}" in ${searchPath}: 0 matches` };
1316
+ return { success: false, error: `grep 失败: ${e.message}` };
1317
+ }
1318
+ }
1319
+ });
1320
+ // glob 找文件
1321
+ this.tools.set('glob_files', {
1322
+ name: 'glob_files',
1323
+ description: '用 glob pattern 找文件. 例如 "**/*.test.ts".',
1324
+ parameters: { pattern: 'glob pattern (必填, e.g. "src/**/*.ts")' },
1325
+ execute: async (args) => {
1326
+ const pattern = String(args.pattern || '').trim();
1327
+ if (!pattern)
1328
+ return { success: false, error: 'pattern 必填' };
1329
+ try {
1330
+ const pathResult = checkWritePath(pattern.replace(/\*\*.*$/, '').replace(/\/\*.*$/, '') || '.');
1331
+ if (!pathResult.allowed && pattern !== '**/*' && pattern !== '*')
1332
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1333
+ const { execFile } = await import('child_process');
1334
+ const { promisify } = await import('util');
1335
+ const pExecFile = promisify(execFile);
1336
+ // 用 shell 的 find + glob (避免引入额外依赖)
1337
+ const { stdout } = await pExecFile('find', [this.cwd, '-path', `*${pattern.replace(/\*\*\//, '*').replace(/\*\*$/, '*').replace(/\*$/, '*')}`, '-type', 'f'], { maxBuffer: 1024 * 1024 });
1338
+ const files = stdout.split('\n').filter(Boolean).slice(0, 100);
1339
+ const relFiles = files.map(f => path.relative(this.cwd, f));
1340
+ return { success: true, output: `🔍 glob "${pattern}" 找到 ${relFiles.length} 个文件${relFiles.length === 100 ? ' (truncated)' : ''}:\n${relFiles.join('\n')}` };
1341
+ }
1342
+ catch (e) {
1343
+ return { success: false, error: `glob 失败: ${e.message}` };
1344
+ }
1345
+ }
1346
+ });
1347
+ // git log
1348
+ this.tools.set('git_log', {
1349
+ name: 'git_log',
1350
+ description: '查看 git log. 默认 --oneline -10. 支持过滤和范围.',
1351
+ parameters: { range: '可选, e.g. "HEAD~5..HEAD" 或分支名', maxCount: '可选, 默认 10', oneline: '可选, "true" 用 --oneline (默认 true)' },
1352
+ execute: async (args) => {
1353
+ const range = String(args.range || '').trim();
1354
+ const maxCount = parseInt(String(args.maxCount || '10')) || 10;
1355
+ const oneline = String(args.oneline || 'true') === 'true';
1356
+ const argv = ['log'];
1357
+ if (oneline)
1358
+ argv.push('--oneline');
1359
+ if (maxCount > 0)
1360
+ argv.push(`-n`, String(maxCount));
1361
+ if (range)
1362
+ argv.push(range);
1363
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1364
+ if (result.deniedByGuard)
1365
+ return { success: false, error: result.error };
1366
+ if (!result.success)
1367
+ return { success: false, error: result.error, output: result.output };
1368
+ return { success: true, output: `📜 git log:\n${result.output || '(empty)'}` };
1369
+ }
1370
+ });
1371
+ // git show
1372
+ this.tools.set('git_show', {
1373
+ name: 'git_show',
1374
+ description: '查看 commit 内容. 默认 HEAD. 支持 --stat 看统计, --patch 看 diff.',
1375
+ parameters: { ref: '可选, 默认 HEAD', stat: '可选, "true" 只看 stat (默认 false)' },
1376
+ execute: async (args) => {
1377
+ const ref = String(args.ref || 'HEAD').trim();
1378
+ const argv = ['show', ref];
1379
+ if (String(args.stat || 'false') === 'true')
1380
+ argv.push('--stat');
1381
+ const result = await shellExec('git', argv, { timeoutMs: 15_000 });
1382
+ if (result.deniedByGuard)
1383
+ return { success: false, error: result.error };
1384
+ if (!result.success)
1385
+ return { success: false, error: result.error, output: result.output };
1386
+ return { success: true, output: `📜 git show ${ref}:\n${result.output || '(empty)'}` };
1387
+ }
1388
+ });
1389
+ // git stash
1390
+ this.tools.set('git_stash', {
1391
+ name: 'git_stash',
1392
+ description: 'git stash 暂存当前未提交改动. action: save/pop/list/apply/drop. 支持 message.',
1393
+ parameters: { action: '动作 (必填, "save" | "pop" | "list" | "apply" | "drop")', message: '可选, save 时的描述', index: '可选, apply/pop/drop 的 stash index (e.g. "0")' },
1394
+ execute: async (args) => {
1395
+ const action = String(args.action || '').trim();
1396
+ if (!['save', 'pop', 'list', 'apply', 'drop'].includes(action)) {
1397
+ return { success: false, error: `action 必须是 save/pop/list/apply/drop 之一, 收到: ${action}` };
1398
+ }
1399
+ const argv = ['stash', action];
1400
+ if (action === 'save' && args.message)
1401
+ argv.push('-m', String(args.message));
1402
+ if ((action === 'apply' || action === 'drop' || action === 'pop') && args.index) {
1403
+ argv.push('stash@{' + String(args.index) + '}');
1404
+ }
1405
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1406
+ if (result.deniedByGuard)
1407
+ return { success: false, error: result.error };
1408
+ if (!result.success)
1409
+ return { success: false, error: result.error, output: result.output };
1410
+ return { success: true, output: `✅ git stash ${action}\n${result.output || ''}` };
1411
+ }
1412
+ });
1413
+ // vitest 跑测试
1414
+ this.tools.set('vitest_run', {
1415
+ name: 'vitest_run',
1416
+ description: '跑 vitest 测试. 自动 bail (失败就停). 默认 60s timeout.',
1417
+ parameters: { pattern: '可选, 文件 glob e.g. "src/agents/pi-sdk.test.ts"', timeoutMs: '可选, 默认 60000' },
1418
+ execute: async (args) => {
1419
+ const argv = ['vitest', 'run', '--reporter=basic', '--no-color', '--bail=1'];
1420
+ if (args.pattern)
1421
+ argv.push(String(args.pattern));
1422
+ const timeoutMs = parseInt(String(args.timeoutMs || '60000')) || 60000;
1423
+ const result = await shellExec('npx', argv, { timeoutMs });
1424
+ if (result.deniedByGuard)
1425
+ return { success: false, error: result.error };
1426
+ if (!result.success)
1427
+ return { success: false, error: result.error || 'vitest 失败', output: result.output };
1428
+ return { success: true, output: `✅ vitest 通过:\n${(result.output || '').slice(0, 2000)}` };
1429
+ }
1430
+ });
1431
+ // tsc 编译检查
1432
+ this.tools.set('tsc_check', {
1433
+ name: 'tsc_check',
1434
+ description: '跑 tsc --noEmit 检查 TypeScript 编译. 默认 60s timeout.',
1435
+ parameters: { project: '可选, tsconfig 路径 (默认 tsconfig.json)', timeoutMs: '可选, 默认 60000' },
1436
+ execute: async (args) => {
1437
+ const argv = ['tsc', '--noEmit'];
1438
+ if (args.project)
1439
+ argv.push('-p', String(args.project));
1440
+ else
1441
+ argv.push('-p', 'tsconfig.json');
1442
+ const timeoutMs = parseInt(String(args.timeoutMs || '60000')) || 60000;
1443
+ const result = await shellExec('npx', argv, { timeoutMs });
1444
+ if (result.deniedByGuard)
1445
+ return { success: false, error: result.error };
1446
+ if (!result.success)
1447
+ return { success: false, error: result.error || 'tsc 失败', output: result.output };
1448
+ return { success: true, output: `✅ tsc 通过:\n${(result.output || 'no errors').slice(0, 1000)}` };
1449
+ }
1450
+ });
1451
+ // M3.2: 任务状态机工具 — 让 agent 自己维护 multi_step 任务的状态
1452
+ this.tools.set('create_task', {
1453
+ name: 'create_task',
1454
+ description: '创建一个新多步任务, 初始 steps 列表由 LLM 给出. 返回 task-id, 后续用 update_task / get_task 跟踪进度. 任务状态写 ~/.bolloon/tasks/<id>.yaml 持久化.',
1455
+ parameters: {
1456
+ goal: '任务目标 (1 句话, 必填)',
1457
+ steps: '步骤列表 (数组, 必填, 至少 1 步)',
1458
+ sessionKey: '可选, 关联的 session key (channel:sessionId)',
1459
+ branch: '可选, 关联的 git 分支名',
1460
+ },
1461
+ execute: async (args) => {
1462
+ const goal = String(args.goal || '').trim();
1463
+ const stepsRaw = args.steps;
1464
+ if (!goal)
1465
+ return { success: false, error: 'goal 必填' };
1466
+ let steps = [];
1467
+ if (Array.isArray(stepsRaw)) {
1468
+ steps = stepsRaw.map((s) => String(s).trim()).filter(Boolean);
1469
+ }
1470
+ else if (typeof stepsRaw === 'string') {
1471
+ // 支持 "step1\nstep2\nstep3" 或 "step1,step2,step3"
1472
+ steps = stepsRaw.split(/[\n,]/).map(s => s.trim()).filter(Boolean);
1473
+ }
1474
+ if (steps.length === 0)
1475
+ return { success: false, error: 'steps 必填且至少 1 步' };
1476
+ const sessionKey = String(args.sessionKey || '').trim() || undefined;
1477
+ const branch = String(args.branch || '').trim() || undefined;
1478
+ try {
1479
+ const { createTask } = await import('./task-state.js');
1480
+ const task = await createTask({ goal, steps, sessionKey, branch });
1481
+ return { success: true, output: `✅ task created: ${task.id}\nbranch: ${branch || '(未指定)'}\nsteps:\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}` };
1482
+ }
1483
+ catch (e) {
1484
+ return { success: false, error: `create_task 失败: ${String(e)}` };
1485
+ }
1486
+ }
1487
+ });
1488
+ this.tools.set('update_task', {
1489
+ name: 'update_task',
1490
+ description: '更新任务的某一步状态 (pending → running → done/failed/skipped). 系统会自动推进下一步 pending → running (当当前步 done 时).',
1491
+ parameters: {
1492
+ task_id: '任务 id (必填)',
1493
+ step_id: '步骤 id (必填, e.g. "step-1")',
1494
+ status: '新状态 (running | done | failed | skipped)',
1495
+ result_summary: '可选, 结果摘要',
1496
+ error: '可选, 失败原因 (status=failed 时填)',
1497
+ },
1498
+ execute: async (args) => {
1499
+ const taskId = String(args.task_id || '').trim();
1500
+ const stepId = String(args.step_id || '').trim();
1501
+ const status = String(args.status || '').trim();
1502
+ if (!taskId || !stepId)
1503
+ return { success: false, error: 'task_id + step_id 必填' };
1504
+ if (!['running', 'done', 'failed', 'skipped'].includes(status)) {
1505
+ return { success: false, error: `status 必须是 running|done|failed|skipped` };
1506
+ }
1507
+ try {
1508
+ const { updateStep } = await import('./task-state.js');
1509
+ const patch = { status };
1510
+ if (args.result_summary)
1511
+ patch.resultSummary = String(args.result_summary);
1512
+ if (args.error)
1513
+ patch.error = String(args.error);
1514
+ const updated = await updateStep(taskId, stepId, patch);
1515
+ if (!updated)
1516
+ return { success: false, error: `任务 ${taskId} 未找到` };
1517
+ const nextRunning = updated.steps.find((s) => s.status === 'running');
1518
+ return {
1519
+ success: true,
1520
+ output: `✅ step ${stepId} → ${status}\ntask 状态: ${updated.status}${nextRunning ? `\n下一步: ${nextRunning.id} — ${nextRunning.description}` : ''}`,
1521
+ };
1522
+ }
1523
+ catch (e) {
1524
+ return { success: false, error: `update_task 失败: ${String(e)}` };
1525
+ }
1526
+ }
1527
+ });
1528
+ this.tools.set('get_task', {
1529
+ name: 'get_task',
1530
+ description: '查任务的当前状态和步骤进度. 用于跨 loop / 跨 session 恢复.',
1531
+ parameters: { task_id: '任务 id (必填)' },
1532
+ execute: async (args) => {
1533
+ const taskId = String(args.task_id || '').trim();
1534
+ if (!taskId)
1535
+ return { success: false, error: 'task_id 必填' };
1536
+ try {
1537
+ const { getTask } = await import('./task-state.js');
1538
+ const t = await getTask(taskId);
1539
+ if (!t)
1540
+ return { success: false, error: `任务 ${taskId} 未找到` };
1541
+ const lines = [
1542
+ `任务: ${t.id}`,
1543
+ `目标: ${t.goal}`,
1544
+ `状态: ${t.status}`,
1545
+ `branch: ${t.branch || '(未指定)'}`,
1546
+ `sessionKey: ${t.sessionKey || '(未指定)'}`,
1547
+ `创建: ${t.createdAt}`,
1548
+ `更新: ${t.updatedAt}`,
1549
+ ``,
1550
+ `步骤:`,
1551
+ ...t.steps.map((s) => ` ${s.status === 'done' ? '✅' : s.status === 'running' ? '🔄' : s.status === 'failed' ? '❌' : s.status === 'skipped' ? '⏭️' : '⏳'} ${s.id} — ${s.description}${s.resultSummary ? `\n 结果: ${s.resultSummary}` : ''}${s.error ? `\n 错误: ${s.error}` : ''}`),
1552
+ ];
1553
+ return { success: true, output: lines.join('\n') };
1554
+ }
1555
+ catch (e) {
1556
+ return { success: false, error: `get_task 失败: ${String(e)}` };
1557
+ }
1558
+ }
1559
+ });
1560
+ this.tools.set('list_tasks', {
1561
+ name: 'list_tasks',
1562
+ description: '列出最近 N 个任务 (默认 10). 用于多任务并行管理.',
1563
+ parameters: { limit: '可选, 默认 10' },
1564
+ execute: async (args) => {
1565
+ const limit = Number(args.limit) || 10;
1566
+ try {
1567
+ const { listTasks } = await import('./task-state.js');
1568
+ const tasks = await listTasks(limit);
1569
+ if (tasks.length === 0) {
1570
+ return { success: true, output: '当前没有任务. 用 create_task 创建一个.' };
1571
+ }
1572
+ const lines = tasks.map((t) => {
1573
+ const done = t.steps.filter((s) => s.status === 'done').length;
1574
+ const total = t.steps.length;
1575
+ return `${t.status === 'completed' ? '✅' : t.status === 'failed' ? '❌' : '🔄'} ${t.id} — ${t.goal} (${done}/${total} steps, branch: ${t.branch || '-'})`;
1576
+ });
1577
+ return { success: true, output: `最近 ${tasks.length} 个任务:\n${lines.join('\n')}` };
1578
+ }
1579
+ catch (e) {
1580
+ return { success: false, error: `list_tasks 失败: ${String(e)}` };
1581
+ }
1582
+ }
1583
+ });
1584
+ // M3.3 (2026-06-17): 工具幂等性 — 显式 cache 防止重试时副作用执行两次
1585
+ // 设计: 在 registerTools 末尾 wrap 所有 this.tools, 每个调用走 cache:
1586
+ // - 算 (toolName + JSON.stringify(args)) 的 hash
1587
+ // - 命中 cache → 返缓存结果 (不重跑副作用, 关键对 write_file / edit_file / shell_exec)
1588
+ // - 失败结果不缓存 (避免缓存 transient 错误)
1589
+ // - 缓存容量 200 条, 超过就清空
1590
+ this.wrapToolsWithIdempotency();
1591
+ }
1592
+ /** M3.3: 工具结果缓存 — 防止 loop 重试时副作用 (写文件 / 改代码) 执行多次 */
1593
+ idempotencyCache = new Map();
1594
+ IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; // 5 分钟内同 (tool, args) 走 cache
1595
+ IDEMPOTENCY_MAX = 200;
1596
+ wrapToolsWithIdempotency() {
1597
+ // 只 wrap 会产生副作用的工具 — 读类工具 (list_files, read_document, get_task) 不 wrap
1598
+ // (让 LLM 拿到最新数据, 不会因为缓存读到旧 task 状态)
1599
+ const SIDE_EFFECT_TOOLS = new Set([
1600
+ 'write_file', 'edit_file', 'shell_exec', 'git_commit', 'git_push', 'git_branch',
1601
+ 'create_task', 'update_task',
1602
+ ]);
1603
+ for (const [name, tool] of this.tools.entries()) {
1604
+ if (!SIDE_EFFECT_TOOLS.has(name))
1605
+ continue;
1606
+ const original = tool.execute;
1607
+ tool.execute = async (args) => {
1608
+ const key = `${name}|${JSON.stringify(args)}`;
1609
+ const cached = this.idempotencyCache.get(key);
1610
+ if (cached && Date.now() - cached.ts < this.IDEMPOTENCY_TTL_MS) {
1611
+ // 命中 — 加标记让 LLM 知道这是 cache (不会真执行副作用)
1612
+ return { ...cached.result, output: (cached.result.output || '') + '\n[↻ idempotency cache hit]' };
1613
+ }
1614
+ const result = await original(args);
1615
+ // 只缓存成功结果, 避免缓存 transient 错误
1616
+ if (result && result.success) {
1617
+ if (this.idempotencyCache.size >= this.IDEMPOTENCY_MAX) {
1618
+ this.idempotencyCache.clear();
1619
+ }
1620
+ this.idempotencyCache.set(key, { result, ts: Date.now() });
1621
+ }
1622
+ return result;
1623
+ };
1624
+ }
1625
+ }
1626
+ /** 清幂等性缓存 — 强制下次调用真正执行 (用于 agent 显式需要重新跑的场景) */
1627
+ clearIdempotencyCache() {
1628
+ this.idempotencyCache.clear();
819
1629
  }
820
1630
  async registerP2PDocumentReceiver() {
821
1631
  await initDocumentReceiver();
822
1632
  }
823
1633
  getToolDefinitions() {
1634
+ // M2.4 (2026-06-17): 缓存 tool 定义 — registerTools() 在构造时调一次, 此后不变
1635
+ if (this.cachedToolDefinitions)
1636
+ return this.cachedToolDefinitions;
824
1637
  const defs = ['可用工具:'];
825
1638
  for (const tool of this.tools.values()) {
826
1639
  const params = Object.entries(tool.parameters).map(([k, v]) => `${k}: ${v}`).join(', ');
827
1640
  defs.push(`- ${tool.name}(${params}) - ${tool.description}`);
828
1641
  }
829
- return defs.join('\n');
1642
+ this.cachedToolDefinitions = defs.join('\n');
1643
+ return this.cachedToolDefinitions;
830
1644
  }
831
1645
  async initSession() {
832
1646
  await this.sessionManager.initialize();
@@ -869,6 +1683,17 @@ class PiAgentSession {
869
1683
  this.currentSignal = options?.signal ?? null;
870
1684
  this.currentOnStream = options?.onStream ?? null;
871
1685
  await this.computeJudgmentGate(input);
1686
+ // M2.2 (2026-06-17): intent 分类 — prompt() 路径也跑 (跟 promptStream 对齐)
1687
+ try {
1688
+ const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1689
+ this.currentIntent = classifyIntent(input);
1690
+ this.currentIntentHint = intentHint(this.currentIntent);
1691
+ }
1692
+ catch (err) {
1693
+ console.warn('[PiAgent] classifyIntent in prompt() failed:', err);
1694
+ this.currentIntent = 'chitchat';
1695
+ this.currentIntentHint = '';
1696
+ }
872
1697
  // P2: 解析当前 permission mode
873
1698
  try {
874
1699
  const { resolvePermissionMode } = await import('./permission-mode.js');
@@ -878,6 +1703,22 @@ class PiAgentSession {
878
1703
  console.warn('[PiAgent] resolvePermissionMode failed (non-fatal):', err);
879
1704
  this.currentPermissionMode = 'default';
880
1705
  }
1706
+ // M3.1 (2026-06-17): 跟 promptStream 一样, usePivotLoop 时走 pivotLoop 路径
1707
+ // 之前 prompt() 永远跑老 runReActLoop, CLI/web 行为不一致
1708
+ if (this.usePivotLoop) {
1709
+ try {
1710
+ const lr = await this.promptWithPivotLoop(input, undefined, options?.channelId);
1711
+ return lr.response || '';
1712
+ }
1713
+ finally {
1714
+ if (this.judgmentGateUsedIds.length > 0) {
1715
+ recordJudgmentUsage(this.judgmentGateUsedIds, { userInput: input }).catch((err) => console.warn('[PiAgent] recordJudgmentUsage failed:', err));
1716
+ }
1717
+ this.clearJudgmentGate();
1718
+ this.currentSignal = null;
1719
+ this.currentOnStream = null;
1720
+ }
1721
+ }
881
1722
  try {
882
1723
  // 2026-06-16: runReActLoop 现在返回 { reply, aiFailed, aiFailureReason } — 这里只需 reply 字符串
883
1724
  const loopResult = await this.runReActLoop(undefined, options?.signal);
@@ -893,15 +1734,27 @@ class PiAgentSession {
893
1734
  }
894
1735
  }
895
1736
  async promptStream(input, onStream, signal, channelId) {
1737
+ console.log(`[PiAgent.promptStream] ENTRY, channelId=${channelId}, input chars=${input.length}`);
896
1738
  this.minimaxAvailable = this.checkMinimax();
1739
+ console.log(`[PiAgent.promptStream] minimaxAvailable=${this.minimaxAvailable}`);
897
1740
  this.currentChannelId = channelId ?? this.currentChannelId;
1741
+ // 2026-06-18 (supervisor): web server 把 46K markedPrompt 喂过来
1742
+ // (【本轮用户请求】\n<text>\n【请求结束】\n\n<contextHint>).
1743
+ // 整个 input 走下游, pivot loop 之前拿 47K buildContext 当 user message 发出去,
1744
+ // 模型撞 context window. 提取 userText 替代 input, contextHint 拼到 systemPrompt 末尾.
1745
+ const markerMatch = input.match(/【本轮用户请求】\s*([\s\S]*?)\s*【请求结束】/);
1746
+ const userText = markerMatch ? markerMatch[1].trim() : input;
1747
+ const contextHint = markerMatch ? input.replace(markerMatch[0], '').trim() : '';
1748
+ console.log(`[PiAgent.promptStream] marker matched=${!!markerMatch}, userText chars=${userText.length}, contextHint chars=${contextHint.length}`);
898
1749
  this.messageHistory.push({
899
1750
  role: 'user',
900
- content: input
1751
+ content: userText
901
1752
  });
1753
+ // 2026-06-18: web server 喂的 markedPrompt 外的 contextHint 拼到 system 末尾 (而不是当 user message)
1754
+ this.contextHintAddition = contextHint;
902
1755
  onStream({ type: 'thinking', content: '🤔 开始思考...' });
903
1756
  if (!this.minimaxAvailable) {
904
- const response = await this.handleFallback(input);
1757
+ const response = await this.handleFallback(userText);
905
1758
  this.messageHistory.push({ role: 'assistant', content: response });
906
1759
  onStream({ type: 'done', content: '' });
907
1760
  return response;
@@ -909,7 +1762,21 @@ class PiAgentSession {
909
1762
  // P0 注入门: 缓存 onStream + signal, computeJudgmentGate 用 currentOnStream 广播 phase
910
1763
  this.currentOnStream = onStream;
911
1764
  this.currentSignal = signal ?? null;
912
- await this.computeJudgmentGate(input);
1765
+ await this.computeJudgmentGate(userText);
1766
+ // M2.2 (2026-06-17): intent 分类 — 0 LLM 成本, 5 行 keyword 匹配
1767
+ try {
1768
+ const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1769
+ this.currentIntent = classifyIntent(userText);
1770
+ this.currentIntentHint = intentHint(this.currentIntent);
1771
+ if (this.currentIntent !== 'chitchat') {
1772
+ onStream({ type: 'phase', phase: 'intent_classified', detail: this.currentIntent, content: '' });
1773
+ }
1774
+ }
1775
+ catch (err) {
1776
+ console.warn('[PiAgent] classifyIntent failed (non-fatal):', err);
1777
+ this.currentIntent = 'chitchat';
1778
+ this.currentIntentHint = '';
1779
+ }
913
1780
  // P1.1: 异步跑 Auto-Compact (LLM 摘要, 仅在 budget 超限时触发, 失败静默)
914
1781
  // 复用 computeJudgmentGate 的 onStream 广播 phase, 跟 judgment 注入门风格一致
915
1782
  try {
@@ -939,6 +1806,51 @@ class PiAgentSession {
939
1806
  this.currentPermissionMode = 'default';
940
1807
  }
941
1808
  this.promptStartTime = Date.now();
1809
+ // M3.1 (2026-06-17): 走 WorkflowPivotLoop (usePivotLoop: true)
1810
+ // pivot loop 自带 quality scoring / 30 iter cap / complexity analysis — 比老 runReActLoop 鲁棒
1811
+ if (this.usePivotLoop) {
1812
+ let pivotResult = '';
1813
+ try {
1814
+ const lr = await this.promptWithPivotLoop(userText, undefined, channelId);
1815
+ pivotResult = lr.response || '';
1816
+ onStream({ type: 'done', content: '' });
1817
+ }
1818
+ catch (err) {
1819
+ if (signal?.aborted || err?.name === 'AbortError') {
1820
+ console.log(`[chat] pivot aborted channel=${channelId}`);
1821
+ }
1822
+ else {
1823
+ console.error(`[chat] pivot 失败 channel=${channelId}:`, err);
1824
+ pivotResult = `[错误: pivot loop 失败] ${String(err?.message || err).slice(0, 300)}`;
1825
+ try {
1826
+ onStream({ type: 'error', content: pivotResult, tool: 'system' });
1827
+ }
1828
+ catch { }
1829
+ }
1830
+ }
1831
+ finally {
1832
+ if (this.judgmentGateUsedIds.length > 0) {
1833
+ try {
1834
+ onStream({ type: 'used_judgments', usedIds: this.judgmentGateUsedIds, content: '' });
1835
+ }
1836
+ catch { }
1837
+ }
1838
+ monitorAfterReply(userText, pivotResult);
1839
+ const stopStartTime = this.promptStartTime || Date.now();
1840
+ onStop({
1841
+ channelId: this.currentChannelId || 'unknown',
1842
+ durationMs: Date.now() - stopStartTime,
1843
+ usedJudgmentIds: [...this.judgmentGateUsedIds],
1844
+ }).catch((err) => console.warn('[PiAgent] onStop failed:', err));
1845
+ this.clearJudgmentGate();
1846
+ this.currentOnStream = null;
1847
+ this.currentSignal = null;
1848
+ this.bootstrapAddition = '';
1849
+ this.contextHintAddition = '';
1850
+ this.promptStartTime = 0;
1851
+ }
1852
+ return pivotResult;
1853
+ }
942
1854
  // 2026-06-16: loop 自动重试 — runReActLoop 内部遇到 [AI 服务调用失败] sentinel 时,
943
1855
  // 会设 aiFailed=true 并提前 break. 这里在外层重跑整个 loop (不是单次 LLM 调用),
944
1856
  // 临时网络抖动 / 配额瞬时超限可自愈. 最多 3 次, 指数退避 1s/2s/4s.
@@ -1057,14 +1969,27 @@ class PiAgentSession {
1057
1969
  }
1058
1970
  // P0 注入门: 在构造 systemPrompt 之前算一次, 拼到末尾
1059
1971
  await this.computeJudgmentGate(input);
1060
- const personaSection = this.persona ? `
1972
+ // M2.2 (2026-06-17): intent 分类 — pivot loop 也要拿到 hint
1973
+ try {
1974
+ const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1975
+ this.currentIntent = classifyIntent(input);
1976
+ this.currentIntentHint = intentHint(this.currentIntent);
1977
+ }
1978
+ catch (err) {
1979
+ console.warn('[PiAgent] classifyIntent in pivot failed:', err);
1980
+ }
1981
+ // M2.4: persona 缓存
1982
+ if (!this.cachedPersonaSection && this.persona) {
1983
+ this.cachedPersonaSection = `
1061
1984
  角色描述: ${this.persona.description || '无'}
1062
1985
  性格特点: ${this.persona.personality || '无'}
1063
1986
  问候语: ${this.persona.greeting || '无'}
1064
- ` : '';
1065
- const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
1987
+ `;
1988
+ }
1989
+ const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${this.cachedPersonaSection}
1066
1990
  当前工作目录: ${this.cwd}
1067
1991
  当前身份: ${this.identity.name} (${this.identity.did})
1992
+ ${this.currentIntentHint}
1068
1993
 
1069
1994
  ${this.getToolDefinitions()}
1070
1995
 
@@ -1075,14 +2000,24 @@ ${this.getToolDefinitions()}
1075
2000
  4. 根据观察结果决定下一步
1076
2001
  5. 最终给出完整回答
1077
2002
 
1078
- 重要:
2003
+ 重要 (一次命中要求):
1079
2004
  - 每次只调用一个工具
1080
2005
  - 仔细分析工具返回结果
1081
2006
  - 当任务完成时,必须在回答末尾添加 <final gen> 标记表示结束
1082
- - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}`;
2007
+ - 如果需要更多信息,继续调用工具
2008
+
2009
+ 【工具调用格式 (严格遵守, 否则系统无法解析)】
2010
+ - 你只能输出**一个**工具调用, 不要堆叠多个 invoke
2011
+ - 工具调用格式: {"name":"<tool_name>","input":{"arg1":"value1"}}
2012
+ - 用 markdown json code block 包裹: \`\`\`json\n{"name":"X","input":{...}}\n\`\`\`
2013
+ - 工具调用前可以简短思考 (1-2 句话), 但**不要写长篇 thinking** (会撞 max_tokens)
2014
+ - 工具调用后必须等结果, 不要在同一个回复里继续输出
2015
+ - <final gen> 只在**真完成所有任务**时输出, 不要在工具调用前/中输出${this.judgmentGateAddition}${this.contextHintAddition}`;
1083
2016
  // 2026-06-15: 把 currentOnStream 传给 loop, 让 step-timeline 在 pivot 循环里也能 emit step_start/done
1084
2017
  // 之前 loop.execute() 不接 streamCallback, 导致 step-timeline 只能看到老 runReActLoop 路径
1085
2018
  // promptWithPivotLoop 路径 0 step events — UI 显示 timeline 但永远是空
2019
+ // 2026-06-17: 透传 signal 让 abort 工作 — loop.execute() 当前不接 signal 参数,
2020
+ // 所以 abort 行为通过 this.currentSignal 共享给 loop 内部读 (后续 M3.2 接 task plan 时一起加)
1086
2021
  const result = await loop.execute(input, llm, systemPrompt, this.currentOnStream ?? undefined);
1087
2022
  this.messageHistory.push({ role: 'user', content: input });
1088
2023
  if (result.response) {
@@ -1144,7 +2079,15 @@ ${this.getToolDefinitions()}
1144
2079
  if (totalErrors >= this.MAX_TOTAL_ERRORS) {
1145
2080
  console.warn(`[PiAgent] 累计错误 ${totalErrors} >= ${this.MAX_TOTAL_ERRORS}, 强制终止 (防死循环)`);
1146
2081
  onStream?.({ type: 'error', content: `⛔ 累计 ${totalErrors} 次错误, 强制终止 (防止 LLM 死循环)`, tool: 'loop' });
1147
- finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
2082
+ // 2026-06-19: 即使 LLM 一直失败, 也汇总之前成功执行的 tool result 给用户
2083
+ if (this.successfulToolResults.length > 0) {
2084
+ finalResponse = `✅ 之前步骤成功执行了 ${this.successfulToolResults.length} 个工具 (但 LLM 后续 ${totalErrors} 次调用失败):\n` +
2085
+ this.successfulToolResults.map((r, i) => ` ${i + 1}. ${r.tool}: ${r.outputPreview}`).join('\n') +
2086
+ `\n\n⚠️ (LLM 连续失败, 可能是 minimax 上游限流/网络问题, 工具已成功执行但 LLM 没能继续总结)`;
2087
+ }
2088
+ else {
2089
+ finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
2090
+ }
1148
2091
  break;
1149
2092
  }
1150
2093
  // 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
@@ -1177,6 +2120,10 @@ ${this.getToolDefinitions()}
1177
2120
  onStream({ type: 'status', content: `🔄 循环 ${iteration}/${this.MAX_REACT_ITERATIONS}`, tool: 'loop' });
1178
2121
  }
1179
2122
  const context = this.buildContext();
2123
+ // M3.5 (2026-06-17): 也构造 messages 数组版本, 让 LLM 看到结构化 tool 角色
2124
+ // buildContext() 把 history 序列化成字符串 — LLM 看不到 tool 调用的真实结果
2125
+ // 新版用 messages 数组直接喂给 LLM, 保留 role 语义 (user/assistant/tool/system)
2126
+ const messages = this.buildMessages();
1180
2127
  const toolDefs = this.getToolDefinitions();
1181
2128
  // 动态构建 refine 上下文
1182
2129
  let refineContext = '';
@@ -1187,15 +2134,20 @@ ${this.getToolDefinitions()}
1187
2134
  if (consecutiveErrors > 0) {
1188
2135
  refineContext += `\n【错误提示】上轮发生 ${consecutiveErrors} 次错误,请重新分析问题或换一种方式处理。`;
1189
2136
  }
1190
- const personaSection = this.persona ? `
2137
+ // M2.4: persona section 缓存 — persona loadPersona() 时一次设定, 此后不变
2138
+ if (!this.cachedPersonaSection && this.persona) {
2139
+ this.cachedPersonaSection = `
1191
2140
  角色描述: ${this.persona.description || '无'}
1192
2141
  性格特点: ${this.persona.personality || '无'}
1193
2142
  问候语: ${this.persona.greeting || '无'}
1194
- ` : '';
2143
+ `;
2144
+ }
2145
+ const personaSection = this.cachedPersonaSection;
1195
2146
  const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
1196
2147
  当前工作目录: ${this.cwd}
1197
2148
  当前身份: ${this.identity.name} (${this.identity.did})
1198
2149
  ${refineContext}
2150
+ ${this.currentIntentHint}
1199
2151
 
1200
2152
  ${toolDefs}
1201
2153
 
@@ -1210,26 +2162,35 @@ ${toolDefs}
1210
2162
  - 每次只调用一个工具
1211
2163
  - 仔细分析工具返回结果
1212
2164
  - 当任务完成时,必须在回答末尾添加 <final gen> 标记表示结束
1213
- - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}`;
2165
+ - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}${this.contextHintAddition}`;
1214
2166
  // 3 个恢复机制 (Claude Code 论文 9-step pipeline 内部):
1215
2167
  // 1. max output token 升级 (最多 3 次, 每次 maxOutputTokens 翻倍)
1216
2168
  // 2. reactive compaction (prompt 估算超阈值, 跑压缩)
1217
2169
  // 3. prompt-too-long (LLM 报错 4xxx token 错误, 跑 reactive compaction 再试 1 次)
1218
2170
  // 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
1219
- const response = await this.callLlmWithRecovery(llm, context, systemPrompt, signal, onStream);
2171
+ const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream);
1220
2172
  const reply = (response.reply || '').trim();
1221
- // 2026-06-16: 看到 [AI 服务调用失败] sentinel → 不再立即 break,
1222
- // 而是设 aiFailed=true, 让外层 promptStream 自动重跑整个 loop 最多 N 次
1223
- // (LLM API 401 / 网络错 / 配额满时, pi-ai 返回这个 prefix;
1224
- // 自动 retry 兜底: 临时网络抖动可自愈, 真挂 N 次后才报失败)
2173
+ // 2026-06-19 架构 fix: 不再因 [AI 服务调用失败] break
2174
+ // 旧逻辑: sentinel aiFailed=true break 外层 retry 整个 loop (重置 history)
2175
+ // 新逻辑: 把错误当 tool_result push history 下一轮 LLM 看到错误能反思重试
2176
+ // 这是 dive-into 文档的"fail-open error recovery" 错误进入 context, 不让 LLM 重复犯同样错
1225
2177
  if (reply.startsWith('[AI 服务调用失败]')) {
1226
- console.log(`[PiAgent] 收到 AI 错误 sentinel, 标记 aiFailed, 外层会自动重试整个 loop`);
1227
- aiFailed = true;
2178
+ console.log(`[PiAgent] 收到 AI 错误 sentinel, 推到 history LLM 反思, 继续 loop`);
1228
2179
  aiFailureReason = reply.length > 200 ? reply.substring(0, 200) : reply;
2180
+ totalErrors++;
2181
+ consecutiveErrors++;
2182
+ // 把错误当成 tool 结果 push 进 history, 这样下一轮 LLM 看到错误能调整
2183
+ this.messageHistory.push({
2184
+ role: 'system',
2185
+ content: `[Loop 错误恢复 ${totalErrors}/${this.MAX_TOTAL_ERRORS}] ${aiFailureReason}\n\n请基于上轮工具结果继续完成任务, 不要重复调用同一失败操作. 如果工具已成功执行, 请基于 result.output 给用户总结; 如果工具失败, 请换其他方式或重试.`
2186
+ });
1229
2187
  if (onStream) {
1230
- onStream({ type: 'status', content: `⚠️ AI 调用失败, 将自动重试整个 loop`, tool: 'system' });
2188
+ onStream({ type: 'status', content: `⚠️ AI 调用失败 ${totalErrors}/${this.MAX_TOTAL_ERRORS}, push 错误到 history 让 LLM 反思`, tool: 'system' });
1231
2189
  }
1232
- break;
2190
+ // 退避 2s 后继续 — 临时 minimax 限流避开, 不让 loop 终止
2191
+ await new Promise(resolve => setTimeout(resolve, 2000));
2192
+ // 关键: 不设 aiFailed=true, 让外层不重试整个 loop (重置 history), 继续内层循环
2193
+ continue;
1233
2194
  }
1234
2195
  console.log(`[PiAgent] LLM 回复长度: ${reply.length}, 内容预览: "${reply.substring(0, 80)}..."`);
1235
2196
  console.log(`[PiAgent] LLM 完整回复:\n${reply}`);
@@ -1237,19 +2198,12 @@ ${toolDefs}
1237
2198
  if (onStream) {
1238
2199
  onStream({ type: 'token', content: reply.substring(0, 100) });
1239
2200
  }
1240
- if (this.isFinalResponse(reply)) {
1241
- // 检查质量分数
1242
- lastQualityScore = this.estimateResponseQuality(reply);
1243
- // 如果质量太低且还有改进机会,进入改进循环
1244
- if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
1245
- refineAttempts++;
1246
- console.log(`[PiAgent] 质量评分 ${(lastQualityScore * 10).toFixed(1)}/10 < ${(this.QUALITY_THRESHOLD * 10).toFixed(1)}/10,自动改进中 (${refineAttempts}/${this.MAX_REFINE_ATTEMPTS})`);
1247
- continue;
1248
- }
1249
- finalResponse = this.extractFinalAnswer(reply);
1250
- break;
1251
- }
2201
+ // 2026-06-19 架构 fix: parseToolCall 优先于 isFinalResponse
2202
+ // 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
2203
+ // 现在: 先尝试解析 tool_call, 有就执行; 没有才检查是不是真正的 final gen
1252
2204
  const toolCall = this.parseToolCall(reply);
2205
+ // 2026-06-19 修: 即便 reply 含 <final gen>, 只要 parseToolCall 命中, 就执行工具
2206
+ // (之前 isFinalResponse break 会先于 parseToolCall 触发, 思考块里有 final gen 字符串就误杀)
1253
2207
  if (toolCall) {
1254
2208
  this.messageHistory.push({
1255
2209
  role: 'assistant',
@@ -1448,6 +2402,16 @@ ${toolDefs}
1448
2402
  }
1449
2403
  if (result.success) {
1450
2404
  consecutiveErrors = 0; // 重置连续错误计数
2405
+ // 2026-06-19: 记录成功结果, 用于 LLM 失败退出时汇总给用户
2406
+ if (result.output) {
2407
+ this.successfulToolResults.push({
2408
+ tool: toolCall.name,
2409
+ outputPreview: result.output.substring(0, 200) + (result.output.length > 200 ? '...' : '')
2410
+ });
2411
+ }
2412
+ else {
2413
+ this.successfulToolResults.push({ tool: toolCall.name, outputPreview: '(无输出)' });
2414
+ }
1451
2415
  // 检查工具执行质量
1452
2416
  lastQualityScore = this.estimateToolResultQuality(result);
1453
2417
  if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
@@ -1519,6 +2483,29 @@ ${toolDefs}
1519
2483
  if (onStream) {
1520
2484
  onStream({ type: 'token', content: reply.substring(0, 150) });
1521
2485
  }
2486
+ // 2026-06-19 架构 fix: 只有 strip <think> 后才检查 isFinalResponse
2487
+ // (parseToolCall 已先尝试, 既然没解析出 tool_call, 现在检查 final gen 是否真的在最终回答区)
2488
+ if (this.isFinalResponse(reply)) {
2489
+ // 2026-06-19 dive-into 风格修复: 如果还有 successful tool results 没汇报,
2490
+ // LLM 不能提前 final_gen — harness 自动注入"请汇报剩余工具结果" hint 再 continue
2491
+ // 这是 dive-into 文档"step 9 stop condition check" 的具体化:
2492
+ // stop condition = (有工具结果未汇报) ? continue : break
2493
+ if (this.successfulToolResults.length > 0 && iteration < this.MAX_REACT_ITERATIONS) {
2494
+ const unreported = this.successfulToolResults.length;
2495
+ console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报, push hint 让其继续`);
2496
+ this.messageHistory.push({
2497
+ role: 'system',
2498
+ content: `[dive-into stop condition] 你之前已成功执行了 ${unreported} 个工具, 但当前回复里没把它们的结果告诉用户. 请基于已有的工具结果 (在 history 里) 写一个完整总结回复给用户, 用 <final gen> 结尾. 不要再调工具.`
2499
+ });
2500
+ if (onStream) {
2501
+ onStream({ type: 'status', content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结`, tool: 'system' });
2502
+ }
2503
+ continue;
2504
+ }
2505
+ lastQualityScore = this.estimateResponseQuality(reply);
2506
+ finalResponse = this.extractFinalAnswer(reply);
2507
+ break;
2508
+ }
1522
2509
  // 检查是否需要继续循环处理
1523
2510
  // 更严格的判断:只有当回复明确表示需要更多信息时才继续
1524
2511
  const containsToolCallIntent = reply.includes('调用工具') || reply.includes('tool(') ||
@@ -1646,6 +2633,44 @@ Workspace root folder: ${this.cwd}
1646
2633
  return m.content;
1647
2634
  }).join('\n');
1648
2635
  }
2636
+ /**
2637
+ * M3.5 (2026-06-17): 把 history 转成 messages 数组, 给 llm.chat() 用.
2638
+ * 不再用 buildContext() 把所有 role 压成字符串 — LLM 看不到 tool 调用结果.
2639
+ * messages 数组保留 role 语义, tool role 单独传递, LLM 能看到完整 tool 结果.
2640
+ *
2641
+ * 取最近 N 条, 同步压缩前 3 层 (跟 buildContext 同步).
2642
+ * 跳过 projectedHistory 路径 — messages 数组必须真实, 不能用投影.
2643
+ */
2644
+ buildMessages() {
2645
+ try {
2646
+ const recentMessages = this.compressHistorySync(this.messageHistory).slice(-15);
2647
+ const out = [];
2648
+ for (const m of recentMessages) {
2649
+ const role = m.role;
2650
+ let content = m.content;
2651
+ // tool role: 用 toolResult 序列化 (跟 buildContext 一样)
2652
+ if (role === 'tool') {
2653
+ const result = m.toolResult ? JSON.stringify(m.toolResult) : content;
2654
+ content = `[工具结果] ${result}`;
2655
+ }
2656
+ // system role (router hint 等) 直接保留
2657
+ if (role === 'system') {
2658
+ out.push({ role: 'system', content });
2659
+ continue;
2660
+ }
2661
+ // assistant / user / tool 直接转
2662
+ if (role === 'user' || role === 'assistant' || role === 'tool') {
2663
+ out.push({ role, content });
2664
+ }
2665
+ }
2666
+ return out;
2667
+ }
2668
+ catch (err) {
2669
+ console.warn('[PiAgent] buildMessages failed (silent, falling back to text):', err);
2670
+ // 退化: 用 buildContext 字符串包装成单 user message
2671
+ return [{ role: 'user', content: this.buildContext() }];
2672
+ }
2673
+ }
1649
2674
  /**
1650
2675
  * 估算 messageHistory 的 token 数 (4 字符 ≈ 1 token, 与 context-compaction 同步).
1651
2676
  * 失败静默: 任何异常 → 0 (不阻塞)
@@ -1667,7 +2692,7 @@ Workspace root folder: ${this.cwd}
1667
2692
  *
1668
2693
  * 失败静默: 全部失败 → 返回空 reply, 让上层 no-tool_use 终止
1669
2694
  */
1670
- async callLlmWithRecovery(llm, context, systemPrompt, signal, onStream) {
2695
+ async callLlmWithRecovery(llm, contextOrMessages, systemPrompt, signal, onStream) {
1671
2696
  // Reactive compaction 预检: 估算 token 超 80% 阈值, 跑一次
1672
2697
  const estimated = this.estimateHistoryTokens();
1673
2698
  if (estimated > this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * 0.8) {
@@ -1684,41 +2709,93 @@ Workspace root folder: ${this.cwd}
1684
2709
  console.warn('[PiAgent] reactive compaction pre-check failed:', err);
1685
2710
  }
1686
2711
  }
1687
- // 主调用 + 3 个恢复路径
2712
+ // 错误分级 (M1.3, 2026-06-17):
2713
+ // - 401/403/400 (认证/请求错误): 不重试, 直接 fail-fast
2714
+ // - 429 (rate limit): 重试 2 次, 指数退避
2715
+ // - 5xx (上游错误): 重试 2 次, 指数退避
2716
+ // - network (ECONNRESET / fetch failed / abort/timeout): 重试 2 次
2717
+ // - 4xx prompt-too-long: 走 reactive compaction
2718
+ // 这样以前所有错误都触发整个 runReActLoop 重跑(浪费 token),现在 4xx 直接失败
2719
+ // 让上层把失败原因广播给用户,而不是闷在 loop 里 retry 3 次后给空回复
2720
+ const classifyError = (err) => {
2721
+ const msg = String(err?.message || err || '');
2722
+ // 401/403: 认证失败
2723
+ if (/401|unauthor|invalid api key|api_key|forbidden|403/i.test(msg))
2724
+ return 'auth';
2725
+ // 400 prompt-too-long
2726
+ if (/token|too long|exceed|length|context|4000|413/i.test(msg))
2727
+ return 'prompt_too_long';
2728
+ // 429 rate limit
2729
+ if (/429|rate.?limit|too many requests/i.test(msg))
2730
+ return 'rate_limit';
2731
+ // 5xx
2732
+ if (/5\d\d|internal server|bad gateway|service unavailable|gateway timeout|cloudflare|502|503|504/i.test(msg))
2733
+ return 'server';
2734
+ // network
2735
+ if (/econnreset|econnrefused|enotfound|etimedout|fetch failed|network|aborted|timeout/i.test(msg))
2736
+ return 'network';
2737
+ return 'other';
2738
+ };
2739
+ const isRetryable = (cls) => cls === 'rate_limit' || cls === 'server' || cls === 'network' || cls === 'prompt_too_long';
2740
+ const maxAttempts = (cls) => isRetryable(cls) ? 3 : 1;
2741
+ const backoffMs = (attempt) => Math.min(1000 * 2 ** attempt, 8000); // 1s, 2s, 4s, 8s cap
1688
2742
  let lastErr = null;
1689
- for (let attempt = 0; attempt <= this.MAX_OUTPUT_TOKEN_ESCALATION_RETRIES; attempt++) {
2743
+ let lastClass = 'other';
2744
+ for (let attempt = 0; attempt < 4; attempt++) { // 最多 4 次尝试
1690
2745
  try {
1691
- const response = await llm.chat(context, systemPrompt, signal);
2746
+ // M3.5 (2026-06-17): messages 数组 (如果 contextOrMessages 是数组) 或字符串
2747
+ // 数组版让 LLM 看到结构化的 user/assistant/tool role, 而不是把 history 拼成单字符串
2748
+ const response = await llm.chat(contextOrMessages, systemPrompt, signal);
1692
2749
  return { reply: response.reply || '' };
1693
2750
  }
1694
2751
  catch (err) {
2752
+ // 用户主动 abort: 不重试, 立即抛
2753
+ if (signal?.aborted || err?.name === 'AbortError')
2754
+ throw err;
1695
2755
  lastErr = err;
1696
- const errMsg = String(err?.message || err || '');
1697
- const isPromptTooLong = /token|too long|exceed|length|context|4000|413|429/i.test(errMsg);
1698
- if (isPromptTooLong) {
1699
- console.warn(`[PiAgent] prompt-too-long 触发 (attempt ${attempt + 1}), reactive compaction`);
1700
- onStream?.({ type: 'status', content: `⚠️ prompt-too-long 触发 (attempt ${attempt + 1}/${this.MAX_OUTPUT_TOKEN_ESCALATION_RETRIES + 1})`, tool: 'recovery' });
2756
+ lastClass = classifyError(err);
2757
+ const errMsg = String(err?.message || err || '').slice(0, 200);
2758
+ const attempts = maxAttempts(lastClass);
2759
+ if (attempt + 1 >= attempts) {
2760
+ console.warn(`[PiAgent] LLM 调用失败, 不再重试 (class=${lastClass}, attempt=${attempt + 1}/${attempts}): ${errMsg}`);
2761
+ break;
2762
+ }
2763
+ console.warn(`[PiAgent] LLM 调用失败 (class=${lastClass}, attempt=${attempt + 1}/${attempts}), ${backoffMs(attempt)}ms 后重试: ${errMsg}`);
2764
+ onStream?.({ type: 'status', content: `⚠️ LLM 调用失败 (${lastClass}), 重试 ${attempt + 2}/${attempts}...`, tool: 'recovery' });
2765
+ if (lastClass === 'prompt_too_long') {
1701
2766
  try {
1702
2767
  await this.maybeAutoCompact(onStream, signal);
1703
2768
  }
1704
2769
  catch (compactionErr) {
1705
2770
  console.warn('[PiAgent] reactive compaction on prompt-too-long failed:', compactionErr);
1706
2771
  }
1707
- // 重新生成 context (compressHistorySync + projected)
1708
- context = this.buildContext();
2772
+ // 重新生成 context (重试 prompt_too_long 时重建 messages — 包含压缩后的 history)
2773
+ if (Array.isArray(contextOrMessages)) {
2774
+ contextOrMessages = this.buildMessages();
2775
+ }
2776
+ else {
2777
+ contextOrMessages = this.buildContext();
2778
+ }
1709
2779
  }
1710
2780
  else {
1711
- // 非 prompt-too-long 错误, 1 次重试就放弃
1712
- if (attempt === 0) {
1713
- console.warn(`[PiAgent] LLM 调用失败 (non-prompt-too-long), 1 次重试:`, err);
1714
- continue;
1715
- }
1716
- break;
2781
+ // 指数退避
2782
+ await new Promise((r) => setTimeout(r, backoffMs(attempt)));
1717
2783
  }
1718
2784
  }
1719
2785
  }
1720
- console.warn('[PiAgent] callLlmWithRecovery 全失败 (silent):', lastErr);
1721
- return { reply: '' };
2786
+ // 失败: 返回结构化错误 reply (而不是空字符串), 上层可识别 + UI 可显示
2787
+ const errMsg = String(lastErr?.message || lastErr || '').slice(0, 300);
2788
+ const userMsg = lastClass === 'auth'
2789
+ ? `[AI 服务调用失败] 认证错误: ${errMsg}\n请检查 API key 配置 (env: OPENAI_API_KEY / ANTHROPIC_API_KEY 等)`
2790
+ : lastClass === 'rate_limit'
2791
+ ? `[AI 服务调用失败] 上游限流 (429): ${errMsg}\n请稍后重试`
2792
+ : lastClass === 'server'
2793
+ ? `[AI 服务调用失败] 上游错误: ${errMsg}\n已重试 2 次仍失败, 可稍后重试`
2794
+ : lastClass === 'network'
2795
+ ? `[AI 服务调用失败] 网络错误: ${errMsg}\n请检查网络连接`
2796
+ : `[AI 服务调用失败] ${errMsg}`;
2797
+ console.warn(`[PiAgent] callLlmWithRecovery 全部失败 (class=${lastClass}): ${errMsg}`);
2798
+ return { reply: userMsg };
1722
2799
  }
1723
2800
  /**
1724
2801
  * 同步压缩: 跑前 3 层 (Budget Reduction / Snip / Microcompact).
@@ -1795,8 +2872,11 @@ Workspace root folder: ${this.cwd}
1795
2872
  }
1796
2873
  }
1797
2874
  isFinalResponse(content) {
1798
- // 只有明确输出 <final gen> 才认为是最终回答
1799
- return content.includes('<final gen>');
2875
+ // 2026-06-19 修: 先剥离 <think>...</think> 思考块, 再判断 <final gen>.
2876
+ // 之前用 includes('<final gen>') 误杀: LLM 思考块里说"先看看当前状态再 <final gen>"
2877
+ // 也会被识别成终止信号, 工具调用直接跳过了.
2878
+ const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, '');
2879
+ return stripped.includes('<final gen>');
1800
2880
  }
1801
2881
  extractFinalAnswer(content) {
1802
2882
  // 提取 <final gen> 后的内容作为最终回答
@@ -1822,7 +2902,89 @@ Workspace root folder: ${this.cwd}
1822
2902
  return cleaned;
1823
2903
  }
1824
2904
  parseToolCall(content) {
2905
+ // 2026-06-19 修: JSON function-call (OpenAI/Anthropic/Minimax-style) 优先 — match[1] 是 name 字段, match[2] 是 arguments/input 对象
2906
+ // LLM 实际产出变体:
2907
+ // {"name": "shell_exec", "arguments": {"command": "git", "args": ["status"]}} (OpenAI 标准)
2908
+ // {"name": "shell_exec", "input": {"command": "git", "args": ["status"]}} (Anthropic/Minimax 风格)
2909
+ // 常见包裹: ```json\n{...}\n``` (markdown code block), <tool_call>{...}</tool_call> (OpenAI Hermes)
2910
+ const jsonPatterns = [
2911
+ // markdown json code block + OpenAI <tool_call> 块, 同时匹配 arguments/input 字段
2912
+ /(?:```(?:json|json5)?\s*\n?)?\{[\s\S]*?"name"\s*:\s*["'](\w+)["']\s*,\s*["']?(?:arguments|input)["']?\s*:\s*(\{[\s\S]*?\})\s*\}/,
2913
+ ];
2914
+ for (const p of jsonPatterns) {
2915
+ const m = content.match(p);
2916
+ if (m) {
2917
+ const name = m[1];
2918
+ let args = {};
2919
+ try {
2920
+ const parsed = JSON.parse(m[2]);
2921
+ if (parsed && typeof parsed === 'object') {
2922
+ args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
2923
+ }
2924
+ }
2925
+ catch { /* 解析失败保持 args={} */ }
2926
+ const resolved = this.resolveToolName(name);
2927
+ if (resolved) {
2928
+ return { name: resolved, args };
2929
+ }
2930
+ }
2931
+ }
2932
+ // 2026-06-19 修: fallback — LLM 有时输出 <tool_name>shell_exec</tool_name> 这种伪 XML 标签
2933
+ // 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配, 但 name="tool_name" 不在 tools 里
2934
+ // 这里 detect 内容是否像 tool_call (有 <command>/<args>/<path> 子标签) 然后按 <command> 第一词推断工具
2935
+ const xmlTagMatch = content.match(/<(\w+)>([\s\S]*?)<\/\1>/);
2936
+ if (xmlTagMatch) {
2937
+ const outerTag = xmlTagMatch[1];
2938
+ const inner = xmlTagMatch[2];
2939
+ // 如果外层标签不在 tools 里但有 <command>/<args> 子标签, 尝试按内容推断
2940
+ if (!this.resolveToolName(outerTag)) {
2941
+ const cmdMatch = inner.match(/<command>(\w+)<\/command>/);
2942
+ if (cmdMatch) {
2943
+ const cmd = cmdMatch[1];
2944
+ // 推测工具: git/npx/npm/tsx → shell_exec; cat/head/tail → read_document
2945
+ if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmd)) {
2946
+ const args = {};
2947
+ const argsMatch = inner.match(/<args>([\s\S]*?)<\/args>/);
2948
+ if (argsMatch)
2949
+ args.args = argsMatch[1].trim();
2950
+ const cmdsMatch = inner.match(/<command>([\s\S]*?)<\/command>/);
2951
+ if (cmdsMatch)
2952
+ args.command = cmdsMatch[1].trim();
2953
+ return { name: 'shell_exec', args };
2954
+ }
2955
+ }
2956
+ }
2957
+ }
2958
+ // 2026-06-19 修: <tools:call name="X">...</tools:call> 嵌套格式
2959
+ // LLM 实际产出变体: <tools:call name="shell_exec"><tools:call name="command">git</tools:call>...</tools:call>
2960
+ const toolsCallMatch = content.match(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/);
2961
+ if (toolsCallMatch) {
2962
+ const name = toolsCallMatch[1];
2963
+ const inner = toolsCallMatch[2];
2964
+ const args = {};
2965
+ const argTags = inner.matchAll(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/g);
2966
+ for (const m of argTags) {
2967
+ args[m[1]] = m[2].trim();
2968
+ }
2969
+ const resolved = this.resolveToolName(name);
2970
+ if (resolved) {
2971
+ return { name: resolved, args };
2972
+ }
2973
+ // 兜底: 如果外层 tool name 不在 tools, 但内层有 command, 推断 shell_exec
2974
+ if (args.command) {
2975
+ const cmdFirst = args.command.split(/\s+/)[0];
2976
+ if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmdFirst)) {
2977
+ return { name: 'shell_exec', args };
2978
+ }
2979
+ }
2980
+ }
1825
2981
  const patterns = [
2982
+ // 2026-06-19 修: minimax/Hermes 自闭合 XML 格式 <invoke name="X">...</invoke>
2983
+ // 实际 LLM 习惯: <invoke name="shell_exec"><command>sed</command><args>["-n","1060,1095p"]</args></invoke>
2984
+ // 必须放在最前 — 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配到外层 <invoke> 但 name 是 "invoke" 而不是 "shell_exec"
2985
+ new RegExp('<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>'),
2986
+ // 2026-06-19 修: <function_calls> 包裹
2987
+ new RegExp('<function_calls>[\\s\\S]*?<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>[\\s\\S]*?</function_calls>'),
1826
2988
  /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
1827
2989
  /使用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
1828
2990
  /tool[_\w]*[::]\s*(\w+)\s*\(([^)]*)\)/i,
@@ -1883,13 +3045,157 @@ Workspace root folder: ${this.cwd}
1883
3045
  args[key] = valueParts.join(':') || '';
1884
3046
  }
1885
3047
  }
1886
- if (this.tools.has(name) || this.tools.has(name.replace(/_/g, '_'))) {
3048
+ if (this.tools.has(name)) {
1887
3049
  return { name, args };
1888
3050
  }
3051
+ const resolved = this.resolveToolName(name);
3052
+ if (resolved) {
3053
+ return { name: resolved, args };
3054
+ }
1889
3055
  }
1890
3056
  }
1891
3057
  return null;
1892
3058
  }
3059
+ // [debug-2026-06-19] 临时: 打印 parseToolCall 输入和返回
3060
+ _dbgParseToolCall(content) {
3061
+ const r = this.parseToolCall(content);
3062
+ console.log('[DBG parseToolCall] result:', JSON.stringify(r), 'content head:', JSON.stringify(content.substring(0, 200)));
3063
+ return r;
3064
+ }
3065
+ /**
3066
+ * 2026-06-19: 工具名大小写不敏感 + Claude Code 风格别名映射
3067
+
3068
+ /**
3069
+ * 2026-06-19: 工具名大小写不敏感 + Claude Code 风格别名映射
3070
+ * LLM 实际产出 Read/Edit/Write/Bash/Grep/Glob 等大写名 (Claude Code 工具命名)
3071
+ * bolloon 注册的是 read_document / edit_file / write_file / shell_exec / list_files
3072
+ * 返回 this.tools 里的标准名, 或 null 表示未识别
3073
+ */
3074
+ resolveToolName(name) {
3075
+ if (this.tools.has(name))
3076
+ return name;
3077
+ const lower = name.toLowerCase();
3078
+ const aliasMap = {
3079
+ // Claude Code 风格 → bolloon 工具
3080
+ read: 'read_file',
3081
+ edit: 'edit_file',
3082
+ write: 'write_file',
3083
+ rm: 'delete_file',
3084
+ mv: 'move_file',
3085
+ bash: 'shell_exec',
3086
+ shell: 'shell_exec',
3087
+ sh: 'shell_exec',
3088
+ // grep / glob 现在是独立工具
3089
+ cat: 'read_file',
3090
+ // 测试/编译 → 独立工具
3091
+ test: 'vitest_run',
3092
+ vitest: 'vitest_run',
3093
+ typecheck: 'tsc_check',
3094
+ tsc: 'tsc_check',
3095
+ // git 操作
3096
+ log: 'git_log',
3097
+ show: 'git_show',
3098
+ diff: 'git_diff',
3099
+ commit: 'git_commit',
3100
+ push: 'git_push',
3101
+ branch: 'git_branch',
3102
+ checkout: 'git_branch',
3103
+ stash: 'git_stash',
3104
+ // 任务管理 (todo)
3105
+ todo_write: 'create_task',
3106
+ todowrite: 'create_task',
3107
+ task: 'create_task',
3108
+ };
3109
+ const aliased = aliasMap[lower];
3110
+ if (aliased && this.tools.has(aliased))
3111
+ return aliased;
3112
+ if (this.tools.has(lower))
3113
+ return lower;
3114
+ return null;
3115
+ }
3116
+ /**
3117
+ * 2026-06-19: 注册 P2P 消息 listener, 把收到的消息存到 _inboxMessages 供 check_inbox 读.
3118
+ * 监听 type='message' (send_to_peer) 和 type='agent-message' (P2P 远端 agent).
3119
+ * 失败静默, 不阻塞 PiAgentSession 构造.
3120
+ */
3121
+ _setupInboxListener() {
3122
+ if (!p2pNetwork || typeof p2pNetwork.onMessage !== 'function')
3123
+ return;
3124
+ try {
3125
+ // 监听所有 type 的消息, 存储到 inbox
3126
+ p2pNetwork.onMessage('*', (msg, from, did) => {
3127
+ try {
3128
+ const text = new TextDecoder().decode(msg);
3129
+ // 解析 DID:|type:payload 格式
3130
+ let type = 'message';
3131
+ let payload = text;
3132
+ if (text.startsWith('DID:')) {
3133
+ const pipeIdx = text.indexOf('|');
3134
+ if (pipeIdx > 0) {
3135
+ const rest = text.substring(pipeIdx + 1);
3136
+ const colonIdx = rest.indexOf(':');
3137
+ if (colonIdx > 0) {
3138
+ type = rest.substring(0, colonIdx);
3139
+ payload = rest.substring(colonIdx + 1);
3140
+ }
3141
+ else {
3142
+ type = rest;
3143
+ payload = '';
3144
+ }
3145
+ }
3146
+ }
3147
+ else {
3148
+ const colonIdx = text.indexOf(':');
3149
+ if (colonIdx > 0) {
3150
+ type = text.substring(0, colonIdx);
3151
+ payload = text.substring(colonIdx + 1);
3152
+ }
3153
+ }
3154
+ this._inboxMessages.push({
3155
+ id: `p2p-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3156
+ from: from.substring(0, 32),
3157
+ fromDid: did,
3158
+ type,
3159
+ payload,
3160
+ timestamp: Date.now(),
3161
+ source: 'p2p',
3162
+ });
3163
+ // 限制 inbox 大小, 防止内存泄漏
3164
+ if (this._inboxMessages.length > 1000) {
3165
+ this._inboxMessages = this._inboxMessages.slice(-1000);
3166
+ }
3167
+ }
3168
+ catch (err) {
3169
+ console.warn('[PiAgent] inbox listener decode error:', err);
3170
+ }
3171
+ });
3172
+ }
3173
+ catch (err) {
3174
+ console.warn('[PiAgent] _setupInboxListener failed (non-fatal):', err);
3175
+ }
3176
+ // 监听本地 inbox bus 投递 (send_to_local_agent)
3177
+ try {
3178
+ const { LocalInboxBus } = require('../network/local-inbox-bus.js');
3179
+ const myRole = process.env.BOLLOON_ROLE || 'default';
3180
+ LocalInboxBus.getInstance().subscribe(myRole, (msg) => {
3181
+ this._inboxMessages.push({
3182
+ id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3183
+ from: msg.from || 'unknown',
3184
+ fromDid: msg.fromDid,
3185
+ type: msg.type || 'agent-local-message',
3186
+ payload: msg.payload || '',
3187
+ timestamp: msg.timestamp || Date.now(),
3188
+ source: 'local',
3189
+ });
3190
+ if (this._inboxMessages.length > 1000) {
3191
+ this._inboxMessages = this._inboxMessages.slice(-1000);
3192
+ }
3193
+ });
3194
+ }
3195
+ catch (err) {
3196
+ // LocalInboxBus 可能还没创建, 不阻塞
3197
+ }
3198
+ }
1893
3199
  estimateResponseQuality(response) {
1894
3200
  let score = 0.5;
1895
3201
  if (response.length > 50)