Skip to content
Closed
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
21 changes: 21 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8831,6 +8831,27 @@
"description": "Allow npm pre/post install scripts to execute during package installation. By default, --ignore-scripts is added to all generated npm install commands to prevent supply chain attacks via malicious install hooks. Setting run-install-scripts: true disables this protection globally (all runtimes). A supply chain security warning is emitted at compile time; in strict mode this is an error. Per-runtime control is also available via runtimes.<runtime>.run-install-scripts. See: https://github.github.com/gh-aw/reference/frontmatter/#run-install-scripts",
"examples": [false, true]
},
"structured-output": {
"type": "object",
"description": "Declares a JSON Schema (draft-07) that the agent's primary response must conform to. Enables structured output mode: the compiler validates the schema at compile time, writes it to disk before agent execution, and a post-agent step validates that the agent produced well-formed JSON matching the schema. The validated JSON is exposed as the `structured_output` job output for downstream jobs. Exactly one of 'schema' (inline) or 'schema-file' (file path) must be specified.",
"properties": {
"schema": {
"type": "object",
"description": "Inline JSON Schema object (draft-07 compatible) that the agent's response must conform to. Mutually exclusive with 'schema-file'.",
"additionalProperties": true
},
"schema-file": {
"type": "string",
"description": "Path to a JSON Schema file (relative to the workflow file). The file must contain a valid JSON Schema object. Mutually exclusive with 'schema'.",
"examples": [".github/schemas/triage-output.schema.json"]
}
},
"oneOf": [
{ "required": ["schema"], "not": { "required": ["schema-file"] } },
{ "required": ["schema-file"], "not": { "required": ["schema"] } }
],
"additionalProperties": false
},
"mcp-scripts": {
"type": "object",
"description": "MCP Scripts configuration for defining custom lightweight MCP tools as JavaScript, shell scripts, or Python scripts. Tools are mounted in an MCP server and have access to secrets specified by the user. Only one of 'script' (JavaScript), 'run' (shell), or 'py' (Python) must be specified per tool.",
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/claude_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,9 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
// Add GH_AW_SAFE_OUTPUTS if output is needed
applySafeOutputEnvToMap(env, workflowData)

// Add GH_AW_STRUCTURED_OUTPUT_SCHEMA / GH_AW_STRUCTURED_OUTPUT_FILE if structured output is configured
applyStructuredOutputEnvToMap(env, workflowData)

// Add GH_AW_STARTUP_TIMEOUT environment variable (in seconds) if startup-timeout is specified
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.startup-timeout }}")
if workflowData.ToolsStartupTimeout != "" {
Expand Down
18 changes: 16 additions & 2 deletions pkg/workflow/codex_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ func (e *CodexEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri
customArgsParam += customArgsParamSb.String()
}

// Build output-schema parameter for structured output mode.
// When structured-output is configured, pass --output-schema to Codex so the model
// is constrained at the token-sampling level to produce schema-conformant JSON.
// The schema file is written by the "Set up structured output schema" pre-agent step.
// See: https://openai.github.io/codex/cli/exec#param-output-schema
var outputSchemaParam string
if HasStructuredOutput(workflowData) && !workflowData.IsDetectionRun {
outputSchemaParam = fmt.Sprintf("--output-schema %s ", StructuredOutputSchemaPath)
codexEngineLog.Printf("Adding --output-schema flag for structured output mode: %s", StructuredOutputSchemaPath)
}

// Build the Codex command
// Determine which command to use
var commandName string
Expand All @@ -203,8 +214,8 @@ func (e *CodexEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri
commandName = "codex"
}

codexCommand := fmt.Sprintf("%s %sexec%s%s%s%s\"$INSTRUCTION\"",
commandName, modelParam, webSearchParam, webFetchParam, fullAutoParam, customArgsParam)
codexCommand := fmt.Sprintf("%s %sexec%s%s%s%s%s\"$INSTRUCTION\"",
commandName, modelParam, webSearchParam, webFetchParam, fullAutoParam, outputSchemaParam, customArgsParam)

// Build the full command with agent file handling and AWF wrapping if enabled
var command string
Expand Down Expand Up @@ -302,6 +313,9 @@ mkdir -p "$CODEX_HOME/logs"
// Add GH_AW_SAFE_OUTPUTS if output is needed
applySafeOutputEnvToMap(env, workflowData)

// Add GH_AW_STRUCTURED_OUTPUT_SCHEMA / GH_AW_STRUCTURED_OUTPUT_FILE if structured output is configured
applyStructuredOutputEnvToMap(env, workflowData)

// In sandbox (AWF) mode, set git identity environment variables so the first git commit
// succeeds inside the container. AWF's --env-all forwards these to the container, ensuring
// git does not rely on the host-side ~/.gitconfig which is not visible in the sandbox.
Expand Down
72 changes: 72 additions & 0 deletions pkg/workflow/codex_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1007,3 +1007,75 @@ func TestCodexEngineWithExpressionVersion(t *testing.T) {
t.Errorf("Expression should NOT be embedded directly in npm install command, got:\n%s", installStep)
}
}

