@bolloon/bolloon-agent 0.3.22 → 0.3.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/agents/deny-pipeline.js +116 -0
  2. package/dist/agents/parse-tool-call.js +26 -4
  3. package/dist/agents/pi-sdk.js +247 -50
  4. package/dist/agents/session-store.js +87 -2
  5. package/dist/bootstrap/snip-collapse.js +135 -0
  6. package/dist/cli/loading-tui.js +64 -10
  7. package/dist/electron/config.js +14 -9
  8. package/dist/electron/dialogs.js +53 -16
  9. package/dist/electron/first-run.js +65 -24
  10. package/dist/electron/ipc.js +14 -10
  11. package/dist/electron/logger.js +44 -7
  12. package/dist/electron/main.js +45 -42
  13. package/dist/electron/menu.js +18 -13
  14. package/dist/electron/paths.js +54 -12
  15. package/dist/electron/server.js +57 -18
  16. package/dist/electron/tray.js +53 -15
  17. package/dist/electron/window.js +61 -22
  18. package/dist/electron-preload.js +19 -16
  19. package/dist/electron.js +4 -1
  20. package/dist/external-engines/delegate.js +19 -0
  21. package/dist/hooks/hooks-engine.js +329 -0
  22. package/dist/index.js +41 -23
  23. package/dist/llm/pi-ai.js +5 -17
  24. package/dist/security/tool-gate.js +8 -1
  25. package/dist/social/dunbar-tier.js +409 -0
  26. package/dist/utils/auto-update.js +51 -12
  27. package/dist/web/client.js +4833 -4328
  28. package/dist/web/components/p2p/index.js +234 -276
  29. package/dist/web/server.js +17 -3
  30. package/dist/web/style.css +2 -2
  31. package/dist/web/ui/message-renderer.js +396 -535
  32. package/dist/web/ui/step-timeline.js +273 -372
  33. package/package.json +24 -24
  34. package/dist/web/components/p2p/P2PModal.js +0 -188
  35. package/dist/web/components/p2p/p2p-modal.js +0 -664
  36. package/dist/web/components/p2p/p2p-tools.js +0 -248
