Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ them through an ordinary `cargo update`.
*highest-precedence* credential variable Copilot accepts and it was missing from the
list, so a host authenticating that way would have failed to authenticate at all once
`Minimal` became the default.
- **A failure now reports its cause rather than a status line.** The first line of stderr
was taken as the explanation, but CLIs open with progress chatter: a rejected Codex schema
reported "Reading additional input from stdin...", which is not what went wrong. A line
that looks like an error now wins, and stdout is consulted when stderr only narrates, since
Codex reports errors as JSON events on stdout.
- **Dropping a `Run` now reliably kills the process group.** It signalled the driver and
aborted it, which left the kill waiting on the runtime to poll the aborted task. On Linux
that did not reliably happen and grandchildren survived, while `cancel` and timeouts were
Expand All @@ -51,6 +56,17 @@ them through an ordinary `cargo update`.

### Added

- **`Request::schema`** constrains an answer to a JSON Schema, with the conforming value on
`Outcome::structured` already parsed. For answers that are data rather than prose, such as
a set of review findings, this replaces guessing at formatting the model never promised.
Delivery differs and is hidden: Claude takes the schema inline and reports the value in its
own field, Codex reads it from a file this crate writes and removes. Copilot 1.0.75 has no
schema support, so asking is `Error::Unsupported` rather than prose presented as data.

Write schemas strictly: Codex sends yours to a provider that requires
`"additionalProperties": false` on every object, and rejects the request with a 400 before
the model runs otherwise. Claude is more forgiving, so a schema that works there can still
fail on Codex.
- **`AuthStatus::check(agent)`** answers whether an agent is logged in without spending a
request, which a missing login otherwise only revealed by running a turn and failing.
Claude reports JSON, Codex reports prose, and Copilot offers neither: that case is
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ the first one's conversation. Uppercase is encoded too, since macOS and Windows
case-insensitive and would otherwise collide `Chat` with `chat`. The record keeps your
original name, so `list()` hands back what you passed, not a mangled segment.

## Structured answers

When the answer is data rather than prose, constrain it with a JSON Schema and read it
back parsed instead of guessing at formatting the model never promised:

```rust
let outcome = run(&Request::new(Agent::Codex, "Alice is 30 years old.")
.schema(r#"{"type":"object",
"properties":{"name":{"type":"string"},"age":{"type":"integer"}},
"required":["name","age"],"additionalProperties":false}"#))
.await?;

assert_eq!(outcome.structured.unwrap()["name"], "Alice");
```

The delivery differs and is hidden: Claude takes the schema inline and reports the value in
its own field, Codex reads it from a file this crate writes and removes, and returns the
value as its answer text. **Copilot 1.0.75 has no schema support at all**, so asking is an
`Error::Unsupported` rather than prose dressed up as data.

**Write schemas strictly.** Codex sends yours to OpenAI's structured-output API, which
requires `"additionalProperties": false` on every object and every property in `required`.
Without it the request fails with a 400 before the model runs. Claude is more forgiving, so
a schema that works there can still fail on Codex; writing to the stricter rule keeps one
schema usable for both.

## Cancellation

**Dropping a `Run` kills the agent, and everything it spawned.** Closing a window or
Expand Down
102 changes: 102 additions & 0 deletions src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ pub struct Caps {
/// Whether the agent takes a real system-prompt flag. When false the system
/// text is prepended to the prompt so it still reaches the model.
pub native_system: bool,
/// How the agent accepts a JSON Schema for its answer, if at all.
pub schema: SchemaSupport,
}

/// How an agent accepts a JSON Schema constraining its answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaSupport {
/// The schema rides the command line (`claude --json-schema <schema>`).
Inline,
/// The schema must be a file the agent reads
/// (`codex exec --output-schema <FILE>`), so the runner writes one.
File,
/// No structured-output support. Asking is an error rather than a prose
/// answer dressed up as data.
None,
}

/// Permission posture for a run, mapped onto each agent's own vocabulary.
Expand Down Expand Up @@ -216,6 +231,15 @@ pub struct Plan {
pub cont: Continue,
/// True when the prompt is piped on stdin instead of riding the argv.
pub stdin_prompt: bool,
/// A JSON Schema the answer must conform to, as text.
///
/// Delivered differently per agent: Claude takes it inline, Codex takes a
/// path, so this is the source and `schema_file` is where the runner put it
/// when a file was needed.
pub schema: Option<String>,
/// Path to the schema on disk, materialized by the runner for agents that
/// take a file rather than an inline value.
pub schema_file: Option<String>,
}