// TestCodexEngineStructuredOutputParam verifies that --output-schema is added to the codex
// exec command when structured-output is configured, and absent otherwise.
func TestCodexEngineStructuredOutputParam(t *testing.T) {
engine := NewCodexEngine()

t.Run("no --output-schema without structured output config", func(t *testing.T) {
steps := engine.GetExecutionSteps(&WorkflowData{Name: "test-workflow"}, "/tmp/gh-aw/test.log")
if len(steps) == 0 {
t.Fatal("Expected at least one execution step")
}
stepContent := strings.Join([]string(steps[0]), "\n")
if strings.Contains(stepContent, "--output-schema") {
t.Errorf("Expected no --output-schema flag when structured output is not configured, got:\n%s", stepContent)
}
})

t.Run("--output-schema added with structured output config", func(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"decision": map[string]any{"type": "string"},
},
}
soConfig, err := extractStructuredOutputConfig(
map[string]any{"structured-output": map[string]any{"schema": schema}},
"/tmp",
)
if err != nil {
t.Fatalf("extractStructuredOutputConfig returned unexpected error: %v", err)
}

data := &WorkflowData{
Name: "test-workflow",
StructuredOutputConfig: soConfig,
}
steps := engine.GetExecutionSteps(data, "/tmp/gh-aw/test.log")
if len(steps) == 0 {
t.Fatal("Expected at least one execution step")
}
stepContent := strings.Join([]string(steps[0]), "\n")
expectedFlag := "--output-schema " + StructuredOutputSchemaPath
if !strings.Contains(stepContent, expectedFlag) {
t.Errorf("Expected %q in codex exec command for structured output, got:\n%s", expectedFlag, stepContent)
}
})

t.Run("no --output-schema for detection run", func(t *testing.T) {
schema := map[string]any{"type": "object"}
soConfig, err := extractStructuredOutputConfig(
map[string]any{"structured-output": map[string]any{"schema": schema}},
"/tmp",
)
if err != nil {
t.Fatalf("extractStructuredOutputConfig returned unexpected error: %v", err)
}

data := &WorkflowData{
Name: "test-workflow",
StructuredOutputConfig: soConfig,
IsDetectionRun: true,
}
steps := engine.GetExecutionSteps(data, "/tmp/gh-aw/test.log")
if len(steps) == 0 {
t.Fatal("Expected at least one execution step")
}
stepContent := strings.Join([]string(steps[0]), "\n")
if strings.Contains(stepContent, "--output-schema") {
t.Errorf("Expected no --output-schema flag for detection run, got:\n%s", stepContent)
}
})
}
7 changes: 7 additions & 0 deletions pkg/workflow/compiler_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (
outputs["has_patch"] = "${{ steps.collect_output.outputs.has_patch }}"
}

// Add structured_output job output when structured-output mode is configured.
// The value is the validated, compact JSON string produced by the validate_structured_output step.
if HasStructuredOutput(data) {
outputs["structured_output"] = "${{ steps.validate_structured_output.outputs.structured_output }}"
compilerMainJobLog.Print("Added structured_output output (structured-output mode enabled)")
}

// Add checkout_pr_success output to track PR checkout status only if the checkout-pr step will be generated
// This is used by the conclusion job to skip failure handling when checkout fails
// (e.g., when PR is merged and branch is deleted)
Expand Down
7 changes: 7 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ func (c *Compiler) extractAdditionalConfigurations(
workflowData.MCPScripts = c.mergeMCPScripts(workflowData.MCPScripts, importsResult.MergedMCPScripts)
}

// Extract structured-output configuration (compile-time schema validation included)
structuredOutputConfig, err := extractStructuredOutputConfig(frontmatter, markdownDir)
if err != nil {
return fmt.Errorf("structured-output: %w", err)
}
workflowData.StructuredOutputConfig = structuredOutputConfig