@@ -0,0 +1,116 @@
1
+ /**
2
+ * deny-pipeline.ts — Unified Deny-First Pipeline (2026-07-29)
3
+ *
4
+ * 将分散在 system 各处的拒绝逻辑统一到一条管道:
5
+ * 1. deny-list (Tool pre-filter — pi-sdk.ts _deniedToolNames)
6
+ * 2. hooks (HooksEngine.checkToolUse — shell/LLM)
7
+ * 3. permission (permission-mode.ts — static mode)
8
+ * 4. judgment (injectNegativeGuard — 负向判断力)
9
+ *
10
+ * 设计: deny-first
11
+ * - 任何一层拒绝, 整个工具被阻塞
12
+ * - 第一层拒绝后不再检查后续 (fail-fast)
13
+ *
14
+ * Claude Code 论文 7 层防御的简化实现:
15
+ * 第 1 层 = deny-list (硬拒绝, 最便宜)
16
+ * 第 2 层 = permission (静态规则)
17
+ * 第 3 层 = hooks (可编程策略, 中等成本)
18
+ * 第 4 层 = judgment (LLM 评估, 最贵)
19
+ */
20
+ /**
21
+ * Unified Deny Pipeline.
22
+ *
23
+ * 按顺序注册 checker, 每个 checker 返回 {denied} 表示是否拒绝该工具.
24
+ * 任何 checker 返回 denied=true 即中断, 不再执行后续.
25
+ */
26
+ export class DenyPipeline {
27
+ checkers = [];
28
+ constructor() {
29
+ // 默认注册: 从 null 开始, 调用方按需 addChecker
30
+ }
31
+ /** 添加一个检查器 (按添加顺序执行) */
32
+ addChecker(checker) {
33
+ this.checkers.push(checker);
34
+ }
35
+ /** 清空所有检查器 (调试/测试用) */
36
+ clear() {
37
+ this.checkers = [];
38
+ }
39
+ /**
40
+ * 对工具执行完整 deny 检查.
41
+ * 返回第一个拒绝结果, 或 {denied: false} (全部通过).
42
+ */
43
+ async check(ctx) {
44
+ for (const checker of this.checkers) {
45
+ try {
46
+ const result = await checker(ctx);
47
+ if (result.denied) {
48
+ return result;
49
+ }
50
+ // 如果 check 返回了 systemAddition, 累积
51
+ if (result.systemAddition) {
52
+ // 当前 check 通过, 但携带了注入文本
53
+ }
54
+ }
55
+ catch (e) {
56
+ // checker 异常: 按 deny-first 原则, 异常视为拒绝 (安全侧)
57
+ return {
58
+ denied: true,
59
+ reason: `Deny check 异常: ${String(e)}`,
60
+ source: `checker:${this.checkers.indexOf(checker)}`,
61
+ };
62
+ }
63
+ }
64
+ return { denied: false, reason: '', source: '' };
65
+ }
66
+ // ============== 工厂方法 ==============
67
+ /**
68
+ * 构建 deny-list checker.
69
+ * 检查工具名是否在 deny set 中.
70
+ */
71
+ static denyListChecker(deniedNames) {
72
+ return (ctx) => ({
73
+ denied: deniedNames.has(ctx.toolName),
74
+ reason: deniedNames.has(ctx.toolName)
75
+ ? `工具 ${ctx.toolName} 在拒绝列表中, 不允许调用`
76
+ : '',
77
+ source: 'deny-list',
78
+ });
79
+ }
80
+ /**
81
+ * 构建 permission-mode checker.
82
+ * default 模式: 禁用 shell_exec / git_commit / git_push 等危险工具
83
+ * bypassPermissions: 放行所有
84
+ */
85
+ static permissionChecker() {
86
+ const DEFAULT_DENY_TOOLS = new Set([
87
+ 'shell_exec', 'git_commit', 'git_push', 'git_branch',
88
+ 'delete_file', 'write_file', 'edit_file',
89
+ ]);
90
+ return (ctx) => {
91
+ if (ctx.permissionMode === 'bypassPermissions') {
92
+ return { denied: false, reason: '', source: 'permission' };
93
+ }
94
+ if (ctx.permissionMode === 'acceptEdits') {
95
+ // acceptEdits: 允许写文件, 但禁止 shell 和 git 操作
96
+ if (ctx.toolName === 'shell_exec' || ctx.toolName === 'git_commit' || ctx.toolName === 'git_push') {
97
+ return {
98
+ denied: true,
99
+ reason: `当前 permission mode 为 acceptEdits, 工具 ${ctx.toolName} 受限. 如需调用请切换到 bypassPermissions.`,
100
+ source: 'permission',
101
+ };
102
+ }
103
+ return { denied: false, reason: '', source: 'permission' };
104
+ }
105
+ // default: 危险工具全禁
106
+ if (DEFAULT_DENY_TOOLS.has(ctx.toolName)) {
107
+ return {
108
+ denied: true,
109
+ reason: `当前 permission mode 为 default, 工具 ${ctx.toolName} 不允许调用. 如需调用请切换到 acceptEdits 或 bypassPermissions.`,
110
+ source: 'permission',
111
+ };
112
+ }
113
+ return { denied: false, reason: '', source: 'permission' };
114
+ };
115
+ }
116
+ }
@@ -222,21 +222,43 @@ export function parseAllToolCalls(content, ctx) {
222
222
  }
223
223
  catch { /* skip */ }
224
224
  }
225
+ // 8. 自闭合 XML 标签: <toolName attr1="val1" attr2="val2" />
226
+ const selfCloseRe = /<(\w+)((?:\s+\w+\s*=\s*["'][^"']*["'])*)\s*\/\s*>/g;
227
+ let scm;
228
+ while ((scm = selfCloseRe.exec(stripped)) !== null) {
229
+ const name = scm[1];
230
+ const attrStr = scm[2] || '';
231
+ const resolved = ctx.tools.has(name) ? name : resolve(ctx, name);
232
+ if (!resolved)
233
+ continue;
234
+ const args = {};
235
+ const attrRe = /(\w+)\s*=\s*["']([^"']*)["']/g;
236
+ let am;
237
+ while ((am = attrRe.exec(attrStr)) !== null) {
238
+ args[am[1]] = am[2].trim();
239
+ }
240
+ autoSplitCommand(args);
241
+ const key = resolved + JSON.stringify(args);
242
+ if (!seen.has(key)) {
243
+ seen.add(key);
244
+ results.push({ name: resolved, args });
245
+ }
246
+ }
225
247
  return results;
226
248
  }
227
249
  /** 从 XML 子标签中提取 args */
