@bolloon/bolloon-agent 0.5.3 → 0.5.5
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/agent-identity-store.js +41 -5
- package/dist/agents/background-notices.js +124 -0
- package/dist/agents/channel-identity.js +54 -0
- package/dist/agents/code-write-gate.js +47 -0
- package/dist/agents/deny-pipeline.js +11 -2
- package/dist/agents/egress-sanitize.js +69 -0
- package/dist/agents/error-classifier.js +67 -5
- package/dist/agents/experience-review.js +226 -0
- package/dist/agents/gateway-group.js +153 -18
- package/dist/agents/iteration-budget.js +64 -0
- package/dist/agents/lesson-to-skill.js +211 -0
- package/dist/agents/managed-services.js +64 -0
- package/dist/agents/parse-tool-call.js +38 -32
- package/dist/agents/pi-sdk-session-manager.js +72 -3
- package/dist/agents/pi-sdk-tools.js +268 -47
- package/dist/agents/pi-sdk.js +470 -22
- package/dist/agents/plan-store.js +36 -0
- package/dist/agents/process-runner.js +64 -2
- package/dist/agents/session-store.js +144 -3
- package/dist/agents/shell-guard.js +109 -13
- package/dist/agents/skill-health.js +90 -0
- package/dist/agents/skill-ledger.js +129 -0
- package/dist/agents/tool-loop-guard.js +303 -0
- package/dist/agents/tool-path-args.js +53 -0
- package/dist/agents/tool-result-gate.js +50 -0
- package/dist/agents/tool-subset.js +109 -0
- package/dist/agents/tool-telemetry.js +97 -0
- package/dist/agents/wallet-tools.js +63 -0
- package/dist/bootstrap/context-collector.js +37 -7
- package/dist/bootstrap/lifecycle-hooks.js +12 -3
- package/dist/bootstrap/persona-init.js +174 -0
- package/dist/bootstrap/persona-loader.js +5 -1
- package/dist/cli/ink-app.js +231 -32
- package/dist/cli/input-history.js +119 -0
- package/dist/cli/input-paste.js +218 -0
- package/dist/cli/loading-tui.js +69 -7
- package/dist/cli/log-gate.js +46 -1
- package/dist/cli/mention-data.js +30 -0
- package/dist/cli/reasoning-view.js +103 -0
- package/dist/cli/session-summary.js +129 -0
- package/dist/cli/session-tree.js +141 -0
- package/dist/cli/startup-notice.js +25 -0
- package/dist/cli/status-segments.js +155 -0
- package/dist/cli/stores.js +16 -0
- package/dist/cli/trace-line.js +54 -0
- package/dist/cli/wallet-store.js +159 -0
- package/dist/cli/wallet-table.js +46 -0
- package/dist/cli-entry.js +22 -0
- package/dist/context-compaction/auto-compact.js +37 -2
- package/dist/electron-build/agents/agent-identity-store.js +43 -5
- package/dist/electron-build/agents/agent-identity-store.js.map +1 -1
- package/dist/electron-build/agents/background-notices.js +138 -0
- package/dist/electron-build/agents/background-notices.js.map +1 -0
- package/dist/electron-build/agents/channel-identity.js +64 -0
- package/dist/electron-build/agents/channel-identity.js.map +1 -0
- package/dist/electron-build/agents/code-write-gate.js +55 -0
- package/dist/electron-build/agents/code-write-gate.js.map +1 -0
- package/dist/electron-build/agents/deny-pipeline.js +11 -2
- package/dist/electron-build/agents/deny-pipeline.js.map +1 -1
- package/dist/electron-build/agents/egress-sanitize.js +76 -0
- package/dist/electron-build/agents/egress-sanitize.js.map +1 -0
- package/dist/electron-build/agents/error-classifier.js +68 -5
- package/dist/electron-build/agents/error-classifier.js.map +1 -1
- package/dist/electron-build/agents/experience-review.js +243 -0
- package/dist/electron-build/agents/experience-review.js.map +1 -0
- package/dist/electron-build/agents/iteration-budget.js +73 -0
- package/dist/electron-build/agents/iteration-budget.js.map +1 -0
- package/dist/electron-build/agents/lesson-to-skill.js +228 -0
- package/dist/electron-build/agents/lesson-to-skill.js.map +1 -0
- package/dist/electron-build/agents/managed-services.js +69 -0
- package/dist/electron-build/agents/managed-services.js.map +1 -0
- package/dist/electron-build/agents/parse-tool-call.js +38 -32
- package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-session-manager.js +75 -3
- package/dist/electron-build/agents/pi-sdk-session-manager.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-tools.js +268 -46
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +471 -22
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/plan-store.js +37 -0
- package/dist/electron-build/agents/plan-store.js.map +1 -1
- package/dist/electron-build/agents/process-runner.js +71 -2
- package/dist/electron-build/agents/process-runner.js.map +1 -1
- package/dist/electron-build/agents/session-store.js +144 -3
- package/dist/electron-build/agents/session-store.js.map +1 -1
- package/dist/electron-build/agents/shell-guard.js +110 -14
- package/dist/electron-build/agents/shell-guard.js.map +1 -1
- package/dist/electron-build/agents/skill-health.js +103 -0
- package/dist/electron-build/agents/skill-health.js.map +1 -0
- package/dist/electron-build/agents/skill-ledger.js +146 -0
- package/dist/electron-build/agents/skill-ledger.js.map +1 -0
- package/dist/electron-build/agents/tool-loop-guard.js +319 -0
- package/dist/electron-build/agents/tool-loop-guard.js.map +1 -0
- package/dist/electron-build/agents/tool-path-args.js +63 -0
- package/dist/electron-build/agents/tool-path-args.js.map +1 -0
- package/dist/electron-build/agents/tool-result-gate.js +59 -0
- package/dist/electron-build/agents/tool-result-gate.js.map +1 -0
- package/dist/electron-build/agents/tool-subset.js +116 -0
- package/dist/electron-build/agents/tool-subset.js.map +1 -0
- package/dist/electron-build/agents/tool-telemetry.js +108 -0
- package/dist/electron-build/agents/tool-telemetry.js.map +1 -0
- package/dist/electron-build/agents/wallet-tools.js +102 -0
- package/dist/electron-build/agents/wallet-tools.js.map +1 -0
- package/dist/electron-build/bootstrap/context-collector.js +38 -7
- package/dist/electron-build/bootstrap/context-collector.js.map +1 -1
- package/dist/electron-build/bootstrap/lifecycle-hooks.js +12 -3
- package/dist/electron-build/bootstrap/lifecycle-hooks.js.map +1 -1
- package/dist/electron-build/bootstrap/persona-init.js +216 -0
- package/dist/electron-build/bootstrap/persona-init.js.map +1 -0
- package/dist/electron-build/bootstrap/persona-loader.js +5 -1
- package/dist/electron-build/bootstrap/persona-loader.js.map +1 -1
- package/dist/electron-build/cli/wallet-store.js +200 -0
- package/dist/electron-build/cli/wallet-store.js.map +1 -0
- package/dist/electron-build/context-compaction/auto-compact.js +38 -2
- package/dist/electron-build/context-compaction/auto-compact.js.map +1 -1
- package/dist/electron-build/external-engines/delegate.js +22 -0
- package/dist/electron-build/external-engines/delegate.js.map +1 -1
- package/dist/electron-build/llm/pi-ai.js +25 -0
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/orbitdb/cid-database.js +55 -0
- package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
- package/dist/electron-build/orbitdb/ipfs-node.js +36 -11
- package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
- package/dist/electron-build/web/server-storage.js +26 -5
- package/dist/electron-build/web/server-storage.js.map +1 -1
- package/dist/external-engines/delegate.js +21 -0
- package/dist/index.js +1324 -173
- package/dist/llm/pi-ai.js +23 -0
- package/dist/orbitdb/cid-database.js +55 -0
- package/dist/orbitdb/event-index.js +370 -0
- package/dist/orbitdb/event-query.js +131 -0
- package/dist/orbitdb/event-shell.js +510 -0
- package/dist/orbitdb/group-access.js +381 -0
- package/dist/orbitdb/group-shards.js +140 -0
- package/dist/orbitdb/ipfs-node.js +36 -11
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/client.js +3 -2
- package/dist/web/mobile-core.js +7762 -6289
- package/dist/web/routes-mobile-tasks.js +2 -1
- package/dist/web/server-storage.js +26 -5
- package/dist/web/server.js +2 -0
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能账本 + 单条回滚 + 写来源隔离 (2026-10-01, 按"账本而非闸门"的形态做)。
|
|
3
|
+
*
|
|
4
|
+
* 三件事:
|
|
5
|
+
* ① **账本**: 每次技能变更追加一条 JSONL(前后清单 + 内容寻址备份)⇒ 可单条回滚 ✓。
|
|
6
|
+
* 放 JSONL 不放数据库: 耐久 · 可 grep · 数据库重置也不丢 ✓。
|
|
7
|
+
* ② **单条回滚**: 按 id 还原那一次变更; **唯独回滚是 fail-closed** ✓(安全快照拿不到就拒绝回滚,
|
|
8
|
+
* 绝不"半还原"✗); 其余全部吞错只记日志(账本坏掉不该挡住干活 ✓)。
|
|
9
|
+
* ③ **写来源隔离**: 用 AsyncLocalStorage 区分"用户点名要的写"(foreground ✓)与
|
|
10
|
+
* "自审/复盘产生的写"(review ✓)⇒ 自审**只许治理自己造出来的技能** ✓, 用户要的归用户 ✓。
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
|
+
const originStore = new AsyncLocalStorage();
|
|
18
|
+
/** 在"自审"上下文里跑一段(其余一律按 foreground ✓) */
|
|
19
|
+
export function runWithWriteOrigin(origin, fn) {
|
|
20
|
+
return originStore.run({ origin }, fn);
|
|
21
|
+
}
|
|
22
|
+
export function currentWriteOrigin() {
|
|
23
|
+
return originStore.getStore()?.origin ?? 'foreground';
|
|
24
|
+
}
|
|
25
|
+
export function isReviewWrite() {
|
|
26
|
+
return currentWriteOrigin() === 'review';
|
|
27
|
+
}
|
|
28
|
+
export function ledgerPath(home = os.homedir()) {
|
|
29
|
+
return path.join(home, '.bolloon', 'skills', '.skill-ledger.jsonl');
|
|
30
|
+
}
|
|
31
|
+
export function blobsDir(home = os.homedir()) {
|
|
32
|
+
return path.join(home, '.bolloon', 'skills', '.ledger-blobs');
|
|
33
|
+
}
|
|
34
|
+
function sha256(text) {
|
|
35
|
+
return crypto.createHash('sha256').update(text, 'utf-8').digest('hex');
|
|
36
|
+
}
|
|
37
|
+
/** 把内容按哈希存进 blobs(已存在则复用 ✓ 天然去重) */
|
|
38
|
+
function putBlob(text, home) {
|
|
39
|
+
const h = sha256(text);
|
|
40
|
+
const p = path.join(blobsDir(home), h);
|
|
41
|
+
try {
|
|
42
|
+
if (!fs.existsSync(p)) {
|
|
43
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
44
|
+
fs.writeFileSync(p, text, { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch { /* 备份失败 ⇒ 由调用方决定是否允许这次变更(见 captureBefore) */ }
|
|
48
|
+
return h;
|
|
49
|
+
}
|
|
50
|
+
/** 变更前快照: 返回 beforeSha(拿不到安全快照时返回 undefined ⇒ 回滚能力缺失, 调用方须知道 ✓) */
|
|
51
|
+
export function captureSkillBefore(file, home) {
|
|
52
|
+
try {
|
|
53
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
54
|
+
return { beforeSha: putBlob(text, home), bytesBefore: text.length };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {}; // 文件不存在(新建)= 正常; 读不到 = 快照缺失
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** 追加一条账本(吞错只记日志 ✓ —— 账本坏掉不该挡住干活) */
|
|
61
|
+
export function appendLedger(entry, home) {
|
|
62
|
+
const full = { id: entry.id || crypto.randomBytes(8).toString('hex'), at: entry.at ?? Date.now(), ...entry };
|
|
63
|
+
try {
|
|
64
|
+
const p = ledgerPath(home);
|
|
65
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
66
|
+
fs.appendFileSync(p, JSON.stringify(full) + '\n', { mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
catch { /* telemetry, not a gate */ }
|
|
69
|
+
return full;
|
|
70
|
+
}
|
|
71
|
+
export function readLedger(home) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = fs.readFileSync(ledgerPath(home), 'utf-8');
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const line of raw.split('\n')) {
|
|
76
|
+
if (!line.trim())
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
out.push(JSON.parse(line));
|
|
80
|
+
}
|
|
81
|
+
catch { /* 坏行跳过 */ }
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** 这个技能是不是"自审造出来的"?(决定自审有没有资格改它 ✓) */
|
|
90
|
+
export function isAgentCreated(skill, home) {
|
|
91
|
+
return readLedger(home).some((e) => e.skill === skill && e.tool === 'create_skill' && e.origin === 'review');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 自审写前的资格检查(只读判断, 不改任何东西 ✓):
|
|
95
|
+
* 自审只许动**自己造出来**的技能 ✓; 用户点名要的归用户 ✓。
|
|
96
|
+
*/
|
|
97
|
+
export function reviewMayTouch(skill, home) {
|
|
98
|
+
if (!isReviewWrite())
|
|
99
|
+
return { ok: true, reason: 'foreground(用户点名要的写)' };
|
|
100
|
+
return isAgentCreated(skill, home)
|
|
101
|
+
? { ok: true, reason: 'review(该技能由自审创建, 可治理)' }
|
|
102
|
+
: { ok: false, reason: '该技能不是自审创建的(用户点名要的归用户)⇒ 自审只记候选, 不动它' };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 单条回滚: 把某次变更前的快照还原回去 ✓。
|
|
106
|
+
* **fail-closed**: 快照缺失/内容哈希对不上 ⇒ 拒绝(绝不半还原 ✗)。
|
|
107
|
+
*/
|
|
108
|
+
export function rollbackEntry(id, home) {
|
|
109
|
+
const entry = readLedger(home).find((e) => e.id === id);
|
|
110
|
+
if (!entry)
|
|
111
|
+
return { ok: false, reason: `账本里没有 id=${id}` };
|
|
112
|
+
if (entry.tool === 'create_skill') {
|
|
113
|
+
// 创建类变更: 回滚 = 删掉那个文件(要用户明确同意 ⇒ 这里只报告, 不擅自删 ✗)
|
|
114
|
+
return { ok: false, reason: '该条是"新建技能"; 回滚等于删除文件 —— 需要你明确同意, 我不擅自删' };
|
|
115
|
+
}
|
|
116
|
+
if (!entry.beforeSha)
|
|
117
|
+
return { ok: false, reason: '该条没有变更前快照 ⇒ 拒绝回滚(fail-closed, 不做半还原)' };
|
|
118
|
+
try {
|
|
119
|
+
const blob = fs.readFileSync(path.join(blobsDir(home), entry.beforeSha), 'utf-8');
|
|
120
|
+
if (sha256(blob) !== entry.beforeSha)
|
|
121
|
+
return { ok: false, reason: '快照内容与哈希不符 ⇒ 拒绝回滚' };
|
|
122
|
+
fs.writeFileSync(entry.file, blob, { mode: 0o600 });
|
|
123
|
+
appendLedger({ origin: 'foreground', tool: 'rollback', skill: entry.skill, file: entry.file, beforeSha: entry.afterSha, afterSha: entry.beforeSha, note: `回到 ${entry.id}` }, home);
|
|
124
|
+
return { ok: true, reason: `已回滚到变更前(${entry.id})`, restored: entry.file };
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
return { ok: false, reason: `回滚失败: ${String(e?.message || e).slice(0, 120)}` };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-loop-guard.ts — 工具调用的**停滞观测与温和引导** (2026-10-01)
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它 (用户原话: 「一个简单的任务居然花这么多次工具调用」):
|
|
5
|
+
* 原先的防重只有一条「同工具连续 5 次 ⇒ 注入一条提示, 然后重置计数」。实测模型无视提示后
|
|
6
|
+
* 完全没刹车 —— 同一个 get_identity 连调 10 次 ✗; 而且**只看工具名**, 连"同工具换参数的正常
|
|
7
|
+
* 迭代"(逐文件读)也会被误判。
|
|
8
|
+
* 反过来"硬停"也不行 (用户明确否决): 那是在症状上下手, 而且会堵掉正当的重复轮询。
|
|
9
|
+
*
|
|
10
|
+
* 所以这里采用**分类 + 温和合成**的口径:
|
|
11
|
+
* ① 工具先分类: 幂等读 / 有副作用 / 可重复轮询 (含命名约定 `_poll` / `_get_result`);
|
|
12
|
+
* ② 判据看**签名** (工具名 + 参数的规范 JSON 的哈希), 不只看工具名 ⇒ 换参数不误伤;
|
|
13
|
+
* ③ 命中停滞**不拒绝执行**, 而是产出一条**引导文本**追加在真结果后面 (保留模型的选择权);
|
|
14
|
+
* ④ 完全相同的**结果**从第 2 次起用**引用 stub** 替代 (省上下文; 且明确告诉模型"与上次相同");
|
|
15
|
+
* ⑤ 抓**循环** (A→B→A→B…, 周期 ≤4): 相邻重复计数会被交替调用清零, 只抓相邻的会漏;
|
|
16
|
+
* ⑥ 失败宽容类工具 (跑测试红了 / grep 空 / 页面超时) 的"失败"是正常工作输出, 不参与停滞判定。
|
|
17
|
+
*
|
|
18
|
+
* 纯逻辑, 不碰 IO / 不发请求 —— 便于单测与变异验证。
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
/** 幂等只读类: 同参数重复调用不会产生新信息 (但允许换参数继续用) */
|
|
22
|
+
/**
|
|
23
|
+
* **结果恒定**的工具 (2026-10-01): 同一会话内反复调, 返回**永远一样**, 且答案本身很短。
|
|
24
|
+
* 用户实测: `get_identity` 连调 3–4 次 ✗ —— 原有"引用 stub"有字符数门槛, 而它的输出才 ~90 字符
|
|
25
|
+
* ⇒ 门槛拦不住 ⇒ 模型看不出"再问也一样"。
|
|
26
|
+
* 规矩 (工具设置层, 不做硬刹车): 从**第 2 次**完全相同的调用起, 直接把结果换成一句标准答复,
|
|
27
|
+
* 明确告诉模型"答案就在上面那次返回里, 再调不会变" ⇒ 去掉它重复调的理由。
|
|
28
|
+
*/
|
|
29
|
+
export const CONSTANT_RESULT_TOOLS = new Set([
|
|
30
|
+
'get_identity', 'bolloon_config_get', 'get_agent_info', 'whoami',
|
|
31
|
+
]);
|
|
32
|
+
export const IDEMPOTENT_TOOLS = new Set([
|
|
33
|
+
'read_file', 'list_files', 'search_files', 'get_identity', 'get_operation_logs',
|
|
34
|
+
'list_remote_channels', 'list_context_layers', 'read_context_assets', 'get_balance', 'wallet_list',
|
|
35
|
+
'web_search', 'web_extract', 'check_inbox',
|
|
36
|
+
]);
|
|
37
|
+
/** 有副作用类: 重复调用可能是正当的 (例如再跑一条命令), 由"同签名同结果"判定停滞 */
|
|
38
|
+
export const MUTATING_TOOLS = new Set([
|
|
39
|
+
'terminal', 'shell_exec', 'execute_code', 'write_file', 'patch', 'set_persona', 'memory',
|
|
40
|
+
'todo_list', 'send_message', 'send_to_remote_channel', 'delegate_task', 'create_group', 'set_wallet',
|
|
41
|
+
]);
|
|
42
|
+
/** 轮询类: 同参数重复**本来就正当** (等异步任务), 永不触发相同调用提示 */
|
|
43
|
+
export const REPEATABLE_TOOLS = new Set(['process_manage', 'check_task_status']);
|
|
44
|
+
/** 生成/MCP 轮询工具的命名约定 (与上面同理) */
|
|
45
|
+
export const REPEATABLE_SUFFIXES = ['_poll', '_get_result'];
|
|
46
|
+
/** 失败的"正常输出"类: 红了/空结果/超时属于工作产出, 不参与停滞判定 */
|
|
47
|
+
export const FAILURE_TOLERANT_TOOLS = new Set(['terminal', 'shell_exec', 'execute_code', 'web_search', 'web_extract']);
|
|
48
|
+
/** 第几次**连续相同 (工具+参数+结果)** 触发引导 (3 = 容忍一次复核) */
|
|
49
|
+
export const IDENTICAL_CALL_THRESHOLD = 3;
|
|
50
|
+
/** 抓循环时最长的周期 (A→B→A→B 的周期是 2) */
|
|
51
|
+
export const MAX_CYCLE_PERIOD = 4;
|
|
52
|
+
/** 历史窗口: 够放最长的周期若干圈 + 余量 */
|
|
53
|
+
export const CYCLE_HISTORY = 64;
|
|
54
|
+
/** 结果达到这个字符数才值得用 stub 替代 (太短本来就省不了多少) */
|
|
55
|
+
export const STUB_MIN_CHARS = 512;
|
|
56
|
+
/**
|
|
57
|
+
* **同签名 + 同结果**的重复调用: 门槛降到这个值 (2026-10-01, 优化 #1 的收尾)。
|
|
58
|
+
* 为什么单独一条: 512 是"值不值得压缩"的门槛 ✗, 而"同一个调用又跑了一遍、结果一字不差"**本身就是浪费** ✓
|
|
59
|
+
* —— 实测 `get_identity` 输出才 ~90 字符 ⇒ 卡在 512 门外, 于是被连调 3–4 次 ✓。
|
|
60
|
+
* 现在: 同签名重复 且 结果一致 且 ≥64 字符 ⇒ 从第 2 次起换引用 stub ✓ (少于 64 字符的本来也占不了多少上下文, 放行 ✓)。
|
|
61
|
+
*/
|
|
62
|
+
export const REPEAT_STUB_MIN_CHARS = 64;
|
|
63
|
+
/** stub 里保留的参数预览长度 (万一压缩把原结果挤掉, 还能看出当初调了什么) */
|
|
64
|
+
export const STUB_ARGS_PREVIEW_CHARS = 120;
|
|
65
|
+
/** 参数的规范 JSON (键排序 + 紧凑分隔符) —— 让 {a,b} 与 {b,a} 同签名 */
|
|
66
|
+
export function canonicalToolArgs(args) {
|
|
67
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args))
|
|
68
|
+
return '';
|
|
69
|
+
const obj = args;
|
|
70
|
+
const keys = Object.keys(obj).sort();
|
|
71
|
+
const parts = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
|
|
72
|
+
return `{${parts.join(',')}}`;
|
|
73
|
+
}
|
|
74
|
+
function stableStringify(v) {
|
|
75
|
+
if (v === null || typeof v !== 'object')
|
|
76
|
+
return JSON.stringify(v) ?? 'null';
|
|
77
|
+
if (Array.isArray(v))
|
|
78
|
+
return `[${v.map(stableStringify).join(',')}]`;
|
|
79
|
+
const o = v;
|
|
80
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(',')}}`;
|
|
81
|
+
}
|
|
82
|
+
/** 调用签名: 不可逆 (只留哈希), 不泄漏参数原文 */
|
|
83
|
+
export function toolCallSignature(toolName, args) {
|
|
84
|
+
return createHash('sha256').update(`${toolName}\u0000${canonicalToolArgs(args)}`).digest('hex').slice(0, 16);
|
|
85
|
+
}
|
|
86
|
+
/** 结果指纹 (判"完全相同的返回") */
|
|
87
|
+
export function resultFingerprint(resultText) {
|
|
88
|
+
return createHash('sha256').update(String(resultText ?? '')).digest('hex').slice(0, 16);
|
|
89
|
+
}
|
|
90
|
+
export function isRepeatableTool(toolName) {
|
|
91
|
+
if (REPEATABLE_TOOLS.has(toolName))
|
|
92
|
+
return true;
|
|
93
|
+
return REPEATABLE_SUFFIXES.some((s) => toolName.endsWith(s));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 记录一次调用并给出观测。**纯函数式**: 调用方每次都要把上一次的 streak 传进来
|
|
97
|
+
* (见 `LoopStallState`), 这样状态归属清晰、也便于单测。
|
|
98
|
+
*/
|
|
99
|
+
export function observeToolCall(state, input) {
|
|
100
|
+
const ok = input.ok !== false;
|
|
101
|
+
const tolerant = FAILURE_TOLERANT_TOOLS.has(input.toolName);
|
|
102
|
+
const repeatable = isRepeatableTool(input.toolName);
|
|
103
|
+
const text = String(input.resultText ?? '');
|
|
104
|
+
const chars = input.resultChars ?? text.length;
|
|
105
|
+
// ① 轮询类: 同参数重复本就正当 ⇒ 只记录, 不出声
|
|
106
|
+
if (repeatable) {
|
|
107
|
+
state.push(input.toolName, input.args, text);
|
|
108
|
+
return { action: 'allow', code: 'allow', count: 0 };
|
|
109
|
+
}
|
|
110
|
+
const sig = toolCallSignature(input.toolName, input.args);
|
|
111
|
+
const fp = resultFingerprint(text);
|
|
112
|
+
const prev = state.last;
|
|
113
|
+
const sameAsPrev = !!prev && prev.sig === sig && prev.fp === fp;
|
|
114
|
+
// ② 连续相同 (工具 + 参数 + 结果): 每次 +1, 否则归 1
|
|
115
|
+
state.streak = sameAsPrev ? state.streak + 1 : 1;
|
|
116
|
+
state.push(input.toolName, input.args, text);
|
|
117
|
+
// ③ 失败宽容类工具的失败不参与停滞判定 (红测试/空 grep 是产出, 不是卡住)
|
|
118
|
+
if (!ok && tolerant) {
|
|
119
|
+
return { action: 'allow', code: 'allow', count: state.streak };
|
|
120
|
+
}
|
|
121
|
+
// ③.5 恒定结果工具 (get_identity 这类): 从第 2 次起换标准答复 —— 不管结果长短
|
|
122
|
+
if (sameAsPrev && CONSTANT_RESULT_TOOLS.has(input.toolName) && ok) {
|
|
123
|
+
return {
|
|
124
|
+
action: 'allow',
|
|
125
|
+
code: 'allow',
|
|
126
|
+
count: state.streak,
|
|
127
|
+
stub: `[已答] ${input.toolName} 的结果在本次会话内**恒定不变**(它读的是固定身份/配置, 不是活数据)。` +
|
|
128
|
+
`上面那一次返回就是答案, 直接用它作答即可; 再调一次只会得到同样的内容。` +
|
|
129
|
+
`若确实需要变化, 请改用写类工具(如 set_persona / 相应 update 工具)。`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ④ 结果引用 stub: 从**第 2 次**完全相同的返回起 (够长才替代; 失败永不替代)
|
|
133
|
+
let stub;
|
|
134
|
+
// 2026-10-01 (优化 #1 收尾): 同签名重复 + 结果一字不差 ⇒ 用更低门槛 (重复本身就是浪费, 不管长短)
|
|
135
|
+
const repeatFloor = input.sameSignatureBefore ? REPEAT_STUB_MIN_CHARS : STUB_MIN_CHARS;
|
|
136
|
+
if (ok && input.seenResultBefore && chars >= repeatFloor) {
|
|
137
|
+
stub = `[结果引用] 与本次会话中此前一次 ${input.toolName} 的返回**逐字相同** (${chars} 字符, 已折叠)。`
|
|
138
|
+
+ `参数预览: ${canonicalToolArgs(input.args).slice(0, STUB_ARGS_PREVIEW_CHARS)}`;
|
|
139
|
+
}
|
|
140
|
+
if (state.streak >= IDENTICAL_CALL_THRESHOLD) {
|
|
141
|
+
// ⑤ 连续相同达阈值 ⇒ 温和引导 (不拒绝执行)
|
|
142
|
+
return {
|
|
143
|
+
action: 'warn',
|
|
144
|
+
code: 'identical_call_streak',
|
|
145
|
+
count: state.streak,
|
|
146
|
+
stub,
|
|
147
|
+
notice: `[系统提示] 这是第 ${state.streak} 次**用完全相同的参数**调用 ${input.toolName}, 且返回与上次完全相同。`
|
|
148
|
+
+ `重复调用不会得到新信息 —— 请换参数、换工具, 或基于已有结果直接回答。`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// ⑥ 循环检测: 最近 N 次里出现了周期 ≤4 的重复串 (相邻计数会被交替调用清零, 只抓相邻必漏)
|
|
152
|
+
const cycle = detectCycle(state.history);
|
|
153
|
+
if (cycle) {
|
|
154
|
+
return {
|
|
155
|
+
action: 'warn',
|
|
156
|
+
code: 'identical_cycle',
|
|
157
|
+
count: cycle.laps,
|
|
158
|
+
stub,
|
|
159
|
+
notice: `[系统提示] 检测到最近 ${cycle.laps} 圈重复同一组 ${cycle.period} 次调用`
|
|
160
|
+
+ `(以 ${input.toolName} 结尾), 参数与返回都逐字相同。请不要重复这一组 —— 换参数、换工具, 或基于已有结果直接回答。`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { action: 'allow', code: 'allow', count: state.streak, stub };
|
|
164
|
+
}
|
|
165
|
+
/** 每轮 ReAct 循环一份的停滞状态 */
|
|
166
|
+
export class LoopStallState {
|
|
167
|
+
streak = 0;
|
|
168
|
+
last = null;
|
|
169
|
+
history = [];
|
|
170
|
+
/** 已见过的结果指纹 (用于"完全相同返回"的 stub 判定) */
|
|
171
|
+
seenResults = new Set();
|
|
172
|
+
push(toolName, args, resultText) {
|
|
173
|
+
const step = { sig: toolCallSignature(toolName, args), fp: resultFingerprint(resultText) };
|
|
174
|
+
this.last = step;
|
|
175
|
+
this.history.push(step);
|
|
176
|
+
if (this.history.length > CYCLE_HISTORY)
|
|
177
|
+
this.history.shift();
|
|
178
|
+
this.seenResults.add(step.fp);
|
|
179
|
+
}
|
|
180
|
+
/** 这个结果指纹此前是否出现过 (供 observeToolCall 的 seenResultBefore) */
|
|
181
|
+
hasSeenResult(text) {
|
|
182
|
+
return this.seenResults.has(resultFingerprint(text));
|
|
183
|
+
}
|
|
184
|
+
reset() {
|
|
185
|
+
this.streak = 0;
|
|
186
|
+
this.last = null;
|
|
187
|
+
this.history.length = 0;
|
|
188
|
+
this.seenResults.clear();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 找"最近若干圈完全重复"的串: 对每个候选周期 p ∈ [1, MAX_CYCLE_PERIOD],
|
|
193
|
+
* 看末尾是否由同一个 p 长度的块**重复 ≥ THRESHOLD 次**组成 (块内每个 (签名,结果指纹) 都相同)。
|
|
194
|
+
* 返回圈数与周期, 没有则 null。
|
|
195
|
+
*/
|
|
196
|
+
export function detectCycle(history) {
|
|
197
|
+
const h = history;
|
|
198
|
+
for (let p = 1; p <= MAX_CYCLE_PERIOD; p++) {
|
|
199
|
+
const need = p * IDENTICAL_CALL_THRESHOLD;
|
|
200
|
+
if (h.length < need)
|
|
201
|
+
continue;
|
|
202
|
+
const tail = h.slice(-need);
|
|
203
|
+
let same = true;
|
|
204
|
+
for (let i = p; i < need && same; i++) {
|
|
205
|
+
if (tail[i].sig !== tail[i % p].sig || tail[i].fp !== tail[i % p].fp)
|
|
206
|
+
same = false;
|
|
207
|
+
}
|
|
208
|
+
if (same)
|
|
209
|
+
return { period: p, laps: IDENTICAL_CALL_THRESHOLD };
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
214
|
+
// 2026-10-01 第二轮 (继续学): 三处补上
|
|
215
|
+
// ① **无进展**是独立一轴: 幂等工具即使**换了参数**, 只要结果与上次相同 ⇒ 也是无进展
|
|
216
|
+
// (原先只抓"同参数" ⇒ 换个措辞再问一遍就绕过去了)
|
|
217
|
+
// ② **进展的定义 = 有副作用的调用成功过** (纯读再多都不算) ⇒ 用它清零失败/无进展计数
|
|
218
|
+
// ③ 升级策略**按在场与否分流**: 有人在 ⇒ 只提醒(从不阻止执行); 无人值守 ⇒ 才允许拦/停
|
|
219
|
+
// (人在场时不该替用户做决定; 但 cron/supervisor 这种无人值守的场景, 该果断收)
|
|
220
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
221
|
+
/** 有副作用 = 会改变世界 (文件/进程/网络/资金/记忆). 唯一判据, 供"进展"与"无进展"用 */
|
|
222
|
+
export function toolMayHaveSideEffect(toolName) {
|
|
223
|
+
if (MUTATING_TOOLS.has(toolName))
|
|
224
|
+
return true;
|
|
225
|
+
if (IDEMPOTENT_TOOLS.has(toolName))
|
|
226
|
+
return false;
|
|
227
|
+
if (isRepeatableTool(toolName))
|
|
228
|
+
return false;
|
|
229
|
+
// 未登记的工具按"可能有副作用"处理 (保守: 不轻易判定"它没改变世界")
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
export const DEFAULT_THRESHOLDS = {
|
|
233
|
+
exactFailureWarnAfter: 2,
|
|
234
|
+
exactFailureBlockAfter: 5,
|
|
235
|
+
sameToolFailureWarnAfter: 3,
|
|
236
|
+
sameToolFailureHaltAfter: 8,
|
|
237
|
+
noProgressWarnAfter: 2,
|
|
238
|
+
noProgressBlockAfter: 5,
|
|
239
|
+
hardStopEnabled: false,
|
|
240
|
+
};
|
|
241
|
+
/** 无人值守场景 (cron / supervisor / 非交互) —— 那里才默认允许拦与停 */
|
|
242
|
+
export function defaultHardStopFor(context = {}) {
|
|
243
|
+
if (typeof context.unattended === 'boolean')
|
|
244
|
+
return context.unattended;
|
|
245
|
+
const env = context.env || process.env;
|
|
246
|
+
return env.BOLLOON_UNATTENDED === '1' || env.BOLLOON_CRON === '1' || env.BOLLOON_SUPERVISOR === '1';
|
|
247
|
+
}
|
|
248
|
+
export function newProgressBookkeeping() {
|
|
249
|
+
return { progressClearedSignatures: new Set(), noProgress: new Map() };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 记一次调用结果, 维护"进展 / 无进展"两本账。
|
|
253
|
+
* - 有副作用的调用**成功** ⇒ 视为进展: 清掉该签名的失败标记 + 清掉**所有**无进展记录
|
|
254
|
+
* (世界变了 ⇒ 之前"读了没变化"的判定作废)
|
|
255
|
+
* - 幂等工具: 结果指纹与上次相同 ⇒ 无进展 +1 (**参数变没变不影响**), 不同 ⇒ 归 1
|
|
256
|
+
*/
|
|
257
|
+
export function trackProgress(book, input, thresholds = DEFAULT_THRESHOLDS) {
|
|
258
|
+
const ok = input.ok !== false;
|
|
259
|
+
const sig = toolCallSignature(input.toolName, input.args);
|
|
260
|
+
const fp = resultFingerprint(String(input.resultText ?? ''));
|
|
261
|
+
if (ok && toolMayHaveSideEffect(input.toolName)) {
|
|
262
|
+
book.progressClearedSignatures.add(sig);
|
|
263
|
+
if (book.noProgress.size > 0)
|
|
264
|
+
book.noProgress.clear();
|
|
265
|
+
return { action: 'allow', code: 'allow', count: 0 };
|
|
266
|
+
}
|
|
267
|
+
if (!toolMayHaveSideEffect(input.toolName)) {
|
|
268
|
+
const prev = book.noProgress.get(input.toolName);
|
|
269
|
+
const count = prev && prev.lastResultFp === fp ? prev.count + 1 : 1;
|
|
270
|
+
book.noProgress.set(input.toolName, { lastResultFp: fp, count });
|
|
271
|
+
if (count >= thresholds.noProgressWarnAfter) {
|
|
272
|
+
return {
|
|
273
|
+
action: 'warn',
|
|
274
|
+
code: 'idempotent_no_progress',
|
|
275
|
+
count,
|
|
276
|
+
notice: `[系统提示] 连续 ${count} 次读取类调用 (${input.toolName}) 返回的都是**同一份内容**`
|
|
277
|
+
+ `(参数不同也一样) —— 世界没有变化, 再读还是它。请基于已有内容作答, 或改去查别的来源。`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return { action: 'allow', code: 'allow', count };
|
|
281
|
+
}
|
|
282
|
+
return { action: 'allow', code: 'allow', count: 0 };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 2026-10-01 (用户: 「为什么这么慢」): 量到的事实 —— 每次 LLM 往返平均 **3338ms**, 而
|
|
286
|
+
* `toolCalls>1`(一轮发多个工具)**只占 12/63** ✗ ⇒ 回合时长 ≈ 往返次数 × 3.3s ✓;
|
|
287
|
+
* 也就是说"慢"主要不是工具慢(工具合计仅占 4% ✗), 而是**同一轮只干一件事**、来回跑 ✓。
|
|
288
|
+
* 做法: 一轮只调了一个**只读**工具时, 在结果后附一句**带代价**的提醒(不是口号 ✓)。
|
|
289
|
+
* 只读工具才提醒(写类工具串行更安全 ✓ 不催)。
|
|
290
|
+
*/
|
|
291
|
+
const BATCHABLE_HINT_TOOLS = new Set([
|
|
292
|
+
'read_file', 'grep_files', 'glob_files', 'list_files', 'read_document',
|
|
293
|
+
'git_status', 'git_log', 'git_diff', 'get_identity', 'list_tools', 'list_skills',
|
|
294
|
+
'process', 'list_local_channels', 'get_balance',
|
|
295
|
+
]);
|
|
296
|
+
export function batchHint(toolName, toolCallsThisRound) {
|
|
297
|
+
if (toolCallsThisRound !== 1)
|
|
298
|
+
return '';
|
|
299
|
+
if (!BATCHABLE_HINT_TOOLS.has(String(toolName || '')))
|
|
300
|
+
return '';
|
|
301
|
+
return '\n⚡ 若你接下来还要查别的(或还要读别的文件), **同一轮可以一次发多个工具** —— '
|
|
302
|
+
+ '每次来回约 3.3 秒, 分成多轮就是纯等待; 独立的读操作请并成一轮发。';
|
|
303
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具参数里的 `~` 展开 (2026-10-01)。
|
|
3
|
+
*
|
|
4
|
+
* 用户实测: 智能体调 `list_files { path: "~/.bolloon" }` ⇒ `ENOENT: scandir '~/.bolloon'` ✗。
|
|
5
|
+
* 根因: 全仓**没有任何一处**做 `~` 展开 ⇒ 模型写的 `~/x` 原样进 fs, 变成字面目录名。
|
|
6
|
+
* 修法: 在**唯一分发点** (pi-sdk.ts 的 `tool.execute(...)`) 统一展开 —— 工具设置层修一次,
|
|
7
|
+
* 而不是每个工具各打一个补丁 (那样必然漏)。只碰已知的路径类参数, 其它参数一律不动。
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
/** 会被当成路径的参数名 (只展开这些, 别的一律不碰) */
|
|
12
|
+
export const PATH_ARG_KEYS = [
|
|
13
|
+
'path', 'paths', 'dir', 'directory', 'folder', 'file', 'file_path', 'filePath',
|
|
14
|
+
'from', 'to', 'target', 'source', 'src', 'dest', 'cwd', 'workdir', 'root', 'glob_dir',
|
|
15
|
+
];
|
|
16
|
+
/** 只展开**开头**的 `~` (单独的 `~` 或 `~/...`); `a~b` / `~user` 不碰 */
|
|
17
|
+
export function expandTilde(p, home = os.homedir()) {
|
|
18
|
+
const s = String(p ?? '');
|
|
19
|
+
if (s === '~')
|
|
20
|
+
return home;
|
|
21
|
+
if (s.startsWith('~/') || s.startsWith('~' + path.sep))
|
|
22
|
+
return path.join(home, s.slice(2));
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 已知别名: `.bolloon` 指的就是数据目录 (~/.bolloon)。
|
|
27
|
+
* 2026-10-01 用户实测: 智能体写 `list_files { path: ".bolloon" }` ⇒ `ENOENT: scandir '.bolloon'` ✗
|
|
28
|
+
* (它连 `~` 都没写, 是相对路径 ⇒ 按 cwd 解析必然不存在)。只映射**恰好的**那个别名:
|
|
29
|
+
* `.bolloon` / `.bolloon/...`; `foo/.bolloon` 之类一律不碰 (避免误伤真的同名目录)。
|
|
30
|
+
*/
|
|
31
|
+
export function expandKnownAliases(p, home = os.homedir()) {
|
|
32
|
+
const s = String(p ?? '');
|
|
33
|
+
if (s === '.bolloon')
|
|
34
|
+
return path.join(home, '.bolloon');
|
|
35
|
+
if (s.startsWith('.bolloon/') || s.startsWith('.bolloon' + path.sep))
|
|
36
|
+
return path.join(home, '.bolloon', s.slice('.bolloon/'.length));
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
export function expandHomeArgs(args, home) {
|
|
40
|
+
if (!args || typeof args !== 'object')
|
|
41
|
+
return args;
|
|
42
|
+
const out = { ...args };
|
|
43
|
+
for (const key of Object.keys(out)) {
|
|
44
|
+
if (!PATH_ARG_KEYS.includes(key))
|
|
45
|
+
continue;
|
|
46
|
+
const v = out[key];
|
|
47
|
+
if (typeof v === 'string')
|
|
48
|
+
out[key] = expandKnownAliases(expandTilde(v, home), home);
|
|
49
|
+
else if (Array.isArray(v))
|
|
50
|
+
out[key] = v.map((x) => (typeof x === 'string' ? expandKnownAliases(expandTilde(x, home), home) : x));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具结果的"进上下文"闸 (2026-10-01, 优化方向 #1)。
|
|
3
|
+
*
|
|
4
|
+
* 问题: 工具结果**没有上限** ✗ 就直接进 history, 而 history **每一轮都重放** ⇒ 一条大输出会持续吃上下文。
|
|
5
|
+
* 规矩: 超上限 ⇒ 保留**头 + 尾**(中段信息密度最低) + 把**完整结果落到文件**并把路径给模型
|
|
6
|
+
* (要看细节就 read_file ⇒ 既省上下文, 又不丢信息 ✓)。
|
|
7
|
+
* 纯函数 + 一个落盘动作; 落盘失败就退化成"只截断"(绝不因此让工具失败 ✓)。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
/** 默认上限: 8000 字符 (够放下绝大多数结果; 超了才走落文件) */
|
|
13
|
+
export const DEFAULT_RESULT_MAX_CHARS = 8000;
|
|
14
|
+
export function resultsDir(home = os.homedir()) {
|
|
15
|
+
return path.join(home, '.bolloon', 'logs', 'tool-results');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 头尾保留: head 60% / tail 25%, 留 15% 给提示语 (与出口净化的省略口径一致, 免得两套规矩)。
|
|
19
|
+
*/
|
|
20
|
+
export function capToolResult(text, opts = { tool: '?' }) {
|
|
21
|
+
const s = String(text ?? '');
|
|
22
|
+
const max = opts.maxChars ?? DEFAULT_RESULT_MAX_CHARS;
|
|
23
|
+
const originalChars = s.length;
|
|
24
|
+
if (max <= 0 || s.length <= max)
|
|
25
|
+
return { text: s, capped: false, originalChars };
|
|
26
|
+
const head = Math.floor(max * 0.6);
|
|
27
|
+
const tail = Math.floor(max * 0.25);
|
|
28
|
+
let spilledTo;
|
|
29
|
+
if (opts.spill !== false) {
|
|
30
|
+
try {
|
|
31
|
+
const home = opts.home || os.homedir();
|
|
32
|
+
const dir = resultsDir(home);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
const safeTool = String(opts.tool || 'tool').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 40);
|
|
35
|
+
const file = path.join(dir, `${Date.now()}-${safeTool}.txt`);
|
|
36
|
+
fs.writeFileSync(file, s, 'utf-8');
|
|
37
|
+
spilledTo = file;
|
|
38
|
+
}
|
|
39
|
+
catch { /* 落盘失败就只截断 */ }
|
|
40
|
+
}
|
|
41
|
+
const notice = spilledTo
|
|
42
|
+
? `\n…[结果过长已截断: 原文 ${originalChars} 字符, 完整内容已存到 ${spilledTo} —— 要看细节用 read_file 读它]…\n`
|
|
43
|
+
: `\n…[结果过长已截断: 原文 ${originalChars} 字符]…\n`;
|
|
44
|
+
return {
|
|
45
|
+
text: `${s.slice(0, head)}${notice}${s.slice(-tail)}`,
|
|
46
|
+
capped: true,
|
|
47
|
+
spilledTo,
|
|
48
|
+
originalChars,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具集合的"注入降噪" (2026-10-01, 优化 #2 v2)。
|
|
3
|
+
*
|
|
4
|
+
* 背景: 125 个工具一次性列出 ⇒ **选择过载**(实测两次错工具: terminal 拿去造文件 ✗ / list_files 当搜索 ✗)。
|
|
5
|
+
* 但注意两个约束(实测过):
|
|
6
|
+
* ① 清单**本来就是紧凑的**(只列 `name(params)`, 不列描述) ⇒ 省上下文的价值有限, 主要价值在**选得准**;
|
|
7
|
+
* ② 这份清单**被缓存**(每轮重建会顶大 prompt, 还有踩 max_tokens 的历史教训) ⇒ 默认**不做**每轮动态子集。
|
|
8
|
+
* 因此两档:
|
|
9
|
+
* · 默认档: **分组列 + 末尾一句"未列出的工具也能直接按名字调用"** ⇒ 不丢能力, 且不破坏缓存;
|
|
10
|
+
* · 开关档 (`BOLLOON_TOOL_SUBSET=on`): 按 intent 只列相关桶 + 永远带 `list_tools` ⇒ 最省认知。
|
|
11
|
+
* 纪律: **执行/校验永远用完整注册表** ⇒ 子集只影响"列给模型看的那份", 不影响能不能调 ✓。
|
|
12
|
+
*/
|
|
13
|
+
/** 分类: 名字 → 桶 (机械规则, 不猜) */
|
|
14
|
+
export function bucketOf(name) {
|
|
15
|
+
const n = name.toLowerCase();
|
|
16
|
+
// 注意顺序: `list_tools` 会被 ^list 抢成"读/找文件" ⇒ 元工具先判 (2026-10-01 门抓到)
|
|
17
|
+
if (/^(get_identity|set_persona|persona|identity|list_tools|bolloon_config)/.test(n))
|
|
18
|
+
return '身份/元';
|
|
19
|
+
if (/wallet|chain|token|balance|^tx|polymarket|safe_|x402|erc20|escrow|treasury/.test(n))
|
|
20
|
+
return '链上/钱包';
|
|
21
|
+
if (/^(read|list|glob|grep|search|find)/.test(n) || /file|dir|document|doc$/.test(n))
|
|
22
|
+
return '读/找文件';
|
|
23
|
+
if (/^(write|edit|mkdir|move|delete|copy|patch|truncate)/.test(n))
|
|
24
|
+
return '写/改文件';
|
|
25
|
+
if (/task|plan|todo|goal|^run|queue|operation_log/.test(n))
|
|
26
|
+
return '任务/计划';
|
|
27
|
+
if (/peer|channel|friend|send|broadcast|group|inbox|p2p|contact|message/.test(n))
|
|
28
|
+
return 'P2P/沟通';
|
|
29
|
+
if (/web|fetch|http|url|search_web/.test(n))
|
|
30
|
+
return '网络';
|
|
31
|
+
if (/agent|delegate|registry|spawn|call_/.test(n))
|
|
32
|
+
return '智能体';
|
|
33
|
+
return '其它';
|
|
34
|
+
}
|
|
35
|
+
/** 核心桶: 任何任务都可能用到 ⇒ 默认档全部展开 */
|
|
36
|
+
export const CORE_BUCKETS = ['读/找文件', '写/改文件', '任务/计划', '身份/元'];
|
|
37
|
+
/** intent 关键词 → 额外展开的桶 */
|
|
38
|
+
const INTENT_BUCKETS = [
|
|
39
|
+
[/钱包|链上|转账|余额|代币|合约|多签|gas|wallet|token|chain|tx/i, '链上/钱包'],
|
|
40
|
+
[/群|频道|好友|发消息|广播|节点|点对点|channel|group|friend|peer|p2p|broadcast/i, 'P2P/沟通'],
|
|
41
|
+
[/网页|抓取|搜索|下载|链接|url|web|fetch|http/i, '网络'],
|
|
42
|
+
[/智能体|子智能体|委派|agent|delegate/i, '智能体'],
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* 生成"工具清单"文本。**只影响列出来的那份**; 调用方必须保证执行侧仍用完整注册表。
|
|
46
|
+
*/
|
|
47
|
+
export function renderToolList(allNames, intentText = '', opts = {}) {
|
|
48
|
+
const total = allNames.length;
|
|
49
|
+
const byBucket = new Map();
|
|
50
|
+
for (const n of allNames) {
|
|
51
|
+
const b = bucketOf(n);
|
|
52
|
+
if (!byBucket.has(b))
|
|
53
|
+
byBucket.set(b, []);
|
|
54
|
+
byBucket.get(b).push(n);
|
|
55
|
+
}
|
|
56
|
+
const per = Math.max(1, opts.perBucket ?? 14);
|
|
57
|
+
// 哪些桶要"展开列出", 哪些只给名字(折叠)
|
|
58
|
+
const expanded = new Set();
|
|
59
|
+
if (!opts.subset) {
|
|
60
|
+
for (const b of byBucket.keys())
|
|
61
|
+
expanded.add(b);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
for (const b of CORE_BUCKETS)
|
|
65
|
+
expanded.add(b);
|
|
66
|
+
for (const [re, b] of INTENT_BUCKETS)
|
|
67
|
+
if (re.test(intentText))
|
|
68
|
+
expanded.add(b);
|
|
69
|
+
}
|
|
70
|
+
const lines = [];
|
|
71
|
+
let shown = 0;
|
|
72
|
+
const foldedBuckets = [];
|
|
73
|
+
for (const [b, names] of byBucket) {
|
|
74
|
+
if (!expanded.has(b)) {
|
|
75
|
+
foldedBuckets.push(`${b}(${names.length})`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const head = names.slice(0, per).sort();
|
|
79
|
+
shown += head.length;
|
|
80
|
+
const rest = names.length - head.length;
|
|
81
|
+
lines.push(`【${b}】${head.join(' ')}${rest > 0 ? ` …还有${rest}个` : ''}`);
|
|
82
|
+
}
|
|
83
|
+
const folded = foldedBuckets.length ? `未列出的分类: ${foldedBuckets.join(' ')}` : '';
|
|
84
|
+
const footer = [
|
|
85
|
+
'**没找到合适的工具 ⇒ 用 `list_tools {keyword:"…"}` 查**; 未列出的工具也**可以直接按名字调用**(名字对了就能跑)。',
|
|
86
|
+
folded,
|
|
87
|
+
].filter(Boolean).join('\n');
|
|
88
|
+
return { text: [lines.join('\n'), footer].filter(Boolean).join('\n'), shown, total };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 带参数名的完整清单 (= `getToolDefinitions` 真正注入的那份)。
|
|
92
|
+
* 单独抽出来是为了**可测**: 之前这个拼装写在 pi-sdk 里(私有方法 ✗ 测不到), 于是"渲染得对不对"只能靠眼看。
|
|
93
|
+
*/
|
|
94
|
+
export function renderToolListWithParams(tools, intentText = '', opts = {}) {
|
|
95
|
+
const paramOf = new Map(tools.map((t) => [t.name, Object.keys(t.parameters || {}).join(',')]));
|
|
96
|
+
const grouped = renderToolList(tools.map((t) => t.name), intentText, opts);
|
|
97
|
+
const body = grouped.text.split('\n').map((line) => {
|
|
98
|
+
if (!line.startsWith('【'))
|
|
99
|
+
return line;
|
|
100
|
+
const m = /^【([^】]+)】(.*)$/.exec(line);
|
|
101
|
+
if (!m)
|
|
102
|
+
return line;
|
|
103
|
+
const [, bucket, rest] = m;
|
|
104
|
+
const more = rest.includes('…') ? rest.slice(rest.indexOf('…')) : '';
|
|
105
|
+
const names = rest.replace(/….*$/, '').trim().split(/\s+/).filter(Boolean);
|
|
106
|
+
return `【${bucket}】` + names.map((n) => `${n}(${paramOf.get(n) ?? ''})`).join(' ') + (more ? ` ${more}` : '');
|
|
107
|
+
}).join('\n');
|
|
108
|
+
return { text: body, shown: grouped.shown, total: grouped.total };
|
|
109
|
+
}
|