ppxans-harness 2.4.0
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/LICENSE +201 -0
- package/README.md +265 -0
- package/bin/ppx-channels.js +3 -0
- package/bin/ppx-serve.js +6 -0
- package/bin/ppx.js +3 -0
- package/config/identity.md +6 -0
- package/config/ishiki.md +16 -0
- package/config/ppx.json +151 -0
- package/package.json +69 -0
- package/src/agent/context.js +179 -0
- package/src/agent/index.js +717 -0
- package/src/agent/prompts.js +107 -0
- package/src/aml-server.js +151 -0
- package/src/ans/eviction.js +144 -0
- package/src/ans/guard.js +120 -0
- package/src/ans/lifecycle.js +93 -0
- package/src/ans/proactive.js +129 -0
- package/src/ans/reward.js +112 -0
- package/src/ans/values.js +15 -0
- package/src/audit/audit-chain.js +167 -0
- package/src/audit/verifier.js +120 -0
- package/src/bus/circuit-breaker.js +115 -0
- package/src/bus/runtime-bus.js +94 -0
- package/src/channels/base.js +35 -0
- package/src/channels/feishu.js +127 -0
- package/src/channels/http.js +592 -0
- package/src/channels/index.js +110 -0
- package/src/channels/log.js +29 -0
- package/src/channels/wechat-crypto.js +74 -0
- package/src/channels/wechat.js +197 -0
- package/src/channels-cli.js +124 -0
- package/src/cli.js +120 -0
- package/src/config/channels.js +170 -0
- package/src/config/index.js +224 -0
- package/src/config/providers.js +189 -0
- package/src/config/settings.js +182 -0
- package/src/core/policy.js +272 -0
- package/src/core/trace.js +89 -0
- package/src/evolve/playbook.js +194 -0
- package/src/llm/client.js +446 -0
- package/src/llm/dsml.js +74 -0
- package/src/llm/embedder.js +35 -0
- package/src/llm/fence.js +105 -0
- package/src/llm/index.js +4 -0
- package/src/llm/retry.js +73 -0
- package/src/llm/router.js +98 -0
- package/src/mcp/client.js +375 -0
- package/src/mcp/index.js +116 -0
- package/src/memory/asset-hub.js +131 -0
- package/src/memory/canvas.js +131 -0
- package/src/memory/compaction.js +28 -0
- package/src/memory/experience.js +122 -0
- package/src/memory/fact-store.js +699 -0
- package/src/memory/failure-episode.js +99 -0
- package/src/memory/fork.js +83 -0
- package/src/memory/index.js +7 -0
- package/src/memory/l0.js +52 -0
- package/src/memory/l2.js +131 -0
- package/src/memory/l3.js +112 -0
- package/src/memory/memory-ticker.js +240 -0
- package/src/memory/session.js +398 -0
- package/src/mode/blackboard.js +49 -0
- package/src/mode/graph.js +41 -0
- package/src/mode/index.js +64 -0
- package/src/mode/legion.js +51 -0
- package/src/mode/plan-exec.js +50 -0
- package/src/mode/router.js +40 -0
- package/src/orchestrator/agent-worker.js +70 -0
- package/src/orchestrator/dag.js +83 -0
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/legion.js +188 -0
- package/src/orchestrator/supervisor.js +177 -0
- package/src/persona/index.js +29 -0
- package/src/plugin/builtin.js +212 -0
- package/src/plugin/context.js +79 -0
- package/src/plugin/index.js +62 -0
- package/src/seam/registry.js +98 -0
- package/src/seam/shell.js +55 -0
- package/src/selfheal/evolve.js +68 -0
- package/src/selfheal/healer.js +167 -0
- package/src/selfheal/run.js +9 -0
- package/src/server.js +60 -0
- package/src/services/learning-service.js +177 -0
- package/src/services/memory-health.js +99 -0
- package/src/services/memory-service.js +160 -0
- package/src/skills/loader.js +150 -0
- package/src/skills/verify.js +100 -0
- package/src/tools/advanced.js +353 -0
- package/src/tools/builtin.js +298 -0
- package/src/tools/catalog.js +159 -0
- package/src/tools/command-guard.js +112 -0
- package/src/tools/custom.js +47 -0
- package/src/tools/delegate.js +297 -0
- package/src/tools/document.js +253 -0
- package/src/tools/governance.js +260 -0
- package/src/tools/index.js +11 -0
- package/src/tools/methods.js +178 -0
- package/src/tools/ocr.js +59 -0
- package/src/tools/seam.js +125 -0
- package/src/tools/selfmod.js +176 -0
- package/src/utils/logger.js +17 -0
- package/src/utils/pii.js +42 -0
- package/src/utils/store.js +108 -0
- package/src/utils/text.js +16 -0
- package/src/utils/trace.js +153 -0
- package/src/utils/winutf8.js +15 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/config/settings.js - 通用设置读写 (HTTP API 后端)
|
|
2
|
+
// 覆盖 config/ppx.json 顶层字段: user / channels.http / security / agent 预设 (values/system_extra/citation_rule/mode)
|
|
3
|
+
// 设计 (与 providers.js 对齐):
|
|
4
|
+
// - 唯一事实源 = config/ppx.json
|
|
5
|
+
// - 写盘: 备份原文件 → 原子写 (.tmp + rename) → 防配置丢失
|
|
6
|
+
// - 读取: 深度合并默认值 (loadConfig), 保证前端总能拿到完整结构
|
|
7
|
+
// - 热重载: 写盘后由调用方调 agent.reload() 重建内存
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { loadConfig, DEFAULT_CONFIG } from "./index.js";
|
|
11
|
+
import { withFileLock } from "../utils/store.js";
|
|
12
|
+
import { warn } from "../utils/logger.js";
|
|
13
|
+
|
|
14
|
+
function getConfigPath(root) {
|
|
15
|
+
return path.join(root, "config", "ppx.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 读取当前 config (磁盘原文), 缺文件返回空
|
|
19
|
+
export function readConfigRaw(root) {
|
|
20
|
+
const p = getConfigPath(root);
|
|
21
|
+
if (!fs.existsSync(p)) return {};
|
|
22
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch (e) { warn("config/ppx.json 读取失败:", e.message); return {}; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 写盘: 先备份 (保留最近 3 个), 再原子写 (tmp + rename)
|
|
26
|
+
function writeConfigAtomic(root, cfg) {
|
|
27
|
+
const p = getConfigPath(root);
|
|
28
|
+
if (fs.existsSync(p)) {
|
|
29
|
+
const bak = p + ".bak-" + new Date().toISOString().replace(/[:.]/g, "-");
|
|
30
|
+
try { fs.copyFileSync(p, bak); } catch (e) { warn("备份失败:", e.message); }
|
|
31
|
+
try {
|
|
32
|
+
const dir = path.dirname(p);
|
|
33
|
+
const base = path.basename(p);
|
|
34
|
+
const baks = fs.readdirSync(dir)
|
|
35
|
+
.filter((f) => f.startsWith(base + ".bak-"))
|
|
36
|
+
.map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs }));
|
|
37
|
+
baks.sort((a, b) => b.t - a.t);
|
|
38
|
+
for (const old of baks.slice(3)) {
|
|
39
|
+
try { fs.unlinkSync(path.join(dir, old.f)); } catch {}
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
const tmp = p + ".tmp";
|
|
44
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf8");
|
|
45
|
+
fs.renameSync(tmp, p);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 通用设置字段白名单 (GET 返回 + PUT 可改的顶层键)
|
|
49
|
+
// 每个键对应 config 里的一个可编辑分区, 值里的字段再细分
|
|
50
|
+
export const SETTINGS_FIELDS = {
|
|
51
|
+
user: ["name"],
|
|
52
|
+
http: ["port", "auth_token"],
|
|
53
|
+
security: ["allow_all", "command_timeout_ms", "code_act"],
|
|
54
|
+
agent: ["name", "mode", "citation_rule", "system_extra", "values"],
|
|
55
|
+
mcp: ["servers", "auto_connect"],
|
|
56
|
+
tools: ["disabled"],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// MCP 服务器字段白名单 (防注入任意字段)
|
|
60
|
+
export const MCP_SERVER_KEYS = ["command", "args", "env", "prefix", "url", "headers", "timeout", "name"];
|
|
61
|
+
|
|
62
|
+
// 安全视图: 抹掉敏感明文 (auth_token / api_key / mcp headers), 只暴露 set 标志
|
|
63
|
+
function sanitizeSettings(cfg) {
|
|
64
|
+
const out = {
|
|
65
|
+
user: { name: cfg.user?.name || "兄弟" },
|
|
66
|
+
http: {
|
|
67
|
+
port: cfg.channels?.http?.port ?? 8899,
|
|
68
|
+
auth_token_set: !!(cfg.channels?.http?.auth_token),
|
|
69
|
+
},
|
|
70
|
+
security: {
|
|
71
|
+
allow_all: !!cfg.security?.allow_all,
|
|
72
|
+
command_timeout_ms: cfg.security?.command_timeout_ms ?? 30000,
|
|
73
|
+
code_act: !!cfg.security?.code_act,
|
|
74
|
+
},
|
|
75
|
+
agent: {
|
|
76
|
+
name: cfg.agent?.name || "皮皮虾",
|
|
77
|
+
mode: cfg.agent?.mode || "react",
|
|
78
|
+
citation_rule: cfg.agent?.citation_rule ?? "",
|
|
79
|
+
system_extra: cfg.agent?.system_extra ?? "",
|
|
80
|
+
values: Array.isArray(cfg.agent?.values) ? cfg.agent.values : (DEFAULT_CONFIG.agent?.values || []),
|
|
81
|
+
},
|
|
82
|
+
mcp: {
|
|
83
|
+
auto_connect: !!cfg.mcp?.auto_connect,
|
|
84
|
+
servers: Array.isArray(cfg.mcp?.servers) ? cfg.mcp.servers.map((s) => sanitizeMcpServer(s)) : [],
|
|
85
|
+
},
|
|
86
|
+
tools: {
|
|
87
|
+
disabled: Array.isArray(cfg.tools?.disabled) ? cfg.tools.disabled : [],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// MCP 服务器安全视图: 只暴露白名单字段, headers 抹掉明文只回 set 标志
|
|
94
|
+
function sanitizeMcpServer(s) {
|
|
95
|
+
if (!s || typeof s !== "object") return {};
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const k of MCP_SERVER_KEYS) {
|
|
98
|
+
if (k === "headers") {
|
|
99
|
+
if (s.headers && typeof s.headers === "object") out.headers_set = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (k === "env") {
|
|
103
|
+
if (s.env && typeof s.env === "object") out.env_set = Object.keys(s.env).length > 0;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (s[k] !== undefined) out[k] = s[k];
|
|
107
|
+
}
|
|
108
|
+
if (!out.name) out.name = s.command || s.url || "";
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// GET /api/settings: 返回可编辑设置的安全视图
|
|
113
|
+
export function getSettings(root) {
|
|
114
|
+
// 用 loadConfig 深合并默认值, 保证缺字段也有结构; 但 write 需基于磁盘原文避免覆盖默认
|
|
115
|
+
const merged = loadConfig(root);
|
|
116
|
+
return sanitizeSettings(merged);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// PUT /api/settings: 只更新白名单字段, 返回更新后的安全视图
|
|
120
|
+
// patch = { user?, http?, security?, agent?, mcp?, tools? }
|
|
121
|
+
// v1.0.8: patch 顶层/分区字段都按 SETTINGS_FIELDS 白名单过滤 (防注入任意字段); 写盘在文件锁内读-改-写 (防并发丢更新)
|
|
122
|
+
export function updateSettings(root, patch) {
|
|
123
|
+
if (!patch || typeof patch !== "object") throw new Error("patch 必须是对象");
|
|
124
|
+
const p = getConfigPath(root);
|
|
125
|
+
return withFileLock(p, () => {
|
|
126
|
+
const cfg = readConfigRaw(root); // 锁内重读: 拿最新磁盘状态再改
|
|
127
|
+
// 只取白名单字段 (SETTINGS_FIELDS 定义每个分区可改的键)
|
|
128
|
+
const pick = (obj, keys) => {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const k of keys) if (obj && typeof obj === "object" && obj[k] !== undefined) out[k] = obj[k];
|
|
131
|
+
return out;
|
|
132
|
+
};
|
|
133
|
+
cfg.user = { ...(cfg.user || {}), ...pick(patch.user, SETTINGS_FIELDS.user) };
|
|
134
|
+
cfg.channels = cfg.channels || {};
|
|
135
|
+
cfg.channels.http = { ...(cfg.channels.http || {}), ...pick(patch.http, SETTINGS_FIELDS.http) };
|
|
136
|
+
cfg.security = { ...(cfg.security || {}), ...pick(patch.security, SETTINGS_FIELDS.security) };
|
|
137
|
+
cfg.agent = { ...(cfg.agent || {}), ...pick(patch.agent, SETTINGS_FIELDS.agent) };
|
|
138
|
+
cfg.mcp = { ...(cfg.mcp || {}), ...pick(patch.mcp, SETTINGS_FIELDS.mcp) };
|
|
139
|
+
cfg.tools = { ...(cfg.tools || {}), ...pick(patch.tools, SETTINGS_FIELDS.tools) };
|
|
140
|
+
|
|
141
|
+
// 校验
|
|
142
|
+
const port = cfg.channels.http.port;
|
|
143
|
+
if (port != null && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error("HTTP 端口必须是 1-65535 的整数");
|
|
144
|
+
const timeout = cfg.security.command_timeout_ms;
|
|
145
|
+
if (timeout != null && (!Number.isFinite(timeout) || timeout < 1000)) throw new Error("命令超时至少 1000ms");
|
|
146
|
+
if (cfg.agent.values != null && !Array.isArray(cfg.agent.values)) throw new Error("核心价值必须是字符串数组");
|
|
147
|
+
if (cfg.agent.values && cfg.agent.values.some((v) => typeof v !== "string")) throw new Error("核心价值必须是字符串数组");
|
|
148
|
+
if (cfg.agent.mode != null && !/^[a-z-]{2,30}$/.test(String(cfg.agent.mode))) throw new Error("编排模式只含小写字母/横线");
|
|
149
|
+
// mcp.servers: 必须是对象数组, 每项至少 command 或 url, 只保留白名单字段
|
|
150
|
+
if (cfg.mcp.servers != null && !Array.isArray(cfg.mcp.servers)) throw new Error("MCP servers 必须是数组");
|
|
151
|
+
if (Array.isArray(cfg.mcp.servers)) {
|
|
152
|
+
const clean = [];
|
|
153
|
+
for (const s of cfg.mcp.servers) {
|
|
154
|
+
if (!s || typeof s !== "object") continue;
|
|
155
|
+
if (!s.command && !s.url) throw new Error("MCP 服务器至少需要 command(stdio) 或 url(http)");
|
|
156
|
+
const norm = {};
|
|
157
|
+
for (const k of MCP_SERVER_KEYS) if (s[k] !== undefined) norm[k] = s[k];
|
|
158
|
+
clean.push(norm);
|
|
159
|
+
}
|
|
160
|
+
cfg.mcp.servers = clean;
|
|
161
|
+
}
|
|
162
|
+
// tools.disabled: 必须是字符串数组
|
|
163
|
+
if (cfg.tools.disabled != null) {
|
|
164
|
+
if (!Array.isArray(cfg.tools.disabled)) throw new Error("tools.disabled 必须是字符串数组");
|
|
165
|
+
if (cfg.tools.disabled.some((t) => typeof t !== "string")) throw new Error("tools.disabled 必须是字符串数组");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
writeConfigAtomic(root, cfg);
|
|
169
|
+
return sanitizeSettings(cfg);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 应用 tools.disabled: 返回需要禁用的工具名列表 (供启动时/热重载时禁用)
|
|
174
|
+
export function disabledTools(root) {
|
|
175
|
+
const { tools } = getSettings(root);
|
|
176
|
+
return Array.isArray(tools?.disabled) ? tools.disabled : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 可编辑字段元数据 (供前端渲染表单, 免前端硬编码结构)
|
|
180
|
+
export function settingsSchema() {
|
|
181
|
+
return SETTINGS_FIELDS;
|
|
182
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// src/core/policy.js - 工具循环执行策略 (纯逻辑, 不依赖 agent 实例)
|
|
2
|
+
// 重构第一刀 (2026-09-14): 从 src/agent/index.js PPXAgent._llmWithTools 抽离。
|
|
3
|
+
// 抽走: 探索熔断 / 重复命令检测 / 溢出降档 / 错误重试 / 轮次上限 / 工具结果裁剪。
|
|
4
|
+
// 原则: 策略与执行分离 — agent 只负责"调 LLM、跑工具、传消息",
|
|
5
|
+
// 循环何时停、降档、重试、注入方向盘, 全由本模块决策。
|
|
6
|
+
// 依赖全部注入 (llm/tools/runTool/shrinkMessages/...), 无 agent 引用, 可独立测试。
|
|
7
|
+
import { TOOL_ERROR_PREFIX } from "../tools/index.js";
|
|
8
|
+
import { warn } from "../utils/logger.js";
|
|
9
|
+
|
|
10
|
+
// ---- 阈值默认值 (config.agent.* 可覆盖) ----
|
|
11
|
+
export const DEFAULT_MAX_TOOL_ROUNDS = 8;
|
|
12
|
+
export const DEFAULT_TOOL_RESULT_BUDGET = 4000; // L4 toolResultBudget: 工具结果超过此长度裁剪, 防撑爆上下文
|
|
13
|
+
export const DEFAULT_MAX_TOOL_ERROR_RETRY = 2;
|
|
14
|
+
export const DEFAULT_OVERFLOW_SHRINK_MAX = 2;
|
|
15
|
+
|
|
16
|
+
// P0③ harness 融断: 探索连击 / 重复命令 阈值 (config.agent.explore_break_limit / repeat_flag_limit 可调)
|
|
17
|
+
export const DEFAULT_EXPLORE_BREAK = 3; // 连续 3 轮只有只读/查询无产出 -> 融断
|
|
18
|
+
export const DEFAULT_REPEAT_FLAG = 2; // 同一工具+args 命中 2 次 -> 警告重复
|
|
19
|
+
|
|
20
|
+
// 探索类工具集 (read-only/发现; 不算"产出或修改")
|
|
21
|
+
export const EXPLORE_TOOLS = new Set([
|
|
22
|
+
"read_file", "list_dir", "web_search", "fetch_page", "memory_search", "read_document",
|
|
23
|
+
"get_time", "read_image", "ocr_image", "list_schedules", "list_capabilities", "replay_session",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// 判断是否为「上下文溢出」错误 (常见信号: 消息含 context/length/token/window, 或 HTTP 400/413)
|
|
27
|
+
// 注意: AbortError(用户取消/内部超时中止) 一律不算溢出, 沿用 retry.js 不重试约定。
|
|
28
|
+
export function isOverflowError(e) {
|
|
29
|
+
if (!e) return false;
|
|
30
|
+
if (e.name === "AbortError" || e.code === "ABORT_ERR") return false;
|
|
31
|
+
const status = (typeof e.status === "number" ? e.status : e.statusCode) ?? null;
|
|
32
|
+
if (status === 413) return true; // 请求体过大 (content too large)
|
|
33
|
+
if (status !== null && status !== 400 && (status >= 500 || status < 400)) return false; // 服务端/非 4xx 非溢出
|
|
34
|
+
const msg = String(e?.message || e || "");
|
|
35
|
+
// 仅在消息出现上下文/长度/token 相关措辞时判为溢出, 普适 HTTP 400 不误判
|
|
36
|
+
if (status === 400) {
|
|
37
|
+
return /context|token|length|window/i.test(msg);
|
|
38
|
+
}
|
|
39
|
+
return /context\s*(size)?\s*exceeded|maximum\s*context\s*length|too\s*many\s*tokens|context\s*window|token\s*(limit|budget)|exceeds?\s*(the\s*)?(model|context|token)|insufficient\s*context/i.test(msg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// LLM 调用失败的兜底提示: 附排查指引, 避免裸抛错误对用户不友好
|
|
43
|
+
export function LLM_FAILED_HINT(message) {
|
|
44
|
+
return `[皮皮虾] LLM 调用失败: ${message}
|
|
45
|
+
排查指引: 1) 检查 config/ppx.json 的 providers 是否配置了可用的 API key (export XXX_API_KEY=...); 2) 本地模型 (lmstudio) 是否在运行; 3) 启动 ppx-serve 看日志确认模型加载。`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// L4 toolResultBudget: 裁剪超长工具结果, 保留头尾关键信息 (默认 4000, config.agent.tool_result_budget 可调)
|
|
49
|
+
export function trimToolResult(r, budget = DEFAULT_TOOL_RESULT_BUDGET) {
|
|
50
|
+
const s = String(r || "");
|
|
51
|
+
if (s.length <= budget) return s;
|
|
52
|
+
const head = s.slice(0, budget * 0.7);
|
|
53
|
+
const tail = s.slice(-budget * 0.3);
|
|
54
|
+
return head + `\n...[结果已裁剪: 共 ${s.length} 字符, 保留头尾 ${budget}]...\n` + tail;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 工具结果 → OpenAI 消息 content: 图片 data URL 转 image_url 块 (多模态), 否则文本裁剪
|
|
58
|
+
export function toToolContent(result, budget = DEFAULT_TOOL_RESULT_BUDGET) {
|
|
59
|
+
const s = String(result || "");
|
|
60
|
+
if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(s)) {
|
|
61
|
+
return [{ type: "image_url", image_url: { url: s } }];
|
|
62
|
+
}
|
|
63
|
+
return trimToolResult(s, budget);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- 超时检测与重试 (v1.6.0 第四刀: 首个功能增量, 非等价重构) ----
|
|
67
|
+
// 语义: 工具层 (seam.js runWithPolicy) 已用 AbortController 真中断底层执行 (资源超时),
|
|
68
|
+
// 这里负责策略层: 超时结果识别 + 幂等工具重试一次 + tool.timeout 事件采集。
|
|
69
|
+
// 边界 (最小版本): 不搞退避/熔断/自适应预算 — 留到有真实超时数据后 (第五刀) 再设计。
|
|
70
|
+
// 返回: { result, elapsedMs, timedOut, retried }
|
|
71
|
+
export function isTimeoutResult(r) {
|
|
72
|
+
return typeof r === "string" && r.startsWith(TOOL_ERROR_PREFIX) && r.includes("超时");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function callWithTimeoutRetry({
|
|
76
|
+
name, args, runTool,
|
|
77
|
+
isIdempotent = true, // 幂等工具才自动重试 (避免非幂等工具副作用二次执行)
|
|
78
|
+
budgetMs = null, // 工具超时预算 (toolTimeoutOf 注入, 事件采集用)
|
|
79
|
+
onEvent = null,
|
|
80
|
+
}) {
|
|
81
|
+
const ev = (type, payload) => { if (onEvent) { try { onEvent(type, payload); } catch {} } };
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
let result = await runTool(name, args);
|
|
84
|
+
let elapsedMs = Date.now() - t0;
|
|
85
|
+
if (!isTimeoutResult(result)) return { result, elapsedMs, timedOut: false, retried: false };
|
|
86
|
+
// 超时: 非幂等不重试 (副作用安全边界), 直接返回结构化错误
|
|
87
|
+
if (!isIdempotent) {
|
|
88
|
+
ev("tool/timeout", { tool: name, elapsedMs, budgetMs, retried: false, skippedRetry: true });
|
|
89
|
+
return { result, elapsedMs, timedOut: true, retried: false };
|
|
90
|
+
}
|
|
91
|
+
// 幂等: 重试一次
|
|
92
|
+
ev("tool/timeout", { tool: name, elapsedMs, budgetMs, retried: false });
|
|
93
|
+
const t1 = Date.now();
|
|
94
|
+
result = await runTool(name, args);
|
|
95
|
+
elapsedMs = Date.now() - t1;
|
|
96
|
+
if (isTimeoutResult(result)) {
|
|
97
|
+
ev("tool/timeout", { tool: name, elapsedMs, budgetMs, retried: true, gaveUp: true });
|
|
98
|
+
return { result, elapsedMs, timedOut: true, retried: true };
|
|
99
|
+
}
|
|
100
|
+
return { result, elapsedMs, timedOut: false, retried: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- 工具循环策略状态机 ----
|
|
104
|
+
// 每轮工具循环的决策都收敛到这里: 阈值从 config 读, 状态在实例内, 判定是纯方法。
|
|
105
|
+
// 换策略 = 换这个类, 不动 agent 主循环。
|
|
106
|
+
export class ToolLoopPolicy {
|
|
107
|
+
constructor(cfg = {}) {
|
|
108
|
+
const c = cfg || {};
|
|
109
|
+
this.maxRounds = Number(c.max_tool_rounds) || DEFAULT_MAX_TOOL_ROUNDS;
|
|
110
|
+
this.resultBudget = Number(c.tool_result_budget) || DEFAULT_TOOL_RESULT_BUDGET;
|
|
111
|
+
this.maxErrorRetry = Number(c.max_tool_error_retry) || DEFAULT_MAX_TOOL_ERROR_RETRY;
|
|
112
|
+
this.exploreBreak = Number(c.explore_break_limit) || DEFAULT_EXPLORE_BREAK;
|
|
113
|
+
this.repeatFlag = Number(c.repeat_flag_limit) || DEFAULT_REPEAT_FLAG;
|
|
114
|
+
this.overflowShrinkMax = DEFAULT_OVERFLOW_SHRINK_MAX;
|
|
115
|
+
// 运行时状态 (每轮循环实例持有, 重启归零)
|
|
116
|
+
this.errorRetries = 0;
|
|
117
|
+
this.exploreStreak = 0;
|
|
118
|
+
this.seenSig = new Map();
|
|
119
|
+
this.overflowShrinks = 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 溢出判定: 是否该降档裁剪后重试 (未超降档次数上限 && 确实是溢出错误)
|
|
123
|
+
shouldShrinkOverflow(e) {
|
|
124
|
+
return this.overflowShrinks < this.overflowShrinkMax && isOverflowError(e);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 溢出降档: 计数 +1, 返回更紧的历史预算 (逐档缩紧, 下限 200)
|
|
128
|
+
nextOverflowCap(histTokenCap) {
|
|
129
|
+
this.overflowShrinks++;
|
|
130
|
+
return Math.max(200, Math.floor(histTokenCap / (this.overflowShrinks + 1)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 记录本轮工具调用, 返回需要注入模型的方向盘消息 (无则 null)
|
|
134
|
+
// 两种熔断: 连续探索无产出 / 重复执行相同工具+参数
|
|
135
|
+
recordTurn(toolCalls) {
|
|
136
|
+
const called = (toolCalls || []).filter((tc) => tc.type === "function" && tc.function);
|
|
137
|
+
if (!called.length) return null;
|
|
138
|
+
let allExplore = true;
|
|
139
|
+
for (const tc of called) {
|
|
140
|
+
if (!EXPLORE_TOOLS.has(tc.function?.name || "")) { allExplore = false; break; }
|
|
141
|
+
}
|
|
142
|
+
let repeatHit = false;
|
|
143
|
+
for (const tc of called) {
|
|
144
|
+
let a = {};
|
|
145
|
+
try { a = JSON.parse(tc.function.arguments || "{}"); } catch {}
|
|
146
|
+
const sig = (tc.function?.name || "") + "::" + JSON.stringify(a).slice(0, 120);
|
|
147
|
+
this.seenSig.set(sig, (this.seenSig.get(sig) || 0) + 1);
|
|
148
|
+
if (this.seenSig.get(sig) >= this.repeatFlag) repeatHit = true;
|
|
149
|
+
}
|
|
150
|
+
if (allExplore) this.exploreStreak++; else this.exploreStreak = 0;
|
|
151
|
+
if (repeatHit) { this.exploreStreak = 0; this.seenSig.clear(); }
|
|
152
|
+
if (allExplore && this.exploreStreak >= this.exploreBreak) {
|
|
153
|
+
this.exploreStreak = 0; this.seenSig.clear();
|
|
154
|
+
return "检测到连续探索循环: 连续 " + this.exploreBreak + " 轮只有只读/查询工具, 未产生任何产出或修改。请停止继续探测, 基于已获得的信息直接给出结论或交付物; 若确实缺少关键信息, 明确说明并结束本轮, 不要空转。";
|
|
155
|
+
}
|
|
156
|
+
if (repeatHit) {
|
|
157
|
+
return "检测到重复执行相同工具与参数。请不要再重复该调用, 换一条不同路径推进, 或直接基于现有信息产出结论。";
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 工具错误: 是否该把错误喂回模型修正重试 (未超重试上限)
|
|
163
|
+
shouldRetryErrors(errors) {
|
|
164
|
+
if (!errors || !errors.length) return false;
|
|
165
|
+
if (this.errorRetries >= this.maxErrorRetry) return false;
|
|
166
|
+
this.errorRetries++;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- 工具循环主驱动 (原 PPXAgent._llmWithTools) ----
|
|
172
|
+
// 依赖全部注入, 不持有 agent 引用:
|
|
173
|
+
// seedMessages 初始消息数组 (system + history + user)
|
|
174
|
+
// llm LLM 客户端 (apiChat)
|
|
175
|
+
// tools OpenAI 格式工具声明数组 ([] = 禁用工具)
|
|
176
|
+
// config 完整配置 (读 config.agent.* 阈值)
|
|
177
|
+
// isInterrupted () => boolean, 中断信号
|
|
178
|
+
// onStep (ev) => void, 推理轮次事件
|
|
179
|
+
// runTool (name, args) => Promise<string>, 工具执行 (trace/事件由调用方负责)
|
|
180
|
+
// shrinkMessages (messages, budget) => messages, 溢出降档裁剪 (agent 上下文管理职责)
|
|
181
|
+
// histTokenCap () => number, 当前历史 token 预算上限
|
|
182
|
+
// onEvent (type, payload) => void, 可选策略事件回调 (工具失败路径: 溢出降档/熔断/错误重试/超时), 供 trace 埋点
|
|
183
|
+
// isIdempotentTool (name) => boolean, 工具是否幂等可安全重试 (默认全 true)
|
|
184
|
+
// toolTimeoutOf (name) => number|null, 工具超时预算 (事件采集用, 默认 null)
|
|
185
|
+
export async function runToolLoop({
|
|
186
|
+
seedMessages,
|
|
187
|
+
llm,
|
|
188
|
+
tools,
|
|
189
|
+
config = {},
|
|
190
|
+
isInterrupted = () => false,
|
|
191
|
+
onStep = null,
|
|
192
|
+
onEvent = null,
|
|
193
|
+
isIdempotentTool = () => true,
|
|
194
|
+
toolTimeoutOf = () => null,
|
|
195
|
+
runTool,
|
|
196
|
+
shrinkMessages,
|
|
197
|
+
histTokenCap = () => 8192,
|
|
198
|
+
}) {
|
|
199
|
+
const policy = new ToolLoopPolicy(config.agent || config);
|
|
200
|
+
let messages = [...seedMessages];
|
|
201
|
+
const ev = (type, payload) => { if (onEvent) { try { onEvent(type, payload); } catch {} } };
|
|
202
|
+
|
|
203
|
+
for (let round = 0; round < policy.maxRounds; round++) {
|
|
204
|
+
if (isInterrupted()) return "[皮皮虾] 任务已被中断 (operator cancelled).";
|
|
205
|
+
if (onStep) { try { onStep({ type: "step", round, maxRounds: policy.maxRounds, ts: Date.now() }); } catch {} }
|
|
206
|
+
|
|
207
|
+
let resp;
|
|
208
|
+
try {
|
|
209
|
+
resp = await llm.apiChat(messages, {
|
|
210
|
+
tools,
|
|
211
|
+
toolRunner: async (name, args) => runTool(name, args),
|
|
212
|
+
});
|
|
213
|
+
} catch (e) {
|
|
214
|
+
// 上下文溢出: 降档裁剪历史后重发 (不影响其它错误路径 — 非溢出照常抛出,
|
|
215
|
+
// 交由上层 _llmWithFallback 切换 provider / 调用方处理)
|
|
216
|
+
if (policy.shouldShrinkOverflow(e)) {
|
|
217
|
+
const cap = policy.nextOverflowCap(histTokenCap());
|
|
218
|
+
ev("tool/overflow", { round, shrink: policy.overflowShrinks, max: policy.overflowShrinkMax });
|
|
219
|
+
warn(`上下文溢出, 降档裁剪后重试 (${policy.overflowShrinks}/${policy.overflowShrinkMax}): ${String(e?.message || e).slice(0, 120)}`);
|
|
220
|
+
messages = shrinkMessages(messages, cap);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const msg = resp.message;
|
|
227
|
+
messages.push(msg);
|
|
228
|
+
|
|
229
|
+
const toolCalls = msg.tool_calls;
|
|
230
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
231
|
+
return msg.content || "[皮皮虾] (无回复)";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 工具错误重试: 若本轮有工具失败, 汇总错误喂回模型修正后重试 (最多 maxErrorRetry 次)
|
|
235
|
+
const errors = [];
|
|
236
|
+
for (const tc of toolCalls) {
|
|
237
|
+
if (tc.type === "function" && tc.function) {
|
|
238
|
+
let args = {};
|
|
239
|
+
try { args = JSON.parse(tc.function.arguments || "{}"); } catch {}
|
|
240
|
+
// v1.6.0 第四刀: 超时检测 + 幂等重试一次 (tool/timeout 事件采集 P50/P95/P99 数据基础)
|
|
241
|
+
const { result } = await callWithTimeoutRetry({
|
|
242
|
+
name: tc.function.name,
|
|
243
|
+
args,
|
|
244
|
+
runTool,
|
|
245
|
+
isIdempotent: isIdempotentTool(tc.function.name),
|
|
246
|
+
budgetMs: toolTimeoutOf(tc.function.name),
|
|
247
|
+
onEvent,
|
|
248
|
+
});
|
|
249
|
+
messages.push({ role: "tool", tool_call_id: tc.id, content: toToolContent(result, policy.resultBudget) });
|
|
250
|
+
if (result.startsWith(TOOL_ERROR_PREFIX)) errors.push(result);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (policy.shouldRetryErrors(errors)) {
|
|
254
|
+
ev("tool/error_retry", { round, errors: errors.length, retries: policy.errorRetries, max: policy.maxErrorRetry });
|
|
255
|
+
messages.push({
|
|
256
|
+
role: "user",
|
|
257
|
+
content: "以下工具调用失败, 请修正参数或改用其他方式后重试:\n" + errors.join("\n"),
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// P0③ harness 融断: 探索循环 / 重复命令 (无产出的自转) → 注入方向盘给模型
|
|
263
|
+
const steer = policy.recordTurn(toolCalls);
|
|
264
|
+
if (steer) {
|
|
265
|
+
if (steer.includes("连续探索循环")) ev("tool/explore_break", { round });
|
|
266
|
+
else ev("tool/repeat_warn", { round });
|
|
267
|
+
messages.push({ role: "user", content: steer });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return "[皮皮虾] 工具调用轮次过多, 已停止。";
|
|
272
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/core/trace.js - 结构化事件流 (重构第三刀, 2026-09-14)
|
|
2
|
+
// 目的: 给关键路径 (记忆升降级/工具失败/Agent spawn/自愈触发) 提供 traceId 贯穿的事件流,
|
|
3
|
+
// 作为后续「记忆+学习服务化」重构的行为等价验证基础设施。
|
|
4
|
+
// 设计:
|
|
5
|
+
// - AsyncLocalStorage (node:async_hooks 原生, 零依赖) 贯穿 traceId: 一次 chat/chatStream
|
|
6
|
+
// 入口生成 traceId, 所有深层异步调用 (记忆提炼/压缩/检索/自愈/spawn) 自动继承, 无需手动传参。
|
|
7
|
+
// - EventTracer 写 data/logs/traces/events-YYYY-MM-DD.jsonl (与工具轨迹 traces/ 同目录, 独立文件),
|
|
8
|
+
// 每条事件带 traceId/sessionId/seq/ts/type/payload, payload 落盘前 PII 脱敏。
|
|
9
|
+
// - span() 包装子操作自动记录耗时, 失败自动带 error。
|
|
10
|
+
// 与 src/utils/trace.js (工具调用专用轨迹) 互补: 工具轨迹保留原结构不动, 事件流只做横切补充。
|
|
11
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { ensureDir, logicalDay } from "../utils/store.js";
|
|
15
|
+
import { scrubPII } from "../utils/pii.js";
|
|
16
|
+
|
|
17
|
+
const als = new AsyncLocalStorage();
|
|
18
|
+
const MAX_PAYLOAD = 2000; // 单条事件载荷上限, 防爆文件
|
|
19
|
+
|
|
20
|
+
export function genTraceId() {
|
|
21
|
+
return "t_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 入口包装: 生成新 traceId, 所有异步子调用自动继承
|
|
25
|
+
// meta 可带 { sessionKey, channel, userMsg } 等上下文, 存于 ALS store
|
|
26
|
+
export function runWithTrace(fn, meta = {}) {
|
|
27
|
+
const store = { traceId: genTraceId(), ...meta, t0: Date.now() };
|
|
28
|
+
return als.run(store, fn);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 任意深层调用取当前 traceId (无入口上下文时返回 null, 事件照记不丢)
|
|
32
|
+
export function currentTrace() {
|
|
33
|
+
return als.getStore() || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 当前 trace 是否存在 (测试用)
|
|
37
|
+
export function hasTrace() {
|
|
38
|
+
return !!als.getStore();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- 事件流写入器 (agent 构造时实例化一个, 全局复用) ----
|
|
42
|
+
export class EventTracer {
|
|
43
|
+
constructor(dataDir) {
|
|
44
|
+
this.dir = path.join(dataDir, "logs", "traces");
|
|
45
|
+
ensureDir(this.dir);
|
|
46
|
+
this.sessionId = "s_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
47
|
+
this.count = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_file(day = logicalDay()) { return path.join(this.dir, `events-${day}.jsonl`); }
|
|
51
|
+
|
|
52
|
+
// 记录一条结构化事件。payload 对象落盘前序列化 + PII 脱敏。
|
|
53
|
+
// 自动附加: ts/sessionId/traceId(ALS 继承)/seq/type/durationMs(可选)
|
|
54
|
+
event(type, payload = {}, opts = {}) {
|
|
55
|
+
const store = als.getStore();
|
|
56
|
+
this.count += 1;
|
|
57
|
+
let safe = {};
|
|
58
|
+
try { safe = JSON.parse(scrubPII(JSON.stringify(payload ?? {})).cleaned); } catch { safe = { _raw: "(unserializable)" }; }
|
|
59
|
+
const entry = {
|
|
60
|
+
ts: new Date().toISOString(),
|
|
61
|
+
sessionId: this.sessionId,
|
|
62
|
+
traceId: store?.traceId || null,
|
|
63
|
+
seq: this.count,
|
|
64
|
+
type,
|
|
65
|
+
...safe,
|
|
66
|
+
};
|
|
67
|
+
if (opts.durationMs != null) entry.durationMs = Math.round(opts.durationMs);
|
|
68
|
+
if (opts.error != null) entry.error = String(opts.error).slice(0, 500);
|
|
69
|
+
const line = JSON.stringify(entry);
|
|
70
|
+
try {
|
|
71
|
+
fs.appendFileSync(this._file(), (line.length > MAX_PAYLOAD * 4 ? line.slice(0, MAX_PAYLOAD * 4) + "…" : line) + "\n", "utf8");
|
|
72
|
+
} catch (e) { /* 事件写入失败不影响主流程 (可观测性降级不阻塞 agent) */ }
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 包装子操作: 自动记录 type 事件 + 耗时, fn 抛错时事件带 error 并重抛
|
|
77
|
+
// 用法: await tracer.span("memory/extract", async () => {...})
|
|
78
|
+
async span(type, fn, payload = {}) {
|
|
79
|
+
const t0 = Date.now();
|
|
80
|
+
try {
|
|
81
|
+
const r = await fn();
|
|
82
|
+
this.event(type, { ...payload, ok: true }, { durationMs: Date.now() - t0 });
|
|
83
|
+
return r;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
this.event(type, { ...payload, ok: false }, { durationMs: Date.now() - t0, error: e?.message || String(e) });
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|