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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ appear in a patch rather than inflating the version toward 1.0 on a crate still
shape. **Where that happens the entry says so at the top**, because a version number that
under-signals is only acceptable if the changelog over-signals to compensate.

## 0.4.20

### Added

- **Grok Build as a fourth agent**, over `grok agent stdio` (ACP). A patch
rather than a minor: this adds `Agent::Grok` and changes no existing
behaviour.

One child per `stream()`, so an IDE can keep many Grok sessions and many
other backends live in parallel. Mid-turn input is `_x.ai/interject`
(same turn, does not cancel or queue). Interrupt is `session/cancel`
(kicks the in-flight turn; the session stays and can reattach). Auto is
native `--permission-mode auto`, not `--always-approve`. `/compact` maps
to `_x.ai/compact_conversation`. `/clear` is refused: Grok has `/new`.

No new crates. Codex `app-server` and Claude `-p` are untouched. Verified
against grok 1.0.30 `--help` and the live ACP method names; a live
`grok agent stdio` smoke is still outstanding.

## 0.4.18

### Added
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[package]
name = "agent-abstraction"
version = "0.4.19"
version = "0.4.21"
edition = "2024"
# The floor edition 2024 requires, and where the strictest dependencies (uuid,
# getrandom) sit. Derived from the dependency graph rather than compile-tested.
rust-version = "1.85"
description = "Drive the Claude Code, Codex and GitHub Copilot CLIs headlessly from Rust. One request type, one event stream and one session model across all three, with resume and fork."
description = "Drive the Claude Code, Codex, GitHub Copilot and Grok CLIs headlessly from Rust. One request type, one event stream and one session model across all four, with resume and fork."
license = "MIT"
repository = "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/pathscale/RustAgentAbstraction"
readme = "README.md"
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ and not inferred from documentation.
| **Claude Code** | caller-minted (`--session-id`) | yes (`--fork-session`) | `stream-json` | native (`--append-system-prompt`) | `--resume` |
| **Codex** | agent-printed (`thread_id`) | no | `--json` | prepended to prompt | `exec resume <id>` |
| **Copilot** | caller-minted (`--session-id`) | no | `--output-format json` | prepended to prompt | `--session-id` |
| **Grok** | agent-printed (`session/new`) | yes (`session/fork`) | ACP stdio | native (`--rules`) | `session/load` |

### Can I choose the session id, or do I have to read it back?

Expand All @@ -53,6 +54,7 @@ Both, depending on the agent. Verified by round-trip, not from `--help`:
| **Claude Code** | yes, `.session_id(uuid)` | also reported |
| **Copilot** | yes, `.session_id(uuid)` | also reported |
| **Codex** | **no** | `thread_id`, before it answers |
| **Grok** | **no** | `sessionId`, from `session/new` |

```rust
// Claude and Copilot: the id is yours to pick, so it can match a thread id
Expand Down
2 changes: 1 addition & 1 deletion src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ impl Agent {
// Deliberately an error rather than a half-answer assembled from a
// past run's rate-limit event: that would be neither current nor
// account-wide, and would read as though it were both.
Agent::Claude | Agent::Copilot => Err(Error::Unsupported {
Agent::Claude | Agent::Copilot | Agent::Grok => Err(Error::Unsupported {
agent: self,
what: "reporting account usage without a terminal",
}),
Expand Down
150 changes: 144 additions & 6 deletions src/agent.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! The three agents, what each can do, and how a request becomes an argv.
//! The four agents, what each can do, and how a request becomes an argv.
//!
//! Everything here is pure: [`Agent::argv`] builds a command line from a
//! [`Plan`] without touching the filesystem, the clock, or a process, so every
Expand All @@ -21,6 +21,8 @@ pub enum Agent {
Codex,
/// GitHub Copilot CLI (`copilot`).
Copilot,
/// Grok Build (`grok`).
Grok,
}

/// How an agent's native session id is obtained. This is the axis deciding whether
Expand Down Expand Up @@ -308,7 +310,7 @@ pub(crate) const MAX_COMMAND_LINE: usize = 512 * 1024;

impl Agent {
/// Every agent, in a stable order.
pub const ALL: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Copilot];
pub const ALL: [Agent; 4] = [Agent::Claude, Agent::Codex, Agent::Copilot, Agent::Grok];

/// The stable identifier used in session records and logs.
#[must_use]
Expand All @@ -317,6 +319,7 @@ impl Agent {
Agent::Claude => "claude-code",
Agent::Codex => "codex",
Agent::Copilot => "copilot",
Agent::Grok => "grok",
}
}

Expand All @@ -327,6 +330,7 @@ impl Agent {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Copilot => "copilot",
Agent::Grok => "grok",
}
}

Expand All @@ -343,6 +347,11 @@ impl Agent {
Agent::Claude => Some(&["auth", "status", "--json"]),
Agent::Codex => Some(&["login", "status"]),
Agent::Copilot => None,
// Verified against grok 1.0.30: there is no `auth status`. `grok
// models` prints "You are logged in with grok.com." when a session
// exists. Logged-out wording is not recorded here, so a miss stays
// `Unknown` rather than `LoggedOut`.
Agent::Grok => Some(&["models"]),
}
}

