docs(spikes): OpenWorkflow and Pi SDK compatibility spikes + ADR-002/003 (W0-B)

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 17:39:35 +08:00
1 parent 8b90d396b2
commit ec023a8405
16 files changed
+2884

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
pi/.sessions/
+15
View File
@@ -0,0 +1,15 @@
# W0-B spikes(独立 npm 包,不是 pnpm workspace 成员)
```bash
cd spikes && npm install
docker run -d --name umt-spike-pg -e POSTGRES_PASSWORD=spike -p 55434:5432 postgres:16-alpine
```
- `openworkflow/`:OpenWorkflow 0.10.1 纵切(提交下载→等信号→复制→登记→保留期 sleep→清理)。
`worker.ts`(env: TAG / WF_VERSION / EXTRA_STEP / PIDFILE)、`cli.ts`(start-acquire / signal / cancel / get / steps / ledger / start-flaky / start-slow / start-burst / burst-dups / tables)。
记录:`../docs/spikes/w0b-openworkflow.md`,结论 `../docs/adr/ADR-002-task-engine.md`。
- `pi/`:pi-agent-core/pi-ai 0.87.1。`fake-run.ts`(脚本化 fake 模型 + 2 个工具 + 事件流)、
`abort-resume.ts abort|crash|resume`(取消 / kill -9 后新进程恢复)、`compaction.ts`、`provider-config.ts`(真实 provider 类型打到本地抓包服务器,不出网)。
记录:`../docs/spikes/w0b-pi.md`,结论 `../docs/adr/ADR-003-pi-integration.md`。
运行:`npx tsx openworkflow/cli.ts tables`、`npx tsx pi/fake-run.ts` 等。
+64
View File
@@ -0,0 +1,64 @@
import { makeClient, initBiz, db } from "./common.ts";
import { sql } from "kysely";
await initBiz();
const { ow, backend, acquire, flaky, slow, burst } = await makeClient();
const [cmd, ...args] = process.argv.slice(2);
const out = (x: unknown) => console.log(JSON.stringify(x, null, 0));
switch (cmd) {
case "start-acquire": {
const [downloadId, key, copyMs, retention] = args;
const h = await acquire.run({ downloadId, copyMs: Number(copyMs ?? 0), retention: retention ?? "3s" },
key ? { idempotencyKey: key } : undefined);
out({ runId: h.workflowRun.id, status: h.workflowRun.status, version: h.workflowRun.version });
break;
}
case "start-flaky": {
const h = await flaky.run({ failTimes: Number(args[0]), maxAttempts: Number(args[1]) });
out({ runId: h.workflowRun.id });
break;
}
case "start-slow": { const h = await slow.run({}); out({ runId: h.workflowRun.id }); break; }
case "start-burst": {
const ids = [];
for (let i = 0; i < Number(args[0]); i++) ids.push((await burst.run({ i })).workflowRun.id);
out({ count: ids.length });
break;
}
case "signal": {
const r = await ow.sendSignal({ signal: `download-done:${args[0]}`, data: { ok: true }, idempotencyKey: args[1] });
out(r);
break;
}
case "cancel": { await ow.cancelWorkflowRun(args[0]); out({ canceled: args[0] }); break; }
case "get": {
const r = await backend.getWorkflowRun({ workflowRunId: args[0] });
out({ status: r?.status, version: r?.version, attempts: r?.attempts, error: r?.error, output: r?.output });
break;
}
case "steps": {
const r = await backend.listStepAttempts({ workflowRunId: args[0], limit: 100 } as any);
for (const s of [...r.data].sort((a,b)=>+a.createdAt-+b.createdAt)) console.log(s.stepName.padEnd(18), s.kind.padEnd(12), s.status, s.error ? JSON.stringify(s.error.message) : "");
break;
}
case "ledger": {
const rows = await db.selectFrom("biz.side_effect_ledger").selectAll()
.$if(!!args[0], (q) => q.where("run_id", "=", args[0])).orderBy("id").execute();
for (const r of rows) console.log(r.run_id.slice(0, 8), r.step.padEnd(16), r.worker.padEnd(6), r.note);
break;
}
case "burst-dups": {
const rows = await sql<any>`SELECT count(*) total, count(DISTINCT run_id) runs,
count(*) FILTER (WHERE worker='A') a, count(*) FILTER (WHERE worker='B') b
FROM biz.side_effect_ledger WHERE step='work'`.execute(db);
out(rows.rows[0]);
break;
}
case "tables": {
const r = await sql<any>`SELECT table_schema, table_name FROM information_schema.tables
WHERE table_schema IN ('openworkflow','biz') ORDER BY 1,2`.execute(db);
for (const t of r.rows) console.log(`${t.table_schema}.${t.table_name}`);
break;
}
}
await backend.stop();
await db.destroy();
+110
View File
@@ -0,0 +1,110 @@
// Shared: DB URLs, OpenWorkflow client, Kysely business DB (schema "biz"), workflow definitions.
import { OpenWorkflow } from "openworkflow";
import { BackendPostgres } from "openworkflow/postgres";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
export const PG_URL = process.env.PG_URL ?? "postgresql://postgres:[email protected]:55434/postgres";
export const WF_VERSION = process.env.WF_VERSION ?? "v1";
export const TAG = process.env.TAG ?? `pid${process.pid}`;
export const db = new Kysely<any>({
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 4 }) }),
});
export async function initBiz() {
await sql`CREATE SCHEMA IF NOT EXISTS biz`.execute(db);
await sql`CREATE TABLE IF NOT EXISTS biz.side_effect_ledger (
id bigserial PRIMARY KEY, run_id text, step text, worker text, pid int, note text,
at timestamptz DEFAULT now())`.execute(db);
}
export async function ledger(runId: string, step: string, note = "") {
await db.insertInto("biz.side_effect_ledger")
.values({ run_id: runId, step, worker: TAG, pid: process.pid, note }).execute();
console.log(`[${TAG}] run=${runId.slice(0, 8)} step=${step} ${note}`);
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function makeClient() {
// connect() runs migrations by default (runMigrations: true), schema default "openworkflow".
const backend = await BackendPostgres.connect(PG_URL, { schema: "openworkflow" });
const ow = new OpenWorkflow({ backend });
// Main vertical slice: submit download -> wait signal -> copy -> register -> retention sleep -> cleanup
const acquire = ow.defineWorkflow(
{ name: "acquire", version: WF_VERSION },
async ({ input, step, run, version }) => {
const { downloadId, copyMs = 0, retention = "3s" } = input as any;
const sub = await step.run({ name: "submit-download" }, async () => {
await ledger(run.id, "submit-download", `version=${version}`);
return { torrentHash: `hash-${downloadId}` };
});
const done = await step.waitForSignal<{ ok: boolean }>({
name: "wait-download", signal: `download-done:${downloadId}`, timeout: "10m",
});
if (!done) throw new Error("download wait timed out");
const copied = await step.run({ name: "copy-file" }, async () => {
await ledger(run.id, "copy-file", "start");
await wait(copyMs);
await ledger(run.id, "copy-file", "end");
return { dest: `/lib/${downloadId}.mkv` };
});
if (process.env.EXTRA_STEP === "1") {
// EXTRA_STEP=1 simulates a code change between copy and register (tests code upgrade semantics)
await step.run({ name: "v2-extra-verify" }, async () => { await ledger(run.id, "v2-extra-verify"); return 1; });
}
await step.run({ name: "register" }, async () => { await ledger(run.id, "register", `version=${version}`); return 1; });
await step.sleep("retention", retention);
await step.run({ name: "cleanup" }, async () => { await ledger(run.id, "cleanup"); return 1; });
return { sub, copied, signal: done.data };
},
);
// Retry test: step fails `failTimes` times then succeeds; step maximumAttempts = maxAttempts
const flaky = ow.defineWorkflow(
{ name: "flaky", version: WF_VERSION },
async ({ input, step, run }) => {
const { failTimes, maxAttempts } = input as any;
await step.run({ name: "before" }, async () => { await ledger(run.id, "before"); return 1; });
return await step.run(
{ name: "flaky-step", retryPolicy: { maximumAttempts: maxAttempts, initialInterval: "200ms", maximumInterval: "1s" } },
async () => {
const n = await db.selectFrom("biz.side_effect_ledger").select(db.fn.countAll().as("c"))
.where("run_id", "=", run.id).where("step", "=", "flaky-step").executeTakeFirst();
const attempt = Number(n?.c ?? 0) + 1;
await ledger(run.id, "flaky-step", `attempt=${attempt}`);
if (attempt <= failTimes) throw new Error(`boom attempt ${attempt}`);
return { attempt };
},
);
},
);
// Cancel test: long step, then a follow-up step
const slow = ow.defineWorkflow(
{ name: "slow", version: WF_VERSION },
async ({ step, run }) => {
await step.run({ name: "long" }, async () => {
await ledger(run.id, "long", "start");
await wait(8000);
await ledger(run.id, "long", "end (still ran after cancel?)");
return 1;
});
await step.run({ name: "after-long" }, async () => { await ledger(run.id, "after-long"); return 1; });
return "done";
},
);
// Concurrency test: many short runs
const burst = ow.defineWorkflow(
{ name: "burst", version: WF_VERSION },
async ({ step, run }) => {
await step.run({ name: "work" }, async () => { await wait(300); await ledger(run.id, "work"); return 1; });
return 1;
},
);
return { ow, backend, acquire, flaky, slow, burst };
}
+8
View File
@@ -0,0 +1,8 @@
import { makeClient, initBiz, TAG, WF_VERSION } from "./common.ts";
await initBiz();
const { ow } = await makeClient();
const worker = ow.newWorker({ concurrency: Number(process.env.CONCURRENCY ?? 4) });
await worker.start();
if (process.env.PIDFILE) (await import("node:fs")).writeFileSync(process.env.PIDFILE, String(process.pid));
console.log(`[${TAG}] worker started pid=${process.pid} version=${WF_VERSION}`);
process.on("SIGTERM", async () => { await worker.stop(); process.exit(0); });
+1846
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "umt-spikes",
"private": true,
"type": "module",
"dependencies": {
"openworkflow": "0.10.1",
"postgres": "^3.4.9",
"kysely": "^0.28.0",
"pg": "^8.13.0",
"@earendil-works/pi-agent-core": "0.87.1",
"@earendil-works/pi-ai": "0.87.1",
"tsx": "^4.20.0"
}
}
+60
View File
@@ -0,0 +1,60 @@
// Modes: abort | crash | resume. Uses a file-backed JSONL session so a second process can recover.
import { createModels, fauxProvider } from "@earendil-works/pi-ai";
import { AgentHarness, BACKGROUND_CONTEXT, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs";
import { downloadSubmit, inventoryQuery, script, submitted } from "./common.ts";
const mode = process.argv[2];
const ctx = BACKGROUND_CONTEXT;
const faux = fauxProvider({ provider: "umt-fake" });
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses(mode === "resume" ? [script[2]] : script);
const root = new URL("./.sessions", import.meta.url).pathname;
const fs = new NodeExecutionEnv({ cwd: root }); // used only as FileSystem for session storage; no shell tool is exposed
const repo = new JsonlSessionRepo({ fileSystem: fs, sessionsRoot: root });
let session;
if (mode === "resume") {
const list = await repo.list({ cwd: root }, ctx);
const meta = list.find((m) => m.id === "crash-demo");
console.log("found session", meta?.id, meta?.path);
session = await repo.open(meta!, ctx);
} else {
session = await repo.create({ id: mode === "crash" ? "crash-demo" : `abort-${Date.now()}`, cwd: root }, ctx);
}
const { harness, open } = await AgentHarness.create({
session, models, model: faux.getModel(), systemPrompt: "媒体库助手",
tools: [inventoryQuery, downloadSubmit], toolContext: { actorId: "user:1", grants: ["download.submit"] },
toolExecution: "sequential",
}, ctx);
console.log("open operations at attach:", JSON.stringify(open));
let lane = await harness.lane("main", ctx);
for (const t of ["tool_start", "tool_end", "run_end", "operation_abort", "run_resume"] as const)
harness.events.on(t as any, async (e: any) => {
console.log(`[${e.type}]${e.recovery ? " (recovery)" : ""} ${e.toolName ?? ""} ${e.status ?? ""} ${e.result ? JSON.stringify(e.result.content[0]?.text).slice(0, 220) : ""}`);
if (e.type === "tool_start" && e.toolName === "download__submit") {
if (mode === "abort") setTimeout(async () => { console.log(">> lane.abort()"); console.log("abort result:", JSON.stringify(await lane.abort(ctx))); }, 300);
if (mode === "crash") setTimeout(() => { console.log(">> SIGKILL self during download.submit"); process.kill(process.pid, "SIGKILL"); }, 300);
}
});
if (mode === "resume") {
const r = await lane.resume(ctx);
console.log("RESUME RESULT", JSON.stringify(r));
} else {
const r = await lane.prompt("把《Dune》1080p 收进来", undefined, ctx);
console.log("PROMPT RESULT", JSON.stringify(r));
}
console.log("SUBMITTED in this process", JSON.stringify(submitted), "fake calls:", faux.state.callCount);
const entries = (await lane.findEntries(undefined, ctx)).reverse();
for (const e of entries as any[]) {
if (e.type !== "message") { console.log(" entry", e.type); continue; }
const m = e.message;
const text = m.role === "assistant" ? m.content.map((c: any) => c.type === "toolCall" ? `toolCall(${c.name})` : c.type === "text" ? c.text : `<${c.type}>`).join(" | ")
: Array.isArray(m.content) ? m.content.map((c: any) => c.text).join("") : m.content;
console.log(` ${m.role.padEnd(10)} ${m.toolName ?? ""} ${m.isError ? "[isError]" : ""} ${String(text).slice(0, 200)}`);
}
await harness.close(ctx);
+83
View File
@@ -0,0 +1,83 @@
// Shared pieces for the Pi spike: whitelisted business tools + scripted fake model.
import { Type } from "typebox";
import {
fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall,
type FauxResponseFactory,
} from "@earendil-works/pi-ai";
import type { AgentHarnessTool } from "@earendil-works/pi-agent-core";
// Capability id -> provider-safe tool name. Anthropic/OpenAI tool names must match ^[a-zA-Z0-9_-]{1,64}$,
// so "inventory.query" cannot be sent verbatim; the port maps ids <-> names.
export const toolName = (capabilityId: string) => capabilityId.replace(/\./g, "__");
export const submitted: Array<{ key: string; title: string }> = [];
type Ctx = { actorId: string; grants: string[] };
export const inventoryQuery: AgentHarnessTool<Ctx> = {
name: toolName("inventory.query"),
label: "inventory.query",
description: "Query local library inventory for a title. Returns whether it exists and which versions.",
parameters: Type.Object({ title: Type.String(), year: Type.Optional(Type.Number()) }),
replay: "safe", // read-only: harness may re-execute after a crash
async execute(_id, params, onUpdate, toolCtx, invocation, context) {
onUpdate({ content: [{ type: "text", text: "searching…" }], details: {} });
const found = params.title === "Arrival";
return {
content: [{ type: "text", text: JSON.stringify({ title: params.title, inLibrary: found, versions: found ? ["2160p"] : [] }) }],
details: { actor: toolCtx.actorId, invocationId: invocation.invocationId, aborted: context.abortSignal?.aborted ?? false },
};
},
};
export const downloadSubmit: AgentHarnessTool<Ctx> = {
name: toolName("download.submit"),
label: "download.submit",
description: "Submit a download plan. Server validates authorization and generates the idempotency key.",
parameters: Type.Object({ title: Type.String(), quality: Type.String() }),
replay: "never", // external side effect: after crash the harness synthesizes an 'interrupted' result instead of re-running
executionMode: "sequential",
async execute(_id, params, _onUpdate, toolCtx, invocation, context) {
if (!toolCtx.grants.includes("download.submit")) throw new Error("not authorized: download.submit");
// Server-side idempotency key derived from operation semantics, not from the model.
const key = `dl:${params.title}:${params.quality}`;
const delayMs = Number(process.env.SUBMIT_DELAY_MS ?? 0);
if (delayMs) {
await new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, delayMs);
context.abortSignal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted by signal")); });
});
}
if (!submitted.find((s) => s.key === key)) submitted.push({ key, title: params.title });
await invocation.setMemo("submitted", key);
return { content: [{ type: "text", text: JSON.stringify({ accepted: true, idempotencyKey: key, status: "submitted" }) }], details: { key } };
},
};
const lastToolResult = (ctx: any, name: string) =>
[...ctx.messages].reverse().find((m: any) => m.role === "toolResult" && m.toolName === name);
// Scripted fake model: each step is a factory that looks at the transcript (tool results) and decides.
export const script: FauxResponseFactory[] = [
() => fauxAssistantMessage([
fauxThinking("先查本地库存"),
fauxText("我先查一下库里有没有《Dune》。"),
fauxToolCall(toolName("inventory.query"), { title: "Dune", year: 2021 }),
], { stopReason: "toolUse" }),
(ctx) => {
const inv = JSON.parse(lastToolResult(ctx, toolName("inventory.query")).content[0].text);
if (inv.inLibrary) return fauxAssistantMessage("库里已有,无需下载。");
return fauxAssistantMessage([
fauxText("库里没有,提交 1080p 下载;顺便试一下未授权的 bash。"),
fauxToolCall(toolName("download.submit"), { title: "Dune", quality: "1080p" }),
fauxToolCall("bash", { command: "rm -rf /" }),
], { stopReason: "toolUse" });
},
(ctx) => {
const dl = lastToolResult(ctx, toolName("download.submit"));
const bash = lastToolResult(ctx, "bash");
return fauxAssistantMessage(
`下载已提交(仅代表已提交,不代表已入库):${dl.content[0].text};bash 结果 isError=${bash?.isError}。`,
);
},
];
+21
View File
@@ -0,0 +1,21 @@
// Manual + threshold compaction with the fake model (summary text is scripted).
import { createModels, fauxProvider, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { AgentHarness, BACKGROUND_CONTEXT as ctx, MemorySessionRepo } from "@earendil-works/pi-agent-core";
const faux = fauxProvider({ provider: "umt-fake", models: [{ id: "tiny", contextWindow: 3000, maxTokens: 500 }] });
const models = createModels(); models.setProvider(faux.provider);
const session = await new MemorySessionRepo().create({}, ctx);
const { harness } = await AgentHarness.create({ session, models, model: faux.getModel(), systemPrompt: "助手",
compaction: { enabled: true, reserveTokens: 600, keepRecentTokens: 200 } }, ctx);
for (const t of ["compaction_start", "compaction_end", "run_end"] as const)
harness.events.on(t as any, (e: any) => console.log(`[${e.type}] reason=${e.reason ?? ""} status=${e.status ?? ""}`));
const lane = await harness.lane("main", ctx);
const long = "目标:收录 Dune 1080p。约束:只用已授权能力。".repeat(Number(process.env.REPEAT ?? 60));
faux.setResponses([fauxAssistantMessage("好的,已记录目标。" + "x".repeat(2000)), fauxAssistantMessage("## 摘要\n目标:收录 Dune 1080p;已完成:无"), fauxAssistantMessage("继续")]);
console.log("run1:", JSON.stringify((await lane.prompt(long, undefined, ctx)) as any).slice(0, 400));
console.log("run2:", JSON.stringify((await lane.prompt("继续", undefined, ctx)) as any).slice(0, 400));
faux.appendResponses([fauxAssistantMessage("## 手动摘要\n目标/约束/证据/已完成/剩余")]);
const c = await lane.compact({ customInstructions: "保留目标、约束、证据引用、已完成动作、剩余事项" }, ctx);
console.log("manual compact:", JSON.stringify(c).slice(0, 200));
const entries = await lane.findEntries(undefined, ctx);
console.log("entry types:", entries.map((e: any) => e.type).join(","), "fake calls:", faux.state.callCount);
await harness.close(ctx);
+50
View File
@@ -0,0 +1,50 @@
// Fake model drives AgentHarness through 2 whitelisted tools; streams events.
import { createModels, fauxProvider } from "@earendil-works/pi-ai";
import { AgentHarness, BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
import { downloadSubmit, inventoryQuery, script, submitted } from "./common.ts";
const faux = fauxProvider({ provider: "umt-fake" });
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses(script);
const repo = new MemorySessionRepo();
const session = await repo.create({ id: "spike-1" }, BACKGROUND_CONTEXT);
const { harness } = await AgentHarness.create({
session, models, model: faux.getModel(),
systemPrompt: "你是媒体库自动化助手,只能使用提供的工具。",
tools: [inventoryQuery, downloadSubmit], // explicit whitelist; no discovery
toolContext: { actorId: "user:1", grants: ["download.submit"] },
thinkingLevel: "off",
compaction: { enabled: true, reserveTokens: 4096, keepRecentTokens: 8000 },
retry: { enabled: true, maxRetries: 2, baseDelayMs: 100 },
toolExecution: "sequential",
}, BACKGROUND_CONTEXT);
// Server-side authorization gate in addition to the whitelist.
harness.hooks.on("before_tool", (ev) => {
console.log(` [hook before_tool] ${ev.toolName} args=${JSON.stringify(ev.args)}`);
return undefined;
});
const types = ["run_start", "turn_start", "message_update", "tool_start", "tool_update", "tool_end", "turn_end", "run_end", "usage", "fault", "handler_error"] as const;
for (const t of types) harness.events.on(t as any, (e: any) => {
if (e.type === "message_update") {
if (e.event.type === "text_delta") process.stdout.write(` [text_delta] ${JSON.stringify(e.event.delta)}\n`);
else if (e.event.type === "toolcall_end") console.log(` [toolcall_end] ${e.event.toolCall.name}`);
return;
}
if (e.type === "tool_end") return console.log(`[tool_end] ${e.toolName} isError=${e.isError} -> ${JSON.stringify(e.result.content[0]?.text)}`);
if (e.type === "tool_start") return console.log(`[tool_start] ${e.toolName} ${JSON.stringify(e.args)}`);
if (e.type === "usage") return console.log(`[usage] in=${e.row?.usage?.input ?? "?"} totals.input=${e.totals.input} output=${e.totals.output}`);
if (e.type === "run_end") return console.log(`[run_end] status=${e.status}${e.error ? " error=" + JSON.stringify(e.error) : ""}`);
console.log(`[${e.type}]${e.lane ? " lane=" + e.lane : ""}`);
});
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
const result = await lane.prompt("把《Dune》1080p 收进来", undefined, BACKGROUND_CONTEXT);
console.log("RESULT", JSON.stringify(result));
console.log("SUBMITTED", JSON.stringify(submitted), "fake calls:", faux.state.callCount);
const entries = await lane.findEntries(undefined, BACKGROUND_CONTEXT);
console.log("TRANSCRIPT roles:", entries.map((e: any) => e.type === "message" ? e.message.role : e.type).join(","));
await harness.close(BACKGROUND_CONTEXT);
+69
View File
@@ -0,0 +1,69 @@
// Real provider types (Anthropic Messages + OpenAI-compatible Chat Completions) pointed at a LOCAL capture server.
// No external network: the capture server records method/path/headers/body and returns HTTP 400.
import http from "node:http";
import { createModels, createProvider, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
import { AgentHarness, BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
import { inventoryQuery } from "./common.ts";
const captured: any[] = [];
const server = http.createServer((req, res) => {
let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => {
captured.push({ method: req.method, url: req.url, auth: req.headers["authorization"] ?? req.headers["x-api-key"], body: JSON.parse(body || "{}") });
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: { type: "invalid_request_error", message: "capture server: refusing on purpose" } }));
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${(server.address() as any).port}`;
// Secret comes from our own secret store; resolve() is called at request time. No env var lookup.
const secret = (name: string) => ({ name, resolve: async () => ({ auth: { apiKey: `sk-test-${name}` } }) });
const anthropicCompatible: Model<"anthropic-messages"> = {
id: "claude-sonnet-4-5", name: "Anthropic-compatible upstream", api: "anthropic-messages", provider: "upstream-anthropic",
baseUrl: base, reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000, maxTokens: 8192,
};
const openaiCompatible: Model<"openai-completions"> = {
id: "qwen3-235b", name: "OpenAI-compatible upstream", api: "openai-completions", provider: "upstream-openai",
baseUrl: `${base}/v1`, reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072, maxTokens: 8192, compat: { supportsDeveloperRole: false, thinkingFormat: "qwen" },
};
const models = createModels();
models.setProvider(createProvider({ id: "upstream-anthropic", auth: { apiKey: secret("anthropic") }, models: [anthropicCompatible], api: anthropicMessagesApi() }));
models.setProvider(createProvider({ id: "upstream-openai", auth: { apiKey: secret("openai") }, models: [openaiCompatible], api: openAICompletionsApi() }));
// Built-in provider catalog is available too (no request made):
const builtin = anthropicProvider();
const cat = createModels(); cat.setProvider(builtin);
console.log("builtin anthropic models (first 5):", cat.getModels("anthropic").slice(0, 5).map((m) => m.id).join(", "));
console.log("builtin anthropic auth configured without env?", JSON.stringify(await cat.checkAuth("anthropic")));
console.log("supported thinking levels, custom anthropic model:", getSupportedThinkingLevels(anthropicCompatible).join(","));
console.log("supported thinking levels, custom openai model:", getSupportedThinkingLevels(openaiCompatible).join(","));
for (const model of [anthropicCompatible, openaiCompatible] as Model<any>[]) {
const repo = new MemorySessionRepo();
const session = await repo.create({}, BACKGROUND_CONTEXT);
const { harness } = await AgentHarness.create({
session, models, model, systemPrompt: "媒体库助手", tools: [inventoryQuery], toolContext: { actorId: "u", grants: [] },
thinkingLevel: "high",
compaction: { enabled: true, reserveTokens: 8000, keepRecentTokens: 12000 },
retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 },
streamOptions: { timeoutMs: 5000, maxRetries: 0, headers: { "x-umt-run": "spike" } },
}, BACKGROUND_CONTEXT);
harness.hooks.on("before_request", (ev) => { console.log(`[before_request] ${ev.model.provider}/${ev.model.id} step=${ev.step} attempt=${ev.attempt}`); return undefined; });
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
const r = await lane.prompt("库里有 Dune 吗", undefined, BACKGROUND_CONTEXT);
console.log(`[${model.provider}] run:`, JSON.stringify(r.ok ? { status: r.value.status, error: (r.value as any).error } : r.error).slice(0, 300));
await harness.close(BACKGROUND_CONTEXT);
}
for (const c of captured) {
const b = c.body;
console.log(`CAPTURED ${c.method} ${c.url} auth=${c.auth}`);
console.log(` model=${b.model} max_tokens=${b.max_tokens ?? b.max_completion_tokens} thinking=${JSON.stringify(b.thinking ?? b.enable_thinking ?? b.reasoning_effort)} tools=${JSON.stringify((b.tools ?? []).map((t: any) => t.name ?? t.function?.name))} stream=${b.stream}`);
}
server.close();