feat: add structured output support

Add support for Agent SDK structured outputs.

New input: json_schema - JSON schema for validated outputs
Auto-sets GitHub Action outputs for each field

Security:
- Reserved output protection (prevents shadowing)
- 1MB output size limits enforced
- Output key format validation
- Objects/arrays >1MB skipped (not truncated to invalid JSON)

Tests:
- 26 unit tests
- 5 integration tests
- 480 tests passing

Docs: https://docs.claude.com/en/docs/agent-sdk/structured-outputs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
inigo
2025-11-18 10:08:11 -08:00
parent 08f88abe2b
commit e600a516c7
12 changed files with 1076 additions and 4 deletions

View File

@@ -28,8 +28,22 @@ async function run() {
promptFile: process.env.INPUT_PROMPT_FILE || "",
});
// Build claudeArgs with JSON schema if provided
let claudeArgs = process.env.INPUT_CLAUDE_ARGS || "";
// Add allowed tools if specified
if (process.env.INPUT_ALLOWED_TOOLS) {
claudeArgs += ` --allowedTools "${process.env.INPUT_ALLOWED_TOOLS}"`;
}
// Add JSON schema if specified
if (process.env.JSON_SCHEMA) {
const escapedSchema = process.env.JSON_SCHEMA.replace(/'/g, "'\\''");
claudeArgs += ` --json-schema '${escapedSchema}'`;
}
await runClaude(promptConfig.path, {
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
claudeArgs: claudeArgs.trim(),
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS,

View File

@@ -1,7 +1,7 @@
import * as core from "@actions/core";
import { exec } from "child_process";
import { promisify } from "util";
import { unlink, writeFile, stat } from "fs/promises";
import { unlink, writeFile, stat, readFile } from "fs/promises";
import { createWriteStream } from "fs";
import { spawn } from "child_process";
import { parse as parseShellArgs } from "shell-quote";
@@ -12,6 +12,14 @@ const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
// GitHub Actions output limits
const MAX_OUTPUT_SIZE = 1024 * 1024; // 1MB per output field
type ExecutionMessage = {
type: string;
structured_output?: Record<string, unknown>;
};
/**
* Sanitizes JSON output to remove sensitive information when full output is disabled
* Returns a safe summary message or null if the message should be completely suppressed
@@ -122,6 +130,140 @@ export function prepareRunConfig(
};
}
/**
* Sanitizes output field names to meet GitHub Actions output naming requirements
* GitHub outputs must be alphanumeric, hyphen, or underscore only
*/
export function sanitizeOutputName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
}
// Reserved output names that cannot be used by structured outputs
const RESERVED_OUTPUTS = ["conclusion", "execution_file"] as const;
/**
* Converts values to string format for GitHub Actions outputs
* GitHub outputs must always be strings
*/
export function convertToString(value: unknown): string {
switch (typeof value) {
case "string":
return value;
case "boolean":
case "number":
return String(value);
case "object":
if (value === null) return "";
// Handle circular references
try {
return JSON.stringify(value);
} catch (e) {
return "[Circular or non-serializable object]";
}
case "undefined":
return "";
default:
// Handle Symbol, Function, etc.
return String(value);
}
}
/**
* Parses structured_output from execution file and sets GitHub Action outputs
* Only runs if json_schema was explicitly provided by the user
*/
async function parseAndSetStructuredOutputs(
executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
const messages = JSON.parse(content) as ExecutionMessage[];
const result = messages.find(
(m) => m.type === "result" && m.structured_output,
);
if (!result?.structured_output) {
const error = new Error(
`json_schema was provided but Claude did not return structured_output.\n` +
`Found ${messages.length} messages. Result exists: ${!!result}\n` +
`The schema may be invalid or Claude failed to call the StructuredOutput tool.`,
);
core.setFailed(error.message);
throw error;
}
// Set GitHub Action output for each field
const entries = Object.entries(result.structured_output);
core.info(`Setting ${entries.length} structured output(s)`);
for (const [key, value] of entries) {
// Validate key before sanitization
if (!key || key.trim() === "") {
core.warning("Skipping empty output key");
continue;
}
const sanitizedKey = sanitizeOutputName(key);
// Ensure key starts with letter or underscore (GitHub Actions convention)
if (!/^[a-zA-Z_]/.test(sanitizedKey)) {
core.warning(
`Skipping invalid output key "${key}" (sanitized: "${sanitizedKey}")`,
);
continue;
}
// Prevent shadowing reserved action outputs
if (RESERVED_OUTPUTS.includes(sanitizedKey as any)) {
core.warning(
`Skipping reserved output key "${key}" - would shadow action output "${sanitizedKey}"`,
);
continue;
}
const stringValue = convertToString(value);
// Enforce GitHub Actions output size limit (1MB)
if (stringValue.length > MAX_OUTPUT_SIZE) {
// Don't truncate objects/arrays - would create invalid JSON
if (typeof value === "object" && value !== null) {
core.warning(
`Output "${sanitizedKey}" object/array exceeds 1MB (${stringValue.length} bytes). Skipping - reduce data size.`,
);
continue;
}
// For primitives, truncation is safe
core.warning(
`Output "${sanitizedKey}" exceeds 1MB (${stringValue.length} bytes), truncating`,
);
const truncated = stringValue.substring(0, MAX_OUTPUT_SIZE);
core.setOutput(sanitizedKey, truncated);
core.info(`${sanitizedKey}=[TRUNCATED ${stringValue.length} bytes]`);
} else {
// Truncate long values in logs for readability
const displayValue =
stringValue.length > 100
? `${stringValue.slice(0, 97)}...`
: stringValue;
core.setOutput(sanitizedKey, stringValue);
core.info(`${sanitizedKey}=${displayValue}`);
}
}
} catch (error) {
if (error instanceof Error) {
core.setFailed(error.message);
throw error; // Preserve original error and stack trace
}
const wrappedError = new Error(
`Failed to parse structured outputs: ${error}`,
);
core.setFailed(wrappedError.message);
throw wrappedError;
}
}
export async function runClaude(promptPath: string, options: ClaudeOptions) {
const config = prepareRunConfig(promptPath, options);
@@ -308,8 +450,27 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
core.warning(`Failed to process output for execution metrics: ${e}`);
}
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE);
// Parse and set structured outputs only if user provided json_schema
let structuredOutputSuccess = true;
if (process.env.JSON_SCHEMA) {
try {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
} catch (error) {
structuredOutputSuccess = false;
// Error already logged by parseAndSetStructuredOutputs
}
}
// Set conclusion after structured output parsing (which may fail)
core.setOutput(
"conclusion",
structuredOutputSuccess ? "success" : "failure",
);
if (!structuredOutputSuccess) {
process.exit(1);
}
} else {
core.setOutput("conclusion", "failure");