Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 762ecec872 | |||
| c0ac4ade09 | |||
| a991393053 | |||
| 892ccab8d5 | |||
| 70c83c65b0 | |||
| 8a7e756fe3 | |||
| 4a4ddba9f6 | |||
| d5f47d1a18 | |||
| 37c35560e9 | |||
| f174b96028 | |||
| 43978360ff | |||
| 432400ee20 | |||
| dacebe1841 | |||
| c42125946d | |||
| 4c9ce72395 | |||
| 8b43f7993b | |||
| cf9e2cd3d6 | |||
| 7a99c1a9d6 | |||
| 546237db85 | |||
| 1ed7e32067 | |||
| bd5e5a435b | |||
| 67e689ff1a | |||
| 06eb2dff3b | |||
| a2bd3126c8 |
@@ -10,3 +10,4 @@ xiaoju/
|
|||||||
solve-issue-entry.ts
|
solve-issue-entry.ts
|
||||||
packages/workflow-template-develop/develop.esm.js
|
packages/workflow-template-develop/develop.esm.js
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.py
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/uwf-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
cmdThreadRead,
|
||||||
|
cmdThreadStepDetails,
|
||||||
|
extractLastAssistantContent,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
} from "../commands/thread.js";
|
||||||
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
|
import type { UwfStore } from "../store.js";
|
||||||
|
import { saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TURN_SCHEMA = {
|
||||||
|
title: "hermes-turn",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["index", "role", "content"],
|
||||||
|
properties: {
|
||||||
|
index: { type: "integer" as const },
|
||||||
|
role: { type: "string" as const },
|
||||||
|
content: { type: "string" as const },
|
||||||
|
toolCalls: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "array" as const, items: { type: "object" as const } },
|
||||||
|
{ type: "null" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETAIL_SCHEMA = {
|
||||||
|
title: "hermes-detail",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: "string" as const },
|
||||||
|
model: { type: "string" as const },
|
||||||
|
duration: { type: "integer" as const },
|
||||||
|
turnCount: { type: "integer" as const },
|
||||||
|
turns: {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "string" as const, format: "cas_ref" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||||
|
const casDir = join(storageRoot, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
return { storageRoot, store, schemas };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||||
|
await bootstrap(store);
|
||||||
|
const [turn, detail] = await Promise.all([
|
||||||
|
putSchema(store, TURN_SCHEMA),
|
||||||
|
putSchema(store, DETAIL_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { turn, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── extractLastAssistantContent ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("extractLastAssistantContent", () => {
|
||||||
|
test("returns last non-empty assistant content from turns", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const schemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const turn1 = await uwf.store.put(schemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "intermediate",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const turn2 = await uwf.store.put(schemas.turn, {
|
||||||
|
index: 1,
|
||||||
|
role: "tool",
|
||||||
|
content: "ok",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const turn3 = await uwf.store.put(schemas.turn, {
|
||||||
|
index: 2,
|
||||||
|
role: "assistant",
|
||||||
|
content: "final answer",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await uwf.store.put(schemas.detail, {
|
||||||
|
sessionId: "s1",
|
||||||
|
model: "m1",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 3,
|
||||||
|
turns: [turn1, turn2, turn3],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(extractLastAssistantContent(uwf, detailHash)).toBe("final answer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when detail node does not exist in store", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
expect(extractLastAssistantContent(uwf, "nonexistent00" as CasRef)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when turns array is empty", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const schemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const detailHash = await uwf.store.put(schemas.detail, {
|
||||||
|
sessionId: "s2",
|
||||||
|
model: "m2",
|
||||||
|
duration: 0,
|
||||||
|
turnCount: 0,
|
||||||
|
turns: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(extractLastAssistantContent(uwf, detailHash)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when all assistant turns have empty content", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const schemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const turn1 = await uwf.store.put(schemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await uwf.store.put(schemas.detail, {
|
||||||
|
sessionId: "s3",
|
||||||
|
model: "m3",
|
||||||
|
duration: 0,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turn1],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(extractLastAssistantContent(uwf, detailHash)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips whitespace-only assistant content and returns earlier match", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const schemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const turn1 = await uwf.store.put(schemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "real content",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const turn2 = await uwf.store.put(schemas.turn, {
|
||||||
|
index: 1,
|
||||||
|
role: "assistant",
|
||||||
|
content: " ",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await uwf.store.put(schemas.detail, {
|
||||||
|
sessionId: "s4",
|
||||||
|
model: "m4",
|
||||||
|
duration: 0,
|
||||||
|
turnCount: 2,
|
||||||
|
turns: [turn1, turn2],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(extractLastAssistantContent(uwf, detailHash)).toBe("real content");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadRead ### Content section", () => {
|
||||||
|
test("includes ### Content before ### Output when detail has assistant turns", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
writer: {
|
||||||
|
description: "Write",
|
||||||
|
systemPrompt: "You are a writer.",
|
||||||
|
outputSchema: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Write something",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "The assistant response text",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sx",
|
||||||
|
model: "mx",
|
||||||
|
duration: 500,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "writer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000001" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
expect(markdown).toContain("### Content");
|
||||||
|
expect(markdown).toContain("The assistant response text");
|
||||||
|
|
||||||
|
const contentIdx = markdown.indexOf("### Content");
|
||||||
|
const outputIdx = markdown.indexOf("### Output");
|
||||||
|
expect(contentIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(outputIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(contentIdx).toBeLessThan(outputIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits ### Content when detail has no matching assistant turns", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf2",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Do stuff",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// A detail ref that doesn't exist in the store → extractLastAssistantContent returns null
|
||||||
|
const missingDetailRef = "missingdetail0" as CasRef;
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: missingDetailRef,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000002" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
expect(markdown).not.toContain("### Content");
|
||||||
|
expect(markdown).toContain("### Output");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── cmdThreadStepDetails ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadStepDetails", () => {
|
||||||
|
test("returns expanded detail node with turns inlined", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "wf",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "done",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sess42",
|
||||||
|
model: "gpt-4o",
|
||||||
|
duration: 3000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "coder",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cmdThreadStepDetails(tmpDir, stepHash);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sessionId: "sess42",
|
||||||
|
model: "gpt-4o",
|
||||||
|
duration: 3000,
|
||||||
|
turnCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expanded = result as Record<string, unknown>;
|
||||||
|
expect(Array.isArray(expanded.turns)).toBe(true);
|
||||||
|
const turns = expanded.turns as unknown[];
|
||||||
|
expect(turns).toHaveLength(1);
|
||||||
|
expect(turns[0]).toMatchObject({
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "done",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when step hash does not exist", async () => {
|
||||||
|
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
+113
-40
@@ -1,28 +1,34 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import type { ThreadId } from "@uncaged/uwf-protocol";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import { stringify as yamlStringify } from "yaml";
|
||||||
import {
|
|
||||||
cmdThreadKill,
|
|
||||||
cmdThreadList,
|
|
||||||
cmdThreadShow,
|
|
||||||
cmdThreadStart,
|
|
||||||
cmdThreadStep,
|
|
||||||
} from "./commands/thread.js";
|
|
||||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
|
||||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
|
||||||
import {
|
import {
|
||||||
cmdCasGet,
|
cmdCasGet,
|
||||||
cmdCasHas,
|
cmdCasHas,
|
||||||
cmdCasPut,
|
cmdCasPut,
|
||||||
cmdCasReindex,
|
|
||||||
cmdCasRefs,
|
cmdCasRefs,
|
||||||
|
cmdCasReindex,
|
||||||
cmdCasSchemaGet,
|
cmdCasSchemaGet,
|
||||||
cmdCasSchemaList,
|
cmdCasSchemaList,
|
||||||
cmdCasWalk,
|
cmdCasWalk,
|
||||||
} from "./commands/cas.js";
|
} from "./commands/cas.js";
|
||||||
|
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||||
|
import {
|
||||||
|
cmdThreadFork,
|
||||||
|
cmdThreadKill,
|
||||||
|
cmdThreadList,
|
||||||
|
cmdThreadRead,
|
||||||
|
cmdThreadShow,
|
||||||
|
cmdThreadStart,
|
||||||
|
cmdThreadStep,
|
||||||
|
cmdThreadStepDetails,
|
||||||
|
cmdThreadSteps,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
} from "./commands/thread.js";
|
||||||
|
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
|
import { formatOutput, type OutputFormat } from "./format.js";
|
||||||
import { resolveStorageRoot } from "./store.js";
|
import { resolveStorageRoot } from "./store.js";
|
||||||
import { type OutputFormat, formatOutput } from "./format.js";
|
|
||||||
|
|
||||||
function writeOutput(data: unknown): void {
|
function writeOutput(data: unknown): void {
|
||||||
const fmt = program.opts().format as OutputFormat;
|
const fmt = program.opts().format as OutputFormat;
|
||||||
@@ -144,6 +150,71 @@ thread
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("steps")
|
||||||
|
.description("List all steps in a thread")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadSteps(storageRoot, threadId);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("read")
|
||||||
|
.description("Read thread context as human-readable markdown")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
|
||||||
|
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
|
||||||
|
.option("--start", "Include start step in output")
|
||||||
|
.action(
|
||||||
|
(threadId: string, opts: { quota: string; before: string | undefined; start: boolean }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const quota = Number.parseInt(opts.quota, 10);
|
||||||
|
if (!Number.isFinite(quota) || quota < 1) {
|
||||||
|
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const before = opts.before ?? null;
|
||||||
|
const markdown = await cmdThreadRead(
|
||||||
|
storageRoot,
|
||||||
|
threadId as ThreadId,
|
||||||
|
quota,
|
||||||
|
before,
|
||||||
|
opts.start ?? false,
|
||||||
|
);
|
||||||
|
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("fork")
|
||||||
|
.description("Fork a thread from a specific step")
|
||||||
|
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
|
||||||
|
.action((stepHash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadFork(storageRoot, stepHash);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("step-details")
|
||||||
|
.description("Dump the full detail node of a step as YAML")
|
||||||
|
.argument("<step-hash>", "CAS hash of the StepNode")
|
||||||
|
.action((stepHash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const detail = await cmdThreadStepDetails(storageRoot, stepHash);
|
||||||
|
process.stdout.write(yamlStringify(detail));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("setup")
|
.command("setup")
|
||||||
.description("Configure provider, model, and agent")
|
.description("Configure provider, model, and agent")
|
||||||
@@ -152,34 +223,36 @@ program
|
|||||||
.option("--api-key <key>", "API key")
|
.option("--api-key <key>", "API key")
|
||||||
.option("--model <name>", "Default model name")
|
.option("--model <name>", "Default model name")
|
||||||
.option("--agent <name>", "Default agent alias")
|
.option("--agent <name>", "Default agent alias")
|
||||||
.action((opts: {
|
.action(
|
||||||
provider?: string;
|
(opts: {
|
||||||
baseUrl?: string;
|
provider?: string;
|
||||||
apiKey?: string;
|
baseUrl?: string;
|
||||||
model?: string;
|
apiKey?: string;
|
||||||
agent?: string;
|
model?: string;
|
||||||
}) => {
|
agent?: string;
|
||||||
const storageRoot = resolveStorageRoot();
|
}) => {
|
||||||
runAction(async () => {
|
const storageRoot = resolveStorageRoot();
|
||||||
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
runAction(async () => {
|
||||||
const result = await cmdSetup({
|
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
||||||
provider: opts.provider,
|
const result = await cmdSetup({
|
||||||
baseUrl: opts.baseUrl,
|
provider: opts.provider,
|
||||||
apiKey: opts.apiKey,
|
baseUrl: opts.baseUrl,
|
||||||
model: opts.model,
|
apiKey: opts.apiKey,
|
||||||
agent: opts.agent ?? undefined,
|
model: opts.model,
|
||||||
storageRoot,
|
agent: opts.agent ?? undefined,
|
||||||
});
|
storageRoot,
|
||||||
writeOutput(result);
|
});
|
||||||
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
writeOutput(result);
|
||||||
await cmdSetupInteractive(storageRoot);
|
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
||||||
} else {
|
await cmdSetupInteractive(storageRoot);
|
||||||
throw new Error(
|
} else {
|
||||||
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
|
throw new Error(
|
||||||
);
|
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
|
||||||
}
|
);
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const cas = program.command("cas").description("Content-addressable storage operations");
|
const cas = program.command("cas").description("Content-addressable storage operations");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
||||||
import { validate } from "@uncaged/json-cas";
|
import { getSchema, validate } from "@uncaged/json-cas";
|
||||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||||
import { evaluate } from "@uncaged/uwf-moderator";
|
import { evaluate } from "@uncaged/uwf-moderator";
|
||||||
import type {
|
import type {
|
||||||
@@ -8,18 +8,23 @@ import type {
|
|||||||
AgentConfig,
|
AgentConfig,
|
||||||
CasRef,
|
CasRef,
|
||||||
ModeratorContext,
|
ModeratorContext,
|
||||||
|
StartEntry,
|
||||||
StartNodePayload,
|
StartNodePayload,
|
||||||
StartOutput,
|
StartOutput,
|
||||||
StepContext,
|
StepContext,
|
||||||
|
StepEntry,
|
||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
StepOutput,
|
StepOutput,
|
||||||
|
ThreadForkOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
|
ThreadStepsOutput,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowPayload,
|
WorkflowPayload,
|
||||||
} from "@uncaged/uwf-protocol";
|
} from "@uncaged/uwf-protocol";
|
||||||
import { generateUlid } from "@uncaged/workflow-util";
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appendThreadHistory,
|
appendThreadHistory,
|
||||||
@@ -36,6 +41,7 @@ import {
|
|||||||
import { isCasRef } from "../validate.js";
|
import { isCasRef } from "../validate.js";
|
||||||
|
|
||||||
const END_ROLE = "$END";
|
const END_ROLE = "$END";
|
||||||
|
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||||
|
|
||||||
type ChainState = {
|
type ChainState = {
|
||||||
startHash: CasRef;
|
startHash: CasRef;
|
||||||
@@ -44,6 +50,12 @@ type ChainState = {
|
|||||||
headIsStart: boolean;
|
headIsStart: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrderedStepItem = {
|
||||||
|
hash: CasRef;
|
||||||
|
payload: StepNodePayload;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type KillOutput = {
|
export type KillOutput = {
|
||||||
thread: ThreadId;
|
thread: ThreadId;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
@@ -262,6 +274,247 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
|||||||
return node.payload;
|
return node.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively expand all cas_ref fields in a CAS node's payload,
|
||||||
|
* replacing hash strings with the referenced node's expanded payload.
|
||||||
|
*/
|
||||||
|
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
|
||||||
|
const seen = visited ?? new Set<string>();
|
||||||
|
if (seen.has(hash)) return hash; // cycle guard
|
||||||
|
seen.add(hash);
|
||||||
|
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) return hash;
|
||||||
|
|
||||||
|
const schema = getSchema(store, node.type);
|
||||||
|
if (schema === null) return node.payload;
|
||||||
|
|
||||||
|
return expandValue(store, schema, node.payload, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandValue(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
// If this field is a cas_ref, expand it
|
||||||
|
if (schema.format === "cas_ref") {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return expandDeep(store, value as CasRef, visited);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyOf (nullable refs)
|
||||||
|
if (Array.isArray(schema.anyOf)) {
|
||||||
|
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||||
|
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||||
|
return expandDeep(store, value as CasRef, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array of cas_ref items
|
||||||
|
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||||
|
const itemSchema = schema.items as JSONSchema;
|
||||||
|
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object with properties
|
||||||
|
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
|
||||||
|
const props = schema.properties as Record<string, JSONSchema>;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
const propSchema = props[key];
|
||||||
|
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOrderedSteps(
|
||||||
|
uwf: UwfStore,
|
||||||
|
headHash: CasRef,
|
||||||
|
chain: ChainState,
|
||||||
|
): OrderedStepItem[] {
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null || node.type !== uwf.schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
hashToNode.set(hash, { payload, timestamp: node.timestamp });
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cur: CasRef | null = chain.headIsStart ? null : headHash;
|
||||||
|
const ordered: OrderedStepItem[] = [];
|
||||||
|
while (cur !== null) {
|
||||||
|
const entry = hashToNode.get(cur);
|
||||||
|
if (entry === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ordered.push({ hash: cur, ...entry });
|
||||||
|
cur = entry.payload.prev;
|
||||||
|
}
|
||||||
|
ordered.reverse();
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYaml(value: unknown): string {
|
||||||
|
return stringify(value).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
||||||
|
return [
|
||||||
|
`## Step ${index}: ${item.payload.role}`,
|
||||||
|
"",
|
||||||
|
`- **Hash:** \`${item.hash}\``,
|
||||||
|
`- **Agent:** ${item.payload.agent}`,
|
||||||
|
"",
|
||||||
|
"### Output",
|
||||||
|
"",
|
||||||
|
"```yaml",
|
||||||
|
outputYaml,
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
|
||||||
|
const detailNode = uwf.store.get(detailRef);
|
||||||
|
if (detailNode === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detail = detailNode.payload as Record<string, unknown>;
|
||||||
|
const turns = detail.turns;
|
||||||
|
if (!Array.isArray(turns) || turns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (let i = turns.length - 1; i >= 0; i--) {
|
||||||
|
const turnRef = turns[i];
|
||||||
|
if (typeof turnRef !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const turnNode = uwf.store.get(turnRef as CasRef);
|
||||||
|
if (turnNode === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const turn = turnNode.payload as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
turn.role === "assistant" &&
|
||||||
|
typeof turn.content === "string" &&
|
||||||
|
turn.content.trim() !== ""
|
||||||
|
) {
|
||||||
|
return turn.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatThreadReadMarkdown(options: {
|
||||||
|
threadId: ThreadId;
|
||||||
|
workflowName: string;
|
||||||
|
workflowHash: CasRef;
|
||||||
|
prompt: string;
|
||||||
|
ordered: OrderedStepItem[];
|
||||||
|
uwf: UwfStore;
|
||||||
|
workflow: WorkflowPayload;
|
||||||
|
quota: number;
|
||||||
|
before: CasRef | null;
|
||||||
|
showStart: boolean;
|
||||||
|
}): string {
|
||||||
|
const { ordered, uwf, workflow, quota, before, showStart } = options;
|
||||||
|
|
||||||
|
// Determine which steps to consider
|
||||||
|
let candidates = ordered;
|
||||||
|
if (before !== null) {
|
||||||
|
const idx = candidates.findIndex((s) => s.hash === before);
|
||||||
|
if (idx === -1) {
|
||||||
|
fail(`step ${before} not found in thread ${options.threadId}`);
|
||||||
|
}
|
||||||
|
candidates = candidates.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk backward from newest, accumulating chars until quota exceeded
|
||||||
|
const selected: OrderedStepItem[] = [];
|
||||||
|
let totalChars = 0;
|
||||||
|
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||||
|
const item = candidates[i];
|
||||||
|
if (item === undefined) continue;
|
||||||
|
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||||
|
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
||||||
|
selected.unshift(item);
|
||||||
|
totalChars += blockLen;
|
||||||
|
if (totalChars > quota) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedCount = candidates.length - selected.length;
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Start section
|
||||||
|
if (before === null || showStart) {
|
||||||
|
parts.push(
|
||||||
|
[
|
||||||
|
`# Thread \`${options.threadId}\``,
|
||||||
|
"",
|
||||||
|
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
|
||||||
|
"",
|
||||||
|
"## Task",
|
||||||
|
"",
|
||||||
|
options.prompt,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hint
|
||||||
|
if (skippedCount > 0 && selected.length > 0) {
|
||||||
|
const firstSelected = selected[0];
|
||||||
|
if (firstSelected !== undefined) {
|
||||||
|
parts.push(
|
||||||
|
`*(${skippedCount} earlier step${skippedCount > 1 ? "s" : ""}, load with \`uwf thread read ${options.threadId} --before ${firstSelected.hash}\`)*`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step blocks
|
||||||
|
const startIndex = candidates.length - selected.length;
|
||||||
|
for (let i = 0; i < selected.length; i++) {
|
||||||
|
const item = selected[i];
|
||||||
|
if (item === undefined) continue;
|
||||||
|
const stepNum = startIndex + i + 1;
|
||||||
|
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||||
|
const ts = new Date(item.timestamp)
|
||||||
|
.toISOString()
|
||||||
|
.replace("T", " ")
|
||||||
|
.replace(/\.\d+Z$/, "");
|
||||||
|
const stepLines = [
|
||||||
|
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||||
|
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||||
|
];
|
||||||
|
const roleDef = workflow.roles[item.payload.role];
|
||||||
|
if (roleDef) {
|
||||||
|
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
|
||||||
|
}
|
||||||
|
if (item.payload.detail) {
|
||||||
|
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||||
|
if (content !== null) {
|
||||||
|
stepLines.push("", "### Content", "", content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
|
||||||
|
parts.push(stepLines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n\n---\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||||
const steps: StepContext[] = chronological.map((step) => ({
|
const steps: StepContext[] = chronological.map((step) => ({
|
||||||
@@ -437,6 +690,132 @@ export async function cmdThreadStep(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const activeHead = index[threadId];
|
||||||
|
if (activeHead !== undefined) {
|
||||||
|
return activeHead;
|
||||||
|
}
|
||||||
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
|
if (hist !== null) {
|
||||||
|
return hist.head;
|
||||||
|
}
|
||||||
|
fail(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadSteps(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): Promise<ThreadStepsOutput> {
|
||||||
|
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const chain = walkChain(uwf, headHash);
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(chain.startHash);
|
||||||
|
if (startNode === null) {
|
||||||
|
fail(`StartNode not found: ${chain.startHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEntry: StartEntry = {
|
||||||
|
hash: chain.startHash,
|
||||||
|
workflow: chain.start.workflow,
|
||||||
|
prompt: chain.start.prompt,
|
||||||
|
timestamp: startNode.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepEntries: StepEntry[] = [];
|
||||||
|
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
||||||
|
|
||||||
|
for (const item of ordered) {
|
||||||
|
stepEntries.push({
|
||||||
|
hash: item.hash,
|
||||||
|
role: item.payload.role,
|
||||||
|
output: expandOutput(uwf, item.payload.output),
|
||||||
|
detail: item.payload.detail,
|
||||||
|
agent: item.payload.agent,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: chain.start.workflow,
|
||||||
|
steps: [startEntry, ...stepEntries],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadRead(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
quota: number = THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
before: CasRef | null = null,
|
||||||
|
showStart: boolean = false,
|
||||||
|
): Promise<string> {
|
||||||
|
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const chain = walkChain(uwf, headHash);
|
||||||
|
const workflow = loadWorkflowPayload(uwf, chain.start.workflow);
|
||||||
|
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
||||||
|
|
||||||
|
return formatThreadReadMarkdown({
|
||||||
|
threadId,
|
||||||
|
workflowName: workflow.name,
|
||||||
|
workflowHash: chain.start.workflow,
|
||||||
|
prompt: chain.start.prompt,
|
||||||
|
ordered,
|
||||||
|
uwf,
|
||||||
|
workflow,
|
||||||
|
quota,
|
||||||
|
before,
|
||||||
|
showStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadFork(
|
||||||
|
storageRoot: string,
|
||||||
|
stepHash: CasRef,
|
||||||
|
): Promise<ThreadForkOutput> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const node = uwf.store.get(stepHash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${stepHash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`node ${stepHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newThreadId = generateUlid(Date.now()) as ThreadId;
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
index[newThreadId] = stepHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
thread: newThreadId,
|
||||||
|
forkedFrom: {
|
||||||
|
step: stepHash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStepDetails(
|
||||||
|
storageRoot: string,
|
||||||
|
stepHash: CasRef,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const node = uwf.store.get(stepHash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${stepHash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`node ${stepHash} is not a StepNode`);
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
if (!payload.detail) {
|
||||||
|
fail(`step ${stepHash} has no detail`);
|
||||||
|
}
|
||||||
|
return expandDeep(uwf.store, payload.detail);
|
||||||
|
}
|
||||||
|
|
||||||
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
const head = index[threadId];
|
const head = index[threadId];
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/__tests__/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -16,7 +16,12 @@ describe("parseSessionIdFromStdout", () => {
|
|||||||
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
|
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when trailing line is not session_id", () => {
|
test("reads session_id from the first line (quiet mode)", () => {
|
||||||
|
const stdout = "session_id: 20260518_165315_3467a1\nHello world\n";
|
||||||
|
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_165315_3467a1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when no session_id line present", () => {
|
||||||
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
|
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ function buildHistorySummary(steps: AgentContext["steps"]): string {
|
|||||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||||
const roleDef = ctx.workflow.roles[ctx.role];
|
const roleDef = ctx.workflow.roles[ctx.role];
|
||||||
const systemPrompt = roleDef?.systemPrompt ?? "";
|
const systemPrompt = roleDef?.systemPrompt ?? "";
|
||||||
const parts: string[] = [systemPrompt, "", "## Task", ctx.start.prompt];
|
const parts: string[] = [];
|
||||||
|
if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") {
|
||||||
|
parts.push(ctx.outputFormatInstruction, "");
|
||||||
|
}
|
||||||
|
parts.push(systemPrompt, "", "## Task", ctx.start.prompt);
|
||||||
const historyBlock = buildHistorySummary(ctx.steps);
|
const historyBlock = buildHistorySummary(ctx.steps);
|
||||||
if (historyBlock !== "") {
|
if (historyBlock !== "") {
|
||||||
parts.push("", historyBlock);
|
parts.push("", historyBlock);
|
||||||
@@ -43,7 +47,7 @@ export function buildHermesPrompt(ctx: AgentContext): string {
|
|||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnHermesChat(prompt: string): Promise<string> {
|
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const args = [
|
const args = [
|
||||||
"chat",
|
"chat",
|
||||||
@@ -76,7 +80,7 @@ function spawnHermesChat(prompt: string): Promise<string> {
|
|||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve(stdout);
|
resolve({ stdout, stderr });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
||||||
@@ -87,10 +91,11 @@ function spawnHermesChat(prompt: string): Promise<string> {
|
|||||||
|
|
||||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||||
const fullPrompt = buildHermesPrompt(ctx);
|
const fullPrompt = buildHermesPrompt(ctx);
|
||||||
const rawOutput = await spawnHermesChat(fullPrompt);
|
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
|
||||||
const { store } = ctx;
|
const { store } = ctx;
|
||||||
|
|
||||||
const sessionId = parseSessionIdFromStdout(rawOutput);
|
// --quiet mode: session_id may be on stdout or stderr
|
||||||
|
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||||
if (sessionId !== null) {
|
if (sessionId !== null) {
|
||||||
const session = await loadHermesSession(sessionId);
|
const session = await loadHermesSession(sessionId);
|
||||||
if (session !== null) {
|
if (session !== null) {
|
||||||
@@ -99,8 +104,8 @@ async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const detailHash = await storeHermesRawOutput(store, rawOutput);
|
const detailHash = await storeHermesRawOutput(store, stdout);
|
||||||
return { output: rawOutput, detailHash };
|
return { output: stdout, detailHash };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||||
|
|||||||
@@ -24,19 +24,14 @@ export function getHermesSessionPath(sessionId: string): string {
|
|||||||
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
|
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse `session_id: …` from the last non-empty line of Hermes stdout. */
|
/** Parse `session_id: …` from any line of Hermes stdout. */
|
||||||
export function parseSessionIdFromStdout(stdout: string): string | null {
|
export function parseSessionIdFromStdout(stdout: string): string | null {
|
||||||
const lines = stdout.split(/\r?\n/).map((line) => line.trim());
|
const lines = stdout.split(/\r?\n/);
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
for (const line of lines) {
|
||||||
const line = lines[i];
|
const match = SESSION_ID_LINE.exec(line.trim());
|
||||||
if (line === undefined || line === "") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const match = SESSION_ID_LINE.exec(line);
|
|
||||||
if (match?.[1] !== undefined) {
|
if (match?.[1] !== undefined) {
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||||
|
|
||||||
|
describe("buildOutputFormatInstruction", () => {
|
||||||
|
test("always includes the frontmatter example block", () => {
|
||||||
|
const result = buildOutputFormatInstruction({});
|
||||||
|
expect(result).toContain("---");
|
||||||
|
expect(result).toContain("status: done");
|
||||||
|
expect(result).toContain("confidence:");
|
||||||
|
expect(result).toContain("scope: role");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("always marks frontmatter as the primary deliverable", () => {
|
||||||
|
const result = buildOutputFormatInstruction({});
|
||||||
|
expect(result).toContain("primary deliverable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists fields from a flat object schema", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string" },
|
||||||
|
confidence: { type: "number" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("`status`");
|
||||||
|
expect(result).toContain("`confidence`");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists union of fields from an anyOf schema", () => {
|
||||||
|
const schema = {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { alpha: { type: "string" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { beta: { type: "number" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("`alpha`");
|
||||||
|
expect(result).toContain("`beta`");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists union of fields from a oneOf schema", () => {
|
||||||
|
const schema = {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { foo: { type: "string" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { bar: { type: "boolean" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("`foo`");
|
||||||
|
expect(result).toContain("`bar`");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||||
|
const result = buildOutputFormatInstruction({ type: "string" });
|
||||||
|
expect(result).toContain("schema fields will be extracted automatically");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not list a field more than once for a union with overlapping keys", () => {
|
||||||
|
const schema = {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "object", properties: { shared: { type: "string" } } },
|
||||||
|
{ type: "object", properties: { shared: { type: "number" } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
const matches = [...result.matchAll(/`shared`/g)];
|
||||||
|
expect(matches.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes focus reminder about role scope", () => {
|
||||||
|
const result = buildOutputFormatInstruction({});
|
||||||
|
expect(result).toContain("Focus exclusively on YOUR role");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
|
||||||
|
|
||||||
|
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** JSON Schema that exactly matches the AgentFrontmatter fields. */
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** JSON Schema that requires a non-frontmatter field — fast path must not satisfy it. */
|
||||||
|
const STRICT_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
requiredField: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["requiredField"],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function makeStoreWithSchema(schema: Record<string, unknown>) {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const schemaHash = await putSchema(store, schema);
|
||||||
|
return { store, schemaHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("tryFrontmatterFastPath — happy path", () => {
|
||||||
|
test("parses valid frontmatter and returns outputHash + stripped body", async () => {
|
||||||
|
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||||
|
|
||||||
|
const raw = [
|
||||||
|
"---",
|
||||||
|
"status: done",
|
||||||
|
"next: reviewer",
|
||||||
|
"confidence: 0.9",
|
||||||
|
"artifacts: [src/foo.ts]",
|
||||||
|
"scope: role",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## Summary",
|
||||||
|
"Work is complete.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.body).toContain("## Summary");
|
||||||
|
expect(result?.body).toContain("Work is complete.");
|
||||||
|
expect(result?.body).not.toContain("status: done");
|
||||||
|
expect(typeof result?.outputHash).toBe("string");
|
||||||
|
expect((result?.outputHash ?? "").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stored CAS node payload matches frontmatter fields", async () => {
|
||||||
|
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||||
|
|
||||||
|
const raw = "---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
|
||||||
|
|
||||||
|
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
|
const node = store.get(result!.outputHash);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
const payload = node!.payload as Record<string, unknown>;
|
||||||
|
expect(payload.status).toBe("done");
|
||||||
|
expect(payload.next).toBeNull();
|
||||||
|
expect(payload.confidence).toBeNull();
|
||||||
|
expect(payload.artifacts).toEqual([]);
|
||||||
|
expect(payload.scope).toBe("role");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fallback: no frontmatter ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
|
||||||
|
test("returns null for plain markdown without frontmatter block", async () => {
|
||||||
|
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||||
|
|
||||||
|
const result = await tryFrontmatterFastPath(
|
||||||
|
"This is plain markdown without any frontmatter.",
|
||||||
|
schemaHash,
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fallback: invalid frontmatter ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("tryFrontmatterFastPath — fallback: invalid frontmatter", () => {
|
||||||
|
test("returns null when confidence is out of range [0, 1]", async () => {
|
||||||
|
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||||
|
|
||||||
|
const raw = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
|
||||||
|
|
||||||
|
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when next contains whitespace", async () => {
|
||||||
|
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||||
|
|
||||||
|
const raw = "---\nstatus: done\nnext: some role\nscope: role\n---\n\nBody.";
|
||||||
|
|
||||||
|
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fallback: schema mismatch ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
|
||||||
|
test("returns null when outputSchema requires fields not in frontmatter", async () => {
|
||||||
|
const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA);
|
||||||
|
|
||||||
|
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||||
|
|
||||||
|
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@uncaged/json-cas": "^0.3.0",
|
"@uncaged/json-cas": "^0.3.0",
|
||||||
"@uncaged/json-cas-fs": "^0.3.0",
|
"@uncaged/json-cas-fs": "^0.3.0",
|
||||||
"@uncaged/uwf-protocol": "workspace:^",
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract top-level property names from a JSON Schema object.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Object schemas with a `properties` key
|
||||||
|
* - Union schemas via `anyOf` / `oneOf` — union of all variant property names
|
||||||
|
*
|
||||||
|
* Returns an empty array for schemas with no inspectable property definitions.
|
||||||
|
*/
|
||||||
|
function extractSchemaFields(schema: JSONSchema): string[] {
|
||||||
|
if (typeof schema.properties === "object" && schema.properties !== null) {
|
||||||
|
return Object.keys(schema.properties as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unionKey = Array.isArray(schema.anyOf)
|
||||||
|
? "anyOf"
|
||||||
|
: Array.isArray(schema.oneOf)
|
||||||
|
? "oneOf"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (unionKey !== null) {
|
||||||
|
const variants = schema[unionKey] as JSONSchema[];
|
||||||
|
const fieldSet = new Set<string>();
|
||||||
|
for (const variant of variants) {
|
||||||
|
for (const field of extractSchemaFields(variant)) {
|
||||||
|
fieldSet.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...fieldSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a concise output format instruction block for an agent role.
|
||||||
|
*
|
||||||
|
* The instruction describes the expected frontmatter markdown format and lists
|
||||||
|
* the meta fields derived from the JSON Schema. It is prepended to the agent's
|
||||||
|
* system prompt so the deliverable format is the first thing the agent sees.
|
||||||
|
*/
|
||||||
|
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||||
|
const fields = extractSchemaFields(schema);
|
||||||
|
|
||||||
|
const fieldList =
|
||||||
|
fields.length > 0
|
||||||
|
? fields.map((f) => ` - \`${f}\``).join("\n")
|
||||||
|
: " (schema fields will be extracted automatically)";
|
||||||
|
|
||||||
|
return `## Deliverable Format
|
||||||
|
|
||||||
|
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
---
|
||||||
|
status: done # done | needs_input | in_progress | failed
|
||||||
|
next: <role-name> # suggested next role, or omit
|
||||||
|
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
|
||||||
|
artifacts: # list of file paths or CAS hashes you produced
|
||||||
|
- path/to/file.ts
|
||||||
|
scope: role # role | thread
|
||||||
|
---
|
||||||
|
|
||||||
|
... your markdown work here ...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||||
|
Your meta output must satisfy these fields:
|
||||||
|
|
||||||
|
${fieldList}
|
||||||
|
|
||||||
|
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||||
|
}
|
||||||
@@ -152,6 +152,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
|||||||
steps,
|
steps,
|
||||||
workflow,
|
workflow,
|
||||||
store,
|
store,
|
||||||
|
outputFormatInstruction: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +197,7 @@ export async function buildContextWithMeta(
|
|||||||
steps,
|
steps,
|
||||||
workflow,
|
workflow,
|
||||||
store,
|
store,
|
||||||
|
outputFormatInstruction: "",
|
||||||
meta: { storageRoot, store, schemas, headHash, chain },
|
meta: { storageRoot, store, schemas, headHash, chain },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { validate } from "@uncaged/json-cas";
|
||||||
|
import type { Store } from "@uncaged/json-cas";
|
||||||
|
import type { CasRef } from "@uncaged/uwf-protocol";
|
||||||
|
import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
|
export type FrontmatterFastPathResult = {
|
||||||
|
body: string;
|
||||||
|
outputHash: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to satisfy `outputSchema` from frontmatter fields alone.
|
||||||
|
*
|
||||||
|
* Returns a result containing the stored CAS hash and stripped body on success,
|
||||||
|
* or `null` when frontmatter is absent, invalid, or does not satisfy the schema.
|
||||||
|
* Never throws.
|
||||||
|
*
|
||||||
|
* The candidate object is put into the real CAS store (idempotent content-addressed
|
||||||
|
* write) and validated against the output schema. If validation fails the node
|
||||||
|
* is orphaned — it will be GC'd on the next collection pass.
|
||||||
|
*/
|
||||||
|
export async function tryFrontmatterFastPath(
|
||||||
|
raw: string,
|
||||||
|
outputSchema: CasRef,
|
||||||
|
store: Store,
|
||||||
|
): Promise<FrontmatterFastPathResult | null> {
|
||||||
|
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
||||||
|
|
||||||
|
if (frontmatter === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationErrors = validateFrontmatter(frontmatter);
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate: Record<string, unknown> = {
|
||||||
|
status: frontmatter.status,
|
||||||
|
next: frontmatter.next,
|
||||||
|
confidence: frontmatter.confidence,
|
||||||
|
artifacts: [...frontmatter.artifacts],
|
||||||
|
scope: frontmatter.scope,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outputHash: CasRef;
|
||||||
|
let node: ReturnType<Store["get"]>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputHash = await store.put(outputSchema, candidate);
|
||||||
|
node = store.get(outputHash);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node === null || !validate(store, node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { body, outputHash };
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ export {
|
|||||||
resolveExtractModelAlias,
|
resolveExtractModelAlias,
|
||||||
resolveModel,
|
resolveModel,
|
||||||
} from "./extract.js";
|
} from "./extract.js";
|
||||||
|
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||||
|
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||||
|
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||||
export { createAgent } from "./run.js";
|
export { createAgent } from "./run.js";
|
||||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||||
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
|
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { validate } from "@uncaged/json-cas";
|
import { getSchema, validate } from "@uncaged/json-cas";
|
||||||
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
|
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
|
||||||
import { buildContextWithMeta } from "./context.js";
|
import { buildContextWithMeta } from "./context.js";
|
||||||
|
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||||
import { extract } from "./extract.js";
|
import { extract } from "./extract.js";
|
||||||
|
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||||
import type { AgentStore } from "./storage.js";
|
import type { AgentStore } from "./storage.js";
|
||||||
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||||
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
||||||
@@ -73,7 +75,16 @@ async function extractOutput(
|
|||||||
rawOutput: string,
|
rawOutput: string,
|
||||||
outputSchema: CasRef,
|
outputSchema: CasRef,
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
|
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||||
): Promise<CasRef> {
|
): Promise<CasRef> {
|
||||||
|
const fastPath = await runWithMessage("frontmatter fast path", () =>
|
||||||
|
tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store),
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
if (fastPath !== null) {
|
||||||
|
return fastPath.outputHash;
|
||||||
|
}
|
||||||
|
|
||||||
const config = await runWithMessage("failed to load config", () =>
|
const config = await runWithMessage("failed to load config", () =>
|
||||||
loadWorkflowConfig(storageRoot),
|
loadWorkflowConfig(storageRoot),
|
||||||
);
|
);
|
||||||
@@ -120,8 +131,13 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
|||||||
fail(`unknown role: ${role}`);
|
fail(`unknown role: ${role}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const outputSchema = getSchema(ctx.meta.store, roleDef.outputSchema);
|
||||||
|
if (outputSchema !== null) {
|
||||||
|
ctx.outputFormatInstruction = buildOutputFormatInstruction(outputSchema);
|
||||||
|
}
|
||||||
|
|
||||||
const agentResult = await runAgent(options, ctx);
|
const agentResult = await runAgent(options, ctx);
|
||||||
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot);
|
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot, ctx);
|
||||||
const stepHash = await persistStep({
|
const stepHash = await persistStep({
|
||||||
ctx,
|
ctx,
|
||||||
outputHash,
|
outputHash,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export type AgentContext = ModeratorContext & {
|
|||||||
role: string;
|
role: string;
|
||||||
store: Store;
|
store: Store;
|
||||||
workflow: WorkflowPayload;
|
workflow: WorkflowPayload;
|
||||||
|
/**
|
||||||
|
* Prepend to the role's systemPrompt when building the agent prompt.
|
||||||
|
* Contains the frontmatter deliverable format instruction derived from the
|
||||||
|
* role's output schema. Populated by `createAgent` at run time.
|
||||||
|
*/
|
||||||
|
outputFormatInstruction: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentRunResult = {
|
export type AgentRunResult = {
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ export type {
|
|||||||
RoleDefinition,
|
RoleDefinition,
|
||||||
RoleName,
|
RoleName,
|
||||||
Scenario,
|
Scenario,
|
||||||
|
StartEntry,
|
||||||
StartNodePayload,
|
StartNodePayload,
|
||||||
StartOutput,
|
StartOutput,
|
||||||
StepContext,
|
StepContext,
|
||||||
|
StepEntry,
|
||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
StepOutput,
|
StepOutput,
|
||||||
StepRecord,
|
StepRecord,
|
||||||
|
ThreadForkOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
|
ThreadStepsOutput,
|
||||||
ThreadsIndex,
|
ThreadsIndex,
|
||||||
Transition,
|
Transition,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
|
|||||||
@@ -80,6 +80,39 @@ export type StepOutput = {
|
|||||||
done: boolean;
|
done: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** uwf thread steps — single step entry */
|
||||||
|
export type StepEntry = {
|
||||||
|
hash: CasRef;
|
||||||
|
role: string;
|
||||||
|
output: unknown;
|
||||||
|
detail: CasRef;
|
||||||
|
agent: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread steps — start entry */
|
||||||
|
export type StartEntry = {
|
||||||
|
hash: CasRef;
|
||||||
|
workflow: CasRef;
|
||||||
|
prompt: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread steps output */
|
||||||
|
export type ThreadStepsOutput = {
|
||||||
|
thread: ThreadId;
|
||||||
|
workflow: CasRef;
|
||||||
|
steps: [StartEntry, ...StepEntry[]];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread fork output */
|
||||||
|
export type ThreadForkOutput = {
|
||||||
|
thread: ThreadId;
|
||||||
|
forkedFrom: {
|
||||||
|
step: CasRef;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/** uwf thread list */
|
/** uwf thread list */
|
||||||
export type ThreadListItem = {
|
export type ThreadListItem = {
|
||||||
thread: ThreadId;
|
thread: ThreadId;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||||
|
|
||||||
|
describe("buildOutputFormatInstruction", () => {
|
||||||
|
test("always includes the frontmatter example block", () => {
|
||||||
|
const schema = z.object({ status: z.string() });
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("## Deliverable Format");
|
||||||
|
expect(result).toContain("status:");
|
||||||
|
expect(result).toContain("confidence:");
|
||||||
|
expect(result).toContain("artifacts:");
|
||||||
|
expect(result).toContain("scope:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("always includes scope reminder", () => {
|
||||||
|
const schema = z.object({ status: z.string() });
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("Focus exclusively on YOUR role's deliverable");
|
||||||
|
expect(result).toContain("Do not perform actions outside your role's scope");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists fields from a flat ZodObject schema", () => {
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
phases: z.array(z.string()),
|
||||||
|
reason: z.union([z.string(), z.null()]),
|
||||||
|
});
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("`title`");
|
||||||
|
expect(result).toContain("`phases`");
|
||||||
|
expect(result).toContain("`reason`");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists union of fields from a discriminated union schema", () => {
|
||||||
|
const schema = z.discriminatedUnion("status", [
|
||||||
|
z.object({ status: z.literal("planned"), phases: z.array(z.string()) }),
|
||||||
|
z.object({ status: z.literal("aborted"), reason: z.string() }),
|
||||||
|
]);
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("`status`");
|
||||||
|
expect(result).toContain("`phases`");
|
||||||
|
expect(result).toContain("`reason`");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists fields from a plain ZodUnion schema", () => {
|
||||||
|
const schema = z.union([
|
||||||
|
z.object({ kind: z.literal("a"), valueA: z.string() }),
|
||||||
|
z.object({ kind: z.literal("b"), valueB: z.number() }),
|
||||||
|
]);
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("`kind`");
|
||||||
|
expect(result).toContain("`valueA`");
|
||||||
|
expect(result).toContain("`valueB`");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back gracefully for a non-object schema (no field list crash)", () => {
|
||||||
|
const schema = z.string();
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("## Deliverable Format");
|
||||||
|
expect(result).toContain("schema fields will be extracted automatically");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks frontmatter as the primary deliverable", () => {
|
||||||
|
const schema = z.object({ done: z.boolean() });
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("primary deliverable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no field is listed more than once for a union with overlapping keys", () => {
|
||||||
|
const schema = z.union([
|
||||||
|
z.object({ status: z.literal("a"), shared: z.string() }),
|
||||||
|
z.object({ status: z.literal("b"), shared: z.string() }),
|
||||||
|
]);
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
const matches = [...result.matchAll(/`shared`/g)];
|
||||||
|
expect(matches.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const mock = vi.fn;
|
||||||
|
|
||||||
|
import type { CasStore } from "@uncaged/workflow-cas";
|
||||||
|
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
import { createAgentAdapter } from "../src/index.js";
|
||||||
|
|
||||||
|
// ── Minimal test fixtures ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeCtx(): ThreadContext {
|
||||||
|
return {
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
depth: 0,
|
||||||
|
bundleHash: "TESTHASH00001",
|
||||||
|
start: {
|
||||||
|
role: "START" as const,
|
||||||
|
content: "test task",
|
||||||
|
meta: {},
|
||||||
|
timestamp: 1,
|
||||||
|
parentState: null,
|
||||||
|
},
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCas(): CasStore & { store: Map<string, string> } {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
let seq = 0;
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
async put(content: string) {
|
||||||
|
const hash = `HASH${String(++seq).padStart(9, "0")}`;
|
||||||
|
store.set(hash, content);
|
||||||
|
return hash;
|
||||||
|
},
|
||||||
|
async get(hash: string) {
|
||||||
|
return store.get(hash) ?? null;
|
||||||
|
},
|
||||||
|
async delete(hash: string) {
|
||||||
|
store.delete(hash);
|
||||||
|
},
|
||||||
|
async list() {
|
||||||
|
return [...store.keys()];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frontmatter-compatible schema ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Schema that maps directly to AgentFrontmatter fields so happy path works.
|
||||||
|
const FrontmatterSchema = z.object({
|
||||||
|
status: z.union([
|
||||||
|
z.literal("done"),
|
||||||
|
z.literal("needs_input"),
|
||||||
|
z.literal("in_progress"),
|
||||||
|
z.literal("failed"),
|
||||||
|
z.null(),
|
||||||
|
]),
|
||||||
|
next: z.union([z.string(), z.null()]),
|
||||||
|
confidence: z.union([z.number(), z.null()]),
|
||||||
|
artifacts: z.array(z.string()),
|
||||||
|
scope: z.union([z.literal("role"), z.literal("thread")]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FrontmatterMeta = z.infer<typeof FrontmatterSchema>;
|
||||||
|
|
||||||
|
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createAgentAdapter — happy path (valid frontmatter satisfies schema)", () => {
|
||||||
|
test("returns meta from frontmatter without calling runtime.extract", async () => {
|
||||||
|
const cas = makeCas();
|
||||||
|
const extractMock = mock(async () => {
|
||||||
|
throw new Error("runtime.extract must not be called in happy path");
|
||||||
|
});
|
||||||
|
const runtime: WorkflowRuntime = { cas, extract: extractMock as WorkflowRuntime["extract"] };
|
||||||
|
|
||||||
|
const rawOutput = [
|
||||||
|
"---",
|
||||||
|
"status: done",
|
||||||
|
"next: reviewer",
|
||||||
|
"confidence: 0.9",
|
||||||
|
"artifacts: [src/foo.ts]",
|
||||||
|
"scope: role",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## Summary",
|
||||||
|
"Work is complete.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const agentFn = mock(async (_ctx: ThreadContext, _opts: null) => rawOutput);
|
||||||
|
const extractOpts = mock(async () => null);
|
||||||
|
|
||||||
|
const adapter = createAgentAdapter<null>(agentFn, extractOpts);
|
||||||
|
const roleFn = adapter<FrontmatterMeta>("test prompt", FrontmatterSchema);
|
||||||
|
const result = await roleFn(makeCtx(), runtime);
|
||||||
|
|
||||||
|
// Meta must come from frontmatter
|
||||||
|
expect(result.meta.status).toBe("done");
|
||||||
|
expect(result.meta.next).toBe("reviewer");
|
||||||
|
expect(result.meta.confidence).toBe(0.9);
|
||||||
|
expect(result.meta.artifacts).toEqual(["src/foo.ts"]);
|
||||||
|
expect(result.meta.scope).toBe("role");
|
||||||
|
expect(result.childThread).toBeNull();
|
||||||
|
|
||||||
|
// LLM extract must NOT have been called
|
||||||
|
expect(extractMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// CAS should store the body (without frontmatter) as the CAS node payload
|
||||||
|
const storedContent = [...cas.store.values()][0] ?? "";
|
||||||
|
expect(storedContent).toContain("## Summary");
|
||||||
|
expect(storedContent).toContain("Work is complete.");
|
||||||
|
// The frontmatter block itself must not appear in the stored payload
|
||||||
|
expect(storedContent).not.toContain("status: done\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("body stored in CAS does not include the frontmatter block", async () => {
|
||||||
|
const cas = makeCas();
|
||||||
|
const runtime: WorkflowRuntime = {
|
||||||
|
cas,
|
||||||
|
extract: mock(async () => {
|
||||||
|
throw new Error("must not be called");
|
||||||
|
}) as WorkflowRuntime["extract"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawOutput =
|
||||||
|
"---\nstatus: done\nnext: null\nconfidence: null\nscope: role\n---\n\nThe actual work content here.";
|
||||||
|
|
||||||
|
const adapter = createAgentAdapter<null>(
|
||||||
|
mock(async () => rawOutput),
|
||||||
|
mock(async () => null),
|
||||||
|
);
|
||||||
|
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||||
|
await roleFn(makeCtx(), runtime);
|
||||||
|
|
||||||
|
// CAS node wraps content as `payload: <body>`; check the payload contains only body
|
||||||
|
const stored = [...cas.store.values()][0] ?? "";
|
||||||
|
expect(stored).toContain("The actual work content here.");
|
||||||
|
// The frontmatter block must be stripped
|
||||||
|
expect(stored).not.toContain("status: done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fallback path ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createAgentAdapter — fallback path (no frontmatter)", () => {
|
||||||
|
test("calls runtime.extract when output has no frontmatter block", async () => {
|
||||||
|
const cas = makeCas();
|
||||||
|
const expectedMeta: FrontmatterMeta = {
|
||||||
|
status: "done",
|
||||||
|
next: null,
|
||||||
|
confidence: null,
|
||||||
|
artifacts: [],
|
||||||
|
scope: "role",
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFn = mock(async (_schema: unknown, _hash: string) => ({
|
||||||
|
meta: expectedMeta as Record<string, unknown>,
|
||||||
|
contentPayload: "plain text output",
|
||||||
|
refs: [],
|
||||||
|
}));
|
||||||
|
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||||
|
|
||||||
|
const rawOutput = "This is plain markdown without any frontmatter.";
|
||||||
|
const adapter = createAgentAdapter<null>(
|
||||||
|
mock(async () => rawOutput),
|
||||||
|
mock(async () => null),
|
||||||
|
);
|
||||||
|
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||||
|
const result = await roleFn(makeCtx(), runtime);
|
||||||
|
|
||||||
|
// runtime.extract must have been called once
|
||||||
|
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.meta).toEqual(expectedMeta);
|
||||||
|
expect(result.childThread).toBeNull();
|
||||||
|
|
||||||
|
// CAS should store the full raw output (as CAS node payload)
|
||||||
|
const stored = [...cas.store.values()][0] ?? "";
|
||||||
|
expect(stored).toContain(rawOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to runtime.extract when frontmatter is structurally invalid", async () => {
|
||||||
|
const cas = makeCas();
|
||||||
|
const expectedMeta: FrontmatterMeta = {
|
||||||
|
status: null,
|
||||||
|
next: null,
|
||||||
|
confidence: null,
|
||||||
|
artifacts: [],
|
||||||
|
scope: "role",
|
||||||
|
};
|
||||||
|
const extractFn = mock(async () => ({
|
||||||
|
meta: expectedMeta as Record<string, unknown>,
|
||||||
|
contentPayload: "",
|
||||||
|
refs: [],
|
||||||
|
}));
|
||||||
|
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||||
|
|
||||||
|
// confidence out of range — validateFrontmatter will reject
|
||||||
|
const rawOutput = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
|
||||||
|
const adapter = createAgentAdapter<null>(
|
||||||
|
mock(async () => rawOutput),
|
||||||
|
mock(async () => null),
|
||||||
|
);
|
||||||
|
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||||
|
await roleFn(makeCtx(), runtime);
|
||||||
|
|
||||||
|
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back when frontmatter fields do not satisfy schema", async () => {
|
||||||
|
const cas = makeCas();
|
||||||
|
|
||||||
|
// Schema requires a mandatory non-null string field that frontmatter cannot provide
|
||||||
|
const StrictSchema = z.object({
|
||||||
|
requiredField: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractFn = mock(async () => ({
|
||||||
|
meta: { requiredField: "from-llm" } as Record<string, unknown>,
|
||||||
|
contentPayload: "",
|
||||||
|
refs: [],
|
||||||
|
}));
|
||||||
|
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||||
|
|
||||||
|
const rawOutput = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||||
|
const adapter = createAgentAdapter<null>(
|
||||||
|
mock(async () => rawOutput),
|
||||||
|
mock(async () => null),
|
||||||
|
);
|
||||||
|
const roleFn = adapter<{ requiredField: string }>("prompt", StrictSchema);
|
||||||
|
await roleFn(makeCtx(), runtime);
|
||||||
|
|
||||||
|
// frontmatter has no `requiredField`, so schema parse fails → fallback
|
||||||
|
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-cas": "workspace:^",
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -3,30 +3,20 @@ import type { AgentContext, ThreadContext } from "@uncaged/workflow-runtime";
|
|||||||
/**
|
/**
|
||||||
* Builds a user-message string from thread context: task, previous steps, and tool hints.
|
* Builds a user-message string from thread context: task, previous steps, and tool hints.
|
||||||
* Does NOT include a system prompt — that is passed separately via the adapter.
|
* Does NOT include a system prompt — that is passed separately via the adapter.
|
||||||
|
*
|
||||||
|
* Ordering: Task → Previous Steps → Parent Context → Tools
|
||||||
|
* The "Deliverable" section lives in the system prompt (injected by createAgentAdapter).
|
||||||
*/
|
*/
|
||||||
export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
|
export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
if (ctx.start.parentState !== null) {
|
// 1. Task — what to do
|
||||||
lines.push("## Parent Context");
|
|
||||||
lines.push(
|
|
||||||
"This workflow was spawned by a parent workflow. The parent's state at spawn time is available at hash: " +
|
|
||||||
ctx.start.parentState,
|
|
||||||
);
|
|
||||||
lines.push(
|
|
||||||
`Use \`uncaged-workflow cas get ${ctx.start.parentState}\` to inspect the parent's context and trace back through its steps.`,
|
|
||||||
);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("## Task");
|
lines.push("## Task");
|
||||||
lines.push(ctx.start.content);
|
lines.push(ctx.start.content);
|
||||||
|
|
||||||
const { steps } = ctx;
|
const { steps } = ctx;
|
||||||
if (steps.length === 0) {
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 2. Context — previous steps
|
||||||
if (steps.length === 1) {
|
if (steps.length === 1) {
|
||||||
const s = steps[0];
|
const s = steps[0];
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -34,7 +24,7 @@ export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`ContentHash: ${s.contentHash}`);
|
lines.push(`ContentHash: ${s.contentHash}`);
|
||||||
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
||||||
} else {
|
} else if (steps.length > 1) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("## Previous Steps");
|
lines.push("## Previous Steps");
|
||||||
for (let i = 0; i < steps.length - 1; i++) {
|
for (let i = 0; i < steps.length - 1; i++) {
|
||||||
@@ -51,6 +41,24 @@ export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
|
|||||||
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Parent context — available when this workflow was spawned by another
|
||||||
|
if (ctx.start.parentState !== null) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Parent Context");
|
||||||
|
lines.push(
|
||||||
|
"This workflow was spawned by a parent workflow. The parent's state at spawn time is available at hash: " +
|
||||||
|
ctx.start.parentState,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`Use \`uncaged-workflow cas get ${ctx.start.parentState}\` to inspect the parent's context and trace back through its steps.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 0 && ctx.start.parentState === null) {
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Tools — available commands
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("## Tools");
|
lines.push("## Tools");
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
|
type ZodSchema = z.ZodType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the top-level field names from a Zod schema.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - ZodObject → its `.shape` keys
|
||||||
|
* - ZodDiscriminatedUnion / ZodUnion → union of all variant shapes
|
||||||
|
*
|
||||||
|
* Returns an empty array for schemas that have no inspectable shape
|
||||||
|
* (e.g. primitives, ZodAny).
|
||||||
|
*/
|
||||||
|
function extractSchemaFields(schema: ZodSchema): string[] {
|
||||||
|
const def = schema.def as {
|
||||||
|
type: string;
|
||||||
|
shape?: Record<string, ZodSchema>;
|
||||||
|
options?: ZodSchema[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (def.type === "object" && def.shape !== undefined) {
|
||||||
|
return Object.keys(def.shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((def.type === "discriminated_union" || def.type === "union") && Array.isArray(def.options)) {
|
||||||
|
const fieldSet = new Set<string>();
|
||||||
|
for (const option of def.options) {
|
||||||
|
for (const field of extractSchemaFields(option as ZodSchema)) {
|
||||||
|
fieldSet.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...fieldSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a concise output format instruction block for an agent role.
|
||||||
|
*
|
||||||
|
* The instruction describes the expected frontmatter markdown format and lists
|
||||||
|
* the meta fields derived from `schema`. It is injected at the top of the
|
||||||
|
* system prompt so the deliverable format is the first thing the agent sees.
|
||||||
|
*
|
||||||
|
* Focus on YOUR role's deliverable. Do not perform actions outside your role's scope.
|
||||||
|
*/
|
||||||
|
export function buildOutputFormatInstruction(schema: ZodSchema): string {
|
||||||
|
const fields = extractSchemaFields(schema);
|
||||||
|
|
||||||
|
const fieldList =
|
||||||
|
fields.length > 0
|
||||||
|
? fields.map((f) => ` - \`${f}\``).join("\n")
|
||||||
|
: " (schema fields will be extracted automatically)";
|
||||||
|
|
||||||
|
return `## Deliverable Format
|
||||||
|
|
||||||
|
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
---
|
||||||
|
status: done # done | needs_input | in_progress | failed
|
||||||
|
next: <role-name> # suggested next role, or omit
|
||||||
|
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
|
||||||
|
artifacts: # list of file paths or CAS hashes you produced
|
||||||
|
- path/to/file.ts
|
||||||
|
scope: role # role | thread
|
||||||
|
---
|
||||||
|
|
||||||
|
... your markdown work here ...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||||
|
Your meta output must satisfy these fields:
|
||||||
|
|
||||||
|
${fieldList}
|
||||||
|
|
||||||
|
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||||
|
}
|
||||||
@@ -6,7 +6,15 @@ import type {
|
|||||||
ThreadContext,
|
ThreadContext,
|
||||||
WorkflowRuntime,
|
WorkflowRuntime,
|
||||||
} from "@uncaged/workflow-runtime";
|
} from "@uncaged/workflow-runtime";
|
||||||
|
import {
|
||||||
|
createLogger,
|
||||||
|
parseFrontmatterMarkdown,
|
||||||
|
validateFrontmatter,
|
||||||
|
} from "@uncaged/workflow-util";
|
||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
|
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||||
|
|
||||||
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
export type ExtractOptionsFn<Opt> = (
|
export type ExtractOptionsFn<Opt> = (
|
||||||
ctx: ThreadContext,
|
ctx: ThreadContext,
|
||||||
@@ -14,22 +22,82 @@ export type ExtractOptionsFn<Opt> = (
|
|||||||
runtime: WorkflowRuntime,
|
runtime: WorkflowRuntime,
|
||||||
) => Promise<Opt>;
|
) => Promise<Opt>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to satisfy `schema` from frontmatter fields alone.
|
||||||
|
*
|
||||||
|
* Returns the parsed value on success, or `null` when the frontmatter does not
|
||||||
|
* cover all required fields of the schema. Never throws.
|
||||||
|
*/
|
||||||
|
function tryFrontmatterMeta<T>(
|
||||||
|
raw: string,
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
): { meta: T; body: string } | null {
|
||||||
|
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
||||||
|
|
||||||
|
if (frontmatter === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationErrors = validateFrontmatter(frontmatter);
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
log(
|
||||||
|
"4KNMR2PX",
|
||||||
|
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce frontmatter into the plain object shape the schema expects.
|
||||||
|
const candidate: Record<string, unknown> = {
|
||||||
|
status: frontmatter.status,
|
||||||
|
next: frontmatter.next,
|
||||||
|
confidence: frontmatter.confidence,
|
||||||
|
artifacts: frontmatter.artifacts,
|
||||||
|
scope: frontmatter.scope,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(candidate);
|
||||||
|
if (!result.success) {
|
||||||
|
log("7BQST3VW", "frontmatter does not satisfy schema; falling back to extract");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { meta: result.data, body };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridges {@link AgentFn} to {@link AdapterFn}.
|
* Bridges {@link AgentFn} to {@link AdapterFn}.
|
||||||
*
|
*
|
||||||
* 1. extract(ctx, prompt, runtime) → Opt
|
* Happy path (zero LLM cost):
|
||||||
* 2. agent(ctx, options) → raw string
|
* 1. extract(ctx, prompt, runtime) → Opt
|
||||||
* 3. Store raw string in CAS
|
* 2. agent(ctx, options) → raw string
|
||||||
* 4. runtime.extract(schema, contentHash) → typed meta T
|
* 3. Parse raw as frontmatter markdown
|
||||||
|
* 4. If frontmatter is valid AND satisfies `schema` → use as meta directly
|
||||||
|
* CAS stores the body (without frontmatter block)
|
||||||
|
*
|
||||||
|
* Fallback (safety net):
|
||||||
|
* 4b. Store full raw in CAS
|
||||||
|
* 5b. runtime.extract(schema, contentHash) → typed meta via LLM
|
||||||
*/
|
*/
|
||||||
export function createAgentAdapter<Opt>(
|
export function createAgentAdapter<Opt>(
|
||||||
agent: AgentFn<Opt>,
|
agent: AgentFn<Opt>,
|
||||||
extract: ExtractOptionsFn<Opt>,
|
extract: ExtractOptionsFn<Opt>,
|
||||||
): AdapterFn {
|
): AdapterFn {
|
||||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||||
|
const augmentedPrompt = `${buildOutputFormatInstruction(schema)}\n\n${prompt}`;
|
||||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||||
const options = await extract(ctx, prompt, runtime);
|
const options = await extract(ctx, augmentedPrompt, runtime);
|
||||||
const raw = await agent(ctx, options);
|
const raw = await agent(ctx, options);
|
||||||
|
|
||||||
|
const frontmatterResult = tryFrontmatterMeta(raw, schema);
|
||||||
|
|
||||||
|
if (frontmatterResult !== null) {
|
||||||
|
log("3VXPW8QR", "frontmatter satisfied schema — skipping LLM extract");
|
||||||
|
await putContentNodeWithRefs(runtime.cas, frontmatterResult.body, []);
|
||||||
|
return { meta: frontmatterResult.meta, childThread: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
log("8MTNJ5YK", "no valid frontmatter — falling back to runtime.extract");
|
||||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||||
const extracted = await runtime.extract(
|
const extracted = await runtime.extract(
|
||||||
schema as z.ZodType<Record<string, unknown>>,
|
schema as z.ZodType<Record<string, unknown>>,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
||||||
|
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||||
export { createAgentAdapter } from "./create-agent-adapter.js";
|
export { createAgentAdapter } from "./create-agent-adapter.js";
|
||||||
export type { SpawnCliError } from "./spawn-cli.js";
|
export type { SpawnCliError } from "./spawn-cli.js";
|
||||||
export { spawnCli } from "./spawn-cli.js";
|
export { spawnCli } from "./spawn-cli.js";
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentFrontmatter } from "../src/index.js";
|
||||||
|
import { parseFrontmatterMarkdown, validateFrontmatter } from "../src/index.js";
|
||||||
|
|
||||||
|
// ── parseFrontmatterMarkdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("parseFrontmatterMarkdown", () => {
|
||||||
|
describe("no frontmatter", () => {
|
||||||
|
it("returns null frontmatter and full text as body when no fence", () => {
|
||||||
|
const raw = "Just some markdown text.\n\n## Section\n\nContent.";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter).toBeNull();
|
||||||
|
expect(result.body).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null frontmatter when --- appears mid-document", () => {
|
||||||
|
const raw = "# Heading\n\n---\n\nContent.";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter).toBeNull();
|
||||||
|
expect(result.body).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null frontmatter when opening fence is not followed by newline", () => {
|
||||||
|
const raw = "--- inline content ---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter).toBeNull();
|
||||||
|
expect(result.body).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null frontmatter when no closing fence", () => {
|
||||||
|
const raw = "---\nstatus: done\nbody without close";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter).toBeNull();
|
||||||
|
expect(result.body).toBe(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
const result = parseFrontmatterMarkdown("");
|
||||||
|
expect(result.frontmatter).toBeNull();
|
||||||
|
expect(result.body).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("full frontmatter document", () => {
|
||||||
|
it("parses all fields from a well-formed document", () => {
|
||||||
|
const raw = `---
|
||||||
|
status: done
|
||||||
|
next: reviewer
|
||||||
|
confidence: 0.9
|
||||||
|
artifacts:
|
||||||
|
- src/foo.ts
|
||||||
|
- src/bar.ts
|
||||||
|
scope: thread
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Everything looks good.`;
|
||||||
|
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter).not.toBeNull();
|
||||||
|
const fm = result.frontmatter!;
|
||||||
|
expect(fm.status).toBe("done");
|
||||||
|
expect(fm.next).toBe("reviewer");
|
||||||
|
expect(fm.confidence).toBe(0.9);
|
||||||
|
expect(fm.artifacts).toEqual(["src/foo.ts", "src/bar.ts"]);
|
||||||
|
expect(fm.scope).toBe("thread");
|
||||||
|
expect(result.body).toBe("## Summary\n\nEverything looks good.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips leading newline from body", () => {
|
||||||
|
const raw = "---\nstatus: done\n---\n\nbody here";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.body).toBe("body here");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("body is empty string when nothing after closing fence", () => {
|
||||||
|
const raw = "---\nstatus: done\n---\n";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.body).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("body is empty string when document ends exactly at closing fence", () => {
|
||||||
|
const raw = "---\nstatus: done\n---";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.body).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("status field", () => {
|
||||||
|
it.each([
|
||||||
|
"done",
|
||||||
|
"needs_input",
|
||||||
|
"in_progress",
|
||||||
|
"failed",
|
||||||
|
] as const)('parses status "%s"', (status) => {
|
||||||
|
const raw = `---\nstatus: ${status}\n---\nbody`;
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.status).toBe(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null status for unknown value", () => {
|
||||||
|
const raw = "---\nstatus: unknown_value\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.status).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null status when omitted", () => {
|
||||||
|
const raw = "---\nconfidence: 0.5\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.status).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confidence field", () => {
|
||||||
|
it("parses integer as number", () => {
|
||||||
|
const raw = "---\nconfidence: 1\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.confidence).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses decimal", () => {
|
||||||
|
const raw = "---\nconfidence: 0.75\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.confidence).toBe(0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when omitted", () => {
|
||||||
|
const raw = "---\nstatus: done\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.confidence).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-numeric value", () => {
|
||||||
|
const raw = "---\nconfidence: high\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.confidence).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("artifacts field", () => {
|
||||||
|
it("parses block sequence", () => {
|
||||||
|
const raw = "---\nartifacts:\n - a.ts\n - b.ts\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses inline sequence", () => {
|
||||||
|
const raw = "---\nartifacts: [a.ts, b.ts]\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when omitted", () => {
|
||||||
|
const raw = "---\nstatus: done\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.artifacts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps single scalar in array", () => {
|
||||||
|
const raw = "---\nartifacts: only-one.ts\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.artifacts).toEqual(["only-one.ts"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scope field", () => {
|
||||||
|
it('parses scope "role"', () => {
|
||||||
|
const raw = "---\nscope: role\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.scope).toBe("role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses scope "thread"', () => {
|
||||||
|
const raw = "---\nscope: thread\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.scope).toBe("thread");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to "role" when omitted', () => {
|
||||||
|
const raw = "---\nstatus: done\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.scope).toBe("role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to "role" for unknown scope value', () => {
|
||||||
|
const raw = "---\nscope: global\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.scope).toBe("role");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("next field", () => {
|
||||||
|
it("parses a role name", () => {
|
||||||
|
const raw = "---\nnext: planner\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.next).toBe("planner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when omitted", () => {
|
||||||
|
const raw = "---\nstatus: done\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.next).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown fields", () => {
|
||||||
|
it("ignores unknown keys silently", () => {
|
||||||
|
const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.status).toBe("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("YAML comments", () => {
|
||||||
|
it("ignores YAML comment lines", () => {
|
||||||
|
const raw = "---\n# this is a comment\nstatus: done\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter?.status).toBe("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("empty frontmatter block", () => {
|
||||||
|
it("parses empty frontmatter and uses all defaults", () => {
|
||||||
|
const raw = "---\n---\nbody";
|
||||||
|
const result = parseFrontmatterMarkdown(raw);
|
||||||
|
expect(result.frontmatter).not.toBeNull();
|
||||||
|
const fm = result.frontmatter!;
|
||||||
|
expect(fm.status).toBeNull();
|
||||||
|
expect(fm.next).toBeNull();
|
||||||
|
expect(fm.confidence).toBeNull();
|
||||||
|
expect(fm.artifacts).toEqual([]);
|
||||||
|
expect(fm.scope).toBe("role");
|
||||||
|
expect(result.body).toBe("body");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── validateFrontmatter ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validFm(overrides: Partial<AgentFrontmatter> = {}): AgentFrontmatter {
|
||||||
|
return {
|
||||||
|
status: "done",
|
||||||
|
next: null,
|
||||||
|
confidence: null,
|
||||||
|
artifacts: [],
|
||||||
|
scope: "role",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateFrontmatter", () => {
|
||||||
|
it("returns no errors for a fully valid frontmatter", () => {
|
||||||
|
const errors = validateFrontmatter(validFm());
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no errors when all nullable fields are null", () => {
|
||||||
|
const fm: AgentFrontmatter = {
|
||||||
|
status: null,
|
||||||
|
next: null,
|
||||||
|
confidence: null,
|
||||||
|
artifacts: [],
|
||||||
|
scope: "role",
|
||||||
|
};
|
||||||
|
expect(validateFrontmatter(fm)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confidence validation", () => {
|
||||||
|
it("accepts 0.0", () => {
|
||||||
|
expect(validateFrontmatter(validFm({ confidence: 0 }))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 1.0", () => {
|
||||||
|
expect(validateFrontmatter(validFm({ confidence: 1 }))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects value below 0", () => {
|
||||||
|
const errors = validateFrontmatter(validFm({ confidence: -0.1 }));
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]?.field).toBe("confidence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects value above 1", () => {
|
||||||
|
const errors = validateFrontmatter(validFm({ confidence: 1.01 }));
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]?.field).toBe("confidence");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("next validation", () => {
|
||||||
|
it("accepts a simple role name", () => {
|
||||||
|
expect(validateFrontmatter(validFm({ next: "reviewer" }))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts kebab-case role name", () => {
|
||||||
|
expect(validateFrontmatter(validFm({ next: "code-reviewer" }))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects role name with whitespace", () => {
|
||||||
|
const errors = validateFrontmatter(validFm({ next: "role name" }));
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]?.field).toBe("next");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("artifacts validation", () => {
|
||||||
|
it("accepts non-empty path strings", () => {
|
||||||
|
expect(
|
||||||
|
validateFrontmatter(validFm({ artifacts: ["src/foo.ts", "src/bar.ts"] })),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty string artifact entries", () => {
|
||||||
|
const errors = validateFrontmatter(validFm({ artifacts: [""] }));
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]?.field).toBe("artifacts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects whitespace-only artifact entries", () => {
|
||||||
|
const errors = validateFrontmatter(validFm({ artifacts: [" "] }));
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]?.field).toBe("artifacts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple errors", () => {
|
||||||
|
it("reports multiple violations at once", () => {
|
||||||
|
const fm: AgentFrontmatter = {
|
||||||
|
status: "done",
|
||||||
|
next: "bad role",
|
||||||
|
confidence: 2,
|
||||||
|
artifacts: [""],
|
||||||
|
scope: "role",
|
||||||
|
};
|
||||||
|
const errors = validateFrontmatter(fm);
|
||||||
|
const fields = errors.map((e) => e.field);
|
||||||
|
expect(fields).toContain("next");
|
||||||
|
expect(fields).toContain("confidence");
|
||||||
|
expect(fields).toContain("artifacts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import type {
|
||||||
|
AgentFrontmatter,
|
||||||
|
FrontmatterScope,
|
||||||
|
FrontmatterStatus,
|
||||||
|
FrontmatterValidationError,
|
||||||
|
ParsedFrontmatterMarkdown,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
// ── YAML frontmatter extractor ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FENCE = "---";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a raw agent response into a YAML string (or null) and a markdown body.
|
||||||
|
*
|
||||||
|
* A frontmatter block MUST:
|
||||||
|
* 1. Start at character position 0 with `---` (no leading whitespace / BOM).
|
||||||
|
* 2. Be closed by a second `---` on its own line.
|
||||||
|
*
|
||||||
|
* Anything that doesn't match this shape is returned verbatim as the body.
|
||||||
|
*/
|
||||||
|
function splitFrontmatter(raw: string): { yaml: string | null; body: string } {
|
||||||
|
if (!raw.startsWith(FENCE)) {
|
||||||
|
return { yaml: null, body: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = raw.slice(FENCE.length);
|
||||||
|
// The opening `---` must be followed immediately by a newline (or end-of-string).
|
||||||
|
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
|
||||||
|
return { yaml: null, body: raw };
|
||||||
|
}
|
||||||
|
// Consume the newline after the opening fence so that `afterOpen` starts at the
|
||||||
|
// first line of YAML content (not a leading empty line).
|
||||||
|
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
|
||||||
|
|
||||||
|
const closeIndex = afterOpen.indexOf(`\n${FENCE}`);
|
||||||
|
if (closeIndex === -1) {
|
||||||
|
// Also handle the edge case where frontmatter is empty: `---\n---`
|
||||||
|
if (afterOpen.startsWith(FENCE)) {
|
||||||
|
const afterClose = afterOpen.slice(FENCE.length);
|
||||||
|
const body = afterClose.replace(/^\n+/, "");
|
||||||
|
return { yaml: "", body };
|
||||||
|
}
|
||||||
|
return { yaml: null, body: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yaml = afterOpen.slice(0, closeIndex);
|
||||||
|
// Skip past `\n---` and strip any leading blank separator lines from the body.
|
||||||
|
const afterClose = afterOpen.slice(closeIndex + 1 + FENCE.length);
|
||||||
|
const body = afterClose.replace(/^\n+/, "");
|
||||||
|
|
||||||
|
return { yaml, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Minimal YAML scalar parser ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// We intentionally avoid a full YAML library dependency inside workflow-util.
|
||||||
|
// The frontmatter schema is flat and uses only scalars + simple string lists.
|
||||||
|
// This parser handles exactly what the spec needs and nothing more.
|
||||||
|
|
||||||
|
type YamlValue = string | number | boolean | null | string[];
|
||||||
|
|
||||||
|
function parseYamlScalar(raw: string): YamlValue {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
|
||||||
|
// Quoted string
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||||
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||||
|
) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (lower === "true") return true;
|
||||||
|
if (lower === "false") return false;
|
||||||
|
if (lower === "null" || lower === "~" || lower === "") return null;
|
||||||
|
|
||||||
|
const num = Number(trimmed);
|
||||||
|
if (!Number.isNaN(num) && trimmed !== "") return num;
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBlockSequence(
|
||||||
|
lines: string[],
|
||||||
|
startIdx: number,
|
||||||
|
): { items: string[]; nextIdx: number } {
|
||||||
|
const items: string[] = [];
|
||||||
|
let i = startIdx;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const itemTrimmed = (lines[i] ?? "").trimStart();
|
||||||
|
if (!itemTrimmed.startsWith("- ")) break;
|
||||||
|
items.push(itemTrimmed.slice(2).trim());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return { items, nextIdx: i };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineSequence(restTrimmed: string): string[] {
|
||||||
|
const inner = restTrimmed.slice(1, -1);
|
||||||
|
return inner
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeyValue(
|
||||||
|
lines: string[],
|
||||||
|
i: number,
|
||||||
|
): { key: string; value: YamlValue; nextIdx: number } | null {
|
||||||
|
const line = lines[i] ?? "";
|
||||||
|
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const colonIdx = line.indexOf(":");
|
||||||
|
if (colonIdx === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = line.slice(0, colonIdx).trim();
|
||||||
|
const restTrimmed = line.slice(colonIdx + 1).trim();
|
||||||
|
|
||||||
|
if (restTrimmed === "") {
|
||||||
|
const { items, nextIdx } = collectBlockSequence(lines, i + 1);
|
||||||
|
return { key, value: items, nextIdx };
|
||||||
|
}
|
||||||
|
if (restTrimmed.startsWith("[") && restTrimmed.endsWith("]")) {
|
||||||
|
return { key, value: parseInlineSequence(restTrimmed), nextIdx: i + 1 };
|
||||||
|
}
|
||||||
|
return { key, value: parseYamlScalar(restTrimmed), nextIdx: i + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a minimal flat YAML document. Only supports:
|
||||||
|
* - Scalar key: value pairs
|
||||||
|
* - Block sequences under a key (items prefixed with ` - `)
|
||||||
|
*
|
||||||
|
* Returns a plain object. Never throws — unparseable lines are silently skipped.
|
||||||
|
*/
|
||||||
|
function parseMinimalYaml(yaml: string): Record<string, YamlValue> {
|
||||||
|
const result: Record<string, YamlValue> = {};
|
||||||
|
const lines = yaml.split("\n");
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const entry = parseKeyValue(lines, i);
|
||||||
|
if (entry === null) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[entry.key] = entry.value;
|
||||||
|
i = entry.nextIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field coercers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"];
|
||||||
|
|
||||||
|
const VALID_SCOPE: readonly FrontmatterScope[] = ["role", "thread"];
|
||||||
|
|
||||||
|
function coerceStatus(raw: YamlValue): FrontmatterStatus | null {
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const s = String(raw).trim().toLowerCase();
|
||||||
|
return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceNext(raw: YamlValue): string | null {
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const s = String(raw).trim();
|
||||||
|
return s === "" ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceConfidence(raw: YamlValue): number | null {
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
|
||||||
|
if (Number.isNaN(n)) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceArtifacts(raw: YamlValue): readonly string[] {
|
||||||
|
if (raw === null || raw === undefined) return [];
|
||||||
|
if (Array.isArray(raw)) return raw.map(String).filter((s) => s !== "");
|
||||||
|
const s = String(raw).trim();
|
||||||
|
return s === "" ? [] : [s];
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceScope(raw: YamlValue): FrontmatterScope {
|
||||||
|
if (raw === null || raw === undefined) return "role";
|
||||||
|
const s = String(raw).trim().toLowerCase();
|
||||||
|
return VALID_SCOPE.includes(s as FrontmatterScope) ? (s as FrontmatterScope) : "role";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw agent response string into structured frontmatter + body.
|
||||||
|
*
|
||||||
|
* - Never throws: malformed YAML is silently treated as "no frontmatter".
|
||||||
|
* - The returned `frontmatter` is `null` when no valid `---…---` block was found.
|
||||||
|
* - Unknown YAML keys are silently ignored.
|
||||||
|
* - Invalid scalar values for known keys are coerced to their null/default.
|
||||||
|
*/
|
||||||
|
export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown {
|
||||||
|
const { yaml, body } = splitFrontmatter(raw);
|
||||||
|
|
||||||
|
if (yaml === null) {
|
||||||
|
return { frontmatter: null, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields: Record<string, YamlValue>;
|
||||||
|
try {
|
||||||
|
fields = parseMinimalYaml(yaml);
|
||||||
|
} catch {
|
||||||
|
// Unparseable YAML → treat as no frontmatter; keep full raw as body.
|
||||||
|
return { frontmatter: null, body: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter: AgentFrontmatter = {
|
||||||
|
status: coerceStatus(fields.status ?? null),
|
||||||
|
next: coerceNext(fields.next ?? null),
|
||||||
|
confidence: coerceConfidence(fields.confidence ?? null),
|
||||||
|
artifacts: coerceArtifacts(fields.artifacts ?? null),
|
||||||
|
scope: coerceScope(fields.scope ?? null),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { frontmatter, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a parsed `AgentFrontmatter` and return a list of violations.
|
||||||
|
*
|
||||||
|
* An empty array means the frontmatter is valid.
|
||||||
|
*
|
||||||
|
* Validated constraints:
|
||||||
|
* - `status` — must be one of the FrontmatterStatus literals (if non-null)
|
||||||
|
* - `confidence` — must be in [0.0, 1.0] (if non-null)
|
||||||
|
* - `next` — must be a non-empty string with no whitespace (if non-null)
|
||||||
|
* - `artifacts` — each entry must be a non-empty string
|
||||||
|
* - `scope` — must be one of the FrontmatterScope literals
|
||||||
|
*/
|
||||||
|
export function validateFrontmatter(
|
||||||
|
frontmatter: AgentFrontmatter,
|
||||||
|
): readonly FrontmatterValidationError[] {
|
||||||
|
const errors: FrontmatterValidationError[] = [];
|
||||||
|
|
||||||
|
if (frontmatter.status !== null && !VALID_STATUS.includes(frontmatter.status)) {
|
||||||
|
errors.push({
|
||||||
|
field: "status",
|
||||||
|
message: `invalid status "${frontmatter.status}"; must be one of: ${VALID_STATUS.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frontmatter.confidence !== null) {
|
||||||
|
if (frontmatter.confidence < 0 || frontmatter.confidence > 1) {
|
||||||
|
errors.push({
|
||||||
|
field: "confidence",
|
||||||
|
message: `confidence ${frontmatter.confidence} is out of range; must be between 0.0 and 1.0 inclusive`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frontmatter.next !== null) {
|
||||||
|
if (frontmatter.next.trim() === "") {
|
||||||
|
errors.push({ field: "next", message: "next must be a non-empty string when present" });
|
||||||
|
} else if (/\s/.test(frontmatter.next)) {
|
||||||
|
errors.push({
|
||||||
|
field: "next",
|
||||||
|
message: `next "${frontmatter.next}" must not contain whitespace`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const artifact of frontmatter.artifacts) {
|
||||||
|
if (artifact.trim() === "") {
|
||||||
|
errors.push({ field: "artifacts", message: "artifact entries must be non-empty strings" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_SCOPE.includes(frontmatter.scope)) {
|
||||||
|
errors.push({
|
||||||
|
field: "scope",
|
||||||
|
message: `invalid scope "${frontmatter.scope}"; must be one of: ${VALID_SCOPE.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { parseFrontmatterMarkdown, validateFrontmatter } from "./frontmatter-markdown.js";
|
||||||
|
export type {
|
||||||
|
AgentFrontmatter,
|
||||||
|
FrontmatterScope,
|
||||||
|
FrontmatterStatus,
|
||||||
|
FrontmatterValidationError,
|
||||||
|
ParsedFrontmatterMarkdown,
|
||||||
|
} from "./types.js";
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Frontmatter Markdown — agent output format (RFC #351 Phase 1).
|
||||||
|
*
|
||||||
|
* An agent response is a Markdown document with an optional YAML frontmatter
|
||||||
|
* block at the top. The frontmatter carries structured signals that the
|
||||||
|
* moderator and engine can consume without running a full LLM extract pass.
|
||||||
|
*
|
||||||
|
* Wire format:
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
* status: done
|
||||||
|
* next: reviewer
|
||||||
|
* confidence: 0.9
|
||||||
|
* artifacts:
|
||||||
|
* - src/foo.ts
|
||||||
|
* scope: role
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* ... free-form markdown body ...
|
||||||
|
*
|
||||||
|
* All frontmatter fields are optional at the parse level. `validateFrontmatter`
|
||||||
|
* enforces the constraints documented on each field below.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Vocabulary types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level signal from the agent about where work stands.
|
||||||
|
*
|
||||||
|
* - `done` — role completed its objective; moderator may advance
|
||||||
|
* - `needs_input` — agent is blocked and requires human or peer clarification
|
||||||
|
* - `in_progress` — work is underway but the agent chose to yield early
|
||||||
|
* - `failed` — agent cannot complete the task and explains why in the body
|
||||||
|
*/
|
||||||
|
export type FrontmatterStatus = "done" | "needs_input" | "in_progress" | "failed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope of frontmatter signals.
|
||||||
|
*
|
||||||
|
* - `role` — signals apply to the current role execution only (default)
|
||||||
|
* - `thread` — signals are suggestions for the entire thread moderator
|
||||||
|
*/
|
||||||
|
export type FrontmatterScope = "role" | "thread";
|
||||||
|
|
||||||
|
// ── Core frontmatter schema ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed and validated frontmatter from an agent response.
|
||||||
|
*
|
||||||
|
* All fields use explicit `T | null` (no optional `?:` per convention).
|
||||||
|
*/
|
||||||
|
export type AgentFrontmatter = {
|
||||||
|
/**
|
||||||
|
* Completion status signal from the agent.
|
||||||
|
* Null when omitted — engine treats it as "done" for backward compatibility.
|
||||||
|
*/
|
||||||
|
status: FrontmatterStatus | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggested next role name for the moderator.
|
||||||
|
* The moderator is NOT obligated to follow this — it is advisory only.
|
||||||
|
* Null when the agent has no preference.
|
||||||
|
*/
|
||||||
|
next: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent's self-assessed confidence in its output (0.0 – 1.0 inclusive).
|
||||||
|
* Null when omitted.
|
||||||
|
*/
|
||||||
|
confidence: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative file paths or CAS hashes the agent considers its primary outputs.
|
||||||
|
* Used for GC ref-tracing and human-readable summaries.
|
||||||
|
* Empty array when omitted (never null — an absent list is an empty list).
|
||||||
|
*/
|
||||||
|
artifacts: readonly string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope of the frontmatter signals.
|
||||||
|
* Defaults to "role" when omitted.
|
||||||
|
*/
|
||||||
|
scope: FrontmatterScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Parse output ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of `parseFrontmatterMarkdown`: the structured frontmatter (if present)
|
||||||
|
* and the body (everything after the closing `---` fence, or the whole input
|
||||||
|
* if no frontmatter was found).
|
||||||
|
*/
|
||||||
|
export type ParsedFrontmatterMarkdown = {
|
||||||
|
/**
|
||||||
|
* Parsed frontmatter fields. Null when no frontmatter block was detected
|
||||||
|
* (i.e. the document does not start with `---`).
|
||||||
|
*/
|
||||||
|
frontmatter: AgentFrontmatter | null;
|
||||||
|
|
||||||
|
/** Markdown body with frontmatter block stripped. Leading newline removed. */
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Validation error ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FrontmatterValidationError =
|
||||||
|
| { field: "status"; message: string }
|
||||||
|
| { field: "next"; message: string }
|
||||||
|
| { field: "confidence"; message: string }
|
||||||
|
| { field: "artifacts"; message: string }
|
||||||
|
| { field: "scope"; message: string };
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
export { err, ok } from "@uncaged/workflow-protocol";
|
export { err, ok } from "@uncaged/workflow-protocol";
|
||||||
export { encodeUint64AsCrockford } from "./base32.js";
|
export { encodeUint64AsCrockford } from "./base32.js";
|
||||||
export { env } from "./env.js";
|
export { env } from "./env.js";
|
||||||
|
export {
|
||||||
|
parseFrontmatterMarkdown,
|
||||||
|
validateFrontmatter,
|
||||||
|
} from "./frontmatter-markdown/index.js";
|
||||||
|
export type {
|
||||||
|
AgentFrontmatter,
|
||||||
|
FrontmatterScope,
|
||||||
|
FrontmatterStatus,
|
||||||
|
FrontmatterValidationError,
|
||||||
|
ParsedFrontmatterMarkdown,
|
||||||
|
} from "./frontmatter-markdown/index.js";
|
||||||
export { createLogger } from "./logger.js";
|
export { createLogger } from "./logger.js";
|
||||||
export { normalizeRefsField } from "./refs-field.js";
|
export { normalizeRefsField } from "./refs-field.js";
|
||||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user