@bolloon/bolloon-agent 0.1.41 → 0.2.0

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 (47) 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/build/icon.icns +0 -0
  5. package/build/icon.ico +0 -0
  6. package/build/icon.png +0 -0
  7. package/build/tray.png +0 -0
  8. package/build/trayTemplate.png +0 -0
  9. package/dist/agents/intent-classifier.js +99 -0
  10. package/dist/agents/pi-sdk.js +1418 -67
  11. package/dist/agents/pre-tool-validator.js +2 -1
  12. package/dist/agents/shell-guard.js +9 -3
  13. package/dist/agents/shell-tool.js +28 -13
  14. package/dist/agents/task-state.js +224 -0
  15. package/dist/agents/workflow-pivot-loop.js +30 -0
  16. package/dist/bollharness-integration/gate-state-machine.js +13 -0
  17. package/dist/bollharness-integration/integration.js +71 -0
  18. package/dist/bollharness-integration/skill-adapter.js +52 -127
  19. package/dist/bootstrap/context-hierarchy.js +6 -4
  20. package/dist/cli/interface.js +28 -0
  21. package/dist/documents/reader.js +14 -0
  22. package/dist/electron/config.js +21 -0
  23. package/dist/electron/dialogs.js +108 -0
  24. package/dist/electron/first-run.js +170 -0
  25. package/dist/electron/ipc.js +20 -0
  26. package/dist/electron/logger.js +114 -0
  27. package/dist/electron/main.js +63 -0
  28. package/dist/electron/menu.js +145 -0
  29. package/dist/electron/paths.js +75 -0
  30. package/dist/electron/server.js +112 -0
  31. package/dist/electron/tray.js +111 -0
  32. package/dist/electron/window.js +108 -0
  33. package/dist/git-transport/chat-render.js +155 -0
  34. package/dist/git-transport/chat-repo.js +370 -0
  35. package/dist/git-transport/chat-types.js +23 -0
  36. package/dist/git-transport/chat-watch.js +102 -0
  37. package/dist/index.js +477 -28
  38. package/dist/llm/pi-ai.js +103 -27
  39. package/dist/network/local-inbox-bus.js +73 -0
  40. package/dist/network/p2p-direct.js +3 -4
  41. package/dist/pi-ecosystem-judgment/adaptive-scan.js +6 -2
  42. package/dist/pi-ecosystem-judgment/causal-judge.js +3 -3
  43. package/dist/pi-ecosystem-judgment/index.js +1 -1
  44. package/dist/security/tool-gate.js +11 -0
  45. package/dist/web/server.js +68 -4
  46. package/package.json +8 -3
  47. package/scripts/lefthook-helper.sh +69 -0
@@ -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,13 +957,38 @@ 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();
750
964
  if (!cmd)
751
965
  return { success: false, error: 'command 必填' };
752
- const argList = String(args.args || '').split(',').map(s => s.trim()).filter(Boolean);
966
+ // 2026-06-19: 支持多种 args 格式
967
+ // 1. JSON 数组: ["checkout", "-b", "branch"] (LLM 偏好)
968
+ // 2. 逗号分隔: "checkout,-b,branch" (旧格式)
969
+ // 3. 字符串: "checkout -b branch" (单参数)
970
+ let argList = [];
971
+ const rawArgs = args.args;
972
+ if (Array.isArray(rawArgs)) {
973
+ argList = rawArgs.map((s) => String(s).trim()).filter(Boolean);
974
+ }
975
+ else if (typeof rawArgs === 'string') {
976
+ const trimmed = rawArgs.trim();
977
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
978
+ // JSON 数组字符串
979
+ try {
980
+ const parsed = JSON.parse(trimmed);
981
+ if (Array.isArray(parsed)) {
982
+ argList = parsed.map((s) => String(s).trim()).filter(Boolean);
983
+ }
984
+ }
985
+ catch { /* fall through to comma split */ }
986
+ }
987
+ if (argList.length === 0) {
988
+ // 逗号分隔 或 单字符串
989
+ argList = trimmed.split(',').map(s => s.trim()).filter(Boolean);
990
+ }
991
+ }
753
992
  const timeoutMs = Number(args.timeoutMs) || 30000;
754
993
  const result = await shellExec(cmd, argList, { timeoutMs });
755
994
  if (result.deniedByGuard) {
@@ -816,17 +1055,620 @@ class PiAgentSession {
816
1055
  }
817
1056
  }
818
1057
  });
