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
973 changes: 0 additions & 973 deletions packages/agent/src/agent_catalog/context_assembly.rs

This file was deleted.

1 change: 0 additions & 1 deletion packages/agent/src/agent_catalog/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
pub mod context_assembly;
pub mod registry;
197 changes: 65 additions & 132 deletions packages/agent/src/agent_catalog/registry.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
//! PTY agent catalog (ADR-032).
//!
//! Hardcoded catalog of external agent CLIs that can be spawned in a PTY.
//! Each entry names the binary, the context file the agent expects to find
//! in its working directory (`CLAUDE.md` for Claude Code, `AGENTS.md` for
//! everything else), the flag used to resume a previous session, and where
//! the agent keeps the sessions that flag resumes.
//! Each entry names the binary and the environment variables the agent reads
//! its credentials and configuration from.

use crate::agent_types::{AgentType, ContextFile};
use crate::agent_types::AgentType;

/// Static description of an external agent CLI spawned via PTY.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -15,31 +13,14 @@ pub struct AgentDefinition {
pub agent_type: AgentType,
/// Human-readable display name.
pub name: &'static str,
/// Binary name to spawn (resolved on `PATH`).
/// Binary name to spawn, resolved on the search path
/// [`crate::pty::detection`] builds.
pub binary: &'static str,
/// Context file the agent reads on startup.
pub context_file: ContextFile,
/// CLI flag used to resume a prior session, if the agent supports one.
pub resume_flag: Option<&'static str>,
/// Where the agent records its sessions, for the agents whose resume flag
/// takes a session id. `None` when the agent has no id to resume by.
pub session_store: Option<SessionStore>,
/// Auth-related environment variables this agent reads. PTY children get
/// a cleared environment plus a fixed base allowlist plus these — never
/// the daemon's full environment (see [`crate::pty::session`]).
pub auth_env_vars: &'static [&'static str],
}

/// The on-disk layout an agent records its own sessions in, under the user's
/// home directory. [`crate::pty::find_harness_session_id`] reads it to learn
/// the id a finished session can be resumed by.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionStore {
/// `~/.claude/projects/<working directory, slugged>/<session id>.jsonl`.
ClaudeProjects,
/// `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<time>-<session id>.jsonl`,
/// whose first line records the session's id and working directory.
CodexRollouts,
/// The environment variables this agent reads its credentials and its
/// configuration directory from. PTY children get a cleared environment
/// plus a fixed base allowlist plus these, never the daemon's full
/// environment (see [`crate::pty::session`]).
pub env_vars: &'static [&'static str],
}

