@bolloon/bolloon-agent 0.3.28 → 0.3.30
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/pi-sdk-tools.js +141 -0
- package/dist/agents/skill-writer.js +19 -0
- package/dist/cli/ink-app.js +26 -3
- package/dist/index.js +31 -2
- package/dist/llm/pi-ai.js +28 -9
- package/dist/security/tool-gate.js +4 -18
- package/dist/web/server.js +5 -12
- package/package.json +1 -1
|
@@ -1852,6 +1852,147 @@ export function registerBuiltinTools(ctx) {
|
|
|
1852
1852
|
}
|
|
1853
1853
|
}
|
|
1854
1854
|
});
|
|
1855
|
+
// ============================================================
|
|
1856
|
+
// IPFS / IPNS 通用工具 (2026-08-04) — 查询 + 发布给 agent
|
|
1857
|
+
// 依赖本地 Kubo (自动安装/启动), 复用 publish_did 的 checkKuboSetup
|
|
1858
|
+
// ============================================================
|
|
1859
|
+
ctx.tools.set('ipfs_add', {
|
|
1860
|
+
name: 'ipfs_add',
|
|
1861
|
+
description: '上传文本内容到本地 IPFS (Kubo), 返回 CID. 适合把任意内容/笔记/数据发布到去中心化网络, 之后可用 ipfs_cat 读回、ipns_publish 绑定稳定标识. 自动安装/启动本地 Kubo.',
|
|
1862
|
+
parameters: { content: '要上传的内容 (必填)', name: '可选: 文件名/标签' },
|
|
1863
|
+
execute: async (args) => {
|
|
1864
|
+
try {
|
|
1865
|
+
const content = String(args.content ?? '').trim();
|
|
1866
|
+
if (!content)
|
|
1867
|
+
return { success: false, error: 'content 必填' };
|
|
1868
|
+
await ensureKuboReady();
|
|
1869
|
+
const sdk = await import('@diap/sdk');
|
|
1870
|
+
const ipfs = await sdk.IpfsClient.newWithRemoteNode('http://127.0.0.1:5001', 'http://127.0.0.1:8080');
|
|
1871
|
+
const r = await ipfs.upload(content, args.name ? String(args.name) : 'data');
|
|
1872
|
+
return { success: true, output: `✅ 已上传到 IPFS:\n CID: ${r.cid}\n size: ${r.size} bytes\n 读回: ipfs_cat(cid="${r.cid}")\n 绑定稳定标识: ipns_publish(cid="${r.cid}")` };
|
|
1873
|
+
}
|
|
1874
|
+
catch (e) {
|
|
1875
|
+
return { success: false, error: `ipfs_add 失败: ${String(e.message || e).slice(0, 200)}` };
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
ctx.tools.set('ipfs_cat', {
|
|
1880
|
+
name: 'ipfs_cat',
|
|
1881
|
+
description: '按 CID 从本地 IPFS (Kubo) 读取内容. 参数 cid 可以是 ipfs_add 的返回 CID, 也可以是 ipns_resolve 解析出的 CID.',
|
|
1882
|
+
parameters: { cid: 'IPFS CID (必填)' },
|
|
1883
|
+
execute: async (args) => {
|
|
1884
|
+
try {
|
|
1885
|
+
const cid = String(args.cid || '').trim();
|
|
1886
|
+
if (!cid)
|
|
1887
|
+
return { success: false, error: 'cid 必填' };
|
|
1888
|
+
await ensureKuboReady();
|
|
1889
|
+
const text = await kuboApi(`/api/v0/cat?arg=${encodeURIComponent(cid)}`);
|
|
1890
|
+
const s = String(text ?? '');
|
|
1891
|
+
return { success: true, output: `📄 ${cid} (${s.length} 字符):\n${s.slice(0, 4000)}${s.length > 4000 ? '\n...(截断)' : ''}` };
|
|
1892
|
+
}
|
|
1893
|
+
catch (e) {
|
|
1894
|
+
return { success: false, error: `ipfs_cat 失败: ${String(e.message || e).slice(0, 200)}` };
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
ctx.tools.set('ipfs_ls', {
|
|
1899
|
+
name: 'ipfs_ls',
|
|
1900
|
+
description: '列出 IPFS CID 下的目录内容 (Kubo). 适用于 CID 指向目录 (如 ipfs_add 上传带 name 或 DID 文档目录) 时查看子项.',
|
|
1901
|
+
parameters: { cid: 'IPFS CID (必填)' },
|
|
1902
|
+
execute: async (args) => {
|
|
1903
|
+
try {
|
|
1904
|
+
const cid = String(args.cid || '').trim();
|
|
1905
|
+
if (!cid)
|
|
1906
|
+
return { success: false, error: 'cid 必填' };
|
|
1907
|
+
await ensureKuboReady();
|
|
1908
|
+
const r = await kuboApi(`/api/v0/ls?arg=${encodeURIComponent(cid)}`);
|
|
1909
|
+
const objs = r?.Objects || [];
|
|
1910
|
+
const obj = objs[0];
|
|
1911
|
+
const links = obj?.Links || [];
|
|
1912
|
+
if (links.length === 0 && obj?.Type === 2) {
|
|
1913
|
+
return { success: true, output: `📄 ${cid} 是单个文件 (${obj.Size ?? '?'} bytes), 不是目录` };
|
|
1914
|
+
}
|
|
1915
|
+
const lines = links.map((l) => ` ${l.Type === 1 ? '📁' : '📄'} ${l.Name} ${l.Size} bytes ${l.Hash}`);
|
|
1916
|
+
return { success: true, output: `📂 ${cid} (${links.length} 项):\n${lines.join('\n') || ' (空目录)'}` };
|
|
1917
|
+
}
|
|
1918
|
+
catch (e) {
|
|
1919
|
+
return { success: false, error: `ipfs_ls 失败: ${String(e.message || e).slice(0, 200)}` };
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
ctx.tools.set('ipns_publish', {
|
|
1924
|
+
name: 'ipns_publish',
|
|
1925
|
+
description: '把 IPFS CID 发布为 IPNS name (稳定标识, 内容更新后 name 不变). 默认用 self key (agent 身份), 可指定已有 key. 发布后任何节点可用 ipns_resolve 解析该 name 得到 CID.',
|
|
1926
|
+
parameters: { cid: 'IPFS CID (必填, 通常是 ipfs_add 的返回)', keyName: '可选: Kubo key 名 (默认 self)' },
|
|
1927
|
+
execute: async (args) => {
|
|
1928
|
+
try {
|
|
1929
|
+
const cid = String(args.cid || '').trim();
|
|
1930
|
+
if (!cid)
|
|
1931
|
+
return { success: false, error: 'cid 必填' };
|
|
1932
|
+
await ensureKuboReady();
|
|
1933
|
+
const sdk = await import('@diap/sdk');
|
|
1934
|
+
const ipfs = await sdk.IpfsClient.newWithRemoteNode('http://127.0.0.1:5001', 'http://127.0.0.1:8080');
|
|
1935
|
+
const keyName = String(args.keyName || 'self').trim() || 'self';
|
|
1936
|
+
await ipfs.ensureKeyExists(keyName);
|
|
1937
|
+
const r = await ipfs.publishIpns(cid, keyName, '8760h', '1h');
|
|
1938
|
+
return { success: true, output: `✅ IPNS 已发布:\n name: ${r.name}\n value: ${r.value}\n 解析: ipns_resolve(name="${r.name}")\n 公网访问: https://ipfs.io/ipns/${r.name}` };
|
|
1939
|
+
}
|
|
1940
|
+
catch (e) {
|
|
1941
|
+
return { success: false, error: `ipns_publish 失败: ${String(e.message || e).slice(0, 200)}` };
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
ctx.tools.set('ipns_resolve', {
|
|
1946
|
+
name: 'ipns_resolve',
|
|
1947
|
+
description: '解析 IPNS name 得到 IPFS CID (Kubo). name 通常是 ipns_publish 返回的 name 或 k51... 形式的 IPNS 标识, 也可以是完整 /ipns/<name> 路径. 注意: 首次解析需查 DHT, 最长约 60 秒; 同一 name 重发布后本地缓存可能返回旧 CID, 等待传播后重试.',
|
|
1948
|
+
parameters: { name: 'IPNS name (必填, 如 k51qzi5uqu5d... 或 /ipns/k51...)' },
|
|
1949
|
+
execute: async (args) => {
|
|
1950
|
+
try {
|
|
1951
|
+
const name = String(args.name || '').trim();
|
|
1952
|
+
if (!name)
|
|
1953
|
+
return { success: false, error: 'name 必填' };
|
|
1954
|
+
await ensureKuboReady();
|
|
1955
|
+
const r = await kuboApi(`/api/v0/name/resolve?arg=${encodeURIComponent(name)}`, undefined, 60000);
|
|
1956
|
+
const path = typeof r === 'object' && r !== null ? r.Path : String(r);
|
|
1957
|
+
const cid = String(path).replace(/^\/ipfs\//, '').trim();
|
|
1958
|
+
return { success: true, output: `🔗 ${name} → ${path}\n CID: ${cid}` };
|
|
1959
|
+
}
|
|
1960
|
+
catch (e) {
|
|
1961
|
+
return { success: false, error: `ipns_resolve 失败: ${String(e.message || e).slice(0, 200)}` };
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
// ─── IPFS/IPNS 通用 helper (2026-08-04) ─────────────────────────────────────
|
|
1967
|
+
// 复用 publish_did 的 checkKuboSetup 自动安装/启动本地 Kubo (darwin-arm64 v0.28.0)
|
|
1968
|
+
async function ensureKuboReady() {
|
|
1969
|
+
const sdk = await import('@diap/sdk');
|
|
1970
|
+
const checkKuboSetup = sdk.checkKuboSetup;
|
|
1971
|
+
if (typeof checkKuboSetup === 'function') {
|
|
1972
|
+
const setup = await checkKuboSetup(true, true);
|
|
1973
|
+
if (!setup?.ready || !setup?.daemonRunning) {
|
|
1974
|
+
throw new Error('本地 Kubo 不可用 (自动安装失败), 无法访问 IPFS');
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
async function kuboApi(pathAndQuery, init, timeoutMs = 30000) {
|
|
1979
|
+
const controller = new AbortController();
|
|
1980
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1981
|
+
try {
|
|
1982
|
+
const url = `http://127.0.0.1:5001${pathAndQuery}`;
|
|
1983
|
+
const resp = await fetch(url, { method: 'POST', signal: controller.signal, ...(init || {}) });
|
|
1984
|
+
if (!resp.ok) {
|
|
1985
|
+
const text = await resp.text();
|
|
1986
|
+
throw new Error(`Kubo API ${pathAndQuery.split('?')[0]} 失败: ${resp.status} ${text.slice(0, 200)}`);
|
|
1987
|
+
}
|
|
1988
|
+
const ct = resp.headers.get('content-type') || '';
|
|
1989
|
+
if (ct.includes('application/json'))
|
|
1990
|
+
return resp.json();
|
|
1991
|
+
return resp.text();
|
|
1992
|
+
}
|
|
1993
|
+
finally {
|
|
1994
|
+
clearTimeout(timer);
|
|
1995
|
+
}
|
|
1855
1996
|
}
|
|
1856
1997
|
/**
|
|
1857
1998
|
* 注册 Wallet + Polymarket + Safe 工具 (基于 constraint-runtime/src/tools/).
|
|
@@ -191,3 +191,22 @@ export async function promoteCandidate(name, opts = {}, home = os.homedir()) {
|
|
|
191
191
|
}
|
|
192
192
|
return r;
|
|
193
193
|
}
|
|
194
|
+
export async function writeRunEndSkillCandidates(steps, source, minOk = 2) {
|
|
195
|
+
const okSteps = (steps || []).filter((s) => s.status === 'ok' && s.name && s.name !== 'system' && s.name !== '?');
|
|
196
|
+
if (okSteps.length < minOk) {
|
|
197
|
+
return { wrote: false, reason: `成功工具不足 (${okSteps.length} < ${minOk})` };
|
|
198
|
+
}
|
|
199
|
+
const toolNames = okSteps.map((s) => s.name).slice(0, 5).join(', ');
|
|
200
|
+
const body = `## 背景\n本轮对话连续成功调用了 ${okSteps.length} 个工具: ${toolNames}.\n\n` +
|
|
201
|
+
`## 流程\n${okSteps.map((s) => `1. 调用 ${s.name}${s.output ? ': ' + String(s.output).slice(0, 120) : ''}`).join('\n')}\n\n` +
|
|
202
|
+
`## 注意事项\n- 工具名以 list_skills / get_operation_logs 的实际注册名为准\n- 沉淀为正式 skill 前请人工确认流程可复用\n`;
|
|
203
|
+
const candName = `auto-${okSteps[0].name}-${Date.now().toString(36)}`;
|
|
204
|
+
const file = await writeSkillCandidate({
|
|
205
|
+
name: candName,
|
|
206
|
+
description: `自动候选: ${okSteps.length} 个工具连续成功 (${toolNames})`,
|
|
207
|
+
body,
|
|
208
|
+
source,
|
|
209
|
+
timestamp: new Date().toISOString(),
|
|
210
|
+
});
|
|
211
|
+
return { wrote: true, file, count: okSteps.length, names: toolNames };
|
|
212
|
+
}
|
package/dist/cli/ink-app.js
CHANGED
|
@@ -31,6 +31,9 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
31
31
|
const { exit } = useApp();
|
|
32
32
|
const [thinking, setThinking] = useState(false);
|
|
33
33
|
const thinkingIdx = useRef(0);
|
|
34
|
+
// 双击 Esc 退出当前进程 (500ms 窗口内第二次按下)
|
|
35
|
+
const lastEscRef = useRef(0);
|
|
36
|
+
const C_WARN_ANSI = '\x1b[38;2;245;158;11m'; // #f59e0b
|
|
34
37
|
// 全局: 思考动画控制
|
|
35
38
|
useEffect(() => {
|
|
36
39
|
globalThis.__inkSetThinking = (v) => setThinking(v);
|
|
@@ -55,9 +58,29 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
55
58
|
onPrompt(trimmed);
|
|
56
59
|
}, [onPrompt]);
|
|
57
60
|
useInput((_input, key) => {
|
|
58
|
-
|
|
61
|
+
// 退出请求: 通知 startCLI resolve → 走清理 → process.exit (带兜底)
|
|
62
|
+
const requestExit = () => {
|
|
63
|
+
globalThis.__inkRequestExit?.();
|
|
59
64
|
exit();
|
|
60
|
-
|
|
65
|
+
// 兜底: 清理路径挂住时 2s 后强制退出
|
|
66
|
+
setTimeout(() => process.exit(0), 2000);
|
|
67
|
+
};
|
|
68
|
+
if (key.ctrl && _input === 'c') {
|
|
69
|
+
requestExit();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// 双击 Esc 退出当前进程: 第一击提示, 500ms 内第二击退出
|
|
73
|
+
if (key.escape) {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
if (now - lastEscRef.current < 500) {
|
|
76
|
+
requestExit();
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
lastEscRef.current = now;
|
|
80
|
+
inkAppendLine(`${C_WARN_ANSI}⚠ 再按一次 Esc 退出当前进程\x1b[0m`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// TextInput handles actual input; useInput only for Ctrl+C / Esc
|
|
61
84
|
});
|
|
62
85
|
// 自动更新状态栏 (每秒)
|
|
63
86
|
useEffect(() => {
|
|
@@ -78,7 +101,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
78
101
|
}, 600);
|
|
79
102
|
return () => clearInterval(timer);
|
|
80
103
|
}, [thinking]);
|
|
81
|
-
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: "\u8F93\u5165\u6D88\u606F..." })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) })] }));
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: "\u8F93\u5165\u6D88\u606F... Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) })] }));
|
|
82
105
|
};
|
|
83
106
|
// ─── 启动 ────────────────────────────────────────────────────────────────────
|
|
84
107
|
let _inkInstance = null;
|
package/dist/index.js
CHANGED
|
@@ -422,17 +422,25 @@ async function startCLI(comm) {
|
|
|
422
422
|
return `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${dur}s\x1b[90m │\x1b[0m ${bar} ${cliContextPct}%`;
|
|
423
423
|
};
|
|
424
424
|
startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
|
|
425
|
-
// Wait on a promise that resolves on Ctrl+C
|
|
426
|
-
|
|
425
|
+
// Wait on a promise that resolves on Ctrl+C / 双击 Esc
|
|
426
|
+
// (ink-app 的 requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
|
|
427
|
+
let cliExitResolve = () => { };
|
|
428
|
+
const exitPromise = new Promise(resolve => { cliExitResolve = resolve; });
|
|
429
|
+
globalThis.__inkRequestExit = () => { cliExitResolve(); };
|
|
430
|
+
await exitPromise;
|
|
431
|
+
delete globalThis.__inkRequestExit;
|
|
427
432
|
stopInk();
|
|
428
433
|
appendLine(`\n${CYAN}👋 再见!${RESET}`);
|
|
429
434
|
comm.stop();
|
|
435
|
+
process.exit(0);
|
|
430
436
|
}
|
|
431
437
|
async function processInput(input, comm) {
|
|
432
438
|
const trimmed = input.trim();
|
|
433
439
|
// TUI tool call state (local to this invocation)
|
|
434
440
|
const tuiToolCalls = [];
|
|
435
441
|
let tuiToolCounter = 0;
|
|
442
|
+
// run-end 经验整理: 收集本轮连续成功的工具 (≥2 个自动写候选, 颜文字加载)
|
|
443
|
+
const runEndOkSteps = [];
|
|
436
444
|
// each iteration
|
|
437
445
|
let lastToolEvent = null;
|
|
438
446
|
// !command — 直接执行终端命令
|
|
@@ -489,6 +497,7 @@ async function processInput(input, comm) {
|
|
|
489
497
|
appendLine(` ${C_ACCENT}peers${RESET} 查看 P2P 节点`);
|
|
490
498
|
appendLine(` ${C_ACCENT}iroh${RESET} 查看 iroh 状态`);
|
|
491
499
|
appendLine(` ${C_ACCENT}add_friend${RESET} 添加好友`);
|
|
500
|
+
appendLine(` ${C_ACCENT}Esc 双击${RESET} 退出当前进程`);
|
|
492
501
|
appendLine(` ${C_ACCENT}exit${RESET} 退出`);
|
|
493
502
|
return;
|
|
494
503
|
}
|
|
@@ -576,6 +585,12 @@ async function processInput(input, comm) {
|
|
|
576
585
|
error: e.error,
|
|
577
586
|
durationMs: p ? Date.now() - p._t : undefined,
|
|
578
587
|
};
|
|
588
|
+
if (e.type === 'step_done') {
|
|
589
|
+
const t = e.tool ?? p?.tool;
|
|
590
|
+
if (t && t !== 'system' && t !== '?') {
|
|
591
|
+
runEndOkSteps.push({ status: 'ok', name: t, output: e.output });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
579
594
|
appendLine(renderToolCallListItem(doneItem, tuiToolCalls.length + 1, tuiToolCounter));
|
|
580
595
|
}
|
|
581
596
|
}
|
|
@@ -584,6 +599,20 @@ async function processInput(input, comm) {
|
|
|
584
599
|
appendLine(renderAgentMessage(response));
|
|
585
600
|
// 停止思考动画
|
|
586
601
|
inkSetThinking(false);
|
|
602
|
+
// 2026-08-04: run-end 经验整理 — 连续成功工具 ≥2 自动写 skill 候选 (颜文字加载)
|
|
603
|
+
if (runEndOkSteps.length >= 2) {
|
|
604
|
+
appendLine(`${C_DIM}(`・ω・´) 整理本轮经验中... ${runEndOkSteps.length} 个工具调用${RESET}`);
|
|
605
|
+
setImmediate(async () => {
|
|
606
|
+
try {
|
|
607
|
+
const { writeRunEndSkillCandidates } = await import('./agents/skill-writer.js');
|
|
608
|
+
const r = await writeRunEndSkillCandidates(runEndOkSteps, 'cli:interactive');
|
|
609
|
+
if (r.wrote) {
|
|
610
|
+
appendLine(`${C_OK}✨ (◕‿◕) 经验候选已写入: ${r.names}${RESET}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
catch { /* 非致命, 静默 */ }
|
|
614
|
+
});
|
|
615
|
+
}
|
|
587
616
|
// 更新状态栏: 上下文进度
|
|
588
617
|
try {
|
|
589
618
|
const msgLen = JSON.stringify(a.messageHistory ?? []).length;
|
package/dist/llm/pi-ai.js
CHANGED
|
@@ -282,15 +282,34 @@ export class PiAIModel {
|
|
|
282
282
|
const _t0 = Date.now();
|
|
283
283
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
284
284
|
const _tFetch = Date.now();
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
285
|
+
let response;
|
|
286
|
+
try {
|
|
287
|
+
response = await fetch(`${this.getBaseUrl()}/chat/completions`, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: {
|
|
290
|
+
'Content-Type': 'application/json',
|
|
291
|
+
'Authorization': `Bearer ${apiKey}`
|
|
292
|
+
},
|
|
293
|
+
body: JSON.stringify(requestBody),
|
|
294
|
+
signal: this.combinedSignal(signal),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
// 2026-08-04: 网络层瞬时错误 (undici "terminated" / ECONNRESET / socket hang up / fetch failed 等)
|
|
299
|
+
// 退避重试最多 2 次 — 之前直接抛给 chat() 变成 "[AI 服务调用失败] terminated" 打断 agent 流程.
|
|
300
|
+
// abort (用户主动 / 120s 超时) 不重试, 原样抛出.
|
|
301
|
+
if (err?.name === 'AbortError' || signal?.aborted)
|
|
302
|
+
throw err;
|
|
303
|
+
const netMsg = String(err?.message || err?.cause?.message || '');
|
|
304
|
+
const isNetworkErr = /terminated|ECONNRESET|socket hang up|fetch failed|network|ETIMEDOUT|ECONNREFUSED|UND_ERR/i.test(netMsg);
|
|
305
|
+
if (attempt < 2 && isNetworkErr) {
|
|
306
|
+
const backoff = 1500 * (attempt + 1);
|
|
307
|
+
console.warn(`[pi-ai] 网络错误 attempt ${attempt + 1}/3: ${netMsg.slice(0, 120)}, 退避 ${backoff}ms 重试`);
|
|
308
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
294
313
|
const _tResp = Date.now();
|
|
295
314
|
if (!response.ok) {
|
|
296
315
|
const errBody = await response.text().catch(() => '(no body)');
|
|
@@ -54,6 +54,8 @@ const TOOL_WHITELIST = new Set([
|
|
|
54
54
|
'mcp_list_tools', 'mcp_tool',
|
|
55
55
|
// 2026-08-03: DID 发布到 IPFS+IPNS (自动安装 Kubo)
|
|
56
56
|
'publish_did',
|
|
57
|
+
// 2026-08-04: IPFS/IPNS 通用查询 + 发布 (pi-sdk-tools.ts)
|
|
58
|
+
'ipfs_add', 'ipfs_cat', 'ipfs_ls', 'ipns_publish', 'ipns_resolve',
|
|
57
59
|
// 2026-08-02: 远端 channel 工具 (本地智能体 @ 远程交流)
|
|
58
60
|
'list_remote_channels', 'send_to_remote_channel',
|
|
59
61
|
]);
|
|
@@ -200,23 +202,7 @@ export function checkOutput(output) {
|
|
|
200
202
|
};
|
|
201
203
|
}
|
|
202
204
|
// ============================================================
|
|
203
|
-
// Gate 7:
|
|
204
|
-
// ============================================================
|
|
205
|
-
const MAX_TOOL_CALLS_PER_TURN = 5;
|
|
206
|
-
export function checkChain(ctx) {
|
|
207
|
-
const count = ctx.toolCallCountInTurn ?? 0;
|
|
208
|
-
if (count >= MAX_TOOL_CALLS_PER_TURN) {
|
|
209
|
-
return {
|
|
210
|
-
gate: 'chain',
|
|
211
|
-
allowed: false,
|
|
212
|
-
reason: `单轮已调 ${count} 个 tool (上限 ${MAX_TOOL_CALLS_PER_TURN})`,
|
|
213
|
-
evidence: `当前轮 tool 调用次数: ${count}`,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
return { gate: 'chain', allowed: true };
|
|
217
|
-
}
|
|
218
|
-
// ============================================================
|
|
219
|
-
// Gate 8: 黑名单 (复用 PreToolUse hook 已有 6 条规则, 重新实现于此)
|
|
205
|
+
// Gate 7: 黑名单 (复用 PreToolUse hook 已有 6 条规则, 重新实现于此)
|
|
220
206
|
// ============================================================
|
|
221
207
|
const DANGEROUS_CMD_PATTERNS = [
|
|
222
208
|
{ re: /\brm\s+(-[a-z]*f[a-z]*\s+)?-[a-z]*r[a-z]*\s+\//, reason: '禁止递归删除根目录' },
|
|
@@ -243,7 +229,7 @@ const TOOL_GATES = [
|
|
|
243
229
|
checkChannel,
|
|
244
230
|
checkRate,
|
|
245
231
|
checkInject,
|
|
246
|
-
checkChain
|
|
232
|
+
// 2026-08-04: 移除 checkChain — 单轮 5 工具上限会打断 agent 长流程 (MCP 多步测试被反复拦), 用户要求去掉
|
|
247
233
|
checkBlacklist,
|
|
248
234
|
// checkOutput 不在 tool.execute 前
|
|
249
235
|
];
|
package/dist/web/server.js
CHANGED
|
@@ -3740,24 +3740,17 @@ ${goalDesc}
|
|
|
3740
3740
|
// 2026-08-02: run-end skill 候选扫描 (fire-and-forget, 不阻塞 finally)
|
|
3741
3741
|
// 从本轮 lastSteps 提取连续成功的工具调用模式, 写入 ~/.bolloon/skill-candidates/.
|
|
3742
3742
|
// agent 之后可调 list_skill_candidates / promote_skill 决定是否转正.
|
|
3743
|
+
// 2026-08-04: 抽公共函数 writeRunEndSkillCandidates (Web server 与 CLI 共用)
|
|
3743
3744
|
try {
|
|
3744
3745
|
const steps = runState.lastSteps || [];
|
|
3745
3746
|
const okTools = steps.filter(s => s.status === 'ok' && s.name && s.name !== 'system');
|
|
3746
3747
|
if (okTools.length >= 2) {
|
|
3747
3748
|
setImmediate(async () => {
|
|
3748
3749
|
try {
|
|
3749
|
-
const {
|
|
3750
|
-
const
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
const file = await writeSkillCandidate({
|
|
3754
|
-
name: candName,
|
|
3755
|
-
description: `自动候选: ${okTools.length} 个工具连续成功 (${toolNames})`,
|
|
3756
|
-
body,
|
|
3757
|
-
source: `channel:${channelId}`,
|
|
3758
|
-
timestamp: new Date().toISOString(),
|
|
3759
|
-
});
|
|
3760
|
-
console.log(`[skill-candidates] 写入候选 ${file} (${okTools.length} tools)`);
|
|
3750
|
+
const { writeRunEndSkillCandidates } = await import('../agents/skill-writer.js');
|
|
3751
|
+
const r = await writeRunEndSkillCandidates(steps, `channel:${channelId}`);
|
|
3752
|
+
if (r.wrote)
|
|
3753
|
+
console.log(`[skill-candidates] 写入候选 ${r.file} (${r.count} tools)`);
|
|
3761
3754
|
}
|
|
3762
3755
|
catch (candErr) {
|
|
3763
3756
|
console.warn('[skill-candidates] 写入失败 (non-fatal):', candErr?.message?.slice(0, 150));
|