1058
+ // M2.1 (2026-06-17): 注册 4 个长期缺失的工具 — 让 agent 真正能"修改 + 提交"代码
1059
+ // 路径限制走 shell-guard 同款 allowlist (复用 checkWritePath / checkCommand)
1060
+ // 这些工具之前在 tool-gate 白名单 + tool-manifest 中存在, 但 pi-sdk 未注册 — 修复孤儿
1061
+ this.tools.set('write_file', {
1062
+ name: 'write_file',
1063
+ 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) 会拒.',
1064
+ parameters: { path: '相对路径 (必填, 相对 cwd)', content: '文件内容 (必填)' },
1065
+ execute: async (args) => {
1066
+ const relPath = String(args.path || '').trim();
1067
+ const content = String(args.content ?? '');
1068
+ if (!relPath)
1069
+ return { success: false, error: 'path 必填' };
1070
+ if (content.length > 100_000)
1071
+ return { success: false, error: `内容过大 (${content.length} > 100000 字节), 请分块写` };
1072
+ // 路径检查: 复用 shell-guard 的 checkWritePath
1073
+ const pathResult = checkWritePath(relPath);
1074
+ if (!pathResult.allowed) {
1075
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1076
+ }
1077
+ try {
1078
+ const absPath = path.resolve(this.cwd, relPath);
1079
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
1080
+ await fs.writeFile(absPath, content, 'utf-8');
1081
+ return { success: true, output: `✅ wrote ${relPath} (${content.length} bytes)` };
1082
+ }
1083
+ catch (e) {
1084
+ return { success: false, error: `写文件失败: ${String(e)}` };
1085
+ }
1086
+ }
1087
+ });
1088
+ this.tools.set('edit_file', {
1089
+ name: 'edit_file',
1090
+ description: '编辑一个文件: 在 path 处查找 old_text, 替换为 new_text. 找不到 old_text 会失败 (避免静默不替换). 路径同样受护栏限制.',
1091
+ parameters: { path: '相对路径 (必填)', old_text: '要替换的文本 (必填, 全文匹配)', new_text: '新文本 (必填)' },
1092
+ execute: async (args) => {
1093
+ const relPath = String(args.path || '').trim();
1094
+ const oldText = String(args.old_text ?? '');
1095
+ const newText = String(args.new_text ?? '');
1096
+ if (!relPath)
1097
+ return { success: false, error: 'path 必填' };
1098
+ if (!oldText)
1099
+ return { success: false, error: 'old_text 必填' };
1100
+ const pathResult = checkWritePath(relPath);
1101
+ if (!pathResult.allowed) {
1102
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1103
+ }
1104
+ try {
1105
+ const absPath = path.resolve(this.cwd, relPath);
1106
+ const original = await fs.readFile(absPath, 'utf-8');
1107
+ if (!original.includes(oldText)) {
1108
+ return { success: false, error: `old_text 在 ${relPath} 中未找到, 拒绝静默写入. 请先用 read_document 读最新内容.` };
1109
+ }
1110
+ const updated = original.replace(oldText, newText);
1111
+ await fs.writeFile(absPath, updated, 'utf-8');
1112
+ return { success: true, output: `✅ edited ${relPath} (${oldText.length} → ${newText.length} 字节)` };
1113
+ }
1114
+ catch (e) {
1115
+ return { success: false, error: `编辑文件失败: ${String(e)}` };
1116
+ }
1117
+ }
1118
+ });
1119
+ this.tools.set('git_diff', {
1120
+ name: 'git_diff',
1121
+ description: '查看 git diff. 默认显示未提交改动 (staged + unstaged), 可指定 ref1..ref2 看两个 commit/分支之间的 diff. 输出会截到 8000 字符避免超长.',
1122
+ parameters: { range: '可选. e.g. "HEAD~3..HEAD" 或 "master..agent/feat-x". 省略则看未提交改动.' },
1123
+ execute: async (args) => {
1124
+ const range = String(args.range || '').trim();
1125
+ const argv = range ? ['diff', range] : ['diff'];
1126
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1127
+ if (result.deniedByGuard)
1128
+ return { success: false, error: result.error };
1129
+ if (!result.success)
1130
+ return { success: false, error: result.error, output: result.output };
1131
+ const out = (result.output || '').slice(0, 8000);
1132
+ return { success: true, output: out || '(空 diff — 没有未提交改动)' };
1133
+ }
1134
+ });
1135
+ this.tools.set('git_commit', {
1136
+ name: 'git_commit',
1137
+ 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 兜底).',
1138
+ parameters: { message: 'commit message (必填, 用 HEREDOC 多行)' },
1139
+ execute: async (args) => {
1140
+ const message = String(args.message || '').trim();
1141
+ if (!message)
1142
+ return { success: false, error: 'message 必填' };
1143
+ // M3.4 (2026-06-17): agent git_commit 自动设 BOLLOON_AUTO_EVOLVE=1, 让 lefthook pre-commit 跳过 vitest/tsc
1144
+ // 这样频繁 commit 不会被 30s+ 的 pre-commit 阻塞. CI 会兜底 (push 后 GitHub Actions 跑测试)
1145
+ // 先 add
1146
+ const addResult = await shellExec('git', ['add', '-A'], { timeoutMs: 10_000 });
1147
+ if (addResult.deniedByGuard)
1148
+ return { success: false, error: addResult.error };
1149
+ if (!addResult.success)
1150
+ return { success: false, error: `git add 失败: ${addResult.error}` };
1151
+ // 再 commit (message 用 -m, 避免 HEREDOC 注入)
1152
+ // Windows shell 不支持 inline env var, 改用 child_process 直接 spawn
1153
+ // 简单方案: 用 spawn + 临时设 env
1154
+ try {
1155
+ const { spawn: spawnFn } = await import('child_process');
1156
+ const env = { ...process.env, BOLLOON_AUTO_EVOLVE: '1' };
1157
+ const output = await new Promise((resolve, reject) => {
1158
+ const proc = spawnFn('git', ['commit', '-m', message], {
1159
+ cwd: this.cwd, env, stdio: ['ignore', 'pipe', 'pipe'],
1160
+ });
1161
+ let stdout = '';
1162
+ let stderr = '';
1163
+ proc.stdout.on('data', (d) => stdout += d.toString());
1164
+ proc.stderr.on('data', (d) => stderr += d.toString());
1165
+ proc.on('close', (code) => code === 0 ? resolve(stdout) : reject(new Error(stderr || `git commit exited ${code}`)));
1166
+ proc.on('error', reject);
1167
+ });
1168
+ return { success: true, output: `✅ committed: ${message.split('\n')[0]}\n${output}` };
1169
+ }
1170
+ catch (e) {
1171
+ return { success: false, error: `git commit 失败: ${String(e.message || e).slice(0, 500)}` };
1172
+ }
1173
+ }
1174
+ });
1175
+ // M3.4: git_push — 改完直接 push (Q3-B 决策, 修改了 pre-push hook 让它别卡)
1176
+ // 命中护栏 (push to master/main, force-push) 仍会被拒 — 这两条底线不变
1177
+ this.tools.set('git_push', {
1178
+ name: 'git_push',
1179
+ description: 'git push 当前分支到 origin. 命中护栏 (push to master/main, --force) 仍会被拒. 用于长期项目自动 commit + push 循环.',
1180
+ parameters: { remote: '可选, 默认 origin', branch: '可选, 默认当前分支' },
1181
+ execute: async (args) => {
1182
+ const remote = String(args.remote || 'origin').trim();
1183
+ const branch = String(args.branch || '').trim();
1184
+ const argv = branch ? ['push', remote, branch] : ['push', remote];
1185
+ const result = await shellExec('git', argv, { timeoutMs: 60_000 });
1186
+ if (result.deniedByGuard)
1187
+ return { success: false, error: result.error };
1188
+ if (!result.success)
1189
+ return { success: false, error: result.error, output: result.output };
1190
+ return { success: true, output: `✅ pushed to ${remote}${branch ? `/${branch}` : ''}\n${result.output || ''}` };
1191
+ }
1192
+ });
1193
+ // M3.4: git_branch — 创建/切换分支 (用于多任务并行隔离)
1194
+ this.tools.set('git_branch', {
1195
+ name: 'git_branch',
1196
+ description: 'git checkout -b <name> 或 git checkout <name>. 用于多任务并行隔离 — 改前先 checkout 到 agent/<task-id> 分支.',
1197
+ parameters: { name: '分支名 (必填, e.g. "agent/task-123")', create: '可选, "true" 表示创建新分支 (默认 false = 切到已有)' },
1198
+ execute: async (args) => {
1199
+ const name = String(args.name || '').trim();
1200
+ const create = String(args.create || 'false') === 'true';
1201
+ if (!name)
1202
+ return { success: false, error: 'name 必填' };
1203
+ const argv = create ? ['checkout', '-b', name] : ['checkout', name];
1204
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1205
+ if (result.deniedByGuard)
1206
+ return { success: false, error: result.error };
1207
+ if (!result.success)
1208
+ return { success: false, error: result.error, output: result.output };
1209
+ return { success: true, output: `✅ ${create ? 'created + checked out' : 'checked out'} ${name}\n${result.output || ''}` };
1210
+ }
1211
+ });
1212
+ // 2026-06-19: 实用代码修改工具集 (M4) — 补全 read/delete/mkdir/move + grep/git log/show
1213
+ // 这些是 LLM 自主完成代码修改的最小工具集, 之前只能通过 shell_exec 间接调用
1214
+ // 通用文件读取 (支持任意扩展名, 跟 read_document 区别是后者只支持 txt/md/pdf/docx)
1215
+ this.tools.set('read_file', {
1216
+ name: 'read_file',
1217
+ description: '读取任意文件内容 (相对 cwd). 与 read_document 区别: 不限制文件类型. 受 shell-guard 路径白名单保护.',
1218
+ parameters: { path: '相对路径 (必填, e.g. "src/agents/pi-sdk.ts")', startLine: '起始行号 (可选, 默认 0)', maxLines: '最大行数 (可选, 默认 500)' },
1219
+ execute: async (args) => {
1220
+ const relPath = String(args.path || '').trim();
1221
+ if (!relPath)
1222
+ return { success: false, error: 'path 必填' };
1223
+ const pathResult = checkWritePath(relPath);
1224
+ if (!pathResult.allowed)
1225
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1226
+ try {
1227
+ const absPath = path.resolve(this.cwd, relPath);
1228
+ const content = fsSync.readFileSync(absPath, 'utf-8');
1229
+ const start = Math.max(0, parseInt(String(args.startLine || '0')) || 0);
1230
+ const max = parseInt(String(args.maxLines || '500')) || 500;
1231
+ const lines = content.split('\n');
1232
+ const slice = lines.slice(start, start + max);
1233
+ 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')}` };
1234
+ }
1235
+ catch (e) {
1236
+ return { success: false, error: `读取失败: ${e.message}` };
1237
+ }
1238
+ }
1239
+ });
1240
+ // 删除文件
1241
+ this.tools.set('delete_file', {
1242
+ name: 'delete_file',
1243
+ description: '删除一个文件. 受 shell-guard 路径白名单保护. 不可恢复, 调用前请确认.',
1244
+ parameters: { path: '相对路径 (必填)' },
1245
+ execute: async (args) => {
1246
+ const relPath = String(args.path || '').trim();
1247
+ if (!relPath)
1248
+ return { success: false, error: 'path 必填' };
1249
+ const pathResult = checkWritePath(relPath);
1250
+ if (!pathResult.allowed)
1251
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1252
+ try {
1253
+ const absPath = path.resolve(this.cwd, relPath);
1254
+ if (!fsSync.existsSync(absPath))
1255
+ return { success: false, error: `文件不存在: ${relPath}` };
1256
+ fsSync.unlinkSync(absPath);
1257
+ return { success: true, output: `✅ deleted ${relPath}` };
1258
+ }
1259
+ catch (e) {
1260
+ return { success: false, error: `删除失败: ${e.message}` };
1261
+ }
1262
+ }
1263
+ });
1264
+ // 创建目录
1265
+ this.tools.set('mkdir', {
1266
+ name: 'mkdir',
1267
+ description: '创建一个或多个目录. 自动 mkdir -p (父目录不存在也创建).',
1268
+ parameters: { path: '目录路径 (必填, 相对 cwd)' },
1269
+ execute: async (args) => {
1270
+ const relPath = String(args.path || '').trim();
1271
+ if (!relPath)
1272
+ return { success: false, error: 'path 必填' };
1273
+ const pathResult = checkWritePath(relPath);
1274
+ if (!pathResult.allowed)
1275
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1276
+ try {
1277
+ const absPath = path.resolve(this.cwd, relPath);
1278
+ fsSync.mkdirSync(absPath, { recursive: true });
1279
+ return { success: true, output: `✅ mkdir ${relPath}` };
1280
+ }
1281
+ catch (e) {
1282
+ return { success: false, error: `创建失败: ${e.message}` };
1283
+ }
1284
+ }
1285
+ });
1286
+ // 移动/重命名文件
1287
+ this.tools.set('move_file', {
1288
+ name: 'move_file',
1289
+ description: '移动或重命名文件. 源和目标路径都在白名单内才允许.',
1290
+ parameters: { from: '源路径 (必填)', to: '目标路径 (必填)' },
1291
+ execute: async (args) => {
1292
+ const from = String(args.from || '').trim();
1293
+ const to = String(args.to || '').trim();
1294
+ if (!from || !to)
1295
+ return { success: false, error: 'from 和 to 都必填' };
1296
+ const fromCheck = checkWritePath(from);
1297
+ if (!fromCheck.allowed)
1298
+ return { success: false, error: `from 路径被护栏拒: ${fromCheck.reason}` };
1299
+ const toCheck = checkWritePath(to);
1300
+ if (!toCheck.allowed)
1301
+ return { success: false, error: `to 路径被护栏拒: ${toCheck.reason}` };
1302
+ try {
1303
+ const fromAbs = path.resolve(this.cwd, from);
1304
+ const toAbs = path.resolve(this.cwd, to);
1305
+ if (!fsSync.existsSync(fromAbs))
1306
+ return { success: false, error: `源文件不存在: ${from}` };
1307
+ fsSync.mkdirSync(path.dirname(toAbs), { recursive: true });
1308
+ fsSync.renameSync(fromAbs, toAbs);
1309
+ return { success: true, output: `✅ ${from} → ${to}` };
1310
+ }
1311
+ catch (e) {
1312
+ return { success: false, error: `移动失败: ${e.message}` };
1313
+ }
1314
+ }
1315
+ });
1316
+ // 文件内搜索 (类似 grep)
1317
+ this.tools.set('grep_files', {
1318
+ name: 'grep_files',
1319
+ description: '在文件中搜索匹配 pattern 的行. 类似 grep -rn. 路径必须在白名单.',
1320
+ parameters: { pattern: '搜索 pattern (必填, 字符串, 不是正则)', path: '搜索目录 (可选, 默认 .)', filePattern: '文件名 glob (可选, e.g. "*.ts")' },
1321
+ execute: async (args) => {
1322
+ const pattern = String(args.pattern || '').trim();
1323
+ if (!pattern)
1324
+ return { success: false, error: 'pattern 必填' };
1325
+ const searchPath = String(args.path || '.').trim();
1326
+ const pathResult = checkWritePath(searchPath);
1327
+ if (!pathResult.allowed)
1328
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1329
+ try {
1330
+ const { execFile } = await import('child_process');
1331
+ const { promisify } = await import('util');
1332
+ const pExecFile = promisify(execFile);
1333
+ const argv = ['-rn', '--include=' + (args.filePattern || '*'), pattern, searchPath];
1334
+ const { stdout, stderr } = await pExecFile('grep', argv, { cwd: this.cwd, maxBuffer: 1024 * 1024 });
1335
+ const lines = stdout.split('\n').filter(Boolean).slice(0, 50);
1336
+ return { success: true, output: `🔍 grep "${pattern}" in ${searchPath} (${args.filePattern || '*'}, 最多 50 行):\n${lines.join('\n')}${lines.length === 50 ? '\n... (truncated)' : ''}` };
1337
+ }
1338
+ catch (e) {
1339
+ if (e.code === 1)
1340
+ return { success: true, output: `🔍 grep "${pattern}" in ${searchPath}: 0 matches` };
1341
+ return { success: false, error: `grep 失败: ${e.message}` };
1342
+ }
1343
+ }
1344
+ });
1345
+ // glob 找文件
1346
+ this.tools.set('glob_files', {
1347
+ name: 'glob_files',
1348
+ description: '用 glob pattern 找文件. 例如 "**/*.test.ts".',
1349
+ parameters: { pattern: 'glob pattern (必填, e.g. "src/**/*.ts")' },
1350
+ execute: async (args) => {
1351
+ const pattern = String(args.pattern || '').trim();
1352
+ if (!pattern)
1353
+ return { success: false, error: 'pattern 必填' };
1354
+ try {
1355
+ const pathResult = checkWritePath(pattern.replace(/\*\*.*$/, '').replace(/\/\*.*$/, '') || '.');
1356
+ if (!pathResult.allowed && pattern !== '**/*' && pattern !== '*')
1357
+ return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
1358
+ const { execFile } = await import('child_process');
1359
+ const { promisify } = await import('util');
1360
+ const pExecFile = promisify(execFile);
1361
+ // 用 shell 的 find + glob (避免引入额外依赖)
1362
+ const { stdout } = await pExecFile('find', [this.cwd, '-path', `*${pattern.replace(/\*\*\//, '*').replace(/\*\*$/, '*').replace(/\*$/, '*')}`, '-type', 'f'], { maxBuffer: 1024 * 1024 });
1363
+ const files = stdout.split('\n').filter(Boolean).slice(0, 100);
1364
+ const relFiles = files.map(f => path.relative(this.cwd, f));
1365
+ return { success: true, output: `🔍 glob "${pattern}" 找到 ${relFiles.length} 个文件${relFiles.length === 100 ? ' (truncated)' : ''}:\n${relFiles.join('\n')}` };
1366
+ }
1367
+ catch (e) {
1368
+ return { success: false, error: `glob 失败: ${e.message}` };
1369
+ }
1370
+ }
1371
+ });
1372
+ // git log
1373
+ this.tools.set('git_log', {
1374
+ name: 'git_log',
1375
+ description: '查看 git log. 默认 --oneline -10. 支持过滤和范围.',
1376
+ parameters: { range: '可选, e.g. "HEAD~5..HEAD" 或分支名', maxCount: '可选, 默认 10', oneline: '可选, "true" 用 --oneline (默认 true)' },
1377
+ execute: async (args) => {
1378
+ const range = String(args.range || '').trim();
1379
+ const maxCount = parseInt(String(args.maxCount || '10')) || 10;
1380
+ const oneline = String(args.oneline || 'true') === 'true';
1381
+ const argv = ['log'];
1382
+ if (oneline)
1383
+ argv.push('--oneline');
1384
+ if (maxCount > 0)
1385
+ argv.push(`-n`, String(maxCount));
1386
+ if (range)
1387
+ argv.push(range);
1388
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1389
+ if (result.deniedByGuard)
1390
+ return { success: false, error: result.error };
1391
+ if (!result.success)
1392
+ return { success: false, error: result.error, output: result.output };
1393
+ return { success: true, output: `📜 git log:\n${result.output || '(empty)'}` };
1394
+ }
1395
+ });
1396
+ // git show
1397
+ this.tools.set('git_show', {
1398
+ name: 'git_show',
1399
+ description: '查看 commit 内容. 默认 HEAD. 支持 --stat 看统计, --patch 看 diff.',
1400
+ parameters: { ref: '可选, 默认 HEAD', stat: '可选, "true" 只看 stat (默认 false)' },
1401
+ execute: async (args) => {
1402
+ const ref = String(args.ref || 'HEAD').trim();
1403
+ const argv = ['show', ref];
1404
+ if (String(args.stat || 'false') === 'true')
1405
+ argv.push('--stat');
1406
+ const result = await shellExec('git', argv, { timeoutMs: 15_000 });
1407
+ if (result.deniedByGuard)
1408
+ return { success: false, error: result.error };
1409
+ if (!result.success)
1410
+ return { success: false, error: result.error, output: result.output };
1411
+ return { success: true, output: `📜 git show ${ref}:\n${result.output || '(empty)'}` };
1412
+ }
1413
+ });
1414
+ // git stash
1415
+ this.tools.set('git_stash', {
1416
+ name: 'git_stash',
1417
+ description: 'git stash 暂存当前未提交改动. action: save/pop/list/apply/drop. 支持 message.',
1418
+ parameters: { action: '动作 (必填, "save" | "pop" | "list" | "apply" | "drop")', message: '可选, save 时的描述', index: '可选, apply/pop/drop 的 stash index (e.g. "0")' },
1419
+ execute: async (args) => {
1420
+ const action = String(args.action || '').trim();
1421
+ if (!['save', 'pop', 'list', 'apply', 'drop'].includes(action)) {
1422
+ return { success: false, error: `action 必须是 save/pop/list/apply/drop 之一, 收到: ${action}` };
1423
+ }
1424
+ const argv = ['stash', action];
1425
+ if (action === 'save' && args.message)
1426
+ argv.push('-m', String(args.message));
1427
+ if ((action === 'apply' || action === 'drop' || action === 'pop') && args.index) {
1428
+ argv.push('stash@{' + String(args.index) + '}');
1429
+ }
1430
+ const result = await shellExec('git', argv, { timeoutMs: 10_000 });
1431
+ if (result.deniedByGuard)
1432
+ return { success: false, error: result.error };
1433
+ if (!result.success)
1434
+ return { success: false, error: result.error, output: result.output };
1435
+ return { success: true, output: `✅ git stash ${action}\n${result.output || ''}` };
1436
+ }
1437
+ });
1438
+ // vitest 跑测试
1439
+ this.tools.set('vitest_run', {
1440
+ name: 'vitest_run',
1441
+ description: '跑 vitest 测试. 自动 bail (失败就停). 默认 60s timeout.',
1442
+ parameters: { pattern: '可选, 文件 glob e.g. "src/agents/pi-sdk.test.ts"', timeoutMs: '可选, 默认 60000' },
1443
+ execute: async (args) => {
1444
+ const argv = ['vitest', 'run', '--reporter=default', '--no-color', '--bail=1'];
1445
+ if (args.pattern)
1446
+ argv.push(String(args.pattern));
1447
+ const timeoutMs = parseInt(String(args.timeoutMs || '60000')) || 60000;
1448
+ const result = await shellExec('npx', argv, { timeoutMs });
1449
+ if (result.deniedByGuard)
1450
+ return { success: false, error: result.error };
1451
+ if (!result.success)
1452
+ return { success: false, error: result.error || 'vitest 失败', output: result.output };
1453
+ return { success: true, output: `✅ vitest 通过:\n${(result.output || '').slice(0, 2000)}` };
1454
+ }
1455
+ });
1456
+ // tsc 编译检查
1457
+ this.tools.set('tsc_check', {
1458
+ name: 'tsc_check',
1459
+ description: '跑 tsc --noEmit 检查 TypeScript 编译. 默认 60s timeout.',
1460
+ parameters: { project: '可选, tsconfig 路径 (默认 tsconfig.json)', timeoutMs: '可选, 默认 60000' },
1461
+ execute: async (args) => {
1462
+ const argv = ['tsc', '--noEmit'];
1463
+ if (args.project)
1464
+ argv.push('-p', String(args.project));
1465
+ else
1466
+ argv.push('-p', 'tsconfig.json');
1467
+ const timeoutMs = parseInt(String(args.timeoutMs || '60000')) || 60000;
1468
+ const result = await shellExec('npx', argv, { timeoutMs });
1469
+ if (result.deniedByGuard)
1470
+ return { success: false, error: result.error };
1471
+ if (!result.success)
1472
+ return { success: false, error: result.error || 'tsc 失败', output: result.output };
1473
+ return { success: true, output: `✅ tsc 通过:\n${(result.output || 'no errors').slice(0, 1000)}` };
1474
+ }
1475
+ });
1476
+ // M3.2: 任务状态机工具 — 让 agent 自己维护 multi_step 任务的状态
1477
+ this.tools.set('create_task', {
1478
+ name: 'create_task',
1479
+ description: '创建一个新多步任务, 初始 steps 列表由 LLM 给出. 返回 task-id, 后续用 update_task / get_task 跟踪进度. 任务状态写 ~/.bolloon/tasks/<id>.yaml 持久化.',
1480
+ parameters: {
1481
+ goal: '任务目标 (1 句话, 必填)',
1482
+ steps: '步骤列表 (数组, 必填, 至少 1 步)',
1483
+ sessionKey: '可选, 关联的 session key (channel:sessionId)',
1484
+ branch: '可选, 关联的 git 分支名',
1485
+ },
1486
+ execute: async (args) => {
1487
+ const goal = String(args.goal || '').trim();
1488
+ const stepsRaw = args.steps;
1489
+ if (!goal)
1490
+ return { success: false, error: 'goal 必填' };
1491
+ let steps = [];
1492
+ if (Array.isArray(stepsRaw)) {
1493
+ steps = stepsRaw.map((s) => String(s).trim()).filter(Boolean);
1494
+ }
1495
+ else if (typeof stepsRaw === 'string') {
1496
+ // 支持 "step1\nstep2\nstep3" 或 "step1,step2,step3"
1497
+ steps = stepsRaw.split(/[\n,]/).map(s => s.trim()).filter(Boolean);
1498
+ }
1499
+ if (steps.length === 0)
1500
+ return { success: false, error: 'steps 必填且至少 1 步' };
1501
+ const sessionKey = String(args.sessionKey || '').trim() || undefined;
1502
+ const branch = String(args.branch || '').trim() || undefined;
1503
+ try {
1504
+ const { createTask } = await import('./task-state.js');
1505
+ const task = await createTask({ goal, steps, sessionKey, branch });
1506
+ return { success: true, output: `✅ task created: ${task.id}\nbranch: ${branch || '(未指定)'}\nsteps:\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}` };
1507
+ }
1508
+ catch (e) {
1509
+ return { success: false, error: `create_task 失败: ${String(e)}` };
1510
+ }
1511
+ }
1512
+ });
1513
+ this.tools.set('update_task', {
1514
+ name: 'update_task',
1515
+ description: '更新任务的某一步状态 (pending → running → done/failed/skipped). 系统会自动推进下一步 pending → running (当当前步 done 时).',
1516
+ parameters: {
1517
+ task_id: '任务 id (必填)',
1518
+ step_id: '步骤 id (必填, e.g. "step-1")',
1519
+ status: '新状态 (running | done | failed | skipped)',
1520
+ result_summary: '可选, 结果摘要',
1521
+ error: '可选, 失败原因 (status=failed 时填)',
1522
+ },
1523
+ execute: async (args) => {
1524
+ const taskId = String(args.task_id || '').trim();
1525
+ const stepId = String(args.step_id || '').trim();
1526
+ const status = String(args.status || '').trim();
1527
+ if (!taskId || !stepId)
1528
+ return { success: false, error: 'task_id + step_id 必填' };
1529
+ if (!['running', 'done', 'failed', 'skipped'].includes(status)) {
1530
+ return { success: false, error: `status 必须是 running|done|failed|skipped` };
1531
+ }
1532
+ try {
1533
+ const { updateStep } = await import('./task-state.js');
1534
+ const patch = { status };
1535
+ if (args.result_summary)
1536
+ patch.resultSummary = String(args.result_summary);
1537
+ if (args.error)
1538
+ patch.error = String(args.error);
1539
+ const updated = await updateStep(taskId, stepId, patch);
1540
+ if (!updated)
1541
+ return { success: false, error: `任务 ${taskId} 未找到` };
1542
+ const nextRunning = updated.steps.find((s) => s.status === 'running');
1543
+ return {
1544
+ success: true,
1545
+ output: `✅ step ${stepId} → ${status}\ntask 状态: ${updated.status}${nextRunning ? `\n下一步: ${nextRunning.id} — ${nextRunning.description}` : ''}`,
1546
+ };
1547
+ }
1548
+ catch (e) {
1549
+ return { success: false, error: `update_task 失败: ${String(e)}` };
1550
+ }
1551
+ }
1552
+ });
1553
+ this.tools.set('get_task', {
1554
+ name: 'get_task',
1555
+ description: '查任务的当前状态和步骤进度. 用于跨 loop / 跨 session 恢复.',
1556
+ parameters: { task_id: '任务 id (必填)' },
1557
+ execute: async (args) => {
1558
+ const taskId = String(args.task_id || '').trim();
1559
+ if (!taskId)
1560
+ return { success: false, error: 'task_id 必填' };
1561
+ try {
1562
+ const { getTask } = await import('./task-state.js');
1563
+ const t = await getTask(taskId);
1564
+ if (!t)
1565
+ return { success: false, error: `任务 ${taskId} 未找到` };
1566
+ const lines = [
1567
+ `任务: ${t.id}`,
1568
+ `目标: ${t.goal}`,
1569
+ `状态: ${t.status}`,
1570
+ `branch: ${t.branch || '(未指定)'}`,
1571
+ `sessionKey: ${t.sessionKey || '(未指定)'}`,
1572
+ `创建: ${t.createdAt}`,
1573
+ `更新: ${t.updatedAt}`,
1574
+ ``,
1575
+ `步骤:`,
1576
+ ...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}` : ''}`),
1577
+ ];
1578
+ return { success: true, output: lines.join('\n') };
1579
+ }
1580
+ catch (e) {
1581
+ return { success: false, error: `get_task 失败: ${String(e)}` };
1582
+ }
1583
+ }
1584
+ });
1585
+ this.tools.set('list_tasks', {
1586
+ name: 'list_tasks',
1587
+ description: '列出最近 N 个任务 (默认 10). 用于多任务并行管理.',
1588
+ parameters: { limit: '可选, 默认 10' },
1589
+ execute: async (args) => {
1590
+ const limit = Number(args.limit) || 10;
1591
+ try {
1592
+ const { listTasks } = await import('./task-state.js');
1593
+ const tasks = await listTasks(limit);
1594
+ if (tasks.length === 0) {
1595
+ return { success: true, output: '当前没有任务. 用 create_task 创建一个.' };
1596
+ }
1597
+ const lines = tasks.map((t) => {
1598
+ const done = t.steps.filter((s) => s.status === 'done').length;
1599
+ const total = t.steps.length;
1600
+ return `${t.status === 'completed' ? '✅' : t.status === 'failed' ? '❌' : '🔄'} ${t.id} — ${t.goal} (${done}/${total} steps, branch: ${t.branch || '-'})`;
1601
+ });
1602
+ return { success: true, output: `最近 ${tasks.length} 个任务:\n${lines.join('\n')}` };
1603
+ }
1604
+ catch (e) {
1605
+ return { success: false, error: `list_tasks 失败: ${String(e)}` };
1606
+ }
1607
+ }
1608
+ });
1609
+ // M3.3 (2026-06-17): 工具幂等性 — 显式 cache 防止重试时副作用执行两次
1610
+ // 设计: 在 registerTools 末尾 wrap 所有 this.tools, 每个调用走 cache:
1611
+ // - 算 (toolName + JSON.stringify(args)) 的 hash
1612
+ // - 命中 cache → 返缓存结果 (不重跑副作用, 关键对 write_file / edit_file / shell_exec)
1613
+ // - 失败结果不缓存 (避免缓存 transient 错误)
1614
+ // - 缓存容量 200 条, 超过就清空
1615
+ this.wrapToolsWithIdempotency();
1616
+ }
1617
+ /** M3.3: 工具结果缓存 — 防止 loop 重试时副作用 (写文件 / 改代码) 执行多次 */
1618
+ idempotencyCache = new Map();
1619
+ IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; // 5 分钟内同 (tool, args) 走 cache
1620
+ IDEMPOTENCY_MAX = 200;
1621
+ wrapToolsWithIdempotency() {
1622
+ // 只 wrap 会产生副作用的工具 — 读类工具 (list_files, read_document, get_task) 不 wrap
1623
+ // (让 LLM 拿到最新数据, 不会因为缓存读到旧 task 状态)
1624
+ const SIDE_EFFECT_TOOLS = new Set([
1625
+ 'write_file', 'edit_file', 'shell_exec', 'git_commit', 'git_push', 'git_branch',
1626
+ 'create_task', 'update_task',
1627
+ ]);
1628
+ for (const [name, tool] of this.tools.entries()) {
1629
+ if (!SIDE_EFFECT_TOOLS.has(name))
1630
+ continue;
1631
+ const original = tool.execute;
1632
+ tool.execute = async (args) => {
1633
+ const key = `${name}|${JSON.stringify(args)}`;
1634
+ const cached = this.idempotencyCache.get(key);
1635
+ if (cached && Date.now() - cached.ts < this.IDEMPOTENCY_TTL_MS) {
1636
+ // 命中 — 加标记让 LLM 知道这是 cache (不会真执行副作用)
1637
+ return { ...cached.result, output: (cached.result.output || '') + '\n[↻ idempotency cache hit]' };
1638
+ }
1639
+ const result = await original(args);
1640
+ // 只缓存成功结果, 避免缓存 transient 错误
1641
+ if (result && result.success) {
1642
+ if (this.idempotencyCache.size >= this.IDEMPOTENCY_MAX) {
1643
+ this.idempotencyCache.clear();
1644
+ }
1645
+ this.idempotencyCache.set(key, { result, ts: Date.now() });
1646
+ }
1647
+ return result;
1648
+ };
1649
+ }
1650
+ }
1651
+ /** 清幂等性缓存 — 强制下次调用真正执行 (用于 agent 显式需要重新跑的场景) */
1652
+ clearIdempotencyCache() {
1653
+ this.idempotencyCache.clear();
819
1654
  }
