@bolloon/bolloon-agent 0.5.4 → 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 +201 -32
- package/dist/agents/pi-sdk.js +463 -21
- package/dist/agents/plan-store.js +36 -0
- package/dist/agents/process-runner.js +64 -2
- 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/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 +153 -6
- package/dist/cli/input-paste.js +218 -0
- package/dist/cli/loading-tui.js +3 -1
- package/dist/cli/log-gate.js +7 -0
- package/dist/cli/mention-data.js +30 -0
- package/dist/cli/reasoning-view.js +103 -0
- package/dist/cli/startup-notice.js +25 -0
- package/dist/cli/status-segments.js +87 -2
- package/dist/cli/trace-line.js +54 -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 +200 -31
- 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 +464 -21
- 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/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/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/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/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 +503 -77
- 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,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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具调用遥测 (2026-10-01, 用户: 「开始吧, 把这些事儿做完」—— 优化方向 #3)。
|
|
3
|
+
*
|
|
4
|
+
* 问题: 我们要"让工具调用变好", 但**没有任何度量** ✗ ⇒ 全靠感觉。这条先把"浪费在哪"变成数字。
|
|
5
|
+
* 记录 (每行一条 JSONL): 工具名 · 参数指纹 · 耗时 · 成功/失败 · 结果字符数 · **是否与上一次同签名**。
|
|
6
|
+
* 由此可算: 每任务调用次数 · **重复率** · 结果体量分布 · 慢工具排行 —— 后面每一条优化的效果都可证伪 ✓。
|
|
7
|
+
* 纪律: **fire-and-forget**(写失败绝不影响工具执行) · 只落本机数据目录 · 不记参数正文(只记指纹,
|
|
8
|
+
* 免得把用户的密钥/正文写进日志 ✓)。
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
export function telemetryDir(home = os.homedir()) {
|
|
15
|
+
return path.join(home, '.bolloon', 'logs');
|
|
16
|
+
}
|
|
17
|
+
export function telemetryPath(home = os.homedir()) {
|
|
18
|
+
return path.join(telemetryDir(home), 'tool-calls.jsonl');
|
|
19
|
+
}
|
|
20
|
+
/** 参数指纹: 只留结构, 不留值 (工具名 + 参数键名 + 值长度) —— 避免把秘密写进日志 */
|
|
21
|
+
export function argsFingerprint(args) {
|
|
22
|
+
try {
|
|
23
|
+
const o = (args && typeof args === 'object' ? args : {});
|
|
24
|
+
const shape = Object.keys(o).sort().map((k) => {
|
|
25
|
+
const v = o[k];
|
|
26
|
+
const kind = Array.isArray(v) ? `arr${v.length}` : typeof v === 'string' ? `str${v.length}` : typeof v;
|
|
27
|
+
return `${k}:${kind}`;
|
|
28
|
+
}).join(',');
|
|
29
|
+
return createHash('sha256').update(`${shape}`).digest('hex').slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 'unknown';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** 追加一条 (绝不影响调用方) */
|
|
36
|
+
export function recordToolCall(rec) {
|
|
37
|
+
try {
|
|
38
|
+
const home = rec.home || os.homedir();
|
|
39
|
+
const dir = telemetryDir(home);
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
const row = {
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
tool: String(rec.tool || '?'),
|
|
44
|
+
sig: String(rec.sig || '?'),
|
|
45
|
+
ms: Math.max(0, Math.round(Number(rec.ms) || 0)),
|
|
46
|
+
ok: !!rec.ok,
|
|
47
|
+
resultChars: Math.max(0, Math.round(Number(rec.resultChars) || 0)),
|
|
48
|
+
repeatOfPrev: !!rec.prevSig && rec.prevSig === rec.sig,
|
|
49
|
+
...(rec.errorClass ? { errorClass: String(rec.errorClass).slice(0, 40) } : {}),
|
|
50
|
+
};
|
|
51
|
+
fs.appendFileSync(telemetryPath(home), JSON.stringify(row) + '\n', 'utf-8');
|
|
52
|
+
return row;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** 汇总 (给"优化前/后"对比用): 读回 JSONL, 算重复率/体量/慢工具 */
|
|
59
|
+
export function summarizeTelemetry(home = os.homedir(), limit = 5000) {
|
|
60
|
+
const empty = { calls: 0, repeats: 0, repeatRate: 0, okRate: 0, resultChars: 0, avgMs: 0, errorsByClass: [], topTools: [], slowest: [], biggestResults: [] };
|
|
61
|
+
try {
|
|
62
|
+
const file = telemetryPath(home);
|
|
63
|
+
if (!fs.existsSync(file))
|
|
64
|
+
return empty;
|
|
65
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean).slice(-limit);
|
|
66
|
+
const rows = [];
|
|
67
|
+
for (const l of lines) {
|
|
68
|
+
try {
|
|
69
|
+
rows.push(JSON.parse(l));
|
|
70
|
+
}
|
|
71
|
+
catch { /* 跳过坏行 */ }
|
|
72
|
+
}
|
|
73
|
+
if (!rows.length)
|
|
74
|
+
return empty;
|
|
75
|
+
const byTool = new Map();
|
|
76
|
+
for (const r of rows)
|
|
77
|
+
byTool.set(r.tool, (byTool.get(r.tool) || 0) + 1);
|
|
78
|
+
const sum = (f) => rows.reduce((a, r) => a + f(r), 0);
|
|
79
|
+
const calls = rows.length;
|
|
80
|
+
const repeats = rows.filter((r) => r.repeatOfPrev).length;
|
|
81
|
+
return {
|
|
82
|
+
calls,
|
|
83
|
+
repeats,
|
|
84
|
+
repeatRate: Number((repeats / calls).toFixed(3)),
|
|
85
|
+
okRate: Number((rows.filter((r) => r.ok).length / calls).toFixed(3)),
|
|
86
|
+
errorsByClass: [...rows.filter((r) => !r.ok).reduce((m, r) => m.set(r.errorClass || '未分类', (m.get(r.errorClass || '未分类') || 0) + 1), new Map()).entries()].map(([cls, n]) => ({ cls, n })).sort((a, b) => b.n - a.n).slice(0, 8),
|
|
87
|
+
resultChars: sum((r) => r.resultChars),
|
|
88
|
+
avgMs: Math.round(sum((r) => r.ms) / calls),
|
|
89
|
+
topTools: [...byTool.entries()].map(([tool, n]) => ({ tool, n })).sort((a, b) => b.n - a.n).slice(0, 10),
|
|
90
|
+
slowest: [...rows].sort((a, b) => b.ms - a.ms).slice(0, 5).map((r) => ({ tool: r.tool, ms: r.ms })),
|
|
91
|
+
biggestResults: [...rows].sort((a, b) => b.resultChars - a.resultChars).slice(0, 5).map((r) => ({ tool: r.tool, chars: r.resultChars })),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return empty;
|
|
96
|
+
}
|
|
97
|
+
}
|