Merge 99ef3ed879 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-04-29 10:03:45 -07:00
committed by GitHub
51 changed files with 1933 additions and 1259 deletions
+15
View File
@@ -2374,6 +2374,7 @@ dependencies = [
"codex-feedback",
"codex-git-utils",
"codex-hooks",
"codex-kernel",
"codex-login",
"codex-mcp",
"codex-memories-read",
@@ -2815,6 +2816,20 @@ dependencies = [
"tempfile",
]
[[package]]
name = "codex-kernel"
version = "0.0.0"
dependencies = [
"codex-protocol",
"codex-tools",
"pretty_assertions",
"rand 0.9.3",
"serde",
"serde_json",
"tokio",
"tokio-util",
]
[[package]]
name = "codex-keyring-store"
version = "0.0.0"
+2
View File
@@ -43,6 +43,7 @@ members = [
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
"kernel",
"file-search",
"linux-sandbox",
"lmstudio",
@@ -158,6 +159,7 @@ codex-file-search = { path = "file-search" }
codex-git-utils = { path = "git-utils" }
codex-hooks = { path = "hooks" }
codex-keyring-store = { path = "keyring-store" }
codex-kernel = { path = "kernel" }
codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
+8 -5
View File
@@ -59,19 +59,22 @@ To test to see what happens when a command is run under the sandbox provided by
```
# macOS
codex sandbox macos [--full-auto] [--log-denials] [COMMAND]...
codex sandbox macos [--log-denials] [COMMAND]...
# Linux
codex sandbox linux [--full-auto] [COMMAND]...
codex sandbox linux [COMMAND]...
# Windows
codex sandbox windows [--full-auto] [COMMAND]...
codex sandbox windows [COMMAND]...
# Legacy aliases
codex debug seatbelt [--full-auto] [--log-denials] [COMMAND]...
codex debug landlock [--full-auto] [COMMAND]...
codex debug seatbelt [--log-denials] [COMMAND]...
codex debug landlock [COMMAND]...
```
To try a writable legacy sandbox mode with these commands, pass an explicit config override such
as `-c 'sandbox_mode="workspace-write"'`.
### Selecting a sandbox policy via `--sandbox`
The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option:
+10 -7
View File
@@ -2029,14 +2029,17 @@ mod tests {
#[tokio::test]
async fn runtime_start_args_forward_environment_manager() {
let config = Arc::new(build_test_config().await);
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
let environment_manager = Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths"),
)
.expect("runtime paths"),
}));
.await,
);
let runtime_args = InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
+9 -6
View File
@@ -418,12 +418,15 @@ pub async fn run_main_with_transport_options(
auth: AppServerWebsocketAuthSettings,
runtime_options: AppServerRuntimeOptions,
) -> IoResult<()> {
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
let environment_manager = Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
))
.await,
);
let (transport_event_tx, mut transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(CHANNEL_CAPACITY);
@@ -325,10 +325,20 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills(
let warning: WarningNotification =
serde_json::from_value(params).expect("deserialize warning notification");
assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str()));
assert_eq!(
warning.message,
"Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list."
);
let prefix = "Exceeded skills context budget of 2%. All skill descriptions were removed and ";
let plural_suffix = " additional skills were not included in the model-visible skills list.";
let singular_suffix = " additional skill was not included in the model-visible skills list.";
let omitted_count = warning
.message
.strip_prefix(prefix)
.and_then(|message| {
message
.strip_suffix(plural_suffix)
.or_else(|| message.strip_suffix(singular_suffix))
})
.and_then(|count| count.parse::<usize>().ok())
.expect("warning should report an omitted skill count");
assert!(omitted_count >= 1, "expected at least one omitted skill");
timeout(
DEFAULT_READ_TIMEOUT,
+193 -37
View File
@@ -42,14 +42,14 @@ pub async fn run_command_under_seatbelt(
codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let SeatbeltCommand {
full_auto,
permissions_profile,
allow_unix_sockets,
log_denials,
config_overrides,
command,
} = command;
run_command_under_sandbox(
full_auto,
permissions_profile,
command,
config_overrides,
codex_linux_sandbox_exe,
@@ -73,12 +73,12 @@ pub async fn run_command_under_landlock(
codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let LandlockCommand {
full_auto,
permissions_profile,
config_overrides,
command,
} = command;
run_command_under_sandbox(
full_auto,
permissions_profile,
command,
config_overrides,
codex_linux_sandbox_exe,
@@ -94,12 +94,12 @@ pub async fn run_command_under_windows(
codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let WindowsCommand {
full_auto,
permissions_profile,
config_overrides,
command,
} = command;
run_command_under_sandbox(
full_auto,
permissions_profile,
command,
config_overrides,
codex_linux_sandbox_exe,
@@ -118,7 +118,7 @@ enum SandboxType {
}
async fn run_command_under_sandbox(
full_auto: bool,
permissions_profile: Option<String>,
command: Vec<String>,
config_overrides: CliConfigOverrides,
codex_linux_sandbox_exe: Option<PathBuf>,
@@ -132,7 +132,7 @@ async fn run_command_under_sandbox(
.parse_overrides()
.map_err(anyhow::Error::msg)?,
codex_linux_sandbox_exe,
full_auto,
permissions_profile,
)
.await?;
@@ -402,14 +402,6 @@ async fn run_command_under_windows_session(
std::process::exit(exit_code);
}
pub fn create_sandbox_mode(full_auto: bool) -> SandboxMode {
if full_auto {
SandboxMode::WorkspaceWrite
} else {
SandboxMode::ReadOnly
}
}
async fn spawn_debug_sandbox_child(
program: PathBuf,
args: Vec<String>,
@@ -579,23 +571,36 @@ mod windows_stdio_bridge {
async fn load_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
full_auto: bool,
permissions_profile: Option<String>,
) -> anyhow::Result<Config> {
load_debug_sandbox_config_with_codex_home(
cli_overrides,
codex_linux_sandbox_exe,
full_auto,
permissions_profile,
/*codex_home*/ None,
)
.await
}
async fn load_debug_sandbox_config_with_codex_home(
cli_overrides: Vec<(String, TomlValue)>,
mut cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
full_auto: bool,
permissions_profile: Option<String>,
codex_home: Option<PathBuf>,
) -> anyhow::Result<Config> {
if let Some(permissions_profile) = permissions_profile {
cli_overrides.push((
"default_permissions".to_string(),
TomlValue::String(permissions_profile),
));
}
// For legacy configs, `codex sandbox` historically defaulted to read-only
// instead of inheriting ambient `sandbox_mode` settings from user/system
// config. Keep that behavior unless this invocation explicitly passes a
// legacy `sandbox_mode` CLI override, which is now the documented writable
// replacement for the removed `--full-auto` flag.
let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides);
let config = build_debug_sandbox_config(
cli_overrides.clone(),
ConfigOverrides {
@@ -606,19 +611,14 @@ async fn load_debug_sandbox_config_with_codex_home(
)
.await?;
if config_uses_permission_profiles(&config) {
if full_auto {
anyhow::bail!(
"`codex sandbox --full-auto` is only supported for legacy `sandbox_mode` configs; choose a writable `[permissions]` profile instead"
);
}
if config_uses_permission_profiles(&config) || uses_legacy_sandbox_mode_override {
return Ok(config);
}
build_debug_sandbox_config(
cli_overrides,
ConfigOverrides {
sandbox_mode: Some(create_sandbox_mode(full_auto)),
sandbox_mode: Some(SandboxMode::ReadOnly),
codex_linux_sandbox_exe,
..Default::default()
},
@@ -652,9 +652,14 @@ fn config_uses_permission_profiles(config: &Config) -> bool {
.is_some()
}
fn cli_overrides_use_legacy_sandbox_mode(cli_overrides: &[(String, TomlValue)]) -> bool {
cli_overrides.iter().any(|(key, _)| key == "sandbox_mode")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn escape_toml_path(path: &std::path::Path) -> String {
@@ -701,7 +706,7 @@ mod tests {
let legacy_config = build_debug_sandbox_config(
Vec::new(),
ConfigOverrides {
sandbox_mode: Some(create_sandbox_mode(/*full_auto*/ false)),
sandbox_mode: Some(SandboxMode::ReadOnly),
..Default::default()
},
Some(codex_home_path.clone()),
@@ -711,7 +716,7 @@ mod tests {
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
/*full_auto*/ false,
/*permissions_profile*/ None,
Some(codex_home_path),
)
.await?;
@@ -735,25 +740,176 @@ mod tests {
}
#[tokio::test]
async fn debug_sandbox_rejects_full_auto_for_permission_profiles() -> anyhow::Result<()> {
async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let codex_home_path = codex_home.path().to_path_buf();
let cli_overrides = vec![(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
)];
let workspace_write_config = build_debug_sandbox_config(
cli_overrides.clone(),
ConfigOverrides::default(),
Some(codex_home_path.clone()),
)
.await?;
let read_only_config = build_debug_sandbox_config(
Vec::new(),
ConfigOverrides {
sandbox_mode: Some(SandboxMode::ReadOnly),
..Default::default()
},
Some(codex_home_path.clone()),
)
.await?;
let config = load_debug_sandbox_config_with_codex_home(
cli_overrides,
/*codex_linux_sandbox_exe*/ None,
/*permissions_profile*/ None,
Some(codex_home_path),
)
.await?;
if cfg!(target_os = "windows") {
assert_eq!(
workspace_write_config
.permissions
.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
"workspace-write downgrades to read-only when the Windows sandbox is disabled"
);
} else {
assert_ne!(
workspace_write_config
.permissions
.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
"test fixture should distinguish explicit workspace-write from read-only"
);
}
assert_eq!(
config.permissions.file_system_sandbox_policy(),
workspace_write_config
.permissions
.file_system_sandbox_policy(),
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_defaults_legacy_configs_to_read_only() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let codex_home_path = codex_home.path().to_path_buf();
let read_only_config = build_debug_sandbox_config(
Vec::new(),
ConfigOverrides {
sandbox_mode: Some(SandboxMode::ReadOnly),
..Default::default()
},
Some(codex_home_path.clone()),
)
.await?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
/*permissions_profile*/ None,
Some(codex_home_path),
)
.await?;
assert!(!config_uses_permission_profiles(&config));
assert_eq!(
config.permissions.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_honors_explicit_builtin_permission_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
Some(":workspace".to_string()),
Some(codex_home.path().to_path_buf()),
)
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
codex_protocol::models::PermissionProfile::workspace_write()
.file_system_sandbox_policy()
);
Ok(())
}
#[tokio::test]
async fn explicit_permission_profile_overrides_active_profile_sandbox_mode()
-> anyhow::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
"profile = \"legacy\"\n\
\n\
[profiles.legacy]\n\
sandbox_mode = \"danger-full-access\"\n",
)?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
Some(":workspace".to_string()),
Some(codex_home.path().to_path_buf()),
)
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
codex_protocol::models::PermissionProfile::workspace_write()
.file_system_sandbox_policy()
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let sandbox_paths = TempDir::new()?;
let docs = sandbox_paths.path().join("docs");
let private = docs.join("private");
write_permissions_profile_config(&codex_home, &docs, &private)?;
let err = load_debug_sandbox_config_with_codex_home(
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
/*full_auto*/ true,
Some("limited-read-test".to_string()),
Some(codex_home.path().to_path_buf()),
)
.await
.expect_err("full-auto should be rejected for active permission profiles");
.await?;
assert!(
err.to_string().contains("--full-auto"),
"unexpected error: {err}"
let expected = build_debug_sandbox_config(
vec![(
"default_permissions".to_string(),
TomlValue::String("limited-read-test".to_string()),
)],
ConfigOverrides::default(),
Some(codex_home.path().to_path_buf()),
)
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
expected.permissions.file_system_sandbox_policy()
);
Ok(())
+9 -9
View File
@@ -21,9 +21,9 @@ pub use login::run_logout;
#[derive(Debug, Parser)]
pub struct SeatbeltCommand {
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
/// Named permissions profile to apply from the active configuration stack.
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option<String>,
/// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths.
#[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)]
@@ -48,9 +48,9 @@ fn parse_allow_unix_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> {
#[derive(Debug, Parser)]
pub struct LandlockCommand {
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
/// Named permissions profile to apply from the active configuration stack.
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option<String>,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
@@ -62,9 +62,9 @@ pub struct LandlockCommand {
#[derive(Debug, Parser)]
pub struct WindowsCommand {
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
/// Named permissions profile to apply from the active configuration stack.
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option<String>,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
+56 -9
View File
@@ -1352,16 +1352,12 @@ async fn run_debug_prompt_input_command(
));
}
let approval_policy = if shared.full_auto {
Some(AskForApproval::OnRequest)
} else if shared.dangerously_bypass_approvals_and_sandbox {
let approval_policy = if shared.dangerously_bypass_approvals_and_sandbox {
Some(AskForApproval::Never)
} else {
interactive.approval_policy.map(Into::into)
};
let sandbox_mode = if shared.full_auto {
Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite)
} else if shared.dangerously_bypass_approvals_and_sandbox {
let sandbox_mode = if shared.dangerously_bypass_approvals_and_sandbox {
Some(codex_protocol::config_types::SandboxMode::DangerFullAccess)
} else {
shared.sandbox_mode.map(Into::into)
@@ -1926,6 +1922,30 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Update)));
}
#[test]
fn sandbox_macos_parses_permissions_profile() {
let cli = MultitoolCli::try_parse_from([
"codex",
"sandbox",
"macos",
"--permissions-profile",
":workspace",
"--",
"echo",
])
.expect("parse");
let Some(Subcommand::Sandbox(SandboxArgs {
cmd: SandboxCommand::Macos(command),
})) = cli.subcommand
else {
panic!("expected sandbox macos command");
};
assert_eq!(command.permissions_profile.as_deref(), Some(":workspace"));
assert_eq!(command.command, vec!["echo"]);
}
#[test]
fn plugin_marketplace_remove_parses_under_plugin() {
let cli =
@@ -1950,6 +1970,35 @@ mod tests {
assert!(remove_result.is_err());
}
#[test]
fn full_auto_no_longer_parses_at_top_level() {
let result = MultitoolCli::try_parse_from(["codex", "--full-auto"]);
assert!(result.is_err());
}
#[test]
fn exec_full_auto_reports_migration_path() {
let cli = MultitoolCli::try_parse_from(["codex", "exec", "--full-auto", "summarize"])
.expect("exec should accept removed flag long enough to report a migration path");
let Some(Subcommand::Exec(exec)) = cli.subcommand else {
panic!("expected exec subcommand");
};
assert_eq!(
exec.removed_full_auto_warning(),
Some("warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.")
);
}
#[test]
fn sandbox_full_auto_no_longer_parses() {
let result =
MultitoolCli::try_parse_from(["codex", "sandbox", "linux", "--full-auto", "--"]);
assert!(result.is_err());
}
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
@@ -2080,14 +2129,13 @@ mod tests {
}
#[test]
fn resume_merges_option_flags_and_full_auto() {
fn resume_merges_option_flags() {
let interactive = finalize_resume_from_args(
[
"codex",
"resume",
"sid",
"--oss",
"--full-auto",
"--search",
"--sandbox",
"workspace-write",
@@ -2116,7 +2164,6 @@ mod tests {
interactive.approval_policy,
Some(codex_utils_cli::ApprovalModeCliArg::OnRequest)
);
assert!(interactive.full_auto);
assert_eq!(
interactive.cwd.as_deref(),
Some(std::path::Path::new("/tmp"))
+47 -23
View File
@@ -16,11 +16,12 @@ use codex_utils_output_truncation::approx_token_count;
const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 10;
const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100;
const APPROX_BYTES_PER_TOKEN: usize = 4;
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_PREFIX: &str = "Warning: Exceeded skills context budget. Loaded skill descriptions were truncated by an average of";
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT: &str = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str =
"Warning: Exceeded skills context budget. All skill descriptions were removed and";
"Exceeded skills context budget. All skill descriptions were removed and";
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.";
pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.";
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
@@ -230,11 +231,13 @@ fn build_available_skills_from_lines(
} else if report.average_truncated_description_chars()
> SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS
{
Some(format!(
"{} {} characters per skill.",
budget_warning_prefix(budget, SKILL_DESCRIPTION_TRUNCATED_WARNING_PREFIX),
report.average_truncated_description_chars()
))
Some(
match budget {
SkillMetadataBudget::Tokens(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT,
SkillMetadataBudget::Characters(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING,
}
.to_string(),
)
} else {
None
};
@@ -431,13 +434,13 @@ fn skill_render_report(
impl SkillRenderReport {
fn average_truncated_description_chars(&self) -> usize {
if self.truncated_description_count == 0 {
if self.total_count == 0 || self.truncated_description_chars == 0 {
return 0;
}
self.truncated_description_chars
.saturating_add(self.truncated_description_count.saturating_sub(1))
/ self.truncated_description_count
.saturating_add(self.total_count.saturating_sub(1))
/ self.total_count
}
}
@@ -1048,30 +1051,51 @@ mod tests {
#[test]
fn budgeted_rendering_warns_when_average_description_truncation_exceeds_threshold() {
let alpha =
make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdefghijklmnop");
let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyzabcdefghij");
let minimum_cost = SkillLine::new(&alpha)
let long_description = "a".repeat(250);
let long_skill =
make_skill_with_description("long-skill", SkillScope::Repo, &long_description);
let empty_skill = make_skill_with_description("empty-skill", SkillScope::Repo, "");
let minimum_cost = SkillLine::new(&long_skill)
.minimum_cost(SkillMetadataBudget::Characters(usize::MAX))
+ SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
let budget = SkillMetadataBudget::Characters(minimum_cost + 6);
+ SkillLine::new(&empty_skill)
.minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
let budget = SkillMetadataBudget::Characters(minimum_cost + 49);
let rendered = build_available_skills_from_metadata(&[alpha, beta], budget)
let rendered = build_available_skills_from_metadata(&[long_skill, empty_skill], budget)
.expect("skills should render");
assert_eq!(rendered.report.total_count, 2);
assert_eq!(rendered.report.included_count, 2);
assert_eq!(rendered.report.omitted_count, 0);
assert_eq!(rendered.report.truncated_description_chars, 28);
assert_eq!(rendered.report.truncated_description_count, 2);
assert_eq!(rendered.report.truncated_description_chars, 202);
assert_eq!(rendered.report.truncated_description_count, 1);
assert_eq!(
rendered.warning_message,
Some(
"Warning: Exceeded skills context budget. Loaded skill descriptions were truncated by an average of 14 characters per skill."
"Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."
.to_string()
)
);
}
#[test]
fn budgeted_rendering_token_budget_truncation_warning_mentions_two_percent() {
let long_description = "a".repeat(1000);
let long_skill =
make_skill_with_description("long-skill", SkillScope::Repo, &long_description);
let minimum_cost =
SkillLine::new(&long_skill).minimum_cost(SkillMetadataBudget::Tokens(usize::MAX));
let budget = SkillMetadataBudget::Tokens(minimum_cost + 1);
let rendered = build_available_skills_from_metadata(&[long_skill], budget)
.expect("skills should render");
assert_eq!(
rendered.warning_message,
Some(SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT.to_string())
);
}
#[test]
fn budgeted_rendering_redistributes_unused_description_budget() {
let short = make_skill_with_description("short-skill", SkillScope::Repo, "x");
@@ -1116,7 +1140,7 @@ mod tests {
assert_eq!(
rendered.warning_message,
Some(
"Warning: Exceeded skills context budget. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
"Exceeded skills context budget. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
.to_string()
)
);
@@ -1145,7 +1169,7 @@ mod tests {
assert_eq!(
rendered.warning_message,
Some(
"Warning: Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
"Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
.to_string()
)
);
+1
View File
@@ -47,6 +47,7 @@ codex-shell-command = { workspace = true }
codex-execpolicy = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-kernel = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
+1 -159
View File
@@ -1,14 +1,7 @@
pub use codex_api::ResponseEvent;
use codex_config::types::Personality;
pub use codex_kernel::Prompt;
use codex_protocol::error::Result;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::ResponseItem;
use codex_tools::ToolSpec;
use futures::Stream;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashSet;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
@@ -23,157 +16,6 @@ pub const REVIEW_EXIT_SUCCESS_TMPL: &str = include_str!("../templates/review/exi
pub const REVIEW_EXIT_INTERRUPTED_TMPL: &str =
include_str!("../templates/review/exit_interrupted.xml");
/// API request payload for a single model turn
#[derive(Debug, Clone)]
pub struct Prompt {
/// Conversation context input items.
pub input: Vec<ResponseItem>,
/// Tools available to the model, including additional tools sourced from
/// external MCP servers.
pub(crate) tools: Vec<ToolSpec>,
/// Whether parallel tool calls are permitted for this prompt.
pub(crate) parallel_tool_calls: bool,
pub base_instructions: BaseInstructions,
/// Optionally specify the personality of the model.
pub personality: Option<Personality>,
/// Optional the output schema for the model's response.
pub output_schema: Option<Value>,
/// Whether the Responses API should strictly validate `output_schema`.
pub output_schema_strict: bool,
}
impl Default for Prompt {
fn default() -> Self {
Self {
input: Vec::new(),
tools: Vec::new(),
parallel_tool_calls: false,
base_instructions: BaseInstructions::default(),
personality: None,
output_schema: None,
output_schema_strict: true,
}
}
}
impl Prompt {
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
let mut input = self.input.clone();
// when using the *Freeform* apply_patch tool specifically, tool outputs
// should be structured text, not json. Do NOT reserialize when using
// the Function tool - note that this differs from the check above for
// instructions. We declare the result as a named variable for clarity.
let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
ToolSpec::Freeform(f) => f.name == "apply_patch",
_ => false,
});
if is_freeform_apply_patch_tool_present {
reserialize_shell_outputs(&mut input);
}
input
}
}
fn reserialize_shell_outputs(items: &mut [ResponseItem]) {
let mut shell_call_ids: HashSet<String> = HashSet::new();
items.iter_mut().for_each(|item| match item {
ResponseItem::LocalShellCall { call_id, id, .. } => {
if let Some(identifier) = call_id.clone().or_else(|| id.clone()) {
shell_call_ids.insert(identifier);
}
}
ResponseItem::CustomToolCall {
id: _,
status: _,
call_id,
name,
input: _,
} => {
if name == "apply_patch" {
shell_call_ids.insert(call_id.clone());
}
}
ResponseItem::FunctionCall { name, call_id, .. }
if is_shell_tool_name(name) || name == "apply_patch" =>
{
shell_call_ids.insert(call_id.clone());
}
ResponseItem::FunctionCallOutput {
call_id, output, ..
}
| ResponseItem::CustomToolCallOutput {
call_id, output, ..
} => {
if shell_call_ids.remove(call_id)
&& let Some(structured) = output
.text_content()
.and_then(parse_structured_shell_output)
{
output.body = FunctionCallOutputBody::Text(structured);
}
}
_ => {}
})
}
fn is_shell_tool_name(name: &str) -> bool {
matches!(name, "shell" | "container.exec")
}
#[derive(Deserialize)]
struct ExecOutputJson {
output: String,
metadata: ExecOutputMetadataJson,
}
#[derive(Deserialize)]
struct ExecOutputMetadataJson {
exit_code: i32,
duration_seconds: f32,
}
fn parse_structured_shell_output(raw: &str) -> Option<String> {
let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?;
Some(build_structured_output(&parsed))
}
fn build_structured_output(parsed: &ExecOutputJson) -> String {
let mut sections = Vec::new();
sections.push(format!("Exit code: {}", parsed.metadata.exit_code));
sections.push(format!(
"Wall time: {} seconds",
parsed.metadata.duration_seconds
));
let mut output = parsed.output.clone();
if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) {
sections.push(format!("Total output lines: {total_lines}"));
output = stripped.to_string();
}
sections.push("Output:".to_string());
sections.push(output);
sections.join("\n")
}
fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
let after_prefix = output.strip_prefix("Total output lines: ")?;
let (total_segment, remainder) = after_prefix.split_once('\n')?;
let total_lines = total_segment.parse::<u32>().ok()?;
let remainder = remainder.strip_prefix('\n').unwrap_or(remainder);
Some((remainder, total_lines))
}
pub struct ResponseStream {
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
/// Signals the mapper task that the consumer stopped polling before the
+1 -65
View File
@@ -3,11 +3,9 @@ use codex_api::ResponsesApiRequest;
use codex_api::TextControls;
use codex_api::create_text_param_for_request;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn serializes_text_verbosity_when_set() {
let input: Vec<ResponseItem> = vec![];
@@ -166,65 +164,3 @@ fn serializes_flex_service_tier_when_set() {
Some("flex")
);
}
#[test]
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
let mut items = vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
name: None,
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
];
reserialize_shell_outputs(&mut items);
assert_eq!(
items,
vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
name: None,
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
]
);
}
+25 -1
View File
@@ -8,6 +8,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
use crate::windows_sandbox::resolve_windows_sandbox_mode;
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirements;
@@ -1558,7 +1559,30 @@ fn resolve_permission_config_syntax(
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
) -> Option<PermissionConfigSyntax> {
if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() {
if sandbox_mode_override.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}
let session_flags_select_profiles = config_layer_stack
.get_layers(
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ false,
)
.into_iter()
.find(|layer| matches!(layer.name, ConfigLayerSource::SessionFlags))
.and_then(|layer| {
layer
.config
.clone()
.try_into::<PermissionSelectionToml>()
.ok()
})
.is_some_and(|selection| selection.default_permissions.is_some());
if session_flags_select_profiles {
return Some(PermissionConfigSyntax::Profiles);
}
if profile_sandbox_mode.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}
+1 -1
View File
@@ -201,7 +201,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
config.codex_linux_sandbox_exe.clone(),
)?;
let environment_manager =
EnvironmentManager::new(EnvironmentManagerArgs::from_env(local_runtime_paths));
EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await;
list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
force_refetch,
+6 -9
View File
@@ -61,7 +61,6 @@ pub(crate) fn selected_primary_environment(
#[cfg(test)]
mod tests {
use codex_exec_server::EnvironmentManagerArgs;
use codex_exec_server::ExecServerRuntimePaths;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_protocol::protocol::TurnEnvironmentSelection;
@@ -81,10 +80,11 @@ mod tests {
#[tokio::test]
async fn default_thread_environment_selections_use_manager_default_id() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: test_runtime_paths(),
});
let manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
test_runtime_paths(),
)
.await;
assert_eq!(
default_thread_environment_selections(&manager, &cwd),
@@ -98,10 +98,7 @@ mod tests {
#[tokio::test]
async fn default_thread_environment_selections_empty_when_default_disabled() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
assert_eq!(
default_thread_environment_selections(&manager, &cwd),
+1 -3
View File
@@ -45,9 +45,7 @@ pub async fn build_prompt_input(
.features
.enabled(Feature::DefaultModeRequestUserInput),
},
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
local_runtime_paths,
))),
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
/*analytics_events_client*/ None,
);
let thread = thread_manager.start_thread(config).await?;
+1
View File
@@ -189,6 +189,7 @@ mod mcp;
mod multi_agents;
mod review;
mod rollout_reconstruction;
mod sampling_loop;
#[allow(clippy::module_inception)]
pub(crate) mod session;
pub(crate) mod turn;
+221
View File
@@ -0,0 +1,221 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use crate::SkillLoadOutcome;
use crate::client::ModelClientSession;
use crate::session::session::Session;
use crate::session::turn::build_prompt_config;
use crate::session::turn::build_tool_config;
use crate::session::turn::built_tools;
use crate::session::turn::try_run_sampling_request;
use crate::session::turn_context::TurnContext;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::parallel::ToolCallRuntime;
use codex_kernel::PreparedSamplingRequest;
use codex_kernel::Prompt;
use codex_kernel::SamplingLoopHost;
use codex_kernel::SamplingRequestResult;
use codex_kernel::run_sampling_request_loop;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::StreamErrorEvent;
use codex_protocol::protocol::WarningEvent;
use tokio_util::sync::CancellationToken;
use tracing::instrument;
use tracing::warn;
struct SamplingRequestRuntime {
_code_mode_worker: Option<codex_code_mode::CodeModeTurnWorker>,
}
#[allow(clippy::too_many_arguments)]
#[instrument(
level = "trace",
skip_all,
fields(
turn_id = %turn_context.sub_id,
model = %turn_context.model_info.slug,
cwd = %turn_context.cwd.display()
)
)]
pub(super) async fn run_sampling_request(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
turn_diff_tracker: SharedTurnDiffTracker,
client_session: &mut ModelClientSession,
turn_metadata_header: Option<&str>,
input: Vec<ResponseItem>,
explicitly_enabled_connectors: &HashSet<String>,
skills_outcome: Option<&SkillLoadOutcome>,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
let host = CoreSamplingLoopHost {
sess,
turn_context,
turn_diff_tracker,
turn_metadata_header,
explicitly_enabled_connectors,
skills_outcome,
};
run_sampling_request_loop(&host, client_session, input, cancellation_token).await
}
struct CoreSamplingLoopHost<'a> {
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
turn_diff_tracker: SharedTurnDiffTracker,
turn_metadata_header: Option<&'a str>,
explicitly_enabled_connectors: &'a HashSet<String>,
skills_outcome: Option<&'a SkillLoadOutcome>,
}
impl SamplingLoopHost for CoreSamplingLoopHost<'_> {
type ClientSession = ModelClientSession;
type Runtime = SamplingRequestRuntime;
type Tools = ToolCallRuntime;
async fn prepare_sampling_request(
&self,
input: &[ResponseItem],
cancellation_token: &CancellationToken,
) -> CodexResult<PreparedSamplingRequest<Self::Runtime, Self::Tools>> {
let router = built_tools(
self.sess.as_ref(),
self.turn_context.as_ref(),
input,
self.explicitly_enabled_connectors,
self.skills_outcome,
cancellation_token,
)
.await?;
let base_instructions = self.sess.get_base_instructions().await;
let tool_runtime = ToolCallRuntime::new(
Arc::clone(&router),
Arc::clone(&self.sess),
Arc::clone(&self.turn_context),
Arc::clone(&self.turn_diff_tracker),
);
let code_mode_worker = self
.sess
.services
.code_mode_service
.start_turn_worker(
&self.sess,
&self.turn_context,
Arc::clone(&router),
Arc::clone(&self.turn_diff_tracker),
)
.await;
Ok(PreparedSamplingRequest {
prompt_config: build_prompt_config(self.turn_context.as_ref(), base_instructions),
tool_config: build_tool_config(router.as_ref(), self.turn_context.as_ref()),
runtime: SamplingRequestRuntime {
_code_mode_worker: code_mode_worker,
},
tools: tool_runtime,
})
}
async fn history_prompt_input(&self) -> Vec<ResponseItem> {
self.sess
.clone_history()
.await
.for_prompt(&self.turn_context.model_info.input_modalities)
}
async fn run_single_sampling_request(
&self,
_runtime: &Self::Runtime,
tools: &Self::Tools,
client_session: &mut Self::ClientSession,
prompt: &Prompt,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
try_run_sampling_request(
tools.clone(),
Arc::clone(&self.sess),
Arc::clone(&self.turn_context),
client_session,
self.turn_metadata_header,
Arc::clone(&self.turn_diff_tracker),
prompt,
cancellation_token,
)
.await
}
fn stream_max_retries(&self) -> u64 {
self.turn_context.provider.info().stream_max_retries()
}
fn try_switch_fallback_transport(&self, client_session: &mut Self::ClientSession) -> bool {
client_session.try_switch_fallback_transport(
&self.turn_context.session_telemetry,
&self.turn_context.model_info,
)
}
fn should_notify_stream_retry(&self, retries: u64, _err: &CodexErr) -> bool {
retries > 1
|| cfg!(debug_assertions)
|| !self
.sess
.services
.model_client
.responses_websocket_enabled()
}
async fn handle_context_window_exceeded(&self) {
self.sess.set_total_tokens_full(&self.turn_context).await;
}
async fn handle_usage_limit_reached(&self, rate_limits: Option<RateLimitSnapshot>) {
if let Some(rate_limits) = rate_limits {
self.sess
.update_rate_limits(&self.turn_context, rate_limits)
.await;
}
}
async fn notify_fallback_to_http(&self, err: &CodexErr) {
self.sess
.send_event(
&self.turn_context,
EventMsg::Warning(WarningEvent {
message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"),
}),
)
.await;
}
async fn notify_stream_retry(
&self,
retries: u64,
max_retries: u64,
delay: Duration,
err: &CodexErr,
) {
warn!(
"stream disconnected - retrying sampling request ({retries}/{max_retries} in {delay:?})..."
);
self.sess
.send_event(
&self.turn_context,
EventMsg::StreamError(StreamErrorEvent {
message: format!("Reconnecting... {retries}/{max_retries}"),
codex_error_info: Some(CodexErrorInfo::ResponseStreamDisconnected {
http_status_code: err.http_status_code_value(),
}),
additional_details: Some(err.to_string()),
}),
)
.await;
}
}
+3 -3
View File
@@ -5615,7 +5615,7 @@ fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() {
assert_eq!(
rendered.warning_message,
Some(
"Warning: Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
"Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
.to_string()
)
);
@@ -5726,7 +5726,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil
assert!(matches!(
warning_event.msg,
EventMsg::Warning(WarningEvent { message })
if message == "Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
if message == "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
));
let _ = session.build_initial_context(&turn_context).await;
@@ -5737,7 +5737,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil
assert!(matches!(
warning_event.msg,
EventMsg::Warning(WarningEvent { message })
if message == "Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
if message == "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
));
}
+22 -163
View File
@@ -40,6 +40,7 @@ use crate::parse_turn_item;
use crate::plugins::build_plugin_injections;
use crate::resolve_skill_dependencies_for_turn;
use crate::session::PreviousTurnSettings;
use crate::session::sampling_loop::run_sampling_request;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::stream_events_utils::HandleOutputCtx;
@@ -57,7 +58,6 @@ use crate::tools::router::ToolRouterParams;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::turn_timing::record_turn_ttft_metric;
use crate::unavailable_tool::collect_unavailable_called_tools;
use crate::util::backoff;
use crate::util::error_or_panic;
use codex_analytics::AppInvocation;
use codex_analytics::CompactionPhase;
@@ -71,6 +71,9 @@ use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
use codex_hooks::HookResult;
use codex_kernel::PromptConfig;
use codex_kernel::SamplingRequestResult;
use codex_kernel::ToolConfig;
use codex_protocol::config_types::ModeKind;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -932,16 +935,11 @@ fn connector_inserted_in_messages(
connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&mention_slug)
}
pub(crate) fn build_prompt(
input: Vec<ResponseItem>,
router: &ToolRouter,
pub(super) fn build_prompt_config(
turn_context: &TurnContext,
base_instructions: BaseInstructions,
) -> Prompt {
Prompt {
input,
tools: router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
) -> PromptConfig {
PromptConfig {
base_instructions,
personality: turn_context.personality,
output_schema: turn_context.final_output_json_schema.clone(),
@@ -951,156 +949,23 @@ pub(crate) fn build_prompt(
}
}
#[allow(clippy::too_many_arguments)]
#[instrument(level = "trace",
skip_all,
fields(
turn_id = %turn_context.sub_id,
model = %turn_context.model_info.slug,
cwd = %turn_context.cwd.display()
)
)]
async fn run_sampling_request(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
turn_diff_tracker: SharedTurnDiffTracker,
client_session: &mut ModelClientSession,
turn_metadata_header: Option<&str>,
input: Vec<ResponseItem>,
explicitly_enabled_connectors: &HashSet<String>,
skills_outcome: Option<&SkillLoadOutcome>,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
let router = built_tools(
sess.as_ref(),
turn_context.as_ref(),
&input,
explicitly_enabled_connectors,
skills_outcome,
&cancellation_token,
)
.await?;
let base_instructions = sess.get_base_instructions().await;
let tool_runtime = ToolCallRuntime::new(
Arc::clone(&router),
Arc::clone(&sess),
Arc::clone(&turn_context),
Arc::clone(&turn_diff_tracker),
);
let _code_mode_worker = sess
.services
.code_mode_service
.start_turn_worker(
&sess,
&turn_context,
Arc::clone(&router),
Arc::clone(&turn_diff_tracker),
)
.await;
let mut retries = 0;
let mut initial_input = Some(input);
loop {
let prompt_input = if let Some(input) = initial_input.take() {
input
} else {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
};
let prompt = build_prompt(
prompt_input,
router.as_ref(),
turn_context.as_ref(),
base_instructions.clone(),
);
let err = match try_run_sampling_request(
tool_runtime.clone(),
Arc::clone(&sess),
Arc::clone(&turn_context),
client_session,
turn_metadata_header,
Arc::clone(&turn_diff_tracker),
&prompt,
cancellation_token.child_token(),
)
.await
{
Ok(output) => {
return Ok(output);
}
Err(CodexErr::ContextWindowExceeded) => {
sess.set_total_tokens_full(&turn_context).await;
return Err(CodexErr::ContextWindowExceeded);
}
Err(CodexErr::UsageLimitReached(e)) => {
let rate_limits = e.rate_limits.clone();
if let Some(rate_limits) = rate_limits {
sess.update_rate_limits(&turn_context, *rate_limits).await;
}
return Err(CodexErr::UsageLimitReached(e));
}
Err(err) => err,
};
if !err.is_retryable() {
return Err(err);
}
// Use the configured provider-specific stream retry budget.
let max_retries = turn_context.provider.info().stream_max_retries();
if retries >= max_retries
&& client_session.try_switch_fallback_transport(
&turn_context.session_telemetry,
&turn_context.model_info,
)
{
sess.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"),
}),
)
.await;
retries = 0;
continue;
}
if retries < max_retries {
retries += 1;
let delay = match &err {
CodexErr::Stream(_, requested_delay) => {
requested_delay.unwrap_or_else(|| backoff(retries))
}
_ => backoff(retries),
};
warn!(
"stream disconnected - retrying sampling request ({retries}/{max_retries} in {delay:?})...",
);
// In release builds, hide the first websocket retry notification to reduce noisy
// transient reconnect messages. In debug builds, keep full visibility for diagnosis.
let report_error = retries > 1
|| cfg!(debug_assertions)
|| !sess.services.model_client.responses_websocket_enabled();
if report_error {
// Surface retry information to any UI/front‑end so the
// user understands what is happening instead of staring
// at a seemingly frozen screen.
sess.notify_stream_error(
&turn_context,
format!("Reconnecting... {retries}/{max_retries}"),
err,
)
.await;
}
tokio::time::sleep(delay).await;
} else {
return Err(err);
}
pub(super) fn build_tool_config(router: &ToolRouter, turn_context: &TurnContext) -> ToolConfig {
ToolConfig {
tools: router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
}
}
pub(crate) fn build_prompt(
input: Vec<ResponseItem>,
router: &ToolRouter,
turn_context: &TurnContext,
base_instructions: BaseInstructions,
) -> Prompt {
build_prompt_config(turn_context, base_instructions)
.build_prompt(input, &build_tool_config(router, turn_context))
}
#[expect(
clippy::await_holding_invalid_type,
reason = "tool router construction reads through the session-owned manager guard"
@@ -1243,12 +1108,6 @@ pub(crate) async fn built_tools(
)))
}
#[derive(Debug)]
struct SamplingRequestResult {
needs_follow_up: bool,
last_agent_message: Option<String>,
}
/// Ephemeral per-response state for streaming a single proposed plan.
/// This is intentionally not persisted or stored in session/state since it
/// only exists while a response is actively streaming. The final plan text
@@ -1806,7 +1665,7 @@ async fn drain_in_flight(
model = %turn_context.model_info.slug
)
)]
async fn try_run_sampling_request(
pub(super) async fn try_run_sampling_request(
tool_runtime: ToolCallRuntime,
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
+12 -94
View File
@@ -10,18 +10,18 @@ use tokio_util::sync::CancellationToken;
use crate::context::ContextualUserFragment;
use crate::context::ImageGenerationInstructions;
use crate::function_tool::FunctionCallError;
use crate::parse_turn_item;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolRouter;
use codex_kernel::CompletedResponseItem;
use codex_kernel::KernelToolExecutor;
use codex_kernel::execute_tool_call_with_default_output;
use codex_kernel::response_input_to_response_item;
use codex_memories_read::citations::parse_memory_citation;
use codex_memories_read::citations::thread_ids_from_memory_citation;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -225,9 +225,8 @@ pub(crate) async fn handle_output_item_done(
let mut output = OutputItemResult::default();
let plan_mode = ctx.turn_context.collaboration_mode.mode == ModeKind::Plan;
match ToolRouter::build_tool_call(ctx.sess.as_ref(), item.clone()).await {
// The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
Ok(Some(call)) => {
match ctx.tool_runtime.classify_response_item(item).await? {
CompletedResponseItem::ToolCall { item, call } => {
ctx.sess
.accept_mailbox_delivery_for_current_turn(&ctx.turn_context.sub_id)
.await;
@@ -244,17 +243,15 @@ pub(crate) async fn handle_output_item_done(
.await;
let cancellation_token = ctx.cancellation_token.child_token();
let tool_future: InFlightFuture<'static> = Box::pin(
ctx.tool_runtime
.clone()
.handle_tool_call(call, cancellation_token),
);
let tool_runtime = ctx.tool_runtime.clone();
let tool_future: InFlightFuture<'static> = Box::pin(async move {
execute_tool_call_with_default_output(&tool_runtime, call, cancellation_token).await
});
output.needs_follow_up = true;
output.tool_future = Some(tool_future);
}
// No tool call: convert messages/reasoning into turn items and mark them as complete.
Ok(None) => {
CompletedResponseItem::NonTool(item) => {
if let Some(turn_item) = handle_non_tool_response_item(
ctx.sess.as_ref(),
ctx.turn_context.as_ref(),
@@ -286,21 +283,7 @@ pub(crate) async fn handle_output_item_done(
output.last_agent_message = last_agent_message;
}
// Guardrail: the model issued a LocalShellCall without an id; surface the error back into history.
Err(FunctionCallError::MissingLocalShellCallId) => {
let msg = "LocalShellCall without call_id or id";
ctx.turn_context
.session_telemetry
.log_tool_failed("local_shell", msg);
tracing::error!(msg);
let response = ResponseInputItem::FunctionCallOutput {
call_id: String::new(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(msg.to_string()),
..Default::default()
},
};
CompletedResponseItem::ImmediateResponse { item, response } => {
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
.await;
if let Some(response_item) = response_input_to_response_item(&response) {
@@ -314,32 +297,6 @@ pub(crate) async fn handle_output_item_done(
output.needs_follow_up = true;
}
// The tool request should be answered directly (or was denied); push that response into the transcript.
Err(FunctionCallError::RespondToModel(message)) => {
let response = ResponseInputItem::FunctionCallOutput {
call_id: String::new(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message),
..Default::default()
},
};
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
.await;
if let Some(response_item) = response_input_to_response_item(&response) {
ctx.sess
.record_conversation_items(
&ctx.turn_context,
std::slice::from_ref(&response_item),
)
.await;
}
output.needs_follow_up = true;
}
// A fatal error occurred; surface it back into history.
Err(FunctionCallError::Fatal(message)) => {
return Err(CodexErr::Fatal(message));
}
}
Ok(output)
@@ -465,45 +422,6 @@ fn completed_item_defers_mailbox_delivery_to_next_turn(
}
}
pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Option<ResponseItem> {
match input {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
Some(ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output: output.clone(),
})
}
ResponseInputItem::CustomToolCallOutput {
call_id,
name,
output,
} => Some(ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
name: name.clone(),
output: output.clone(),
}),
ResponseInputItem::McpToolCallOutput { call_id, output } => {
let output = output.as_function_call_output_payload();
Some(ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output,
})
}
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Some(ResponseItem::ToolSearchOutput {
call_id: Some(call_id.clone()),
status: status.clone(),
execution: execution.clone(),
tools: tools.clone(),
}),
_ => None,
}
}
#[cfg(test)]
#[path = "stream_events_utils_tests.rs"]
mod tests;
+13 -15
View File
@@ -47,20 +47,6 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
}
fn disabled_environment_manager_for_tests() -> Arc<codex_exec_server::EnvironmentManager> {
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe path"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
Arc::new(codex_exec_server::EnvironmentManager::new(
codex_exec_server::EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: runtime_paths,
},
))
}
fn contextual_user_interrupted_marker() -> ResponseItem {
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::ContextualUser)
.expect("contextual-user interrupted marker should be enabled")
@@ -307,11 +293,23 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe path"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
let environment_manager = Arc::new(
codex_exec_server::EnvironmentManager::create_for_tests(
Some("none".to_string()),
runtime_paths,
)
.await,
);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
disabled_environment_manager_for_tests(),
environment_manager,
);
let thread = manager
@@ -1,15 +1,10 @@
use super::*;
use crate::CodexThread;
use crate::ThreadManager;
use crate::config::AgentRoleConfig;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
use crate::context::TurnAborted;
use crate::function_tool::FunctionCallError;
use crate::session::tests::make_session_and_context;
use crate::session_prefix::format_subagent_notification_message;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
@@ -134,121 +129,6 @@ model_reasoning_effort = "minimal"
role_name
}
fn history_contains_inter_agent_communication(
history_items: &[ResponseItem],
expected: &InterAgentCommunication,
) -> bool {
history_items.iter().any(|item| {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "assistant" {
return false;
}
content.iter().any(|content_item| match content_item {
ContentItem::OutputText { text } => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.as_ref()
== Some(expected)
}
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
})
})
}
async fn wait_for_turn_aborted(
thread: &Arc<CodexThread>,
expected_turn_id: &str,
expected_reason: TurnAbortReason,
) {
timeout(Duration::from_secs(5), async {
loop {
let event = thread
.next_event()
.await
.expect("child thread should emit events");
if matches!(
event.msg,
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(ref turn_id),
ref reason,
..
}) if turn_id == expected_turn_id && *reason == expected_reason
) {
break;
}
}
})
.await
.expect("expected child turn to be interrupted");
}
async fn wait_for_redirected_envelope_in_history(
thread: &Arc<CodexThread>,
expected: &InterAgentCommunication,
) {
timeout(Duration::from_secs(5), async {
loop {
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let saw_envelope =
history_contains_inter_agent_communication(&history_items, expected);
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text }
if text == &expected.content
))
)
});
if saw_envelope {
assert!(
!saw_user_message,
"redirected followup should be stored as an assistant envelope, not a plain user message"
);
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("redirected followup envelope should appear in history");
}
#[derive(Clone, Copy)]
struct NeverEndingTask;
impl SessionTask for NeverEndingTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
fn span_name(&self) -> &'static str {
"session_task.multi_agent_never_ending"
}
async fn run(
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
cancellation_token.cancelled().await;
None
}
}
fn expect_text_output<T>(output: T) -> (String, Option<bool>)
where
T: ToolOutput,
@@ -1086,7 +966,6 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
function_payload(json!({
"target": "/root",
"message": "run this",
"interrupt": true
})),
))
.await
@@ -1485,255 +1364,6 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
)));
}
#[tokio::test]
async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_message() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
let agent_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
Op::CleanBackgroundTerminals,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
let interrupted_turn_id = active_turn.sub_id.clone();
thread
.codex
.session
.spawn_task(
Arc::clone(&active_turn),
vec![UserInput::Text {
text: "working".to_string(),
text_elements: Vec::new(),
}],
NeverEndingTask,
)
.await;
FollowupTaskHandlerV2
.handle(invocation(
session,
turn,
"followup_task",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"interrupt": true
})),
))
.await
.expect("interrupting v2 followup_task should succeed");
let ops = manager.captured_ops();
let ops_for_agent: Vec<&Op> = ops
.iter()
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
.collect();
assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt)));
assert!(ops_for_agent.iter().any(|op| {
matches!(
op,
Op::InterAgentCommunication { communication }
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/worker"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
)
}));
wait_for_turn_aborted(&thread, &interrupted_turn_id, TurnAbortReason::Interrupted).await;
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(
history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "developer"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text }
if text.contains(TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
))
)),
"v2 interrupted-turn marker should be recorded as a developer input message"
);
assert!(
!history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } | ContentItem::OutputText { text }
if text.contains(TurnAborted::INTERRUPTED_GUIDANCE)
))
)),
"v2 interrupted-turn marker should not be recorded as a user message"
);
assert!(
!history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } | ContentItem::OutputText { text }
if text.contains(TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
))
)),
"v2 interrupted-turn marker should not be recorded as an assistant message"
);
wait_for_redirected_envelope_in_history(
&thread,
&InterAgentCommunication::new(
AgentPath::root(),
worker_path,
Vec::new(),
"continue".to_string(),
/*trigger_turn*/ true,
),
)
.await;
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_followup_task_can_disable_interrupted_marker() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
config.agent_interrupt_message_enabled = false;
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
let agent_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
Op::CleanBackgroundTerminals,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(worker_path),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
let interrupted_turn_id = active_turn.sub_id.clone();
thread
.codex
.session
.spawn_task(
Arc::clone(&active_turn),
vec![UserInput::Text {
text: "working".to_string(),
text_elements: Vec::new(),
}],
NeverEndingTask,
)
.await;
FollowupTaskHandlerV2
.handle(invocation(
session,
turn,
"followup_task",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"interrupt": true
})),
))
.await
.expect("interrupting v2 followup_task should succeed");
wait_for_turn_aborted(&thread, &interrupted_turn_id, TurnAbortReason::Interrupted).await;
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(
!history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { content, .. }
if content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } | ContentItem::OutputText { text }
if text.contains(TurnAborted::INTERRUPTED_GUIDANCE)
|| text.contains(TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
))
)),
"disabled interrupted-turn marker should not be recorded in history"
);
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -25,7 +25,6 @@ impl ToolHandler for Handler {
MessageDeliveryMode::TriggerTurn,
args.target,
args.message,
args.interrupt,
)
.await
}
@@ -43,8 +43,6 @@ pub(crate) struct SendMessageArgs {
pub(crate) struct FollowupTaskArgs {
pub(crate) target: String,
pub(crate) message: String,
#[serde(default)]
pub(crate) interrupt: bool,
}
fn message_content(message: String) -> Result<String, FunctionCallError> {
@@ -62,16 +60,8 @@ pub(crate) async fn handle_message_string_tool(
mode: MessageDeliveryMode,
target: String,
message: String,
interrupt: bool,
) -> Result<FunctionToolOutput, FunctionCallError> {
handle_message_submission(
invocation,
mode,
target,
message_content(message)?,
interrupt,
)
.await
handle_message_submission(invocation, mode, target, message_content(message)?).await
}
async fn handle_message_submission(
@@ -79,7 +69,6 @@ async fn handle_message_submission(
mode: MessageDeliveryMode,
target: String,
prompt: String,
interrupt: bool,
) -> Result<FunctionToolOutput, FunctionCallError> {
let ToolInvocation {
session,
@@ -103,14 +92,6 @@ async fn handle_message_submission(
"Tasks can't be assigned to the root agent".to_string(),
));
}
if interrupt {
session
.services
.agent_control
.interrupt_agent(receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
session
.send_event(
&turn,
@@ -25,7 +25,6 @@ impl ToolHandler for Handler {
MessageDeliveryMode::QueueOnly,
args.target,
args.message,
/*interrupt*/ false,
)
.await
}
+90 -46
View File
@@ -20,8 +20,14 @@ use crate::tools::registry::ToolArgumentDiffConsumer;
use crate::tools::router::ToolCall;
use crate::tools::router::ToolCallSource;
use crate::tools::router::ToolRouter;
use codex_kernel::CompletedResponseItem;
use codex_kernel::KernelToolCall;
use codex_kernel::KernelToolExecutor;
use codex_kernel::ToolCallError;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_tools::ToolSpec;
#[derive(Clone)]
@@ -60,25 +66,6 @@ impl ToolCallRuntime {
self.router.create_diff_consumer(tool_name)
}
#[instrument(level = "trace", skip_all)]
pub(crate) fn handle_tool_call(
self,
call: ToolCall,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Result<ResponseInputItem, CodexErr>> {
let error_call = call.clone();
let future =
self.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token);
async move {
match future.await {
Ok(response) => Ok(response.into_response()),
Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
Err(other) => Ok(Self::failure_response(error_call, other)),
}
}
.in_current_span()
}
#[instrument(level = "trace", skip_all)]
pub(crate) fn handle_tool_call_with_source(
self,
@@ -144,33 +131,6 @@ impl ToolCallRuntime {
}
impl ToolCallRuntime {
fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem {
let message = err.to_string();
match call.payload {
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
call_id: call.call_id,
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
call_id: call.call_id,
name: None,
output: codex_protocol::models::FunctionCallOutputPayload {
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
_ => ResponseInputItem::FunctionCallOutput {
call_id: call.call_id,
output: codex_protocol::models::FunctionCallOutputPayload {
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
}
}
fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult {
AnyToolResult {
call_id: call.call_id.clone(),
@@ -195,3 +155,87 @@ impl ToolCallRuntime {
}
}
}
impl KernelToolCall for ToolCall {
fn error_response(&self, message: String) -> ResponseInputItem {
failure_response(self, message)
}
}
impl KernelToolExecutor for ToolCallRuntime {
type Call = ToolCall;
async fn classify_response_item(
&self,
item: ResponseItem,
) -> CodexResult<CompletedResponseItem<Self::Call>> {
match ToolRouter::build_tool_call(self.session.as_ref(), item.clone()).await {
Ok(Some(call)) => Ok(CompletedResponseItem::ToolCall { item, call }),
Ok(None) => Ok(CompletedResponseItem::NonTool(item)),
Err(FunctionCallError::MissingLocalShellCallId) => {
let response = ResponseInputItem::FunctionCallOutput {
call_id: String::new(),
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
"LocalShellCall without call_id or id".to_string(),
),
};
Ok(CompletedResponseItem::ImmediateResponse { item, response })
}
Err(FunctionCallError::RespondToModel(message)) => {
let response = ResponseInputItem::FunctionCallOutput {
call_id: String::new(),
output: codex_protocol::models::FunctionCallOutputPayload::from_text(message),
};
Ok(CompletedResponseItem::ImmediateResponse { item, response })
}
Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
}
}
async fn execute_tool_call(
&self,
call: Self::Call,
cancellation_token: CancellationToken,
) -> Result<ResponseInputItem, ToolCallError> {
match self
.clone()
.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token)
.await
{
Ok(result) => Ok(result.into_response()),
Err(FunctionCallError::RespondToModel(message)) => {
Err(ToolCallError::RespondToModel(message))
}
Err(FunctionCallError::MissingLocalShellCallId) => Err(ToolCallError::RespondToModel(
"LocalShellCall without call_id or id".to_string(),
)),
Err(FunctionCallError::Fatal(message)) => Err(ToolCallError::Fatal(message)),
}
}
}
fn failure_response(call: &ToolCall, message: String) -> ResponseInputItem {
match &call.payload {
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
call_id: call.call_id.clone(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
call_id: call.call_id.clone(),
name: None,
output: codex_protocol::models::FunctionCallOutputPayload {
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
_ => ResponseInputItem::FunctionCallOutput {
call_id: call.call_id.clone(),
output: codex_protocol::models::FunctionCallOutputPayload {
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
}
}
@@ -675,10 +675,13 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_user_proxy_env_when_proxy_inactive()
&HashMap::new(),
&HashMap::new(),
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.output()
.expect("run rewritten command");
let mut process = Command::new(&rewritten[0]);
process.args(&rewritten[1..]);
for key in PROXY_ENV_KEYS {
process.env_remove(key);
}
process.env_remove(PROXY_ACTIVE_ENV_KEY);
let output = process.output().expect("run rewritten command");
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(
@@ -338,23 +338,25 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul
std::fs::write(
&script_path,
format!(
"#!/bin/sh\ncat > {log_path}\nprintf '%s\\n' '{response}'\n",
log_path = shlex::try_quote(log_path.to_string_lossy().as_ref())?,
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print({response:?})
"#,
log_path = log_path.display(),
response = "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}",
),
)
.with_context(|| format!("write hook script to {}", script_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&script_path)
.with_context(|| format!("read hook script metadata from {}", script_path.display()))?
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&script_path, permissions)
.with_context(|| format!("set hook script permissions on {}", script_path.display()))?;
}
let script_path_arg = format!(
"'{}'",
script_path.display().to_string().replace('\'', "'\\''")
);
std::fs::write(
turn_context.config.codex_home.join("hooks.json"),
serde_json::json!({
@@ -362,7 +364,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul
"PermissionRequest": [{
"hooks": [{
"type": "command",
"command": script_path.display().to_string(),
"command": format!("python3 {script_path_arg}"),
}]
}]
}
+10 -8
View File
@@ -384,15 +384,17 @@ impl TestCodexBuilder {
.exec_server_url
.clone()
.or_else(|| test_env.exec_server_url().map(str::to_owned));
let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new(
codex_exec_server::EnvironmentManagerArgs {
let local_runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?;
let environment_manager = Arc::new(
codex_exec_server::EnvironmentManager::create_for_tests(
exec_server_url,
local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?,
},
));
local_runtime_paths,
)
.await,
);
let file_system = test_env.environment().get_filesystem();
let mut workspace_setups = vec![];
swap(&mut self.workspace_setups, &mut workspace_setups);
+5 -8
View File
@@ -240,14 +240,11 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> {
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
SessionSource::Exec,
CollaborationModesConfig::default(),
Arc::new(EnvironmentManager::new(
codex_exec_server::EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?,
},
Arc::new(EnvironmentManager::disabled_for_tests(
ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?,
)),
/*analytics_events_client*/ None,
);
+181 -91
View File
@@ -7,6 +7,9 @@ use crate::ExecutorFileSystem;
use crate::HttpClient;
use crate::client::LazyRemoteExecServerClient;
use crate::client::http_client::ReqwestHttpClient;
use crate::environment_provider::DefaultEnvironmentProvider;
use crate::environment_provider::EnvironmentProvider;
use crate::environment_provider::normalize_exec_server_url;
use crate::local_file_system::LocalFileSystem;
use crate::local_process::LocalProcess;
use crate::process::ExecBackend;
@@ -17,15 +20,13 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
/// Owns the execution/filesystem environments available to the Codex runtime.
///
/// `EnvironmentManager` is a shared registry for concrete environments. It
/// always creates a local environment under [`LOCAL_ENVIRONMENT_ID`]. When
/// `CODEX_EXEC_SERVER_URL` is set to a websocket URL, it also creates a remote
/// environment under [`REMOTE_ENVIRONMENT_ID`] and makes that the default
/// environment. Otherwise the local environment is the default.
/// `EnvironmentManager` is a shared registry for concrete environments. Its
/// default constructor preserves the legacy `CODEX_EXEC_SERVER_URL` behavior
/// while provider-based construction accepts a provider-supplied snapshot.
///
/// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving
/// the default environment unset while still keeping the local environment
/// available for internal callers by id. Callers use
/// the default environment unset while still keeping an explicit local
/// environment available through `local_environment()`. Callers use
/// `default_environment().is_some()` as the signal for model-facing
/// shell/filesystem tool availability.
///
@@ -36,6 +37,7 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
pub struct EnvironmentManager {
default_environment: Option<String>,
environments: HashMap<String, Arc<Environment>>,
local_environment: Arc<Environment>,
}
pub const LOCAL_ENVIRONMENT_ID: &str = "local";
@@ -43,21 +45,12 @@ pub const REMOTE_ENVIRONMENT_ID: &str = "remote";
#[derive(Clone, Debug)]
pub struct EnvironmentManagerArgs {
pub exec_server_url: Option<String>,
pub local_runtime_paths: ExecServerRuntimePaths,
}
impl EnvironmentManagerArgs {
pub fn new(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
exec_server_url: None,
local_runtime_paths,
}
}
pub fn from_env(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(),
local_runtime_paths,
}
}
@@ -72,39 +65,103 @@ impl EnvironmentManager {
LOCAL_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::default_for_tests()),
)]),
local_environment: Arc::new(Environment::default_for_tests()),
}
}
/// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local
/// runtime paths used when creating local filesystem helpers.
pub fn new(args: EnvironmentManagerArgs) -> Self {
/// Builds a test-only manager with environment access disabled.
pub fn disabled_for_tests(local_runtime_paths: ExecServerRuntimePaths) -> Self {
let mut manager = Self::from_environments(HashMap::new(), local_runtime_paths);
manager.default_environment = None;
manager
}
/// Builds a test-only manager from a raw exec-server URL value.
pub async fn create_for_tests(
exec_server_url: Option<String>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Self {
Self::from_default_provider_url(exec_server_url, local_runtime_paths).await
}
/// Builds a manager from `CODEX_EXEC_SERVER_URL` and local runtime paths
/// used when creating local filesystem helpers.
pub async fn new(args: EnvironmentManagerArgs) -> Self {
let EnvironmentManagerArgs {
exec_server_url,
local_runtime_paths,
} = args;
let (exec_server_url, environment_disabled) = normalize_exec_server_url(exec_server_url);
let mut environments = HashMap::from([(
LOCAL_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::local(local_runtime_paths.clone())),
)]);
let default_environment = if environment_disabled {
None
} else {
match exec_server_url {
Some(exec_server_url) => {
environments.insert(
REMOTE_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::remote(exec_server_url, local_runtime_paths)),
);
Some(REMOTE_ENVIRONMENT_ID.to_string())
}
None => Some(LOCAL_ENVIRONMENT_ID.to_string()),
let exec_server_url = std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok();
Self::from_default_provider_url(exec_server_url, local_runtime_paths).await
}
async fn from_default_provider_url(
exec_server_url: Option<String>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Self {
let environment_disabled = normalize_exec_server_url(exec_server_url.clone()).1;
let provider = DefaultEnvironmentProvider::new(exec_server_url);
let provider_environments = provider.environments(&local_runtime_paths);
let mut manager = Self::from_environments(provider_environments, local_runtime_paths);
if environment_disabled {
// TODO: Remove this legacy `CODEX_EXEC_SERVER_URL=none` crutch once
// environment attachment defaulting moves out of EnvironmentManager.
manager.default_environment = None;
}
manager
}
/// Builds a manager from a provider-supplied startup snapshot.
pub async fn from_provider<P>(
provider: &P,
local_runtime_paths: ExecServerRuntimePaths,
) -> Result<Self, ExecServerError>
where
P: EnvironmentProvider + ?Sized,
{
Self::from_provider_environments(
provider.get_environments(&local_runtime_paths).await?,
local_runtime_paths,
)
}
fn from_provider_environments(
environments: HashMap<String, Environment>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Result<Self, ExecServerError> {
for id in environments.keys() {
if id.is_empty() {
return Err(ExecServerError::Protocol(
"environment id cannot be empty".to_string(),
));
}
}
Ok(Self::from_environments(environments, local_runtime_paths))
}
fn from_environments(
environments: HashMap<String, Environment>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Self {
// TODO: Stop deriving a default environment here once omitted
// environment attachment is owned by thread/session setup.
let default_environment = if environments.contains_key(REMOTE_ENVIRONMENT_ID) {
Some(REMOTE_ENVIRONMENT_ID.to_string())
} else if environments.contains_key(LOCAL_ENVIRONMENT_ID) {
Some(LOCAL_ENVIRONMENT_ID.to_string())
} else {
None
};
let local_environment = Arc::new(Environment::local(local_runtime_paths));
let environments = environments
.into_iter()
.map(|(id, environment)| (id, Arc::new(environment)))
.collect();
Self {
default_environment,
environments,
local_environment,
}
}
@@ -122,10 +179,7 @@ impl EnvironmentManager {
/// Returns the local environment instance used for internal runtime work.
pub fn local_environment(&self) -> Arc<Environment> {
match self.get_environment(LOCAL_ENVIRONMENT_ID) {
Some(environment) => environment,
None => unreachable!("EnvironmentManager always has a local environment"),
}
Arc::clone(&self.local_environment)
}
/// Returns a named environment instance.
@@ -204,7 +258,7 @@ impl Environment {
})
}
fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self {
pub(crate) fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
exec_server_url: None,
exec_backend: Arc::new(LocalProcess::default()),
@@ -216,11 +270,7 @@ impl Environment {
}
}
fn remote(exec_server_url: String, local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self::remote_inner(exec_server_url, Some(local_runtime_paths))
}
fn remote_inner(
pub(crate) fn remote_inner(
exec_server_url: String,
local_runtime_paths: Option<ExecServerRuntimePaths>,
) -> Self {
@@ -264,20 +314,13 @@ impl Environment {
}
}
fn normalize_exec_server_url(exec_server_url: Option<String>) -> (Option<String>, bool) {
match exec_server_url.as_deref().map(str::trim) {
None | Some("") => (None, false),
Some(url) if url.eq_ignore_ascii_case("none") => (None, true),
Some(url) => (Some(url.to_string()), false),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use super::Environment;
use super::EnvironmentManager;
use super::EnvironmentManagerArgs;
use super::LOCAL_ENVIRONMENT_ID;
use super::REMOTE_ENVIRONMENT_ID;
use crate::ExecServerRuntimePaths;
@@ -303,10 +346,8 @@ mod tests {
#[tokio::test]
async fn environment_manager_normalizes_empty_url() {
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some(String::new()),
local_runtime_paths: test_runtime_paths(),
});
let manager =
EnvironmentManager::create_for_tests(Some(String::new()), test_runtime_paths()).await;
let environment = manager.default_environment().expect("default environment");
assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
@@ -321,29 +362,23 @@ mod tests {
}
#[tokio::test]
async fn environment_manager_treats_none_value_as_disabled() {
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
async fn disabled_environment_manager_has_no_default_but_keeps_explicit_local_environment() {
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
assert!(
!manager
.get_environment(LOCAL_ENVIRONMENT_ID)
.expect("local environment")
.is_remote()
);
assert!(!manager.local_environment().is_remote());
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
}
#[tokio::test]
async fn environment_manager_reports_remote_url() {
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: test_runtime_paths(),
});
let manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
test_runtime_paths(),
)
.await;
let environment = manager.default_environment().expect("default environment");
assert_eq!(
@@ -364,6 +399,7 @@ mod tests {
.expect("local environment")
.is_remote()
);
assert!(!manager.local_environment().is_remote());
}
#[tokio::test]
@@ -380,45 +416,99 @@ mod tests {
));
}
#[tokio::test]
async fn environment_manager_builds_from_provider_environments() {
let manager = EnvironmentManager::from_environments(
HashMap::from([(
REMOTE_ENVIRONMENT_ID.to_string(),
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
)]),
test_runtime_paths(),
);
assert_eq!(
manager.default_environment_id(),
Some(REMOTE_ENVIRONMENT_ID)
);
assert!(
manager
.get_environment(REMOTE_ENVIRONMENT_ID)
.expect("remote environment")
.is_remote()
);
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert!(!manager.local_environment().is_remote());
}
#[tokio::test]
async fn environment_manager_rejects_empty_environment_id() {
let err = EnvironmentManager::from_provider_environments(
HashMap::from([("".to_string(), Environment::default_for_tests())]),
test_runtime_paths(),
)
.expect_err("empty id should fail");
assert_eq!(
err.to_string(),
"exec-server protocol error: environment id cannot be empty"
);
}
#[tokio::test]
async fn environment_manager_uses_provider_supplied_local_environment() {
let manager = EnvironmentManager::create_for_tests(
/*exec_server_url*/ None,
test_runtime_paths(),
)
.await;
assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
let provider_local = manager
.get_environment(LOCAL_ENVIRONMENT_ID)
.expect("provider local environment");
assert!(!provider_local.is_remote());
assert!(!manager.local_environment().is_remote());
assert!(!Arc::ptr_eq(&provider_local, &manager.local_environment()));
}
#[tokio::test]
async fn environment_manager_carries_local_runtime_paths() {
let runtime_paths = test_runtime_paths();
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: None,
local_runtime_paths: runtime_paths.clone(),
});
let manager = EnvironmentManager::create_for_tests(
/*exec_server_url*/ None,
runtime_paths.clone(),
)
.await;
let environment = manager.default_environment().expect("default environment");
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: environment.exec_server_url().map(str::to_owned),
local_runtime_paths: environment
let manager = EnvironmentManager::create_for_tests(
environment.exec_server_url().map(str::to_owned),
environment
.local_runtime_paths()
.expect("local runtime paths")
.clone(),
});
)
.await;
let environment = manager.default_environment().expect("default environment");
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
}
#[tokio::test]
async fn disabled_environment_manager_has_no_default_environment() {
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
}
#[tokio::test]
async fn environment_manager_keeps_local_lookup_when_default_disabled() {
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
async fn environment_manager_keeps_default_provider_local_lookup_when_default_disabled() {
let manager =
EnvironmentManager::create_for_tests(Some("none".to_string()), test_runtime_paths())
.await;
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
@@ -0,0 +1,172 @@
use std::collections::HashMap;
use async_trait::async_trait;
use crate::Environment;
use crate::ExecServerError;
use crate::ExecServerRuntimePaths;
use crate::environment::CODEX_EXEC_SERVER_URL_ENV_VAR;
use crate::environment::LOCAL_ENVIRONMENT_ID;
use crate::environment::REMOTE_ENVIRONMENT_ID;
/// Lists the concrete environments available to Codex.
///
/// Implementations should return the provider-owned startup snapshot that
/// `EnvironmentManager` will cache. Providers that want the local environment to
/// be addressable by id should include it explicitly in the returned map.
#[async_trait]
pub trait EnvironmentProvider: Send + Sync {
/// Returns the environments available for a new manager.
async fn get_environments(
&self,
local_runtime_paths: &ExecServerRuntimePaths,
) -> Result<HashMap<String, Environment>, ExecServerError>;
}
/// Default provider backed by `CODEX_EXEC_SERVER_URL`.
#[derive(Clone, Debug)]
pub struct DefaultEnvironmentProvider {
exec_server_url: Option<String>,
}
impl DefaultEnvironmentProvider {
/// Builds a provider from an already-read raw `CODEX_EXEC_SERVER_URL` value.
pub fn new(exec_server_url: Option<String>) -> Self {
Self { exec_server_url }
}
/// Builds a provider by reading `CODEX_EXEC_SERVER_URL`.
pub fn from_env() -> Self {
Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok())
}
pub(crate) fn environments(
&self,
local_runtime_paths: &ExecServerRuntimePaths,
) -> HashMap<String, Environment> {
let mut environments = HashMap::from([(
LOCAL_ENVIRONMENT_ID.to_string(),
Environment::local(local_runtime_paths.clone()),
)]);
let exec_server_url = normalize_exec_server_url(self.exec_server_url.clone()).0;
if let Some(exec_server_url) = exec_server_url {
environments.insert(
REMOTE_ENVIRONMENT_ID.to_string(),
Environment::remote_inner(exec_server_url, Some(local_runtime_paths.clone())),
);
}
environments
}
}
#[async_trait]
impl EnvironmentProvider for DefaultEnvironmentProvider {
async fn get_environments(
&self,
local_runtime_paths: &ExecServerRuntimePaths,
) -> Result<HashMap<String, Environment>, ExecServerError> {
Ok(self.environments(local_runtime_paths))
}
}
pub(crate) fn normalize_exec_server_url(exec_server_url: Option<String>) -> (Option<String>, bool) {
match exec_server_url.as_deref().map(str::trim) {
None | Some("") => (None, false),
Some(url) if url.eq_ignore_ascii_case("none") => (None, true),
Some(url) => (Some(url.to_string()), false),
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
use crate::ExecServerRuntimePaths;
fn test_runtime_paths() -> ExecServerRuntimePaths {
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths")
}
#[tokio::test]
async fn default_provider_returns_local_environment_when_url_is_missing() {
let provider = DefaultEnvironmentProvider::new(/*exec_server_url*/ None);
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
assert_eq!(
environments[LOCAL_ENVIRONMENT_ID].local_runtime_paths(),
Some(&runtime_paths)
);
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
}
#[tokio::test]
async fn default_provider_returns_local_environment_when_url_is_empty() {
let provider = DefaultEnvironmentProvider::new(Some(String::new()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
}
#[tokio::test]
async fn default_provider_returns_local_environment_for_none_value() {
let provider = DefaultEnvironmentProvider::new(Some("none".to_string()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
}
#[tokio::test]
async fn default_provider_adds_remote_environment_for_websocket_url() {
let provider = DefaultEnvironmentProvider::new(Some("ws://127.0.0.1:8765".to_string()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
let remote_environment = &environments[REMOTE_ENVIRONMENT_ID];
assert!(remote_environment.is_remote());
assert_eq!(
remote_environment.exec_server_url(),
Some("ws://127.0.0.1:8765")
);
}
#[tokio::test]
async fn default_provider_normalizes_exec_server_url() {
let provider = DefaultEnvironmentProvider::new(Some(" ws://127.0.0.1:8765 ".to_string()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert_eq!(
environments[REMOTE_ENVIRONMENT_ID].exec_server_url(),
Some("ws://127.0.0.1:8765")
);
}
}
+3
View File
@@ -2,6 +2,7 @@ mod client;
mod client_api;
mod connection;
mod environment;
mod environment_provider;
mod fs_helper;
mod fs_helper_main;
mod fs_sandbox;
@@ -38,6 +39,8 @@ pub use environment::EnvironmentManager;
pub use environment::EnvironmentManagerArgs;
pub use environment::LOCAL_ENVIRONMENT_ID;
pub use environment::REMOTE_ENVIRONMENT_ID;
pub use environment_provider::DefaultEnvironmentProvider;
pub use environment_provider::EnvironmentProvider;
pub use fs_helper::CODEX_FS_HELPER_ARG1;
pub use fs_helper_main::main as run_fs_helper_main;
pub use local_file_system::LOCAL_FS;
+22 -1
View File
@@ -35,6 +35,16 @@ pub struct Cli {
#[arg(long = "ignore-rules", global = true, default_value_t = false)]
pub ignore_rules: bool,
/// Legacy compatibility trap for the removed `--full-auto` flag.
#[arg(
long = "full-auto",
hide = true,
global = true,
default_value_t = false,
conflicts_with = "dangerously_bypass_approvals_and_sandbox"
)]
pub removed_full_auto: bool,
/// Path to a JSON Schema file describing the model's final response shape.
#[arg(long = "output-schema", value_name = "FILE")]
pub output_schema: Option<PathBuf>,
@@ -85,6 +95,18 @@ impl std::ops::DerefMut for Cli {
}
}
impl Cli {
pub fn removed_full_auto_warning(&self) -> Option<&'static str> {
if self.removed_full_auto {
return Some(
"warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.",
);
}
None
}
}
#[derive(Debug, Default)]
pub struct ExecSharedCliOptions(SharedCliOptions);
@@ -130,7 +152,6 @@ impl FromArgMatches for ExecSharedCliOptions {
fn mark_exec_global_args(cmd: clap::Command) -> clap::Command {
cmd.mut_arg("model", |arg| arg.global(true))
.mut_arg("full_auto", |arg| arg.global(true))
.mut_arg("dangerously_bypass_approvals_and_sandbox", |arg| {
arg.global(true)
})
+10
View File
@@ -70,3 +70,13 @@ fn parses_config_isolation_flags() {
assert!(cli.ignore_user_config);
assert!(cli.ignore_rules);
}
#[test]
fn removed_full_auto_flag_reports_migration_path() {
let cli = Cli::parse_from(["codex-exec", "--full-auto", "summarize"]);
assert_eq!(
cli.removed_full_auto_warning(),
Some("warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.")
);
}
+10 -5
View File
@@ -219,6 +219,11 @@ fn exec_root_span() -> tracing::Span {
}
pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
#[allow(clippy::print_stderr)]
if let Some(message) = cli.removed_full_auto_warning() {
eprintln!("{message}");
}
if let Err(err) = set_default_originator("codex_exec".to_string()) {
tracing::warn!(?err, "Failed to set codex exec originator override {err:?}");
}
@@ -230,6 +235,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
ephemeral,
ignore_user_config,
ignore_rules,
removed_full_auto,
color,
last_message_file,
json: json_mode,
@@ -245,7 +251,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
oss_provider,
config_profile,
sandbox_mode: sandbox_mode_cli_arg,
full_auto,
dangerously_bypass_approvals_and_sandbox,
cwd,
add_dir,
@@ -272,7 +277,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.with_writer(std::io::stderr)
.with_filter(env_filter);
let sandbox_mode = if full_auto {
let sandbox_mode = if removed_full_auto {
Some(SandboxMode::WorkspaceWrite)
} else if dangerously_bypass_approvals_and_sandbox {
Some(SandboxMode::DangerFullAccess)
@@ -506,9 +511,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
cloud_requirements: run_cloud_requirements,
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: std::sync::Arc::new(EnvironmentManager::new(
EnvironmentManagerArgs::from_env(local_runtime_paths),
)),
environment_manager: std::sync::Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await,
),
config_warnings,
session_source: SessionSource::Exec,
enable_codex_api_key_env: true,
+25
View File
@@ -0,0 +1,25 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-kernel"
version.workspace = true
[lib]
doctest = false
name = "codex_kernel"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
tokio-util = { workspace = true, features = ["rt"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
+17
View File
@@ -0,0 +1,17 @@
mod prompt;
mod sampling;
mod tools;
pub use prompt::Prompt;
pub use prompt::PromptConfig;
pub use sampling::PreparedSamplingRequest;
pub use sampling::SamplingLoopHost;
pub use sampling::SamplingRequestResult;
pub use sampling::run_sampling_request_loop;
pub use tools::CompletedResponseItem;
pub use tools::KernelToolCall;
pub use tools::KernelToolExecutor;
pub use tools::ToolCallError;
pub use tools::ToolConfig;
pub use tools::execute_tool_call_with_default_output;
pub use tools::response_input_to_response_item;
+257
View File
@@ -0,0 +1,257 @@
use crate::ToolConfig;
use codex_protocol::config_types::Personality;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::ResponseItem;
use codex_tools::ToolSpec;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashSet;
/// API request payload for a single model turn.
#[derive(Debug, Clone)]
pub struct Prompt {
pub input: Vec<ResponseItem>,
pub tools: Vec<ToolSpec>,
pub parallel_tool_calls: bool,
pub base_instructions: BaseInstructions,
pub personality: Option<Personality>,
pub output_schema: Option<Value>,
pub output_schema_strict: bool,
}
/// Retry-stable prompt settings used to rebuild a [`Prompt`] after tool calls or stream retries.
#[derive(Debug, Clone)]
pub struct PromptConfig {
pub base_instructions: BaseInstructions,
pub personality: Option<Personality>,
pub output_schema: Option<Value>,
pub output_schema_strict: bool,
}
impl Prompt {
pub fn get_formatted_input(&self) -> Vec<ResponseItem> {
let mut input = self.input.clone();
let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
ToolSpec::Freeform(f) => f.name == "apply_patch",
_ => false,
});
if is_freeform_apply_patch_tool_present {
reserialize_shell_outputs(&mut input);
}
input
}
}
impl PromptConfig {
pub fn build_prompt(&self, input: Vec<ResponseItem>, tool_config: &ToolConfig) -> Prompt {
Prompt {
input,
tools: tool_config.tools.clone(),
parallel_tool_calls: tool_config.parallel_tool_calls,
base_instructions: self.base_instructions.clone(),
personality: self.personality,
output_schema: self.output_schema.clone(),
output_schema_strict: self.output_schema_strict,
}
}
}
impl Default for Prompt {
fn default() -> Self {
Self {
input: Vec::new(),
tools: Vec::new(),
parallel_tool_calls: false,
base_instructions: BaseInstructions::default(),
personality: None,
output_schema: None,
output_schema_strict: true,
}
}
}
impl Default for PromptConfig {
fn default() -> Self {
Self {
base_instructions: BaseInstructions::default(),
personality: None,
output_schema: None,
output_schema_strict: true,
}
}
}
fn reserialize_shell_outputs(items: &mut [ResponseItem]) {
let mut shell_call_ids: HashSet<String> = HashSet::new();
items.iter_mut().for_each(|item| match item {
ResponseItem::LocalShellCall { call_id, id, .. } => {
if let Some(identifier) = call_id.clone().or_else(|| id.clone()) {
shell_call_ids.insert(identifier);
}
}
ResponseItem::CustomToolCall {
id: _,
status: _,
call_id,
name,
input: _,
} => {
if name == "apply_patch" {
shell_call_ids.insert(call_id.clone());
}
}
ResponseItem::FunctionCall { name, call_id, .. }
if is_shell_tool_name(name) || name == "apply_patch" =>
{
shell_call_ids.insert(call_id.clone());
}
ResponseItem::FunctionCallOutput {
call_id, output, ..
}
| ResponseItem::CustomToolCallOutput {
call_id, output, ..
} => {
if shell_call_ids.remove(call_id)
&& let Some(structured) = output
.text_content()
.and_then(parse_structured_shell_output)
{
output.body = FunctionCallOutputBody::Text(structured);
}
}
_ => {}
});
}
fn is_shell_tool_name(name: &str) -> bool {
matches!(name, "shell" | "container.exec")
}
#[derive(Deserialize)]
struct ExecOutputJson {
output: String,
metadata: ExecOutputMetadataJson,
}
#[derive(Deserialize)]
struct ExecOutputMetadataJson {
exit_code: i32,
duration_seconds: f32,
}
fn parse_structured_shell_output(raw: &str) -> Option<String> {
let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?;
Some(build_structured_output(&parsed))
}
fn build_structured_output(parsed: &ExecOutputJson) -> String {
let mut sections = Vec::new();
let exit_code = parsed.metadata.exit_code;
sections.push(format!("Exit code: {exit_code}"));
let duration_seconds = parsed.metadata.duration_seconds;
sections.push(format!("Wall time: {duration_seconds} seconds"));
let mut output = parsed.output.clone();
if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) {
sections.push(format!("Total output lines: {total_lines}"));
output = stripped.to_string();
}
sections.push("Output:".to_string());
sections.push(output);
sections.join("\n")
}
fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
let after_prefix = output.strip_prefix("Total output lines: ")?;
let (total_segment, remainder) = after_prefix.split_once('\n')?;
let total_lines = total_segment.parse::<u32>().ok()?;
let remainder = remainder.strip_prefix('\n').unwrap_or(remainder);
Some((remainder, total_lines))
}
#[cfg(test)]
mod tests {
use super::Prompt;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use codex_tools::FreeformTool;
use codex_tools::FreeformToolFormat;
use codex_tools::ToolSpec;
use pretty_assertions::assert_eq;
#[test]
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
let prompt = Prompt {
input: vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
name: None,
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
],
tools: vec![ToolSpec::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description: "patch".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "patch".to_string(),
},
})],
..Prompt::default()
};
assert_eq!(
prompt.get_formatted_input(),
vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
name: None,
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
]
);
}
}
+170
View File
@@ -0,0 +1,170 @@
use crate::KernelToolExecutor;
use crate::Prompt;
use crate::PromptConfig;
use crate::ToolConfig;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::RateLimitSnapshot;
use rand::Rng;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;
/// Host-facing bundle created once before the kernel begins retrying a logical sampling request.
///
/// Implementations should place retry-stable state here so retries can rebuild prompts without
/// repeating expensive setup work such as tool routing or per-turn worker startup.
pub struct PreparedSamplingRequest<Runtime, Tools> {
pub prompt_config: PromptConfig,
pub tool_config: ToolConfig,
pub runtime: Runtime,
pub tools: Tools,
}
/// Final outcome of a logical sampling request after the model/tool loop reaches a stable state.
#[derive(Debug)]
pub struct SamplingRequestResult {
pub needs_follow_up: bool,
pub last_agent_message: Option<String>,
}
/// Host adapter for the kernel's retryable sampling loop.
///
/// Implementations provide the concrete model transport, tool runtime, history access, and
/// user-facing retry notifications while allowing `codex-kernel` to stay agnostic of local
/// filesystem, network, or tool-execution details.
#[allow(async_fn_in_trait)]
pub trait SamplingLoopHost {
type ClientSession;
type Runtime;
type Tools: KernelToolExecutor;
async fn prepare_sampling_request(
&self,
input: &[ResponseItem],
cancellation_token: &CancellationToken,
) -> Result<PreparedSamplingRequest<Self::Runtime, Self::Tools>>;
async fn history_prompt_input(&self) -> Vec<ResponseItem>;
async fn run_single_sampling_request(
&self,
runtime: &Self::Runtime,
tools: &Self::Tools,
client_session: &mut Self::ClientSession,
prompt: &Prompt,
cancellation_token: CancellationToken,
) -> Result<SamplingRequestResult>;
fn stream_max_retries(&self) -> u64;
fn try_switch_fallback_transport(&self, client_session: &mut Self::ClientSession) -> bool;
fn should_notify_stream_retry(&self, retries: u64, err: &CodexErr) -> bool;
async fn handle_context_window_exceeded(&self);
async fn handle_usage_limit_reached(&self, rate_limits: Option<RateLimitSnapshot>);
async fn notify_fallback_to_http(&self, err: &CodexErr);
async fn notify_stream_retry(
&self,
retries: u64,
max_retries: u64,
delay: Duration,
err: &CodexErr,
);
}
pub async fn run_sampling_request_loop<Host>(
host: &Host,
client_session: &mut Host::ClientSession,
initial_input: Vec<ResponseItem>,
cancellation_token: CancellationToken,
) -> Result<SamplingRequestResult>
where
Host: SamplingLoopHost,
{
let prepared = host
.prepare_sampling_request(initial_input.as_slice(), &cancellation_token)
.await?;
let mut retries = 0;
let mut initial_input = Some(initial_input);
loop {
let prompt_input = if let Some(input) = initial_input.take() {
input
} else {
host.history_prompt_input().await
};
let prompt = prepared
.prompt_config
.build_prompt(prompt_input, &prepared.tool_config);
let err = match host
.run_single_sampling_request(
&prepared.runtime,
&prepared.tools,
client_session,
&prompt,
cancellation_token.child_token(),
)
.await
{
Ok(output) => {
return Ok(output);
}
Err(CodexErr::ContextWindowExceeded) => {
host.handle_context_window_exceeded().await;
return Err(CodexErr::ContextWindowExceeded);
}
Err(CodexErr::UsageLimitReached(error)) => {
host.handle_usage_limit_reached(
error
.rate_limits
.as_ref()
.map(|snapshot| (**snapshot).clone()),
)
.await;
return Err(CodexErr::UsageLimitReached(error));
}
Err(err) => err,
};
if !err.is_retryable() {
return Err(err);
}
let max_retries = host.stream_max_retries();
if retries >= max_retries && host.try_switch_fallback_transport(client_session) {
host.notify_fallback_to_http(&err).await;
retries = 0;
continue;
}
if retries < max_retries {
retries += 1;
let delay = match &err {
CodexErr::Stream(_, requested_delay) => {
requested_delay.unwrap_or_else(|| backoff(retries))
}
_ => backoff(retries),
};
if host.should_notify_stream_retry(retries, &err) {
host.notify_stream_retry(retries, max_retries, delay, &err)
.await;
}
tokio::time::sleep(delay).await;
} else {
return Err(err);
}
}
}
fn backoff(attempt: u64) -> Duration {
let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
let jitter = rand::rng().random_range(0.9..1.1);
Duration::from_millis((base as f64 * jitter) as u64)
}
+226
View File
@@ -0,0 +1,226 @@
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_tools::ToolSpec;
use std::fmt;
use tokio_util::sync::CancellationToken;
/// Retry-stable tool registration for a logical model request.
///
/// The host chooses which tools are exposed to the model and whether the model
/// may issue them in parallel. `codex-kernel` treats this as opaque model-facing
/// configuration and uses it to rebuild prompts across retries.
#[derive(Debug, Clone, Default)]
pub struct ToolConfig {
pub tools: Vec<ToolSpec>,
pub parallel_tool_calls: bool,
}
/// Metadata a host-specific tool call must expose so `codex-kernel` can
/// synthesize a model-visible failure response when execution cannot complete.
pub trait KernelToolCall: Clone + Send + Sync {
fn error_response(&self, message: String) -> ResponseInputItem;
}
/// Non-fatal tool execution failures are surfaced back to the model as a
/// complementary output item, while fatal failures abort the turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolCallError {
RespondToModel(String),
Fatal(String),
}
impl fmt::Display for ToolCallError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RespondToModel(message) => write!(f, "{message}"),
Self::Fatal(message) => write!(f, "Fatal error: {message}"),
}
}
}
/// Classification of a completed model output item relative to the host's tool
/// catalog.
pub enum CompletedResponseItem<Call> {
NonTool(ResponseItem),
ToolCall {
item: ResponseItem,
call: Call,
},
ImmediateResponse {
item: ResponseItem,
response: ResponseInputItem,
},
}
/// Host-provided tool adapter used by `codex-kernel`'s default model/tool loop.
///
/// Implementations own tool-call parsing and execution, while
/// `codex-kernel` owns the generic behavior of attempting a complementary
/// tool output item for every tool call the model emits.
#[allow(async_fn_in_trait)]
pub trait KernelToolExecutor {
type Call: KernelToolCall;
async fn classify_response_item(
&self,
item: ResponseItem,
) -> Result<CompletedResponseItem<Self::Call>>;
async fn execute_tool_call(
&self,
call: Self::Call,
cancellation_token: CancellationToken,
) -> std::result::Result<ResponseInputItem, ToolCallError>;
}
pub async fn execute_tool_call_with_default_output<Executor>(
executor: &Executor,
call: Executor::Call,
cancellation_token: CancellationToken,
) -> Result<ResponseInputItem>
where
Executor: KernelToolExecutor,
{
match executor
.execute_tool_call(call.clone(), cancellation_token)
.await
{
Ok(response) => Ok(response),
Err(ToolCallError::RespondToModel(message)) => Ok(call.error_response(message)),
Err(ToolCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
}
}
pub fn response_input_to_response_item(input: &ResponseInputItem) -> Option<ResponseItem> {
match input {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
Some(ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output: output.clone(),
})
}
ResponseInputItem::CustomToolCallOutput {
call_id,
name,
output,
} => Some(ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
name: name.clone(),
output: output.clone(),
}),
ResponseInputItem::McpToolCallOutput { call_id, output } => {
let output = output.as_function_call_output_payload();
Some(ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output,
})
}
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Some(ResponseItem::ToolSearchOutput {
call_id: Some(call_id.clone()),
status: status.clone(),
execution: execution.clone(),
tools: tools.clone(),
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::CompletedResponseItem;
use super::KernelToolCall;
use super::KernelToolExecutor;
use super::ToolCallError;
use super::execute_tool_call_with_default_output;
use codex_protocol::error::Result;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug, PartialEq, Eq)]
struct TestCall {
call_id: String,
}
impl KernelToolCall for TestCall {
fn error_response(&self, message: String) -> ResponseInputItem {
ResponseInputItem::FunctionCallOutput {
call_id: self.call_id.clone(),
output: FunctionCallOutputPayload::from_text(message),
}
}
}
struct TestExecutor;
impl KernelToolExecutor for TestExecutor {
type Call = TestCall;
async fn classify_response_item(
&self,
item: ResponseItem,
) -> Result<CompletedResponseItem<Self::Call>> {
Ok(CompletedResponseItem::NonTool(item))
}
async fn execute_tool_call(
&self,
call: Self::Call,
_cancellation_token: CancellationToken,
) -> std::result::Result<ResponseInputItem, ToolCallError> {
match call.call_id.as_str() {
"ok" => Ok(ResponseInputItem::FunctionCallOutput {
call_id: call.call_id,
output: FunctionCallOutputPayload::from_text("done".to_string()),
}),
"retry" => Err(ToolCallError::RespondToModel("try again".to_string())),
_ => Err(ToolCallError::Fatal("boom".to_string())),
}
}
}
#[tokio::test]
async fn execute_tool_call_with_default_output_surfaces_non_fatal_errors() {
let response = execute_tool_call_with_default_output(
&TestExecutor,
TestCall {
call_id: "retry".to_string(),
},
CancellationToken::new(),
)
.await
.expect("tool response");
assert_eq!(
response,
ResponseInputItem::FunctionCallOutput {
call_id: "retry".to_string(),
output: FunctionCallOutputPayload::from_text("try again".to_string()),
}
);
}
#[tokio::test]
async fn execute_tool_call_with_default_output_propagates_fatal_errors() {
let err = execute_tool_call_with_default_output(
&TestExecutor,
TestCall {
call_id: "fatal".to_string(),
},
CancellationToken::new(),
)
.await
.expect_err("fatal tool error");
assert_eq!(err.to_string(), "Fatal error: boom");
}
}
+9 -6
View File
@@ -60,12 +60,15 @@ pub async fn run_main(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
) -> IoResult<()> {
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
let environment_manager = Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
))
.await,
);
// Parse CLI overrides once and derive the base Config eagerly so later
// components do not need to work with raw TOML values.
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
+2 -9
View File
@@ -139,7 +139,7 @@ pub fn create_send_message_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Send a string message to an existing agent without triggering a new turn."
description: "Send a message to an existing agent. The message will be delivered promptly. Does not trigger a new turn."
.to_string(),
strict: false,
defer_loading: None,
@@ -166,18 +166,11 @@ pub fn create_followup_task_tool() -> ToolSpec {
"Message text to send to the target agent.".to_string(),
)),
),
(
"interrupt".to_string(),
JsonSchema::boolean(Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message; if the target is already running, it starts the target's next turn after the current turn completes."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "followup_task".to_string(),
description: "Send a string message to an existing non-root agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. If interrupt=false and the target's turn has not completed, the message is queued and starts the target's next turn after the current turn completes."
description: "Send a message to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes."
.to_string(),
strict: false,
defer_loading: None,
-21
View File
@@ -131,7 +131,6 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
#[test]
fn send_message_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
@@ -151,10 +150,6 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
description,
"Send a string message to an existing agent without triggering a new turn."
);
assert_eq!(
properties
.get("target")
@@ -171,7 +166,6 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
#[test]
fn followup_task_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
@@ -189,22 +183,7 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() {
.expect("followup_task should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert!(description.contains(
"Send a string message to an existing non-root agent and trigger a turn in the target."
));
assert!(description.contains(
"If interrupt=false and the target's turn has not completed, the message is queued"
));
assert_eq!(
properties
.get("interrupt")
.and_then(|schema| schema.description.as_deref()),
Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message; if the target is already running, it starts the target's next turn after the current turn completes."
)
);
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])
@@ -191,7 +191,7 @@ async fn live_app_server_warning_notification_renders_message() {
chat.handle_server_notification(
ServerNotification::Warning(WarningNotification {
thread_id: None,
message: "Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list.".to_string(),
message: "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list.".to_string(),
}),
/*replay_kind*/ None,
);
@@ -201,7 +201,7 @@ async fn live_app_server_warning_notification_renders_message() {
let rendered = lines_to_single_string(&cells[0]);
let normalized = rendered.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
normalized.contains("Warning: Exceeded skills context budget of 2%."),
normalized.contains("Exceeded skills context budget of 2%."),
"expected warning notification message, got {rendered}"
);
assert!(
+18 -20
View File
@@ -697,12 +697,7 @@ pub async fn run_main(
.cwd
.clone()
.filter(|_| matches!(app_server_target, AppServerTarget::Remote { .. }));
let (sandbox_mode, approval_policy) = if cli.full_auto {
(
Some(SandboxMode::WorkspaceWrite),
Some(AskForApproval::OnRequest),
)
} else if cli.dangerously_bypass_approvals_and_sandbox {
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
(
Some(SandboxMode::DangerFullAccess),
Some(AskForApproval::Never),
@@ -746,12 +741,15 @@ pub async fn run_main(
}
};
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
let environment_manager = Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
))
.await,
);
let cwd = cli.cwd.clone();
let config_cwd =
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
@@ -2017,14 +2015,14 @@ mod tests {
Path::new("/definitely/not/local/to/this/test")
};
let target = AppServerTarget::Embedded;
let environment_manager =
EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)?,
});
let environment_manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)?,
)
.await;
let config_cwd =
config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?;
+3 -15
View File
@@ -38,17 +38,12 @@ pub struct SharedCliOptions {
#[arg(long = "sandbox", short = 's')]
pub sandbox_mode: Option<SandboxModeCliArg>,
/// Convenience alias for low-friction sandboxed automatic execution.
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
/// Skip all confirmation prompts and execute commands without sandboxing.
/// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed.
#[arg(
long = "dangerously-bypass-approvals-and-sandbox",
alias = "yolo",
default_value_t = false,
conflicts_with = "full_auto"
default_value_t = false
)]
pub dangerously_bypass_approvals_and_sandbox: bool,
@@ -63,9 +58,8 @@ pub struct SharedCliOptions {
impl SharedCliOptions {
pub fn inherit_exec_root_options(&mut self, root: &Self) {
let self_selected_sandbox_mode = self.sandbox_mode.is_some()
|| self.full_auto
|| self.dangerously_bypass_approvals_and_sandbox;
let self_selected_sandbox_mode =
self.sandbox_mode.is_some() || self.dangerously_bypass_approvals_and_sandbox;
let Self {
images,
model,
@@ -73,7 +67,6 @@ impl SharedCliOptions {
oss_provider,
config_profile,
sandbox_mode,
full_auto,
dangerously_bypass_approvals_and_sandbox,
cwd,
add_dir,
@@ -85,7 +78,6 @@ impl SharedCliOptions {
oss_provider: root_oss_provider,
config_profile: root_config_profile,
sandbox_mode: root_sandbox_mode,
full_auto: root_full_auto,
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
cwd: root_cwd,
add_dir: root_add_dir,
@@ -107,7 +99,6 @@ impl SharedCliOptions {
*sandbox_mode = *root_sandbox_mode;
}
if !self_selected_sandbox_mode {
*full_auto = *root_full_auto;
*dangerously_bypass_approvals_and_sandbox =
*root_dangerously_bypass_approvals_and_sandbox;
}
@@ -128,7 +119,6 @@ impl SharedCliOptions {
pub fn apply_subcommand_overrides(&mut self, subcommand: Self) {
let subcommand_selected_sandbox_mode = subcommand.sandbox_mode.is_some()
|| subcommand.full_auto
|| subcommand.dangerously_bypass_approvals_and_sandbox;
let Self {
images,
@@ -137,7 +127,6 @@ impl SharedCliOptions {
oss_provider,
config_profile,
sandbox_mode,
full_auto,
dangerously_bypass_approvals_and_sandbox,
cwd,
add_dir,
@@ -157,7 +146,6 @@ impl SharedCliOptions {
}
if subcommand_selected_sandbox_mode {
self.sandbox_mode = sandbox_mode;
self.full_auto = full_auto;
self.dangerously_bypass_approvals_and_sandbox =
dangerously_bypass_approvals_and_sandbox;
}
@@ -74,11 +74,13 @@ def run_sbx(
env.update(ENV_BASE)
if env_extra:
env.update(env_extra)
# Map policy to codex CLI flags
# read-only => default; workspace-write => --full-auto
# Map policy to codex CLI overrides.
# read-only => default; workspace-write => legacy sandbox_mode override
if policy not in ("read-only", "workspace-write"):
raise ValueError(f"unknown policy: {policy}")
policy_flags: List[str] = ["--full-auto"] if policy == "workspace-write" else []
policy_flags: List[str] = (
["-c", 'sandbox_mode="workspace-write"'] if policy == "workspace-write" else []
)
overrides: List[str] = []
if policy == "workspace-write" and additional_root is not None: