@bolloon/bolloon-agent 0.3.22 → 0.3.24
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/dist/agents/deny-pipeline.js +116 -0
- package/dist/agents/parse-tool-call.js +26 -4
- package/dist/agents/pi-sdk.js +247 -50
- package/dist/agents/session-store.js +87 -2
- package/dist/bootstrap/snip-collapse.js +135 -0
- package/dist/cli/ink-app.js +116 -0
- package/dist/cli/loading-tui.js +64 -10
- package/dist/electron/config.js +14 -9
- package/dist/electron/dialogs.js +53 -16
- package/dist/electron/first-run.js +65 -24
- package/dist/electron/ipc.js +14 -10
- package/dist/electron/logger.js +44 -7
- package/dist/electron/main.js +45 -42
- package/dist/electron/menu.js +18 -13
- package/dist/electron/paths.js +54 -12
- package/dist/electron/server.js +57 -18
- package/dist/electron/tray.js +53 -15
- package/dist/electron/window.js +61 -22
- package/dist/electron-preload.js +19 -16
- package/dist/electron.js +4 -1
- package/dist/external-engines/delegate.js +19 -0
- package/dist/hooks/hooks-engine.js +329 -0
- package/dist/index.js +130 -133
- package/dist/llm/pi-ai.js +5 -17
- package/dist/security/tool-gate.js +8 -1
- package/dist/social/dunbar-tier.js +409 -0
- package/dist/utils/auto-update.js +51 -12
- package/dist/web/client.js +4833 -4328
- package/dist/web/components/p2p/index.js +234 -276
- package/dist/web/server.js +17 -3
- package/dist/web/style.css +2 -2
- package/dist/web/ui/message-renderer.js +396 -535
- package/dist/web/ui/step-timeline.js +273 -372
- package/package.json +27 -25
- package/dist/web/components/p2p/P2PModal.js +0 -188
- package/dist/web/components/p2p/p2p-modal.js +0 -664
- 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"
|
|
231
|
-
const paramRe = /<parameter\s+name=["'](\w+)["']
|
|
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"
|
|
239
|
-
const pRe = /<param\s+name=["'](\w+)["']
|
|
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
|
}
|
package/dist/agents/pi-sdk.js
CHANGED
|
@@ -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
|
-
|
|
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 连续失败,
|
|
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
|
-
|
|
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 后继续 —
|
|
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
|
-
|
|
1738
|
+
// 直接取 history 最后 15 条, tool 结果转 user role, 避免 tool_calls 配对
|
|
1739
|
+
const slice = this.messageHistory.slice(-15);
|
|
1525
1740
|
const out = [];
|
|
1526
|
-
for (const m of
|
|
1527
|
-
const
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
1544
|
-
|
|
1545
|
-
out.push({ role: 'system', content });
|
|
1747
|
+
if (r === 'assistant') {
|
|
1748
|
+
out.push({ role: 'assistant', content: m.content || '' });
|
|
1546
1749
|
continue;
|
|
1547
1750
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
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 (
|
|
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)));
|