820
1655
  async registerP2PDocumentReceiver() {
821
1656
  await initDocumentReceiver();
822
1657
  }
823
1658
  getToolDefinitions() {
824
- const defs = ['可用工具:'];
1659
+ // M2.4 (2026-06-17): 缓存 tool 定义 — registerTools() 在构造时调一次, 此后不变
1660
+ if (this.cachedToolDefinitions)
1661
+ return this.cachedToolDefinitions;
1662
+ const defs = ['可用工具 (name(params) - 简介):'];
825
1663
  for (const tool of this.tools.values()) {
826
- const params = Object.entries(tool.parameters).map(([k, v]) => `${k}: ${v}`).join(', ');
827
- defs.push(`- ${tool.name}(${params}) - ${tool.description}`);
1664
+ // 2026-06-19: 压缩 tool 定义 只显示参数名 (不显示描述, 减少 60% 长度)
1665
+ // 完整 description 在 history 第一轮注入 (getToolDefinitionsFull 调用), 后续轮只看简短
1666
+ // 避免 system prompt 太大导致 minimax 撞 max_tokens 输出空
1667
+ const paramNames = Object.keys(tool.parameters).join(',');
1668
+ defs.push(`- ${tool.name}(${paramNames})`);
828
1669
  }
829
- return defs.join('\n');
1670
+ this.cachedToolDefinitions = defs.join('\n');
1671
+ return this.cachedToolDefinitions;
830
1672
  }
