diff --git a/packages/workflow-role-committer/__tests__/committer.test.ts b/packages/workflow-role-committer/__tests__/committer.test.ts index 4ea0901..2f10788 100644 --- a/packages/workflow-role-committer/__tests__/committer.test.ts +++ b/packages/workflow-role-committer/__tests__/committer.test.ts @@ -58,7 +58,7 @@ describe("createCommitterRole", () => { }; const role = createCommitterRole( agent, - { provider, dryRun: null }, + { provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } }, { cwd: repo, remote: "origin", threadId: null }, ); const out = await role(makeCtx()); @@ -69,7 +69,11 @@ describe("createCommitterRole", () => { const agent: AgentFn = async () => { throw new Error("agent should not run"); }; - const role = createCommitterRole(agent, { provider, dryRun: true }); + const role = createCommitterRole(agent, { + provider, + dryRun: true, + dryRunMeta: { branch: "dry-run", message: "chore: dry run" }, + }); const out = await role(makeCtx()); expect(out.content).toBe("[dry-run] committer skipped"); expect(out.meta).toEqual({ committed: true }); @@ -87,7 +91,7 @@ describe("createCommitterRole", () => { const agent: AgentFn = async () => "plan text"; const role = createCommitterRole( agent, - { provider, dryRun: null }, + { provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } }, { cwd: repo, remote: "origin", threadId: null }, ); diff --git a/packages/workflow-role-committer/src/committer.ts b/packages/workflow-role-committer/src/committer.ts index e851eb4..05a9f93 100644 --- a/packages/workflow-role-committer/src/committer.ts +++ b/packages/workflow-role-committer/src/committer.ts @@ -18,6 +18,8 @@ const committerPlanSchema = z.object({ message: z.string().describe("Single-line conventional commit subject"), }); +export type CommitterPlanMeta = z.infer; + export type CommitterGitConfig = { cwd: string; remote: string; @@ -90,7 +92,7 @@ Reply with enough detail that a maintainer understands the change; structured ex async function runCommitterPipeline( ctx: ThreadContext, agent: AgentFn, - extract: { provider: LlmProvider; dryRun: boolean | null }, + extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta }, gitConfig: CommitterGitConfig, ): Promise> { const cwd = gitConfig.cwd; @@ -107,6 +109,7 @@ async function runCommitterPipeline( const plan = await extractMetaOrThrow("committer-plan", raw, committerPlanSchema, { provider: extract.provider, dryRun: resolveExtractDryRun(extract.dryRun), + dryRunMeta: extract.dryRunMeta, }); const branch = sanitizeBranch(plan.branch); @@ -129,7 +132,7 @@ async function runCommitterPipeline( */ export function createCommitterRole( adapter: AgentFn, - extract: { provider: LlmProvider; dryRun: boolean | null }, + extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta }, gitConfig: CommitterGitConfig = DEFAULT_COMMITTER_GIT_CONFIG, ): Role { const inner: Role = async (ctx) => diff --git a/packages/workflow-role-committer/src/index.ts b/packages/workflow-role-committer/src/index.ts index ef91d66..e0bb49a 100644 --- a/packages/workflow-role-committer/src/index.ts +++ b/packages/workflow-role-committer/src/index.ts @@ -1,6 +1,7 @@ export { type CommitterGitConfig, type CommitterMeta, + type CommitterPlanMeta, committerMetaSchema, createCommitterRole, DEFAULT_COMMITTER_GIT_CONFIG, diff --git a/packages/workflow-role-llm/__tests__/create-role.test.ts b/packages/workflow-role-llm/__tests__/create-role.test.ts index 176063e..a462537 100644 --- a/packages/workflow-role-llm/__tests__/create-role.test.ts +++ b/packages/workflow-role-llm/__tests__/create-role.test.ts @@ -64,7 +64,7 @@ describe("createRole", () => { schema, systemPrompt: "hello", agent, - extract: { provider, dryRun: null }, + extract: { provider, dryRun: null, dryRunMeta: { n: 0 } }, }); const out = await role(makeCtx()); @@ -85,7 +85,7 @@ describe("createRole", () => { schema: z.object({ n: z.number() }), systemPrompt: "p", agent, - extract: { provider, dryRun: null }, + extract: { provider, dryRun: null, dryRunMeta: { n: 0 } }, }); await role(makeCtx()); @@ -103,7 +103,7 @@ describe("createRole", () => { schema, systemPrompt: async (ctx) => `rounds=${ctx.steps.length}`, agent, - extract: { provider, dryRun: null }, + extract: { provider, dryRun: null, dryRunMeta: { n: 0 } }, }); const ctx = makeCtx(); @@ -121,7 +121,7 @@ describe("createRole", () => { schema: z.object({ n: z.number() }), systemPrompt: "p", agent, - extract: { provider, dryRun: null }, + extract: { provider, dryRun: null, dryRunMeta: { n: 0 } }, }); await role(makeCtx()); @@ -129,7 +129,7 @@ describe("createRole", () => { "r1", "raw", expect.anything(), - expect.objectContaining({ provider, dryRun: false }), + expect.objectContaining({ provider, dryRun: false, dryRunMeta: { n: 0 } }), ); }); @@ -142,7 +142,7 @@ describe("createRole", () => { schema: z.object({ n: z.number() }), systemPrompt: "p", agent, - extract: { provider, dryRun: true }, + extract: { provider, dryRun: true, dryRunMeta: { n: 0 } }, }); await role(makeCtx()); @@ -150,7 +150,7 @@ describe("createRole", () => { "r2", "raw", expect.anything(), - expect.objectContaining({ dryRun: true }), + expect.objectContaining({ dryRun: true, dryRunMeta: { n: 0 } }), ); }); }); diff --git a/packages/workflow-role-llm/__tests__/extract-meta.test.ts b/packages/workflow-role-llm/__tests__/extract-meta.test.ts index f5c80d2..d61fe3f 100644 --- a/packages/workflow-role-llm/__tests__/extract-meta.test.ts +++ b/packages/workflow-role-llm/__tests__/extract-meta.test.ts @@ -12,7 +12,7 @@ const provider = { describe("extractMetaOrThrow", () => { const originalFetch = globalThis.fetch; - test("dryRun returns schema-shaped defaults without calling fetch", async () => { + test("dryRun returns dryRunMeta without calling fetch", async () => { let calls = 0; globalThis.fetch = () => { calls += 1; @@ -23,12 +23,13 @@ describe("extractMetaOrThrow", () => { const out = await extractMetaOrThrow("r", "raw", schema, { provider, dryRun: true, + dryRunMeta: { n: 7 }, }); globalThis.fetch = originalFetch; expect(calls).toBe(0); - expect(out).toEqual({ n: 0 }); + expect(out).toEqual({ n: 7 }); }); test("throws when extraction fails after retry", async () => { @@ -53,7 +54,7 @@ describe("extractMetaOrThrow", () => { const schema = z.object({ n: z.number() }); await expect( - extractMetaOrThrow("plan", "text", schema, { provider, dryRun: false }), + extractMetaOrThrow("plan", "text", schema, { provider, dryRun: false, dryRunMeta: { n: 0 } }), ).rejects.toThrow(/structured extraction failed after retry/); globalThis.fetch = originalFetch; @@ -91,6 +92,7 @@ describe("extractMetaOrThrow", () => { const out = await extractMetaOrThrow("committer-plan", "plan text", schema, { provider, dryRun: false, + dryRunMeta: { branch: "", message: "" }, }); globalThis.fetch = originalFetch; diff --git a/packages/workflow-role-llm/__tests__/llm-extract.test.ts b/packages/workflow-role-llm/__tests__/llm-extract.test.ts index d936906..55c55de 100644 --- a/packages/workflow-role-llm/__tests__/llm-extract.test.ts +++ b/packages/workflow-role-llm/__tests__/llm-extract.test.ts @@ -55,6 +55,7 @@ describe("llmExtract", () => { model: "m", }, dryRun: false, + dryRunMeta: { name: "", description: "" }, }); globalThis.fetch = originalFetch; @@ -105,6 +106,7 @@ describe("llmExtract", () => { schema, provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" }, dryRun: false, + dryRunMeta: { n: 0 }, }); globalThis.fetch = originalFetch; @@ -116,7 +118,7 @@ describe("llmExtract", () => { expect(result.error.kind).toBe("schema_validation_failed"); }); - test("dryRun skips fetch and returns schema-shaped stub values", async () => { + test("dryRun skips fetch and returns dryRunMeta", async () => { let calls = 0; globalThis.fetch = () => { calls += 1; @@ -129,6 +131,7 @@ describe("llmExtract", () => { schema, provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" }, dryRun: true, + dryRunMeta: { n: 42 }, }); globalThis.fetch = originalFetch; @@ -138,6 +141,6 @@ describe("llmExtract", () => { if (!result.ok) { return; } - expect(result.value).toEqual({ n: 0 }); + expect(result.value).toEqual({ n: 42 }); }); }); diff --git a/packages/workflow-role-llm/src/create-role.ts b/packages/workflow-role-llm/src/create-role.ts index 162c991..3ec9fc1 100644 --- a/packages/workflow-role-llm/src/create-role.ts +++ b/packages/workflow-role-llm/src/create-role.ts @@ -10,8 +10,9 @@ export type CreateRoleArgs> = { agent: AgentFn; extract: { provider: LlmProvider; - /** When `true`, structured extract returns schema-shaped defaults. When `null`, live API extract. */ + /** When `true`, structured extract returns `dryRunMeta`. When `null`, live API extract. */ dryRun: boolean | null; + dryRunMeta: M; }; }; @@ -28,6 +29,7 @@ export function createRole>(args: CreateRoleAr const meta = await extractMetaOrThrow(args.name, raw, args.schema, { provider: args.extract.provider, dryRun: resolveExtractDryRun(args.extract.dryRun), + dryRunMeta: args.extract.dryRunMeta, }); return { content: raw, meta }; }; diff --git a/packages/workflow-role-llm/src/extract-meta.ts b/packages/workflow-role-llm/src/extract-meta.ts index 08045c9..f532311 100644 --- a/packages/workflow-role-llm/src/extract-meta.ts +++ b/packages/workflow-role-llm/src/extract-meta.ts @@ -6,13 +6,14 @@ export async function extractMetaOrThrow>( roleName: string, raw: string, schema: z.ZodType, - options: { provider: LlmProvider; dryRun: boolean }, + options: { provider: LlmProvider; dryRun: boolean; dryRunMeta: T }, ): Promise { const result = await llmExtractWithRetry({ text: raw, schema, provider: options.provider, dryRun: options.dryRun, + dryRunMeta: options.dryRunMeta, }); if (!result.ok) { throw new Error( diff --git a/packages/workflow-role-llm/src/index.ts b/packages/workflow-role-llm/src/index.ts index 653fc29..a8b66ba 100644 --- a/packages/workflow-role-llm/src/index.ts +++ b/packages/workflow-role-llm/src/index.ts @@ -3,7 +3,6 @@ export { type OnFailOptions, onFail, type RoleDecorator, - schemaDefaults, type WithDryRunOptions, withDryRun, } from "@uncaged/workflow-util-role"; diff --git a/packages/workflow-role-llm/src/llm-extract.ts b/packages/workflow-role-llm/src/llm-extract.ts index 085fa22..adc7159 100644 --- a/packages/workflow-role-llm/src/llm-extract.ts +++ b/packages/workflow-role-llm/src/llm-extract.ts @@ -1,5 +1,4 @@ import { err, ok, type Result } from "@uncaged/workflow"; -import { schemaDefaults } from "@uncaged/workflow-util-role"; import * as z from "zod/v4"; import type { LlmProvider } from "./types.js"; @@ -10,6 +9,8 @@ export type LlmExtractArgs = { schema: z.ZodType; provider: LlmProvider; dryRun: boolean; + /** Returned when `dryRun` is true (ignored for live extract). */ + dryRunMeta: T; }; export type LlmError = @@ -128,7 +129,7 @@ async function performLlmExtract( options: LlmExtractArgs & { userContent: string }, ): Promise> { if (options.dryRun) { - return ok(schemaDefaults(options.schema) as T); + return ok(options.dryRunMeta); } const rawJsonSchema = z.toJSONSchema(options.schema) as Record; diff --git a/packages/workflow-role-reviewer/__tests__/reviewer.test.ts b/packages/workflow-role-reviewer/__tests__/reviewer.test.ts index 6b6b31b..5db315f 100644 --- a/packages/workflow-role-reviewer/__tests__/reviewer.test.ts +++ b/packages/workflow-role-reviewer/__tests__/reviewer.test.ts @@ -58,7 +58,11 @@ describe("createReviewerRole", () => { return "review done"; }; - const role = createReviewerRole(agent, { provider, dryRun: null }); + const role = createReviewerRole(agent, { + provider, + dryRun: null, + dryRunMeta: { approved: true }, + }); const out = await role(makeCtx()); expect(out.meta).toEqual({ approved: true }); }); @@ -74,7 +78,7 @@ describe("createReviewerRole", () => { const role = createReviewerRole( agent, - { provider, dryRun: null }, + { provider, dryRun: null, dryRunMeta: { approved: false } }, { cwd: "/proj", conventionsPath: null, diff --git a/packages/workflow-role-reviewer/src/reviewer.ts b/packages/workflow-role-reviewer/src/reviewer.ts index 9bf40b2..7e13576 100644 --- a/packages/workflow-role-reviewer/src/reviewer.ts +++ b/packages/workflow-role-reviewer/src/reviewer.ts @@ -90,7 +90,7 @@ or */ export function createReviewerRole( adapter: AgentFn, - extract: { provider: LlmProvider; dryRun: boolean | null }, + extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: ReviewerMeta }, config: ReviewerConfig = DEFAULT_REVIEWER_CONFIG, ): Role { return createRole({ @@ -98,6 +98,10 @@ export function createReviewerRole( schema: reviewerMetaSchema, systemPrompt: async (ctx) => reviewerPrompt(config, ctx), agent: adapter, - extract: { provider: extract.provider, dryRun: extract.dryRun }, + extract: { + provider: extract.provider, + dryRun: extract.dryRun, + dryRunMeta: extract.dryRunMeta, + }, }); } diff --git a/packages/workflow-template-solve-issue/src/roles.ts b/packages/workflow-template-solve-issue/src/roles.ts index 99e872d..0795845 100644 --- a/packages/workflow-template-solve-issue/src/roles.ts +++ b/packages/workflow-template-solve-issue/src/roles.ts @@ -1,5 +1,9 @@ import type { AgentFn, Role } from "@uncaged/workflow"; -import { type CommitterMeta, createCommitterRole } from "@uncaged/workflow-role-committer"; +import { + type CommitterMeta, + type CommitterPlanMeta, + createCommitterRole, +} from "@uncaged/workflow-role-committer"; import { createRole, type LlmProvider } from "@uncaged/workflow-role-llm"; import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer"; import * as z from "zod/v4"; @@ -33,6 +37,26 @@ export type PlannerMeta = z.infer; export type CoderMeta = z.infer; +const PLANNER_DRY_RUN_META: PlannerMeta = { + plan: "", + files: [], + approach: "", +}; + +const CODER_DRY_RUN_META: CoderMeta = { + filesChanged: [], + summary: "", +}; + +const REVIEWER_DRY_RUN_META: ReviewerMeta = { + approved: true, +}; + +const COMMITTER_PLAN_DRY_RUN_META: CommitterPlanMeta = { + branch: "dry-run", + message: "chore: dry run", +}; + export type SolveIssueMeta = { planner: PlannerMeta; coder: CoderMeta; @@ -40,7 +64,7 @@ export type SolveIssueMeta = { committer: CommitterMeta; }; -/** Wiring for workflow-role LLM structured extraction. Use null for schema-default dry runs (tests / stubs). */ +/** Wiring for workflow-role LLM structured extraction. Use `null` for stub extract (dry-run meta from built-in placeholders). */ export type SolveIssueRolesConfig = { agent: AgentFn; workdir: string; @@ -83,7 +107,11 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue schema: plannerMetaSchema, systemPrompt: PLANNER_SYSTEM, agent: config.agent, - extract: { provider: extract.provider, dryRun: extract.dryRun }, + extract: { + provider: extract.provider, + dryRun: extract.dryRun, + dryRunMeta: PLANNER_DRY_RUN_META, + }, }); const coder: Role = createRole({ @@ -91,18 +119,30 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue schema: coderMetaSchema, systemPrompt: CODER_SYSTEM, agent: config.agent, - extract: { provider: extract.provider, dryRun: extract.dryRun }, + extract: { + provider: extract.provider, + dryRun: extract.dryRun, + dryRunMeta: CODER_DRY_RUN_META, + }, }); const reviewer: Role = createReviewerRole( config.agent, - { provider: extract.provider, dryRun: extract.dryRun }, + { + provider: extract.provider, + dryRun: extract.dryRun, + dryRunMeta: REVIEWER_DRY_RUN_META, + }, reviewerGit, ); const committer: Role = createCommitterRole( config.agent, - { provider: extract.provider, dryRun: extract.dryRun }, + { + provider: extract.provider, + dryRun: extract.dryRun, + dryRunMeta: COMMITTER_PLAN_DRY_RUN_META, + }, committerGit, ); diff --git a/packages/workflow-util-role/src/index.ts b/packages/workflow-util-role/src/index.ts index 1ad39da..973de1e 100644 --- a/packages/workflow-util-role/src/index.ts +++ b/packages/workflow-util-role/src/index.ts @@ -6,4 +6,3 @@ export { type WithDryRunOptions, withDryRun, } from "./decorators.js"; -export { schemaDefaults } from "./schema-defaults.js"; diff --git a/packages/workflow-util-role/src/schema-defaults.ts b/packages/workflow-util-role/src/schema-defaults.ts deleted file mode 100644 index a63a68d..0000000 --- a/packages/workflow-util-role/src/schema-defaults.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type * as z from "zod/v4"; - -type ZodTypeAny = z.ZodType; - -type Def = Record & { type: string }; -type TypeHandler = (schema: ZodTypeAny, def: Def) => unknown; - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isZodExactOptional(s: ZodTypeAny): boolean { - return s.constructor.name === "ZodExactOptional"; -} - -function resolveDefaultValue(defaultValue: unknown | (() => unknown)): unknown { - if (typeof defaultValue === "function") { - return (defaultValue as () => unknown)(); - } - return defaultValue; -} - -function mergeIntersection(left: unknown, right: unknown): unknown { - if (isPlainObject(left) && isPlainObject(right)) { - return { ...left, ...right }; - } - return right; -} - -function defaultsForObject(_schema: ZodTypeAny, def: Def): unknown { - const shape = def.shape as Record | undefined; - if (shape === undefined) { - return {}; - } - const out: Record = {}; - for (const key of Object.keys(shape)) { - const child = shape[key]; - const cdef = child.def as { type: string }; - if (cdef.type === "optional") { - if (isZodExactOptional(child)) { - continue; - } - out[key] = undefined; - } else { - out[key] = schemaDefaultsInner(child); - } - } - return out; -} - -function firstUnionOption(_schema: ZodTypeAny, def: Def): unknown { - const options = def.options as readonly ZodTypeAny[] | undefined; - if (options === undefined || options.length === 0) { - return null; - } - return schemaDefaultsInner(options[0]); -} - -function defaultsFromNullable(_schema: ZodTypeAny, _def: Def): unknown { - return null; -} - -function defaultsFromInner(_schema: ZodTypeAny, def: Def): unknown { - const inner = def.innerType as ZodTypeAny | undefined; - if (inner === undefined) { - return null; - } - return schemaDefaultsInner(inner); -} - -function defaultsForPipe(_schema: ZodTypeAny, def: Def): unknown { - const out = def.out as ZodTypeAny | undefined; - if (out === undefined) { - return null; - } - return schemaDefaultsInner(out); -} - -function defaultsForIntersection(_schema: ZodTypeAny, def: Def): unknown { - const left = def.left as ZodTypeAny | undefined; - const right = def.right as ZodTypeAny | undefined; - if (left === undefined || right === undefined) { - return null; - } - return mergeIntersection(schemaDefaultsInner(left), schemaDefaultsInner(right)); -} - -function defaultsForTuple(_schema: ZodTypeAny, def: Def): unknown { - const items = def.items as readonly ZodTypeAny[] | undefined; - if (items === undefined) { - return []; - } - return items.map((item) => schemaDefaultsInner(item)); -} - -function defaultsForLazy(schema: ZodTypeAny, def: Def): unknown { - const inner = - (schema as { _zod?: { innerType?: ZodTypeAny } })._zod?.innerType ?? - (def.getter as (() => ZodTypeAny) | undefined)?.(); - if (inner === undefined) { - return null; - } - return schemaDefaultsInner(inner); -} - -function defaultsForPromise(_schema: ZodTypeAny, def: Def): unknown { - const inner = def.innerType as ZodTypeAny | undefined; - if (inner === undefined) { - return Promise.resolve(null); - } - return Promise.resolve(schemaDefaultsInner(inner)); -} - -function firstEnumValue(_schema: ZodTypeAny, def: Def): unknown { - const entries = def.entries as Record | undefined; - if (entries === undefined) { - return null; - } - const values = Object.values(entries); - return values[0] ?? null; -} - -function firstLiteralValue(_schema: ZodTypeAny, def: Def): unknown { - const values = def.values as unknown[] | undefined; - if (values === undefined || values.length === 0) { - return null; - } - return values[0]; -} - -const TYPE_HANDLERS: Record = { - string: () => "", - number: () => 0, - boolean: () => false, - bigint: () => 0n, - date: () => new Date(0), - symbol: () => Symbol(), - undefined: () => undefined, - null: () => null, - void: () => undefined, - any: () => null, - unknown: () => null, - never: () => undefined, - nan: () => Number.NaN, - array: () => [], - object: defaultsForObject, - record: () => ({}), - map: () => new Map(), - set: () => new Set(), - enum: firstEnumValue, - literal: firstLiteralValue, - optional: () => undefined, - nullable: defaultsFromNullable, - default: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)), - prefault: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)), - nonoptional: defaultsFromInner, - catch: defaultsFromInner, - success: () => false, - readonly: defaultsFromInner, - union: firstUnionOption, - xor: firstUnionOption, - intersection: defaultsForIntersection, - pipe: defaultsForPipe, - transform: () => null, - tuple: defaultsForTuple, - lazy: defaultsForLazy, - promise: defaultsForPromise, - file: () => new File([], ""), - function: () => null, - custom: () => null, - template_literal: () => "", -}; - -/** - * Produces a structurally valid placeholder that mirrors primitive/array/object - * shape for a Zod schema. Used for `llmExtract` dry runs so downstream code - * does not throw on `undefined` fields. - */ -export function schemaDefaults(schema: z.ZodType): unknown { - return schemaDefaultsInner(schema as ZodTypeAny); -} - -function schemaDefaultsInner(schema: ZodTypeAny): unknown { - const def = schema.def as Def; - const run = TYPE_HANDLERS[def.type]; - if (run === undefined) { - return null; - } - return run(schema, def); -}