#!/usr/bin/env node
// observed-agent.mjs —— 给 harness 装上观测层(结构化日志 + trace 树 + 指标汇总)
// 跑法:node observed-agent.mjs --version v-good|v-bug|v-fixed
// 零依赖,node 裸跑。模型调用由固定响应队列的桩 client 驱动,三个版本的差异全部在下面的 VERSIONS 表里。
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
const MODEL = "claude-sonnet-4-5";
const MAX_TOKENS = 4096;
const MAX_ROUNDS = 12; // 循环上限:超了就当作跑飞,非零退出
const HEAD_CHARS = 60; // 日志里每段内容最多留多少字符的摘要;设成 0 就一个字都不记
// ============ 1. 被测任务:从 data/ 读几份销售 CSV,汇总各区合计,写出 summary.md ============
const CSV_FILES = {
"2026-q1-east.csv":
"region,month,amount\n华东,2026-01,182400\n华东,2026-02,161250\n华东,2026-03,204900\n",
"2026-q1-south.csv":
"region,month,amount\n华南,2026-01,97300\n华南,2026-02,88600\n华南,2026-03,120450\n",
"2026-q1-north.csv":
"region,month,amount\n华北,2026-01,143000\n华北,2026-02,150700\n华北,2026-03,138900\n",
};
function setupWorkspace(root) {
fs.rmSync(root, { recursive: true, force: true });
fs.mkdirSync(path.join(root, "data"), { recursive: true });
for (const [name, body] of Object.entries(CSV_FILES)) {
fs.writeFileSync(path.join(root, "data", name), body);
}
}
// ============ 2. 三个工具(真实读写磁盘,报错也是真的报错) ============
const tools = [
{
name: "list_files",
description: "列出一个目录下的文件名,按字典序返回,每行一个。",
input_schema: {
type: "object",
properties: { dir: { type: "string", description: "相对工作目录的路径,例如 data" } },
required: ["dir"],
},
},
{
name: "read_file",
description: "按路径读取一个文本文件,返回全文。路径必须用 list_files 返回的原样文件名。",
input_schema: {
type: "object",
properties: { path: { type: "string", description: "相对工作目录的文件路径" } },
required: ["path"],
},
},
{
name: "write_file",
description: "把一段文本写到指定路径,覆盖同名文件。",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "相对工作目录的文件路径" },
content: { type: "string", description: "要写入的完整文本" },
},
required: ["path", "content"],
},
},
];
function resolveInRoot(root, p) {
const abs = path.resolve(root, p);
if (abs !== root && !abs.startsWith(root + path.sep)) {
throw new Error(`路径越界,拒绝访问:${p}`);
}
return abs;
}
const impls = {
list_files({ dir }, ctx) {
const abs = resolveInRoot(ctx.root, dir);
return fs.readdirSync(abs).sort().join("\n");
},
read_file({ path: p }, ctx) {
const abs = resolveInRoot(ctx.root, p);
if (fs.existsSync(abs)) return fs.readFileSync(abs, "utf8");
// 同一个「文件不存在」,两套文案。v-fixed 用的是带可执行建议的那套。
if (ctx.errorStyle === "actionable") {
const available = fs
.readdirSync(path.join(ctx.root, "data"))
.sort()
.map((f) => `data/${f}`)
.join("、");
throw new Error(
`找不到文件 ${p}。data/ 目录下现有:${available}。` +
`请用 list_files 返回的原样文件名重试;如果你要的那份数据确实不在里面,` +
`停下来告诉用户缺哪份文件,不要自己估算缺失的数字。`
);
}
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
},
write_file({ path: p, content }, ctx) {
const abs = resolveInRoot(ctx.root, p);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return `已写入 ${p}(${content.length} 字符)`;
},
};
// ============ 3. 桩 client:固定响应队列 ============
const SUMMARY_CORRECT = `# 2026 Q1 各区销售汇总
| 区 | 合计(元) |
| --- | --- |
| 华东 | 548550 |
| 华南 | 306350 |
| 华北 | 432600 |
| 总计 | 1287500 |
数据来源:data/2026-q1-east.csv、data/2026-q1-south.csv、data/2026-q1-north.csv
`;
const SUMMARY_FABRICATED = `# 2026 Q1 各区销售汇总
| 区 | 合计(元) |
| --- | --- |
| 华东 | 548550 |
| 华中 | 208000 |
| 华北 | 432600 |
| 总计 | 1189150 |
数据来源:data/ 目录下的季度销售 CSV
`;
const say = (text) => ({ type: "text", text });
const call = (id, name, input) => ({ type: "tool_use", id, name, input });
const turn = (stop_reason, content, input_tokens, output_tokens) => ({
id: `msg_stub_${crypto.randomBytes(3).toString("hex")}`,
model: MODEL,
stop_reason,
content,
usage: { input_tokens, output_tokens },
});
const READ_EAST = call("toolu_e", "read_file", { path: "data/2026-q1-east.csv" });
const READ_NORTH = call("toolu_n", "read_file", { path: "data/2026-q1-north.csv" });
const READ_SOUTH = call("toolu_s", "read_file", { path: "data/2026-q1-south.csv" });
const READ_TYPO = call("toolu_x", "read_file", { path: "data/2026-q1-sourth.csv" });
const VERSIONS = {
// 顺风局:三份 CSV 都读到了,汇总正确。
"v-good": {
errorStyle: "opaque",
queue: [
turn("tool_use", [say("先看看 data/ 下有哪些文件。"), call("toolu_l", "list_files", { dir: "data" })], 812, 96),
turn("tool_use", [say("三份区域 CSV,我一起读。"), READ_EAST, READ_SOUTH, READ_NORTH], 946, 218),
turn("tool_use", [say("三个区都拿到了,写汇总。"), call("toolu_w", "write_file", { path: "summary.md", content: SUMMARY_CORRECT })], 1584, 342),
turn("end_turn", [say("已写出 summary.md:华东 548550、华南 306350、华北 432600,总计 1287500。")], 1961, 74),
],
},
// 出事版:第二份 CSV 的文件名拼错了,工具报了个晦涩的错,模型没停,编了一个区继续写。
"v-bug": {
errorStyle: "opaque",
queue: [
turn("tool_use", [say("先看看 data/ 下有哪些文件。"), call("toolu_l", "list_files", { dir: "data" })], 812, 96),
turn("tool_use", [say("三份区域 CSV,我一起读。"), READ_EAST, READ_TYPO, READ_NORTH], 946, 218),
turn("tool_use", [say("数据齐了,写汇总。"), call("toolu_w", "write_file", { path: "summary.md", content: SUMMARY_FABRICATED })], 1602, 355),
turn("end_turn", [say("已写出 summary.md:华东 548550、华中 208000、华北 432600,总计 1189150。")], 1990, 81),
],
},
// 修好版:同样拼错,但报错文案换成带可执行建议的,模型改成回去求证而不是编。
"v-fixed": {
errorStyle: "actionable",
queue: [
turn("tool_use", [say("先看看 data/ 下有哪些文件。"), call("toolu_l", "list_files", { dir: "data" })], 812, 96),
turn("tool_use", [say("三份区域 CSV,我一起读。"), READ_EAST, READ_TYPO, READ_NORTH], 946, 218),
turn("tool_use", [say("我把文件名拼成了 sourth,按报错里给的原样文件名重读。"), READ_SOUTH], 1688, 64),
turn("tool_use", [say("三个区都拿到了,写汇总。"), call("toolu_w", "write_file", { path: "summary.md", content: SUMMARY_CORRECT })], 1849, 342),
turn("end_turn", [say("已写出 summary.md:华东 548550、华南 306350、华北 432600,总计 1287500。补充一句:我第一次把文件名拼成了 data/2026-q1-sourth.csv,已按 list_files 的原样名重取。如果 data/ 之外还有别的区的数据,请告诉我文件在哪,我不会自己补数字。")], 2226, 118),
],
},
};
function makeStubClient(queue) {
let i = 0;
return {
messages: {
async create(req) {
if (!req.model || !req.max_tokens) {
throw new Error("桩 client:create 必须带 model 与 max_tokens");
}
if (i >= queue.length) {
const err = new Error(`桩队列耗尽:第 ${i + 1} 次请求没有预设响应`);
err.code = "STUB_QUEUE_EXHAUSTED";
throw err;
}
return queue[i++];
},
},
};
}
// ============ 4. 观测层之一:结构化日志(JSON Lines) ============
const newId = (prefix) => `${prefix}-${crypto.randomBytes(4).toString("hex")}`;
function shapeOf(value) {
if (value === null || value === undefined) return "null";
if (Array.isArray(value)) return `array(${value.length})`;
if (typeof value === "string") return `string(${value.length})`;
if (typeof value === "object") return `object{${Object.keys(value).join(",")}}`;
return typeof value;
}
// 默认只记形状 + 长度 + 前 HEAD_CHARS 个字符的摘要,不记全文。
function summarize(value) {
const text = typeof value === "string" ? value : JSON.stringify(value ?? null);
const out = { shape: shapeOf(value), chars: text.length };
if (HEAD_CHARS > 0) {
const flat = text.replace(/\s+/g, " ").trim();
out.head = flat.length > HEAD_CHARS ? `${flat.slice(0, HEAD_CHARS)}…` : flat;
}
return out;
}
function createLogger(logPath, traceId) {
fs.writeFileSync(logPath, "");
return {
record(fields) {
const line = { ts: new Date().toISOString(), trace_id: traceId, ...fields };
fs.appendFileSync(logPath, `${JSON.stringify(line)}\n`);
},
};
}
// ============ 5. 观测层之二:从 JSONL 重建 trace 树 ============
function buildTree(records) {
const byId = new Map(records.map((r) => [r.span_id, { ...r, children: [] }]));
const roots = [];
for (const node of byId.values()) {
const parent = node.parent_id ? byId.get(node.parent_id) : null;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
function labelOf(n) {
const name = n.name.padEnd(13);
const dur = `${String(n.duration_ms).padStart(3)}ms`;
if (n.kind === "agent_run") return `agent_run ${name}${dur} trace_id=${n.trace_id}`;
if (n.kind === "model_call") {
const t = n.tokens;
return `model_call ${name}${dur} in=${t.input} out=${t.output} stop=${n.stop_reason}`;
}
if (n.kind === "harness_error") return `harness_err ${name}${dur} ${n.error.head ?? n.error.shape}`;
const inHead = clip(n.tool_input.head ?? n.tool_input.shape, 34);
const out = n.error ? `ERROR ${clip(n.error.head ?? n.error.shape, 44)}` : `ok ${n.tool_result.shape}`;
return `tool_call ${name}${dur} in=${inHead} ${out}`;
}
function renderTree(nodes, prefix, lines) {
nodes.forEach((node, idx) => {
const last = idx === nodes.length - 1;
lines.push(prefix === null ? labelOf(node) : `${prefix}${last ? "└─ " : "├─ "}${labelOf(node)}`);
const childPrefix = prefix === null ? "" : `${prefix}${last ? " " : "│ "}`;
renderTree(node.children, childPrefix, lines);
});
return lines;
}
// ============ 6. 观测层之三:指标汇总 ============
function metricsOf(records) {
const model = records.filter((r) => r.kind === "model_call");
const tool = records.filter((r) => r.kind === "tool_call");
const root = records.find((r) => r.kind === "agent_run");
return {
// 这个 harness 里一轮恰好等于一次模型请求,所以 rounds 直接取 model_calls;
// 桩队列耗尽抛 harness_error 时,最后一轮没有对应的 model_call——那种运行里两个数会差 1
rounds: model.length,
model_calls: model.length,
tool_calls: tool.length,
errors: records.filter((r) => r.error).length,
tokens_in: model.reduce((s, r) => s + r.tokens.input, 0),
tokens_out: model.reduce((s, r) => s + r.tokens.output, 0),
wall_ms: root ? root.duration_ms : 0,
};
}
// ============ 7. 被观测的 harness 循环 ============
async function runToolUses(content, ctx) {
const results = [];
for (const block of content) {
if (block.type !== "tool_use") continue;
const spanId = newId("span");
const startedAt = Date.now();
const base = {
span_id: spanId,
parent_id: ctx.parentId,
kind: "tool_call",
name: block.name,
tool_input: summarize(block.input),
};
try {
const impl = impls[block.name];
if (!impl) throw new Error(`未知工具:${block.name}`);
const result = impl(block.input, ctx);
ctx.log.record({ ...base, duration_ms: Date.now() - startedAt, tool_result: summarize(result), error: null });
results.push({ type: "tool_result", tool_use_id: block.id, content: result });
} catch (e) {
ctx.log.record({ ...base, duration_ms: Date.now() - startedAt, tool_result: null, error: summarize(e.message) });
results.push({ type: "tool_result", tool_use_id: block.id, content: e.message, is_error: true });
}
}
return results;
}
async function main() {
const argv = process.argv.slice(2);
const version = argv[argv.indexOf("--version") + 1];
if (!argv.includes("--version") || !VERSIONS[version]) {
console.error("用法:node observed-agent.mjs --version v-good|v-bug|v-fixed");
process.exit(2);
}
const root = path.resolve(process.cwd(), "runs", version);
setupWorkspace(root);
const traceId = newId("tr");
const log = createLogger(path.join(root, "run.log.jsonl"), traceId);
const rootSpan = newId("span");
const runStartedAt = Date.now();
const { queue, errorStyle } = VERSIONS[version];
const client = makeStubClient(queue);
const ctx = { root, errorStyle, log, parentId: rootSpan };
const messages = [
{
role: "user",
content: "把 data/ 目录下的销售 CSV 汇总成各区合计,写到 summary.md。只用文件里真实存在的数据。",
},
];
let rounds = 0;
let exitCode = 0;
const callModel = async () => {
const spanId = newId("span");
const startedAt = Date.now();
rounds += 1;
const response = await client.messages.create({ model: MODEL, max_tokens: MAX_TOKENS, tools, messages });
log.record({
span_id: spanId,
parent_id: rootSpan,
kind: "model_call",
name: `turn-${rounds}`,
duration_ms: Date.now() - startedAt,
stop_reason: response.stop_reason,
tokens: { input: response.usage.input_tokens, output: response.usage.output_tokens },
error: null,
});
ctx.parentId = spanId;
return response;
};
try {
let response = await callModel();
while (response.stop_reason === "tool_use") {
if (rounds >= MAX_ROUNDS) throw new Error(`超过 MAX_ROUNDS=${MAX_ROUNDS},判定为跑飞`);
messages.push({ role: "assistant", content: response.content });
const toolResults = await runToolUses(response.content, ctx);
messages.push({ role: "user", content: toolResults });
response = await callModel();
}
} catch (e) {
log.record({
span_id: newId("span"),
parent_id: rootSpan,
kind: "harness_error",
name: e.code ?? "harness_error",
duration_ms: 0,
error: summarize(e.message),
});
console.error(`harness 中断:${e.message}`);
exitCode = 2;
}
log.record({
span_id: rootSpan,
parent_id: null,
kind: "agent_run",
name: "sales-summary",
duration_ms: Date.now() - runStartedAt,
error: null,
});
// 跑完之后,只从磁盘上的 JSONL 重建视图——内存里那份不算数。
const records = fs
.readFileSync(path.join(root, "run.log.jsonl"), "utf8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
const m = metricsOf(records);
console.log(`\n=== trace 树(${version},从 run.log.jsonl 重建)===`);
console.log(renderTree(buildTree(records), null, []).join("\n"));
console.log(
`\n=== 指标汇总(${version})===\n` +
`rounds=${m.rounds} model_calls=${m.model_calls} tool_calls=${m.tool_calls} ` +
`errors=${m.errors} tokens_in=${m.tokens_in} tokens_out=${m.tokens_out} ` +
`tokens_total=${m.tokens_in + m.tokens_out} wall=${m.wall_ms}ms`
);
console.log(`日志:runs/${version}/run.log.jsonl 产物:runs/${version}/summary.md`);
process.exit(exitCode);
}
main();