831
1673
  async initSession() {
832
1674
  await this.sessionManager.initialize();
@@ -869,6 +1711,17 @@ class PiAgentSession {
869
1711
  this.currentSignal = options?.signal ?? null;
870
1712
  this.currentOnStream = options?.onStream ?? null;
871
1713
  await this.computeJudgmentGate(input);
1714
+ // M2.2 (2026-06-17): intent 分类 — prompt() 路径也跑 (跟 promptStream 对齐)
1715
+ try {
1716
+ const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1717
+ this.currentIntent = classifyIntent(input);
1718
+ this.currentIntentHint = intentHint(this.currentIntent);
1719
+ }
1720
+ catch (err) {
1721
+ console.warn('[PiAgent] classifyIntent in prompt() failed:', err);
1722
+ this.currentIntent = 'chitchat';
1723
+ this.currentIntentHint = '';
1724
+ }
872
1725
  // P2: 解析当前 permission mode
873
1726
  try {
874
1727
  const { resolvePermissionMode } = await import('./permission-mode.js');
@@ -878,6 +1731,22 @@ class PiAgentSession {
878
1731
  console.warn('[PiAgent] resolvePermissionMode failed (non-fatal):', err);
879
1732
  this.currentPermissionMode = 'default';
880
1733
  }
1734
+ // M3.1 (2026-06-17): 跟 promptStream 一样, usePivotLoop 时走 pivotLoop 路径
1735
+ // 之前 prompt() 永远跑老 runReActLoop, CLI/web 行为不一致
1736
+ if (this.usePivotLoop) {
1737
+ try {
1738
+ const lr = await this.promptWithPivotLoop(input, undefined, options?.channelId);
1739
+ return lr.response || '';
1740
+ }
1741
+ finally {
1742
+ if (this.judgmentGateUsedIds.length > 0) {
1743
+ recordJudgmentUsage(this.judgmentGateUsedIds, { userInput: input }).catch((err) => console.warn('[PiAgent] recordJudgmentUsage failed:', err));
1744
+ }
1745
+ this.clearJudgmentGate();
1746
+ this.currentSignal = null;
1747
+ this.currentOnStream = null;
1748
+ }
1749
+ }
881
1750
  try {
882
1751
  // 2026-06-16: runReActLoop 现在返回 { reply, aiFailed, aiFailureReason } — 这里只需 reply 字符串
883
1752
  const loopResult = await this.runReActLoop(undefined, options?.signal);
@@ -893,15 +1762,27 @@ class PiAgentSession {
893
1762
  }
894
1763
  }
895
1764
  async promptStream(input, onStream, signal, channelId) {
1765
+ console.log(`[PiAgent.promptStream] ENTRY, channelId=${channelId}, input chars=${input.length}`);
896
1766
  this.minimaxAvailable = this.checkMinimax();
1767
+ console.log(`[PiAgent.promptStream] minimaxAvailable=${this.minimaxAvailable}`);
897
1768
  this.currentChannelId = channelId ?? this.currentChannelId;
1769
+ // 2026-06-18 (supervisor): web server 把 46K markedPrompt 喂过来
1770
+ // (【本轮用户请求】\n<text>\n【请求结束】\n\n<contextHint>).
1771
+ // 整个 input 走下游, pivot loop 之前拿 47K buildContext 当 user message 发出去,
1772
+ // 模型撞 context window. 提取 userText 替代 input, contextHint 拼到 systemPrompt 末尾.
1773
+ const markerMatch = input.match(/【本轮用户请求】\s*([\s\S]*?)\s*【请求结束】/);
1774
+ const userText = markerMatch ? markerMatch[1].trim() : input;
1775
+ const contextHint = markerMatch ? input.replace(markerMatch[0], '').trim() : '';
1776
+ console.log(`[PiAgent.promptStream] marker matched=${!!markerMatch}, userText chars=${userText.length}, contextHint chars=${contextHint.length}`);
898
1777
  this.messageHistory.push({
899
1778
  role: 'user',
900
- content: input
1779
+ content: userText
901
1780
  });
1781
+ // 2026-06-18: web server 喂的 markedPrompt 外的 contextHint 拼到 system 末尾 (而不是当 user message)
1782
+ this.contextHintAddition = contextHint;
902
1783
  onStream({ type: 'thinking', content: '🤔 开始思考...' });
903
1784
  if (!this.minimaxAvailable) {
904
- const response = await this.handleFallback(input);
1785
+ const response = await this.handleFallback(userText);
905
1786
  this.messageHistory.push({ role: 'assistant', content: response });
906
1787
  onStream({ type: 'done', content: '' });
907
1788
  return response;
@@ -909,7 +1790,21 @@ class PiAgentSession {
909
1790
  // P0 注入门: 缓存 onStream + signal, computeJudgmentGate 用 currentOnStream 广播 phase
910
1791
  this.currentOnStream = onStream;
911
1792
  this.currentSignal = signal ?? null;
912
- await this.computeJudgmentGate(input);
1793
+ await this.computeJudgmentGate(userText);
1794
+ // M2.2 (2026-06-17): intent 分类 — 0 LLM 成本, 5 行 keyword 匹配
1795
+ try {
1796
+ const { classifyIntent, intentHint } = await import('./intent-classifier.js');
1797
+ this.currentIntent = classifyIntent(userText);
1798
+ this.currentIntentHint = intentHint(this.currentIntent);
1799
+ if (this.currentIntent !== 'chitchat') {
1800
+ onStream({ type: 'phase', phase: 'intent_classified', detail: this.currentIntent, content: '' });
1801
+ }
1802
+ }
1803
+ catch (err) {
1804
+ console.warn('[PiAgent] classifyIntent failed (non-fatal):', err);
1805
+ this.currentIntent = 'chitchat';
1806
+ this.currentIntentHint = '';
1807
+ }
913
1808
  // P1.1: 异步跑 Auto-Compact (LLM 摘要, 仅在 budget 超限时触发, 失败静默)
914
1809
  // 复用 computeJudgmentGate 的 onStream 广播 phase, 跟 judgment 注入门风格一致
915
1810
  try {
@@ -939,6 +1834,51 @@ class PiAgentSession {
939
1834
  this.currentPermissionMode = 'default';
940
1835
  }
941
1836
  this.promptStartTime = Date.now();
1837
+ // M3.1 (2026-06-17): 走 WorkflowPivotLoop (usePivotLoop: true)
1838
+ // pivot loop 自带 quality scoring / 30 iter cap / complexity analysis — 比老 runReActLoop 鲁棒
1839
+ if (this.usePivotLoop) {
1840
+ let pivotResult = '';
1841
+ try {
1842
+ const lr = await this.promptWithPivotLoop(userText, undefined, channelId);
1843
+ pivotResult = lr.response || '';
1844
+ onStream({ type: 'done', content: '' });
1845
+ }
1846
+ catch (err) {
1847
+ if (signal?.aborted || err?.name === 'AbortError') {
1848
+ console.log(`[chat] pivot aborted channel=${channelId}`);
1849
+ }
1850
+ else {
1851
+ console.error(`[chat] pivot 失败 channel=${channelId}:`, err);
1852
+ pivotResult = `[错误: pivot loop 失败] ${String(err?.message || err).slice(0, 300)}`;
1853
+ try {
1854
+ onStream({ type: 'error', content: pivotResult, tool: 'system' });
1855
+ }
1856
+ catch { }
1857
+ }
1858
+ }
1859
+ finally {
1860
+ if (this.judgmentGateUsedIds.length > 0) {
1861
+ try {
1862
+ onStream({ type: 'used_judgments', usedIds: this.judgmentGateUsedIds, content: '' });
1863
+ }
1864
+ catch { }
1865
+ }
1866
+ monitorAfterReply(userText, pivotResult);
1867
+ const stopStartTime = this.promptStartTime || Date.now();
1868
+ onStop({
1869
+ channelId: this.currentChannelId || 'unknown',
1870
+ durationMs: Date.now() - stopStartTime,
1871
+ usedJudgmentIds: [...this.judgmentGateUsedIds],
1872
+ }).catch((err) => console.warn('[PiAgent] onStop failed:', err));
1873
+ this.clearJudgmentGate();
1874
+ this.currentOnStream = null;
1875
+ this.currentSignal = null;
1876
+ this.bootstrapAddition = '';
1877
+ this.contextHintAddition = '';
1878
+ this.promptStartTime = 0;
1879
+ }
1880
+ return pivotResult;
1881
+ }
942
1882
  // 2026-06-16: loop 自动重试 — runReActLoop 内部遇到 [AI 服务调用失败] sentinel 时,
943
1883
  // 会设 aiFailed=true 并提前 break. 这里在外层重跑整个 loop (不是单次 LLM 调用),
944
1884
  // 临时网络抖动 / 配额瞬时超限可自愈. 最多 3 次, 指数退避 1s/2s/4s.
@@ -1057,14 +1997,27 @@ class PiAgentSession {
1057
1997
  }
1058
1998
  // P0 注入门: 在构造 systemPrompt 之前算一次, 拼到末尾
1059
1999
  await this.computeJudgmentGate(input);
1060
- const personaSection = this.persona ? `
2000
+ // M2.2 (2026-06-17): intent 分类 — pivot loop 也要拿到 hint
2001
+ try {
2002
+ const { classifyIntent, intentHint } = await import('./intent-classifier.js');
2003
+ this.currentIntent = classifyIntent(input);
2004
+ this.currentIntentHint = intentHint(this.currentIntent);
2005
+ }
2006
+ catch (err) {
2007
+ console.warn('[PiAgent] classifyIntent in pivot failed:', err);
2008
+ }
2009
+ // M2.4: persona 缓存
2010
+ if (!this.cachedPersonaSection && this.persona) {
2011
+ this.cachedPersonaSection = `
1061
2012
  角色描述: ${this.persona.description || '无'}
1062
2013
  性格特点: ${this.persona.personality || '无'}
1063
2014
  问候语: ${this.persona.greeting || '无'}
1064
- ` : '';
1065
- const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
2015
+ `;
2016
+ }
2017
+ const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${this.cachedPersonaSection}
1066
2018
  当前工作目录: ${this.cwd}
1067
2019
  当前身份: ${this.identity.name} (${this.identity.did})
2020
+ ${this.currentIntentHint}
1068
2021
 
1069
2022
  ${this.getToolDefinitions()}
1070
2023
 
@@ -1075,14 +2028,24 @@ ${this.getToolDefinitions()}
1075
2028
  4. 根据观察结果决定下一步
1076
2029
  5. 最终给出完整回答
1077
2030
 
1078
- 重要:
2031
+ 重要 (一次命中要求):
1079
2032
  - 每次只调用一个工具
1080
2033
  - 仔细分析工具返回结果
1081
2034
  - 当任务完成时,必须在回答末尾添加 <final gen> 标记表示结束
1082
- - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}`;
2035
+ - 如果需要更多信息,继续调用工具
2036
+
2037
+ 【工具调用格式 (严格遵守, 否则系统无法解析)】
2038
+ - 你只能输出**一个**工具调用, 不要堆叠多个 invoke
2039
+ - 工具调用格式: {"name":"<tool_name>","input":{"arg1":"value1"}}
2040
+ - 用 markdown json code block 包裹: \`\`\`json\n{"name":"X","input":{...}}\n\`\`\`
2041
+ - 工具调用前可以简短思考 (1-2 句话), 但**不要写长篇 thinking** (会撞 max_tokens)
2042
+ - 工具调用后必须等结果, 不要在同一个回复里继续输出
2043
+ - <final gen> 只在**真完成所有任务**时输出, 不要在工具调用前/中输出${this.judgmentGateAddition}${this.contextHintAddition}`;
1083
2044
  // 2026-06-15: 把 currentOnStream 传给 loop, 让 step-timeline 在 pivot 循环里也能 emit step_start/done
1084
2045
  // 之前 loop.execute() 不接 streamCallback, 导致 step-timeline 只能看到老 runReActLoop 路径
1085
2046
  // promptWithPivotLoop 路径 0 step events — UI 显示 timeline 但永远是空
2047
+ // 2026-06-17: 透传 signal 让 abort 工作 — loop.execute() 当前不接 signal 参数,
2048
+ // 所以 abort 行为通过 this.currentSignal 共享给 loop 内部读 (后续 M3.2 接 task plan 时一起加)
1086
2049
  const result = await loop.execute(input, llm, systemPrompt, this.currentOnStream ?? undefined);
1087
2050
  this.messageHistory.push({ role: 'user', content: input });
1088
2051
  if (result.response) {
@@ -1144,7 +2107,15 @@ ${this.getToolDefinitions()}
1144
2107
  if (totalErrors >= this.MAX_TOTAL_ERRORS) {
1145
2108
  console.warn(`[PiAgent] 累计错误 ${totalErrors} >= ${this.MAX_TOTAL_ERRORS}, 强制终止 (防死循环)`);
1146
2109
  onStream?.({ type: 'error', content: `⛔ 累计 ${totalErrors} 次错误, 强制终止 (防止 LLM 死循环)`, tool: 'loop' });
1147
- finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
2110
+ // 2026-06-19: 即使 LLM 一直失败, 也汇总之前成功执行的 tool result 给用户
2111
+ if (this.successfulToolResults.length > 0) {
2112
+ finalResponse = `✅ 之前步骤成功执行了 ${this.successfulToolResults.length} 个工具 (但 LLM 后续 ${totalErrors} 次调用失败):\n` +
2113
+ this.successfulToolResults.map((r, i) => ` ${i + 1}. ${r.tool}: ${r.outputPreview}`).join('\n') +
2114
+ `\n\n⚠️ (LLM 连续失败, 可能是 minimax 上游限流/网络问题, 工具已成功执行但 LLM 没能继续总结)`;
2115
+ }
2116
+ else {
2117
+ finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
2118
+ }
1148
2119
  break;
1149
2120
  }
1150
2121
  // 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
@@ -1177,6 +2148,10 @@ ${this.getToolDefinitions()}
1177
2148
  onStream({ type: 'status', content: `🔄 循环 ${iteration}/${this.MAX_REACT_ITERATIONS}`, tool: 'loop' });
1178
2149
  }
1179
2150
  const context = this.buildContext();
2151
+ // M3.5 (2026-06-17): 也构造 messages 数组版本, 让 LLM 看到结构化 tool 角色
2152
+ // buildContext() 把 history 序列化成字符串 — LLM 看不到 tool 调用的真实结果
2153
+ // 新版用 messages 数组直接喂给 LLM, 保留 role 语义 (user/assistant/tool/system)
2154
+ const messages = this.buildMessages();
1180
2155
  const toolDefs = this.getToolDefinitions();
1181
2156
  // 动态构建 refine 上下文
1182
2157
  let refineContext = '';
@@ -1187,15 +2162,20 @@ ${this.getToolDefinitions()}
1187
2162
  if (consecutiveErrors > 0) {
1188
2163
  refineContext += `\n【错误提示】上轮发生 ${consecutiveErrors} 次错误,请重新分析问题或换一种方式处理。`;
1189
2164
  }
1190
- const personaSection = this.persona ? `
2165
+ // M2.4: persona section 缓存 — persona loadPersona() 时一次设定, 此后不变
2166
+ if (!this.cachedPersonaSection && this.persona) {
2167
+ this.cachedPersonaSection = `
1191
2168
  角色描述: ${this.persona.description || '无'}
1192
2169
  性格特点: ${this.persona.personality || '无'}
1193
2170
  问候语: ${this.persona.greeting || '无'}
1194
- ` : '';
2171
+ `;
2172
+ }
2173
+ const personaSection = this.cachedPersonaSection;
1195
2174
  const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
1196
2175
  当前工作目录: ${this.cwd}
1197
2176
  当前身份: ${this.identity.name} (${this.identity.did})
1198
2177
  ${refineContext}
2178
+ ${this.currentIntentHint}
1199
2179
 
1200
2180
  ${toolDefs}
1201
2181
 
@@ -1210,26 +2190,35 @@ ${toolDefs}
1210
2190
  - 每次只调用一个工具
1211
2191
  - 仔细分析工具返回结果
1212
2192
  - 当任务完成时,必须在回答末尾添加 <final gen> 标记表示结束
1213
- - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}`;
2193
+ - 如果需要更多信息,继续调用工具${this.judgmentGateAddition}${this.contextHintAddition}`;
1214
2194
  // 3 个恢复机制 (Claude Code 论文 9-step pipeline 内部):
1215
2195
  // 1. max output token 升级 (最多 3 次, 每次 maxOutputTokens 翻倍)
1216
2196
  // 2. reactive compaction (prompt 估算超阈值, 跑压缩)
1217
2197
  // 3. prompt-too-long (LLM 报错 4xxx token 错误, 跑 reactive compaction 再试 1 次)
1218
2198
  // 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
1219
- const response = await this.callLlmWithRecovery(llm, context, systemPrompt, signal, onStream);
2199
+ const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream);
1220
2200
  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 次后才报失败)
2201
+ // 2026-06-19 架构 fix: 不再因 [AI 服务调用失败] break
2202
+ // 旧逻辑: sentinel aiFailed=true break 外层 retry 整个 loop (重置 history)
2203
+ // 新逻辑: 把错误当 tool_result push history 下一轮 LLM 看到错误能反思重试
2204
+ // 这是 dive-into 文档的"fail-open error recovery" 错误进入 context, 不让 LLM 重复犯同样错
1225
2205
  if (reply.startsWith('[AI 服务调用失败]')) {
1226
- console.log(`[PiAgent] 收到 AI 错误 sentinel, 标记 aiFailed, 外层会自动重试整个 loop`);
1227
- aiFailed = true;
2206
+ console.log(`[PiAgent] 收到 AI 错误 sentinel, 推到 history LLM 反思, 继续 loop`);
1228
2207
  aiFailureReason = reply.length > 200 ? reply.substring(0, 200) : reply;
2208
+ totalErrors++;
2209
+ consecutiveErrors++;
2210
+ // 把错误当成 tool 结果 push 进 history, 这样下一轮 LLM 看到错误能调整
2211
+ this.messageHistory.push({
2212
+ role: 'system',
2213
+ content: `[Loop 错误恢复 ${totalErrors}/${this.MAX_TOTAL_ERRORS}] ${aiFailureReason}\n\n请基于上轮工具结果继续完成任务, 不要重复调用同一失败操作. 如果工具已成功执行, 请基于 result.output 给用户总结; 如果工具失败, 请换其他方式或重试.`
2214
+ });
1229
2215
  if (onStream) {
1230
- onStream({ type: 'status', content: `⚠️ AI 调用失败, 将自动重试整个 loop`, tool: 'system' });
2216
+ onStream({ type: 'status', content: `⚠️ AI 调用失败 ${totalErrors}/${this.MAX_TOTAL_ERRORS}, push 错误到 history 让 LLM 反思`, tool: 'system' });
1231
2217
  }
1232
- break;
2218
+ // 退避 2s 后继续 — 临时 minimax 限流避开, 不让 loop 终止
2219
+ await new Promise(resolve => setTimeout(resolve, 2000));
2220
+ // 关键: 不设 aiFailed=true, 让外层不重试整个 loop (重置 history), 继续内层循环
2221
+ continue;
1233
2222
  }
1234
2223
  console.log(`[PiAgent] LLM 回复长度: ${reply.length}, 内容预览: "${reply.substring(0, 80)}..."`);
1235
2224
  console.log(`[PiAgent] LLM 完整回复:\n${reply}`);
@@ -1237,19 +2226,12 @@ ${toolDefs}
1237
2226
  if (onStream) {
1238
2227
  onStream({ type: 'token', content: reply.substring(0, 100) });
1239
2228
  }
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
- }
2229
+ // 2026-06-19 架构 fix: parseToolCall 优先于 isFinalResponse
2230
+ // 之前: 思考块里的 "<final gen>" 触发 isFinalResponse 提前 break, 工具从未真正执行
2231
+ // 现在: 先尝试解析 tool_call, 有就执行; 没有才检查是不是真正的 final gen
1252
2232
  const toolCall = this.parseToolCall(reply);