228
250
  function parseXmlArgs(rawArgs) {
229
251
  const args = {};
230
- // <parameter name="X">value</parameter>
231
- const paramRe = /<parameter\s+name=["'](\w+)["']>([\s\S]*?)<\/parameter>/g;
252
+ // <parameter name="X" ...>value</parameter> — 允许额外属性如 string="true"
253
+ const paramRe = /<parameter\s+name=["'](\w+)["'][^>]*>([\s\S]*?)<\/parameter>/g;
232
254
  let pm;
233
255
  while ((pm = paramRe.exec(rawArgs)) !== null) {
234
256
  args[pm[1]] = pm[2].trim();
235
257
  }
236
258
  if (Object.keys(args).length > 0)
237
259
  return args;
238
- // <param name="X">value</param>
239
- const pRe = /<param\s+name=["'](\w+)["']>([\s\S]*?)<\/param>/g;
260
+ // <param name="X" ...>value</param>
261
+ const pRe = /<param\s+name=["'](\w+)["'][^>]*>([\s\S]*?)<\/param>/g;
240
262
  while ((pm = pRe.exec(rawArgs)) !== null) {
241
263
  args[pm[1]] = pm[2].trim().replace(/^["']|["']$/g, '');
242
264
  }
@@ -41,6 +41,8 @@ import { onPostToolUse } from '../bootstrap/lifecycle-hooks.js';
41
41
  import { budgetReduce, snip, microcompact } from '../context-compaction/index.js';
42
42
  // React Harness: 8-gate + 4-guard (防越权 / 防 prompt 注入)
43
43
  import { ReactHarness } from '../security/react-harness.js';
44
+ import { HooksEngine } from '../hooks/hooks-engine.js';
45
+ import { DenyPipeline } from './deny-pipeline.js';
44
46
  import { parseToolCall as parseToolCallImpl, parseAllToolCalls, isFinalResponse as isFinalResponseImpl, extractFinalAnswer as extractFinalAnswerImpl } from './parse-tool-call.js';
45
47
  import { buildObservation, buildReflection, formatObservationWithReflection } from './error-classifier.js';
46
48
  import { sessionStore as defaultSessionStore } from './session-store.js';
@@ -105,6 +107,47 @@ export class PiAgentSession {
105
107
  currentPermissionMode = 'default';
106
108
  /** P1.2: Context Collapse 读时投影结果 (feature flag 开启时由 maybeAutoCompact 写入, buildContext 优先用) */
107
109
  projectedHistory = null;
110
+ /**
111
+ * 2026-07-29 (Tool pre-filter): 拒绝工具列表 — 这些工具从模型视野完全删除
112
+ * 模型"看不到"这些工具 (name/description/params 不在 system prompt 列出,
113
+ * 也不出现在 native API tools 参数中).
114
+ * 在 registerTools() 之后立即应用, 也可运行时通过 denyTool/allowTool 动态调整.
115
+ * 首次 getToolDefinitions() 调用时会缓存带过滤的结果.
116
+ */
117
+ _deniedToolNames = new Set();
118
+ /** 2026-07-29: Hook 引擎 */
119
+ _hooks = new HooksEngine();
120
+ /** 2026-07-29: Unified Deny-First Pipeline */
121
+ _denyPipeline = new DenyPipeline();
122
+ /** 2026-07-29: Snip + Context Collapse 启用标志 (默认启用) */
123
+ _enableSnipCollapse = true;
124
+ /** 注册一个或多个工具到拒绝列表 */
125
+ denyTool(...names) {
126
+ for (const name of names)
127
+ this._deniedToolNames.add(name);
128
+ // 拒绝列表变了, 清除缓存让下次 getToolDefinitions 重新生成
129
+ this.cachedToolDefinitions = '';
130
+ }
131
+ /** 从拒绝列表移除一个或多个工具 */
132
+ allowTool(...names) {
133
+ for (const name of names)
134
+ this._deniedToolNames.delete(name);
135
+ this.cachedToolDefinitions = '';
136
+ }
137
+ /** 获取当前拒绝列表 (快照) */
138
+ getDeniedTools() {
139
+ return Array.from(this._deniedToolNames);
140
+ }
141
+ /** 返回已过滤(剔除拒绝工具)的工具迭代器 */
142
+ allowedTools() {
143
+ const self = this;
144
+ return (function* () {
145
+ for (const [name, tool] of self.tools) {
146
+ if (!self._deniedToolNames.has(name))
147
+ yield tool;
148
+ }
149
+ })();
150
+ }
108
151
  /**
109
152
  * Judgment 注入门临时结果: 在 prompt / promptStream / promptWithPivotLoop 入口算一次, 拼到本轮 systemPrompt 末尾
110
153
  * 每次调用都会重置 (避免上一轮遗留)
@@ -201,6 +244,54 @@ export class PiAgentSession {
201
244
  this.initSession();
202
245
  initDocumentReceiver();
203
246
  this.registerTools();
247
+ // 2026-07-29: 从环境变量加载默认拒绝工具列表 (逗号分隔)
248
+ // BOLLOON_DENIED_TOOLS=shell_exec,git_commit 会在启动时拒绝高危险工具
249
+ try {
250
+ const envDenied = process.env.BOLLOON_DENIED_TOOLS;
251
+ if (envDenied && envDenied.trim()) {
252
+ const names = envDenied.split(',').map(n => n.trim()).filter(Boolean);
253
+ if (names.length > 0)
254
+ this.denyTool(...names);
255
+ }
256
+ }
257
+ catch { /* env 读失败静默 */ }
258
+ // 2026-07-29: 从环境变量控制 Snip/Collapse
259
+ try {
260
+ if (process.env.BOLLOON_SNIP_COLLAPSE === '0')
261
+ this._enableSnipCollapse = false;
262
+ }
263
+ catch { /* 静默 */ }
264
+ // 2026-07-29: 从 ~/.bolloon/hooks.yaml 加载 hook 配置
265
+ // 失败静默 (无 hook 配置也正常)
266
+ try {
267
+ // fire-and-forget, 不阻塞构造
268
+ this._hooks.loadFromConfig().catch(() => { });
269
+ }
270
+ catch { /* 静默 */ }
271
+ // 2026-07-29: 初始化 DenyPipeline — 注册所有检查器
272
+ // 顺序: deny-list (最快) → permission → hooks → judgment
273
+ this._denyPipeline.addChecker(DenyPipeline.denyListChecker(this._deniedToolNames));
274
+ this._denyPipeline.addChecker(DenyPipeline.permissionChecker());
275
+ // 仅当启用了 hook 时注册 hooks 检查器
276
+ // (hook 可能走到 LLM, 是最贵的, 放在最后)
277
+ this._denyPipeline.addChecker(async (ctx) => {
278
+ try {
279
+ const hookResult = await this._hooks.checkToolUse(ctx.toolName, ctx.toolArgs);
280
+ if (hookResult?.deny) {
281
+ return {
282
+ denied: true,
283
+ reason: hookResult.reason || 'Hook 拒绝',
284
+ source: 'hooks',
285
+ systemAddition: hookResult.systemAddition,
286
+ };
287
+ }
288
+ if (hookResult?.systemAddition) {
289
+ this.contextHintAddition += '\n' + hookResult.systemAddition;
290
+ }
291
+ }
292
+ catch { /* hook 失败不阻塞 */ }
293
+ return { denied: false, reason: '', source: 'hooks' };
294
+ });
204
295
  this.loadSkills(config.skillsPaths);
205
296
  this.initHarness();
206
297
  // M2.3 (2026-06-17): 重启后 LLM 恢复记忆 — 从 session JSON 加载历史到 messageHistory
@@ -429,10 +520,12 @@ export class PiAgentSession {
429
520
  }
430
521
  getToolDefinitions() {
431
522
  // M2.4 (2026-06-17): 缓存 tool 定义 — registerTools() 在构造时调一次, 此后不变
523
+ // 2026-07-29: 拒绝列表变化时清空缓存, 重新生成
432
524
  if (this.cachedToolDefinitions)
433
525
  return this.cachedToolDefinitions;
434
526
  const defs = ['可用工具 (name(params) - 简介):'];
435
- for (const tool of this.tools.values()) {
527
+ // 2026-07-29: 使用 allowedTools() 过滤掉拒绝列表中的工具
528
+ for (const tool of this.allowedTools()) {
436
529
  // 2026-06-19: 压缩 tool 定义 — 只显示参数名 (不显示描述, 减少 60% 长度)
437
530
  // 完整 description 在 history 第一轮注入 (getToolDefinitionsFull 调用), 后续轮只看简短
438
531
  // 避免 system prompt 太大导致 minimax 撞 max_tokens 输出空
@@ -521,7 +614,7 @@ export class PiAgentSession {
521
614
  }
522
615
  try {
523
616
  // 2026-06-16: runReActLoop 现在返回 { reply, aiFailed, aiFailureReason } — 这里只需 reply 字符串
524
- const loopResult = await this.runReActLoop(undefined, options?.signal);
617
+ const loopResult = await this.runReActLoop(this.currentOnStream ?? undefined, options?.signal);
525
618
  return loopResult.reply;
526
619
  }
527
620
  finally {
@@ -925,6 +1018,11 @@ ${this.getToolDefinitions()}
925
1018
  let aiFailureReason = '';
926
1019
  const MAX_CONSECUTIVE_ERRORS = 3;
927
1020
  const MAX_SAME_TOOL_FAILURES = 3; // 同一工具连续失败 3 次, 强制让 LLM 给出最终答案
1021
+ // 2026-07-29: Hermes 风格硬限制 — 防死循环 (不再靠 soft hint)
1022
+ const MAX_IDEMPOTENT_TOOL = 5; // 同工具成功调 5 次 → 注入 hint 强制 final gen
1023
+ const MAX_TOOL_CALLS_PER_LOOP = 25; // 单轮循环总工具调用上限 → 注入 hint
1024
+ let totalToolCallsThisLoop = 0;
1025
+ const lastNTools = []; // 最近 MAX_IDEMPOTENT_TOOL 次工具名, 检测重复
928
1026
  // 发送循环开始的事件
929
1027
  if (onStream) {
930
1028
  onStream({ type: 'status', content: '🔄 开始 ReAct 循环...', tool: 'system' });
@@ -937,6 +1035,11 @@ ${this.getToolDefinitions()}
937
1035
  catch (err) {
938
1036
  console.warn('[PiAgent] reactHarness.onSessionStart failed (non-fatal):', err);
939
1037
  }
1038
+ // 2026-07-29: Hook onLoopStart
1039
+ try {
1040
+ await this._hooks.fire('onLoopStart', { event: 'onLoopStart', channelId: this.currentChannelId, agentId: this.currentAgentId });
1041
+ }
1042
+ catch { /* hook 失败静默 */ }
940
1043
  while (iteration < this.MAX_REACT_ITERATIONS) {
941
1044
  iteration++;
942
1045
  // 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
@@ -955,6 +1058,21 @@ ${this.getToolDefinitions()}
955
1058
  finalResponse = finalResponse || '(用户中断)';
956
1059
  break;
957
1060
  }
1061
+ // 2026-07-29: Hermes 风格硬限制 (idempotent tool / total call cap)
1062
+ if (totalToolCallsThisLoop >= MAX_TOOL_CALLS_PER_LOOP) {
1063
+ console.warn(`[PiAgent] 单轮工具调用已达 ${MAX_TOOL_CALLS_PER_LOOP}, 注入 hint 让 LLM 总结`);
1064
+ onStream?.({ type: 'error', content: `⏹️ 工具调用已达上限 (${MAX_TOOL_CALLS_PER_LOOP}), 请基于已有结果回答`, tool: 'loop' });
1065
+ this.messageHistory.push({ role: 'system', content: `[注意] 你已连续调用 ${MAX_TOOL_CALLS_PER_LOOP} 次工具。请基于已有结果直接回答用户, 不要再次调用任何工具。在回答末尾加 <final gen> 标记结束。` });
1066
+ totalToolCallsThisLoop = 0; // 重置计数器, 只防连续死循环
1067
+ }
1068
+ if (lastNTools.length >= MAX_IDEMPOTENT_TOOL && new Set(lastNTools).size === 1) {
1069
+ const repeatedTool = lastNTools[0];
1070
+ console.warn(`[PiAgent] 同工具 ${repeatedTool} 连续成功调 ${MAX_IDEMPOTENT_TOOL} 次, 注入 hint 让 LLM 总结`);
1071
+ onStream?.({ type: 'error', content: `⏹️ 工具 ${repeatedTool} 重复调用 ${MAX_IDEMPOTENT_TOOL} 次, 请基于已有结果回答`, tool: 'loop' });
1072
+ this.messageHistory.push({ role: 'system', content: `[注意] 你已连续 ${MAX_IDEMPOTENT_TOOL} 次调用 ${repeatedTool}。请基于已有结果直接回答用户, 不要再次调用任何工具。在回答末尾加 <final gen> 标记结束。` });
1073
+ lastNTools.length = 0; // 重置计数器
1074
+ // 不 break — 让 LLM 在下一轮用已有信息回答
1075
+ }
958
1076
  // 2026-06-16 新增: 累计错误兜底 — 跨工具, 防 LLM 轮换工具名绕过 MAX_SAME_TOOL_FAILURES
959
1077
  if (totalErrors >= this.MAX_TOTAL_ERRORS) {
960
1078
  console.warn(`[PiAgent] 累计错误 ${totalErrors} >= ${this.MAX_TOTAL_ERRORS}, 强制终止 (防死循环)`);
@@ -963,7 +1081,7 @@ ${this.getToolDefinitions()}
963
1081
  if (this.successfulToolResults.length > 0) {
964
1082
  finalResponse = `✅ 之前步骤成功执行了 ${this.successfulToolResults.length} 个工具 (但 LLM 后续 ${totalErrors} 次调用失败):\n` +
965
1083
  this.successfulToolResults.map((r, i) => ` ${i + 1}. ${r.tool}: ${r.outputPreview}`).join('\n') +
966
- `\n\n⚠️ (LLM 连续失败, 可能是 minimax 上游限流/网络问题, 工具已成功执行但 LLM 没能继续总结)`;
1084
+ `\n\n⚠️ (LLM 连续失败, 可能是上游限流/网络问题, 工具已成功执行但 LLM 没能继续总结)`;
967
1085
  }
968
1086
  else {
969
1087
  finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
@@ -1051,9 +1169,31 @@ ${toolDefs}
1051
1169
  // 2. reactive compaction (prompt 估算超阈值, 跑压缩)
1052
1170
  // 3. prompt-too-long (LLM 报错 4xxx token 错误, 跑 reactive compaction 再试 1 次)
1053
1171
  // 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
1054
- // Bug 5: pass tool IDs for native OpenAI tool calling
1055
- const toolIds = Array.from(this.tools.keys());
1056
- const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, toolIds);
1172
+ // Bug 5: pass tool IDs for native OpenAI tool calling — 2026-07-29: 过滤拒绝工具
1173
+ const toolIds = Array.from(this.tools.keys()).filter(n => !this._deniedToolNames.has(n));
1174
+ // 2026-07-29: this.tools Map 生成 OpenAI 原生 tools 格式 (含参数 schema)
1175
+ const openaiFormattedTools = [];
1176
+ for (const [name, tool] of this.tools) {
1177
+ if (this._deniedToolNames.has(name))
1178
+ continue;
1179
+ const params = tool.parameters || {};
1180
+ const properties = {};
1181
+ const required = [];
1182
+ for (const [pName, pDesc] of Object.entries(params)) {
1183
+ properties[pName] = { type: 'string', description: String(pDesc) };
1184
+ if (String(pDesc).includes('必填'))
1185
+ required.push(pName);
1186
+ }
1187
+ openaiFormattedTools.push({
1188
+ type: 'function',
1189
+ function: {
1190
+ name,
1191
+ description: tool.description || name,
1192
+ parameters: { type: 'object', properties, required },
1193
+ },
1194
+ });
1195
+ }
1196
+ const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, openaiFormattedTools);
1057
1197
  const reply = (response.reply || '').trim();
1058
1198
  // 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
1059
1199
  const nativeToolCalls = response.toolCalls;
@@ -1106,7 +1246,7 @@ ${toolDefs}
1106
1246
  if (onStream) {
1107
1247
  onStream({ type: 'status', content: `⚠️ AI 调用失败 ${totalErrors}/${this.MAX_TOTAL_ERRORS}, 已 push 错误到 history 让 LLM 反思`, tool: 'system' });
1108
1248
  }
1109
- // 退避 2s 后继续 — 临时 minimax 限流避开, 不让 loop 终止
1249
+ // 退避 2s 后继续 — 临时上游限流避开, 不让 loop 终止
1110
1250
  await new Promise(resolve => setTimeout(resolve, 2000));
1111
1251
  // 关键: 不设 aiFailed=true, 让外层不重试整个 loop (重置 history), 继续内层循环
1112
1252
  continue;
@@ -1184,6 +1324,29 @@ ${toolDefs}
1184
1324
  args: toolCall.args || {},
1185
1325
  });
1186
1326
  }
1327
+ // 2026-07-29: Unified Deny-First Pipeline — 统合所有拒绝检查
1328
+ let denyResult = { denied: false, reason: '', source: '' };
1329
+ try {
1330
+ denyResult = await this._denyPipeline.check({
1331
+ toolName: toolCall.name,
1332
+ toolArgs: toolCall.args || {},
1333
+ permissionMode: this.currentPermissionMode,
1334
+ channelId: this.currentChannelId,
1335
+ agentId: this.currentAgentId,
1336
+ });
1337
+ }
1338
+ catch { /* pipeline 失败不阻塞工具调用 */ }
1339
+ if (denyResult.denied) {
1340
+ consecutiveErrors++;
1341
+ totalErrors++;
1342
+ const denyResultMsg = { success: false, error: `拒绝: [${denyResult.source}] ${denyResult.reason}` };
1343
+ this.messageHistory.push({ role: 'tool', content: JSON.stringify(denyResultMsg), toolResult: denyResultMsg });
1344
+ this.logToHarness(toolCall.name, toolCall.args, denyResultMsg);
1345
+ continue;
1346
+ }
1347
+ if (denyResult.systemAddition) {
1348
+ this.contextHintAddition += '\n' + denyResult.systemAddition;
1349
+ }
1187
1350
  const tool = this.tools.get(toolCall.name);
1188
1351
  if (!tool) {
1189
1352
  consecutiveErrors++;
@@ -1222,6 +1385,26 @@ ${toolDefs}
1222
1385
  onStream({ type: 'step_error', content: `PreToolUse 拒绝 ${toolCall.name}`, tool: toolCall.name, error: pre.reason || '安全校验失败' });
1223
1386
  }
1224
1387
  console.warn(`[PiAgent] PreToolUse denied ${toolCall.name}: ${pre.reason}`);
1388
+ // 拒绝也算错误, 让错误恢复机制触发
1389
+ consecutiveErrors++;
1390
+ totalErrors++;
1391
+ if (toolCall.name === lastFailedTool) {
1392
+ lastFailedToolCount++;
1393
+ }
1394
+ else {
1395
+ lastFailedTool = toolCall.name;
1396
+ lastFailedToolCount = 1;
1397
+ }
1398
+ if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
1399
+ this.messageHistory.push({ role: 'system', content: `[注意] 工具 ${toolCall.name} 被系统拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 直接用已有信息回答用户, 末尾加 <final gen>.` });
1400
+ lastFailedTool = '';
1401
+ lastFailedToolCount = 0;
1402
+ consecutiveErrors = 0;
1403
+ }
1404
+ else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1405
+ this.messageHistory.push({ role: 'system', content: `[注意] 连续 ${consecutiveErrors} 次工具调用被系统拒绝. 请换其他工具或直接回答用户, 末尾加 <final gen>.` });
1406
+ consecutiveErrors = 0;
1407
+ }
1225
1408
  continue;
1226
1409
  }
1227
1410
  }
@@ -1240,6 +1423,25 @@ ${toolDefs}
1240
1423
  onStream({ type: 'step_error', content: `Harness 拒绝 ${toolCall.name}`, tool: toolCall.name, error: pre.reason || '安全校验失败' });
1241
1424
  }
1242
1425
  console.warn(`[PiAgent] Harness denied ${toolCall.name} (${pre.details.rejectedBy}): ${pre.reason}`);
1426
+ consecutiveErrors++;
1427
+ totalErrors++;
1428
+ if (toolCall.name === lastFailedTool) {
1429
+ lastFailedToolCount++;
1430
+ }
1431
+ else {
1432
+ lastFailedTool = toolCall.name;
1433
+ lastFailedToolCount = 1;
1434
+ }
1435
+ if (lastFailedToolCount >= MAX_SAME_TOOL_FAILURES) {
1436
+ this.messageHistory.push({ role: 'system', content: `[注意] 工具 ${toolCall.name} 被 Harness 拒绝 (连续 ${MAX_SAME_TOOL_FAILURES} 次). 请不要再次尝试, 末尾加 <final gen>.` });
1437
+ lastFailedTool = '';
1438
+ lastFailedToolCount = 0;
1439
+ consecutiveErrors = 0;
1440
+ }
1441
+ else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
1442
+ this.messageHistory.push({ role: 'system', content: `[注意] 连续 ${consecutiveErrors} 次工具调用被 Harness 拒绝. 请换其他工具或直接回答.` });
1443
+ consecutiveErrors = 0;
1444
+ }
1243
1445
  continue;
1244
1446
  }
1245
1447
  }
@@ -1292,6 +1494,11 @@ ${toolDefs}
1292
1494
  }
