@bolloon/bolloon-agent 0.3.8 → 0.3.10
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 +90 -9
- package/dist/agents/pi-sdk.js +26 -5
- package/dist/bootstrap/exhaust-scrubber.js +233 -0
- package/dist/bootstrap/memory-compressor.js +11 -0
- package/dist/constraint-runtime/src/tools/PolymarketSDK/cancelOrder.js +19 -4
- package/dist/constraint-runtime/src/tools/PolymarketSDK/clobShared.js +104 -0
- package/dist/constraint-runtime/src/tools/PolymarketSDK/createOrder.js +34 -4
- package/dist/constraint-runtime/src/tools/PolymarketSDK/getOrders.js +17 -4
- package/dist/external-engines/delegate.js +148 -0
- package/dist/external-engines/discovery.js +394 -0
- package/dist/external-engines/index.js +9 -0
- package/dist/external-engines/types.js +11 -0
- package/dist/pi-ecosystem-judgment/injection-gate.js +85 -1
- package/dist/web/api-config.html +250 -1
- package/dist/web/client.js +32 -10
- package/dist/web/index.html +47 -23
- package/dist/web/routes-external-engines.js +111 -0
- package/dist/web/routes-judgments.js +8 -2
- package/dist/web/server.js +15 -0
- package/dist/web/style.css +61 -0
- package/package.json +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* external-engines/delegate.ts — 把编码任务委派给本机已安装的外部编码智能体 CLI
|
|
3
|
+
*
|
|
4
|
+
* 与 discovery 配合: discovery 找到 CLI 路径 + 规格, 这里真正 spawn 起来跑任务.
|
|
5
|
+
*
|
|
6
|
+
* 安全边界 (参照 shell-tool.ts 的护栏思路):
|
|
7
|
+
* - 只委派给"已发现且 installed"的引擎 (cliPath 来自 command -v, 不由用户输入)
|
|
8
|
+
* - prompt 作为单一 argv 传入, shell: false, 杜绝命令注入
|
|
9
|
+
* - 默认 120s 超时 (BOLLOON_ENGINE_DELEGATE_TIMEOUT_MS 可配), 超时杀进程
|
|
10
|
+
* - experiment 引擎是 API 供应商不是 CLI, 不支持委派 (提示改用 import)
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
import { discoverEngines, buildDelegateArgs } from './discovery.js';
|
|
14
|
+
function delegateTimeoutMs() {
|
|
15
|
+
const env = Number(process.env.BOLLOON_ENGINE_DELEGATE_TIMEOUT_MS);
|
|
16
|
+
return Number.isFinite(env) && env > 0 ? env : 120_000;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 把任务派发给指定引擎的 CLI 执行.
|
|
20
|
+
* @param id 引擎 id: codex / claude-code / opencode / openclaw / hermes
|
|
21
|
+
* @param prompt 任务描述 (作为单参数传给 CLI)
|
|
22
|
+
*/
|
|
23
|
+
export async function delegateToEngine(id, prompt, opts = {}) {
|
|
24
|
+
const trimmedId = String(id || '').trim();
|
|
25
|
+
const trimmedPrompt = String(prompt || '').trim();
|
|
26
|
+
if (!trimmedId)
|
|
27
|
+
return { success: false, error: 'engine id 必填', unavailable: true };
|
|
28
|
+
if (!trimmedPrompt)
|
|
29
|
+
return { success: false, error: 'prompt 必填', unavailable: false };
|
|
30
|
+
// 实验引擎是 API 供应商, 不是 CLI, 不能委派
|
|
31
|
+
if (trimmedId.startsWith('experiment:')) {
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
unavailable: true,
|
|
35
|
+
error: `引擎 ${trimmedId} 是实验 API 供应商 (无 CLI), 不能委派执行. 请先用 /api/external-engines/import 把它注册为 LLM provider 后由 Bolloon 直接调用.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// 发现引擎, 拿到 cliPath + argv 模板
|
|
39
|
+
const engines = await discoverEngines();
|
|
40
|
+
const engine = engines.find((e) => e.id === trimmedId);
|
|
41
|
+
if (!engine) {
|
|
42
|
+
return { success: false, unavailable: true, error: `未发现的引擎: ${trimmedId}` };
|
|
43
|
+
}
|
|
44
|
+
if (!engine.installed || !engine.cliPath) {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
unavailable: true,
|
|
48
|
+
error: `引擎 ${trimmedId} 未安装 (CLI 不在 PATH 上), 无法委派. 可用 /api/external-engines 查看已安装列表.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const argv = buildDelegateArgs(trimmedId, trimmedPrompt, opts.model);
|
|
52
|
+
if (!argv) {
|
|
53
|
+
return { success: false, unavailable: true, error: `引擎 ${trimmedId} 没有配置委派参数模板` };
|
|
54
|
+
}
|
|
55
|
+
const cwd = opts.cwd || process.cwd();
|
|
56
|
+
const timeoutMs = opts.timeoutMs || delegateTimeoutMs();
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
let stdout = '';
|
|
59
|
+
let stderr = '';
|
|
60
|
+
let settled = false;
|
|
61
|
+
// 注意: 不要用 detached:true — 实测会让 opencode 不退出 (探针: detached=true 时 90s 仍不 exit,
|
|
62
|
+
// detached=false 时 ~11s 正常 exit+close). opencode run --format json 退出干净, 无残留孙进程.
|
|
63
|
+
// 监听 'exit' 而非 'close': exit 在进程退出时即触发, 更稳 (close 也正常, 两者都可用).
|
|
64
|
+
const proc = spawn(engine.cliPath, argv, {
|
|
65
|
+
cwd,
|
|
66
|
+
env: { ...process.env },
|
|
67
|
+
shell: false,
|
|
68
|
+
windowsHide: true,
|
|
69
|
+
// stdin 必须 'ignore' (/dev/null): 否则 stdin 是默认管道, opencode run 会阻塞等
|
|
70
|
+
// stdin EOF 导致永不退出. stdout/stderr 用 pipe 收集输出.
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
});
|
|
73
|
+
const killTree = () => {
|
|
74
|
+
try {
|
|
75
|
+
proc.kill('SIGKILL');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// 进程可能已退出, 忽略
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const killTimer = setTimeout(() => {
|
|
82
|
+
if (!settled) {
|
|
83
|
+
settled = true;
|
|
84
|
+
killTree();
|
|
85
|
+
resolve({
|
|
86
|
+
success: false,
|
|
87
|
+
output: stdout.slice(-8000),
|
|
88
|
+
error: `委派超时 (${timeoutMs}ms), 已终止 ${trimmedId} 进程`,
|
|
89
|
+
exitCode: null,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
proc.stdout?.on('data', (d) => {
|
|
94
|
+
stdout += d.toString();
|
|
95
|
+
if (stdout.length > 8_000_000) {
|
|
96
|
+
// 超过 8MB, 截断防止内存爆炸 (仍继续收集尾部不重要)
|
|
97
|
+
stdout = stdout.slice(-8_000_000);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
proc.stderr?.on('data', (d) => {
|
|
101
|
+
stderr += d.toString();
|
|
102
|
+
if (stderr.length > 8_000_000)
|
|
103
|
+
stderr = stderr.slice(-8_000_000);
|
|
104
|
+
});
|
|
105
|
+
proc.on('error', (err) => {
|
|
106
|
+
if (settled)
|
|
107
|
+
return;
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(killTimer);
|
|
110
|
+
killTree();
|
|
111
|
+
resolve({ success: false, error: `启动 ${trimmedId} 失败: ${err.message}`, exitCode: null, unavailable: true });
|
|
112
|
+
});
|
|
113
|
+
// 用 'exit' 而非 'close': exit 在进程退出时即触发, 不被孙子进程持有的管道阻塞.
|
|
114
|
+
// setImmediate 给最后一批 stdout data 一个 tick 的 flush 机会, 避免截断.
|
|
115
|
+
proc.on('exit', (code, signal) => {
|
|
116
|
+
if (settled)
|
|
117
|
+
return;
|
|
118
|
+
setImmediate(() => {
|
|
119
|
+
if (settled)
|
|
120
|
+
return;
|
|
121
|
+
settled = true;
|
|
122
|
+
clearTimeout(killTimer);
|
|
123
|
+
// opencode run 会留一个 headless server 孙进程继承 stdout 管道, 让 Node 的
|
|
124
|
+
// 'close' 永不触发 / 事件循环不退出. destroy 掉我们这一侧的流句柄, 释放 event loop
|
|
125
|
+
// (孙进程的 fd 副本在它自己进程里, 不影响 Node 退出). 结果已在 stdout/stderr 字符串里.
|
|
126
|
+
try {
|
|
127
|
+
proc.stdout?.destroy();
|
|
128
|
+
}
|
|
129
|
+
catch { /* noop */ }
|
|
130
|
+
try {
|
|
131
|
+
proc.stderr?.destroy();
|
|
132
|
+
}
|
|
133
|
+
catch { /* noop */ }
|
|
134
|
+
killTree();
|
|
135
|
+
const combined = (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim();
|
|
136
|
+
if (code === 0) {
|
|
137
|
+
resolve({ success: true, output: combined || '(无输出)', exitCode: code });
|
|
138
|
+
}
|
|
139
|
+
else if (signal) {
|
|
140
|
+
resolve({ success: false, output: combined || '(无输出)', error: `${trimmedId} 被信号 ${signal} 终止`, exitCode: null });
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
resolve({ success: false, output: combined || '(无输出)', error: `${trimmedId} 退出码 ${code}`, exitCode: code });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* external-engines/discovery.ts — 自动发现本机已安装的外部编码智能体
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* - 纯函数 + 可注入 deps, 便于单测 (不真实碰 fs / 不真实 spawn)
|
|
6
|
+
* - best-effort: 不同工具版本配置文件 schema 会变, 用一个宽松的 key 提取器
|
|
7
|
+
* - 发现 ≠ 启用: 这里只回报"装了没 / 配了没", 真正写进 provider 由 import 负责
|
|
8
|
+
*
|
|
9
|
+
* 复用现有范式: 参照 src/pi-ecosystem-mcp/index.ts 的 discoverMcpServers()
|
|
10
|
+
*/
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as fs from 'fs/promises';
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
/**
|
|
15
|
+
* 可筛选的模型候选列表.
|
|
16
|
+
* OpenCode / OpenClaw / Hermes 是 provider 无关 (openai 兼容 + anthropic 等),
|
|
17
|
+
* 给一份跨供应商的宽列表便于在 UI 里筛选; Codex / Claude Code 给各自供应商的列表.
|
|
18
|
+
* 实验 API 由声明文件决定, 不预置.
|
|
19
|
+
*/
|
|
20
|
+
const OPENAI_COMPAT_MODELS = [
|
|
21
|
+
'gpt-5.5', 'gpt-5', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo',
|
|
22
|
+
'deepseek-v4-flash', 'deepseek-v4-pro',
|
|
23
|
+
'qwen-plus', 'qwen-max', 'qwen-turbo',
|
|
24
|
+
'moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k',
|
|
25
|
+
'glm-4-flash', 'glm-4', 'glm-4-plus',
|
|
26
|
+
'mimo-v2.5-pro', 'mimo-v2-pro',
|
|
27
|
+
];
|
|
28
|
+
const ANTHROPIC_MODELS = [
|
|
29
|
+
'claude-sonnet-4-5-20250929', 'claude-opus-4', 'claude-3-5-sonnet-20241022',
|
|
30
|
+
'claude-3-5-haiku-20241022', 'claude-3-opus-20240229',
|
|
31
|
+
];
|
|
32
|
+
const GEMINI_MODELS = ['gemini-3.5-flash', 'gemini-2.5-pro', 'gemini-3.1-flash-lite', 'gemini-flash-latest'];
|
|
33
|
+
const OPENROUTER_MODELS = [
|
|
34
|
+
'anthropic/claude-sonnet-4.5', 'anthropic/claude-3.5-sonnet', 'openai/gpt-4.1',
|
|
35
|
+
'deepseek/deepseek-v4-flash', 'google/gemini-2.5-pro',
|
|
36
|
+
];
|
|
37
|
+
/** OpenCode 是 provider 无关, 合并主流供应商列表 */
|
|
38
|
+
const OPENCODE_MODELS = [
|
|
39
|
+
...OPENAI_COMPAT_MODELS,
|
|
40
|
+
...ANTHROPIC_MODELS,
|
|
41
|
+
...GEMINI_MODELS,
|
|
42
|
+
...OPENROUTER_MODELS,
|
|
43
|
+
];
|
|
44
|
+
/** provider 别名 → Bolloon ModelProvider */
|
|
45
|
+
const PROVIDER_ALIASES = {
|
|
46
|
+
openai: 'openai',
|
|
47
|
+
'openai-compatible': 'openai',
|
|
48
|
+
azure: 'openai',
|
|
49
|
+
anthropic: 'anthropic',
|
|
50
|
+
claude: 'anthropic',
|
|
51
|
+
ollama: 'ollama',
|
|
52
|
+
openrouter: 'openrouter',
|
|
53
|
+
gemini: 'gemini',
|
|
54
|
+
google: 'gemini',
|
|
55
|
+
minimax: 'minimax',
|
|
56
|
+
deepseek: 'deepseek',
|
|
57
|
+
kimi: 'kimi',
|
|
58
|
+
moonshot: 'kimi',
|
|
59
|
+
glm: 'glm',
|
|
60
|
+
zhipu: 'glm',
|
|
61
|
+
qwen: 'qwen',
|
|
62
|
+
dashscope: 'qwen',
|
|
63
|
+
mimo: 'mimo',
|
|
64
|
+
xiaomi: 'mimo',
|
|
65
|
+
local: 'local',
|
|
66
|
+
};
|
|
67
|
+
/** 已知引擎规格 (不含 experiment, experiment 由目录扫描动态产出) */
|
|
68
|
+
const KNOWN_ENGINES = [
|
|
69
|
+
{
|
|
70
|
+
id: 'codex',
|
|
71
|
+
displayName: 'OpenAI Codex CLI',
|
|
72
|
+
binaries: ['codex'],
|
|
73
|
+
configFiles: ['.codex/config.json', '.codex/auth.json'],
|
|
74
|
+
envKeys: ['OPENAI_API_KEY', 'CODEX_API_KEY'],
|
|
75
|
+
providerHint: 'openai',
|
|
76
|
+
baseUrlHint: 'https://api.openai.com/v1',
|
|
77
|
+
modelHint: 'gpt-4.1',
|
|
78
|
+
models: OPENAI_COMPAT_MODELS,
|
|
79
|
+
modelFlag: '-m',
|
|
80
|
+
delegateArgs: (p) => ['exec', '--full-auto', p],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'claude-code',
|
|
84
|
+
displayName: 'Claude Code (Anthropic)',
|
|
85
|
+
binaries: ['claude', 'claude-code'],
|
|
86
|
+
configFiles: ['.claude.json', '.config/claude/config.json'],
|
|
87
|
+
envKeys: ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'],
|
|
88
|
+
providerHint: 'anthropic',
|
|
89
|
+
baseUrlHint: 'https://api.anthropic.com/v1',
|
|
90
|
+
modelHint: 'claude-sonnet-4-5-20250929',
|
|
91
|
+
models: ANTHROPIC_MODELS,
|
|
92
|
+
modelFlag: '--model',
|
|
93
|
+
delegateArgs: (p) => ['-p', p, '--print'],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'opencode',
|
|
97
|
+
displayName: 'OpenCode',
|
|
98
|
+
binaries: ['opencode'],
|
|
99
|
+
configFiles: ['.config/opencode/opencode.json', '.opencode/opencode.json', 'opencode.json'],
|
|
100
|
+
envKeys: ['OPENCODE_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY'],
|
|
101
|
+
providerHint: 'openai',
|
|
102
|
+
models: OPENCODE_MODELS,
|
|
103
|
+
modelFlag: '-m',
|
|
104
|
+
// opencode run 默认进 TUI 不退出, --format json 强制 headless 输出并退出 (非交互委派必需)
|
|
105
|
+
delegateArgs: (p) => ['run', p, '--format', 'json'],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'openclaw',
|
|
109
|
+
displayName: 'OpenClaw',
|
|
110
|
+
binaries: ['openclaw', 'open-claw'],
|
|
111
|
+
configFiles: ['.openclaw/config.json', '.config/openclaw/config.json'],
|
|
112
|
+
envKeys: ['OPENCLAW_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
|
|
113
|
+
providerHint: 'openai',
|
|
114
|
+
models: OPENCODE_MODELS,
|
|
115
|
+
delegateArgs: (p) => ['run', p],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'hermes',
|
|
119
|
+
displayName: 'Hermes',
|
|
120
|
+
binaries: ['hermes'],
|
|
121
|
+
configFiles: ['.hermes/config.json', '.config/hermes/config.json'],
|
|
122
|
+
envKeys: ['HERMES_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
|
|
123
|
+
providerHint: 'openai',
|
|
124
|
+
models: OPENCODE_MODELS,
|
|
125
|
+
delegateArgs: (p) => ['prompt', p],
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
// ====================== 默认 deps (真实 IO) ======================
|
|
129
|
+
function realWhichImpl(name) {
|
|
130
|
+
// 用 command -v 解析 PATH; JSON.stringify 防止名字里的元字符注入内层 shell
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const p = spawn('sh', ['-c', `command -v ${JSON.stringify(name)}`], {
|
|
133
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
|
+
});
|
|
135
|
+
let out = '';
|
|
136
|
+
p.stdout?.on('data', (d) => (out += d.toString()));
|
|
137
|
+
p.on('close', () => resolve(out.trim() || undefined));
|
|
138
|
+
p.on('error', () => resolve(undefined));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function realReadFileImpl(p) {
|
|
142
|
+
return fs.readFile(p, 'utf-8').then((c) => c).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
function realReaddirImpl(dir) {
|
|
145
|
+
return fs.readdir(dir).then((c) => c).catch(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
export function defaultDeps() {
|
|
148
|
+
return {
|
|
149
|
+
which: realWhichImpl,
|
|
150
|
+
readFile: realReadFileImpl,
|
|
151
|
+
readdir: realReaddirImpl,
|
|
152
|
+
env: process.env,
|
|
153
|
+
home: process.env.HOME || process.env.USERPROFILE || '/tmp',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// ====================== 纯工具函数 (可单测) ======================
|
|
157
|
+
const APIKEY_KEYS = ['apiKey', 'api_key', 'apikey', 'key', 'token', 'access_token', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'OPENCLAW_API_KEY', 'HERMES_API_KEY', 'CODEX_API_KEY', 'OPENCODE_API_KEY'];
|
|
158
|
+
const BASEURL_KEYS = ['baseUrl', 'base_url', 'apiBase', 'api_base', 'endpoint', 'baseURL', 'API_BASE'];
|
|
159
|
+
const MODEL_KEYS = ['model', 'modelName', 'model_name'];
|
|
160
|
+
const PROVIDER_KEYS = ['provider', 'providerName', 'provider_name'];
|
|
161
|
+
/** 宽松提取: 先看顶层, 再看一层嵌套 (常见的 providers.xxx / auth.xxx) */
|
|
162
|
+
function pickKey(obj, keys) {
|
|
163
|
+
if (!obj || typeof obj !== 'object')
|
|
164
|
+
return undefined;
|
|
165
|
+
for (const k of keys) {
|
|
166
|
+
const v = obj[k];
|
|
167
|
+
if (typeof v === 'string' && v.trim())
|
|
168
|
+
return v.trim();
|
|
169
|
+
}
|
|
170
|
+
for (const k of Object.keys(obj)) {
|
|
171
|
+
const sub = obj[k];
|
|
172
|
+
if (sub && typeof sub === 'object') {
|
|
173
|
+
for (const kk of keys) {
|
|
174
|
+
const v = sub[kk];
|
|
175
|
+
if (typeof v === 'string' && v.trim())
|
|
176
|
+
return v.trim();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/** 把任意 provider 字符串解析成 Bolloon ModelProvider (未知 → 兜底 hint) */
|
|
183
|
+
export function resolveProvider(raw, hint) {
|
|
184
|
+
if (!raw)
|
|
185
|
+
return hint;
|
|
186
|
+
const norm = raw.trim().toLowerCase();
|
|
187
|
+
if (PROVIDER_ALIASES[norm]) {
|
|
188
|
+
return PROVIDER_ALIASES[norm];
|
|
189
|
+
}
|
|
190
|
+
// 容错: 包含关键字也识别
|
|
191
|
+
if (norm.includes('anthropic') || norm.includes('claude'))
|
|
192
|
+
return 'anthropic';
|
|
193
|
+
if (norm.includes('openai'))
|
|
194
|
+
return 'openai';
|
|
195
|
+
if (norm.includes('gemini') || norm.includes('google'))
|
|
196
|
+
return 'gemini';
|
|
197
|
+
if (norm.includes('ollama'))
|
|
198
|
+
return 'ollama';
|
|
199
|
+
if (norm.includes('deepseek'))
|
|
200
|
+
return 'deepseek';
|
|
201
|
+
if (norm.includes('minimax'))
|
|
202
|
+
return 'minimax';
|
|
203
|
+
if (norm.includes('openrouter'))
|
|
204
|
+
return 'openrouter';
|
|
205
|
+
return hint;
|
|
206
|
+
}
|
|
207
|
+
/** 把发现到的引擎映射成 provider 导入 patch (纯函数, 可单测) */
|
|
208
|
+
export function mapEngineToProviderConfig(engine) {
|
|
209
|
+
if (!engine.provider) {
|
|
210
|
+
throw new Error(`引擎 ${engine.id} 没有可映射的 provider, 无法导入为供应商`);
|
|
211
|
+
}
|
|
212
|
+
const patch = { enabled: true };
|
|
213
|
+
if (engine.apiKey)
|
|
214
|
+
patch.apiKey = engine.apiKey;
|
|
215
|
+
if (engine.baseUrl)
|
|
216
|
+
patch.baseUrl = engine.baseUrl;
|
|
217
|
+
if (engine.model)
|
|
218
|
+
patch.model = engine.model;
|
|
219
|
+
return { provider: engine.provider, patch };
|
|
220
|
+
}
|
|
221
|
+
/** 解析单个实验 API 文件内容 → 一组 {name, provider, apiKey, baseUrl, model, models?} */
|
|
222
|
+
export function parseExperimentFile(content) {
|
|
223
|
+
let json;
|
|
224
|
+
try {
|
|
225
|
+
json = JSON.parse(content);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
const out = [];
|
|
231
|
+
const pushOne = (obj, fallbackName) => {
|
|
232
|
+
if (!obj || typeof obj !== 'object')
|
|
233
|
+
return;
|
|
234
|
+
const provider = resolveProvider(pickKey(obj, PROVIDER_KEYS), 'openai');
|
|
235
|
+
const apiKey = pickKey(obj, APIKEY_KEYS);
|
|
236
|
+
const baseUrl = pickKey(obj, BASEURL_KEYS);
|
|
237
|
+
const model = pickKey(obj, MODEL_KEYS);
|
|
238
|
+
const models = Array.isArray(obj.models) ? obj.models.filter((m) => typeof m === 'string' && m.trim()) : undefined;
|
|
239
|
+
if (!apiKey && !baseUrl)
|
|
240
|
+
return; // 没任何连接信息, 跳过
|
|
241
|
+
out.push({
|
|
242
|
+
name: typeof obj.name === 'string' && obj.name.trim() ? obj.name.trim() : fallbackName,
|
|
243
|
+
provider,
|
|
244
|
+
apiKey,
|
|
245
|
+
baseUrl,
|
|
246
|
+
model,
|
|
247
|
+
models,
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
// 形态 1: 顶层直接是 { name, provider, apiKey, baseUrl, model }
|
|
251
|
+
if (json.name || json.provider || json.apiKey || json.baseUrl) {
|
|
252
|
+
pushOne(json, 'experiment');
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
// 形态 2: { providers: [...] } / { engines: [...] } / { apis: [...] }
|
|
256
|
+
for (const arrKey of ['providers', 'engines', 'apis', 'experiments']) {
|
|
257
|
+
if (Array.isArray(json[arrKey])) {
|
|
258
|
+
json[arrKey].forEach((e, i) => pushOne(e, `experiment-${arrKey}-${i}`));
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
// ====================== 核心发现逻辑 ======================
|
|
265
|
+
async function discoverOne(spec, deps) {
|
|
266
|
+
// 1. CLI 是否安装
|
|
267
|
+
let cliPath;
|
|
268
|
+
for (const bin of spec.binaries) {
|
|
269
|
+
const found = await deps.which(bin);
|
|
270
|
+
if (found) {
|
|
271
|
+
cliPath = found;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// 2. 读配置文件
|
|
276
|
+
let configObj = null;
|
|
277
|
+
let configPath;
|
|
278
|
+
for (const cf of spec.configFiles) {
|
|
279
|
+
const abs = path.isAbsolute(cf) ? cf : path.join(deps.home, cf);
|
|
280
|
+
const content = await deps.readFile(abs);
|
|
281
|
+
if (content) {
|
|
282
|
+
try {
|
|
283
|
+
configObj = JSON.parse(content);
|
|
284
|
+
configPath = abs;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// 非 JSON, 跳过
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// 3. 解析 apiKey: 环境变量优先, 其次配置文件
|
|
293
|
+
let apiKey;
|
|
294
|
+
let source = 'none';
|
|
295
|
+
for (const ek of spec.envKeys) {
|
|
296
|
+
const v = deps.env[ek];
|
|
297
|
+
if (v && v.trim()) {
|
|
298
|
+
apiKey = v.trim();
|
|
299
|
+
source = 'env';
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!apiKey) {
|
|
304
|
+
const fromConfig = pickKey(configObj, APIKEY_KEYS);
|
|
305
|
+
if (fromConfig) {
|
|
306
|
+
apiKey = fromConfig;
|
|
307
|
+
source = 'config';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 4. baseUrl / model: 配置文件优先, 其次 hint
|
|
311
|
+
const baseUrl = pickKey(configObj, BASEURL_KEYS) || spec.baseUrlHint;
|
|
312
|
+
const model = pickKey(configObj, MODEL_KEYS) || spec.modelHint;
|
|
313
|
+
// 4.1 模型候选列表: 配置文件声明的 models 数组优先, 否则用规格预置列表
|
|
314
|
+
let models = spec.models;
|
|
315
|
+
if (configObj && Array.isArray(configObj.models) && configObj.models.length > 0) {
|
|
316
|
+
models = configObj.models.filter((m) => typeof m === 'string' && m.trim());
|
|
317
|
+
}
|
|
318
|
+
// 5. provider: 配置文件声明的 > hint
|
|
319
|
+
const provider = resolveProvider(pickKey(configObj, PROVIDER_KEYS), spec.providerHint);
|
|
320
|
+
const configured = !!apiKey || provider === 'ollama' || provider === 'local';
|
|
321
|
+
const available = !!cliPath && configured;
|
|
322
|
+
return {
|
|
323
|
+
id: spec.id,
|
|
324
|
+
displayName: spec.displayName,
|
|
325
|
+
installed: !!cliPath,
|
|
326
|
+
configured,
|
|
327
|
+
available,
|
|
328
|
+
cliPath,
|
|
329
|
+
configPath,
|
|
330
|
+
provider,
|
|
331
|
+
apiKey,
|
|
332
|
+
baseUrl,
|
|
333
|
+
model,
|
|
334
|
+
models,
|
|
335
|
+
source,
|
|
336
|
+
notes: `委派参数模板: ${spec.binaries[0]} ${spec.delegateArgs('<prompt>').join(' ')}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async function discoverExperimentEngines(deps) {
|
|
340
|
+
const dir = deps.env['BOLLOON_EXPERIMENT_API_DIR'] ||
|
|
341
|
+
path.join(deps.home, '.bolloon', 'experiments');
|
|
342
|
+
const files = await deps.readdir(dir);
|
|
343
|
+
if (!files || files.length === 0)
|
|
344
|
+
return [];
|
|
345
|
+
const result = [];
|
|
346
|
+
for (const f of files) {
|
|
347
|
+
if (!f.endsWith('.json'))
|
|
348
|
+
continue;
|
|
349
|
+
const content = await deps.readFile(path.join(dir, f));
|
|
350
|
+
if (!content)
|
|
351
|
+
continue;
|
|
352
|
+
const parsed = parseExperimentFile(content);
|
|
353
|
+
for (const item of parsed) {
|
|
354
|
+
const provider = item.provider || 'openai';
|
|
355
|
+
result.push({
|
|
356
|
+
id: `experiment:${item.name}`,
|
|
357
|
+
displayName: `实验 API: ${item.name}`,
|
|
358
|
+
installed: true, // 实验 API 视为"已装" (它们就是配置文件声明的)
|
|
359
|
+
configured: !!(item.apiKey || item.baseUrl),
|
|
360
|
+
available: !!(item.apiKey || item.baseUrl),
|
|
361
|
+
configPath: path.join(dir, f),
|
|
362
|
+
provider,
|
|
363
|
+
apiKey: item.apiKey,
|
|
364
|
+
baseUrl: item.baseUrl,
|
|
365
|
+
model: item.model,
|
|
366
|
+
models: item.models,
|
|
367
|
+
source: 'config',
|
|
368
|
+
notes: '来自实验目录声明的 API (BOLLOON_EXPERIMENT_API_DIR)',
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
/** 发现所有外部引擎 (已知 + 实验) */
|
|
375
|
+
export async function discoverEngines(deps = defaultDeps()) {
|
|
376
|
+
const known = await Promise.all(KNOWN_ENGINES.map((spec) => discoverOne(spec, deps)));
|
|
377
|
+
const experiment = await discoverExperimentEngines(deps);
|
|
378
|
+
return [...known, ...experiment];
|
|
379
|
+
}
|
|
380
|
+
/** 取单个引擎规格 (委派时用) */
|
|
381
|
+
export function getEngineSpec(id) {
|
|
382
|
+
return KNOWN_ENGINES.find((e) => e.id === id);
|
|
383
|
+
}
|
|
384
|
+
/** 取已知引擎的委派 argv (best-effort); 传 model 时追加该引擎的 modelFlag */
|
|
385
|
+
export function buildDelegateArgs(id, prompt, model) {
|
|
386
|
+
const spec = getEngineSpec(id);
|
|
387
|
+
if (!spec)
|
|
388
|
+
return undefined;
|
|
389
|
+
const args = spec.delegateArgs(prompt);
|
|
390
|
+
if (model && spec.modelFlag) {
|
|
391
|
+
args.push(spec.modelFlag, model);
|
|
392
|
+
}
|
|
393
|
+
return args;
|
|
394
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* external-engines/index.ts — 外部编码智能体 模块 barrel
|
|
3
|
+
*
|
|
4
|
+
* 对外暴露: 发现 (discover) / 配置为供应商映射 (mapEngineToProviderConfig) /
|
|
5
|
+
* 委派 (delegateToEngine) / 类型.
|
|
6
|
+
*/
|
|
7
|
+
export * from './types.js';
|
|
8
|
+
export { discoverEngines, getEngineSpec, buildDelegateArgs, mapEngineToProviderConfig, parseExperimentFile, resolveProvider, defaultDeps, } from './discovery.js';
|
|
9
|
+
export { delegateToEngine } from './delegate.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* external-engines/types.ts — 外部编码智能体/工具 类型定义
|
|
3
|
+
*
|
|
4
|
+
* "外部引擎" = 本机已安装的其他 AI 编码工具 (codex / claude code / openclaw /
|
|
5
|
+
* hermes / opencode) 以及实验里已声明的 API. Bolloon 可以:
|
|
6
|
+
* 1. 发现 (discover): 扫描 CLI 是否安装 + 它们的配置文件/环境变量里已有的 API key
|
|
7
|
+
* 2. 配置为供应商 (import): 把发现到的 API key/baseUrl/model 写进 Bolloon 的
|
|
8
|
+
* LLM provider 体系, 当作普通供应商启用 (用户无需重复填 key)
|
|
9
|
+
* 3. 委派 (delegate): 直接调用这些工具的 CLI, 把编码任务派发给它们当子智能体跑
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
@@ -138,7 +138,7 @@ function formatInjection(values, mode, resolvedCount, maxChars = 1500) {
|
|
|
138
138
|
// 使用记录 (回溯): AI 实际"用了"哪些判断力
|
|
139
139
|
// ============================================================
|
|
140
140
|
const USAGE_LOG = (os.homedir() || '/tmp') + '/.bolloon/human-values/usage.jsonl';
|
|
141
|
-
export async function recordJudgmentUsage(usedIds, meta) {
|
|
141
|
+
export async function recordJudgmentUsage(usedIds, meta = {}) {
|
|
142
142
|
if (usedIds.length === 0)
|
|
143
143
|
return;
|
|
144
144
|
try {
|
|
@@ -147,6 +147,7 @@ export async function recordJudgmentUsage(usedIds, meta) {
|
|
|
147
147
|
channelId: meta.channelId ?? null,
|
|
148
148
|
userInputPreview: (meta.userInput ?? '').substring(0, 80),
|
|
149
149
|
usedIds,
|
|
150
|
+
polarity: meta.polarity ?? 'positive',
|
|
150
151
|
};
|
|
151
152
|
await fs.appendFile(USAGE_LOG, JSON.stringify(entry) + '\n', 'utf-8');
|
|
152
153
|
}
|
|
@@ -154,6 +155,89 @@ export async function recordJudgmentUsage(usedIds, meta) {
|
|
|
154
155
|
console.warn('[injection-gate] recordJudgmentUsage failed:', err);
|
|
155
156
|
}
|
|
156
157
|
}
|
|
158
|
+
// ============================================================
|
|
159
|
+
// 2026-07-22 设计 B: 负向判断力回收 — "避免清单"注入 (显式, 进 prompt)
|
|
160
|
+
//
|
|
161
|
+
// 涡轮增压锚点: 判断力的负向是"判断力"不是"上下文废气", 可进 prompt 作为约束
|
|
162
|
+
// (精准 = 正向指引 + 负向避免). 从 reject 类 + 高 stakes + 高 confidence 选 Top N,
|
|
163
|
+
// 以"避免清单"语义产出 systemAddition. maxChars=300 (远小于正向 1500, 防噪音).
|
|
164
|
+
// ============================================================
|
|
165
|
+
export const DEFAULT_NEGATIVE_CONFIG = {
|
|
166
|
+
topN: 3,
|
|
167
|
+
mode: 'concise',
|
|
168
|
+
skip: false,
|
|
169
|
+
maxChars: 300,
|
|
170
|
+
/** 最低置信度门槛 (只注入足够可信的否决) */
|
|
171
|
+
minConfidence: 0.7,
|
|
172
|
+
};
|
|
173
|
+
function emptyGateResult(skipReason) {
|
|
174
|
+
return { systemAddition: '', usedIds: [], matchedCount: 0, didInject: false, skipReason };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 负向判断力注入门: 给定用户输入, 返回"避免清单"追加文本 + 用到的负向 judgment id.
|
|
178
|
+
*
|
|
179
|
+
* 筛选: decision_type='reject' && status='active' && stakes∈{high,critical} && confidence>=minConfidence
|
|
180
|
+
* 排序: critical 优先, 再按 confidence desc
|
|
181
|
+
* 静默: 任意步骤失败返回空字符串, 不 throw (主对话不阻塞)
|
|
182
|
+
*/
|
|
183
|
+
export async function injectNegativeGuard(userInput, _ctx = {}, options = {}) {
|
|
184
|
+
const cfg = { ...DEFAULT_NEGATIVE_CONFIG, ...options };
|
|
185
|
+
if (cfg.skip)
|
|
186
|
+
return emptyGateResult('skip');
|
|
187
|
+
if (!userInput || userInput.trim().length === 0)
|
|
188
|
+
return emptyGateResult('no-input');
|
|
189
|
+
try {
|
|
190
|
+
const all = await loadAllJudgments();
|
|
191
|
+
const negatives = all.filter((j) => j.decision_type === 'reject' &&
|
|
192
|
+
(j.status ?? 'active') === 'active' &&
|
|
193
|
+
(j.context?.stakes === 'high' || j.context?.stakes === 'critical') &&
|
|
194
|
+
(j.metadata?.confidence ?? 0.5) >= (cfg.minConfidence ?? 0.7));
|
|
195
|
+
if (negatives.length === 0)
|
|
196
|
+
return emptyGateResult('empty-negatives');
|
|
197
|
+
// 排序: critical > high, 再按 confidence desc
|
|
198
|
+
negatives.sort((a, b) => {
|
|
199
|
+
const sa = a.context?.stakes === 'critical' ? 2 : 1;
|
|
200
|
+
const sb = b.context?.stakes === 'critical' ? 2 : 1;
|
|
201
|
+
const ca = a.metadata?.confidence ?? 0.5;
|
|
202
|
+
const cb = b.metadata?.confidence ?? 0.5;
|
|
203
|
+
return (sb - sa) || (cb - ca);
|
|
204
|
+
});
|
|
205
|
+
const top = negatives.slice(0, cfg.topN);
|
|
206
|
+
const usedIds = top.map((j) => j.id);
|
|
207
|
+
const systemAddition = formatNegativeInjection(top, cfg.maxChars);
|
|
208
|
+
return {
|
|
209
|
+
systemAddition,
|
|
210
|
+
usedIds,
|
|
211
|
+
matchedCount: negatives.length,
|
|
212
|
+
didInject: true,
|
|
213
|
+
skipReason: null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
console.warn('[injection-gate] injectNegativeGuard failed (silent fallback):', err);
|
|
218
|
+
return emptyGateResult('exception');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function formatNegativeInjection(items, maxChars) {
|
|
222
|
+
if (items.length === 0)
|
|
223
|
+
return '';
|
|
224
|
+
const SOURCE_TAG = '<!-- source: injection-gate (negative) -->';
|
|
225
|
+
const lines = items.map((j, i) => {
|
|
226
|
+
const stakes = j.context?.stakes === 'critical' ? ' [关键风险]' : ' [高风险]';
|
|
227
|
+
const decision = (j.decision || '').slice(0, 80);
|
|
228
|
+
return `${i + 1}. 避免: ${decision}${stakes}`;
|
|
229
|
+
});
|
|
230
|
+
let result = `${SOURCE_TAG}\n` +
|
|
231
|
+
`# 避免清单 (负向判断力, 自动注入)\n` +
|
|
232
|
+
`- 以下行为已被明确否决, 执行时主动规避; 如情境冲突在回复中说明\n` +
|
|
233
|
+
`${lines.join('\n')}\n`;
|
|
234
|
+
if (maxChars > 0 && result.length > maxChars) {
|
|
235
|
+
result =
|
|
236
|
+
result.substring(0, maxChars) +
|
|
237
|
+
'\n[System Note: 避免清单因长度截断, 这是背景约束, 不影响用户实际请求.]\n';
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
157
241
|
/**
|
|
158
242
|
* 给定 channelId, 取最近 N 条 usage 记录 (UI 显示用)
|
|
159
243
|
*/
|