2233
+ // 2026-06-19 修: 即便 reply 含 <final gen>, 只要 parseToolCall 命中, 就执行工具
2234
+ // (之前 isFinalResponse break 会先于 parseToolCall 触发, 思考块里有 final gen 字符串就误杀)
1253
2235
  if (toolCall) {
1254
2236
  this.messageHistory.push({
1255
2237
  role: 'assistant',
@@ -1448,6 +2430,16 @@ ${toolDefs}
1448
2430
  }
1449
2431
  if (result.success) {
1450
2432
  consecutiveErrors = 0; // 重置连续错误计数
2433
+ // 2026-06-19: 记录成功结果, 用于 LLM 失败退出时汇总给用户
2434
+ if (result.output) {
2435
+ this.successfulToolResults.push({
2436
+ tool: toolCall.name,
2437
+ outputPreview: result.output.substring(0, 200) + (result.output.length > 200 ? '...' : '')
2438
+ });
2439
+ }
2440
+ else {
2441
+ this.successfulToolResults.push({ tool: toolCall.name, outputPreview: '(无输出)' });
2442
+ }
1451
2443
  // 检查工具执行质量
1452
2444
  lastQualityScore = this.estimateToolResultQuality(result);
1453
2445
  if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
@@ -1519,6 +2511,29 @@ ${toolDefs}
1519
2511
  if (onStream) {
1520
2512
  onStream({ type: 'token', content: reply.substring(0, 150) });
1521
2513
  }
2514
+ // 2026-06-19 架构 fix: 只有 strip <think> 后才检查 isFinalResponse
2515
+ // (parseToolCall 已先尝试, 既然没解析出 tool_call, 现在检查 final gen 是否真的在最终回答区)
2516
+ if (this.isFinalResponse(reply)) {
2517
+ // 2026-06-19 dive-into 风格修复: 如果还有 successful tool results 没汇报,
2518
+ // LLM 不能提前 final_gen — harness 自动注入"请汇报剩余工具结果" hint 再 continue
2519
+ // 这是 dive-into 文档"step 9 stop condition check" 的具体化:
2520
+ // stop condition = (有工具结果未汇报) ? continue : break
2521
+ if (this.successfulToolResults.length > 0 && iteration < this.MAX_REACT_ITERATIONS) {
2522
+ const unreported = this.successfulToolResults.length;
2523
+ console.log(`[PiAgent] LLM 想 final_gen 但还有 ${unreported} 个工具结果未汇报, push hint 让其继续`);
2524
+ this.messageHistory.push({
2525
+ role: 'system',
2526
+ content: `[dive-into stop condition] 你之前已成功执行了 ${unreported} 个工具, 但当前回复里没把它们的结果告诉用户. 请基于已有的工具结果 (在 history 里) 写一个完整总结回复给用户, 用 <final gen> 结尾. 不要再调工具.`
2527
+ });
2528
+ if (onStream) {
2529
+ onStream({ type: 'status', content: `🔄 还有 ${unreported} 个工具结果未汇报, 让 LLM 继续总结`, tool: 'system' });
2530
+ }
2531
+ continue;
2532
+ }
2533
+ lastQualityScore = this.estimateResponseQuality(reply);
2534
+ finalResponse = this.extractFinalAnswer(reply);
2535
+ break;
2536
+ }
1522
2537
  // 检查是否需要继续循环处理
1523
2538
  // 更严格的判断:只有当回复明确表示需要更多信息时才继续
1524
2539
  const containsToolCallIntent = reply.includes('调用工具') || reply.includes('tool(') ||
@@ -1646,6 +2661,44 @@ Workspace root folder: ${this.cwd}
1646
2661
  return m.content;
1647
2662
  }).join('\n');
1648
2663
  }
