Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions skills/dynamic-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const out = await agent('Return JSON findings', {
},
})
// out is validated object; engine retries ≤2 on invalid JSON
// codex/claude: native structured output first, then prompt repair; others: prompt+Ajv
```

### Providers
Expand Down
40 changes: 40 additions & 0 deletions src/local-agent-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import { delimiter } from "node:path";
import {
claudeCommandEnvironment,
claudeOutputFormatOptions,
createLocalAgentAdapter,
extractClaudeResultPayload,
extractOpenCodeFinalResponse,
extractPiFinalResponse,
extractPiProviderError,
Expand Down Expand Up @@ -388,3 +390,41 @@ assert.equal(

assert.equal(env.PATH, [devspaceBin, "/home/user/.local/bin"].join(delimiter));
}

{
assert.deepEqual(claudeOutputFormatOptions(undefined), {});
const schema = {
type: "object",
properties: { n: { type: "number" } },
required: ["n"],
};
assert.deepEqual(claudeOutputFormatOptions(schema), {
outputFormat: { type: "json_schema", schema },
});
}

{
assert.deepEqual(
extractClaudeResultPayload({
type: "result",
result: '{"n":1}',
structured_output: { n: 1 },
}),
{ finalResponse: '{"n":1}', structured: { n: 1 } },
);
assert.deepEqual(
extractClaudeResultPayload({
type: "result",
structured_output: { n: 2 },
}),
{ finalResponse: '{"n":2}', structured: { n: 2 } },
);
assert.deepEqual(
extractClaudeResultPayload({
type: "result",
result: "plain text only",
}),
{ finalResponse: "plain text only" },
);
assert.equal(extractClaudeResultPayload({ type: "assistant" }), undefined);
}
115 changes: 88 additions & 27 deletions src/local-agent-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { LocalAgentProvider } from "./local-agent-profiles.js";
import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js";
import {
createCodexSdkLocalAgentRuntime,
isNativeSchemaUnsupportedFailure,
ProviderSchemaUnsupportedError,
type LocalAgentRunInput,
type LocalAgentRunResult,
} from "./local-agent-runtime.js";
Expand Down Expand Up @@ -59,42 +61,98 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter {
async run(input: LocalAgentRunInput): Promise<LocalAgentRunResult> {
const { query } = await import("@anthropic-ai/claude-agent-sdk");
const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude");
const messages = query({
prompt: input.prompt,
options: {
cwd: input.workspace,
model: input.model,
...(input.effort ? { thinking: { type: "adaptive" } as const, effort: input.effort as EffortLevel } : {}),
resume: input.providerSessionId,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
env: claudeCommandEnvironment(process.env),
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
},
});
try {
const messages = query({
prompt: input.prompt,
options: {
cwd: input.workspace,
model: input.model,
...(input.effort
? { thinking: { type: "adaptive" } as const, effort: input.effort as EffortLevel }
: {}),
resume: input.providerSessionId,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
env: claudeCommandEnvironment(process.env),
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
...claudeOutputFormatOptions(input.schema),
},
});

let providerSessionId = input.providerSessionId ?? null;
let finalResponse = "";
const items: unknown[] = [];
for await (const message of messages) {
items.push(message);
const record = message as Record<string, unknown>;
if (typeof record.session_id === "string") providerSessionId = record.session_id;
if (record.type === "result" && typeof record.result === "string") {
let providerSessionId = input.providerSessionId ?? null;
let finalResponse = "";
let structured: unknown | undefined;
const items: unknown[] = [];
for await (const message of messages) {
items.push(message);
const record = message as Record<string, unknown>;
if (typeof record.session_id === "string") providerSessionId = record.session_id;
if (record.type !== "result") continue;
const resultError = claudeResultError(record);
if (resultError) throw new Error(resultError);
finalResponse = record.result;
const extracted = extractClaudeResultPayload(record);
if (extracted) {
finalResponse = extracted.finalResponse;
structured = extracted.structured;
}
}

finalResponse = requireFinalResponse("Claude", finalResponse);
return {
provider: this.provider,
providerSessionId,
finalResponse,
items,
...(structured !== undefined ? { structured } : {}),
};
} catch (error) {
if (input.schema && isNativeSchemaUnsupportedFailure(error)) {
throw new ProviderSchemaUnsupportedError(this.provider, error);
}
throw error;
}
}
}

/** Build Claude SDK outputFormat when a JSON Schema is requested. */
export function claudeOutputFormatOptions(
schema: object | undefined,
): { outputFormat: { type: "json_schema"; schema: Record<string, unknown> } } | Record<string, never> {
if (!schema) return {};
return {
outputFormat: {
type: "json_schema",
schema: schema as Record<string, unknown>,
},
};
}

finalResponse = requireFinalResponse("Claude", finalResponse);
/**
* Prefer structured_output from a Claude result message; fall back to text result.
* When only structured is present, stringify it for journal finalResponse.
*/
export function extractClaudeResultPayload(
record: Record<string, unknown>,
): { finalResponse: string; structured?: unknown } | undefined {
const hasStructured = Object.prototype.hasOwnProperty.call(record, "structured_output");
const structured = hasStructured ? record.structured_output : undefined;
const text = typeof record.result === "string" ? record.result : undefined;

if (hasStructured && structured !== undefined) {
return {
provider: this.provider,
providerSessionId,
finalResponse,
items,
finalResponse:
text && text.trim()
? text
: typeof structured === "string"
? structured
: JSON.stringify(structured),
structured,
};
}
if (text !== undefined) {
return { finalResponse: text };
}
return undefined;
}

function claudeResultError(record: Record<string, unknown>): string | undefined {
Expand Down Expand Up @@ -516,6 +574,9 @@ async function promptOpencodeSession(
sessionId: string,
input: LocalAgentRunInput,
): Promise<unknown> {
// OpenCode SessionPrompt accepts format: { type: 'json_schema', schema }, but
// per-model support is not programmatically discoverable — workflow agent({ schema })
// keeps the prompt+Ajv path for opencode (no native structured output here).
const session = (client as {
session: {
prompt(parameters?: unknown, options?: unknown): Promise<unknown>;
Expand Down
40 changes: 39 additions & 1 deletion src/local-agent-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RunResult, ThreadOptions } from "@openai/codex-sdk";
import {
CodexSdkLocalAgentRuntime,
createCodexSdkLocalAgentRuntime,
isNativeSchemaUnsupportedFailure,
} from "./local-agent-runtime.js";

const emptyTurn = (finalResponse: string): RunResult => ({
Expand All @@ -13,11 +14,19 @@ const emptyTurn = (finalResponse: string): RunResult => ({

class FakeThread {
prompts: string[] = [];
turnOptions: Array<{ outputSchema?: unknown; signal?: AbortSignal } | undefined> = [];

constructor(readonly id: string | null) {}

async run(prompt: string): Promise<RunResult> {
async run(
prompt: string,
turnOptions?: { outputSchema?: unknown; signal?: AbortSignal },
): Promise<RunResult> {
this.prompts.push(prompt);
this.turnOptions.push(turnOptions);
if (turnOptions?.outputSchema) {
return emptyTurn('{"ok":true}');
}
return emptyTurn(`response:${prompt}`);
}
}
Expand Down Expand Up @@ -49,7 +58,9 @@ const readOnly = await runtime.run({
assert.equal(readOnly.provider, "codex");
assert.equal(readOnly.providerSessionId, "new-thread");
assert.equal(readOnly.finalResponse, "response:inspect only");
assert.equal(readOnly.structured, undefined);
assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]);
assert.deepEqual(codex.startThreadInstance.turnOptions, [undefined]);
assert.deepEqual(codex.started[0], {
workingDirectory: "/tmp/project",
sandboxMode: "read-only",
Expand All @@ -74,6 +85,24 @@ assert.deepEqual(codex.started[1], {
modelReasoningEffort: "high",
});

const schema = {
type: "object",
properties: { ok: { type: "boolean" } },
required: ["ok"],
} as const;

const structured = await runtime.run({
prompt: "return structured",
workspace: "/tmp/project",
schema,
});

assert.equal(structured.finalResponse, '{"ok":true}');
assert.deepEqual(structured.structured, { ok: true });
assert.deepEqual(codex.startThreadInstance.turnOptions.at(-1), {
outputSchema: schema,
});

const resumed = await runtime.run({
prompt: "continue",
workspace: "/tmp/project",
Expand All @@ -83,6 +112,7 @@ const resumed = await runtime.run({

assert.equal(resumed.providerSessionId, "resumed-thread");
assert.deepEqual(codex.resumeThreadInstance.prompts, ["continue"]);
assert.deepEqual(codex.resumeThreadInstance.turnOptions, [undefined]);
assert.deepEqual(codex.resumed, [
{
id: "existing-thread",
Expand All @@ -98,3 +128,11 @@ assert.deepEqual(codex.resumed, [

const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex());
assert.equal(created.provider, "codex");

assert.equal(
isNativeSchemaUnsupportedFailure(
new Error("Invalid output schema: keyword is not supported"),
),
true,
);
assert.equal(isNativeSchemaUnsupportedFailure(new Error("authentication failed")), false);
Loading
Loading