/// Prompts at or above this many bytes are piped on stdin rather than placed on
Expand Down Expand Up @@ -416,6 +440,9 @@ impl Agent {
fork: true,
events: true,
native_system: true,
// Verified: `--json-schema <inline>` puts the conforming value
// in the result document's `structured_output`.
schema: SchemaSupport::Inline,
},
// `codex exec --json` emits `thread_id`; continuation is the
// `resume` subcommand and is linear (`codex fork` is TUI-only).
Expand All @@ -424,6 +451,9 @@ impl Agent {
fork: false,
events: true,
native_system: false,
// Verified: `--output-schema <FILE>` makes the final
// `agent_message` the conforming JSON, with no separate field.
schema: SchemaSupport::File,
},
// Verified against Copilot CLI 1.0.75: `--session-id <uuid>` both
// mints a new session and resumes an existing one (one flag, both
Expand All @@ -434,6 +464,8 @@ impl Agent {
fork: false,
events: true,
native_system: false,
// Copilot 1.0.75 exposes no schema flag at all.
schema: SchemaSupport::None,
},
}
}
Expand Down Expand Up @@ -482,6 +514,12 @@ impl Agent {
what: "assigning a session id up front",
});
}
if plan.schema.is_some() && caps.schema == SchemaSupport::None {
return Err(Error::Unsupported {
agent: self,
what: "constraining its answer to a JSON schema",
});
}
if plan.format == Format::Stream && !caps.events {
return Err(Error::Unsupported {
agent: self,
Expand Down Expand Up @@ -693,6 +731,10 @@ fn argv_claude(plan: &Plan) -> Vec<Arg> {
Format::Stream => "stream-json",
},
);
if let Some(schema) = &plan.schema {
// Inline, and the conforming value comes back in `structured_output`.
a.secret("--json-schema", schema, Sensitivity::Prompt);
}
if plan.format == Format::Stream {
// Claude refuses `-p --output-format stream-json` without it:
// "--print with --output-format=stream-json requires --verbose".
Expand Down Expand Up @@ -741,6 +783,15 @@ fn argv_codex(plan: &Plan) -> Vec<Arg> {
};

a.opt("--model", plan.model.as_ref());
// Codex reads the schema from a file, which the runner writes before the
// spawn. `Request::argv` has no file to name, so it shows a placeholder:
// the preview is for display, and the real path exists only at spawn time.
if plan.schema.is_some() {
a.pair(
"--output-schema",
plan.schema_file.as_deref().unwrap_or("<schema-file>"),
);
}
// `--json` is Codex's event stream and the only place `thread_id` appears.
if plan.format != Format::Text {
a.bare("--json");
Expand Down Expand Up @@ -827,6 +878,8 @@ mod tests {
format: Format::Json,
cont: Continue::New,
stdin_prompt: false,
schema: None,
schema_file: None,
}
}

Expand Down Expand Up @@ -1022,6 +1075,55 @@ mod tests {
/// `codex exec resume` rejects `--sandbox` and takes `-c sandbox_mode=`
/// instead. Getting this wrong makes every second turn fail with an
/// "unexpected argument" error, which only a multi-turn run reveals.
/// The two CLIs take a schema differently, and the difference is the
/// whole reason this needs handling rather than one shared flag.
#[test]
fn each_agent_takes_a_schema_in_its_own_shape() {
let schema = r#"{"type":"object"}"#;

let mut claude = plan("claude");
claude.schema = Some(schema.into());
let a = argv(Agent::Claude, &claude);
assert_eq!(
a[pos(&a, "--json-schema").unwrap() + 1],
schema,
"claude takes it inline"
);

let mut codex = plan("codex");
codex.schema = Some(schema.into());
codex.schema_file = Some("/tmp/s.json".into());
let a = argv(Agent::Codex, &codex);
assert_eq!(
a[pos(&a, "--output-schema").unwrap() + 1],
"/tmp/s.json",
"codex takes a path, never the schema itself"
);
assert!(!a.iter().any(|arg| arg.contains("\"type\"")));
}

/// Copilot 1.0.75 has no schema flag, and a prose answer presented as data
/// is exactly the silent downgrade this crate refuses elsewhere.
#[test]
fn copilot_refuses_a_schema_rather_than_answering_in_prose() {
let mut p = plan("copilot");
p.schema = Some(r#"{"type":"object"}"#.into());
assert!(matches!(
Agent::Copilot.argv(&p),
Err(Error::Unsupported { .. })
));
}

/// A caller inspecting the command before running it has no file yet, since
/// it is written at spawn time.
#[test]
fn a_codex_schema_preview_shows_a_placeholder_path() {
let mut p = plan("codex");
p.schema = Some(r#"{"type":"object"}"#.into());
let a = argv(Agent::Codex, &p);
assert_eq!(a[pos(&a, "--output-schema").unwrap() + 1], "<schema-file>");
}

#[test]
fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() {
let mut fresh = plan("codex");
Expand Down
7 changes: 7 additions & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ pub struct Terminal {
pub unparsed: usize,
/// The first line that failed to parse, as evidence for the above.
pub first_unparsed: Option<String>,
/// The schema-conforming answer, where the agent reports one separately.
pub structured: Option<Value>,
}

/// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`].
Expand Down Expand Up @@ -476,6 +478,11 @@ impl Parser {
if let Some(text) = v.get("result").and_then(Value::as_str) {
self.term.text = text.to_string();
}
// Claude returns the conforming value as its own field, so it
// needs no re-parsing out of the answer text.
if let Some(value) = v.get("structured_output") {
self.term.structured = Some(value.clone());
}
self.term.usage = claude_usage(v);
self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
Stop::Error
Expand Down
6 changes: 6 additions & 0 deletions src/outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ pub struct Outcome {
pub unparsed: usize,
/// The first line that failed to parse.
pub first_unparsed: Option<String>,
/// The answer parsed against the schema given to [`crate::Request::schema`].
///
/// `None` when no schema was asked for, or when the agent's answer did not
/// parse as JSON. Never a re-interpretation of prose: this is only set from
/// a value the agent produced under a schema.
pub structured: Option<serde_json::Value>,
}

impl Outcome {
Expand Down
47 changes: 47 additions & 0 deletions src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ pub struct Request {
pub(crate) env: Vec<(String, String)>,
pub(crate) extra_args: Vec<String>,
pub(crate) env_policy: EnvPolicy,
pub(crate) schema: Option<String>,
/// Set by the runner for agents that read the schema from a file.
pub(crate) schema_file: Option<String>,
pub(crate) timeout: Option<Duration>,
/// Set when [`Request::session`] resolved a named session, so the runner
/// knows to write the binding back.
Expand Down Expand Up @@ -69,6 +72,8 @@ impl Request {
env: Vec::new(),
extra_args: Vec::new(),
env_policy: EnvPolicy::Minimal,
schema: None,
schema_file: None,
timeout: None,
binding: None,
}
Expand Down Expand Up @@ -174,6 +179,44 @@ impl Request {
self
}

/// Constrain the answer to a JSON Schema.
///
/// The agent is asked to return a value conforming to `schema`, which
/// [`Outcome::structured`] then carries already parsed. Useful when the
/// answer is data rather than prose: a set of review findings, an
/// extraction, a classification. Reading it beats parsing prose, which is
/// a guess about formatting the model never promised.
///
/// The two CLIs that support this take it differently, and the difference
/// is hidden: Claude accepts the schema inline, Codex reads it from a file
/// this crate writes for the run and removes afterwards. **Copilot 1.0.75
/// has no schema support**, so asking is [`crate::Error::Unsupported`]
/// rather than a prose answer presented as data.
///
/// The schema is passed through unvalidated; a malformed one surfaces as
/// the agent's own error.
///
/// # Write the schema strictly
///
/// Codex sends it to `OpenAI`'s structured-output API, which rejects anything
/// permissive. Every object needs `"additionalProperties": false` and every
/// property listed in `required`, or the request fails with a 400 before
/// the model runs:
///
/// ```text
/// 'additionalProperties' is required to be supplied and to be false
/// ```
///
/// Claude is more forgiving, so a schema that works there can still fail on
/// Codex. Writing to the stricter rule keeps one schema usable for both.
///
/// [`Outcome::structured`]: crate::Outcome::structured
#[must_use]
pub fn schema(mut self, schema: impl Into<String>) -> Self {
self.schema = Some(schema.into());
self
}

/// Continue an earlier conversation by its native id, bypassing the session
/// store. Prefer [`Request::session`] unless you are tracking ids yourself.
#[must_use]
Expand Down Expand Up @@ -264,6 +307,8 @@ impl Request {
self.prompt.len()
+ self.system.as_ref().map_or(0, String::len)
+ self.extra_args.iter().map(String::len).sum::<usize>()
// Claude's schema rides the command line too.
+ self.schema.as_ref().map_or(0, String::len)
}

/// The format this request will actually use.
Expand Down Expand Up @@ -304,6 +349,8 @@ impl Request {
// system prompt rides its own argument. A small prompt with a large
// system prompt would otherwise still hit E2BIG.
stdin_prompt: self.argv_weight() >= STDIN_THRESHOLD,
schema: self.schema.clone(),
schema_file: self.schema_file.clone(),
}
}

Expand Down
Loading
Loading