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
521 changes: 473 additions & 48 deletions crates/cli/src/commands/mod.rs

Large diffs are not rendered by default.

67 changes: 62 additions & 5 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,23 @@ use std::sync::Arc;

use agent_code_lib::config::{ApiAuthMode, Config};
use agent_code_lib::llm::provider::{ProviderKind, WireFormat, detect_provider};
use agent_code_lib::output_styles::AgentKind;
use agent_code_lib::permissions::PermissionChecker;
use agent_code_lib::query::QueryEngine;
use agent_code_lib::state::AppState;
use agent_code_lib::tools::registry::ToolRegistry;

/// Detect whether this process was spawned as a child by the parent's
/// `Agent` tool. The Agent tool sets `AGENT_CODE_SUBAGENT=1` in the
/// child's environment so the child knows to filter output styles whose
/// `applies_to` excludes the `subagent` role.
fn detect_agent_kind() -> AgentKind {
match std::env::var("AGENT_CODE_SUBAGENT") {
Ok(v) if !v.is_empty() && v != "0" => AgentKind::Subagent,
_ => AgentKind::Main,
}
}

/// AI-powered coding agent for the terminal.
#[derive(Parser, Debug)]
#[command(name = "agent", version, about)]
Expand Down Expand Up @@ -453,10 +465,24 @@ async fn main() -> anyhow::Result<()> {
config.api.auth_mode = auth_mode;
}

// `--dump-system-prompt` is a diagnostic that builds the prompt
// from tools+state without contacting any LLM, so it must not
// require an API key. CI environments and the e2e test in
// `output_styles_subagent.rs` strip all key env vars precisely
// because none should be necessary on this path. Fall through
// with an empty placeholder so provider construction below still
// type-checks; the provider is never used before the early
// return at the `dump_system_prompt` branch.
let api_key = if config.api.auth_mode == ApiAuthMode::ApiKey {
Some(config.api.api_key.as_deref().ok_or_else(|| {
anyhow::anyhow!("API key required. Set AGENT_CODE_API_KEY or pass --api-key.")
})?)
if let Some(key) = config.api.api_key.as_deref() {
Some(key)
} else if cli.dump_system_prompt {
Some("")
} else {
return Err(anyhow::anyhow!(
"API key required. Set AGENT_CODE_API_KEY or pass --api-key."
));
}
} else {
None
};
Expand Down Expand Up @@ -589,7 +615,34 @@ async fn main() -> anyhow::Result<()> {
));
let permission_checker = PermissionChecker::from_config(&config.permissions)
.with_project_root(session_env.project_root.clone());
let app_state = AppState::new(config.clone());
let mut app_state = AppState::new(config.clone());

// Subagents spawned by the parent's `Agent` tool inherit the
// parent's active disk-loaded output style via
// `AGENT_CODE_DISK_OUTPUT_STYLE`. Resolve the named style against
// this child's own loaded registry. If the style is missing (e.g.
// the parent had a project-level style but this subagent is in a
// worktree without the file), log a warning and continue with no
// override — never crash the child for a style mismatch.
if let Ok(name) = std::env::var("AGENT_CODE_DISK_OUTPUT_STYLE") {
let trimmed = name.trim();
if !trimmed.is_empty() {
let project_root = session_env.project_root.clone();
let registry =
agent_code_lib::output_styles::OutputStyleRegistry::load_all(Some(&project_root));
match registry.find(trimmed) {
Some(style) => {
app_state.disk_output_style = Some(style.clone());
}
None => {
tracing::warn!(
"AGENT_CODE_DISK_OUTPUT_STYLE='{trimmed}' is not a known output style; \
continuing with no override"
);
}
}
}
}

// Connect configured MCP servers and register their tools.
for (name, entry) in &config.mcp_servers {
Expand Down Expand Up @@ -633,8 +686,11 @@ async fn main() -> anyhow::Result<()> {
}
}

let agent_kind = detect_agent_kind();