/// Hardcoded catalog of PTY-spawnable external agents.
Expand All @@ -48,41 +29,32 @@ pub const AGENT_CATALOG: &[AgentDefinition] = &[
agent_type: AgentType::ClaudeCode,
name: "Claude Code",
binary: "claude",
context_file: ContextFile::ClaudeMd,
resume_flag: Some("--resume"),
session_store: Some(SessionStore::ClaudeProjects),
auth_env_vars: &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
// `CLAUDE_CONFIG_DIR` selects the profile the skill installer wrote
// the plugin to, so the session loads the same one.
env_vars: &[
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CONFIG_DIR",
],
},
AgentDefinition {
agent_type: AgentType::Codex,
name: "Codex",
binary: "codex",
context_file: ContextFile::AgentsMd,
resume_flag: Some("resume"),
session_store: Some(SessionStore::CodexRollouts),
auth_env_vars: &["OPENAI_API_KEY"],
env_vars: &["OPENAI_API_KEY"],
},
AgentDefinition {
agent_type: AgentType::AntigravityCli,
agent_type: AgentType::Antigravity,
name: "Antigravity CLI",
binary: "agy",
context_file: ContextFile::AgentsMd,
// `agy -c` / `agy --continue` resumes the most recent conversation
// scoped to the current working directory.
resume_flag: Some("-c"),
session_store: None,
auth_env_vars: &["GEMINI_API_KEY"],
env_vars: &["GEMINI_API_KEY"],
},
AgentDefinition {
agent_type: AgentType::Pi,
name: "Pi",
binary: "pi",
context_file: ContextFile::AgentsMd,
// Pi is session-aware but does not take an explicit resume flag.
resume_flag: None,
session_store: None,
// Pi is multi-provider; pass through every provider key it may need.
auth_env_vars: &[
env_vars: &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
Expand All @@ -93,11 +65,7 @@ pub const AGENT_CATALOG: &[AgentDefinition] = &[
agent_type: AgentType::OpenCode,
name: "OpenCode",
binary: "opencode",
context_file: ContextFile::AgentsMd,
// OpenCode is stateless across invocations.
resume_flag: None,
session_store: None,
auth_env_vars: &[
env_vars: &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
Expand Down Expand Up @@ -131,100 +99,65 @@ mod tests {
use super::*;

#[test]
fn catalog_includes_all_five_agents() {
assert_eq!(AGENT_CATALOG.len(), 5);
}

#[test]
fn catalog_agent_types_are_unique() {
let mut types: Vec<AgentType> = AGENT_CATALOG.iter().map(|d| d.agent_type).collect();
types.sort();
types.dedup();
assert_eq!(types.len(), AGENT_CATALOG.len());
}

#[test]
fn claude_code_uses_claude_md_and_resume_flag() {
let registry = SystemAgentRegistry::new();
let def = registry.get(AgentType::ClaudeCode).unwrap();
assert_eq!(def.binary, "claude");
assert_eq!(def.context_file, ContextFile::ClaudeMd);
assert_eq!(def.resume_flag, Some("--resume"));
}

#[test]
fn non_claude_agents_use_agents_md() {
let registry = SystemAgentRegistry::new();
for agent_type in [
AgentType::Codex,
AgentType::AntigravityCli,
AgentType::Pi,
AgentType::OpenCode,
] {
let def = registry.get(agent_type).unwrap();
assert_eq!(def.context_file, ContextFile::AgentsMd);
}
}

#[test]
fn codex_uses_resume_subcommand() {
let registry = SystemAgentRegistry::new();
let def = registry.get(AgentType::Codex).unwrap();
assert_eq!(def.resume_flag, Some("resume"));
fn the_catalog_holds_every_agent_once_in_order() {
let types: Vec<AgentType> = AGENT_CATALOG.iter().map(|d| d.agent_type).collect();
assert_eq!(types, AgentType::ALL);
}

/// One vocabulary: an agent's id is its serde form, the form the skill
/// installer and the CLI use.
#[test]
fn stateless_agents_have_no_resume_flag() {
let registry = SystemAgentRegistry::new();
for agent_type in [AgentType::Pi, AgentType::OpenCode] {
let def = registry.get(agent_type).unwrap();
assert!(
def.resume_flag.is_none(),
"{:?} should have no resume flag",
agent_type
);
fn an_agents_id_is_its_serde_form() {
for agent in AgentType::ALL {
assert_eq!(serde_json::json!(agent), serde_json::json!(agent.id()));
assert_eq!(AgentType::from_id(agent.id()), Some(agent));
}
assert_eq!(
AgentType::ALL.map(AgentType::id),
["claude-code", "codex", "antigravity", "pi", "opencode"]
);
assert_eq!(AgentType::from_id("open-code"), None);
assert_eq!(AgentType::from_id("antigravity-cli"), None);
}

/// The skill installer names the agents it installs into by the same ids.
#[test]
fn antigravity_cli_uses_continue_resume_flag() {
let registry = SystemAgentRegistry::new();
let def = registry.get(AgentType::AntigravityCli).unwrap();
assert_eq!(def.resume_flag, Some("-c"));
fn the_skill_installer_names_agents_by_the_same_ids() {
let types = include_str!("../../../skill/src/types.ts");
let declaration = types
.lines()
.find(|line| line.starts_with("export type AgentName"))
.expect("the installer declares AgentName");
let mut names: Vec<&str> = declaration.split('\'').skip(1).step_by(2).collect();
names.sort_unstable();
let mut ids = AgentType::ALL.map(AgentType::id).to_vec();
ids.sort_unstable();
assert_eq!(names, ids);
}

#[test]
fn every_catalog_entry_declares_at_least_one_auth_env_var() {
let registry = SystemAgentRegistry::new();
for def in registry.all() {
fn every_catalog_entry_declares_at_least_one_env_var() {
for def in SystemAgentRegistry::new().all() {
assert!(
!def.auth_env_vars.is_empty(),
"{:?} should declare auth env vars for the PTY allowlist",
!def.env_vars.is_empty(),
"{:?} should declare env vars for the PTY allowlist",
def.agent_type
);
}
}

#[test]
fn claude_code_auth_env_vars_cover_key_and_oauth_token() {
let registry = SystemAgentRegistry::new();
let def = registry.get(AgentType::ClaudeCode).unwrap();
assert!(def.auth_env_vars.contains(&"ANTHROPIC_API_KEY"));
assert!(def.auth_env_vars.contains(&"CLAUDE_CODE_OAUTH_TOKEN"));
}

#[test]
fn all_returns_full_catalog() {
let registry = SystemAgentRegistry::new();
assert_eq!(registry.all().len(), AGENT_CATALOG.len());
}

#[test]
fn get_returns_none_for_unknown() {
// No way to construct an unknown AgentType today (enum is closed),
// but lookups still match by identity.
let registry = SystemAgentRegistry::new();
let def = registry.get(AgentType::ClaudeCode).unwrap();
assert_eq!(def.agent_type, AgentType::ClaudeCode);
fn claude_code_gets_its_credentials_and_its_config_directory() {
let def = SystemAgentRegistry::new()
.get(AgentType::ClaudeCode)
.unwrap();
assert_eq!(def.binary, "claude");
for var in [
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CONFIG_DIR",
] {
assert!(def.env_vars.contains(&var), "{var}");
}
}
}
6 changes: 3 additions & 3 deletions packages/agent/src/agent_guidance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
//! may not say, and the eval-contamination check across every model-facing
//! guidance string.
//!
//! External PTY-spawned agent sessions ([`crate::agent_catalog::context_assembly`]) do
//! not read this guidance — they get all tool/capability guidance from
//! `packages/skill/SKILL.md`, the CLI-vocabulary companion doc.
//! External PTY-spawned agent sessions ([`crate::pty`]) do not read this
//! guidance: theirs comes from the harness plugin and `packages/skill/SKILL.md`,
//! the CLI-vocabulary companion doc.
//!
//! `N_CTX_MINIMUM` in `nlp-engine`'s `chat/mod.rs` (16,384) is sized against
//! the full tool-registered system prompt (~6,600 tokens), of which the
Expand Down
57 changes: 27 additions & 30 deletions packages/agent/src/agent_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,22 +95,6 @@ pub enum ToolError {
Other(#[from] anyhow::Error),
}

/// Errors returned when writing a context file for a PTY agent session (ADR-032).
#[derive(Debug, Error)]
pub enum ContextError {
/// The requested node could not be found.
#[error("node not found: {0}")]
NodeNotFound(String),

/// Writing the context file to disk failed.
#[error("context file write failed: {0}")]
WriteFailed(#[from] std::io::Error),

/// Catch-all for unexpected errors.
#[error(transparent)]
Other(#[from] anyhow::Error),
}

// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -193,33 +177,46 @@ pub enum ModelStatus {
}

/// External agent CLI catalogued for PTY spawning (ADR-032).
///
/// The serde form is the agent's id everywhere one is written or typed: the
/// launch request, a chat node's `agent` field, the CLI and the skill
/// installer all use it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentType {
ClaudeCode,
Codex,
AntigravityCli,
Antigravity,
Pi,
#[serde(rename = "opencode")]
OpenCode,
}

/// Context file convention an external agent expects on launch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ContextFile {
/// Claude Code reads `CLAUDE.md` from its working directory.
ClaudeMd,
/// All other supported agents read `AGENTS.md`.
AgentsMd,
}
impl AgentType {
/// Every catalogued agent, in catalog order.
pub const ALL: [Self; 5] = [
Self::ClaudeCode,
Self::Codex,
Self::Antigravity,
Self::Pi,
Self::OpenCode,
];

impl ContextFile {
/// Filename written to the session directory.
pub fn filename(self) -> &'static str {
/// The agent's id: its serde form.
pub const fn id(self) -> &'static str {
match self {
ContextFile::ClaudeMd => "CLAUDE.md",
ContextFile::AgentsMd => "AGENTS.md",
Self::ClaudeCode => "claude-code",
Self::Codex => "codex",
Self::Antigravity => "antigravity",
Self::Pi => "pi",
Self::OpenCode => "opencode",
}
}

/// The agent an id names, or `None` for an id no agent has.
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|agent| agent.id() == id)
}
}

/// Current status of the local agent.
Expand Down
Loading