import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { once } from "node:events"; import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { createRequire } from "node:module"; import { homedir, tmpdir } from "node:os"; import { basename, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; const self = fileURLToPath(import.meta.url); const provider = "em-coding-messages"; const modelName = "DeepSeek-V4-Flash"; const fixtureFiles = ["orders.mjs", "orders.test.mjs"]; const request = `给这个订单列表加状态筛选。filterOrders(orders) 仍返回全部,传入 "pending" 或 "shipped" 时只返回匹配项,保持顺序。保留原测试和 before 钩子,用两个独立的 test(...) 分别测试 pending 和 shipped。 先用 read_file 看 orders.mjs 和 orders.test.mjs,再用 write_file 修改。最后调用 start_tests 启动真实测试,收到任务 ID 后简短汇报已启动并结束当前轮;完成事件稍后投递。`; const followUp = "请报告刚收到的测试结果。再用 repo_diff 读取这两个文件此刻的真实工作区差异。如果工具返回 EXECUTOR_OFFLINE,就说明差异需要等设备重连,然后结束本轮。"; const [action = "record", firstArgument, phase] = process.argv.slice(2); function recorder(directory, lane) { return (kind, summary, data = null, route = [lane, lane]) => { appendFileSync(join(directory, "events.jsonl"), JSON.stringify({ at: Date.now(), pid: process.pid, lane, kind, route, summary, data }) + "\n"); }; } function fixture(directory) { const repo = join(directory, "repo"); mkdirSync(repo, { recursive: true }); writeFileSync(join(repo, "orders.mjs"), `export function filterOrders(orders) { return orders; } `); writeFileSync(join(repo, "orders.test.mjs"), `import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { before, test } from "node:test"; import { setTimeout } from "node:timers/promises"; import { filterOrders } from "./orders.mjs"; const orders = [ { id: 101, status: "pending" }, { id: 102, status: "shipped" }, { id: 103, status: "pending" }, ]; // The lab releases this gate after observing the Loop process state. before(async () => { const gate = process.env.AGENT_LAB_TEST_GATE; if (!gate) return; const deadline = Date.now() + 60_000; while (!existsSync(gate)) { assert(Date.now() < deadline, "Lab did not release the test gate"); await setTimeout(50); } }); test("defaults to all orders", () => { assert.deepEqual(filterOrders(orders), orders); }); `); writeFileSync(join(repo, "notes.txt"), "Release notes\n"); git(repo, ["init", "-q"]); git(repo, ["add", "."]); git(repo, ["-c", "user.name=Agent Loop Lab", "-c", "user.email=lab@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "Order list fixture"]); appendFileSync(join(repo, "notes.txt"), "Uncommitted local release note\n"); return repo; } function git(repo, args) { return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } function snapshot(repo) { return { status: git(repo, ["status", "--short"]), diff: git(repo, ["diff", "--", ...fixtureFiles]), notes: readFileSync(join(repo, "notes.txt"), "utf8") }; } function makeExecutor(directory) { const repo = join(directory, "repo"); const log = recorder(directory, "executor"); let job; return async ({ id, name, input }) => { log("tool.execute", "执行器开始 " + name, { id, name, input }); if (name === "read_file" || name === "write_file") { if (!fixtureFiles.includes(input.path)) throw Error("Path outside the fixture allowlist"); const path = join(repo, input.path); if (name === "read_file") return readFileSync(path, "utf8"); if (typeof input.content !== "string" || input.content.length > 8000) throw Error("Invalid file content"); writeFileSync(path, input.content); log("git.snapshot", "写入后 Git 工作区变化", snapshot(repo)); return "Wrote " + input.path; } if (name === "repo_diff") return snapshot(repo).diff; if (name !== "start_tests") throw Error("Unknown fixture tool"); if (job) return JSON.stringify(job); const child = spawn(process.execPath, ["--test", "--test-reporter=tap", "orders.test.mjs"], { cwd: repo, env: { PATH: process.env.PATH, AGENT_LAB_TEST_GATE: join(directory, "release-tests") }, stdio: ["ignore", "pipe", "pipe"] }); job = { jobId: "orders-tests-1", pid: child.pid, status: "running", command: "node --test --test-reporter=tap orders.test.mjs" }; let stdout = ""; let stderr = ""; child.stdout.on("data", chunk => { stdout += chunk; }); child.stderr.on("data", chunk => { stderr += chunk; }); child.on("close", (code, signal) => { const completed = { ...job, status: "finished", code, signal, stdout, stderr }; log("test.finish", "本机测试进程退出", completed); writeFileSync(join(directory, "test-result.json"), JSON.stringify(completed)); }); log("test.start", "本机启动 Node 测试进程", job); return JSON.stringify(job); }; } async function serve(directory) { const log = recorder(directory, "executor"); const execute = makeExecutor(directory); const server = createServer(async (req, res) => { if (req.method !== "POST" || req.url !== "/tool") { res.writeHead(404).end(); return; } try { const chunks = []; for await (const chunk of req) chunks.push(chunk); const call = JSON.parse(Buffer.concat(chunks)); log("rpc.receive", "daemon 收到工具请求", call, ["loop", "executor"]); const text = await execute(call); log("rpc.send", "daemon 回传工具结果", { id: call.id, name: call.name, text }, ["executor", "loop"]); res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ text })); } catch { res.writeHead(400).end(JSON.stringify({ text: "Fixture tool rejected the request" })); } }); const portFile = join(directory, "port"); server.listen(existsSync(portFile) ? Number(readFileSync(portFile, "utf8")) : 0, "127.0.0.1"); await once(server, "listening"); log("executor.start", "本机 daemon 监听 loopback", { port: server.address().port }); writeFileSync(portFile, String(server.address().port)); process.send({ ready: true }); } function sanitizedPayload(payload) { const { model, max_tokens, stream, system, messages, tools } = payload; const scrub = value => { if (Array.isArray(value)) return value.filter(item => !["thinking", "redacted_thinking"].includes(item?.type)).map(scrub); if (!value || typeof value !== "object") return value; return Object.fromEntries(Object.entries(value) .filter(([key]) => !["signature", "metadata"].includes(key)) .map(([key, child]) => [key, scrub(child)])); }; return scrub({ model, max_tokens, stream, system, messages, tools }); } async function runLoop(directory, mode) { const piCli = realpathSync(execFileSync("which", ["pi"], { encoding: "utf8" }).trim()); const piModule = new URL("../index.js", pathToFileURL(piCli)); const { Type } = await import(pathToFileURL(createRequire(piModule).resolve("typebox")).href); const { createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, SettingsManager } = await import(piModule.href); const log = recorder(directory, "loop"); const repo = join(directory, "repo"); const sessions = join(directory, "state"); const config = join(homedir(), ".pi", "agent"); const sdkDir = join(directory, "pi-sdk"); mkdirSync(sdkDir, { recursive: true }); mkdirSync(sessions, { recursive: true }); const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); const modelRuntime = await ModelRuntime.create({ authPath: join(sdkDir, "auth.json"), modelsPath: join(config, "models.json"), modelsStorePath: join(sdkDir, "models-store.json"), allowModelNetwork: false }); const model = modelRuntime.getModel(provider, modelName); assert(model, "Configured V4 Flash model is unavailable"); const resourceLoader = new DefaultResourceLoader({ cwd: sdkDir, agentDir: sdkDir, settingsManager, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPromptOverride: () => "You are a coding agent in an isolated demo repository. Follow the requested tool sequence and report observed results. Do not invent outputs.", extensionFactories: [pi => { pi.on("before_provider_request", event => { log("model.request", "Pi 组装并发送 Anthropic Messages 请求", sanitizedPayload(event.payload), ["loop", "gateway"]); }); pi.on("after_provider_response", event => { log("model.http", "收到网关 HTTP 响应头", { status: event.status }, ["gateway", "loop"]); }); }] }); await resourceLoader.reload(); const rpc = mode !== "baseline"; const execute = rpc ? null : makeExecutor(directory); const definitions = [ ["read_file", "Read a file from the working tree", Type.Object({ path: Type.String() })], ["write_file", "Replace the contents of a file in the working tree", Type.Object({ path: Type.String(), content: Type.String() })], ["start_tests", "Start the repository's Node test command and return its job ID immediately", Type.Object({})], ["repo_diff", "Read current git diff for orders.mjs and orders.test.mjs", Type.Object({})] ]; const customTools = definitions.map(([name, description, parameters]) => defineTool({ name, label: name, description, parameters, execute: async (id, input) => { const call = { id, name, input }; log("tool.dispatch", "Loop 派发 " + name, call, ["loop", "executor"]); let text; let resultOrigin = "executor"; if (rpc) { const port = readFileSync(join(directory, "port"), "utf8"); try { const response = await fetch("http://127.0.0.1:" + port + "/tool", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(call), signal: AbortSignal.timeout(5000) }); ({ text } = await response.json()); } catch (error) { const code = error.cause?.code || error.name; log("rpc.offline", "Loop 无法连到本机 daemon", { id, name, code }, ["loop", "executor"]); text = "EXECUTOR_OFFLINE: current workspace could not be read (" + code + ")"; resultOrigin = "loop"; } } else { text = await execute(call); } log("tool.result", resultOrigin === "executor" ? "工具结果交回 Loop" : "Loop 把连接失败转换成工具结果", { id, name, text }, [resultOrigin, "loop"]); return { content: [{ type: "text", text }], details: {} }; } })); const receipt = join(directory, "session-path"); const restored = existsSync(receipt); // In the split topology Pi's cwd does not point to the repository. const cwd = rpc ? sdkDir : repo; const sessionManager = restored ? SessionManager.open(readFileSync(receipt, "utf8"), sessions, cwd) : SessionManager.create(cwd, sessions); const { session } = await createAgentSession({ cwd, agentDir: sdkDir, resourceLoader, settingsManager, modelRuntime, sessionManager, model, thinkingLevel: "off", noTools: "builtin", customTools }); log(restored ? "session.restore" : "loop.start", restored ? "新 Loop 进程恢复同一个 Session" : "启动 Pi SDK Loop", { sessionId: session.sessionId, existingMessages: session.messages.length, mode }, restored ? ["session", "loop"] : ["loop", "loop"]); session.subscribe(event => { if (event.type !== "message_end" || event.message.role !== "assistant") return; const { stopReason, usage, content } = event.message; log("model.reply", "模型返回 " + stopReason, { stopReason, usage, content: content.filter(block => ["text", "toolCall"].includes(block.type)) }, ["gateway", "loop"]); }); const prompt = async (text, origin = "user") => { log("turn.input", "Loop 接收新输入", { text }, [origin, "loop"]); await session.prompt(text); assert.notEqual(session.messages.at(-1)?.stopReason, "error", "Pi reported a provider error"); assert.notEqual(session.messages.at(-1)?.stopReason, "aborted", "Pi run was aborted"); assert(session.sessionFile && existsSync(session.sessionFile), "Pi session was not persisted"); writeFileSync(receipt, session.sessionFile); const entries = sessionManager.getEntries(); log("session.save", "Pi Session JSONL 已落盘", { file: basename(session.sessionFile), sessionId: session.sessionId, entries: entries.length, roles: entries.filter(entry => entry.type === "message").map(entry => entry.message.role) }, ["loop", "session"]); }; try { if (mode === "baseline" || mode === "initial") { await prompt(request); if (mode === "baseline") { writeFileSync(join(directory, "release-tests"), ""); await prompt(await completionMessage(directory) + "\n\n" + followUp, "controller"); } } else if (mode === "offline") { await prompt(await completionMessage(directory) + "\n\n" + followUp, "controller"); } else if (mode === "reconnect") { await prompt("设备已重连。继续上轮挂起的请求,调用 repo_diff,返回当前实际 diff 摘要。", "controller"); } } finally { session.dispose(); log("loop.exit", "Loop 主动退出", { mode }); } } async function until(predicate, label, timeout = 70_000) { const deadline = Date.now() + timeout; while (!predicate()) { if (Date.now() >= deadline) throw Error("Timed out: " + label); await delay(50); } } async function completionMessage(directory) { const resultFile = join(directory, "test-result.json"); await until(() => existsSync(resultFile), "test completion"); const result = JSON.parse(readFileSync(resultFile, "utf8")); return "本机完成事件 " + JSON.stringify({ jobId: result.jobId, exitCode: result.code, stdout: result.stdout }); } function startChild(command, directory, mode = "") { const child = spawn(process.execPath, [self, command, directory, mode], { env: { ...process.env, PI_OFFLINE: "1" }, stdio: ["ignore", "ignore", "inherit", "ipc"] }); const watchdog = setTimeout(() => child.kill(), 120_000); const done = once(child, "exit").then(([code, signal]) => { clearTimeout(watchdog); return { code, signal, pid: child.pid }; }); return { child, done }; } async function childLoop(directory, mode) { const result = await startChild("loop", directory, mode).done; assert.equal(result.code, 0, "Pi Loop child failed in phase " + mode); return result; } async function startExecutor(directory) { const execution = startChild("executor", directory); await Promise.race([ once(execution.child, "message"), execution.done.then(() => { throw Error("Executor exited before startup"); }) ]); return execution; } async function recordRun(root, mode) { const directory = join(root, mode); const repo = fixture(directory); const log = recorder(directory, "controller"); log("git.snapshot", "实验开始时的工作区", snapshot(repo), ["executor", "executor"]); let daemon; try { if (mode === "baseline") { await childLoop(directory, "baseline"); } else { daemon = await startExecutor(directory); const loop = await childLoop(directory, "initial"); const events = readEvents(directory); const test = events.find(event => event.kind === "test.start"); assert(test?.data?.pid, "Model did not launch the tests"); assert(!existsSync(join(directory, "test-result.json")), "Tests finished before observing Loop exit"); process.kill(test.data.pid, 0); log("controller.observed", "确认 Loop 已退出,本机测试 PID 仍存活", { loopPid: loop.pid, executorPid: daemon.child.pid, testPid: test.data.pid, testGate: "released after observation" }); writeFileSync(join(directory, "release-tests"), ""); await completionMessage(directory); daemon.child.kill(); const stopped = await daemon.done; log("executor.stop", "停止 daemon,注入设备离线", stopped, ["executor", "executor"]); await childLoop(directory, "offline"); assert(readEvents(directory).some(event => event.kind === "rpc.offline" && event.data.name === "repo_diff"), "Model did not attempt to read diff while offline"); daemon = await startExecutor(directory); await childLoop(directory, "reconnect"); } const final = snapshot(repo); log("git.snapshot", "实验完成后的工作区", final, ["executor", "executor"]); assert(final.diff.includes("orders.mjs") && final.diff.includes("orders.test.mjs"), "Model did not change both files"); const test = JSON.parse(readFileSync(join(directory, "test-result.json"), "utf8")); assert.equal(test.code, 0, "Fixture tests failed"); const passes = Number(test.stdout.match(/^# pass (\d+)$/m)?.[1]); assert(passes >= 3, "Expected at least three passed test cases; recorded " + passes); assert(final.notes.includes("Uncommitted local release note"), "Unrelated local edit changed"); const events = readEvents(directory); const requests = events.filter(event => event.kind === "model.request"); const responses = events.filter(event => event.kind === "model.http"); assert(requests.length && requests.length === responses.length && responses.every(event => event.data.status === 200), "Expected one successful gateway HTTP response per observed request"); const instances = events.filter(event => ["loop.start", "session.restore"].includes(event.kind)); assert.equal(new Set(instances.map(event => event.data.sessionId)).size, 1, "Session ID changed"); assert.equal(new Set(instances.map(event => event.pid)).size, mode === "split" ? 3 : 1, "Unexpected Loop instances"); assert(events.some(event => event.kind === "tool.result" && event.data.name === "repo_diff" && event.data.text === final.diff), "Model did not read the final diff"); return { mode, events }; } finally { if (daemon?.child.exitCode === null && daemon.child.signalCode === null) { daemon.child.kill(); await daemon.done; } } } function readEvents(directory) { return readFileSync(join(directory, "events.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); } async function record(output) { assert(output, "Usage: node run-agent-loop-lab.mjs record "); const root = mkdtempSync(join(tmpdir(), "agent-loop-lab-")); try { const runs = []; for (const mode of ["baseline", "split"]) { console.error("Running " + mode + " with Pi SDK and " + modelName); runs.push(await recordRun(root, mode)); console.error("Verified " + mode + " (" + runs.at(-1).events.length + " observed events)"); } const trace = { capturedAt: new Date().toISOString(), model: modelName, protocol: "Anthropic Messages", runs }; if (output.endsWith(".html")) embedTrace(trace, output); else writeFileSync(output, JSON.stringify(trace, null, 2)); } finally { rmSync(root, { recursive: true, force: true }); } } function embedTrace(trace, htmlPath) { const html = readFileSync(htmlPath, "utf8"); const section = /(