@bolloon/bolloon-agent 0.3.21 → 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.
- package/dist/agents/deny-pipeline.js +116 -0
- package/dist/agents/parse-tool-call.js +26 -4
- package/dist/agents/pi-sdk.js +247 -64
- package/dist/agents/session-store.js +87 -2
- package/dist/bootstrap/snip-collapse.js +135 -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 +49 -23
- 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 +1 -1
- package/dist/web/server.js +17 -3
- package/dist/web/style.css +2 -2
- package/dist/web/ui/step-timeline.js +1 -1
- package/package.json +24 -24
|
@@ -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
|
|
@@ -353,11 +444,6 @@ export class PiAgentSession {
|
|
|
353
444
|
this.skillRegistry.register(s);
|
|
354
445
|
}
|
|
355
446
|
console.log(`[loadSkills] 已加载 ${skills.length} 个 skill from ${resolved.join(', ')}`);
|
|
356
|
-
if (skills.length > 0) {
|
|
357
|
-
for (const s of skills) {
|
|
358
|
-
console.log(` - ${s.name}: ${s.description.substring(0, 100)}${s.description.length > 100 ? '...' : ''}`);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
447
|
})
|
|
362
448
|
.catch((err) => {
|
|
363
449
|
console.error('[loadSkills] 加载失败:', err);
|
|
@@ -434,10 +520,12 @@ export class PiAgentSession {
|
|
|
434
520
|
}
|
|
435
521
|
getToolDefinitions() {
|
|
436
522
|
// M2.4 (2026-06-17): 缓存 tool 定义 — registerTools() 在构造时调一次, 此后不变
|
|
523
|
+
// 2026-07-29: 拒绝列表变化时清空缓存, 重新生成
|
|
437
524
|
if (this.cachedToolDefinitions)
|
|
438
525
|
return this.cachedToolDefinitions;
|
|
439
526
|
const defs = ['可用工具 (name(params) - 简介):'];
|
|
440
|
-
|
|
527
|
+
// 2026-07-29: 使用 allowedTools() 过滤掉拒绝列表中的工具
|
|
528
|
+
for (const tool of this.allowedTools()) {
|
|
441
529
|
// 2026-06-19: 压缩 tool 定义 — 只显示参数名 (不显示描述, 减少 60% 长度)
|
|
442
530
|
// 完整 description 在 history 第一轮注入 (getToolDefinitionsFull 调用), 后续轮只看简短
|
|
443
531
|
// 避免 system prompt 太大导致 minimax 撞 max_tokens 输出空
|
|
@@ -526,7 +614,7 @@ export class PiAgentSession {
|
|
|
526
614
|
}
|
|
527
615
|
try {
|
|
528
616
|
// 2026-06-16: runReActLoop 现在返回 { reply, aiFailed, aiFailureReason } — 这里只需 reply 字符串
|
|
529
|
-
const loopResult = await this.runReActLoop(undefined, options?.signal);
|
|
617
|
+
const loopResult = await this.runReActLoop(this.currentOnStream ?? undefined, options?.signal);
|
|
530
618
|
return loopResult.reply;
|
|
531
619
|
}
|
|
532
620
|
finally {
|
|
@@ -930,6 +1018,11 @@ ${this.getToolDefinitions()}
|
|
|
930
1018
|
let aiFailureReason = '';
|
|
931
1019
|
const MAX_CONSECUTIVE_ERRORS = 3;
|
|
932
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 次工具名, 检测重复
|
|
933
1026
|
// 发送循环开始的事件
|
|
934
1027
|
if (onStream) {
|
|
935
1028
|
onStream({ type: 'status', content: '🔄 开始 ReAct 循环...', tool: 'system' });
|
|
@@ -942,6 +1035,11 @@ ${this.getToolDefinitions()}
|
|
|
942
1035
|
catch (err) {
|
|
943
1036
|
console.warn('[PiAgent] reactHarness.onSessionStart failed (non-fatal):', err);
|
|
944
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 失败静默 */ }
|
|
945
1043
|
while (iteration < this.MAX_REACT_ITERATIONS) {
|
|
946
1044
|
iteration++;
|
|
947
1045
|
// 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
|
|
@@ -960,6 +1058,21 @@ ${this.getToolDefinitions()}
|
|
|
960
1058
|
finalResponse = finalResponse || '(用户中断)';
|
|
961
1059
|
break;
|
|
962
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
|
+
}
|
|
963
1076
|
// 2026-06-16 新增: 累计错误兜底 — 跨工具, 防 LLM 轮换工具名绕过 MAX_SAME_TOOL_FAILURES
|
|
964
1077
|
if (totalErrors >= this.MAX_TOTAL_ERRORS) {
|
|
965
1078
|
console.warn(`[PiAgent] 累计错误 ${totalErrors} >= ${this.MAX_TOTAL_ERRORS}, 强制终止 (防死循环)`);
|
|
@@ -968,7 +1081,7 @@ ${this.getToolDefinitions()}
|
|
|
968
1081
|
if (this.successfulToolResults.length > 0) {
|
|
969
1082
|
finalResponse = `✅ 之前步骤成功执行了 ${this.successfulToolResults.length} 个工具 (但 LLM 后续 ${totalErrors} 次调用失败):\n` +
|
|
970
1083
|
this.successfulToolResults.map((r, i) => ` ${i + 1}. ${r.tool}: ${r.outputPreview}`).join('\n') +
|
|
971
|
-
`\n\n⚠️ (LLM 连续失败,
|
|
1084
|
+
`\n\n⚠️ (LLM 连续失败, 可能是上游限流/网络问题, 工具已成功执行但 LLM 没能继续总结)`;
|
|
972
1085
|
}
|
|
973
1086
|
else {
|
|
974
1087
|
finalResponse = finalResponse || `(本轮 ReAct 循环累计 ${totalErrors} 次错误, 强制结束。请换个思路或简化任务重试。)`;
|
|
@@ -1056,9 +1169,31 @@ ${toolDefs}
|
|
|
1056
1169
|
// 2. reactive compaction (prompt 估算超阈值, 跑压缩)
|
|
1057
1170
|
// 3. prompt-too-long (LLM 报错 4xxx token 错误, 跑 reactive compaction 再试 1 次)
|
|
1058
1171
|
// 失败静默: 全部重试失败 → 空 reply (上层用 no tool_use 终止)
|
|
1059
|
-
// Bug 5: pass tool IDs for native OpenAI tool calling
|
|
1060
|
-
const toolIds = Array.from(this.tools.keys());
|
|
1061
|
-
|
|
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);
|
|
1062
1197
|
const reply = (response.reply || '').trim();
|
|
1063
1198
|
// 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
|
|
1064
1199
|
const nativeToolCalls = response.toolCalls;
|
|
@@ -1111,7 +1246,7 @@ ${toolDefs}
|
|
|
1111
1246
|
if (onStream) {
|
|
1112
1247
|
onStream({ type: 'status', content: `⚠️ AI 调用失败 ${totalErrors}/${this.MAX_TOTAL_ERRORS}, 已 push 错误到 history 让 LLM 反思`, tool: 'system' });
|
|
1113
1248
|
}
|
|
1114
|
-
// 退避 2s 后继续 —
|
|
1249
|
+
// 退避 2s 后继续 — 临时上游限流避开, 不让 loop 终止
|
|
1115
1250
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
1116
1251
|
// 关键: 不设 aiFailed=true, 让外层不重试整个 loop (重置 history), 继续内层循环
|
|
1117
1252
|
continue;
|
|
@@ -1189,6 +1324,29 @@ ${toolDefs}
|
|
|
1189
1324
|
args: toolCall.args || {},
|
|
1190
1325
|
});
|
|
1191
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
|
+
}
|
|
1192
1350
|
const tool = this.tools.get(toolCall.name);
|
|
1193
1351
|
if (!tool) {
|
|
1194
1352
|
consecutiveErrors++;
|
|
@@ -1227,6 +1385,26 @@ ${toolDefs}
|
|
|
1227
1385
|
onStream({ type: 'step_error', content: `PreToolUse 拒绝 ${toolCall.name}`, tool: toolCall.name, error: pre.reason || '安全校验失败' });
|
|
1228
1386
|
}
|
|
1229
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
|
+
}
|
|
1230
1408
|
continue;
|
|
1231
1409
|
}
|
|
1232
1410
|
}
|
|
@@ -1245,6 +1423,25 @@ ${toolDefs}
|
|
|
1245
1423
|
onStream({ type: 'step_error', content: `Harness 拒绝 ${toolCall.name}`, tool: toolCall.name, error: pre.reason || '安全校验失败' });
|
|
1246
1424
|
}
|
|
1247
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
|
+
}
|
|
1248
1445
|
continue;
|
|
1249
1446
|
}
|
|
1250
1447
|
}
|
|
@@ -1297,6 +1494,11 @@ ${toolDefs}
|
|
|
1297
1494
|
}
|
|
1298
1495
|
if (result.success) {
|
|
1299
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();
|
|
1300
1502
|
if (result.output) {
|
|
1301
1503
|
this.successfulToolResults.push({ tool: toolCall.name, outputPreview: result.output.substring(0, 200) + (result.output.length > 200 ? '...' : '') });
|
|
1302
1504
|
}
|
|
@@ -1395,6 +1597,13 @@ ${toolDefs}
|
|
|
1395
1597
|
continue;
|
|
1396
1598
|
}
|
|
1397
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
|
+
}
|
|
1398
1607
|
finalResponse = this.extractFinalAnswer(reply);
|
|
1399
1608
|
break;
|
|
1400
1609
|
}
|
|
@@ -1442,15 +1651,6 @@ ${toolDefs}
|
|
|
1442
1651
|
if (onStream) {
|
|
1443
1652
|
onStream({ type: 'status', content: `✅ 处理完成,共 ${iteration - 1} 次循环`, tool: 'system' });
|
|
1444
1653
|
}
|
|
1445
|
-
const now = new Date().toISOString();
|
|
1446
|
-
const identityPrefix = `${this.identity.name} | bolloon 智能体
|
|
1447
|
-
<environment_details>
|
|
1448
|
-
Current time: ${now}
|
|
1449
|
-
Working directory: ${this.cwd}
|
|
1450
|
-
Workspace root folder: ${this.cwd}
|
|
1451
|
-
</environment_details>
|
|
1452
|
-
`;
|
|
1453
|
-
finalResponse = identityPrefix + finalResponse;
|
|
1454
1654
|
this.messageHistory.push({ role: 'assistant', content: finalResponse });
|
|
1455
1655
|
// React Harness: 循环结束
|
|
1456
1656
|
try {
|
|
@@ -1535,55 +1735,24 @@ Workspace root folder: ${this.cwd}
|
|
|
1535
1735
|
*/
|
|
1536
1736
|
buildMessages() {
|
|
1537
1737
|
try {
|
|
1538
|
-
|
|
1738
|
+
// 直接取 history 最后 15 条, tool 结果转 user role, 避免 tool_calls 配对
|
|
1739
|
+
const slice = this.messageHistory.slice(-15);
|
|
1539
1740
|
const out = [];
|
|
1540
|
-
for (const m of
|
|
1541
|
-
const
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
// bolloon 之前把所有 tool result 包成 "[工具结果] ..." 当 user/assistant role 发, minimax 严格校验失败
|
|
1545
|
-
// 现在: 保留 role='tool' + 加 tool_call_id 字段 (用 messageHistory 里自己生成的 id)
|
|
1546
|
-
if (role === 'tool') {
|
|
1547
|
-
const toolCallId = m.toolCallId || m.toolCall?.id || '';
|
|
1548
|
-
const result = m.toolResult;
|
|
1549
|
-
out.push({
|
|
1550
|
-
role: 'tool',
|
|
1551
|
-
content: result ? (typeof result === 'string' ? result : JSON.stringify(result)) : content,
|
|
1552
|
-
tool_call_id: toolCallId,
|
|
1553
|
-
name: m.toolCall?.name || '',
|
|
1554
|
-
});
|
|
1741
|
+
for (const m of slice) {
|
|
1742
|
+
const r = m.role;
|
|
1743
|
+
if (r === 'tool') {
|
|
1744
|
+
out.push({ role: 'user', content: `[工具结果]\n${m.content || ''}` });
|
|
1555
1745
|
continue;
|
|
1556
1746
|
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
out.push({ role: 'system', content });
|
|
1747
|
+
if (r === 'assistant') {
|
|
1748
|
+
out.push({ role: 'assistant', content: m.content || '' });
|
|
1560
1749
|
continue;
|
|
1561
1750
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
if (role === 'assistant') {
|
|
1565
|
-
const tc = m.toolCall;
|
|
1566
|
-
if (tc && tc.id) {
|
|
1567
|
-
out.push({
|
|
1568
|
-
role: 'assistant',
|
|
1569
|
-
content: content || '',
|
|
1570
|
-
tool_calls: [{
|
|
1571
|
-
id: tc.id,
|
|
1572
|
-
type: 'function',
|
|
1573
|
-
function: {
|
|
1574
|
-
name: tc.name,
|
|
1575
|
-
arguments: JSON.stringify(tc.args || {}),
|
|
1576
|
-
},
|
|
1577
|
-
}],
|
|
1578
|
-
});
|
|
1579
|
-
}
|
|
1580
|
-
else {
|
|
1581
|
-
out.push({ role, content });
|
|
1582
|
-
}
|
|
1583
|
-
continue;
|
|
1751
|
+
if (r === 'user') {
|
|
1752
|
+
out.push({ role: 'user', content: m.content || '' });
|
|
1584
1753
|
}
|
|
1585
|
-
if (
|
|
1586
|
-
out.push({ role, content });
|
|
1754
|
+
if (r === 'system') {
|
|
1755
|
+
out.push({ role: 'system', content: m.content || '' });
|
|
1587
1756
|
}
|
|
1588
1757
|
}
|
|
1589
1758
|
return out;
|
|
@@ -1702,6 +1871,20 @@ Workspace root folder: ${this.cwd}
|
|
|
1702
1871
|
contextOrMessages = this.buildContext();
|
|
1703
1872
|
}
|
|
1704
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
|
+
}
|
|
1705
1888
|
else {
|
|
1706
1889
|
// 指数退避
|
|
1707
1890
|
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|