2664
+ /**
2665
+ * M3.5 (2026-06-17): 把 history 转成 messages 数组, 给 llm.chat() 用.
2666
+ * 不再用 buildContext() 把所有 role 压成字符串 — LLM 看不到 tool 调用结果.
2667
+ * messages 数组保留 role 语义, tool role 单独传递, LLM 能看到完整 tool 结果.
2668
+ *
2669
+ * 取最近 N 条, 同步压缩前 3 层 (跟 buildContext 同步).
2670
+ * 跳过 projectedHistory 路径 — messages 数组必须真实, 不能用投影.
2671
+ */
2672
+ buildMessages() {
2673
+ try {
2674
+ const recentMessages = this.compressHistorySync(this.messageHistory).slice(-15);
2675
+ const out = [];
2676
+ for (const m of recentMessages) {
2677
+ const role = m.role;
2678
+ let content = m.content;
2679
+ // tool role: 用 toolResult 序列化 (跟 buildContext 一样)
2680
+ if (role === 'tool') {
2681
+ const result = m.toolResult ? JSON.stringify(m.toolResult) : content;
2682
+ content = `[工具结果] ${result}`;
2683
+ }
2684
+ // system role (router hint 等) 直接保留
2685
+ if (role === 'system') {
2686
+ out.push({ role: 'system', content });
2687
+ continue;
2688
+ }
2689
+ // assistant / user / tool 直接转
2690
+ if (role === 'user' || role === 'assistant' || role === 'tool') {
2691
+ out.push({ role, content });
2692
+ }
2693
+ }
2694
+ return out;
2695
+ }
2696
+ catch (err) {
2697
+ console.warn('[PiAgent] buildMessages failed (silent, falling back to text):', err);
2698
+ // 退化: 用 buildContext 字符串包装成单 user message
2699
+ return [{ role: 'user', content: this.buildContext() }];
2700
+ }
2701
+ }
1649
2702
  /**
1650
2703
  * 估算 messageHistory 的 token 数 (4 字符 ≈ 1 token, 与 context-compaction 同步).
1651
2704
  * 失败静默: 任何异常 → 0 (不阻塞)
@@ -1667,7 +2720,7 @@ Workspace root folder: ${this.cwd}
1667
2720
  *
1668
2721
  * 失败静默: 全部失败 → 返回空 reply, 让上层 no-tool_use 终止
1669
2722
  */