1293
1495
  if (result.success) {
1294
1496
  consecutiveErrors = 0;
1497
+ // 2026-07-29: Hermes 风格硬限制计数
1498
+ totalToolCallsThisLoop++;
1499
+ lastNTools.push(toolCall.name);
1500
+ if (lastNTools.length > MAX_IDEMPOTENT_TOOL)
1501
+ lastNTools.shift();
1295
1502
  if (result.output) {
1296
1503
  this.successfulToolResults.push({ tool: toolCall.name, outputPreview: result.output.substring(0, 200) + (result.output.length > 200 ? '...' : '') });
1297
1504
  }
@@ -1390,6 +1597,13 @@ ${toolDefs}
1390
1597
  continue;
1391
1598
  }
1392
1599
  lastQualityScore = this.estimateResponseQuality(reply);
1600
+ // 2026-07-29: 质量门 — 即使 LLM 声称完成, 质量太低也继续
1601
+ if (lastQualityScore < this.QUALITY_THRESHOLD && refineAttempts < this.MAX_REFINE_ATTEMPTS) {
1602
+ console.log(`[PiAgent] final gen 质量 ${lastQualityScore.toFixed(2)} < ${this.QUALITY_THRESHOLD}, 注入 refine hint`);
1603
+ this.messageHistory.push({ role: 'system', content: `[质量检查] 你的回答质量评分为 ${(lastQualityScore * 10).toFixed(1)}/10, 低于 ${(this.QUALITY_THRESHOLD * 10).toFixed(1)}/10 阈值。请提供更完整、详细的回答, 包含工具调用获取到的具体信息, 末尾加 <final gen>。` });
1604
+ refineAttempts++;
1605
+ continue;
1606
+ }
1393
1607
  finalResponse = this.extractFinalAnswer(reply);
