From b95bbae5fca17382c18d1bc2249c6601b652a8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 27 May 2026 23:55:40 +0000 Subject: [PATCH] feat(agent): change adapter stdout from plain stepHash to JSON with full metadata (#566) - Add AdapterOutput type (stepHash, detailHash, role, frontmatter, body, startedAtMs, completedAtMs) - Update FrontmatterFastPathResult to include frontmatter record - Change createAgent to output JSON line instead of plain hash - Update spawnAgent in cli-workflow to parse JSON - Add adapter-stdout tests (A-group) and spawn-agent-json tests (B-group) --- .../src/__tests__/spawn-agent-json.test.ts | 100 +++++++++++++++++ packages/cli-workflow/src/commands/thread.ts | 24 +++- .../__tests__/adapter-stdout.test.ts | 105 ++++++++++++++++++ .../workflow-util-agent/src/frontmatter.ts | 3 +- packages/workflow-util-agent/src/index.ts | 1 + packages/workflow-util-agent/src/run.ts | 37 ++++-- packages/workflow-util-agent/src/types.ts | 10 ++ 7 files changed, 265 insertions(+), 15 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/spawn-agent-json.test.ts create mode 100644 packages/workflow-util-agent/__tests__/adapter-stdout.test.ts diff --git a/packages/cli-workflow/src/__tests__/spawn-agent-json.test.ts b/packages/cli-workflow/src/__tests__/spawn-agent-json.test.ts new file mode 100644 index 0000000..3d03091 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/spawn-agent-json.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "vitest"; + +/** + * B-group tests: validate JSON parsing logic used by spawnAgent. + * + * We test the parsing logic inline since spawnAgent is a private function. + * These tests verify the contract: last line of stdout must be valid JSON + * with a valid stepHash CasRef. + */ + +const CASREF_PATTERN = /^[0-9A-HJ-NP-TV-Z]{13}$/; + +function isCasRef(s: string): boolean { + return CASREF_PATTERN.test(s); +} + +type AdapterOutput = { + stepHash: string; + detailHash: string; + role: string; + frontmatter: Record; + body: string; + startedAtMs: number; + completedAtMs: number; +}; + +function parseAgentStdout(stdout: string): AdapterOutput { + const line = stdout.trim().split("\n").pop()?.trim() ?? ""; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + throw new Error(`agent stdout last line is not valid JSON: ${line || "(empty)"}`); + } + const obj = parsed as Record; + if ( + typeof obj !== "object" || + obj === null || + typeof obj.stepHash !== "string" || + !isCasRef(obj.stepHash as string) + ) { + throw new Error(`agent stdout JSON missing valid stepHash: ${line}`); + } + return obj as unknown as AdapterOutput; +} + +const VALID_OUTPUT: AdapterOutput = { + stepHash: "0123456789ABC", + detailHash: "DEFGH12345678", + role: "planner", + frontmatter: { $status: "ready", plan: "somehash" }, + body: "Plan body", + startedAtMs: 1000, + completedAtMs: 2000, +}; + +describe("spawnAgent JSON parsing", () => { + test("B1. parses valid JSON from agent stdout", () => { + const stdout = JSON.stringify(VALID_OUTPUT) + "\n"; + const result = parseAgentStdout(stdout); + expect(result.stepHash).toBe("0123456789ABC"); + expect(result.detailHash).toBe("DEFGH12345678"); + expect(result.role).toBe("planner"); + expect(result.frontmatter).toEqual({ $status: "ready", plan: "somehash" }); + expect(result.body).toBe("Plan body"); + expect(result.startedAtMs).toBe(1000); + expect(result.completedAtMs).toBe(2000); + }); + + test("B2. extracts stepHash for head pointer", () => { + const stdout = JSON.stringify(VALID_OUTPUT) + "\n"; + const result = parseAgentStdout(stdout); + expect(result.stepHash).toBe("0123456789ABC"); + expect(isCasRef(result.stepHash)).toBe(true); + }); + + test("B3. handles debug lines before JSON", () => { + const debugLines = "[debug] loading context...\n[debug] running agent...\n"; + const stdout = debugLines + JSON.stringify(VALID_OUTPUT) + "\n"; + const result = parseAgentStdout(stdout); + expect(result.stepHash).toBe("0123456789ABC"); + }); + + test("B4. rejects non-JSON last line", () => { + const stdout = "not-json-at-all\n"; + expect(() => parseAgentStdout(stdout)).toThrow("not valid JSON"); + }); + + test("B5. rejects JSON missing stepHash", () => { + const incomplete = { detailHash: "DEFGH12345678", role: "planner" }; + const stdout = JSON.stringify(incomplete) + "\n"; + expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash"); + }); + + test("B6. rejects JSON with invalid stepHash", () => { + const bad = { ...VALID_OUTPUT, stepHash: "not-a-hash" }; + const stdout = JSON.stringify(bad) + "\n"; + expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash"); + }); +}); diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 2418321..c3b245d 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -23,6 +23,7 @@ import { generateUlid, type ProcessLogger, } from "@uncaged/workflow-util"; +import type { AdapterOutput } from "@uncaged/workflow-util-agent"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent"; import { config as loadDotenv } from "dotenv"; import { parse } from "yaml"; @@ -788,7 +789,7 @@ function spawnAgent( role: string, edgePrompt: string, cwd: string, -): CasRef { +): AdapterOutput { const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt]; let stdout: string; try { @@ -811,10 +812,22 @@ function spawnAgent( } const line = stdout.trim().split("\n").pop()?.trim() ?? ""; - if (!isCasRef(line)) { - failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`); } - return line; + const obj = parsed as Record; + if ( + typeof obj !== "object" || + obj === null || + typeof obj.stepHash !== "string" || + !isCasRef(obj.stepHash as string) + ) { + failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`); + } + return obj as unknown as AdapterOutput; } async function archiveThread( @@ -1019,7 +1032,8 @@ async function cmdThreadStepOnce( }); loadDotenv({ path: getEnvPath(storageRoot) }); - const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd); + const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd); + const newHead = agentResult.stepHash as CasRef; plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null); diff --git a/packages/workflow-util-agent/__tests__/adapter-stdout.test.ts b/packages/workflow-util-agent/__tests__/adapter-stdout.test.ts new file mode 100644 index 0000000..a724c98 --- /dev/null +++ b/packages/workflow-util-agent/__tests__/adapter-stdout.test.ts @@ -0,0 +1,105 @@ +import { createMemoryStore, putSchema } from "@uncaged/json-cas"; +import { describe, expect, test } from "vitest"; + +import { tryFrontmatterFastPath } from "../src/frontmatter.js"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const PLANNER_SCHEMA = { + type: "object", + properties: { + $status: { type: "string", enum: ["ready", "failed"] }, + plan: { type: "string" }, + }, + required: ["$status"], + additionalProperties: false, +}; + +const FRONTMATTER_SCHEMA = { + type: "object", + properties: { + status: { anyOf: [{ type: "string" }, { type: "null" }] }, + next: { anyOf: [{ type: "string" }, { type: "null" }] }, + confidence: { anyOf: [{ type: "number" }, { type: "null" }] }, + artifacts: { type: "array", items: { type: "string" } }, + scope: { type: "string" }, + }, + required: ["status", "next", "confidence", "artifacts", "scope"], + additionalProperties: false, +}; + +describe("adapter-stdout: FrontmatterFastPathResult includes frontmatter", () => { + test("A2. frontmatter field contains the parsed YAML frontmatter object", async () => { + const store = createMemoryStore(); + const schemaHash = await putSchema(store, PLANNER_SCHEMA); + + const raw = `---\n$status: ready\nplan: abc123\n---\nSome body text`; + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + + expect(result).not.toBeNull(); + expect(result!.frontmatter).toEqual({ $status: "ready", plan: "abc123" }); + }); + + test("A3. body field contains the markdown body after frontmatter", async () => { + const store = createMemoryStore(); + const schemaHash = await putSchema(store, PLANNER_SCHEMA); + + const raw = `---\n$status: ready\nplan: hash123\n---\nHere is the body.\n\nWith multiple paragraphs.`; + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + + expect(result).not.toBeNull(); + expect(result!.body).toBe("Here is the body.\n\nWith multiple paragraphs."); + }); + + test("A1. result contains outputHash as valid CasRef", async () => { + const store = createMemoryStore(); + const schemaHash = await putSchema(store, FRONTMATTER_SCHEMA); + + const raw = `---\nstatus: done\nnext: null\nconfidence: 0.9\nartifacts: []\nscope: test\n---\nBody`; + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + + expect(result).not.toBeNull(); + expect(result!.outputHash).toMatch(/^[0-9A-Z]{13}$/); + expect(result!.frontmatter).toBeDefined(); + expect(result!.body).toBe("Body"); + }); +}); + +describe("adapter-stdout: AdapterOutput JSON shape", () => { + test("A5. JSON.stringify produces valid parseable JSON with all fields", () => { + const output = { + stepHash: "0123456789ABC", + detailHash: "DEFGH12345678", + role: "planner", + frontmatter: { $status: "ready", plan: "somehash" }, + body: "Plan body text", + startedAtMs: 1000, + completedAtMs: 2000, + }; + + const json = JSON.stringify(output); + const parsed = JSON.parse(json); + + expect(parsed.stepHash).toBe("0123456789ABC"); + expect(parsed.detailHash).toBe("DEFGH12345678"); + expect(parsed.role).toBe("planner"); + expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "somehash" }); + expect(parsed.body).toBe("Plan body text"); + expect(parsed.startedAtMs).toBe(1000); + expect(parsed.completedAtMs).toBe(2000); + }); + + test("completedAtMs >= startedAtMs", () => { + const output = { + stepHash: "0123456789ABC", + detailHash: "DEFGH12345678", + role: "planner", + frontmatter: {}, + body: "", + startedAtMs: 1000, + completedAtMs: 2000, + }; + + expect(output.completedAtMs).toBeGreaterThanOrEqual(output.startedAtMs); + }); +}); diff --git a/packages/workflow-util-agent/src/frontmatter.ts b/packages/workflow-util-agent/src/frontmatter.ts index 3e49666..c5b1c2c 100644 --- a/packages/workflow-util-agent/src/frontmatter.ts +++ b/packages/workflow-util-agent/src/frontmatter.ts @@ -20,6 +20,7 @@ type StandardKey = (typeof STANDARD_KEYS)[number]; export type FrontmatterFastPathResult = { body: string; outputHash: CasRef; + frontmatter: Record; }; function extractYamlBlock(raw: string): string | null { @@ -191,5 +192,5 @@ export async function tryFrontmatterFastPath( return null; } - return { body, outputHash }; + return { body, outputHash, frontmatter: candidate }; } diff --git a/packages/workflow-util-agent/src/index.ts b/packages/workflow-util-agent/src/index.ts index bd32100..dfdfcdc 100644 --- a/packages/workflow-util-agent/src/index.ts +++ b/packages/workflow-util-agent/src/index.ts @@ -15,6 +15,7 @@ export { createAgent, parseArgv } from "./run.js"; export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js"; export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js"; export type { + AdapterOutput, AgentContext, AgentContinueFn, AgentOptions, diff --git a/packages/workflow-util-agent/src/run.ts b/packages/workflow-util-agent/src/run.ts index 1090ccb..b6d08f6 100644 --- a/packages/workflow-util-agent/src/run.ts +++ b/packages/workflow-util-agent/src/run.ts @@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js"; import { tryFrontmatterFastPath } from "./frontmatter.js"; import type { AgentStore } from "./storage.js"; import { getEnvPath, resolveStorageRoot } from "./storage.js"; -import type { AgentOptions } from "./types.js"; +import type { AdapterOutput, AgentOptions } from "./types.js"; const MAX_FRONTMATTER_RETRIES = 2; @@ -85,14 +85,24 @@ async function writeStepNode(options: { return hash; } +type ExtractedOutput = { + outputHash: CasRef; + frontmatter: Record; + body: string; +}; + async function tryExtractOutput( rawOutput: string, outputSchema: CasRef, ctx: Awaited>, -): Promise { +): Promise { const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store); if (fastPath !== null) { - return fastPath.outputHash; + return { + outputHash: fastPath.outputHash, + frontmatter: fastPath.frontmatter, + body: fastPath.body, + }; } return null; } @@ -148,9 +158,9 @@ export function createAgent(options: AgentOptions): () => Promise { const primaryDetailHash = agentResult.detailHash; // Try to extract frontmatter; retry via continue if it fails - let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); + let extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); - for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) { + for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) { const correctionMessage = "Your previous response did not contain valid YAML frontmatter matching the role schema.\n" + "You MUST begin your response with a YAML frontmatter block (--- delimited).\n" + @@ -159,10 +169,10 @@ export function createAgent(options: AgentOptions): () => Promise { agentResult = await runWithMessage("agent continue failed", () => options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store), ); - outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); + extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); } - if (outputHash === null) { + if (extracted === null) { fail( "Agent output does not contain valid YAML frontmatter matching the role schema " + `after ${MAX_FRONTMATTER_RETRIES} retries.\n` + @@ -172,13 +182,22 @@ export function createAgent(options: AgentOptions): () => Promise { const completedAtMs = Date.now(); const stepHash = await persistStep({ ctx, - outputHash, + outputHash: extracted.outputHash, detailHash: primaryDetailHash, agentName: agentLabel(options.name), startedAtMs, completedAtMs, }); - process.stdout.write(`${stepHash}\n`); + const adapterOutput: AdapterOutput = { + stepHash, + detailHash: primaryDetailHash, + role, + frontmatter: extracted.frontmatter, + body: extracted.body, + startedAtMs, + completedAtMs, + }; + process.stdout.write(`${JSON.stringify(adapterOutput)}\n`); }; } diff --git a/packages/workflow-util-agent/src/types.ts b/packages/workflow-util-agent/src/types.ts index 42f8c1e..dbff5e3 100644 --- a/packages/workflow-util-agent/src/types.ts +++ b/packages/workflow-util-agent/src/types.ts @@ -37,6 +37,16 @@ export type AgentContinueFn = ( export type AgentRunFn = (ctx: AgentContext) => Promise; +export type AdapterOutput = { + stepHash: string; + detailHash: string; + role: string; + frontmatter: Record; + body: string; + startedAtMs: number; + completedAtMs: number; +}; + export type AgentOptions = { name: string; run: AgentRunFn;