Expand All @@ -357,6 +366,7 @@ impl Agent {
Agent::Claude => &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
Agent::Codex => &["CODEX_API_KEY", "OPENAI_API_KEY"],
Agent::Copilot => &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
Agent::Grok => &["XAI_API_KEY"],
}
}

Expand All @@ -374,6 +384,7 @@ impl Agent {
}
Agent::Codex => "run `codex login`",
Agent::Copilot => "run `copilot login`",
Agent::Grok => "run `grok login`",
}
}

Expand All @@ -392,6 +403,8 @@ impl Agent {
Agent::Codex => (0, 147, 0),
// `copilot --version` -> "GitHub Copilot CLI 1.0.78."
Agent::Copilot => (1, 0, 78),
// `grok --version` -> "grok 1.0.30 (04b7ffed98c6) [stable]"
Agent::Grok => (1, 0, 30),
};
crate::Version {
major,
Expand All @@ -407,6 +420,7 @@ impl Agent {
Agent::Claude => "npm install -g @anthropic-ai/claude-code",
Agent::Codex => "npm install -g @openai/codex",
Agent::Copilot => "npm install -g @github/copilot",
Agent::Grok => "curl -fsSL https://x.ai/cli/install.sh | bash",
}
}

Expand Down Expand Up @@ -474,6 +488,7 @@ impl Agent {
"GITHUB_TOKEN",
"XDG_CONFIG_HOME",
],
Agent::Grok => &["XAI_API_KEY", "GROK_HOME"],
};
BASE.iter().chain(WINDOWS).chain(agent).copied().collect()
}
Expand All @@ -495,6 +510,8 @@ impl Agent {
pub fn thinking_env(self, thinking: Option<bool>) -> Option<(&'static str, &'static str)> {
match (self, thinking) {
(Agent::Claude, Some(false)) => Some(("MAX_THINKING_TOKENS", "0")),
// grok 1.0.30 steers reasoning with `--effort` / `--reasoning-effort`,
// not an environment variable.
_ => None,
}
}
Expand Down Expand Up @@ -551,6 +568,27 @@ impl Agent {
live_follow_up: false,
approvals: false,
},
// Verified against grok 1.0.30 `--help` and ACP stdio: `session/new`
// prints the id, `session/fork` / `_x.ai/session/fork` branches,
// `_x.ai/interject` steers mid-turn (confirmed live; bare
// `x.ai/interject` is -32601), `session/cancel` interrupts,
// `session/request_permission` asks. Auto is native
// `--permission-mode auto`, same as Claude: opening the approval
// channel would replace it with a round trip the host would only
// answer yes to.
Agent::Grok => Caps {
session: SessionSupport::Printed,
fork: true,
events: true,
native_system: true,
schema: SchemaSupport::Inline,
// `/compact` is a Grok command and an ACP method. `/clear` is
// not: Grok uses `/new`. Other names are refused rather than
// sent as prose.
commands: true,
live_follow_up: true,
approvals: true,
},
}
}

