@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.
- package/.comm/README.md +21 -0
- package/.comm/default/2026-06-17T08-23-00-017Z-8a735de8.md +7 -0
- package/CLAUDE.md +3 -0
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/build/tray.png +0 -0
- package/build/trayTemplate.png +0 -0
- package/dist/agents/intent-classifier.js +99 -0
- package/dist/agents/pi-sdk.js +1418 -67
- package/dist/agents/pre-tool-validator.js +2 -1
- package/dist/agents/shell-guard.js +9 -3
- package/dist/agents/shell-tool.js +28 -13
- package/dist/agents/task-state.js +224 -0
- package/dist/agents/workflow-pivot-loop.js +30 -0
- package/dist/bollharness-integration/gate-state-machine.js +13 -0
- package/dist/bollharness-integration/integration.js +71 -0
- package/dist/bollharness-integration/skill-adapter.js +52 -127
- package/dist/bootstrap/context-hierarchy.js +6 -4
- package/dist/cli/interface.js +28 -0
- package/dist/documents/reader.js +14 -0
- package/dist/electron/config.js +21 -0
- package/dist/electron/dialogs.js +108 -0
- package/dist/electron/first-run.js +170 -0
- package/dist/electron/ipc.js +20 -0
- package/dist/electron/logger.js +114 -0
- package/dist/electron/main.js +63 -0
- package/dist/electron/menu.js +145 -0
- package/dist/electron/paths.js +75 -0
- package/dist/electron/server.js +112 -0
- package/dist/electron/tray.js +111 -0
- package/dist/electron/window.js +108 -0
- package/dist/git-transport/chat-render.js +155 -0
- package/dist/git-transport/chat-repo.js +370 -0
- package/dist/git-transport/chat-types.js +23 -0
- package/dist/git-transport/chat-watch.js +102 -0
- package/dist/index.js +477 -28
- package/dist/llm/pi-ai.js +103 -27
- package/dist/network/local-inbox-bus.js +73 -0
- package/dist/network/p2p-direct.js +3 -4
- package/dist/pi-ecosystem-judgment/adaptive-scan.js +6 -2
- package/dist/pi-ecosystem-judgment/causal-judge.js +3 -3
- package/dist/pi-ecosystem-judgment/index.js +1 -1
- package/dist/security/tool-gate.js +11 -0
- package/dist/web/server.js +68 -4
- package/package.json +8 -3
- package/scripts/lefthook-helper.sh +69 -0
package/dist/agents/pi-sdk.js
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2199
|
+
const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream);
|
|
1220
2200
|
const reply = (response.reply || '').trim();
|
|
1221
|
-
// 2026-06-
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
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,
|
|
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
|
|
2216
|
+
onStream({ type: 'status', content: `⚠️ AI 调用失败 ${totalErrors}/${this.MAX_TOTAL_ERRORS}, 已 push 错误到 history 让 LLM 反思`, tool: 'system' });
|
|
1231
2217
|
}
|
|
1232
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
2771
|
+
let lastClass = 'other';
|
|
2772
|
+
for (let attempt = 0; attempt < 4; attempt++) { // 最多 4 次尝试
|
|
1690
2773
|
try {
|
|
1691
|
-
|
|
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
|
-
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
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 (
|
|
1708
|
-
|
|
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
|
-
//
|
|
1712
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
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
|
-
//
|
|
1799
|
-
|
|
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
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
const
|
|
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)
|
|
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)
|