1670
- async callLlmWithRecovery(llm, context, systemPrompt, signal, onStream) {
2723
+ async callLlmWithRecovery(llm, contextOrMessages, systemPrompt, signal, onStream) {
1671
2724
  // Reactive compaction 预检: 估算 token 超 80% 阈值, 跑一次
1672
2725
  const estimated = this.estimateHistoryTokens();
1673
2726
  if (estimated > this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * 0.8) {
@@ -1684,41 +2737,93 @@ Workspace root folder: ${this.cwd}
1684
2737
  console.warn('[PiAgent] reactive compaction pre-check failed:', err);
1685
2738
  }
1686
2739
  }
1687
- // 主调用 + 3 个恢复路径
2740
+ // 错误分级 (M1.3, 2026-06-17):
2741
+ // - 401/403/400 (认证/请求错误): 不重试, 直接 fail-fast
2742
+ // - 429 (rate limit): 重试 2 次, 指数退避
2743
+ // - 5xx (上游错误): 重试 2 次, 指数退避
2744
+ // - network (ECONNRESET / fetch failed / abort/timeout): 重试 2 次
2745
+ // - 4xx prompt-too-long: 走 reactive compaction
2746
+ // 这样以前所有错误都触发整个 runReActLoop 重跑(浪费 token),现在 4xx 直接失败
2747
+ // 让上层把失败原因广播给用户,而不是闷在 loop 里 retry 3 次后给空回复
2748
+ const classifyError = (err) => {
2749
+ const msg = String(err?.message || err || '');
2750
+ // 401/403: 认证失败
2751
+ if (/401|unauthor|invalid api key|api_key|forbidden|403/i.test(msg))
2752
+ return 'auth';
2753
+ // 400 prompt-too-long
2754
+ if (/token|too long|exceed|length|context|4000|413/i.test(msg))
2755
+ return 'prompt_too_long';
2756
+ // 429 rate limit
2757
+ if (/429|rate.?limit|too many requests/i.test(msg))
2758
+ return 'rate_limit';
2759
+ // 5xx
2760
+ if (/5\d\d|internal server|bad gateway|service unavailable|gateway timeout|cloudflare|502|503|504/i.test(msg))
2761
+ return 'server';
2762
+ // network
2763
+ if (/econnreset|econnrefused|enotfound|etimedout|fetch failed|network|aborted|timeout/i.test(msg))
2764
+ return 'network';
2765
+ return 'other';
2766
+ };
2767
+ const isRetryable = (cls) => cls === 'rate_limit' || cls === 'server' || cls === 'network' || cls === 'prompt_too_long';
2768
+ const maxAttempts = (cls) => isRetryable(cls) ? 3 : 1;
2769
+ const backoffMs = (attempt) => Math.min(1000 * 2 ** attempt, 8000); // 1s, 2s, 4s, 8s cap
1688
2770
  let lastErr = null;
1689
- for (let attempt = 0; attempt <= this.MAX_OUTPUT_TOKEN_ESCALATION_RETRIES; attempt++) {
2771
+ let lastClass = 'other';
2772
+ for (let attempt = 0; attempt < 4; attempt++) { // 最多 4 次尝试
1690
2773
  try {
1691
- const response = await llm.chat(context, systemPrompt, signal);
2774
+ // M3.5 (2026-06-17): messages 数组 (如果 contextOrMessages 是数组) 或字符串
2775
+ // 数组版让 LLM 看到结构化的 user/assistant/tool role, 而不是把 history 拼成单字符串
2776
+ const response = await llm.chat(contextOrMessages, systemPrompt, signal);
1692
2777
  return { reply: response.reply || '' };
1693
2778
  }
1694
2779
  catch (err) {
2780
+ // 用户主动 abort: 不重试, 立即抛
2781
+ if (signal?.aborted || err?.name === 'AbortError')
2782
+ throw err;
1695
2783
  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' });
2784
+ lastClass = classifyError(err);
2785
+ const errMsg = String(err?.message || err || '').slice(0, 200);
2786
+ const attempts = maxAttempts(lastClass);
2787
+ if (attempt + 1 >= attempts) {
2788
+ console.warn(`[PiAgent] LLM 调用失败, 不再重试 (class=${lastClass}, attempt=${attempt + 1}/${attempts}): ${errMsg}`);
2789
+ break;
2790
+ }
2791
+ console.warn(`[PiAgent] LLM 调用失败 (class=${lastClass}, attempt=${attempt + 1}/${attempts}), ${backoffMs(attempt)}ms 后重试: ${errMsg}`);
2792
+ onStream?.({ type: 'status', content: `⚠️ LLM 调用失败 (${lastClass}), 重试 ${attempt + 2}/${attempts}...`, tool: 'recovery' });
2793
+ if (lastClass === 'prompt_too_long') {
1701
2794
  try {
1702
2795
  await this.maybeAutoCompact(onStream, signal);
1703
2796
  }
1704
2797
  catch (compactionErr) {
1705
2798
  console.warn('[PiAgent] reactive compaction on prompt-too-long failed:', compactionErr);
1706
2799
  }
1707
- // 重新生成 context (compressHistorySync + projected)
1708
- context = this.buildContext();
2800
+ // 重新生成 context (重试 prompt_too_long 时重建 messages — 包含压缩后的 history)
2801
+ if (Array.isArray(contextOrMessages)) {
2802
+ contextOrMessages = this.buildMessages();
2803
+ }
2804
+ else {
2805
+ contextOrMessages = this.buildContext();
2806
+ }
1709
2807
  }
1710
2808
  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;
2809
+ // 指数退避
2810
+ await new Promise((r) => setTimeout(r, backoffMs(attempt)));
1717
2811
  }
1718
2812
  }
1719
2813
  }
1720
- console.warn('[PiAgent] callLlmWithRecovery 全失败 (silent):', lastErr);
1721
- return { reply: '' };
2814
+ // 失败: 返回结构化错误 reply (而不是空字符串), 上层可识别 + UI 可显示
2815
+ const errMsg = String(lastErr?.message || lastErr || '').slice(0, 300);
2816
+ const userMsg = lastClass === 'auth'
2817
+ ? `[AI 服务调用失败] 认证错误: ${errMsg}\n请检查 API key 配置 (env: OPENAI_API_KEY / ANTHROPIC_API_KEY 等)`
2818
+ : lastClass === 'rate_limit'
2819
+ ? `[AI 服务调用失败] 上游限流 (429): ${errMsg}\n请稍后重试`
2820
+ : lastClass === 'server'
2821
+ ? `[AI 服务调用失败] 上游错误: ${errMsg}\n已重试 2 次仍失败, 可稍后重试`
2822
+ : lastClass === 'network'
2823
+ ? `[AI 服务调用失败] 网络错误: ${errMsg}\n请检查网络连接`
2824
+ : `[AI 服务调用失败] ${errMsg}`;
2825
+ console.warn(`[PiAgent] callLlmWithRecovery 全部失败 (class=${lastClass}): ${errMsg}`);
2826
+ return { reply: userMsg };
1722
2827
  }