1394
1608
  break;
1395
1609
  }
@@ -1521,55 +1735,24 @@ ${toolDefs}
1521
1735
  */
1522
1736
  buildMessages() {
1523
1737
  try {
1524
- const recentMessages = this.compressHistorySync(this.messageHistory).slice(-15);
1738
+ // 直接取 history 最后 15 条, tool 结果转 user role, 避免 tool_calls 配对
1739
+ const slice = this.messageHistory.slice(-15);
1525
1740
  const out = [];
1526
- for (const m of recentMessages) {
1527
- const role = m.role;
1528
- let content = m.content;
1529
- // 2026-06-30 修: OpenAI 协议 tool role 必须带 tool_call_id, 否则 minimax (OpenAI-compatible) 400
1530
- // bolloon 之前把所有 tool result 包成 "[工具结果] ..." 当 user/assistant role 发, minimax 严格校验失败
1531
- // 现在: 保留 role='tool' + 加 tool_call_id 字段 (用 messageHistory 里自己生成的 id)
1532
- if (role === 'tool') {
1533
- const toolCallId = m.toolCallId || m.toolCall?.id || '';
1534
- const result = m.toolResult;
1535
- out.push({
1536
- role: 'tool',
1537
- content: result ? (typeof result === 'string' ? result : JSON.stringify(result)) : content,
1538
- tool_call_id: toolCallId,
1539
- name: m.toolCall?.name || '',
1540
- });
1741
+ for (const m of slice) {
1742
+ const r = m.role;
1743
+ if (r === 'tool') {
1744
+ out.push({ role: 'user', content: `[工具结果]\n${m.content || ''}` });
1541
1745
  continue;
1542
1746
  }
1543
- // system role (router hint ) 直接保留
1544
- if (role === 'system') {
1545
- out.push({ role: 'system', content });
1747
+ if (r === 'assistant') {
1748
+ out.push({ role: 'assistant', content: m.content || '' });
1546
1749
  continue;
1547
1750
  }
1548
- // 2026-06-30 修: assistant 消息如果带 toolCall (bolloon 内部对象), emit OpenAI 协议的 tool_calls 数组
1549
- // minimax 严格要求 assistant 消息含 tool_calls 字段, 后续 tool result 才能引用 tool_call_id
1550
- if (role === 'assistant') {
1551
- const tc = m.toolCall;
1552
- if (tc && tc.id) {
1553
- out.push({
1554
- role: 'assistant',
1555
- content: content || '',
1556
- tool_calls: [{
1557
- id: tc.id,
1558
- type: 'function',
1559
- function: {
1560
- name: tc.name,
1561
- arguments: JSON.stringify(tc.args || {}),
1562
- },
1563
- }],
1564
- });
1565
- }
1566
- else {
1567
- out.push({ role, content });
1568
- }
1569
- continue;
1751
+ if (r === 'user') {
1752
+ out.push({ role: 'user', content: m.content || '' });
1570
1753
  }
1571
- if (role === 'user') {
1572
- out.push({ role, content });
1754
+ if (r === 'system') {
1755
+ out.push({ role: 'system', content: m.content || '' });
1573
1756
  }
1574
1757
  }
1575
1758
  return out;
@@ -1688,6 +1871,20 @@ ${toolDefs}
1688
1871
  contextOrMessages = this.buildContext();
1689
1872
  }
1690
1873
  }
1874
+ else if (errMsg.includes('insufficient tool messages') || errMsg.includes('must be followed by tool messages')) {
1875
+ // 2026-07-29: 特殊的 400 错误 — tool_calls 配对异常, 降级为纯文本 context
1876
+ console.warn('[PiAgent] insufficient tool messages — 降级为 buildContext 文本');
1877
+ if (Array.isArray(contextOrMessages)) {
1878
+ contextOrMessages = this.buildContext();
1879
+ // 也清除最近一轮的 toolCalls, 防止再触发
1880
+ if (this.messageHistory.length > 1) {
1881
+ const last = this.messageHistory[this.messageHistory.length - 1];
1882
+ if (last.role === 'assistant' && last.toolCalls) {
1883
+ delete last.toolCalls;
1884
+ }
1885
+ }
1886
+ }
1887
+ }
1691
1888
  else {
1692
1889
  // 指数退避
1693
1890
  await new Promise((r) => setTimeout(r, backoffMs(attempt)));