Compare commits

..

5 Commits

Author SHA1 Message Date
xiaoju 762ecec872 feat(cli-uwf): thread read shows Content + new step-details command
- thread read: add ### Content section (last assistant message) before ### Output
- Remove --detail flag (replaced by step-details command)
- New: uwf thread step-details <step-hash> — full detail dump as yaml

Closes #357
2026-05-19 06:44:18 +00:00
xiaoju c0ac4ade09 fix(uwf-agent-hermes): consume outputFormatInstruction in prompt
buildHermesPrompt was ignoring ctx.outputFormatInstruction — the
deliverable format and scope constraint were injected into context
but never passed to the agent.

Now prepends it before systemPrompt (deliverable-first principle).

Refs #355
2026-05-19 06:23:13 +00:00
xiaomo a991393053 Merge pull request 'feat(uwf-agent-kit): frontmatter fast path + prompt injection — #355' (#356) from feat/355-uwf-frontmatter into main 2026-05-19 06:21:35 +00:00
xiaoju 892ccab8d5 feat(uwf-agent-kit): frontmatter fast path + prompt injection
Port RFC #351 frontmatter markdown to uwf-* path:
- tryFrontmatterFastPath(): parse → validate → JSON Schema check via json-cas
- Happy path skips LLM extract, fallback to existing extract()
- buildOutputFormatInstruction(): generates deliverable format from JSON Schema
- Injected into agent context before execution
- Scope reminder: 'Focus exclusively on YOUR role's deliverable'
- 14 new tests (vitest)

Closes #355
2026-05-19 06:20:15 +00:00
xiaomo 70c83c65b0 Merge pull request 'feat(workflow-util-agent): prompt restructure + scope focus — RFC #351 Phase 3' (#354) from feat/351-phase3-prompt-focus into main 2026-05-19 05:57:37 +00:00
15 changed files with 946 additions and 82 deletions
+4 -1
View File
@@ -22,9 +22,12 @@
"yaml": "^2.8.4"
},
"scripts": {
"test": "bun test"
"test": "vitest run"
},
"publishConfig": {
"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();
});
});
+79 -57
View File
@@ -1,33 +1,34 @@
#!/usr/bin/env bun
import { Command } from "commander";
import type { ThreadId } from "@uncaged/uwf-protocol";
import { Command } from "commander";
import { stringify as yamlStringify } from "yaml";
import {
cmdCasGet,
cmdCasHas,
cmdCasPut,
cmdCasRefs,
cmdCasReindex,
cmdCasSchemaGet,
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdThreadFork,
cmdThreadKill,
cmdThreadList,
cmdThreadRead,
THREAD_READ_DEFAULT_QUOTA,
cmdThreadShow,
cmdThreadStart,
cmdThreadStep,
cmdThreadStepDetails,
cmdThreadSteps,
THREAD_READ_DEFAULT_QUOTA,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdCasGet,
cmdCasHas,
cmdCasPut,
cmdCasReindex,
cmdCasRefs,
cmdCasSchemaGet,
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { formatOutput, type OutputFormat } from "./format.js";
import { resolveStorageRoot } from "./store.js";
import { type OutputFormat, formatOutput } from "./format.js";
function writeOutput(data: unknown): void {
const fmt = program.opts().format as OutputFormat;
@@ -168,20 +169,27 @@ thread
.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")
.option("--detail", "Expand detail content for each step")
.action((threadId: string, opts: { quota: string; before: string | undefined; start: boolean; detail: 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, opts.detail ?? false);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
});
.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")
@@ -195,6 +203,18 @@ thread
});
});
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
.command("setup")
.description("Configure provider, model, and agent")
@@ -203,34 +223,36 @@ program
.option("--api-key <key>", "API key")
.option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent alias")
.action((opts: {
provider?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
agent?: string;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
const result = await cmdSetup({
provider: opts.provider,
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
model: opts.model,
agent: opts.agent ?? undefined,
storageRoot,
});
writeOutput(result);
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
await cmdSetupInteractive(storageRoot);
} else {
throw new Error(
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
);
}
});
});
.action(
(opts: {
provider?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
agent?: string;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
const result = await cmdSetup({
provider: opts.provider,
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
model: opts.model,
agent: opts.agent ?? undefined,
storageRoot,
});
writeOutput(result);
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
await cmdSetupInteractive(storageRoot);
} else {
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");
+69 -21
View File
@@ -1,8 +1,6 @@
import { execFileSync } from "node:child_process";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import type { JSONSchema, Store as CasStore } from "@uncaged/json-cas";
import { stringify } from "yaml";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
import { evaluate } from "@uncaged/uwf-moderator";
import type {
@@ -26,6 +24,7 @@ import type {
} from "@uncaged/uwf-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { stringify } from "yaml";
import {
appendThreadHistory,
@@ -293,7 +292,12 @@ function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unkno
return expandValue(store, schema, node.payload, seen);
}
function expandValue(store: CasStore, schema: JSONSchema, value: unknown, visited: Set<string>): unknown {
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") {
@@ -383,6 +387,37 @@ function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: str
].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;
@@ -394,9 +429,8 @@ function formatThreadReadMarkdown(options: {
quota: number;
before: CasRef | null;
showStart: boolean;
showDetail: boolean;
}): string {
const { ordered, uwf, workflow, quota, before, showStart, showDetail } = options;
const { ordered, uwf, workflow, quota, before, showStart } = options;
// Determine which steps to consider
let candidates = ordered;
@@ -456,7 +490,10 @@ function formatThreadReadMarkdown(options: {
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 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}`,
@@ -465,19 +502,13 @@ function formatThreadReadMarkdown(options: {
if (roleDef) {
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
}
stepLines.push(
"",
"### Output",
"",
"```yaml",
outputYaml,
"```",
);
if (showDetail && item.payload.detail) {
const detailExpanded = expandDeep(uwf.store, item.payload.detail);
const detailYaml = formatYaml(detailExpanded);
stepLines.push("", "### Detail", "", "```yaml", detailYaml, "```");
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"));
}
@@ -719,7 +750,6 @@ export async function cmdThreadRead(
quota: number = THREAD_READ_DEFAULT_QUOTA,
before: CasRef | null = null,
showStart: boolean = false,
showDetail: boolean = false,
): Promise<string> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
@@ -738,7 +768,6 @@ export async function cmdThreadRead(
quota,
before,
showStart,
showDetail,
});
}
@@ -768,6 +797,25 @@ export async function cmdThreadFork(
};
}
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> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
},
});
+5 -1
View File
@@ -35,7 +35,11 @@ function buildHistorySummary(steps: AgentContext["steps"]): string {
export function buildHermesPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
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);
if (historyBlock !== "") {
parts.push("", historyBlock);
@@ -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();
});
});
+1
View File
@@ -21,6 +21,7 @@
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@uncaged/uwf-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"dotenv": "^16.6.1",
"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.`;
}
+2
View File
@@ -152,6 +152,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
steps,
workflow,
store,
outputFormatInstruction: "",
};
}
@@ -196,6 +197,7 @@ export async function buildContextWithMeta(
steps,
workflow,
store,
outputFormatInstruction: "",
meta: { storageRoot, store, schemas, headHash, chain },
};
}
+61
View File
@@ -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 };
}
+3
View File
@@ -6,6 +6,9 @@ export {
resolveExtractModelAlias,
resolveModel,
} 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 { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
+18 -2
View File
@@ -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 { config as loadDotenv } from "dotenv";
import { buildContextWithMeta } from "./context.js";
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
import { extract } from "./extract.js";
import { tryFrontmatterFastPath } from "./frontmatter.js";
import type { AgentStore } from "./storage.js";
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
@@ -73,7 +75,16 @@ async function extractOutput(
rawOutput: string,
outputSchema: CasRef,
storageRoot: string,
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
): 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", () =>
loadWorkflowConfig(storageRoot),
);
@@ -120,8 +131,13 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
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 outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot);
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot, ctx);
const stepHash = await persistStep({
ctx,
outputHash,
+6
View File
@@ -6,6 +6,12 @@ export type AgentContext = ModeratorContext & {
role: string;
store: Store;
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 = {