1723
2828
  /**
1724
2829
  * 同步压缩: 跑前 3 层 (Budget Reduction / Snip / Microcompact).
@@ -1795,8 +2900,11 @@ Workspace root folder: ${this.cwd}
1795
2900
  }
1796
2901
  }
1797
2902
  isFinalResponse(content) {
1798
- // 只有明确输出 <final gen> 才认为是最终回答
1799
- return content.includes('<final gen>');
2903
+ // 2026-06-19 修: 先剥离 <think>...</think> 思考块, 再判断 <final gen>.
2904
+ // 之前用 includes('<final gen>') 误杀: LLM 思考块里说"先看看当前状态再 <final gen>"
2905
+ // 也会被识别成终止信号, 工具调用直接跳过了.
2906
+ const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, '');
2907
+ return stripped.includes('<final gen>');
1800
2908
  }
1801
2909
  extractFinalAnswer(content) {
1802
2910
  // 提取 <final gen> 后的内容作为最终回答
@@ -1822,7 +2930,89 @@ Workspace root folder: ${this.cwd}
1822
2930
  return cleaned;
1823
2931
  }
1824
2932
  parseToolCall(content) {
2933
+ // 2026-06-19 修: JSON function-call (OpenAI/Anthropic/Minimax-style) 优先 — match[1] 是 name 字段, match[2] 是 arguments/input 对象
2934
+ // LLM 实际产出变体:
2935
+ // {"name": "shell_exec", "arguments": {"command": "git", "args": ["status"]}} (OpenAI 标准)
2936
+ // {"name": "shell_exec", "input": {"command": "git", "args": ["status"]}} (Anthropic/Minimax 风格)
2937
+ // 常见包裹: ```json\n{...}\n``` (markdown code block), <tool_call>{...}</tool_call> (OpenAI Hermes)
2938
+ const jsonPatterns = [
2939
+ // markdown json code block + OpenAI <tool_call> 块, 同时匹配 arguments/input 字段
2940
+ /(?:```(?:json|json5)?\s*\n?)?\{[\s\S]*?"name"\s*:\s*["'](\w+)["']\s*,\s*["']?(?:arguments|input)["']?\s*:\s*(\{[\s\S]*?\})\s*\}/,
2941
+ ];
2942
+ for (const p of jsonPatterns) {
2943
+ const m = content.match(p);
2944
+ if (m) {
2945
+ const name = m[1];
2946
+ let args = {};
2947
+ try {
2948
+ const parsed = JSON.parse(m[2]);
2949
+ if (parsed && typeof parsed === 'object') {
2950
+ args = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
2951
+ }
2952
+ }
2953
+ catch { /* 解析失败保持 args={} */ }
2954
+ const resolved = this.resolveToolName(name);
2955
+ if (resolved) {
2956
+ return { name: resolved, args };
2957
+ }
2958
+ }
2959
+ }
2960
+ // 2026-06-19 修: fallback — LLM 有时输出 <tool_name>shell_exec</tool_name> 这种伪 XML 标签
2961
+ // 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配, 但 name="tool_name" 不在 tools 里
2962
+ // 这里 detect 内容是否像 tool_call (有 <command>/<args>/<path> 子标签) 然后按 <command> 第一词推断工具
2963
+ const xmlTagMatch = content.match(/<(\w+)>([\s\S]*?)<\/\1>/);
2964
+ if (xmlTagMatch) {
2965
+ const outerTag = xmlTagMatch[1];
2966
+ const inner = xmlTagMatch[2];
2967
+ // 如果外层标签不在 tools 里但有 <command>/<args> 子标签, 尝试按内容推断
2968
+ if (!this.resolveToolName(outerTag)) {
2969
+ const cmdMatch = inner.match(/<command>(\w+)<\/command>/);
2970
+ if (cmdMatch) {
2971
+ const cmd = cmdMatch[1];
2972
+ // 推测工具: git/npx/npm/tsx → shell_exec; cat/head/tail → read_document
2973
+ if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmd)) {
2974
+ const args = {};
2975
+ const argsMatch = inner.match(/<args>([\s\S]*?)<\/args>/);
2976
+ if (argsMatch)
2977
+ args.args = argsMatch[1].trim();
2978
+ const cmdsMatch = inner.match(/<command>([\s\S]*?)<\/command>/);
2979
+ if (cmdsMatch)
2980
+ args.command = cmdsMatch[1].trim();
2981
+ return { name: 'shell_exec', args };
2982
+ }
2983
+ }
2984
+ }
2985
+ }
2986
+ // 2026-06-19 修: <tools:call name="X">...</tools:call> 嵌套格式
2987
+ // LLM 实际产出变体: <tools:call name="shell_exec"><tools:call name="command">git</tools:call>...</tools:call>
2988
+ const toolsCallMatch = content.match(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/);
2989
+ if (toolsCallMatch) {
2990
+ const name = toolsCallMatch[1];
2991
+ const inner = toolsCallMatch[2];
2992
+ const args = {};
2993
+ const argTags = inner.matchAll(/<tools:call\s+name=["'](\w+)["']>([\s\S]*?)<\/tools:call>/g);
2994
+ for (const m of argTags) {
2995
+ args[m[1]] = m[2].trim();
2996
+ }
2997
+ const resolved = this.resolveToolName(name);
2998
+ if (resolved) {
2999
+ return { name: resolved, args };
3000
+ }
3001
+ // 兜底: 如果外层 tool name 不在 tools, 但内层有 command, 推断 shell_exec
3002
+ if (args.command) {
3003
+ const cmdFirst = args.command.split(/\s+/)[0];
3004
+ if (['git', 'npx', 'npm', 'tsx', 'tsc', 'vitest', 'node', 'mkdir', 'touch', 'ls', 'echo', 'cat', 'head', 'tail', 'wc', 'pwd', 'date'].includes(cmdFirst)) {
3005
+ return { name: 'shell_exec', args };
3006
+ }
3007
+ }
3008
+ }
1825
3009
  const patterns = [
3010
+ // 2026-06-19 修: minimax/Hermes 自闭合 XML 格式 <invoke name="X">...</invoke>
3011
+ // 实际 LLM 习惯: <invoke name="shell_exec"><command>sed</command><args>["-n","1060,1095p"]</args></invoke>
3012
+ // 必须放在最前 — 旧正则 /<(\w+)>([\s\S]*?)<\/\1>/ 会匹配到外层 <invoke> 但 name 是 "invoke" 而不是 "shell_exec"
3013
+ new RegExp('<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>'),
3014
+ // 2026-06-19 修: <function_calls> 包裹
3015
+ new RegExp('<function_calls>[\\s\\S]*?<invoke\\s+name=["\']([\\w]+)["\']>([\\s\\S]*?)</invoke>[\\s\\S]*?</function_calls>'),
1826
3016
  /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
1827
3017
  /使用工具[::]\s*(\w+)\s*\(([^)]*)\)/,
1828
3018
  /tool[_\w]*[::]\s*(\w+)\s*\(([^)]*)\)/i,
@@ -1833,6 +3023,10 @@ Workspace root folder: ${this.cwd}
1833
3023
  /\btool\s*=>\s*["'](\w+)["']/,
1834
3024
  // 兼容: [TOOL_CALL] 块内 JSON 形式 {"name": "x", "args": {...}}
1835
3025
  /\[TOOL_CALL\][\s\S]*?\{\s*"name"\s*:\s*"(\w+)"\s*,\s*"args"\s*:\s*(\{[\s\S]*?\})/i,
3026
+ // 2026-06-19: 兼容 LLM 输出 "tool_name {json_args}" 形式 (单行, 无 name 字段)
3027
+ // 实际输出: write_file {"path":"docs/test.md","content":"..."}
3028
+ // 只在行首/新行匹配, 避免误匹配普通文本里的 "foo {...}"
3029
+ /(?:^|\n)(\w+)\s+(\{[\s\S]*?\})(?=\n|$)/,
1836
3030
  // 2026-06-15 修: 兼容 LLM 输出的 XML 格式 <tool_name>...<arg>value</arg>...</tool_name>
1837
3031
  // 实际 LLM 习惯: <shell_exec>\n<command>ls</command>\n<args>["-la", "..."]</args>\n</shell_exec>
1838
3032
  /<(\w+)>([\s\S]*?)<\/\1>/,
@@ -1864,15 +3058,28 @@ Workspace root folder: ${this.cwd}
1864
3058
  else if (rawArgs && /<\w+>[\s\S]*<\/\w+>/.test(rawArgs)) {
1865
3059
  // 2026-06-15 修: XML 格式, 解析内嵌子标签 <argname>value</argname>
1866
3060
  // 例: <command>ls</command>\n<args>["-la","~/.bolloon/skills"]</args>
1867
- const xmlArgPattern = /<(\w+)>([\s\S]*?)<\/\1>/g;
1868
- let xmlMatch;
1869
- while ((xmlMatch = xmlArgPattern.exec(rawArgs)) !== null) {
1870
- const argName = xmlMatch[1];
1871
- const argValue = xmlMatch[2].trim();
3061
+ // 2026-06-19 修: 也支持 <parameter name="X">value</parameter> 形式 (OpenAI Hermes function_call)
3062
+ const paramRe = /<parameter\s+name=["'](\w+)["']>([\s\S]*?)<\/parameter>/g;
3063
+ let pMatch;
3064
+ while ((pMatch = paramRe.exec(rawArgs)) !== null) {
3065
+ const argName = pMatch[1];
3066
+ const argValue = pMatch[2].trim();
1872
3067
  if (argName && argValue) {
1873
3068
  args[argName] = argValue;
1874
3069
  }
1875
3070
  }
3071
+ // 如果没匹配到 <parameter>, 用普通 XML 子标签
3072
+ if (Object.keys(args).length === 0) {
3073
+ const xmlArgPattern = /<(\w+)>([\s\S]*?)<\/\1>/g;
3074
+ let xmlMatch;
3075
+ while ((xmlMatch = xmlArgPattern.exec(rawArgs)) !== null) {
3076
+ const argName = xmlMatch[1];
3077
+ const argValue = xmlMatch[2].trim();
3078
+ if (argName && argValue) {
3079
+ args[argName] = argValue;
3080
+ }
3081
+ }
3082
+ }
1876
3083
  }
1877
3084
  else if (rawArgs) {
1878
3085
  // 形参串, 形如 key: value, key2: value2
@@ -1883,13 +3090,157 @@ Workspace root folder: ${this.cwd}
1883
3090
  args[key] = valueParts.join(':') || '';
1884
3091
  }
1885
3092
  }
1886
- if (this.tools.has(name) || this.tools.has(name.replace(/_/g, '_'))) {
3093
+ if (this.tools.has(name)) {
1887
3094
  return { name, args };
1888
3095
  }
3096
+ const resolved = this.resolveToolName(name);
3097
+ if (resolved) {
3098
+ return { name: resolved, args };
3099
+ }
1889
3100
  }
1890
3101
  }
1891
3102
  return null;
1892
3103
  }
3104
+ // [debug-2026-06-19] 临时: 打印 parseToolCall 输入和返回
3105
+ _dbgParseToolCall(content) {
3106
+ const r = this.parseToolCall(content);
3107
+ console.log('[DBG parseToolCall] result:', JSON.stringify(r), 'content head:', JSON.stringify(content.substring(0, 200)));
3108
+ return r;
3109
+ }
3110
+ /**
3111
+ * 2026-06-19: 工具名大小写不敏感 + Claude Code 风格别名映射
3112
+
3113
+ /**
3114
+ * 2026-06-19: 工具名大小写不敏感 + Claude Code 风格别名映射
3115
+ * LLM 实际产出 Read/Edit/Write/Bash/Grep/Glob 等大写名 (Claude Code 工具命名)
3116
+ * bolloon 注册的是 read_document / edit_file / write_file / shell_exec / list_files
3117
+ * 返回 this.tools 里的标准名, 或 null 表示未识别
3118
+ */
3119
+ resolveToolName(name) {
3120
+ if (this.tools.has(name))
3121
+ return name;
3122
+ const lower = name.toLowerCase();
3123
+ const aliasMap = {
3124
+ // Claude Code 风格 → bolloon 工具
3125
+ read: 'read_file',
3126
+ edit: 'edit_file',
3127
+ write: 'write_file',
3128
+ rm: 'delete_file',
3129
+ mv: 'move_file',
3130
+ bash: 'shell_exec',
3131
+ shell: 'shell_exec',
3132
+ sh: 'shell_exec',
3133
+ // grep / glob 现在是独立工具
3134
+ cat: 'read_file',
3135
+ // 测试/编译 → 独立工具
3136
+ test: 'vitest_run',
3137
+ vitest: 'vitest_run',
3138
+ typecheck: 'tsc_check',
3139
+ tsc: 'tsc_check',
3140
+ // git 操作
3141
+ log: 'git_log',
3142
+ show: 'git_show',
3143
+ diff: 'git_diff',
3144
+ commit: 'git_commit',
3145
+ push: 'git_push',
3146
+ branch: 'git_branch',
3147
+ checkout: 'git_branch',
3148
+ stash: 'git_stash',
3149
+ // 任务管理 (todo)
3150
+ todo_write: 'create_task',
3151
+ todowrite: 'create_task',
3152
+ task: 'create_task',
3153
+ };
3154
+ const aliased = aliasMap[lower];
3155
+ if (aliased && this.tools.has(aliased))
3156
+ return aliased;
3157
+ if (this.tools.has(lower))
3158
+ return lower;
3159
+ return null;
3160
+ }
3161
+ /**
3162
+ * 2026-06-19: 注册 P2P 消息 listener, 把收到的消息存到 _inboxMessages 供 check_inbox 读.
3163
+ * 监听 type='message' (send_to_peer) 和 type='agent-message' (P2P 远端 agent).
3164
+ * 失败静默, 不阻塞 PiAgentSession 构造.
3165
+ */
3166
+ _setupInboxListener() {
3167
+ if (!p2pNetwork || typeof p2pNetwork.onMessage !== 'function')
3168
+ return;
3169
+ try {
3170
+ // 监听所有 type 的消息, 存储到 inbox
3171
+ p2pNetwork.onMessage('*', (msg, from, did) => {
3172
+ try {
3173
+ const text = new TextDecoder().decode(msg);
3174
+ // 解析 DID:|type:payload 格式
3175
+ let type = 'message';
3176
+ let payload = text;
3177
+ if (text.startsWith('DID:')) {
3178
+ const pipeIdx = text.indexOf('|');
3179
+ if (pipeIdx > 0) {
3180
+ const rest = text.substring(pipeIdx + 1);
3181
+ const colonIdx = rest.indexOf(':');
3182
+ if (colonIdx > 0) {
3183
+ type = rest.substring(0, colonIdx);
3184
+ payload = rest.substring(colonIdx + 1);
3185
+ }
3186
+ else {
3187
+ type = rest;
3188
+ payload = '';
3189
+ }
3190
+ }
3191
+ }
3192
+ else {
3193
+ const colonIdx = text.indexOf(':');
3194
+ if (colonIdx > 0) {
3195
+ type = text.substring(0, colonIdx);
3196
+ payload = text.substring(colonIdx + 1);
3197
+ }
3198
+ }
3199
+ this._inboxMessages.push({
3200
+ id: `p2p-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3201
+ from: from.substring(0, 32),
3202
+ fromDid: did,
3203
+ type,
3204
+ payload,
3205
+ timestamp: Date.now(),
3206
+ source: 'p2p',
3207
+ });
3208
+ // 限制 inbox 大小, 防止内存泄漏
3209
+ if (this._inboxMessages.length > 1000) {
3210
+ this._inboxMessages = this._inboxMessages.slice(-1000);
3211
+ }
3212
+ }
3213
+ catch (err) {
3214
+ console.warn('[PiAgent] inbox listener decode error:', err);
3215
+ }
3216
+ });
3217
+ }
3218
+ catch (err) {
3219
+ console.warn('[PiAgent] _setupInboxListener failed (non-fatal):', err);
3220
+ }
3221
+ // 监听本地 inbox bus 投递 (send_to_local_agent)
3222
+ try {
3223
+ const { LocalInboxBus } = require('../network/local-inbox-bus.js');
3224
+ const myRole = process.env.BOLLOON_ROLE || 'default';
3225
+ LocalInboxBus.getInstance().subscribe(myRole, (msg) => {
3226
+ this._inboxMessages.push({
3227
+ id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3228
+ from: msg.from || 'unknown',
3229
+ fromDid: msg.fromDid,
3230
+ type: msg.type || 'agent-local-message',
3231
+ payload: msg.payload || '',
3232
+ timestamp: msg.timestamp || Date.now(),
3233
+ source: 'local',
3234
+ });
3235
+ if (this._inboxMessages.length > 1000) {
3236
+ this._inboxMessages = this._inboxMessages.slice(-1000);
3237
+ }
3238
+ });
3239
+ }
3240
+ catch (err) {
3241
+ // LocalInboxBus 可能还没创建, 不阻塞
3242
+ }
3243
+ }
1893
3244
  estimateResponseQuality(response) {
1894
3245
  let score = 0.5;
1895
3246
  if (response.length > 50)