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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "agent-abstraction"
version = "0.4.4"
version = "0.4.5"
edition = "2024"
# The floor edition 2024 requires, and where the strictest dependencies (uuid,
# getrandom) sit. Derived from the dependency graph rather than compile-tested.
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,10 @@ if let Err(e) = run.send(&text).await {
```

`Caps::live_follow_up` and `Caps::approvals` let a host decide whether to offer these
controls before building a request. Both are Claude only today. Neither other agent reads
a structured message stream on stdin, so requesting either capability returns
`Error::Unsupported` before spawning. `approvals` implies `interactive`, since both ride
the same open channel.
controls before building a request. Claude and Codex support both; interactive Codex runs
use app-server while ordinary Codex runs keep using `exec`. Copilot returns
`Error::Unsupported` before spawning. `approvals` implies `interactive`, since both need
the same open control channel.

## Slash commands

Expand Down Expand Up @@ -390,7 +390,7 @@ Four things are refused up front rather than met as a hang or a silence:

| combination | why |
|---|---|
| Codex or Copilot | neither has a headless approval channel. Codex's sandbox mode is decided before the run; Copilot needs `--allow-all-tools` to run headlessly at all |
| Copilot | it has no headless approval channel and needs `--allow-all-tools` to run headlessly at all |
| `run()` instead of `stream()` | `run` discards events, so nobody could answer |
| `Permission::ReadOnly` | it removes the mutating tools outright, so there is nothing left to be asked about, and a caller would never be asked |
| `respond` on a run that did not opt in | there is no channel to answer on |
Expand Down Expand Up @@ -420,6 +420,18 @@ settings in place.

The default is `ReadOnly`. Widen it explicitly.

Use `Request::cwd` for the primary workspace and repeat `Request::add_dir` for repositories
or folders the agent also needs to edit. The mapping stays provider-neutral: Claude and
ordinary Codex runs receive `--add-dir`, while interactive Codex runs receive the same paths
as app-server runtime and workspace-write roots.

```rust
let request = Request::new(agent, prompt)
.cwd("/work/task")
.add_dir("/work/repository")
.permission(Permission::Auto);
```

**What this does not cover.** These postures constrain each CLI's *built-in* tools: its
shell, its file writes, its sandbox. They do **not** constrain MCP servers, plugins or
custom tools, which are a separate tool category in all three CLIs. An MCP tool that files
Expand Down
36 changes: 25 additions & 11 deletions docs/host-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ run.send("actually, skip the tests and just fix the parser").await?;
**Append the message to the transcript below the user's previous one, immediately, and
carry on.** That is the entire contract. Do not wait for anything.

Do **not** build echo-based ordering. Claude can re-emit messages with
`--replay-user-messages` so an agent can sequence its own transcript, and this crate
deliberately does not pass it: the host already knows what it sent, so an echo reports
something it knew, and waiting for one would delay the exact thing this exists to make
immediate. Render on send.
Do **not** build echo-based ordering. The host already knows what it sent, so an echo
reports something it knew, and waiting for one would delay the exact thing this exists to
make immediate. Render on send.

### When the message takes effect

Expand Down Expand Up @@ -61,9 +59,9 @@ leaving the user believing it landed.

### Availability

Claude only. Codex and Copilot return `Error::Unsupported` **before spawning**, so a host
can check once at startup rather than discovering it mid-conversation. Neither reads a
structured message stream on stdin.
Claude and Codex. Interactive Codex runs switch to app-server, which carries `turn/steer`;
ordinary runs remain on `codex exec`. Copilot returns `Error::Unsupported` **before
spawning**, so a host can check once at startup.

## Asking a human about tool calls

Expand Down Expand Up @@ -108,9 +106,8 @@ the gate.

### Silence is not proof that nothing ran

Claude decides what needs asking, and read-only commands run without a question: `whoami`
runs unasked, `touch some-file` asks. Do not present "no approvals requested" as "no
commands executed".
The agent decides what needs asking, and read-only commands may run without a question.
Do not present "no approvals requested" as "no commands executed".

## A worked shape

Expand Down Expand Up @@ -142,3 +139,20 @@ ui.append_usage(&outcome.usage);

The user typing mid-turn calls `run.send(...)` from the UI thread and appends to the
transcript at the same moment. Nothing in the loop above changes.

## Workspace roots

Give the run its actual workspace rather than asking the model to discover or clone it:

```rust
let request = Request::new(agent, prompt)
.cwd(workspace)
.add_dir(repository)
.permission(Permission::Auto)
.approvals();
```

`cwd` is the primary root. Each `add_dir` is another writable working root. On interactive
Codex runs these become explicit app-server runtime and sandbox roots. With `approvals`, a
Codex request for access outside those roots arrives as `Event::ApprovalRequest`, so the
host can ask the user instead of leaving the model to work around a silent denial.
105 changes: 69 additions & 36 deletions src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ pub struct Plan {
pub format: Format,
/// How this run continues an earlier one.
pub cont: Continue,
/// Additional working roots beside the primary working directory.
pub extra_dirs: Vec<String>,
/// True when the prompt is piped on stdin instead of riding the argv.
pub stdin_prompt: bool,
/// True when stdin stays open for the turn so the caller can send more.
Expand Down Expand Up @@ -493,8 +495,9 @@ impl Agent {
live_follow_up: true,
approvals: true,
},
// `codex exec --json` emits `thread_id`; continuation is the
// `resume` subcommand and is linear (`codex fork` is TUI-only).
// `codex exec --json` emits `thread_id`; interactive turns use the
// app-server protocol, which exposes steering and approvals.
// Continuation remains linear (`codex fork` is TUI-only).
Agent::Codex => Caps {
session: SessionSupport::Printed,
fork: false,
Expand All @@ -504,8 +507,8 @@ impl Agent {
// `agent_message` the conforming JSON, with no separate field.
schema: SchemaSupport::File,
commands: false,
live_follow_up: false,
approvals: false,
live_follow_up: true,
approvals: true,
},
// Verified against Copilot CLI 1.0.75: `--session-id <uuid>` both
// mints a new session and resumes an existing one (one flag, both
Expand Down Expand Up @@ -614,22 +617,20 @@ impl Agent {
/// # Errors
/// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
pub(crate) fn typed_argv(self, plan: &Plan) -> Result<Vec<Arg>> {
// Verified against codex-cli 0.145.0 and Copilot CLI 1.0.75: `codex
// exec` has no approval callback, its sandbox mode being the answer
// decided before the run starts, and Copilot needs `--allow-all-tools`
// to run headlessly at all and gates only through `--deny-tool`. A run
// that quietly never asked would be the worst outcome here, since a
// caller would read silence as "nothing needed approval".
// Codex app-server supplies an approval callback. Copilot CLI 1.0.75
// needs `--allow-all-tools` to run headlessly at all and gates only
// through `--deny-tool`. A run that quietly never asked would be the
// worst outcome here, since a caller would read silence as "nothing
// needed approval".
let caps = self.caps();
if plan.approvals && !caps.approvals {
return Err(Error::Unsupported {
agent: self,
what: "routing tool approvals to the caller",
});
}
// Verified against codex-cli 0.145.0 and Copilot CLI 1.0.75: neither
// takes a structured message stream on stdin, so neither can be sent a
// second message once a turn is under way.
// Codex app-server accepts `turn/steer`. Copilot CLI 1.0.75 has no
// structured input stream and cannot take a second message mid-turn.
if plan.duplex && !caps.live_follow_up {
return Err(Error::Unsupported {
agent: self,
Expand Down Expand Up @@ -837,6 +838,12 @@ fn argv_claude(plan: &Plan) -> Vec<Arg> {
if let Some(system) = &plan.system {
a.secret("--append-system-prompt", system, Sensitivity::Prompt);
}
// Verified against Claude Code 2.1.212: every value after one `--add-dir`
// widens tool access. Repeat the flag so a path beginning with a dash can
// never be mistaken for another option.
for dir in &plan.extra_dirs {
a.secret("--add-dir", dir, Sensitivity::Unchecked);
}

match &plan.cont {
Continue::New => {}
Expand Down Expand Up @@ -883,6 +890,13 @@ fn argv_claude(plan: &Plan) -> Vec<Arg> {
/// `codex exec [resume <id>] --skip-git-repo-check [sandbox flags] [--model M]
/// [--json] <prompt>`
fn argv_codex(plan: &Plan) -> Vec<Arg> {
if plan.duplex || plan.approvals {
return Argv::new(&plan.bin)
.bare("app-server")
.bare("--stdio")
.done();
}

let mut a = Argv::new(&plan.bin);
a.bare("exec");
if let Continue::Resume(id) = &plan.cont {
Expand Down Expand Up @@ -926,6 +940,12 @@ fn argv_codex(plan: &Plan) -> Vec<Arg> {
if let Some(effort) = plan.effort.as_ref() {
a.pair("-c", format!("model_reasoning_effort={effort}"));
}
// Verified against codex-cli 0.145.0. Options remain options after the
// positional prompt, but keeping roots before it makes the command's
// security posture readable and matches the CLI's help shape.
for dir in &plan.extra_dirs {
a.pair("--add-dir", dir);
}
// 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.
Expand Down Expand Up @@ -1026,6 +1046,7 @@ mod tests {
permission: Permission::ReadOnly,
format: Format::Json,
cont: Continue::New,
extra_dirs: Vec::new(),
stdin_prompt: false,
duplex: false,
approvals: false,
Expand All @@ -1041,15 +1062,14 @@ mod tests {

#[test]
fn interactive_capabilities_match_the_supported_request_paths() {
let claude = Agent::Claude.caps();
assert!(claude.live_follow_up);
assert!(claude.approvals);

for agent in [Agent::Codex, Agent::Copilot] {
for agent in [Agent::Claude, Agent::Codex] {
let caps = agent.caps();
assert!(!caps.live_follow_up, "{agent} cannot take live follow-ups");
assert!(!caps.approvals, "{agent} has no headless approval channel");
assert!(caps.live_follow_up, "{agent} can take live follow-ups");
assert!(caps.approvals, "{agent} has an approval channel");
}
let copilot = Agent::Copilot.caps();
assert!(!copilot.live_follow_up);
assert!(!copilot.approvals);
}

fn pos(a: &[String], needle: &str) -> Option<usize> {
Expand Down Expand Up @@ -1102,21 +1122,19 @@ mod tests {
);
}

/// Neither other agent has a headless approval channel, so asking must fail
/// loudly. A run that quietly never asked is the dangerous outcome: silence
/// would read as "nothing needed approval".
/// Copilot has no headless approval channel, so asking must fail loudly. A
/// run that quietly never asked is the dangerous outcome.
#[test]
fn agents_without_an_approval_channel_refuse_before_spawning() {
let mut p = plan("x");
p.approvals = true;
p.permission = Permission::Edit;
for agent in [Agent::Codex, Agent::Copilot] {
assert!(
matches!(agent.typed_argv(&p), Err(Error::Unsupported { .. })),
"{agent} should refuse to pretend it can ask"
);
}
assert!(matches!(
Agent::Copilot.typed_argv(&p),
Err(Error::Unsupported { .. })
));
assert!(Agent::Claude.typed_argv(&p).is_ok());
assert_eq!(argv(Agent::Codex, &p), ["x", "app-server", "--stdio"]);
}

/// Read-only removes the mutating tools outright, so there is nothing left
Expand Down Expand Up @@ -1176,17 +1194,17 @@ mod tests {
assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "stream-json");
}

/// Neither other agent reads a structured message stream on stdin.
/// Copilot does not expose a structured message stream on stdin. Codex
/// switches to app-server when a live follow-up is requested.
#[test]
fn agents_that_cannot_take_a_follow_up_refuse_before_spawning() {
let mut p = plan("x");
p.duplex = true;
for agent in [Agent::Codex, Agent::Copilot] {
assert!(
matches!(agent.typed_argv(&p), Err(Error::Unsupported { .. })),
"{agent} cannot be sent a second message mid-turn"
);
}
assert!(matches!(
Agent::Copilot.typed_argv(&p),
Err(Error::Unsupported { .. })
));
assert_eq!(argv(Agent::Codex, &p), ["x", "app-server", "--stdio"]);
}

/// An ordinary run is untouched, so nothing about the default path changes.
Expand Down Expand Up @@ -1400,6 +1418,21 @@ mod tests {
}
}

#[test]
fn working_roots_reach_both_flag_based_agents() {
let mut p = plan("x");
p.extra_dirs = vec!["/repo-a".into(), "/repo-b".into()];
for agent in [Agent::Claude, Agent::Codex] {
let a = argv(agent, &p);
let roots: Vec<_> = a
.windows(2)
.filter(|pair| pair[0] == "--add-dir")
.map(|pair| pair[1].as_str())
.collect();
assert_eq!(roots, ["/repo-a", "/repo-b"], "{agent}: {a:?}");
}
}

/// `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.
Expand Down
11 changes: 5 additions & 6 deletions src/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@
//! [`crate::Run::respond`] answers. Deciding stays entirely with the caller;
//! this crate carries the question out and the answer back.
//!
//! # Claude only
//! # Supported agents
//!
//! Verified against claude 2.1.212. Codex `exec` has no approval callback: its
//! sandbox mode *is* the answer, decided before the run starts. Copilot needs
//! `--allow-all-tools` to run headlessly at all, and gates only through
//! `--deny-tool`. Asking either for approvals is
//! [`crate::Error::Unsupported`] rather than a run that quietly never asks.
//! Verified against claude 2.1.212 and codex-cli 0.145.0. Codex interactive
//! runs use app-server because `codex exec` has no approval callback. Copilot
//! needs `--allow-all-tools` to run headlessly at all and gates only through
//! `--deny-tool`, so asking it for approvals is [`crate::Error::Unsupported`].
//!
//! # A run that asks must be streamed
//!
Expand Down
Loading
Loading