if cli.dump_system_prompt {
let prompt = agent_code_lib::query::build_system_prompt(&tool_registry, &app_state);
let prompt =
agent_code_lib::query::build_system_prompt(&tool_registry, &app_state, agent_kind);
println!("{prompt}");
return Ok(());
}
Expand All @@ -651,6 +707,7 @@ async fn main() -> anyhow::Result<()> {
max_turns: cli.max_turns,
verbose: cli.verbose,
unattended: cli.prompt.is_some(),
agent_kind,
},
);

Expand Down
98 changes: 98 additions & 0 deletions crates/cli/tests/output_styles_subagent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! End-to-end test for output-style propagation across the
//! Agent-tool subprocess boundary.
//!
//! Regression for the codex finding that `applies_to: [subagent]`
//! styles never reached the spawned child: the parent set
//! `AGENT_CODE_SUBAGENT=1` but did not propagate the active
//! disk-loaded style. The fix introduces
//! `AGENT_CODE_DISK_OUTPUT_STYLE=<name>`; this test boots the binary
//! with that env var and `--dump-system-prompt` and asserts the
//! prompt contains the disk style's body.

use assert_cmd::Command;
use std::fs;

const STYLE_BODY: &str =
"Be a marker phrase that should not occur in the default prompt: subagent_propagation_works.";

fn agent() -> Command {
Command::cargo_bin("agent").expect("binary should exist")
}

#[test]
fn subagent_inherits_disk_output_style_via_env_var() {
let project = tempfile::tempdir().expect("tempdir");
let styles_dir = project.path().join(".agent").join("output-styles");
fs::create_dir_all(&styles_dir).unwrap();
let style_path = styles_dir.join("subagent-only.md");
fs::write(
&style_path,
format!(
"---\n\
name: subagent-only\n\
description: subagent-only style fixture\n\
applies_to:\n - subagent\n\
---\n\
{STYLE_BODY}",
),
)
.unwrap();

// Pin the user-level dir to an empty tempdir so this test cannot
// pick up whatever may live in `~/.config/agent-code/output-styles/`
// on the developer's machine.
let user_dir = tempfile::tempdir().expect("user tempdir");

let output = agent()
.arg("--dump-system-prompt")
.arg("--cwd")
.arg(project.path())
.env("AGENT_CODE_SUBAGENT", "1")
.env("AGENT_CODE_DISK_OUTPUT_STYLE", "subagent-only")
.env("AGENT_CODE_USER_OUTPUT_STYLES_DIR", user_dir.path())
// No API key needed for --dump-system-prompt, but scrub anyway
// so a developer's environment can't influence the prompt.
.env_remove("AGENT_CODE_API_KEY")
.env_remove("ANTHROPIC_API_KEY")
.output()
.expect("failed to invoke binary");

let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"binary should exit cleanly under --dump-system-prompt; stderr: {stderr}"
);
assert!(
stdout.contains(STYLE_BODY),
"subagent-applied disk style body must appear in the dumped system prompt; \
stdout was:\n{stdout}",
);
}

#[test]
fn missing_disk_output_style_logs_warning_and_continues() {
// The CLI must NOT crash when AGENT_CODE_DISK_OUTPUT_STYLE points
// at a style that doesn't exist (e.g. parent had a project file
// but the subagent runs in a worktree without it).
let project = tempfile::tempdir().expect("tempdir");
let user_dir = tempfile::tempdir().expect("user tempdir");

let output = agent()
.arg("--dump-system-prompt")
.arg("--cwd")
.arg(project.path())
.env("AGENT_CODE_SUBAGENT", "1")
.env("AGENT_CODE_DISK_OUTPUT_STYLE", "no-such-style")
.env("AGENT_CODE_USER_OUTPUT_STYLES_DIR", user_dir.path())
.env_remove("AGENT_CODE_API_KEY")
.env_remove("ANTHROPIC_API_KEY")
.output()
.expect("failed to invoke binary");

let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"binary must not crash on a missing style id; stderr: {stderr}"
);
}
1 change: 1 addition & 0 deletions crates/lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub mod error;
pub mod hooks;
pub mod llm;
pub mod memory;
pub mod output_styles;
pub mod permissions;
pub mod query;
pub mod sandbox;
Expand Down
Loading
Loading