@bolloon/bolloon-agent 0.5.1 → 0.5.2
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/README.md +39 -0
- package/dist/agents/agent-lang.js +101 -0
- package/dist/agents/agent-manifest-protocol.js +4 -0
- package/dist/agents/gateway-group.js +44 -3
- package/dist/agents/pi-sdk.js +101 -8
- package/dist/agents/workflow-pivot-loop.js +3 -1
- package/dist/efficode/compress.js +78 -0
- package/dist/efficode/crc32.js +38 -0
- package/dist/efficode/did.js +66 -0
- package/dist/efficode/index.js +24 -0
- package/dist/efficode/lz77.js +170 -0
- package/dist/efficode/negotiate.js +187 -0
- package/dist/efficode/ops.js +87 -0
- package/dist/efficode/packet.js +271 -0
- package/dist/efficode/types.js +45 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js +4 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +102 -8
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/workflow-pivot-loop.js +3 -1
- package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
- package/dist/electron-build/llm/pi-ai.js +685 -48
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/llm/pi-ai.js +666 -47
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/mobile.js +64 -0
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
package/dist/llm/pi-ai.js
CHANGED
|
@@ -1,15 +1,253 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
3
4
|
import { request, Agent } from 'undici';
|
|
4
5
|
import { sanitizeToolsForApi } from './tool-name.js';
|
|
6
|
+
/** 本地 llama.cpp 的声明 id / 默认端点 / 默认模型名 (都能被 env 或显式 config 覆盖) */
|
|
7
|
+
export const LLAMACPP_PROVIDER_ID = 'llamacpp';
|
|
8
|
+
export const LLAMACPP_DEFAULT_BASE_URL = 'http://localhost:8080/v1';
|
|
9
|
+
export const LLAMACPP_DEFAULT_MODEL = 'local';
|
|
10
|
+
/** 唯一的重路径用途 */
|
|
11
|
+
export const MAIN_AGENT_PURPOSE = 'main-agent';
|
|
12
|
+
/** 轻量前缀的 system 上限 (字符). 只对**自动装配部分 + 调用方自带短提示**做兜底截断, 正常调用远低于它 */
|
|
13
|
+
export const LIGHTWEIGHT_SYSTEM_MAX_CHARS = 2000;
|
|
14
|
+
/**
|
|
15
|
+
* 这条请求走不走轻量前缀. **只有 `main-agent` 是重路径** —— 其余 (含没写 purpose 的老调用方)
|
|
16
|
+
* 一律轻量: 未知用途名按轻量处理 (宁可少给前缀, 也不让探针抢掉主对话的 KV slot).
|
|
17
|
+
*/
|
|
18
|
+
export function isLightweightPurpose(purpose) {
|
|
19
|
+
return (purpose || 'chat') !== MAIN_AGENT_PURPOSE;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 要不要在请求体里带 `cache_prompt` (llama.cpp 的前缀 KV 复用开关).
|
|
23
|
+
*
|
|
24
|
+
* **仅 `main-agent` 为 true**; `BOLLOON_DISABLE_CACHE_PROMPT=1` 彻底关 (含主对话).
|
|
25
|
+
* 纯函数: 门可以逐 purpose 断言, 不需要发请求.
|
|
26
|
+
*/
|
|
27
|
+
export function shouldUseCachePrompt(purpose, env = process.env) {
|
|
28
|
+
if (env.BOLLOON_DISABLE_CACHE_PROMPT === '1')
|
|
29
|
+
return false;
|
|
30
|
+
return (purpose || 'chat') === MAIN_AGENT_PURPOSE;
|
|
31
|
+
}
|
|
32
|
+
/** 本机 endpoint 判定 (`cache_prompt` 是自建服务的扩展字段, 云端不认 —— 只在本机才带) */
|
|
33
|
+
export function isLocalEndpoint(baseUrl) {
|
|
34
|
+
try {
|
|
35
|
+
const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
36
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host.endsWith('.local');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* CURRENT TURN 动态区的标记 —— 注入幂等的唯一判据 (已含标记 → 跳过, 不重复注入、不追加多余 user 消息).
|
|
44
|
+
* 名字里的 runtime/git/p2p 就是这一区的三类内容: 运行时状态 / git 状态 / P2P reserve.
|
|
45
|
+
*/
|
|
46
|
+
export const CURRENT_TURN_MARKER = '<!-- current-turn: runtime/git/p2p -->';
|
|
47
|
+
/** CURRENT TURN 区块自身的预算 (字符) —— 防止动态区反过来把用户原话挤出上层的历史窗口 */
|
|
48
|
+
export const CURRENT_TURN_BUDGET_CHARS = 2400;
|
|
49
|
+
/**
|
|
50
|
+
* 按预算组装 CURRENT TURN 区块 (**从后往前**填: 越靠后的段越优先保住).
|
|
51
|
+
*
|
|
52
|
+
* 为什么是"从后往前": 段落顺序是 `registry 动态层 → P2P reserve → 调用方易变段(循环进度/改进提示)`,
|
|
53
|
+
* 最后那段是**最可操作**的指令 (循环进度丢了 LLM 会重复劳动); 而 registry 动态层与 system 里的
|
|
54
|
+
* 项目上下文高度重复, 被截掉损失最小. 所以超预算时先压前面的段.
|
|
55
|
+
*/
|
|
56
|
+
export function assembleCurrentTurn(parts, budget = CURRENT_TURN_BUDGET_CHARS) {
|
|
57
|
+
const kept = [];
|
|
58
|
+
let used = 0;
|
|
59
|
+
let dropped = false;
|
|
60
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
61
|
+
const p = String(parts[i] ?? '').trim();
|
|
62
|
+
if (!p)
|
|
63
|
+
continue;
|
|
64
|
+
const remain = budget - used;
|
|
65
|
+
if (remain < 50) {
|
|
66
|
+
dropped = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (p.length <= remain) {
|
|
70
|
+
kept.unshift(p);
|
|
71
|
+
used += p.length;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
kept.unshift(`${p.slice(0, remain)}\n[… CURRENT TURN 超预算截断]`);
|
|
75
|
+
used = budget;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (dropped)
|
|
79
|
+
kept.unshift('[… CURRENT TURN 区块超预算: 靠前的段被丢弃]');
|
|
80
|
+
return kept.join('\n\n');
|
|
81
|
+
}
|
|
82
|
+
/** 系统提示词装配缓存的 TTL (10 分钟) */
|
|
83
|
+
export const SYSTEM_PROMPT_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
84
|
+
/**
|
|
85
|
+
* 2026-09-28: `canonicalizeJson` — 递归对**对象 key 排序**, **数组保序**.
|
|
86
|
+
*
|
|
87
|
+
* 为什么: 工具 schema (`parameters.properties` / 嵌套对象) 的 key 顺序若由调用方动态生成,
|
|
88
|
+
* 每轮可能不同; 语义相同但 JSON.stringify 出的字节不同 → chat template 渲染出的 token 前缀
|
|
89
|
+
* 不同 → 服务端前缀 KV 命中失败. 规范化后同一份 schema 永远序列化成同一个字符串.
|
|
90
|
+
*/
|
|
91
|
+
export function canonicalizeJson(value) {
|
|
92
|
+
if (Array.isArray(value))
|
|
93
|
+
return value.map(canonicalizeJson); // 数组有序语义, 保序
|
|
94
|
+
if (value && typeof value === 'object') {
|
|
95
|
+
const sorted = {};
|
|
96
|
+
for (const k of Object.keys(value).sort())
|
|
97
|
+
sorted[k] = canonicalizeJson(value[k]);
|
|
98
|
+
return sorted;
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 2026-09-28: 工具定义规范化 —— 递归 key 排序 + **按 `function.name` 排序**.
|
|
104
|
+
*
|
|
105
|
+
* 跨轮前缀稳定前提: 工具集合相同, 但顺序不同 (`read,write,grep` vs `read,grep,write`) 或
|
|
106
|
+
* schema 内 key 顺序不同, 都会让 token 前缀断裂. 这里把两种抖动都消掉.
|
|
107
|
+
*/
|
|
108
|
+
export function canonicalizeTools(tools) {
|
|
109
|
+
if (!Array.isArray(tools))
|
|
110
|
+
return tools;
|
|
111
|
+
return tools
|
|
112
|
+
.map((t) => canonicalizeJson(t))
|
|
113
|
+
.sort((a, b) => String(a?.function?.name || '').localeCompare(String(b?.function?.name || '')));
|
|
114
|
+
}
|
|
115
|
+
/** 运行前缀哈希序列 (逐消息累积, sha256[:12]) —— 诊断用, 只出 hash 不出内容 */
|
|
116
|
+
export function prefixHashes(wire) {
|
|
117
|
+
const out = [];
|
|
118
|
+
let acc = '';
|
|
119
|
+
for (const m of wire) {
|
|
120
|
+
acc += JSON.stringify({ role: m?.role, content: m?.content ?? '' });
|
|
121
|
+
out.push(createHash('sha256').update(acc).digest('hex').slice(0, 12));
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** 工具面的 hash (诊断用, 只出 hash) */
|
|
126
|
+
export function toolsHashOf(tools) {
|
|
127
|
+
if (!tools || tools.length === 0)
|
|
128
|
+
return '-';
|
|
129
|
+
// 先规范化再 hash: 这个数字回答的是"**上到线上的那份** tools 段变了吗", 而不是"调用方这次传的顺序变了吗"
|
|
130
|
+
return createHash('sha256').update(JSON.stringify(canonicalizeTools(tools))).digest('hex').slice(0, 12);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 从响应里取用量 (只取数字, 出处逐条可指认):
|
|
134
|
+
* - OpenAI/DeepSeek: `usage.prompt_tokens_details.cached_tokens` / `usage.prompt_tokens`;
|
|
135
|
+
* - llama.cpp: `timings.cache_n` (复用的 token 数) / `timings.prompt_n`.
|
|
136
|
+
*/
|
|
137
|
+
export function extractUsage(data) {
|
|
138
|
+
const t = data?.timings || {};
|
|
139
|
+
return {
|
|
140
|
+
promptTokens: Number(data?.usage?.prompt_tokens ?? t.prompt_n ?? 0) || 0,
|
|
141
|
+
cachedTokens: Number(data?.usage?.prompt_tokens_details?.cached_tokens ?? data?.usage?.prompt_cache_hit_tokens ?? t.cache_n ?? 0) || 0,
|
|
142
|
+
completionTokens: Number(data?.usage?.completion_tokens ?? t.predicted_n ?? 0) || 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** `[kv-server]` 诊断行 (纯函数: 只拼数字与百分比, 门可以断言形状) */
|
|
146
|
+
export function formatKvServerLine(purpose, usage, opts = {}) {
|
|
147
|
+
const hit = usage.promptTokens > 0 ? `${((usage.cachedTokens / usage.promptTokens) * 100).toFixed(1)}%` : '?';
|
|
148
|
+
return `[kv-server] purpose=${purpose}${opts.stream ? ' stream' : ''} cached=${usage.cachedTokens} prompt=${usage.promptTokens} hit=${hit}`;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 把动态区注入「最后一条 user 消息」前部 (CURRENT TURN 区), **原地改**.
|
|
152
|
+
*
|
|
153
|
+
* 幂等: 目标消息已含 `CURRENT_TURN_MARKER` → 跳过 (多 iteration / 重试 / 重复调用都不重复注入).
|
|
154
|
+
* 位置: 只找最后一条 user (= 当前轮), 不碰 assistant/tool —— 往那些注入会污染历史;
|
|
155
|
+
* 整段没有 user → 返回 `no-user-message` 交给调用方决定 (chat() 会明确追加一条, 绝不写回 system).
|
|
156
|
+
*/
|
|
157
|
+
export function injectCurrentTurn(messages, dynamicText) {
|
|
158
|
+
if (!dynamicText || !dynamicText.trim())
|
|
159
|
+
return { injected: false, index: -1, reason: 'no-dynamic-text' };
|
|
160
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
161
|
+
const m = messages[i];
|
|
162
|
+
if (!m || m.role !== 'user')
|
|
163
|
+
continue;
|
|
164
|
+
if (typeof m.content === 'string' && m.content.includes(CURRENT_TURN_MARKER)) {
|
|
165
|
+
return { injected: false, index: i, reason: 'already' };
|
|
166
|
+
}
|
|
167
|
+
const body = dynamicText.length > CURRENT_TURN_BUDGET_CHARS + 400
|
|
168
|
+
? `${dynamicText.slice(0, CURRENT_TURN_BUDGET_CHARS + 400)}\n[… CURRENT TURN 区块超预算截断]`
|
|
169
|
+
: dynamicText;
|
|
170
|
+
m.content = `${CURRENT_TURN_MARKER}\n${body}\n\n---\n\n${m.content ?? ''}`;
|
|
171
|
+
return { injected: true, index: i, reason: 'injected' };
|
|
172
|
+
}
|
|
173
|
+
return { injected: false, index: -1, reason: 'no-user-message' };
|
|
174
|
+
}
|
|
175
|
+
/** 用途 → 中文名 (轻量前缀里给人看的那一行, 也是日志口径) */
|
|
176
|
+
const PURPOSE_ZH = {
|
|
177
|
+
'main-agent': '主对话',
|
|
178
|
+
summarize: '摘要',
|
|
179
|
+
improve: '文档改进',
|
|
180
|
+
'auto-compact': '上下文压缩',
|
|
181
|
+
social: '社交心跳',
|
|
182
|
+
health: '健康探针',
|
|
183
|
+
p2p: 'P2P 协作',
|
|
184
|
+
cron: '定时任务',
|
|
185
|
+
judgment: '判断力评估',
|
|
186
|
+
probe: '能力探测',
|
|
187
|
+
chat: '一次性调用',
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* 轻量 system —— 非主对话请求用. 结构:
|
|
191
|
+
* `<一行身份/用途说明>` + (调用方自带的短提示原样保留)
|
|
192
|
+
* **不带**完整 IMMUTABLE PREFIX (layer 装配) / 工具全集 / CURRENT TURN 动态区.
|
|
193
|
+
* 调用方自带提示超上限时截断 (并写明截断), 避免一次性探针把大前缀带出去.
|
|
194
|
+
*/
|
|
195
|
+
export function buildLightweightSystem(purpose, source, callerSystem) {
|
|
196
|
+
const head = `你是 Bolloon Agent 的轻量调用助手 (用途: ${PURPOSE_ZH[purpose] || purpose}${source ? ` · 来源: ${source}` : ''})。只完成下面这一件事, 直接输出结果; 不要调用工具。`;
|
|
197
|
+
const caller = String(callerSystem || '').trim();
|
|
198
|
+
if (!caller)
|
|
199
|
+
return head;
|
|
200
|
+
const capped = caller.length > LIGHTWEIGHT_SYSTEM_MAX_CHARS
|
|
201
|
+
? `${caller.slice(0, LIGHTWEIGHT_SYSTEM_MAX_CHARS)}\n[… 轻量请求 system 超上限截断]`
|
|
202
|
+
: caller;
|
|
203
|
+
return `${head}\n\n${capped}`;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 2026-09-28: 把装配好的系统提示词按 layer 标记拆成「稳定段」与「动态段」.
|
|
207
|
+
*
|
|
208
|
+
* 动态段 = registry 里 `source === 'function'` 的那几层 (本仓目前只有 `dynamic.project-context`:
|
|
209
|
+
* 它会随 cwd / git 状态变化). 这几层从 system 里移出 → system 逐字节稳定 → 服务端前缀 KV 能复用.
|
|
210
|
+
*
|
|
211
|
+
* 拆法: 装配器给每层写了 `<!-- id@version -->` 头 (见 system-prompt/registry.ts),
|
|
212
|
+
* 按这些标记切块; 标记之间的内容归它前面那个 id. 找不到任何标记 → 整段算稳定段 (不抛错).
|
|
213
|
+
*/
|
|
214
|
+
export function splitLayerBlocks(text, dynamicIds) {
|
|
215
|
+
const re = /<!--\s*([A-Za-z0-9_.\-]+)@([A-Za-z0-9_.\-]+)\s*-->/g;
|
|
216
|
+
const marks = [];
|
|
217
|
+
let m;
|
|
218
|
+
while ((m = re.exec(text)) !== null)
|
|
219
|
+
marks.push({ id: m[1], start: m.index, end: m.index + m[0].length });
|
|
220
|
+
if (marks.length === 0)
|
|
221
|
+
return { stableText: text.trim(), dynamicText: '' };
|
|
222
|
+
const stable = [];
|
|
223
|
+
const dynamic = [];
|
|
224
|
+
const head = text.slice(0, marks[0].start).trim();
|
|
225
|
+
if (head)
|
|
226
|
+
stable.push(head);
|
|
227
|
+
for (let i = 0; i < marks.length; i++) {
|
|
228
|
+
const from = marks[i].end;
|
|
229
|
+
const to = i + 1 < marks.length ? marks[i + 1].start : text.length;
|
|
230
|
+
const content = text.slice(from, to).trim();
|
|
231
|
+
(dynamicIds.has(marks[i].id) ? dynamic : stable).push(content);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
stableText: stable.filter(Boolean).join('\n\n').trim(),
|
|
235
|
+
dynamicText: dynamic.filter(Boolean).join('\n\n').trim(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
5
238
|
/**
|
|
6
239
|
* 外部 system 注入钩子.
|
|
7
240
|
* 调用方 (e.g. auto-evolve-loop) 用 setSystemPrependProvider() 注册一个返回字符串的函数,
|
|
8
|
-
*
|
|
241
|
+
* chat() 在拼好 messages 后, 把返回的字符串作为 **CURRENT TURN 动态区**的一部分, 注入到最后一条
|
|
242
|
+
* user 消息前部 (位于 IMMUTABLE PREFIX + CONVERSATION HISTORY 之后)。
|
|
9
243
|
*
|
|
10
244
|
* 用途: P2P 协作时把"行级 reserve 状态"实时塞给 LLM,
|
|
11
245
|
* 让 LLM 主动避开对方正在改的代码行.
|
|
12
246
|
*
|
|
247
|
+
* ⚠️ 2026-09-28: 只在 chat() 路径生效 (只注入主对话的当前轮), 不进 system ——
|
|
248
|
+
* 这份 reserve 状态每轮都在变, 写回 system 就等于把 IMMUTABLE PREFIX 打碎, 前缀 KV 永远 miss。
|
|
249
|
+
* summarize/improve 这类一次性请求也不再注入它 (不污染探针的 prompt 前缀)。
|
|
250
|
+
*
|
|
13
251
|
* 返回 '' / null / undefined → 不注入.
|
|
14
252
|
*/
|
|
15
253
|
let _prependProvider = null;
|
|
@@ -19,6 +257,66 @@ export function setSystemPrependProvider(p) {
|
|
|
19
257
|
export function getSystemPrependProvider() {
|
|
20
258
|
return _prependProvider;
|
|
21
259
|
}
|
|
260
|
+
let _systemPromptCache = null;
|
|
261
|
+
/** 清空系统提示词缓存 (配置/provider 变更、layer 文件热更时调用) */
|
|
262
|
+
export function clearSystemPromptCache() {
|
|
263
|
+
_systemPromptCache = null;
|
|
264
|
+
}
|
|
265
|
+
/** 诊断/门用: 缓存条目的 key 与写入时间 (**只给数字与键名, 不给内容**) */
|
|
266
|
+
export function systemPromptCacheStamp() {
|
|
267
|
+
return _systemPromptCache ? { key: _systemPromptCache.key, at: _systemPromptCache.at } : null;
|
|
268
|
+
}
|
|
269
|
+
/** 诊断/门用: 稳定段的 sha256[:12] (不是内容) —— 门用它断言 system 段跨轮逐字节稳定 */
|
|
270
|
+
export function systemPromptStableHash() {
|
|
271
|
+
if (!_systemPromptCache)
|
|
272
|
+
return null;
|
|
273
|
+
return createHash('sha256').update(_systemPromptCache.stableText).digest('hex').slice(0, 12);
|
|
274
|
+
}
|
|
275
|
+
/** DEBUG_PROMPT_PREFIX=1 时逐消息打前缀哈希用; 记上一轮, 好报"第几条开始分叉" */
|
|
276
|
+
let _lastPrefixHashes = [];
|
|
277
|
+
/**
|
|
278
|
+
* 2026-09-28 诊断 (只打数字与 hash, 不打内容也不打 key):
|
|
279
|
+
* `[kv-debug] msg=i role=… prefixHash=… chars=…` 逐消息的**累积前缀**哈希
|
|
280
|
+
* `[kv-debug] tools count=… hash=…`
|
|
281
|
+
* `[kv-debug] 对比: 与上一次请求在第 N 条分叉 / 前缀逐条一致`
|
|
282
|
+
* ⚠️ 这是 HTTP JSON 层的前缀哈希, 不是 chat template 渲染后的 token 前缀哈希;
|
|
283
|
+
* 用途是快速定位"应用层 messages 前缀在哪一条断裂", 缩小排查范围。
|
|
284
|
+
*/
|
|
285
|
+
function debugDumpPromptPrefix(wire, tools) {
|
|
286
|
+
const hashes = prefixHashes(wire);
|
|
287
|
+
let chars = 0;
|
|
288
|
+
for (let i = 0; i < wire.length; i++) {
|
|
289
|
+
chars += JSON.stringify({ role: wire[i]?.role, content: wire[i]?.content ?? '' }).length;
|
|
290
|
+
console.log(`[kv-debug] msg=${i} role=${wire[i]?.role} prefixHash=${hashes[i]} chars=${chars}`);
|
|
291
|
+
}
|
|
292
|
+
console.log(`[kv-debug] tools count=${tools?.length ?? 0} hash=${toolsHashOf(tools)}`);
|
|
293
|
+
if (_lastPrefixHashes.length > 0) {
|
|
294
|
+
let diverged = -1;
|
|
295
|
+
const n = Math.max(hashes.length, _lastPrefixHashes.length);
|
|
296
|
+
for (let i = 0; i < n; i++) {
|
|
297
|
+
if (hashes[i] !== _lastPrefixHashes[i]) {
|
|
298
|
+
diverged = i;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (diverged < 0)
|
|
303
|
+
console.log(`[kv-debug] 对比上一次请求: 前缀逐条一致 (共 ${hashes.length} 条)`);
|
|
304
|
+
else
|
|
305
|
+
console.log(`[kv-debug] 对比上一次请求: 第 ${diverged} 条起分叉 (上一次 ${_lastPrefixHashes.length} 条 → 本次 ${hashes.length} 条)`);
|
|
306
|
+
}
|
|
307
|
+
_lastPrefixHashes = hashes;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* BOLLOON_PROMPT_PROFILE=1 时每次调用打一行 prompt 分部体积 (**只打数字与 hash**),
|
|
311
|
+
* 用来回答"这次请求到底是哪一段在膨胀 / 哪一段在抖动"。
|
|
312
|
+
*/
|
|
313
|
+
function logPromptProfile(info) {
|
|
314
|
+
if (process.env.BOLLOON_PROMPT_PROFILE !== '1')
|
|
315
|
+
return;
|
|
316
|
+
console.log(`[prompt-profile] purpose=${info.purpose} source=${info.source || '-'} profile=${info.profile}` +
|
|
317
|
+
` systemCache=${info.cached ? 'hit' : 'miss'} stable=${info.stableChars} dynamic=${info.dynamicChars}` +
|
|
318
|
+
` history=${info.historyChars} msgs=${info.messageCount} tools=${info.toolsCount} toolsHash=${info.toolsHash}`);
|
|
319
|
+
}
|
|
22
320
|
export class PiAIModel {
|
|
23
321
|
config;
|
|
24
322
|
provider;
|
|
@@ -98,25 +396,120 @@ export class PiAIModel {
|
|
|
98
396
|
* LLM 看不到 tool 调用的真实结果,导致 CLI loop 卡死.
|
|
99
397
|
* 现在 messages 数组版本保留 role 语义, LLM 能正确看到工具返回.
|
|
100
398
|
*/
|
|
101
|
-
|
|
102
|
-
|
|
399
|
+
/**
|
|
400
|
+
* ⚠️ 参数顺序 (2026-09-28 起): `(messages, system, signal?, tools?, purpose?, source?, onToken?, currentTurnContext?)`
|
|
401
|
+
* - `purpose` 决定重/轻前缀与 cache_prompt (只有 'main-agent' 是重路径);
|
|
402
|
+
* - `currentTurnContext` = 调用方自己的**当前轮易变段** (如 pi-sdk 的循环进度/改进提示):
|
|
403
|
+
* 它进 CURRENT TURN 动态区, **绝不进 system** —— 每轮都在变的东西放 system 会把
|
|
404
|
+
* IMMUTABLE PREFIX 打碎, 服务端前缀 KV 就永远命不中 (文本一字未改, 只换位置).
|
|
405
|
+
*/
|
|
406
|
+
async chat(messageOrMessages, contextOrSystemPrompt, signal, tools, purpose, source, onToken, currentTurnContext) {
|
|
407
|
+
const _purpose = purpose || 'chat';
|
|
408
|
+
const light = isLightweightPurpose(_purpose);
|
|
409
|
+
const callerArr = Array.isArray(messageOrMessages) ? messageOrMessages : null;
|
|
103
410
|
let messages;
|
|
104
|
-
|
|
105
|
-
|
|
411
|
+
/** CURRENT TURN 动态区内容 (registry 的 function 层 + P2P reserve); 轻量请求不带 */
|
|
412
|
+
const currentTurnParts = [];
|
|
413
|
+
let stableChars = 0;
|
|
414
|
+
let systemCacheHit = false;
|
|
415
|
+
if (light) {
|
|
416
|
+
// ── 轻量路径 (2026-09-28): 非主对话请求不带完整 IMMUTABLE PREFIX / 工具全集 / CURRENT TURN ──
|
|
417
|
+
// 目的 (leo): 本地单 slot 服务上, 探针/摘要/心跳不该把主对话的 KV 顶掉, 也不该为一次性调用付大前缀.
|
|
418
|
+
const lightSystem = buildLightweightSystem(_purpose, source, contextOrSystemPrompt);
|
|
419
|
+
stableChars = lightSystem.length;
|
|
420
|
+
messages = callerArr
|
|
421
|
+
? [{ role: 'system', content: lightSystem }, ...callerArr]
|
|
422
|
+
: [{ role: 'system', content: lightSystem }, { role: 'user', content: messageOrMessages }];
|
|
423
|
+
if (tools && tools.length > 0) {
|
|
424
|
+
// 不静默丢: 打一行数字, 让"为什么工具没被调用"可诊断
|
|
425
|
+
console.warn(`[pi-ai] 轻量请求丢弃工具集 (purpose=${_purpose}, tools=${tools.length}) —— 需要工具请用 purpose='main-agent'`);
|
|
426
|
+
tools = undefined;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else if (Array.isArray(messageOrMessages)) {
|
|
430
|
+
if (contextOrSystemPrompt && contextOrSystemPrompt.length > 2000) {
|
|
431
|
+
// pivot loop 兼容: 第二参 >2K = system prompt 覆盖 → 接在稳定段之后 (**仍在 system 里, 位置与旧版一致**:
|
|
432
|
+
// 旧版 = 装配文本 + '\n\n' + 覆盖文本). 只有 registry 的动态层被移出 system.
|
|
433
|
+
const parts = await this.buildSystemPromptParts(undefined);
|
|
434
|
+
systemCacheHit = parts.cached;
|
|
435
|
+
stableChars = parts.stableText.length + contextOrSystemPrompt.length;
|
|
436
|
+
if (parts.dynamicText)
|
|
437
|
+
currentTurnParts.push(parts.dynamicText);
|
|
438
|
+
messages = [
|
|
439
|
+
{ role: 'system', content: `${parts.stableText}\n\n${contextOrSystemPrompt}` },
|
|
440
|
+
...messageOrMessages
|
|
441
|
+
];
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
const parts = await this.buildSystemPromptParts(contextOrSystemPrompt);
|
|
445
|
+
systemCacheHit = parts.cached;
|
|
446
|
+
stableChars = parts.stableText.length;
|
|
447
|
+
if (parts.dynamicText)
|
|
448
|
+
currentTurnParts.push(parts.dynamicText);
|
|
449
|
+
messages = [{ role: 'system', content: parts.stableText }, ...messageOrMessages];
|
|
450
|
+
}
|
|
106
451
|
}
|
|
107
452
|
else if (contextOrSystemPrompt && contextOrSystemPrompt.length > 2000) {
|
|
108
|
-
const
|
|
453
|
+
const parts = await this.buildSystemPromptParts(undefined);
|
|
454
|
+
systemCacheHit = parts.cached;
|
|
455
|
+
stableChars = parts.stableText.length + contextOrSystemPrompt.length;
|
|
456
|
+
if (parts.dynamicText)
|
|
457
|
+
currentTurnParts.push(parts.dynamicText);
|
|
109
458
|
messages = [
|
|
110
|
-
{ role: 'system', content:
|
|
459
|
+
{ role: 'system', content: `${parts.stableText}\n\n${contextOrSystemPrompt}` },
|
|
111
460
|
{ role: 'user', content: messageOrMessages }
|
|
112
461
|
];
|
|
113
462
|
}
|
|
114
463
|
else {
|
|
464
|
+
const parts = await this.buildSystemPromptParts(contextOrSystemPrompt);
|
|
465
|
+
systemCacheHit = parts.cached;
|
|
466
|
+
stableChars = parts.stableText.length;
|
|
467
|
+
if (parts.dynamicText)
|
|
468
|
+
currentTurnParts.push(parts.dynamicText);
|
|
115
469
|
messages = [
|
|
116
|
-
{ role: 'system', content:
|
|
470
|
+
{ role: 'system', content: parts.stableText },
|
|
117
471
|
{ role: 'user', content: messageOrMessages }
|
|
118
472
|
];
|
|
119
473
|
}
|
|
474
|
+
// 2) 外部 prepend (P2P 行级 reserve 状态) —— 与动态层一起进 CURRENT TURN 区, 不进 system
|
|
475
|
+
if (!light && _prependProvider) {
|
|
476
|
+
try {
|
|
477
|
+
const pre = await _prependProvider();
|
|
478
|
+
if (pre && typeof pre === 'string' && pre.trim())
|
|
479
|
+
currentTurnParts.push(pre);
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
console.warn('[pi-ai] systemPrepend 失败:', err?.message?.slice(0, 100));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// 3) 调用方自己的"当前轮易变段" (pi-sdk 的循环进度 / 改进提示 / 错误提示) —— 同样进 CURRENT TURN 区.
|
|
486
|
+
// 它们每 iteration 都在变; 留在 system 里 = 把 IMMUTABLE PREFIX 打碎 = 前缀 KV 永远 miss.
|
|
487
|
+
if (!light && currentTurnContext && currentTurnContext.trim())
|
|
488
|
+
currentTurnParts.push(currentTurnContext);
|
|
489
|
+
// 4) 注入 CURRENT TURN 区到最后一条 user 消息前部 (幂等: 已含标记则跳过)
|
|
490
|
+
const dynamicText = assembleCurrentTurn(currentTurnParts);
|
|
491
|
+
if (!light && dynamicText.trim()) {
|
|
492
|
+
const inj = injectCurrentTurn(messages, dynamicText);
|
|
493
|
+
if (inj.reason === 'no-user-message') {
|
|
494
|
+
// 一条 user 都没有 (纯 assistant 历史) → 明确追加一条, **绝不写回 system**
|
|
495
|
+
const extra = { role: 'user', content: '' };
|
|
496
|
+
injectCurrentTurn([extra], dynamicText);
|
|
497
|
+
messages.push(extra);
|
|
498
|
+
if (callerArr)
|
|
499
|
+
callerArr.push(extra); // 回写调用方数组, 保证下一轮不重复追加
|
|
500
|
+
console.log('[pi-ai] CURRENT TURN 追加独立 user 消息 (历史里没有 user)');
|
|
501
|
+
}
|
|
502
|
+
else if (inj.reason === 'injected') {
|
|
503
|
+
console.log(`[pi-ai] CURRENT TURN 注入 msg=${inj.index} dynamic=${dynamicText.length}chars`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const historyChars = messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : 0), 0) - stableChars;
|
|
507
|
+
logPromptProfile({
|
|
508
|
+
purpose: _purpose, source, profile: light ? 'light' : 'full',
|
|
509
|
+
stableChars, dynamicChars: dynamicText.length, historyChars: Math.max(0, historyChars),
|
|
510
|
+
messageCount: messages.length, toolsCount: tools?.length ?? 0,
|
|
511
|
+
toolsHash: toolsHashOf(tools), cached: systemCacheHit,
|
|
512
|
+
});
|
|
120
513
|
try {
|
|
121
514
|
const response = await this.generateText({
|
|
122
515
|
messages,
|
|
@@ -124,8 +517,17 @@ export class PiAIModel {
|
|
|
124
517
|
maxTokens: 16384, // 2026-06-17: 提到 16384 — agent 注入 16K+ system prompt + 8K tool defs 时, 8K 撞上限返回空 content (见 memory: bolloon-llm-empty-large-prompt)
|
|
125
518
|
signal,
|
|
126
519
|
tools, // pass through for native tool calling
|
|
520
|
+
onToken,
|
|
521
|
+
purpose: _purpose,
|
|
522
|
+
source,
|
|
127
523
|
});
|
|
128
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
reply: response.reply,
|
|
526
|
+
reasoningContent: response.reasoningContent,
|
|
527
|
+
toolCalls: response.toolCalls,
|
|
528
|
+
messages, // 回带最终 wire messages: 调用方据此把当前轮写回自己的 history
|
|
529
|
+
usage: response.usage,
|
|
530
|
+
};
|
|
129
531
|
}
|
|
130
532
|
catch (error) {
|
|
131
533
|
// abort 不当作错误, 透传一个 sentinel 让上层能识别
|
|
@@ -150,14 +552,24 @@ export class PiAIModel {
|
|
|
150
552
|
console.warn(`[llm-recovery] ${classified.category} attempt ${attempt + 1}/${MAX_RECOVERY_ATTEMPTS}, 退避 ${backoffMs}ms 重试`);
|
|
151
553
|
await new Promise((r) => setTimeout(r, backoffMs));
|
|
152
554
|
try {
|
|
555
|
+
// 重试带着**同一份已注入的 messages** (幂等标记保证不会二次注入)
|
|
153
556
|
const response = await this.generateText({
|
|
154
557
|
messages,
|
|
155
558
|
temperature: 0.8,
|
|
156
559
|
maxTokens: 16384,
|
|
157
560
|
signal,
|
|
158
561
|
tools,
|
|
562
|
+
onToken,
|
|
563
|
+
purpose: _purpose,
|
|
564
|
+
source,
|
|
159
565
|
});
|
|
160
|
-
return {
|
|
566
|
+
return {
|
|
567
|
+
reply: response.reply,
|
|
568
|
+
reasoningContent: response.reasoningContent,
|
|
569
|
+
toolCalls: response.toolCalls,
|
|
570
|
+
messages,
|
|
571
|
+
usage: response.usage,
|
|
572
|
+
};
|
|
161
573
|
}
|
|
162
574
|
catch (retryErr) {
|
|
163
575
|
if (signal?.aborted || retryErr?.name === 'AbortError')
|
|
@@ -186,7 +598,9 @@ export class PiAIModel {
|
|
|
186
598
|
{ role: 'system', content: 'You are a professional document summarizer.' },
|
|
187
599
|
{ role: 'user', content: prompt }
|
|
188
600
|
],
|
|
189
|
-
temperature: 0.7
|
|
601
|
+
temperature: 0.7,
|
|
602
|
+
purpose: 'summarize',
|
|
603
|
+
source: 'pi-ai',
|
|
190
604
|
});
|
|
191
605
|
const qualityScore = this.estimateQuality(text, response.reply);
|
|
192
606
|
return { summary: response.reply, qualityScore };
|
|
@@ -207,7 +621,9 @@ export class PiAIModel {
|
|
|
207
621
|
{ role: 'system', content: 'You are a professional document editor and improver.' },
|
|
208
622
|
{ role: 'user', content: prompt }
|
|
209
623
|
],
|
|
210
|
-
temperature: 0.8
|
|
624
|
+
temperature: 0.8,
|
|
625
|
+
purpose: 'improve',
|
|
626
|
+
source: 'pi-ai',
|
|
211
627
|
});
|
|
212
628
|
return response.reply;
|
|
213
629
|
}
|
|
@@ -217,21 +633,11 @@ export class PiAIModel {
|
|
|
217
633
|
}
|
|
218
634
|
}
|
|
219
635
|
async generateText(options) {
|
|
220
|
-
const { messages, temperature = 0.7, maxTokens = 4096, signal, tools } = options;
|
|
636
|
+
const { messages, temperature = 0.7, maxTokens = 4096, signal, tools, onToken, purpose, source } = options;
|
|
221
637
|
// 工具清单: 代码侧 schema → 进 system prompt (作为额外 system message)
|
|
222
638
|
let finalMessages = messages;
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
const pre = await _prependProvider();
|
|
227
|
-
if (pre && typeof pre === 'string' && pre.trim()) {
|
|
228
|
-
finalMessages = [{ role: 'system', content: pre }, ...finalMessages];
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
catch (err) {
|
|
232
|
-
console.warn('[pi-ai] systemPrepend 失败:', err?.message?.slice(0, 100));
|
|
233
|
-
}
|
|
234
|
-
}
|
|
639
|
+
// 注 (2026-09-28): 外部 prepend (_prependProvider) 已上移到 chat() —— 它进 CURRENT TURN 动态区,
|
|
640
|
+
// 不再作为最前面的 system message (那份 reserve 状态每轮都在变, 放 system 会打碎 IMMUTABLE PREFIX).
|
|
235
641
|
let openaiTools;
|
|
236
642
|
if (tools && tools.length > 0) {
|
|
237
643
|
// 预格式化的 tools (含参数 schema) → 直接使用
|
|
@@ -242,7 +648,9 @@ export class PiAIModel {
|
|
|
242
648
|
// 会让整个请求 400: Invalid 'tools[120].function.name': string does not match pattern.
|
|
243
649
|
// 净化同时把「原名 ↔ API 名」登记进 globalToolNameRoutes, 回程派发靠它还原成真名.
|
|
244
650
|
// 撞名/无名 → 抛错点名, 不许静默.
|
|
245
|
-
|
|
651
|
+
// 2026-09-28: 净化之后再做 `canonicalizeTools` (按 function.name 排序 + 递归 key 排序) ——
|
|
652
|
+
// 顺序与嵌套 key 顺序都不再抖动, 同一份工具集的 token 前缀跨轮逐字节一致.
|
|
653
|
+
openaiTools = canonicalizeTools(sanitizeToolsForApi(tools));
|
|
246
654
|
const toolDescriptions = openaiTools.map(t => `- ${t.function.name}: ${t.function.description || ''} ${Object.keys(t.function.parameters?.properties || {}).length > 0 ? `(${Object.keys(t.function.parameters.properties).join(', ')})` : ''}`).join('\n');
|
|
247
655
|
finalMessages = [{ role: 'system', content: `可用工具:\n${toolDescriptions}` }, ...messages];
|
|
248
656
|
}
|
|
@@ -256,7 +664,7 @@ export class PiAIModel {
|
|
|
256
664
|
case 'qwen':
|
|
257
665
|
case 'mimo':
|
|
258
666
|
case 'grok':
|
|
259
|
-
return this.callOpenAI(finalMessages, temperature, maxTokens, signal, openaiTools);
|
|
667
|
+
return this.callOpenAI(finalMessages, temperature, maxTokens, signal, openaiTools, onToken, purpose, source);
|
|
260
668
|
case 'anthropic':
|
|
261
669
|
return this.callAnthropic(finalMessages, temperature, maxTokens, signal);
|
|
262
670
|
case 'ollama':
|
|
@@ -272,6 +680,11 @@ export class PiAIModel {
|
|
|
272
680
|
}
|
|
273
681
|
}
|
|
274
682
|
getApiKey() {
|
|
683
|
+
// 2026-09-28: llama.cpp (本地 OpenAI 兼容服务) —— 声明 id 单独接 key, key 可空
|
|
684
|
+
// (llama-server 默认不校验 Authorization; 也支持 `--api-key` → 用 LLAMACPP_API_KEY 传)
|
|
685
|
+
if (this.config.providerId === LLAMACPP_PROVIDER_ID) {
|
|
686
|
+
return this.config.apiKey || process.env.LLAMACPP_API_KEY || '';
|
|
687
|
+
}
|
|
275
688
|
return this.config.apiKey || this.getEnvApiKey();
|
|
276
689
|
}
|
|
277
690
|
getEnvApiKey() {
|
|
@@ -296,6 +709,11 @@ export class PiAIModel {
|
|
|
296
709
|
if (this.config.baseUrl) {
|
|
297
710
|
return this.config.baseUrl;
|
|
298
711
|
}
|
|
712
|
+
// 2026-09-28: llama.cpp (本地 OpenAI 兼容服务) 的默认端点 —— 它按声明 id 单独接,
|
|
713
|
+
// 不混进下面那张「内置 13 家」的表 (那张表被源码级门钉着, 见 provider-registry.test.ts)。
|
|
714
|
+
if (this.config.providerId === LLAMACPP_PROVIDER_ID) {
|
|
715
|
+
return process.env.LLAMACPP_BASE_URL || LLAMACPP_DEFAULT_BASE_URL;
|
|
716
|
+
}
|
|
299
717
|
// 允许通过 OPENAI_BASE_URL 等环境变量覆盖默认 base URL
|
|
300
718
|
const baseUrls = {
|
|
301
719
|
openai: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
|
|
@@ -316,6 +734,11 @@ export class PiAIModel {
|
|
|
316
734
|
return baseUrls[this.provider];
|
|
317
735
|
}
|
|
318
736
|
mapModel() {
|
|
737
|
+
// 2026-09-28: llama.cpp 的模型名 = 服务端加载的那份 (名字随 `-m` 走, 客户端不自造):
|
|
738
|
+
// 显式 config.model > LLAMACPP_MODEL > 'local' (占位, 服务端通常忽略未知 model 名)
|
|
739
|
+
if (this.config.providerId === LLAMACPP_PROVIDER_ID) {
|
|
740
|
+
return this.config.model || process.env.LLAMACPP_MODEL || LLAMACPP_DEFAULT_MODEL;
|
|
741
|
+
}
|
|
319
742
|
// 2026-08-04: 型号全面更新 (官方文档确认):
|
|
320
743
|
// OpenAI gpt-5.6 (alias→Sol) / Anthropic claude-sonnet-5 / Gemini gemini-3.5-flash (pro 线最新, 3.5 仅 flash)
|
|
321
744
|
// Grok grok-4.5 / Kimi kimi-k3 / GLM glm-5.2 / Qwen qwen3-max
|
|
@@ -360,9 +783,12 @@ export class PiAIModel {
|
|
|
360
783
|
? { role: 'assistant', content: m.content ?? '', reasoning_content: m.reasoningContent ?? '' }
|
|
361
784
|
: m);
|
|
362
785
|
}
|
|
363
|
-
async callOpenAI(messages, temperature, maxTokens, signal, tools) {
|
|
786
|
+
async callOpenAI(messages, temperature, maxTokens, signal, tools, onToken, purpose, source) {
|
|
787
|
+
const _purpose = purpose || 'chat';
|
|
364
788
|
const apiKey = this.getApiKey();
|
|
365
|
-
|
|
789
|
+
// 2026-09-28: 本地自建服务 (llama.cpp) 允许空 key —— `allowEmptyKey` 由 initPiAI 按声明 id 置位.
|
|
790
|
+
// 云端分支缺 key 仍然抛错 (老行为一字不变).
|
|
791
|
+
if (!apiKey && !this.config.allowEmptyKey) {
|
|
366
792
|
throw new Error('OPENAI_API_KEY not set');
|
|
367
793
|
}
|
|
368
794
|
const requestBody = {
|
|
@@ -371,11 +797,32 @@ export class PiAIModel {
|
|
|
371
797
|
temperature,
|
|
372
798
|
max_tokens: maxTokens
|
|
373
799
|
};
|
|
800
|
+
// 2026-09-28: 前缀 KV 复用开关 `cache_prompt`. 两处都卡住:
|
|
801
|
+
// (1) 只对本机 endpoint 带这个字段 —— 它是自建服务 (llama.cpp) 的扩展, 云端不认,
|
|
802
|
+
// 白带一个未知字段有 400 风险; 换句话说云端分支的请求体与旧版逐字节相同.
|
|
803
|
+
// (2) **只有 purpose='main-agent' 为 true**. 单 slot 的本地服务上, 探针/摘要/心跳
|
|
804
|
+
// 若也带 true, 会去抢 slot 0 驱逐主对话的 KV —— 那正是"主对话缓存无法命中"的一层根因.
|
|
805
|
+
// `BOLLOON_DISABLE_CACHE_PROMPT=1` 彻底关 (见 shouldUseCachePrompt).
|
|
806
|
+
const localEndpoint = isLocalEndpoint(this.getBaseUrl());
|
|
807
|
+
if (localEndpoint) {
|
|
808
|
+
requestBody.cache_prompt = shouldUseCachePrompt(_purpose);
|
|
809
|
+
}
|
|
810
|
+
// 2026-09-28: 流式必须显式要 usage —— 否则 SSE 末帧不带 usage, 命中率测不到.
|
|
811
|
+
if (onToken) {
|
|
812
|
+
requestBody.stream = true;
|
|
813
|
+
requestBody.stream_options = { include_usage: true };
|
|
814
|
+
}
|
|
374
815
|
// Bug 3: 传入原生 tools 参数 + tool_choice auto, LLM 返回结构化 tool_calls
|
|
375
816
|
if (tools && tools.length > 0) {
|
|
376
817
|
requestBody.tools = tools;
|
|
377
818
|
requestBody.tool_choice = 'auto';
|
|
378
819
|
}
|
|
820
|
+
// 2026-09-28: 出网前一行"这次请求长什么样" (**只打数字与 hash, 不打内容**)
|
|
821
|
+
const _promptChars = messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : 0), 0);
|
|
822
|
+
console.log(`[pi-ai] ▶ purpose=${_purpose} source=${source || '-'} model=${this.mapModel()} messages=${requestBody.messages.length} promptChars=${_promptChars} tools=${tools?.length ?? 0} toolsHash=${toolsHashOf(tools)} cache_prompt=${localEndpoint ? requestBody.cache_prompt : '-'}${onToken ? ' stream' : ''}`);
|
|
823
|
+
if (process.env.DEBUG_PROMPT_PREFIX === '1') {
|
|
824
|
+
debugDumpPromptPrefix(requestBody.messages, tools);
|
|
825
|
+
}
|
|
379
826
|
let lastFinishReason = '';
|
|
380
827
|
const _t0 = Date.now();
|
|
381
828
|
// 2026-08-04 (二修): 弃用全局 fetch — node 内置 fetch 的 keep-alive 连接池会积累"僵尸连接"
|
|
@@ -383,15 +830,16 @@ export class PiAIModel {
|
|
|
383
830
|
// 会静默忽略 npm undici Agent 的 dispatcher (实测 localPort 不变), 导致重试仍在复用坏连接.
|
|
384
831
|
// 改用 npm undici 的 request(): 独立连接池 + 重试传 dispatcher 真正生效 (新 TCP 连接).
|
|
385
832
|
let retryAgent = null;
|
|
386
|
-
// 认证头走注册表 (拿不到注册表 → 退回本分支原来的
|
|
833
|
+
// 认证头走注册表 (拿不到注册表 → 退回本分支原来的 Bearer 头)
|
|
387
834
|
const regAuth = await this.registryAuth();
|
|
388
835
|
const authHeaders = regAuth
|
|
389
836
|
? regAuth.headers
|
|
390
|
-
: { 'Authorization': `Bearer ${apiKey}` };
|
|
837
|
+
: (apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}); // 空 key (本地服务) → 不带鉴权头
|
|
391
838
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
392
839
|
const _tFetch = Date.now();
|
|
393
840
|
let statusCode = 0;
|
|
394
841
|
let body;
|
|
842
|
+
let _ctype = '';
|
|
395
843
|
try {
|
|
396
844
|
const reqInit = {
|
|
397
845
|
method: 'POST',
|
|
@@ -406,6 +854,7 @@ export class PiAIModel {
|
|
|
406
854
|
reqInit.dispatcher = retryAgent;
|
|
407
855
|
const res = await request(`${this.getBaseUrl()}/chat/completions`, reqInit);
|
|
408
856
|
statusCode = res.statusCode;
|
|
857
|
+
_ctype = String(res.headers?.['content-type'] || ''); // 流式判定用 (SSE)
|
|
409
858
|
body = res.body;
|
|
410
859
|
}
|
|
411
860
|
catch (err) {
|
|
@@ -444,8 +893,19 @@ export class PiAIModel {
|
|
|
444
893
|
retryAgent?.destroy().catch(() => { });
|
|
445
894
|
throw new Error(`OpenAI API error: ${statusCode} ${String(errBody).slice(0, 300)}`);
|
|
446
895
|
}
|
|
896
|
+
// 2026-09-28: 流式 (SSE) 分支 —— 只有调用方要 onToken 且服务端真回了 event-stream 才走;
|
|
897
|
+
// 末帧 usage 由 `stream_options.include_usage: true` 保证 (见请求体那一段).
|
|
898
|
+
if (onToken && _ctype.includes('text/event-stream')) {
|
|
899
|
+
const streamed = await this.consumeStream(body, onToken, _purpose, _t0);
|
|
900
|
+
retryAgent?.destroy().catch(() => { });
|
|
901
|
+
return streamed;
|
|
902
|
+
}
|
|
447
903
|
const data = await body.json();
|
|
448
904
|
const _tParse = Date.now();
|
|
905
|
+
// 2026-09-28: KV 命中率诊断 (**只打数字与百分比**): 取自
|
|
906
|
+
// usage.prompt_tokens_details.cached_tokens (OpenAI/DeepSeek) 或 timings.cache_n (llama.cpp)
|
|
907
|
+
const usage = extractUsage(data);
|
|
908
|
+
console.log(formatKvServerLine(_purpose, usage));
|
|
449
909
|
const choice = data.choices?.[0];
|
|
450
910
|
const content = choice?.message?.content || '';
|
|
451
911
|
const toolCalls = choice?.message?.tool_calls;
|
|
@@ -461,7 +921,7 @@ export class PiAIModel {
|
|
|
461
921
|
const promptBytes = JSON.stringify(messages).length;
|
|
462
922
|
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`);
|
|
463
923
|
retryAgent?.destroy().catch(() => { });
|
|
464
|
-
return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined, reasoningContent };
|
|
924
|
+
return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined, reasoningContent, usage };
|
|
465
925
|
}
|
|
466
926
|
console.warn(`[pi-ai] attempt ${attempt + 1}/3: 空 content (finish_reason=${lastFinishReason}), 退避 1.5s 重试`);
|
|
467
927
|
const _tSleep = Date.now();
|
|
@@ -472,6 +932,75 @@ export class PiAIModel {
|
|
|
472
932
|
retryAgent?.destroy().catch(() => { });
|
|
473
933
|
return { reply: '' };
|
|
474
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* 2026-09-28: 消费 SSE 流 (`stream: true`).
|
|
937
|
+
*
|
|
938
|
+
* 逐条 `data: {…}` 累积 delta → content / reasoning_content / tool_calls;
|
|
939
|
+
* `usage` 只在**末帧**出现 (且只有带 `stream_options.include_usage: true` 才给) ——
|
|
940
|
+
* 少了它就没法算 KV 命中率, 所以请求体那边是强制带上的。
|
|
941
|
+
*
|
|
942
|
+
* 注: 这里不做逐 token 的回调节奏保证 (Node 侧一次性读完 body 再切片) —— onToken 的语义是
|
|
943
|
+
* "把增量交给调用方", 不是"背压感知的实时流"。真要逐 token 背压, 得换 fetch 的 ReadableStream
|
|
944
|
+
* 并按 chunk 解析 (本仓当前没有这个需求)。
|
|
945
|
+
*/
|
|
946
|
+
async consumeStream(body, onToken, purpose, t0) {
|
|
947
|
+
const raw = await body.text().catch(() => '');
|
|
948
|
+
let reply = '';
|
|
949
|
+
let reasoning = '';
|
|
950
|
+
const acc = [];
|
|
951
|
+
let usage;
|
|
952
|
+
for (const line of String(raw).split('\n')) {
|
|
953
|
+
const s = line.trim();
|
|
954
|
+
if (!s.startsWith('data:'))
|
|
955
|
+
continue;
|
|
956
|
+
const payload = s.slice(5).trim();
|
|
957
|
+
if (!payload || payload === '[DONE]')
|
|
958
|
+
continue;
|
|
959
|
+
let frame;
|
|
960
|
+
try {
|
|
961
|
+
frame = JSON.parse(payload);
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (frame?.usage || frame?.timings)
|
|
967
|
+
usage = extractUsage(frame);
|
|
968
|
+
const delta = frame?.choices?.[0]?.delta;
|
|
969
|
+
if (!delta)
|
|
970
|
+
continue;
|
|
971
|
+
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
|
972
|
+
reply += delta.content;
|
|
973
|
+
try {
|
|
974
|
+
onToken(delta.content);
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
console.warn('[pi-ai] onToken 抛错, 已忽略:', err?.message?.slice(0, 80));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (typeof delta.reasoning_content === 'string')
|
|
981
|
+
reasoning += delta.reasoning_content;
|
|
982
|
+
for (const tc of delta.tool_calls || []) {
|
|
983
|
+
const idx = Number(tc?.index ?? acc.length);
|
|
984
|
+
acc[idx] = acc[idx] || { id: tc?.id, type: 'function', function: { name: '', arguments: '' } };
|
|
985
|
+
if (tc?.id)
|
|
986
|
+
acc[idx].id = tc.id;
|
|
987
|
+
if (tc?.function?.name)
|
|
988
|
+
acc[idx].function.name += tc.function.name;
|
|
989
|
+
if (tc?.function?.arguments)
|
|
990
|
+
acc[idx].function.arguments += tc.function.arguments;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (usage)
|
|
994
|
+
console.log(formatKvServerLine(purpose, usage, { stream: true }));
|
|
995
|
+
const toolCalls = acc.filter(Boolean);
|
|
996
|
+
console.log(`[pi-ai timing] total=${Date.now() - t0}ms stream reply=${reply.length}B toolCalls=${toolCalls.length}`);
|
|
997
|
+
return {
|
|
998
|
+
reply,
|
|
999
|
+
reasoningContent: reasoning || undefined,
|
|
1000
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
1001
|
+
usage,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
475
1004
|
async callAnthropic(messages, temperature, maxTokens, signal) {
|
|
476
1005
|
const apiKey = this.getApiKey();
|
|
477
1006
|
if (!apiKey) {
|
|
@@ -604,27 +1133,64 @@ export class PiAIModel {
|
|
|
604
1133
|
return this.callOllama(messages, temperature, signal);
|
|
605
1134
|
}
|
|
606
1135
|
// 注: callLocal 直接代理 ollama. 工具清单由 generateText 在外层拼到 messages, 这里 messages 已是 finalMessages.
|
|
607
|
-
|
|
1136
|
+
/**
|
|
1137
|
+
* 2026-09-28: 装配系统提示词并拆成「稳定段 / 动态段」, 按 working dir 缓存 (TTL 10 分钟)。
|
|
1138
|
+
*
|
|
1139
|
+
* 返回: `{ stableText, dynamicText, cached }`
|
|
1140
|
+
* - `stableText` 进 system (逐字节稳定 → 服务端前缀 KV 可复用);
|
|
1141
|
+
* - `dynamicText` 由 chat() 注入 CURRENT TURN 区 (最后一轮 user 消息前部)。
|
|
1142
|
+
*
|
|
1143
|
+
* 为什么缓存: 旧版每轮都跑 25 次 fs.readFile + 装配; 缓存后同一次会话内只装一次,
|
|
1144
|
+
* 且装配结果逐字节相同 (缓存本身也是"前缀稳定"的必要条件 —— 每次重装都可能因动态层
|
|
1145
|
+
* 内容变化而抖动)。
|
|
1146
|
+
*/
|
|
1147
|
+
async buildSystemPromptParts(context) {
|
|
1148
|
+
const key = context || process.cwd();
|
|
1149
|
+
const now = Date.now();
|
|
1150
|
+
if (_systemPromptCache && _systemPromptCache.key === key && now - _systemPromptCache.at < SYSTEM_PROMPT_CACHE_TTL_MS) {
|
|
1151
|
+
return { stableText: _systemPromptCache.stableText, dynamicText: _systemPromptCache.dynamicText, cached: true };
|
|
1152
|
+
}
|
|
1153
|
+
const parts = await this.assembleSystemPromptPartsText(key);
|
|
1154
|
+
_systemPromptCache = { key, at: now, ...parts };
|
|
1155
|
+
return { ...parts, cached: false };
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* 真正跑一遍 layer registry 装配 + 拆段 (不含缓存逻辑)。
|
|
1159
|
+
*
|
|
1160
|
+
* 拆段依据: 装配器给每层写的 `<!-- id@version -->` 头 + registry 里 `source === 'function'` 的层集合
|
|
1161
|
+
* (本仓目前只有 `dynamic.project-context`: 它随 cwd/git 状态变 → 必须逐轮新鲜, 但不能待在 system 里)。
|
|
1162
|
+
*/
|
|
1163
|
+
async assembleSystemPromptPartsText(workingDir) {
|
|
608
1164
|
// 走 layer registry: 装配所有相关 layer (身份/行为/工具/角色/渠道)
|
|
609
1165
|
try {
|
|
610
1166
|
const { assembleSystemPrompt, SYSTEM_PROMPT_VERSION } = await import('./system-prompt/registry.js').catch(() => import('./system-prompt/registry.js'));
|
|
611
1167
|
// channel = 本机 (PI SDK 直接调就是 local)
|
|
612
1168
|
// role = 由 prompt context 决定 (默认 expert)
|
|
613
|
-
const ctx =
|
|
1169
|
+
const ctx = { channel: 'local', role: 'expert' };
|
|
614
1170
|
const result = await assembleSystemPrompt(ctx);
|
|
615
|
-
|
|
1171
|
+
// 动态层 = registry 里 source==='function' 的那几层 (resolver 动态求值)
|
|
1172
|
+
const dynamicIds = new Set(result.layers
|
|
1173
|
+
.filter((l) => l.source === 'function')
|
|
1174
|
+
.map((l) => l.id));
|
|
1175
|
+
const { stableText, dynamicText } = splitLayerBlocks(result.text, dynamicIds);
|
|
1176
|
+
const suffix = `\n\n## User Working Directory\n${workingDir}\n\n## bolloon-runtime\n${SYSTEM_PROMPT_VERSION} · layers: ${result.layerIds.join(',')}`;
|
|
1177
|
+
return { stableText: `${stableText}${suffix}`, dynamicText, layerIds: result.layerIds };
|
|
616
1178
|
}
|
|
617
1179
|
catch (err) {
|
|
618
|
-
// 降级: 旧硬编码 (layer registry 不可用时不挂)
|
|
1180
|
+
// 降级: 旧硬编码 (layer registry 不可用时不挂) —— 注意**不写缓存**: 故障态别钉 10 分钟
|
|
619
1181
|
console.warn('[pi-ai] layer registry 不可用, 降级:', err.message?.slice(0, 100));
|
|
620
1182
|
const envDetails = this.getEnvironmentDetails();
|
|
621
|
-
return
|
|
1183
|
+
return {
|
|
1184
|
+
stableText: `You are a friendly AI assistant in a P2P document collaboration network.
|
|
622
1185
|
|
|
623
1186
|
## User Working Directory
|
|
624
|
-
${
|
|
1187
|
+
${workingDir}
|
|
625
1188
|
|
|
626
1189
|
## Environment
|
|
627
|
-
${envDetails}
|
|
1190
|
+
${envDetails}`,
|
|
1191
|
+
dynamicText: '',
|
|
1192
|
+
layerIds: [],
|
|
1193
|
+
};
|
|
628
1194
|
}
|
|
629
1195
|
}
|
|
630
1196
|
// 同步版: 旧调用点 (buildSystemPrompt 是同步)
|
|
@@ -714,6 +1280,32 @@ ${prompt}`;
|
|
|
714
1280
|
}
|
|
715
1281
|
}
|
|
716
1282
|
let modelInstance = null;
|
|
1283
|
+
/** 建这个实例时用的指纹 (provider/providerId/model/baseUrl/apiKey 的 sha256[:8]) —— 指纹一致就复用, 不重建 */
|
|
1284
|
+
let _instanceFingerprint = '';
|
|
1285
|
+
/**
|
|
1286
|
+
* 2026-09-28: provider/model/baseUrl/apiKey 的**指纹**.
|
|
1287
|
+
*
|
|
1288
|
+
* apiKey 只进 sha256 的前 8 位 —— **绝不打印 key**, 也绝不落盘/入报告.
|
|
1289
|
+
* 用途: `initPiAI` 幂等 —— 同一份配置重复 init (CLI 每轮、web 每次请求、脚本里多次调用)
|
|
1290
|
+
* 不再新建实例、不再清系统提示词缓存 (清缓存 = 下轮重装 = 前缀抖动 = KV 白丢)。
|
|
1291
|
+
*/
|
|
1292
|
+
export function modelFingerprint(cfg) {
|
|
1293
|
+
const keyHash = cfg.apiKey ? createHash('sha256').update(String(cfg.apiKey)).digest('hex').slice(0, 8) : '';
|
|
1294
|
+
return createHash('sha256')
|
|
1295
|
+
.update(JSON.stringify({ p: cfg.provider || '', pid: cfg.providerId || '', m: cfg.model || '', u: cfg.baseUrl || '', k: keyHash }))
|
|
1296
|
+
.digest('hex')
|
|
1297
|
+
.slice(0, 16);
|
|
1298
|
+
}
|
|
1299
|
+
/** 当前实例的指纹 (诊断/门用: 只出 hex, 不含任何配置值) */
|
|
1300
|
+
export function currentModelFingerprint() {
|
|
1301
|
+
return _instanceFingerprint;
|
|
1302
|
+
}
|
|
1303
|
+
/** 2026-09-28: 本地 llama.cpp 的探测提示 —— 显式设了 LLAMACPP_* 就把声明 provider 定为 llamacpp */
|
|
1304
|
+
function detectLlamacppHint() {
|
|
1305
|
+
if (process.env.LLAMACPP_BASE_URL || process.env.LLAMACPP_MODEL || process.env.LLAMACPP_API_KEY)
|
|
1306
|
+
return LLAMACPP_PROVIDER_ID;
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
717
1309
|
/** 2026-08-07: bolloon-config.json 优先, 旧 llm-config.json 兜底 (迁移期兼容) */
|
|
718
1310
|
function resolveConfigPath() {
|
|
719
1311
|
const home = process.env.HOME || '/tmp';
|
|
@@ -789,35 +1381,62 @@ function detectModel(provider) {
|
|
|
789
1381
|
return defaults[provider];
|
|
790
1382
|
}
|
|
791
1383
|
export function initPiAI(config = {}) {
|
|
792
|
-
|
|
793
|
-
const
|
|
1384
|
+
// 2026-09-28: llama.cpp = 声明 id `llamacpp` + `openai` 协议分支 (与本仓 providerId/provider 两分法一致)
|
|
1385
|
+
const declared = config.provider || detectLlamacppHint() || detectProvider();
|
|
1386
|
+
const isLlamacpp = declared === LLAMACPP_PROVIDER_ID;
|
|
1387
|
+
const provider = isLlamacpp ? 'openai' : declared;
|
|
1388
|
+
const providerId = config.providerId || (isLlamacpp ? LLAMACPP_PROVIDER_ID : undefined);
|
|
1389
|
+
const model = config.model
|
|
1390
|
+
|| (isLlamacpp ? (process.env.LLAMACPP_MODEL || LLAMACPP_DEFAULT_MODEL) : detectModel(provider));
|
|
1391
|
+
let baseUrl = config.baseUrl || (isLlamacpp ? (process.env.LLAMACPP_BASE_URL || LLAMACPP_DEFAULT_BASE_URL) : undefined);
|
|
794
1392
|
console.log('[PiAIModel] Initializing with provider:', provider, 'model:', model);
|
|
795
1393
|
// 如果没有提供 apiKey,从配置文件读取
|
|
796
1394
|
let apiKey = config.apiKey;
|
|
1395
|
+
// 2026-09-28: llama.cpp 的 key 从 env 兜底取一次 (getApiKey 本来也会读 env, 但指纹需要它 ——
|
|
1396
|
+
// 否则"换 key"这种真变化不会重建实例, 指纹就失去意义)
|
|
1397
|
+
if (!apiKey && isLlamacpp)
|
|
1398
|
+
apiKey = process.env.LLAMACPP_API_KEY || undefined;
|
|
797
1399
|
if (!apiKey) {
|
|
798
1400
|
try {
|
|
799
1401
|
const configPath = resolveConfigPath();
|
|
800
1402
|
if (configPath) {
|
|
801
1403
|
const configData = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
802
|
-
const providerConfig = configData.providers[
|
|
1404
|
+
const providerConfig = configData.providers[declared];
|
|
803
1405
|
if (providerConfig?.apiKey) {
|
|
804
1406
|
apiKey = providerConfig.apiKey;
|
|
805
|
-
console.log('[PiAIModel] Loaded apiKey from config for',
|
|
1407
|
+
console.log('[PiAIModel] Loaded apiKey from config for', declared);
|
|
806
1408
|
}
|
|
1409
|
+
// 2026-09-28: 本地服务 (llamacpp) 的 baseUrl 常写在配置里 → 只对这一家多读一个字段
|
|
1410
|
+
// (别人保持"只读 apiKey"的老行为: 让 model/baseUrl 的优先级变动会牵连 llm-config 那条线)
|
|
1411
|
+
if (isLlamacpp && !baseUrl && providerConfig?.baseUrl)
|
|
1412
|
+
baseUrl = providerConfig.baseUrl;
|
|
807
1413
|
}
|
|
808
1414
|
}
|
|
809
1415
|
catch (e) {
|
|
810
1416
|
console.log('[PiAIModel] Error reading apiKey from config:', e);
|
|
811
1417
|
}
|
|
812
1418
|
}
|
|
1419
|
+
// ── 2026-09-28: 按指纹幂等 ──────────────────────────────────────────────
|
|
1420
|
+
// 同一份配置重复 init (CLI 每轮 / web 每次请求 / 脚本多入口) **不再重建实例、不再清装配缓存**.
|
|
1421
|
+
// 之前每次 init 都清缓存 → 每轮重装 system → 前缀抖动 → 服务端前缀 KV 永远用不上.
|
|
1422
|
+
// 真变化 (provider/model/baseUrl/key) → 清缓存 + 重建 (旧行为).
|
|
1423
|
+
const fingerprint = modelFingerprint({ provider, providerId, model, baseUrl, apiKey });
|
|
1424
|
+
if (modelInstance && _instanceFingerprint === fingerprint) {
|
|
1425
|
+
console.log('[PiAIModel] 指纹一致, 复用现有实例 (不重建, 不清装配缓存):', fingerprint.slice(0, 8));
|
|
1426
|
+
return modelInstance;
|
|
1427
|
+
}
|
|
1428
|
+
clearSystemPromptCache();
|
|
813
1429
|
modelInstance = new PiAIModel({
|
|
814
1430
|
provider,
|
|
815
|
-
providerId
|
|
1431
|
+
providerId,
|
|
816
1432
|
apiKey,
|
|
817
|
-
baseUrl
|
|
818
|
-
model
|
|
1433
|
+
baseUrl,
|
|
1434
|
+
model,
|
|
1435
|
+
// 本地自建服务: 允许空 key + 无 key 不带鉴权头
|
|
1436
|
+
allowEmptyKey: isLlamacpp,
|
|
819
1437
|
});
|
|
820
|
-
|
|
1438
|
+
_instanceFingerprint = fingerprint;
|
|
1439
|
+
console.log('[PiAIModel] Model instance created, provider:', provider, 'fingerprint:', fingerprint.slice(0, 8));
|
|
821
1440
|
return modelInstance;
|
|
822
1441
|
}
|
|
823
1442
|
export function getModel() {
|