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
This commit is contained in:
@@ -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-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.`;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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";
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
Reference in New Issue
Block a user