// Extract safe-jobs from safe-outputs.jobs location
topSafeJobs := extractSafeJobsFromFrontmatter(frontmatter)

Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,10 @@ type WorkflowData struct {
CachedParsedToolsets []string // cached result of ParseGitHubToolsets for the GitHub tool (for performance optimization); populated by applyDefaults
CachedAllowedDomainsStr string // cached allowed-domains string for sanitization (for performance optimization); computed once and reused across multiple compilation steps
CachedAllowedDomainsComputed bool // true once CachedAllowedDomainsStr has been set; distinguishes "computed empty" from "not yet computed"

// StructuredOutputConfig holds the resolved structured-output configuration.
// Set when the frontmatter declares a structured-output section.
StructuredOutputConfig *StructuredOutputConfig
}

// PinContext returns an actionpins.PinContext backed by this WorkflowData.
Expand Down
14 changes: 14 additions & 0 deletions pkg/workflow/compiler_yaml_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
// connects to via host.docker.internal:18443.
c.generateStartCliProxyStep(yaml, data)

// Write structured-output schema to disk before engine execution so the agent can
// discover the schema file via GH_AW_STRUCTURED_OUTPUT_SCHEMA and produce a conforming response.
if schemaStep := generateStructuredOutputSchemaStep(data); schemaStep != "" {
yaml.WriteString(schemaStep)
}

// Add AI execution step using the agentic engine
compilerYamlLog.Printf("Generating engine execution steps for %s", engine.GetID())
c.generateEngineExecutionSteps(yaml, data, engine, logFileFull)
Expand Down Expand Up @@ -459,6 +465,14 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
// secret redaction already scanned this file, so it is safe to append.
c.generateAgentStepSummaryAppend(yaml)

// Validate structured output (if configured): reads the agent's structured output file,
// confirms it is well-formed JSON, and exposes it as the validate_structured_output step output.
if validationStep := generateStructuredOutputValidationStep(data, getCachedActionPin); validationStep != "" {
yaml.WriteString(validationStep)
// Include the structured output file in the agent artifact for traceability.
artifactPaths = append(artifactPaths, StructuredOutputFilePath)
}

// Add output collection step only if safe-outputs feature is used (GH_AW_SAFE_OUTPUTS functionality)
if data.SafeOutputs != nil {
if err := c.generateOutputCollectionStep(yaml, data); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,9 @@ touch %s
// Add GH_AW_SAFE_OUTPUTS if output is needed
applySafeOutputEnvToMap(env, workflowData)

// Add GH_AW_STRUCTURED_OUTPUT_SCHEMA / GH_AW_STRUCTURED_OUTPUT_FILE if structured output is configured
applyStructuredOutputEnvToMap(env, workflowData)

// Add GH_AW_STARTUP_TIMEOUT environment variable (in seconds) if startup-timeout is specified
// Supports both literal integers and GitHub Actions expressions (e.g. "${{ inputs.startup-timeout }}")
if workflowData.ToolsStartupTimeout != "" {
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/crush_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ func (e *CrushEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri
// Safe outputs env
applySafeOutputEnvToMap(env, workflowData)

// Add GH_AW_STRUCTURED_OUTPUT_SCHEMA / GH_AW_STRUCTURED_OUTPUT_FILE if structured output is configured
applyStructuredOutputEnvToMap(env, workflowData)

// Model env var (only when explicitly configured)
if modelConfigured {
crushLog.Printf("Setting %s env var for model: %s",
Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ type FrontmatterConfig struct {
SecretMasking *SecretMaskingConfig `json:"secret-masking,omitempty"`
Observability *ObservabilityConfig `json:"observability,omitempty"`

// Structured output configuration — declares a JSON Schema that the agent's primary
// response must conform to. Either 'schema' (inline) or 'schema-file' (file path) is required.
StructuredOutput *StructuredOutputConfig `json:"structured-output,omitempty"`

// Rate limiting configuration
RateLimit *RateLimitConfig `json:"rate-limit,omitempty"`

Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/gemini_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ touch %s
// Add safe outputs env
applySafeOutputEnvToMap(env, workflowData)

// Add GH_AW_STRUCTURED_OUTPUT_SCHEMA / GH_AW_STRUCTURED_OUTPUT_FILE if structured output is configured
applyStructuredOutputEnvToMap(env, workflowData)

// Set the model environment variable only when explicitly configured.
// When model is configured, use the native GEMINI_MODEL env var - the Gemini CLI reads it
// directly, avoiding the need to embed the value in the shell command (which would fail
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/opencode_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ func (e *OpenCodeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile s

applySafeOutputEnvToMap(env, workflowData)

// Add GH_AW_STRUCTURED_OUTPUT_SCHEMA / GH_AW_STRUCTURED_OUTPUT_FILE if structured output is configured
applyStructuredOutputEnvToMap(env, workflowData)

if modelConfigured {
openCodeLog.Printf("Setting %s env var for model: %s",
constants.OpenCodeCLIModelEnvVar, workflowData.EngineConfig.Model)
Expand Down
Loading