Expand All @@ -563,9 +601,8 @@ impl Agent {
// cheaper default when the caller did not ask to stream.
SessionSupport::Minted | SessionSupport::Printed => Some(match self {
Agent::Claude => Format::Json,
// `--json` IS Codex's stream and Copilot's `json` is JSONL;
// neither has a single-document form.
Agent::Codex | Agent::Copilot => Format::Stream,
// ACP stdio, `codex --json`, and Copilot `json` are all streams.
Agent::Codex | Agent::Copilot | Agent::Grok => Format::Stream,
}),
SessionSupport::None => None,
}
Expand Down Expand Up @@ -682,6 +719,7 @@ impl Agent {
Agent::Claude => argv_claude(plan),
Agent::Codex => argv_codex(plan),
Agent::Copilot => argv_copilot(plan),
Agent::Grok => argv_grok(plan),
})
}

Expand Down Expand Up @@ -1071,6 +1109,60 @@ fn argv_copilot(plan: &Plan) -> Vec<Arg> {
a.done()
}

/// `grok [--permission-mode M] agent [--model M] [--always-approve] stdio`
///
/// Flag placement verified against grok 1.0.30 `--help` and the live ACP
/// client: `--permission-mode` is top-level `grok`, `--model` /
/// `--reasoning-effort` / `--always-approve` sit on `grok agent` before
/// `stdio`. The prompt never rides the argv; it is `session/prompt` after
/// initialize. Auto is `--permission-mode auto`, not `--always-approve`:
/// native auto, same token Claude uses, and Grok has the same token.
/// Bypass is `--always-approve`. Mid-turn steer is `_x.ai/interject` on the
/// open stdio, not a second spawn.
fn argv_grok(plan: &Plan) -> Vec<Arg> {
let mut a = Argv::new(&plan.bin);
a.bare("--no-auto-update");
// ACP has no TUI for plan approval. `enter_plan_mode` / `exit_plan_mode`
// hang until the host's liveness ping aborts the turn (session 01a09bd5).
a.bare("--no-plan");
a.pair("--permission-mode", grok_mode(plan.permission));
if plan.permission == Permission::ReadOnly {
// Internal ids from grok's own headless docs (`--disallowed-tools
// run_terminal_cmd`, `search_replace`, `Agent`). Comma-separated is
// one argument.
a.pair(
"--disallowed-tools",
"run_terminal_cmd,search_replace,Agent",
);
}
if let Some(system) = &plan.system {
a.secret("--rules", system, Sensitivity::Prompt);
}
if let Some(schema) = &plan.schema {
a.secret("--json-schema", schema, Sensitivity::Prompt);
}
a.bare("agent");
a.opt("--model", plan.model.as_ref());
if let Some(effort) = &plan.effort {
a.pair("--reasoning-effort", effort);
}
if plan.permission == Permission::Bypass {
a.bare("--always-approve");
}
a.bare("stdio");
a.done()
}

