Add exec output-schema parameter (#4079)
Adds structured output to `exec` via the `--structured-output` parameter.
This commit is contained in:
@@ -52,6 +52,10 @@ pub struct Cli {
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
/// Path to a JSON Schema file describing the model's final response shape.
|
||||
#[arg(long = "output-schema", value_name = "FILE")]
|
||||
pub output_schema: Option<PathBuf>,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
use event_processor_with_json_output::EventProcessorWithJsonOutput;
|
||||
use serde_json::Value;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
@@ -51,6 +52,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
json: json_mode,
|
||||
sandbox_mode: sandbox_mode_cli_arg,
|
||||
prompt,
|
||||
output_schema: output_schema_path,
|
||||
config_overrides,
|
||||
} = cli;
|
||||
|
||||
@@ -96,6 +98,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
}
|
||||
};
|
||||
|
||||
let output_schema = load_output_schema(output_schema_path);
|
||||
|
||||
let (stdout_with_ansi, stderr_with_ansi) = match color {
|
||||
cli::Color::Always => (true, true),
|
||||
cli::Color::Never => (false, false),
|
||||
@@ -193,7 +197,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
// is using.
|
||||
event_processor.print_config_summary(&config, &prompt);
|
||||
|
||||
if !skip_git_repo_check && get_git_repo_root(&config.cwd.to_path_buf()).is_none() {
|
||||
let default_cwd = config.cwd.to_path_buf();
|
||||
let default_approval_policy = config.approval_policy;
|
||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||
let default_model = config.model.clone();
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
|
||||
if !skip_git_repo_check && get_git_repo_root(&default_cwd).is_none() {
|
||||
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -288,7 +299,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
|
||||
// Send the prompt.
|
||||
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
|
||||
let initial_prompt_task_id = conversation.submit(Op::UserInput { items }).await?;
|
||||
let initial_prompt_task_id = conversation
|
||||
.submit(Op::UserTurn {
|
||||
items,
|
||||
cwd: default_cwd,
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy,
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {initial_prompt_task_id}");
|
||||
|
||||
// Run the loop until the task is complete.
|
||||
@@ -327,3 +349,29 @@ async fn resolve_resume_path(
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
|
||||
let path = path?;
|
||||
|
||||
let schema_str = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Failed to read output schema file {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str::<Value>(&schema_str) {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Output schema file {} is not valid JSON: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod apply_patch;
|
||||
mod common;
|
||||
mod output_schema;
|
||||
mod resume;
|
||||
mod sandbox;
|
||||
|
||||
76
codex-rs/exec/tests/suite/output_schema.rs
Normal file
76
codex-rs/exec/tests/suite/output_schema.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use assert_cmd::prelude::*;
|
||||
use core_test_support::responses;
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let workspace = TempDir::new()?;
|
||||
|
||||
let schema_contents = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let schema_path = workspace.path().join("schema.json");
|
||||
std::fs::write(&schema_path, serde_json::to_vec_pretty(&schema_contents)?)?;
|
||||
let expected_schema: Value = schema_contents;
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp1"}
|
||||
}),
|
||||
responses::ev_assistant_message("m1", "fixture hello"),
|
||||
responses::ev_completed("resp1"),
|
||||
]);
|
||||
responses::mount_sse_once(&server, any(), body).await;
|
||||
|
||||
Command::cargo_bin("codex-exec")?
|
||||
.current_dir(workspace.path())
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(workspace.path())
|
||||
.arg("--output-schema")
|
||||
.arg(&schema_path)
|
||||
.arg("-m")
|
||||
.arg("gpt-5")
|
||||
.arg("tell me a joke")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("failed to capture requests");
|
||||
assert_eq!(requests.len(), 1, "expected exactly one request");
|
||||
let payload: Value = serde_json::from_slice(&requests[0].body)?;
|
||||
let text = payload.get("text").expect("request missing text field");
|
||||
let format = text
|
||||
.get("format")
|
||||
.expect("request missing text.format field");
|
||||
assert_eq!(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"name": "codex_output_schema",
|
||||
"type": "json_schema",
|
||||
"strict": true,
|
||||
"schema": expected_schema,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user