Skip to content

Commit 7d797e3

Browse files
committed
feat(workflow): native-first schema for codex/claude agent()
Hardcode NATIVE_SCHEMA_PROVIDERS; attempt 0 uses adapter schema without prompt bloat, then prompt-repair retries with Ajv. Wire schema through runProvider / CLI worker. Document in skill.
1 parent 6af99da commit 7d797e3

5 files changed

Lines changed: 146 additions & 17 deletions

File tree

skills/dynamic-workflows/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const out = await agent('Return JSON findings', {
7777
},
7878
})
7979
// out is validated object; engine retries ≤2 on invalid JSON
80+
// codex/claude: native structured output first, then prompt repair; others: prompt+Ajv
8081
```
8182

8283
### Providers

src/workflow-api.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ export interface WorkflowProviderRunInput {
2525
signal?: AbortSignal;
2626
label?: string;
2727
phase?: string;
28+
/** JSON Schema for native structured output (codex/claude). */
29+
schema?: object;
2830
}
2931

3032
export interface WorkflowProviderRunResult {
3133
finalResponse: string;
3234
providerSessionId?: string;
35+
/** Provider-native structured object when schema was requested. */
36+
structured?: unknown;
3337
}
3438

3539
export type WorkflowRunProvider = (
@@ -346,14 +350,21 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
346350
const enforced = await enforceAgentSchema({
347351
schema: agentOpts.schema,
348352
prompt,
349-
run: (p) => deps.runProvider({ ...providerBase, prompt: p }),
350-
onRetry: ({ attempt, errors }) => {
353+
provider,
354+
run: (p) =>
355+
deps.runProvider({
356+
...providerBase,
357+
prompt: p,
358+
// Keep schema on adapter for codex/claude native+repair attempts.
359+
schema: agentOpts.schema,
360+
}),
361+
onRetry: ({ attempt, errors, mode }) => {
351362
deps.journal.appendEvent({
352363
runId: deps.runId,
353364
type: "schema_retry",
354365
phase,
355366
label: agentOpts.label,
356-
data: { callIndex: index, attempt, errors },
367+
data: { callIndex: index, attempt, errors, mode },
357368
});
358369
},
359370
});
@@ -362,6 +373,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
362373
result = {
363374
finalResponse: enforced.finalResponse,
364375
providerSessionId: enforced.providerSessionId,
376+
structured: enforced.value,
365377
};
366378
} else {
367379
result = await deps.runProvider(providerBase);

src/workflow-cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,10 +329,12 @@ export async function runWorkflowWorker(
329329
model: input.model,
330330
effort: input.effort,
331331
writeMode: "allowed",
332+
schema: input.schema,
332333
});
333334
return {
334335
finalResponse: providerResult.finalResponse,
335336
providerSessionId: providerResult.providerSessionId ?? undefined,
337+
structured: providerResult.structured,
336338
};
337339
},
338340
resolveNestedSource: async (ref) => {

src/workflow-schema.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
augmentPromptForSchema,
44
enforceAgentSchema,
55
formatAjvErrors,
6+
NATIVE_SCHEMA_PROVIDERS,
67
} from "./workflow-schema.js";
78
import { WorkflowEngineError } from "./workflow-api.js";
89

@@ -21,6 +22,10 @@ assert.equal(
2122
"/n must be number",
2223
);
2324

25+
assert.ok(NATIVE_SCHEMA_PROVIDERS.has("codex"));
26+
assert.ok(NATIVE_SCHEMA_PROVIDERS.has("claude"));
27+
assert.ok(!NATIVE_SCHEMA_PROVIDERS.has("opencode"));
28+
2429
{
2530
let attempts = 0;
2631
const result = await enforceAgentSchema({
@@ -40,6 +45,7 @@ assert.equal(
4045
assert.deepEqual(result.value, { n: 2 });
4146
assert.equal(result.attempts, 2);
4247
assert.equal(result.providerSessionId, "sess");
48+
assert.equal(result.mode, "prompt");
4349
}
4450

4551
{
@@ -56,4 +62,85 @@ assert.equal(
5662
);
5763
}
5864

65+
// Native provider: structured on attempt 0 → single attempt, raw prompt.
66+
{
67+
const seen: Array<{ prompt: string; mode?: string }> = [];
68+
const result = await enforceAgentSchema({
69+
schema: {
70+
type: "object",
71+
properties: { n: { type: "number" } },
72+
required: ["n"],
73+
additionalProperties: false,
74+
},
75+
prompt: "give n",
76+
provider: "codex",
77+
run: async (prompt, opts) => {
78+
seen.push({ prompt, mode: opts?.mode });
79+
return { finalResponse: "noise", structured: { n: 7 } };
80+
},
81+
});
82+
assert.deepEqual(result.value, { n: 7 });
83+
assert.equal(result.attempts, 1);
84+
assert.equal(result.mode, "native");
85+
assert.equal(seen.length, 1);
86+
assert.equal(seen[0]?.prompt, "give n");
87+
assert.equal(seen[0]?.mode, "native");
88+
assert.ok(!seen[0]?.prompt.includes("ONLY a JSON"));
89+
}
90+
91+
// Native fail then prompt repair.
92+
{
93+
const seen: Array<{ prompt: string; mode?: string }> = [];
94+
const retries: Array<{ attempt: number; mode: string }> = [];
95+
const result = await enforceAgentSchema({
96+
schema: {
97+
type: "object",
98+
properties: { n: { type: "number" } },
99+
required: ["n"],
100+
additionalProperties: false,
101+
},
102+
prompt: "give n",
103+
provider: "claude",
104+
onRetry: ({ attempt, mode }) => {
105+
retries.push({ attempt, mode });
106+
},
107+
run: async (prompt, opts) => {
108+
seen.push({ prompt, mode: opts?.mode });
109+
if (opts?.mode === "native") {
110+
return { finalResponse: '{"n":"bad"}', structured: { n: "bad" } };
111+
}
112+
return { finalResponse: '{"n":3}', structured: { n: 3 } };
113+
},
114+
});
115+
assert.deepEqual(result.value, { n: 3 });
116+
assert.equal(result.attempts, 2);
117+
assert.equal(result.mode, "prompt");
118+
assert.equal(seen[0]?.mode, "native");
119+
assert.equal(seen[1]?.mode, "prompt");
120+
assert.ok(seen[1]?.prompt.includes("ONLY a JSON"));
121+
assert.deepEqual(retries[0], { attempt: 1, mode: "native" });
122+
}
123+
124+
// Non-native never gets native mode.
125+
{
126+
const modes: string[] = [];
127+
const result = await enforceAgentSchema({
128+
schema: {
129+
type: "object",
130+
properties: { n: { type: "number" } },
131+
required: ["n"],
132+
},
133+
prompt: "give n",
134+
provider: "opencode",
135+
run: async (prompt, opts) => {
136+
modes.push(opts?.mode ?? "missing");
137+
assert.ok(prompt.includes("ONLY a JSON"));
138+
return { finalResponse: '{"n":1}' };
139+
},
140+
});
141+
assert.deepEqual(result.value, { n: 1 });
142+
assert.deepEqual(modes, ["prompt"]);
143+
assert.equal(result.mode, "prompt");
144+
}
145+
59146
console.log("workflow-schema.test.ts: ok");

src/workflow-schema.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import type { WorkflowProviderRunResult, WorkflowRunProvider } from "./workflow-
55

66
const require = createRequire(import.meta.url);
77

8+
/** Providers with a real structured-output API (hardcoded — no capability probe). */
9+
export const NATIVE_SCHEMA_PROVIDERS = new Set(["codex", "claude"]);
10+
811
type AjvLike = new (opts?: object) => {
912
compile: (schema: object) => ((data: unknown) => boolean) & {
1013
errors?: Array<{ instancePath?: string; message?: string }> | null;
@@ -23,11 +26,25 @@ function loadAjv(): AjvLike {
2326
}
2427
}
2528

29+
export type SchemaEnforceMode = "native" | "prompt";
30+
2631
export interface EnforceSchemaInput {
2732
schema: object;
2833
prompt: string;
29-
run: (prompt: string) => Promise<WorkflowProviderRunResult>;
30-
onRetry?: (info: { attempt: number; errors: string }) => void;
34+
/**
35+
* Provider id for native-vs-prompt policy. When in NATIVE_SCHEMA_PROVIDERS,
36+
* attempt 0 uses raw prompt + native structured path; later attempts repair via prompt.
37+
*/
38+
provider?: string;
39+
run: (
40+
prompt: string,
41+
opts?: { mode: SchemaEnforceMode },
42+
) => Promise<WorkflowProviderRunResult>;
43+
onRetry?: (info: {
44+
attempt: number;
45+
errors: string;
46+
mode: SchemaEnforceMode;
47+
}) => void;
3148
maxRetries?: number;
3249
}
3350

@@ -36,10 +53,12 @@ export interface EnforceSchemaResult {
3653
finalResponse: string;
3754
providerSessionId?: string;
3855
attempts: number;
56+
mode: SchemaEnforceMode;
3957
}
4058

4159
/**
42-
* Augment prompt → run → extract JSON → Ajv validate → retry ≤2.
60+
* Native-first for codex/claude; otherwise prompt+extract+Ajv. Always Ajv-validate.
61+
* Retries ≤ WORKFLOW_MAX_SCHEMA_RETRIES after the first attempt.
4362
*/
4463
export async function enforceAgentSchema(
4564
input: EnforceSchemaInput,
@@ -48,26 +67,31 @@ export async function enforceAgentSchema(
4867
const ajv = new Ajv({ allErrors: true, strict: false });
4968
const validate = ajv.compile(input.schema);
5069
const maxRetries = input.maxRetries ?? WORKFLOW_MAX_SCHEMA_RETRIES;
70+
const native = Boolean(input.provider && NATIVE_SCHEMA_PROVIDERS.has(input.provider));
5171
const basePrompt = augmentPromptForSchema(input.prompt, input.schema);
5272

53-
let lastResponse = "";
54-
let lastSession: string | undefined;
5573
let lastErrors = "unknown validation error";
5674

5775
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
76+
const mode: SchemaEnforceMode = native && attempt === 0 ? "native" : "prompt";
77+
5878
const prompt =
59-
attempt === 0
60-
? basePrompt
61-
: `${basePrompt}\n\nPrevious JSON failed validation:\n${lastErrors}\nReturn only corrected JSON.`;
79+
mode === "native"
80+
? input.prompt
81+
: attempt === 0
82+
? basePrompt
83+
: `${basePrompt}\n\nPrevious JSON failed validation:\n${lastErrors}\nReturn only corrected JSON.`;
84+
85+
const result = await input.run(prompt, { mode });
6286

63-
const result = await input.run(prompt);
64-
lastResponse = result.finalResponse;
65-
lastSession = result.providerSessionId ?? lastSession;
87+
const extracted =
88+
result.structured !== undefined
89+
? result.structured
90+
: tryExtractJson(result.finalResponse);
6691

67-
const extracted = tryExtractJson(result.finalResponse);
6892
if (extracted === undefined) {
6993
lastErrors = "Response was not valid JSON";
70-
input.onRetry?.({ attempt: attempt + 1, errors: lastErrors });
94+
input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode });
7195
continue;
7296
}
7397

@@ -78,11 +102,12 @@ export async function enforceAgentSchema(
78102
finalResponse: result.finalResponse,
79103
providerSessionId: result.providerSessionId,
80104
attempts: attempt + 1,
105+
mode,
81106
};
82107
}
83108

84109
lastErrors = formatAjvErrors(validate.errors);
85-
input.onRetry?.({ attempt: attempt + 1, errors: lastErrors });
110+
input.onRetry?.({ attempt: attempt + 1, errors: lastErrors, mode });
86111
}
87112

88113
throw new WorkflowEngineError(
@@ -122,11 +147,13 @@ export function schemaAwareRunProvider(
122147
return enforceAgentSchema({
123148
schema,
124149
prompt: base.prompt,
150+
provider: base.provider,
125151
onRetry,
126152
run: (prompt) =>
127153
runProvider({
128154
...base,
129155
prompt,
156+
schema,
130157
}),
131158
});
132159
}

0 commit comments

Comments
 (0)