fn grok_mode(p: Permission) -> &'static str {
match p {
Permission::ReadOnly => "dontAsk",
Permission::Plan => "plan",
Permission::Edit => "acceptEdits",
Permission::Auto => "auto",
Permission::Bypass => "bypassPermissions",
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1102,7 +1194,7 @@ mod tests {

#[test]
fn interactive_capabilities_match_the_supported_request_paths() {
for agent in [Agent::Claude, Agent::Codex] {
for agent in [Agent::Claude, Agent::Codex, Agent::Grok] {
let caps = agent.caps();
assert!(caps.live_follow_up, "{agent} can take live follow-ups");
assert!(caps.approvals, "{agent} has an approval channel");
Expand Down Expand Up @@ -1174,6 +1266,7 @@ mod tests {
Err(Error::Unsupported { .. })
));
assert!(Agent::Claude.typed_argv(&p).is_ok());
assert!(Agent::Grok.typed_argv(&p).is_ok());
assert_eq!(argv(Agent::Codex, &p), ["x", "app-server", "--stdio"]);
}

Expand Down Expand Up @@ -1245,6 +1338,9 @@ mod tests {
Err(Error::Unsupported { .. })
));
assert_eq!(argv(Agent::Codex, &p), ["x", "app-server", "--stdio"]);
let grok = argv(Agent::Grok, &p);
assert!(grok.contains(&"stdio".to_string()), "{grok:?}");
assert!(grok.contains(&"agent".to_string()), "{grok:?}");
}

/// An ordinary run is untouched, so nothing about the default path changes.
Expand Down Expand Up @@ -1749,6 +1845,8 @@ mod tests {
Agent::Claude.argv(&p).is_ok(),
"Claude publishes a catalogue and acts on them"
);
p.bin = "grok".into();
assert!(Agent::Grok.argv(&p).is_ok(), "Grok maps /compact over ACP");
}

/// All three expose an id, so all three can back a named session, but only
Expand All @@ -1758,6 +1856,7 @@ mod tests {
assert_eq!(Agent::Claude.session_format(), Some(Format::Json));
assert_eq!(Agent::Codex.session_format(), Some(Format::Stream));
assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream));
assert_eq!(Agent::Grok.session_format(), Some(Format::Stream));
}

/// Claude and Copilot let the caller assign the id, so a run that dies
Expand All @@ -1770,4 +1869,43 @@ mod tests {
.collect();
assert_eq!(minting, [Agent::Claude, Agent::Copilot]);
}

#[test]
fn grok_stdio_keeps_auto_native_and_puts_flags_in_the_right_place() {
let mut p = plan("grok");
p.permission = Permission::Auto;
p.model = Some("grok-4.6".into());
p.effort = Some("high".into());
p.system = Some("be brief".into());
let a = argv(Agent::Grok, &p);
assert_eq!(a[0], "grok");
let agent_at = pos(&a, "agent").expect("agent subcommand");
let stdio_at = pos(&a, "stdio").expect("stdio");
assert!(agent_at < stdio_at);
assert!(pos(&a, "--permission-mode").unwrap() < agent_at);
assert_eq!(a[pos(&a, "--permission-mode").unwrap() + 1], "auto");
assert!(pos(&a, "--model").unwrap() > agent_at);
assert_eq!(a[pos(&a, "--model").unwrap() + 1], "grok-4.6");
assert!(pos(&a, "--reasoning-effort").unwrap() > agent_at);
assert_eq!(a[pos(&a, "--reasoning-effort").unwrap() + 1], "high");
assert!(pos(&a, "--rules").unwrap() < agent_at);
assert!(pos(&a, "--no-plan").unwrap() < agent_at);
assert!(!a.iter().any(|arg| arg == "--always-approve"));
assert!(
!a.iter().any(|arg| arg == "hi"),
"prompt is ACP not argv: {a:?}"
);
}

#[test]
fn grok_bypass_is_always_approve_not_auto() {
let mut p = plan("grok");
p.permission = Permission::Bypass;
let a = argv(Agent::Grok, &p);
assert!(a.iter().any(|arg| arg == "--always-approve"), "{a:?}");
assert_eq!(
a[pos(&a, "--permission-mode").unwrap() + 1],
"bypassPermissions"
);
}
}
14 changes: 14 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ impl AuthStatus {
// Unreachable: `auth_status_argv` returns None, so `check_bin`
// never gets here for Copilot.
Agent::Copilot => {}
Agent::Grok => {
let lower = text.to_ascii_lowercase();
status.state = if lower.contains("not logged in") || lower.contains("logged out") {
AuthState::LoggedOut
} else if lower.contains("logged in") {
status.method = text
.rsplit_once(" with ")
.or_else(|| text.rsplit_once(" using "))
.map(|(_, method)| method.trim().trim_end_matches('.').to_string());
AuthState::LoggedIn
} else {
AuthState::Unknown
};
}
}
status
}
Expand Down
Loading
Loading