@bolloon/bolloon-agent 0.4.21 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * gateway-join.ts — 文档驱动的「加入全球智能体网络」编排 (2026-09-15)
3
+ *
4
+ * 背景: 手机端 / PC 端的人类入口只有一句话 —— `read https://bolloon.cn/bolloon-gateway-join.md`
5
+ * (默认 prompt, 见 src/web/mobile.js DEFAULT_JOIN_PROMPT)。文档本身是 SKILL.md 形状的入网说明,
6
+ * 但文档第 1-8 节写的是 SDK 伪码 (KeyManager / p2pNetwork.createNode / buildManifestRequest),
7
+ * 智能体手里只有工具, 照抄不了 → 需要一个工具把整条链路真正串起来。
8
+ *
9
+ * 本模块把「读文档 → 入网」收成一个可验证的闭环:
10
+ * ① 读入网说明 (http(s), 校验 frontmatter 是 bolloon-gateway-join)
11
+ * ② 确保 DID 身份
12
+ * ③ 确保 P2P 节点 (拿 peerId)
13
+ * ④ 注册本地 manifest (文档第 3 节 /api/agent/register 的等效物: setLocalManifest)
14
+ * ⑤ 建成可分享的网络 (orbitdb registry 链接) + 登记到本机 Agent 服务注册表
15
+ * ⑥ 落盘入网态 (幂等: 同 url 重复入网 → already)
16
+ *
17
+ * 诚实原则: 每一步都记 step.note; 拿不到的 (文档不可达 / 非入网说明 / 节点未就绪 / registry 离线)
18
+ * 一律如实报 ok=false 或 step.ok=false, 不假装入网成功。
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as os from 'os';
22
+ import * as path from 'path';
23
+ export const DEFAULT_GATEWAY_JOIN_DOC = 'https://bolloon.cn/bolloon-gateway-join.md';
24
+ /** 入网说明文档的识别名 (frontmatter name) / 能力标记 */
25
+ export const GATEWAY_JOIN_DOC_NAME = 'bolloon-gateway-join';
26
+ export const GATEWAY_JOIN_CAPABILITY = 'gateway-join';
27
+ const home = () => process.env.HOME || os.homedir() || '/tmp';
28
+ const STATE_FILE = () => path.join(home(), '.bolloon', 'gateway-join.json');
29
+ /** 极简 frontmatter 解析 (只认 name/version/capabilities 三个键, 够用且不引 yaml 依赖) */
30
+ export function parseSkillFrontmatter(text) {
31
+ const raw = String(text || '');
32
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
33
+ if (!m)
34
+ return { body: raw };
35
+ const head = m[1];
36
+ const body = raw.slice(m[0].length);
37
+ const out = {};
38
+ for (const line of head.split(/\r?\n/)) {
39
+ const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
40
+ if (!kv)
41
+ continue;
42
+ const key = kv[1].toLowerCase();
43
+ const val = kv[2].trim();
44
+ if (key === 'name')
45
+ out.name = val.replace(/^["']|["']$/g, '');
46
+ else if (key === 'version')
47
+ out.version = val.replace(/^["']|["']$/g, '');
48
+ else if (key === 'capabilities') {
49
+ out.capabilities = val.replace(/^\[|\]$/g, '').split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
50
+ }
51
+ }
52
+ return { ...out, body };
53
+ }
54
+ /** 从文档文本判定「这是不是入网说明」 */
55
+ export function isGatewayJoinDoc(text) {
56
+ const fm = parseSkillFrontmatter(text);
57
+ if (fm.name === GATEWAY_JOIN_DOC_NAME)
58
+ return true;
59
+ if (fm.capabilities?.includes(GATEWAY_JOIN_CAPABILITY))
60
+ return true;
61
+ // 没有 frontmatter 也允许 (纯正文版本): 标题 + 关键步骤同时出现才算
62
+ return /加入网关|加入 Bolloon|bolloon-gateway-join/.test(text) && /api\/agent\/register/.test(text);
63
+ }
64
+ export async function readGatewaySkillDoc(url, opts = {}) {
65
+ const u = String(url || '').trim();
66
+ if (!/^https?:\/\//i.test(u))
67
+ return { ok: false, url: u, error: `url 必须以 http(s):// 开头 (收到: ${u.slice(0, 60)})` };
68
+ let text = opts.text;
69
+ if (text === undefined) {
70
+ const f = opts.fetchImpl || fetch;
71
+ try {
72
+ const r = await f(u, { signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) });
73
+ if (!r.ok)
74
+ return { ok: false, url: u, error: `文档不可达 (HTTP ${r.status})` };
75
+ text = await r.text();
76
+ }
77
+ catch (e) {
78
+ return { ok: false, url: u, error: `文档不可达: ${String(e?.message || e).slice(0, 140)}` };
79
+ }
80
+ }
81
+ const raw = String(text || '');
82
+ if (!raw.trim())
83
+ return { ok: false, url: u, error: '文档为空' };
84
+ const fm = parseSkillFrontmatter(raw);
85
+ if (!isGatewayJoinDoc(raw)) {
86
+ return { ok: false, url: u, name: fm.name, error: '这不是 Bolloon 网关入网说明 (缺 name: bolloon-gateway-join), 拒绝据此入网' };
87
+ }
88
+ return { ok: true, url: u, name: fm.name, version: fm.version, capabilities: fm.capabilities, body: fm.body };
89
+ }
90
+ export async function getGatewayJoinState() {
91
+ try {
92
+ const raw = fs.readFileSync(STATE_FILE(), 'utf-8');
93
+ const parsed = JSON.parse(raw);
94
+ return parsed && typeof parsed === 'object' ? parsed : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ export function writeGatewayJoinState(state) {
101
+ try {
102
+ const f = STATE_FILE();
103
+ fs.mkdirSync(path.dirname(f), { recursive: true });
104
+ fs.writeFileSync(f, JSON.stringify(state, null, 2), { mode: 0o600 });
105
+ }
106
+ catch { /* 落盘失败不影响本次入网, 只影响幂等/重启恢复 */ }
107
+ }
108
+ /** 节点初始化并发去重 (同一进程内只起一次 libp2p 节点) */
109
+ let nodeInitPromise = null;
110
+ /**
111
+ * 确保本机 libp2p 节点在跑 (文档第 2 节 p2pNetwork.createNode 的等价物)。
112
+ * p2pNetwork.createNode 不是幂等的, 所以先用 getNodePeerId() 探测 + 单飞 promise 兜住并发。
113
+ */
114
+ export async function ensureGatewayNode(deps = {}) {
115
+ if (deps.peerId)
116
+ return { ok: true, peerId: deps.peerId, started: false };
117
+ try {
118
+ const { p2pNetwork } = await import('../network/p2p.js');
119
+ const existing = String(p2pNetwork.getNodePeerId?.() || '');
120
+ if (existing)
121
+ return { ok: true, peerId: existing, multiaddrs: p2pNetwork.getWsMultiaddrs?.() || [], started: false };
122
+ if (!nodeInitPromise) {
123
+ const timeoutMs = deps.nodeTimeoutMs ?? 25_000;
124
+ nodeInitPromise = (async () => {
125
+ try {
126
+ const info = await Promise.race([
127
+ p2pNetwork.createNode({
128
+ enableRelay: true,
129
+ enableRelayServer: true,
130
+ enableAutoNat: deps.enableAutoNat ?? false,
131
+ enableUPnP: deps.enableUPnP ?? false,
132
+ }),
133
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`节点初始化超时 (${timeoutMs}ms)`)), timeoutMs)),
134
+ ]);
135
+ const peerId = String(info?.peerId || p2pNetwork.getNodePeerId?.() || '');
136
+ return peerId
137
+ ? { ok: true, peerId, multiaddrs: (info?.multiaddrs || []) }
138
+ : { ok: false, error: '节点起来了但拿不到 peerId' };
139
+ }
140
+ catch (e) {
141
+ return { ok: false, error: String(e?.message || e).slice(0, 160) };
142
+ }
143
+ finally {
144
+ // 失败也允许下次重试 (成功时后续调用被 getNodePeerId 分支短路)
145
+ setTimeout(() => { nodeInitPromise = null; }, 1000);
146
+ }
147
+ })();
148
+ }
149
+ const r = await nodeInitPromise;
150
+ return { ...r, started: !!r.ok };
151
+ }
152
+ catch (e) {
153
+ return { ok: false, error: String(e?.message || e).slice(0, 160) };
154
+ }
155
+ }
156
+ function readUserDid() {
157
+ try {
158
+ const f = path.join(home(), '.bolloon', 'identity', 'user.json');
159
+ const parsed = JSON.parse(fs.readFileSync(f, 'utf-8'));
160
+ return String(parsed?.did || '');
161
+ }
162
+ catch {
163
+ return '';
164
+ }
165
+ }
166
+ /**
167
+ * 文档驱动入网: 读说明 → DID → 节点 → manifest → 建网 → 落盘。
168
+ * 核心三步 (文档/DID/manifest) 任一失败 → ok=false; 网络/服务登记失败只记 note (部分成功不谎报)。
169
+ */
170
+ export async function joinGlobalGateway(opts = {}) {
171
+ const url = String(opts.url || DEFAULT_GATEWAY_JOIN_DOC).trim();
172
+ const deps = opts.deps || {};
173
+ const steps = [];
174
+ // 幂等: 已经按同一文档入过网 → already (不重复建, 但仍返回既有信息)
175
+ const prev = await getGatewayJoinState();
176
+ if (prev && prev.url === url && prev.did && !opts.force) {
177
+ return {
178
+ ok: true, already: true, url, did: prev.did, peerId: prev.peerId,
179
+ networkLink: prev.networkLink, networkId: prev.networkId,
180
+ steps: [{ step: '幂等', ok: true, note: `已于 ${prev.joinedAt} 入网 (${prev.did.slice(0, 24)}…)` }],
181
+ };
182
+ }
183
+ // ① 读入网说明
184
+ const doc = await readGatewaySkillDoc(url, { fetchImpl: deps.fetchImpl, text: deps.docText });
185
+ steps.push({ step: '读入网说明', ok: doc.ok, note: doc.ok ? `${doc.name || 'doc'} v${doc.version || '?'} (${(doc.body || '').length} 字符)` : (doc.error || '') });
186
+ if (!doc.ok)
187
+ return { ok: false, url, error: doc.error, steps };
188
+ // ② DID 身份
189
+ const did = String(opts.did || deps.did || readUserDid() || '');
190
+ steps.push({ step: 'DID 身份', ok: !!did, note: did ? `${did.slice(0, 30)}…` : '本机无 DID (先运行 bolloon 让 server 生成 ~/.bolloon/identity/user.json)' });
191
+ if (!did)
192
+ return { ok: false, url, error: '缺少 DID 身份, 无法入网', steps };
193
+ const name = String(opts.name || 'bolloon-agent');
194
+ // ③ P2P 节点 (文档第 2 节: p2pNetwork.createNode) — 起不来的话如实在 note 里说
195
+ const node = await ensureGatewayNode(deps);
196
+ const peerId = String(node.peerId || '');
197
+ const multiaddrs = node.multiaddrs || [];
198
+ steps.push({
199
+ step: 'P2P 节点',
200
+ ok: !!peerId,
201
+ note: peerId ? `peerId=${peerId.slice(0, 16)}…${node.started ? ' (本次启动)' : ' (已在跑)'}` : `节点未就绪: ${node.error || '未知原因'}`,
202
+ });
203
+ // ③b 登记节点端点 + 广播地址 (文档第 2/7 节) — 非致命
204
+ if (peerId) {
205
+ try {
206
+ const { initializeAgentNetwork, broadcastOwnAddress } = await import('../network/agent-network.js');
207
+ await initializeAgentNetwork(did, name, peerId, multiaddrs);
208
+ await broadcastOwnAddress().catch(() => { });
209
+ steps.push({ step: '登记节点端点 + 广播地址', ok: true, note: `multiaddrs=${multiaddrs.length} 条` });
210
+ }
211
+ catch (e) {
212
+ steps.push({ step: '登记节点端点 + 广播地址', ok: false, note: `失败: ${String(e?.message || e).slice(0, 120)}` });
213
+ }
214
+ }
215
+ // ④ 注册本地 manifest (文档第 3 节)
216
+ const capabilities = (opts.capabilities && opts.capabilities.length ? opts.capabilities : ['chat', 'gateway-join']).map(String);
217
+ const entry = { id: `${name}-main`.replace(/\s+/g, '-'), name, capabilities, status: 'active', ...(peerId ? { peerId } : {}) };
218
+ let registered = false;
219
+ try {
220
+ if (deps.registerManifest) {
221
+ deps.registerManifest({ ownerName: name, ownerPublicKey: did, agents: [entry] });
222
+ registered = true;
223
+ }
224
+ else {
225
+ const { setLocalManifest, getLocalManifest } = await import('./agent-manifest-protocol.js');
226
+ setLocalManifest({ ownerName: name, ownerPublicKey: did, agents: [entry] });
227
+ registered = (getLocalManifest().agents || []).some((a) => a.id === entry.id);
228
+ }
229
+ }
230
+ catch (e) {
231
+ steps.push({ step: '注册 manifest', ok: false, note: `注册失败: ${String(e?.message || e).slice(0, 120)}` });
232
+ return { ok: false, url, did, error: 'manifest 注册失败', steps };
233
+ }
234
+ steps.push({ step: '注册 manifest', ok: registered, note: registered ? `agents=[${entry.id}] capabilities=[${capabilities.join(',')}]` : 'register 后读回为空' });
235
+ if (!registered)
236
+ return { ok: false, url, did, error: 'manifest 注册后读回为空', steps };
237
+ // ⑤ 建网 (可分享的 orbitdb 网络链接) — 离线/registry 未就绪时如实记 note
238
+ let networkLink;
239
+ let networkId;
240
+ try {
241
+ const share = deps.shareLink
242
+ ? await deps.shareLink({ name })
243
+ : await (await import('./gateway-network.js')).shareNetworkLink({ name });
244
+ if (share.ok && share.link) {
245
+ networkLink = share.link;
246
+ networkId = new URLSearchParams(share.link.split('?')[1] || '').get('name') || undefined;
247
+ steps.push({ step: '建成网络 (可分享链接)', ok: true, note: share.link.slice(0, 72) });
248
+ }
249
+ else {
250
+ steps.push({ step: '建成网络 (可分享链接)', ok: false, note: `未建成: ${share.error || 'registry 未就绪 (离线模式)'}` });
251
+ }
252
+ }
253
+ catch (e) {
254
+ steps.push({ step: '建成网络 (可分享链接)', ok: false, note: `异常: ${String(e?.message || e).slice(0, 120)}` });
255
+ }
256
+ // ⑥ 登记到本机 Agent 服务注册表 (让他方按能力发现/委派)
257
+ try {
258
+ const r = deps.registerService
259
+ ? await deps.registerService({ capability: capabilities[0], did, name })
260
+ : await (await import('./agent-gateway.js')).gatewayRegisterAgent({ capability: capabilities[0], description: `${name} 入网节点`, price: '0', per: 'task' }, { did, name, wallet: '' });
261
+ steps.push({ step: '服务登记 (可被发现)', ok: !!r?.ok, note: r?.ok ? '已写入本机 registry' : (r?.error || '未写入') });
262
+ }
263
+ catch (e) {
264
+ steps.push({ step: '服务登记 (可被发现)', ok: false, note: `异常: ${String(e?.message || e).slice(0, 120)}` });
265
+ }
266
+ // ⑦ 落盘入网态 (幂等 + 重启恢复)
267
+ writeGatewayJoinState({ url, did, name, capabilities, peerId: peerId || undefined, networkLink, networkId, joinedAt: new Date().toISOString() });
268
+ steps.push({ step: '落盘入网态', ok: (await getGatewayJoinState())?.url === url, note: STATE_FILE() });
269
+ return { ok: true, url, did, peerId: peerId || undefined, networkLink, networkId, docVersion: doc.version, steps };
270
+ }
271
+ export default { DEFAULT_GATEWAY_JOIN_DOC, readGatewaySkillDoc, isGatewayJoinDoc, parseSkillFrontmatter, joinGlobalGateway, getGatewayJoinState, writeGatewayJoinState };
@@ -1031,12 +1031,23 @@ export function registerBuiltinTools(ctx) {
1031
1031
  // 通用文件读取 (M4)
1032
1032
  ctx.tools.set('read_file', {
1033
1033
  name: 'read_file',
1034
- description: '读取任意文件内容 (相对 cwd). 只读操作, 无白名单限制.',
1035
- parameters: { path: '相对路径 (必填)', startLine: '起始行号 (可选, 默认 0)', maxLines: '最大行数 (可选, 默认 500)' },
1034
+ description: '读取任意文件内容 (相对 cwd); 也支持 http(s) URL (如 "read https://…/doc.md" 这种口令 → 直接给 URL 即可). 只读操作, 无白名单限制.',
1035
+ parameters: { path: '相对路径或 http(s) URL (必填)', startLine: '起始行号 (可选, 默认 0)', maxLines: '最大行数 (可选, 默认 500)' },
1036
1036
  execute: async (args) => {
1037
1037
  const relPath = String(args.path || '').trim();
1038
1038
  if (!relPath)
1039
1039
  return { success: false, error: 'path 必填' };
1040
+ // 2026-09-15: URL 直读 — 人类口令 "read <url>" 落到的就是本工具, 之前会 ENOENT (当成相对路径)
1041
+ if (/^https?:\/\//i.test(relPath)) {
1042
+ const r = await readUrlAsText(relPath);
1043
+ if (!r.ok)
1044
+ return { success: false, error: `读取 URL 失败: ${r.error}` };
1045
+ const start = Math.max(0, parseInt(String(args.startLine || '0')) || 0);
1046
+ const max = parseInt(String(args.maxLines || '500')) || 500;
1047
+ const lines = String(r.text || '').split('\n');
1048
+ const slice = lines.slice(start, start + max);
1049
+ return { success: true, output: `🌐 ${relPath} (第 ${start + 1}-${start + slice.length} 行, 共 ${lines.length} 行):\n${slice.map((l, i) => `${String(start + i + 1).padStart(4)} | ${l}`).join('\n')}` };
1050
+ }
1040
1051
  try {
1041
1052
  const absPath = path.resolve(ctx.cwd, relPath);
1042
1053
  const content = fsSync.readFileSync(absPath, 'utf-8');
@@ -1175,6 +1186,25 @@ export function registerBuiltinTools(ctx) {
1175
1186
  // Web 上网工具 (2026-08-04) — fetch_url + web_search
1176
1187
  // 用 undici request (独立连接池, 与 pi-ai 一致, 避开全局 fetch 僵尸连接问题)
1177
1188
  // ============================================================
1189
+ /** 读一个 http(s) URL 的正文 (curl, 兼容 TLS 指纹风控). read_file 的 URL 分支复用. 函数声明提升, 上面可调用. */
1190
+ async function readUrlAsText(url, maxChars = 60_000) {
1191
+ try {
1192
+ const { execFile } = await import('child_process');
1193
+ const { promisify } = await import('util');
1194
+ const pExecFile = promisify(execFile);
1195
+ const { stdout, stderr } = await pExecFile('curl', [
1196
+ '-sL', '--max-time', '25',
1197
+ '-A', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
1198
+ url,
1199
+ ], { maxBuffer: 4 * 1024 * 1024, timeout: 30_000 });
1200
+ if (!stdout)
1201
+ return { ok: false, error: String(stderr || '空响应').slice(0, 200) };
1202
+ return { ok: true, text: String(stdout).slice(0, maxChars) };
1203
+ }
1204
+ catch (e) {
1205
+ return { ok: false, error: String(e?.message || e).slice(0, 200) };
1206
+ }
1207
+ }
1178
1208
  ctx.tools.set('fetch_url', {
1179
1209
  name: 'fetch_url',
1180
1210
  description: '抓取一个 URL 的网页内容并转成纯文本. 适合查文档/新闻/API 页面. 返回前 4000 字符. HTML 自动去标签, JSON/文本原样返回. (走 curl, 兼容 TLS 指纹风控)',
@@ -2835,6 +2865,10 @@ export function registerBuiltinTools(ctx) {
2835
2865
  const link = String(args.link || '').trim();
2836
2866
  if (!link)
2837
2867
  return { success: false, error: 'link 必填 (ipns:// / orbitdb:// / https://)' };
2868
+ // 2026-09-15: 入网说明文档 (.md) 不是 registry 链接 — 别静默当 registry 拉, 指到正确工具
2869
+ if (/^https?:\/\//i.test(link) && (/\.md(\?|#|$)/i.test(link) || /gateway-join/i.test(link))) {
2870
+ return { success: false, error: `这是一个「入网说明文档」而不是网络链接。请改用 join_global_gateway (url=${link}) 走文档驱动入网。` };
2871
+ }
2838
2872
  try {
2839
2873
  const { joinNetwork } = await import('./gateway-network.js');
2840
2874
  const r = await joinNetwork(link);
@@ -2847,6 +2881,45 @@ export function registerBuiltinTools(ctx) {
2847
2881
  }
2848
2882
  },
2849
2883
  });
2884
+ // ============================================================
2885
+ // join_global_gateway (2026-09-15) — 文档驱动的「加入全球智能体网络」
2886
+ // 人类口令只有一句: `read https://bolloon.cn/bolloon-gateway-join.md`
2887
+ // 文档写的是 SDK 伪码, agent 手里只有工具 → 本工具把整条链路串成闭环:
2888
+ // 读说明 → DID → P2P 节点 → 注册 manifest → 建成可分享网络 → 服务登记 → 落盘(幂等)
2889
+ // ============================================================
2890
+ ctx.tools.set('join_global_gateway', {
2891
+ name: 'join_global_gateway',
2892
+ description: '按「入网说明文档」加入全球智能体网络 (完整闭环: 读文档 → DID 身份 → P2P 节点 → 注册本地 manifest → 建成可分享的网络 → 服务登记 → 落盘). 默认文档即 https://bolloon.cn/bolloon-gateway-join.md (人类口令 "read <该文档>" 时用本工具)。幂等: 重复入网返回 already。文档不可达或不是入网说明 → 如实失败, 不假装入网。',
2893
+ parameters: {
2894
+ url: '入网说明文档 URL (可选, 默认 https://bolloon.cn/bolloon-gateway-join.md)',
2895
+ name: '本智能体名字 (可选, 默认取当前身份名)',
2896
+ capabilities: '声明能力, 逗号分隔 (可选, 默认 chat,gateway-join)',
2897
+ force: 'true = 忽略幂等重新入网 (可选)',
2898
+ },
2899
+ execute: async (args) => {
2900
+ try {
2901
+ const { joinGlobalGateway } = await import('./gateway-join.js');
2902
+ const caps = String(args.capabilities || '').split(',').map((s) => s.trim()).filter(Boolean);
2903
+ const r = await joinGlobalGateway({
2904
+ url: String(args.url || '').trim() || undefined,
2905
+ did: ctx.identity?.did || undefined,
2906
+ name: String(args.name || '').trim() || ctx.identity?.name || undefined,
2907
+ capabilities: caps.length ? caps : undefined,
2908
+ force: String(args.force || '') === 'true',
2909
+ });
2910
+ const lines = r.steps.map((s) => `${s.ok ? '✓' : '✗'} ${s.step}: ${s.note}`);
2911
+ if (!r.ok)
2912
+ return { success: false, error: `${r.error}\n${lines.join('\n')}` };
2913
+ return {
2914
+ success: true,
2915
+ output: `🌐 已加入全球智能体网络${r.already ? ' (已在网, 幂等)' : ''}\nDID: ${r.did}\npeerId: ${r.peerId || '(节点后台启动中)'}${r.networkLink ? `\n可分享网络链接: ${r.networkLink}` : ''}\n${lines.join('\n')}`,
2916
+ };
2917
+ }
2918
+ catch (e) {
2919
+ return { success: false, error: `join_global_gateway 失败: ${String(e?.message || e).slice(0, 200)}` };
2920
+ }
2921
+ },
2922
+ });
2850
2923
  ctx.tools.set('gateway_status', {
2851
2924
  name: 'gateway_status',
2852
2925
  description: '查看 Agent 网络状态 (已注册服务 + 信誉 + 已加入的网络).',
@@ -1473,6 +1473,8 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1473
1473
  role: 'assistant',
1474
1474
  content: reply,
1475
1475
  toolCalls: toolCalls.length > 1 ? toolCalls : [toolCalls[0]],
1476
+ // 2026-09-15: 思考模式思维链原样存回 (下一轮带 tools 的请求必须回带, 否则 deepseek 400)
1477
+ reasoningContent: response?.reasoningContent,
1476
1478
  });
1477
1479
  // 2026-08-09: 并发执行本轮所有工具 (Hermes 式 Agent Runtime: 一轮内多工具并行,
1478
1480
  // 一轮没跑完之前不中断 — 工具执行不检查 abort, 全部完成才 continue 下一轮)
@@ -1747,7 +1749,8 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1747
1749
  // LLM 返回的不是 tool call 格式
1748
1750
  this.messageHistory.push({
1749
1751
  role: 'assistant',
1750
- content: reply
1752
+ content: reply,
1753
+ reasoningContent: response?.reasoningContent,
1751
1754
  });
1752
1755
  // 通知前端收到非工具调用回复 (2026-08-09: 完整内容, 不再截断 150)
1753
1756
  if (onStream) {
@@ -1979,7 +1982,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1979
1982
  continue;
1980
1983
  }
1981
1984
  if (r === 'assistant') {
1982
- out.push({ role: 'assistant', content: (m.content || '').slice(0, 4000) });
1985
+ out.push({ role: 'assistant', content: (m.content || '').slice(0, 4000), reasoningContent: m.reasoningContent });
1983
1986
  continue;
1984
1987
  }
1985
1988
  if (r === 'user') {
@@ -2001,7 +2004,7 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2001
2004
  continue;
2002
2005
  }
2003
2006
  if (r === 'assistant') {
2004
- out.push({ role: 'assistant', content: m.content || '' });
2007
+ out.push({ role: 'assistant', content: m.content || '', reasoningContent: m.reasoningContent });
2005
2008
  continue;
2006
2009
  }
2007
2010
  if (r === 'user') {
package/dist/llm/pi-ai.js CHANGED
@@ -303,6 +303,28 @@ export class PiAIModel {
303
303
  };
304
304
  return modelMap[this.provider];
305
305
  }
306
+ /**
307
+ * 2026-09-15: 出网前把 messages 规整成 wire 形状.
308
+ *
309
+ * 背景 (真跑复现 + 逐项对照, 见 wiki log 2026-09-15): DeepSeek 思考模式 (deepseek-v4-*)
310
+ * 在**请求带 tools** 时, 任何 assistant 消息缺 `reasoning_content` 字段 →
311
+ * HTTP 400 "The `reasoning_content` in the thinking mode must be passed back to the API".
312
+ * 复现: 同一个 17 条消息的真实请求体, 不带 tools → 200; 带上 tools → 400;
313
+ * 给每条 assistant 补 `reasoning_content:""` → 带 tools 也 200。
314
+ * 表现: 多轮工具循环 (第 2 轮起) 直接断, 用户看到 "AI 服务调用失败"。
315
+ *
316
+ * 处置: 只对 deepseek 生效 (唯一实测过的 provider); 有原文用原文, 没有就补空串 —
317
+ * 空串已被官方接受, 且不改变语义 (思维链不是给模型看的历史内容)。
318
+ * 其他 provider 原样透传 (OpenAI 系对未知字段更敏感, 不做无证据的改动)。
319
+ */
320
+ prepareWireMessages(messages) {
321
+ const echoReasoning = this.provider === 'deepseek';
322
+ if (!echoReasoning)
323
+ return messages;
324
+ return messages.map((m) => m.role === 'assistant'
325
+ ? { role: 'assistant', content: m.content ?? '', reasoning_content: m.reasoningContent ?? '' }
326
+ : m);
327
+ }
306
328
  async callOpenAI(messages, temperature, maxTokens, signal, tools) {
307
329
  const apiKey = this.getApiKey();
308
330
  if (!apiKey) {
@@ -310,7 +332,7 @@ export class PiAIModel {
310
332
  }
311
333
  const requestBody = {
312
334
  model: this.mapModel(),
313
- messages,
335
+ messages: this.prepareWireMessages(messages),
314
336
  temperature,
315
337
  max_tokens: maxTokens
316
338
  };
@@ -370,6 +392,15 @@ export class PiAIModel {
370
392
  const errBody = await body.text().catch(() => '(no body)');
371
393
  console.log(`[pi-ai DEBUG] OpenAI 错误 ${statusCode}: ${String(errBody).slice(0, 500)}`);
372
394
  console.log(`[pi-ai DEBUG] 请求体: model=${requestBody.model}, messages=${requestBody.messages?.length}, max_tokens=${requestBody.max_tokens}, baseUrl=${this.getBaseUrl()}`);
395
+ if (process.env.BOLLOON_DUMP_BODY === '1') {
396
+ try {
397
+ const fsx = await import('fs');
398
+ const p = `/tmp/bolloon-req-${Date.now()}.json`;
399
+ fsx.writeFileSync(p, JSON.stringify(requestBody, null, 2));
400
+ console.log(`[pi-ai DEBUG] 失败请求体已落盘: ${p}`);
401
+ }
402
+ catch { /* 调试用, 失败忽略 */ }
403
+ }
373
404
  retryAgent?.destroy().catch(() => { });
374
405
  throw new Error(`OpenAI API error: ${statusCode} ${String(errBody).slice(0, 300)}`);
375
406
  }
@@ -378,6 +409,8 @@ export class PiAIModel {
378
409
  const choice = data.choices?.[0];
379
410
  const content = choice?.message?.content || '';
380
411
  const toolCalls = choice?.message?.tool_calls;
412
+ // 2026-09-15: 思考模式思维链 — 原样存回 history, 下一轮必须回带 (见 prepareWireMessages)
413
+ const reasoningContent = choice?.message?.reasoning_content || undefined;
381
414
  lastFinishReason = choice?.finish_reason || '';
382
415
  // Bug 7: tool_calls 存在时不走重试 — LLM 选工具时 content 空是合法的
383
416
  if (content || (toolCalls && toolCalls.length > 0)) {
@@ -388,7 +421,7 @@ export class PiAIModel {
388
421
  const promptBytes = JSON.stringify(messages).length;
389
422
  console.log(`[pi-ai timing] total=${_tAfter - _t0}ms attempt=${attempt + 1} fetch=${_tResp - _tFetch}ms parse=${_tParse - _tResp}ms reply=${content.length}B toolCalls=${toolCalls?.length ?? 0} model=${this.mapModel()} prompt=${promptBytes}B`);
390
423
  retryAgent?.destroy().catch(() => { });
391
- return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined };
424
+ return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined, reasoningContent };
392
425
  }
393
426
  console.warn(`[pi-ai] attempt ${attempt + 1}/3: 空 content (finish_reason=${lastFinishReason}), 退避 1.5s 重试`);
394
427
  const _tSleep = Date.now();
@@ -10,6 +10,33 @@ const RELAY_RETRY_INTERVAL = 30000;
10
10
  const MAX_RELAY_HOPS = 3;
11
11
  const MESSAGE_TIMESTAMP_TOLERANCE = 24 * 60 * 60 * 1000;
12
12
  const SIGNED_MESSAGE_TYPES = ['task', 'response', 'discovery', 'address_broadcast'];
13
+ /**
14
+ * 2026-09-15: did:key:z6Mk… 与 Ed25519 公钥字节是否一致。
15
+ *
16
+ * DID 由公钥派生 (did:key 规范: multibase base58btc of 0xed01 || rawKey), 所以首次接触时
17
+ * 自携公钥**无法伪造**: 冒充者换了公钥就解码不出同一个 DID。
18
+ * 返回: true 一致 / false 不一致 (疑似冒充) / null 非 did:key 形态 (无法判定, 不阻断, 交给签名验证)。
19
+ */
20
+ export async function didKeyMatchesPublicKey(did, publicKeyHex) {
21
+ if (!/^did:key:z/.test(did))
22
+ return null;
23
+ try {
24
+ const { base58btc } = await import('multiformats/bases/base58');
25
+ const decoded = base58btc.decode(did.slice('did:key:'.length));
26
+ // 34 字节 = 2 字节 multicodec 前缀 (0xed01 = ed25519-pub) + 32 字节公钥
27
+ if (decoded.length !== 34)
28
+ return false;
29
+ if (decoded[0] !== 0xed || decoded[1] !== 0x01)
30
+ return false;
31
+ const claimed = Buffer.from(publicKeyHex, 'hex');
32
+ if (claimed.length !== 32)
33
+ return false;
34
+ return Buffer.compare(Buffer.from(decoded.slice(2)), claimed) === 0;
35
+ }
36
+ catch {
37
+ return null; // 解码失败 → 不判定 (不当成冒充, 也不放行: 签名仍要过)
38
+ }
39
+ }
13
40
  export class AgentRegistry {
14
41
  agents = new Map();
15
42
  registryPath;
@@ -192,6 +219,7 @@ export class AgentRegistry {
192
219
  return null;
193
220
  }
194
221
  const relayAddr = p2pNetwork.getRelayAddress?.() || null;
222
+ const ownPublicKeyHex = Buffer.from(this.keyPair.publicKey).toString('hex');
195
223
  const broadcastData = JSON.stringify({
196
224
  type: 'address_broadcast',
197
225
  from: this.keyPair.did,
@@ -200,6 +228,7 @@ export class AgentRegistry {
200
228
  multiaddrs: this.ownEndpoint.multiaddrs,
201
229
  relayAddr: relayAddr || undefined,
202
230
  canRelay: relayAddr ? true : false,
231
+ publicKey: ownPublicKeyHex,
203
232
  timestamp: now
204
233
  });
205
234
  const signature = await this.signMessage(broadcastData);
@@ -213,6 +242,7 @@ export class AgentRegistry {
213
242
  multiaddrs: this.ownEndpoint.multiaddrs,
214
243
  relayAddr: relayAddr || undefined,
215
244
  canRelay: relayAddr ? true : false,
245
+ publicKey: ownPublicKeyHex,
216
246
  timestamp: now,
217
247
  signature: Buffer.from(signature).toString('hex')
218
248
  };
@@ -237,10 +267,35 @@ export class AgentRegistry {
237
267
  multiaddrs: broadcast.multiaddrs,
238
268
  relayAddr: broadcast.relayAddr,
239
269
  canRelay: broadcast.canRelay,
270
+ publicKey: broadcast.publicKey,
240
271
  timestamp: broadcast.timestamp
241
272
  });
242
273
  const signature = Buffer.from(broadcast.signature, 'hex');
243
- const isValid = await this.verifySignature(broadcast.from, broadcastData, signature);
274
+ const known = this.agents.get(broadcast.from);
275
+ // 2026-09-15: 首次接触 (全球化去中心网络里全是陌生人) —— 原来只认「registry 里已有的公钥」,
276
+ // 陌生人第一次广播必然验签失败 → 广播被丢弃 → 陌生人永远发现不了彼此 (入网文档 §7 的链路实际不通).
277
+ // 现在: 未知 DID 时用广播自带 publicKey 自证 (TOFU: 首次接触信任自携公钥);
278
+ // did:key 额外做 DID↔公钥 派生一致性检查 (DID 本身就是公钥, 自携公钥无法伪造).
279
+ let verifyKeyHex = known?.publicKey || undefined;
280
+ if (!verifyKeyHex) {
281
+ const claimed = String(broadcast.publicKey || '');
282
+ if (!claimed) {
283
+ console.warn(`[Registry] Unknown agent ${broadcast.from.substring(0, 20)}… 且广播未携带 publicKey → 无法验证, 拒收`);
284
+ return false;
285
+ }
286
+ const binding = await didKeyMatchesPublicKey(broadcast.from, claimed);
287
+ if (binding === false) {
288
+ console.warn(`[Registry] did:key 与自带公钥不匹配 (疑似冒充) from=${broadcast.from.substring(0, 24)}… → 拒收`);
289
+ return false;
290
+ }
291
+ verifyKeyHex = claimed;
292
+ }
293
+ else if (broadcast.publicKey && broadcast.publicKey !== verifyKeyHex) {
294
+ // 已认识这个 DID, 却报出另一把公钥 → 身份接管尝试, 拒收 (不覆盖已存公钥)
295
+ console.warn(`[Registry] ${broadcast.from.substring(0, 20)}… 广播的公钥与已知公钥不一致 → 拒收 (不覆盖)`);
296
+ return false;
297
+ }
298
+ const isValid = await this.verifySignatureWithKey(broadcast.from, verifyKeyHex, broadcastData, signature);
244
299
  if (!isValid) {
245
300
  console.warn(`[Registry] Invalid broadcast signature from ${broadcast.from.substring(0, 20)}`);
246
301
  return false;
@@ -253,7 +308,9 @@ export class AgentRegistry {
253
308
  registeredAt: Date.now(),
254
309
  lastSeen: broadcast.timestamp,
255
310
  lastBroadcast: 0,
256
- publicKey: Buffer.from(this.keyPair.publicKey).toString('hex'),
311
+ // 2026-09-15 修复: 原来写的是**自己**的公钥 (this.keyPair.publicKey)
312
+ // 对端后续任何签名消息都验不过 (拿我方公钥去验对方签名), 属于把对端身份写坏.
313
+ publicKey: verifyKeyHex,
257
314
  relayAddr: broadcast.relayAddr,
258
315
  canRelay: broadcast.canRelay
259
316
  };
@@ -261,15 +318,30 @@ export class AgentRegistry {
261
318
  if (existing) {
262
319
  entry.registeredAt = existing.registeredAt;
263
320
  entry.lastBroadcast = existing.lastBroadcast;
264
- if (!existing.publicKey) {
265
- existing.publicKey = entry.publicKey;
266
- }
321
+ if (existing.publicKey)
322
+ entry.publicKey = existing.publicKey; // 已有公钥永不覆盖
267
323
  }
268
324
  this.agents.set(broadcast.from, entry);
269
325
  this.saveRegistry();
270
- console.log(`[Registry] Verified broadcast from: ${broadcast.name} (${broadcast.from.substring(0, 20)}...) ${broadcast.canRelay ? '[Relay Capable]' : ''}`);
326
+ console.log(`[Registry] Verified broadcast from: ${broadcast.name} (${broadcast.from.substring(0, 20)}...) ${broadcast.canRelay ? '[Relay Capable]' : ''}${known ? '' : ' [首次接触 TOFU]'}`);
271
327
  return true;
272
328
  }
329
+ /** 用给定公钥 (hex) 验签 — 首次接触路径用 (未知 DID 时 registry 里没公钥) */
330
+ async verifySignatureWithKey(did, publicKeyHex, data, signature) {
331
+ try {
332
+ const publicKey = Buffer.from(publicKeyHex, 'hex');
333
+ if (publicKey.length !== 32) {
334
+ console.warn(`[Registry] publicKey 长度非法 (${publicKey.length} 字节, 期望 32): ${did.substring(0, 20)}…`);
335
+ return false;
336
+ }
337
+ const keyPair = { privateKey: new Uint8Array(32), publicKey, did };
338
+ return await KeyManager.verify(keyPair, new TextEncoder().encode(data), signature);
339
+ }
340
+ catch (e) {
341
+ console.warn(`[Registry] Verification with provided key failed:`, e);
342
+ return false;
343
+ }
344
+ }
273
345
  async createSignedMessage(type, payload) {
274
346
  if (!this.keyPair || !this.ownEndpoint)
275
347
  return null;