Merge branch 'main' into jif/wal-config

This commit is contained in:
jif
2026-06-05 15:28:04 +02:00
committed by GitHub
299 changed files with 8041 additions and 2626 deletions
+32 -7
View File
@@ -109,7 +109,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item:
_ => false,
},
RolloutItem::ResponseItem(
ResponseItem::Reasoning { .. }
ResponseItem::AgentMessage { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
@@ -715,7 +716,12 @@ impl AgentControl {
agent_id: ThreadId,
initial_operation: Op,
) -> CodexResult<String> {
let last_task_message = render_input_preview(&initial_operation);
let last_task_message = match &initial_operation {
Op::InterAgentCommunication { communication } => {
last_task_message_from_communication(communication)
}
_ => non_empty_task_message(render_input_preview(&initial_operation)),
};
let state = self.upgrade()?;
let result = self
.handle_thread_request_result(
@@ -725,8 +731,12 @@ impl AgentControl {
)
.await;
if result.is_ok() {
self.state
.update_last_task_message(agent_id, last_task_message);
match last_task_message {
Some(last_task_message) => self
.state
.update_last_task_message(agent_id, last_task_message),
None => self.state.clear_last_task_message(agent_id),
}
}
result
}
@@ -736,7 +746,7 @@ impl AgentControl {
agent_id: ThreadId,
communication: InterAgentCommunication,
) -> CodexResult<String> {
let last_task_message = communication.content.clone();
let last_task_message = last_task_message_from_communication(&communication);
let state = self.upgrade()?;
let result = self
.handle_thread_request_result(
@@ -748,8 +758,12 @@ impl AgentControl {
)
.await;
if result.is_ok() {
self.state
.update_last_task_message(agent_id, last_task_message);
match last_task_message {
Some(last_task_message) => self
.state
.update_last_task_message(agent_id, last_task_message),
None => self.state.clear_last_task_message(agent_id),
}
}
result
}
@@ -1329,6 +1343,17 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
}
}
fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option<String> {
if communication.encrypted_content.is_some() {
return None;
}
non_empty_task_message(communication.content.clone())
}
fn non_empty_task_message(message: String) -> Option<String> {
(!message.is_empty()).then_some(message)
}
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth),
+56
View File
@@ -527,6 +527,62 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
));
}
#[tokio::test]
async fn encrypted_inter_agent_communication_clears_existing_last_task_message() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, _) = harness.start_thread().await;
let agent_path = AgentPath::try_from("/root/worker").expect("agent path");
let spawned_agent = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("old plaintext task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(agent_path.clone()),
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
parent_thread_id: Some(parent_thread_id),
..Default::default()
},
)
.await
.expect("spawn_agent should succeed");
assert_eq!(
harness
.control
.state
.agent_metadata_for_thread(spawned_agent.thread_id)
.and_then(|metadata| metadata.last_task_message),
Some("old plaintext task".to_string())
);
let communication = InterAgentCommunication::new_encrypted(
AgentPath::root(),
agent_path,
Vec::new(),
"encrypted-task".to_string(),
/*trigger_turn*/ true,
);
harness
.control
.send_inter_agent_communication(spawned_agent.thread_id, communication)
.await
.expect("send_inter_agent_communication should succeed");
assert_eq!(
harness
.control
.state
.agent_metadata_for_thread(spawned_agent.thread_id)
.and_then(|metadata| metadata.last_task_message),
None
);
}
#[tokio::test]
async fn spawn_agent_creates_thread_and_sends_prompt() {
let harness = AgentControlHarness::new().await;
+14
View File
@@ -180,6 +180,20 @@ impl AgentRegistry {
}
}
pub(crate) fn clear_last_task_message(&self, thread_id: ThreadId) {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(metadata) = active_agents
.agent_tree
.values_mut()
.find(|metadata| metadata.agent_id == Some(thread_id))
{
metadata.last_task_message = None;
}
}
fn register_spawned_thread(&self, agent_metadata: AgentMetadata) {
let Some(thread_id) = agent_metadata.agent_id else {
return;
+135 -70
View File
@@ -23,11 +23,9 @@ use codex_config::merge_toml_values;
use codex_config::project_root_markers_from_config;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize as normalize_path;
use std::io;
use toml::Value as TomlValue;
use tracing::error;
@@ -37,8 +35,8 @@ pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md";
/// Preferred local override for AGENTS.md instructions.
pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
/// When both `Config::instructions` and AGENTS.md docs are present, they will
/// be concatenated with the following separator.
/// When both user and project AGENTS.md docs are present, they will be
/// concatenated with the following separator.
const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
/// Resolves AGENTS.md files into model-visible user instructions and source
@@ -47,11 +45,6 @@ pub struct AgentsMdManager<'a> {
config: &'a Config,
}
pub(crate) struct LoadedAgentsMd {
pub(crate) contents: String,
pub(crate) path: AbsolutePathBuf,
}
impl<'a> AgentsMdManager<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
@@ -81,10 +74,7 @@ impl<'a> AgentsMdManager<'a> {
let contents = String::from_utf8_lossy(&data);
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(LoadedAgentsMd {
contents: trimmed.to_string(),
path,
});
return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path));
}
}
None
@@ -94,10 +84,10 @@ impl<'a> AgentsMdManager<'a> {
/// single model-visible instruction string.
pub(crate) async fn user_instructions(
&self,
environment: Option<&Environment>,
environment: &Environment,
startup_warnings: &mut Vec<String>,
) -> Option<String> {
let fs = environment?.get_filesystem();
) -> Option<LoadedAgentsMd> {
let fs = environment.get_filesystem();
self.user_instructions_with_fs(fs.as_ref(), startup_warnings)
.await
}
@@ -106,22 +96,13 @@ impl<'a> AgentsMdManager<'a> {
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> Option<String> {
) -> Option<LoadedAgentsMd> {
let agents_md_docs = self.read_agents_md(fs, startup_warnings).await;
let mut output = String::new();
if let Some(instructions) = self.config.user_instructions.clone() {
output.push_str(&instructions);
}
let mut loaded = self.config.user_instructions.clone().unwrap_or_default();
match agents_md_docs {
Ok(Some(docs)) => {
if !output.is_empty() {
output.push_str(AGENTS_MD_SEPARATOR);
}
output.push_str(&docs);
}
Ok(Some(docs)) => loaded.entries.extend(docs.entries),
Ok(None) => {}
Err(e) => {
error!("error trying to find AGENTS.md docs: {e:#}");
@@ -129,50 +110,26 @@ impl<'a> AgentsMdManager<'a> {
};
if self.config.features.enabled(Feature::ChildAgentsMd) {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(HIERARCHICAL_AGENTS_MESSAGE);
loaded.entries.push(InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
});
}
if !output.is_empty() {
Some(output)
} else {
None
}
}
/// Returns all instruction source files included in the current config.
pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec<AbsolutePathBuf> {
let mut global_instruction_warnings = Vec::new();
let mut paths = Self::load_global_instructions(
LOCAL_FS.as_ref(),
Some(&self.config.codex_home),
&mut global_instruction_warnings,
)
.await
.map(|loaded| vec![loaded.path])
.unwrap_or_default();
match self.agents_md_paths(fs).await {
Ok(agents_md_paths) => paths.extend(agents_md_paths),
Err(err) => {
tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources");
}
}
paths
(!loaded.is_empty()).then_some(loaded)
}
/// Attempt to locate and load AGENTS.md documentation.
///
/// On success returns `Ok(Some(contents))` where `contents` is the
/// concatenation of all discovered docs. If no documentation file is found
/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as
/// `Err` so callers can decide how to handle them.
/// On success returns `Ok(Some(loaded))` where `loaded` contains every
/// discovered doc. If no documentation file is found the function returns
/// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can
/// decide how to handle them.
async fn read_agents_md(
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> io::Result<Option<String>> {
) -> io::Result<Option<LoadedAgentsMd>> {
let max_total = self.config.project_doc_max_bytes;
if max_total == 0 {
@@ -185,7 +142,7 @@ impl<'a> AgentsMdManager<'a> {
}
let mut remaining: u64 = max_total as u64;
let mut parts: Vec<String> = Vec::new();
let mut loaded = LoadedAgentsMd::default();
for p in paths {
if remaining == 0 {
@@ -221,15 +178,18 @@ impl<'a> AgentsMdManager<'a> {
let text = String::from_utf8_lossy(&data).to_string();
if !text.trim().is_empty() {
parts.push(text);
loaded.entries.push(InstructionEntry {
contents: text,
provenance: InstructionProvenance::Project(p),
});
remaining = remaining.saturating_sub(data.len() as u64);
}
}
if parts.is_empty() {
if loaded.is_empty() {
Ok(None)
} else {
Ok(Some(parts.join("\n\n")))
Ok(Some(loaded))
}
}
@@ -246,10 +206,7 @@ impl<'a> AgentsMdManager<'a> {
return Ok(Vec::new());
}
let mut dir = self.config.cwd.clone();
if let Ok(canon) = normalize_path(&dir) {
dir = AbsolutePathBuf::try_from(canon)?;
}
let dir = self.config.cwd.clone();
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in self.config.config_layer_stack.get_layers(
@@ -348,6 +305,114 @@ impl<'a> AgentsMdManager<'a> {
}
}
/// Model-visible instructions loaded from AGENTS.md files and internal
/// guidance.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LoadedAgentsMd {
/// Ordered instructions and their provenance.
entries: Vec<InstructionEntry>,
}
impl LoadedAgentsMd {
/// Creates loaded instructions containing one user-level AGENTS.md entry.
pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self {
if contents.trim().is_empty() {
return Self::default();
}
Self {
entries: vec![InstructionEntry {
contents,
provenance: InstructionProvenance::User(path),
}],
}
}
/// Creates source-less user instructions for tests.
///
/// This cannot be gated with `#[cfg(test)]` because integration tests
/// compile `codex-core` as a normal dependency without that configuration.
pub fn from_text_for_testing(contents: impl Into<String>) -> Self {
let contents = contents.into();
if contents.trim().is_empty() {
return Self::default();
}
Self {
entries: vec![InstructionEntry {
contents,
provenance: InstructionProvenance::Internal,
}],
}
}
fn is_empty(&self) -> bool {
self.entries
.iter()
.all(|entry| entry.contents.trim().is_empty())
}
/// Returns the concatenated model-visible instruction text.
pub fn text(&self) -> String {
let mut output = String::new();
let mut previous_provenance: Option<&InstructionProvenance> = None;
for entry in &self.entries {
if let Some(previous_provenance) = previous_provenance {
// The project-doc marker tells the model where workspace-scoped
// instructions begin, so it is only needed on the transition
// from user or internal instructions to project instructions.
let separator = match (previous_provenance, &entry.provenance) {
(
InstructionProvenance::User(_) | InstructionProvenance::Internal,
InstructionProvenance::Project(_),
) => AGENTS_MD_SEPARATOR,
_ => "\n\n",
};
output.push_str(separator);
}
output.push_str(&entry.contents);
previous_provenance = Some(&entry.provenance);
}
output
}
/// Returns the AGENTS.md files that supplied instruction entries.
pub fn sources(&self) -> impl Iterator<Item = &AbsolutePathBuf> {
self.entries
.iter()
.filter_map(|entry| entry.provenance.path())
}
}
/// One model-visible instruction and its provenance.
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstructionEntry {
/// Model-visible instruction text.
contents: String,
/// Origin of the instruction.
provenance: InstructionProvenance,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum InstructionProvenance {
/// User-level instructions, normally loaded from CODEX_HOME.
User(AbsolutePathBuf),
/// Workspace instructions discovered from project AGENTS.md files.
Project(AbsolutePathBuf),
/// Instructions without a file source, including internally defined guidance.
Internal,
}
impl InstructionProvenance {
fn path(&self) -> Option<&AbsolutePathBuf> {
match self {
Self::User(path) | Self::Project(path) => Some(path),
Self::Internal => None,
}
}
}
fn warn_invalid_utf8(
path: &AbsolutePathBuf,
data: &[u8],
+235 -34
View File
@@ -5,6 +5,7 @@ use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::TempDirExt;
use core_test_support::create_directory_symlink;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
@@ -16,6 +17,7 @@ async fn get_user_instructions(config: &Config) -> Option<String> {
AgentsMdManager::new(config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.map(|loaded| loaded.text())
}
async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
@@ -53,7 +55,12 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -
config.cwd = root.abs();
config.project_doc_max_bytes = limit;
config.user_instructions = instructions.map(ToOwned::to_owned);
config.user_instructions = instructions.map(|text| {
LoadedAgentsMd::new_user(
text.to_owned(),
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
)
});
config
}
@@ -96,7 +103,12 @@ async fn make_config_with_project_root_markers(
config.cwd = root.abs();
config.project_doc_max_bytes = limit;
config.user_instructions = instructions.map(ToOwned::to_owned);
config.user_instructions = instructions.map(|text| {
LoadedAgentsMd::new_user(
text.to_owned(),
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
)
});
config
}
@@ -115,17 +127,46 @@ async fn no_doc_file_returns_none() {
assert!(res.is_none(), "Expected None when AGENTS.md is absent");
}
#[tokio::test]
async fn no_environment_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
#[test]
fn empty_loaded_instructions_are_empty() {
let source =
AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path");
let mut warnings = Vec::new();
let res = AgentsMdManager::new(&config)
.user_instructions(/*environment*/ None, &mut warnings)
.await;
assert_eq!(
LoadedAgentsMd::new_user(String::new(), source.clone()),
LoadedAgentsMd::default()
);
assert_eq!(
LoadedAgentsMd::new_user(" \n\t".to_string(), source),
LoadedAgentsMd::default()
);
assert_eq!(
LoadedAgentsMd::from_text_for_testing(String::new()),
LoadedAgentsMd::default()
);
assert_eq!(
LoadedAgentsMd::from_text_for_testing(" \n\t"),
LoadedAgentsMd::default()
);
}
assert_eq!(res, None);
#[test]
fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() {
let empty = LoadedAgentsMd {
entries: vec![InstructionEntry {
contents: String::new(),
provenance: InstructionProvenance::Internal,
}],
};
let whitespace = LoadedAgentsMd {
entries: vec![InstructionEntry {
contents: " \n\t".to_string(),
provenance: InstructionProvenance::Internal,
}],
};
assert!(empty.is_empty());
assert!(whitespace.is_empty());
}
/// Small file within the byte-limit is returned unmodified.
@@ -161,8 +202,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
.await
.expect("global doc expected");
assert_eq!(loaded.contents, "global\u{FFFD} doc");
assert_eq!(loaded.path, path);
assert_eq!(
loaded,
LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone())
);
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
}
@@ -177,11 +220,11 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
let res = AgentsMdManager::new(&config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("doc expected");
.expect("doc expected")
.text();
assert_eq!(res, "project\u{FFFD} doc");
let canonical_path = dunce::canonicalize(&path).expect("canonical doc path");
assert_invalid_utf8_warning(&warnings, "Project", &canonical_path);
assert_invalid_utf8_warning(&warnings, "Project", config.cwd.join("AGENTS.md").as_path());
}
/// Oversize file is truncated to `project_doc_max_bytes`.
@@ -272,6 +315,30 @@ async fn merges_existing_instructions_with_agents_md() {
assert_eq!(res, expected);
}
#[tokio::test]
async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
"user instructions".to_string(),
));
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
assert_eq!(
loaded.text(),
format!("user instructions{AGENTS_MD_SEPARATOR}project doc")
);
assert_eq!(loaded.sources().collect::<Vec<_>>(), vec![&project_agents]);
}
/// If there are existing system instructions but AGENTS.md docs are
/// missing we expect the original instructions to be returned unchanged.
#[tokio::test]
@@ -310,8 +377,32 @@ async fn concatenates_root_and_cwd_docs() {
let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await;
cfg.cwd = nested.abs();
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "root doc\n\ncrate doc");
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("doc expected");
let root_agents = repo.path().join("AGENTS.md").abs();
let crate_agents = cfg.cwd.join("AGENTS.md");
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "root doc".to_string(),
provenance: InstructionProvenance::Project(root_agents.clone()),
},
InstructionEntry {
contents: "crate doc".to_string(),
provenance: InstructionProvenance::Project(crate_agents.clone()),
},
],
};
assert_eq!(loaded, expected);
assert_eq!(loaded.text(), "root doc\n\ncrate doc");
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&root_agents, &crate_agents]
);
}
#[tokio::test]
@@ -334,14 +425,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
cfg.cwd = nested.abs();
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
let expected_parent = AbsolutePathBuf::try_from(
dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"),
)
.expect("absolute parent doc path");
let expected_child = AbsolutePathBuf::try_from(
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"),
)
.expect("absolute child doc path");
let expected_parent = root.path().join("AGENTS.md").abs();
let expected_child = cfg.cwd.join("AGENTS.md");
assert_eq!(discovery.len(), 2);
assert_eq!(discovery[0], expected_parent);
assert_eq!(discovery[1], expected_child);
@@ -350,6 +435,58 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
assert_eq!(res, "parent doc\n\nchild doc");
}
#[tokio::test]
async fn agents_md_paths_preserve_symlinked_cwd() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = tmp.path().join("target");
fs::create_dir(&target).unwrap();
fs::write(target.join("AGENTS.md"), "project doc").unwrap();
let linked_cwd = tmp.path().join("linked");
create_directory_symlink(&target, &linked_cwd);
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.cwd = linked_cwd.abs();
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]);
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "project doc");
}
#[tokio::test]
async fn child_agents_message_after_global_instructions_uses_plain_separator() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await;
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents),
},
InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
},
],
};
assert_eq!(loaded, expected);
assert_eq!(
loaded.text(),
format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
);
}
#[tokio::test]
async fn instruction_sources_include_global_before_agents_md_docs() {
let tmp = tempfile::tempdir().expect("tempdir");
@@ -360,15 +497,79 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
fs::create_dir_all(&cfg.codex_home).unwrap();
fs::write(&global_agents, "global doc").unwrap();
let sources = AgentsMdManager::new(&cfg)
.instruction_sources(LOCAL_FS.as_ref())
.await;
let project_agents = AbsolutePathBuf::try_from(
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
)
.expect("absolute project doc path");
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
assert_eq!(sources, vec![global_agents, project_agents]);
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents.clone()),
},
InstructionEntry {
contents: "project doc".to_string(),
provenance: InstructionProvenance::Project(project_agents.clone()),
},
],
};
assert_eq!(loaded, expected);
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&global_agents, &project_agents]
);
assert_eq!(
loaded.text(),
format!("global doc{AGENTS_MD_SEPARATOR}project doc")
);
}
#[tokio::test]
async fn child_agents_message_after_project_docs_is_not_an_instruction_source() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await;
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
fs::create_dir_all(&cfg.codex_home).unwrap();
fs::write(&global_agents, "global doc").unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents.clone()),
},
InstructionEntry {
contents: "project doc".to_string(),
provenance: InstructionProvenance::Project(project_agents.clone()),
},
InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
},
],
};
assert_eq!(loaded, expected);
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&global_agents, &project_agents]
);
assert_eq!(
loaded.text(),
format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
);
}
/// AGENTS.override.md is preferred over AGENTS.md when both are present.
+13 -21
View File
@@ -44,6 +44,7 @@ use codex_api::RawMemory as ApiRawMemory;
use codex_api::RealtimeCallClient as ApiRealtimeCallClient;
use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig;
use codex_api::Reasoning;
use codex_api::ReasoningContext;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
use codex_api::ResponseCreateWsRequest;
@@ -100,7 +101,6 @@ use tokio::sync::oneshot::error::TryRecvError;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use tracing::instrument;
use tracing::trace;
use tracing::warn;
@@ -610,6 +610,7 @@ impl ModelClient {
reasoning: effort.map(|effort| Reasoning {
effort: Some(effort),
summary: None,
context: None,
}),
};
@@ -722,12 +723,17 @@ impl ModelClient {
) -> Option<Reasoning> {
if model_info.supports_reasoning_summaries {
Some(Reasoning {
effort: effort.or(model_info.default_reasoning_level),
effort: effort.or_else(|| model_info.default_reasoning_level.clone()),
summary: if summary == ReasoningSummaryConfig::None {
None
} else {
Some(summary)
},
// When Responses Lite is disabled, omit context so Responses uses the default,
// which is currently `current_turn`.
context: model_info
.use_responses_lite
.then_some(ReasoningContext::AllTurns),
})
} else {
None
@@ -776,7 +782,7 @@ impl ModelClient {
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: prompt.parallel_tool_calls,
parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite,
reasoning,
store: provider.is_azure_responses_endpoint(),
stream: true,
@@ -967,18 +973,6 @@ impl ModelClientSession {
.set_connection_reused(/*connection_reused*/ false);
}
pub(crate) async fn send_response_processed(&self, response_id: &str) {
let Some(connection) = self.websocket_session.connection.as_ref() else {
return;
};
if let Err(err) = connection
.send_response_processed(response_id.to_string())
.await
{
debug!("failed to send response.processed websocket request: {err}");
}
}
#[allow(clippy::too_many_arguments)]
/// Builds shared Responses API transport options and request-body options.
///
@@ -1280,7 +1274,7 @@ impl ModelClientSession {
&client_setup.api_provider,
prompt,
model_info,
effort,
effort.clone(),
summary,
service_tier.clone(),
)?;
@@ -1389,7 +1383,7 @@ impl ModelClientSession {
&client_setup.api_provider,
prompt,
model_info,
effort,
effort.clone(),
summary,
service_tier.clone(),
)?;
@@ -1608,7 +1602,7 @@ impl ModelClientSession {
prompt,
model_info,
session_telemetry,
effort,
effort.clone(),
summary,
service_tier.clone(),
turn_metadata_header,
@@ -1672,9 +1666,7 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option<Head
/// Meant to be called just before sending the request over the socket, to capture realistic
/// transport timing.
fn stamp_ws_stream_request_start_ms(request: &mut ResponsesWsRequest) {
let ResponsesWsRequest::ResponseCreate(payload) = request else {
return;
};
let ResponsesWsRequest::ResponseCreate(payload) = request;
payload
.client_metadata
.get_or_insert_with(HashMap::new)
+17 -1
View File
@@ -3,6 +3,7 @@ use codex_config::types::Personality;
use codex_protocol::error::Result;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InterAgentCommunication;
use codex_tools::ToolSpec;
use futures::Stream;
use serde_json::Value;
@@ -53,7 +54,22 @@ impl Default for Prompt {
impl Prompt {
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
self.input.clone()
self.input
.iter()
.cloned()
.map(|item| {
let ResponseItem::Message { role, content, .. } = &item else {
return item;
};
if role != "assistant" {
return item;
}
InterAgentCommunication::from_message_content(content)
.filter(|communication| communication.encrypted_content.is_some())
.map(|communication| communication.to_model_input_item())
.unwrap_or(item)
})
.collect()
}
}
+60 -2
View File
@@ -70,10 +70,51 @@ pub struct ThreadConfigSnapshot {
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
pub session_source: SessionSource,
pub forked_from_thread_id: Option<ThreadId>,
pub parent_thread_id: Option<ThreadId>,
pub thread_source: Option<ThreadSource>,
}
/// Explains why `CodexThread::try_start_turn_if_idle` rejected an automatic
/// idle turn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TryStartTurnIfIdleRejectionReason {
/// User/client-triggered mailbox work is already queued and must take
/// priority over extension-initiated idle work.
PendingTriggerTurn,
/// The thread is in Plan mode, where automatic idle work must not start a
/// new model turn.
PlanMode,
/// Another turn or task is active, or the idle reservation was lost before
/// the automatic turn could start.
Busy,
}
/// Rejection returned when an extension asks to start automatic idle work but
/// the thread is not eligible to run it.
#[derive(Debug)]
pub struct TryStartTurnIfIdleError {
reason: TryStartTurnIfIdleRejectionReason,
input: Vec<ResponseItem>,
}
impl TryStartTurnIfIdleError {
pub(crate) fn new(reason: TryStartTurnIfIdleRejectionReason, input: Vec<ResponseItem>) -> Self {
Self { reason, input }
}
/// Returns the stable reason the automatic idle turn was rejected.
pub fn reason(&self) -> TryStartTurnIfIdleRejectionReason {
self.reason
}
/// Consumes the rejection and returns the original model-visible input
/// unchanged, so callers can retry, drop, or log it explicitly.
pub fn into_input(self) -> Vec<ResponseItem> {
self.input
}
}
impl ThreadConfigSnapshot {
pub fn sandbox_policy(&self) -> SandboxPolicy {
codex_sandboxing::compatibility_sandbox_policy_for_permission_profile(
@@ -276,11 +317,23 @@ impl CodexThread {
self.codex.session.inject_if_running(items).await
}
/// Starts a regular turn with model-visible items only if the thread is idle.
/// Starts an automatic regular turn with model-visible items only when idle
/// work is allowed for this thread.
///
/// This is the required entry point for extensions that want to launch
/// model-visible work from `ThreadLifecycleContributor::on_thread_idle`.
/// The call succeeds only if no user/client-triggered turn is queued, no
/// task is currently active, and the thread is not in Plan mode. Active
/// Review tasks are rejected by the active-task check because Review turns
/// are not steerable.
///
/// On rejection, the returned error includes a stable reason and carries
/// the original `items` unchanged so the caller can decide whether to drop
/// them, retry later, or log why no automatic turn was started.
pub async fn try_start_turn_if_idle(
&self,
items: Vec<ResponseItem>,
) -> Result<(), Vec<ResponseItem>> {
) -> Result<(), TryStartTurnIfIdleError> {
self.codex.session.try_start_turn_if_idle(items).await
}
@@ -496,6 +549,11 @@ impl CodexThread {
self.codex.thread_config_snapshot().await
}
/// Returns the files that supplied the thread's loaded model instructions.
pub async fn instruction_sources(&self) -> Vec<AbsolutePathBuf> {
self.codex.instruction_sources().await
}
pub async fn config(&self) -> Arc<crate::config::Config> {
self.codex.session.get_config().await
}
+1 -1
View File
@@ -573,7 +573,7 @@ async fn drain_to_completed(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_effort.clone(),
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
turn_metadata_header,
+2 -1
View File
@@ -242,7 +242,7 @@ async fn run_remote_compact_task_inner_impl(
&prompt,
&turn_context.model_info,
CompactConversationRequestSettings {
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: turn_context.reasoning_summary,
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
None
@@ -348,6 +348,7 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
}
ResponseItem::Message { role, .. } if role == "assistant" => true,
ResponseItem::Message { .. } => false,
ResponseItem::AgentMessage { .. } => true,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger => false,
ResponseItem::Reasoning { .. }
+6 -21
View File
@@ -26,7 +26,6 @@ use codex_analytics::CompactionImplementation;
use codex_analytics::CompactionPhase;
use codex_analytics::CompactionReason;
use codex_analytics::CompactionTrigger;
use codex_features::Feature;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::ContextCompactionItem;
@@ -281,7 +280,6 @@ async fn run_remote_compact_task_inner_impl(
);
let RemoteCompactionV2Output {
compaction_output,
response_id,
token_usage,
} = compaction_output_result?;
if let Some(token_usage) = token_usage {
@@ -314,18 +312,11 @@ async fn run_remote_compact_task_inner_impl(
sess.emit_turn_item_completed(turn_context, compaction_item)
.await;
if turn_context
.features
.enabled(Feature::ResponsesWebsocketResponseProcessed)
{
client_session.send_response_processed(&response_id).await;
}
Ok(())
}
struct RemoteCompactionV2Output {
compaction_output: ResponseItem,
response_id: String,
token_usage: Option<TokenUsage>,
}
@@ -348,7 +339,7 @@ async fn run_remote_compaction_request_v2(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_effort.clone(),
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
turn_metadata_header,
@@ -409,7 +400,7 @@ async fn collect_compaction_output(
let mut output_item_count = 0usize;
let mut compaction_count = 0usize;
let mut compaction_output = None;
let mut completed_response_id = None;
let mut saw_completed = false;
let mut completed_token_usage = None;
while let Some(event) = stream.next().await {
match event? {
@@ -422,12 +413,8 @@ async fn collect_compaction_output(
}
}
}
ResponseEvent::Completed {
response_id,
token_usage,
..
} => {
completed_response_id = Some(response_id);
ResponseEvent::Completed { token_usage, .. } => {
saw_completed = true;
completed_token_usage = token_usage;
break;
}
@@ -435,12 +422,12 @@ async fn collect_compaction_output(
}
}
let Some(response_id) = completed_response_id else {
if !saw_completed {
return Err(CodexErr::Stream(
"remote compaction v2 stream closed before response.completed".to_string(),
None,
));
};
}
if compaction_count != 1 {
return Err(CodexErr::Fatal(format!(
@@ -453,7 +440,6 @@ async fn collect_compaction_output(
};
Ok(RemoteCompactionV2Output {
compaction_output,
response_id,
token_usage: completed_token_usage,
})
}
@@ -804,7 +790,6 @@ mod tests {
.expect("compaction should be collected");
assert_eq!(output.compaction_output, compaction);
assert_eq!(output.response_id, "resp-compact");
assert_eq!(
output.token_usage,
Some(TokenUsage {
+43 -9
View File
@@ -210,10 +210,8 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
#[tokio::test]
async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
let codex_home = tempdir()?;
std::fs::write(
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"\n global instructions \n",
)?;
let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME);
std::fs::write(&global_agents_path, "\n global instructions \n")?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
@@ -223,9 +221,14 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
.await?;
let _ = config.features.enable(Feature::MemoryTool);
let user_instructions = config
.user_instructions
.as_ref()
.expect("global instructions expected");
assert_eq!(user_instructions.text(), "global instructions");
assert_eq!(
config.user_instructions.as_deref(),
Some("global instructions")
user_instructions.sources().collect::<Vec<_>>(),
vec![&global_agents_path]
);
Ok(())
}
@@ -237,7 +240,7 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"global instructions",
)?;
let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME);
let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME);
std::fs::write(&global_agents_override_path, "local override instructions")?;
let config = Config::load_from_base_config_with_overrides(
@@ -247,9 +250,14 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R
)
.await?;
let user_instructions = config
.user_instructions
.as_ref()
.expect("global override instructions expected");
assert_eq!(user_instructions.text(), "local override instructions");
assert_eq!(
config.user_instructions.as_deref(),
Some("local override instructions")
user_instructions.sources().collect::<Vec<_>>(),
vec![&global_agents_override_path]
);
Ok(())
}
@@ -478,6 +486,32 @@ async fn load_config_resolves_experimental_request_user_input_enabled() -> std::
Ok(())
}
#[tokio::test]
async fn load_config_resolves_code_mode_config() -> std::io::Result<()> {
let codex_home = tempdir()?;
let config_toml: ConfigToml = toml::from_str(
r#"
[features.code_mode]
enabled = true
excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"]
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.code_mode.excluded_tool_namespaces,
vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()]
);
assert!(config.features.enabled(Feature::CodeMode));
Ok(())
}
#[test]
fn rejects_provider_auth_with_env_key() {
let err = toml::from_str::<ConfigToml>(
+1 -1
View File
@@ -225,7 +225,7 @@ impl ConfigDocument {
);
mutated |= self.write_optional_value(
&["model_reasoning_effort"],
effort.map(|effort| value(effort.to_string())),
effort.as_ref().map(|effort| value(effort.to_string())),
);
mutated
}),
+34 -3
View File
@@ -1,4 +1,5 @@
use crate::agents_md::AgentsMdManager;
pub use crate::agents_md::LoadedAgentsMd;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::path_utils::normalize_for_native_workdir;
@@ -59,6 +60,7 @@ use codex_core_plugins::PluginsConfigInput;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::CodeModeConfigToml;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
@@ -200,6 +202,7 @@ All agents in the team, including the agents that you can assign tasks to, are e
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn.
Child agents can also spawn their own sub-agents.
You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter.
Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly.
You will receive messages in the analysis channel in the form:
```
@@ -216,6 +219,7 @@ You can spawn sub-agents to handle subtasks, and those sub-agents can spawn thei
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent.
Child agents can also spawn their own sub-agents.
Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly.
When you provide a response in the final channel, that content is immediately delivered back to your parent agent.
@@ -643,7 +647,7 @@ pub struct Config {
pub show_raw_agent_reasoning: bool,
/// User-provided instructions from AGENTS.md.
pub user_instructions: Option<String>,
pub user_instructions: Option<LoadedAgentsMd>,
/// Base instructions override.
pub base_instructions: Option<String>,
@@ -979,6 +983,9 @@ pub struct Config {
/// Whether to register the experimental request_user_input tool.
pub experimental_request_user_input_enabled: bool,
/// Configuration for the experimental code-mode tool surface.
pub code_mode: CodeModeConfig,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -1031,6 +1038,11 @@ pub struct Config {
pub otel: codex_config::types::OtelConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct CodeModeConfig {
pub excluded_tool_namespaces: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MultiAgentV2Config {
pub max_concurrent_threads_per_session: usize,
@@ -2292,6 +2304,17 @@ fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) ->
.is_none_or(|config| config.enabled)
}
fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
let base = code_mode_toml_config(config_toml.features.as_ref());
CodeModeConfig {
excluded_tool_namespaces: base
.and_then(|config| config.excluded_tool_namespaces.as_ref())
.cloned()
.unwrap_or_default(),
}
}
fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config {
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
let default = MultiAgentV2Config::default();
@@ -2374,6 +2397,13 @@ fn resolve_optional_prompt_text(
}
}
fn code_mode_toml_config(features: Option<&FeaturesToml>) -> Option<&CodeModeConfigToml> {
match features?.code_mode.as_ref()? {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => Some(config),
}
}
fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> {
match features?.multi_agent_v2.as_ref()? {
FeatureToml::Enabled(_) => None,
@@ -2580,8 +2610,7 @@ impl Config {
Some(&codex_home),
&mut startup_warnings,
)
.await
.map(|loaded| loaded.contents);
.await;
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
@@ -3015,6 +3044,7 @@ impl Config {
let web_search_config = resolve_web_search_config(&cfg);
let experimental_request_user_input_enabled =
resolve_experimental_request_user_input_enabled(&cfg);
let code_mode = resolve_code_mode_config(&cfg);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg);
let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) {
let base = apps_mcp_path_override_toml_config(cfg.features.as_ref());
@@ -3566,6 +3596,7 @@ impl Config {
web_search_mode: constrained_web_search_mode.value,
web_search_config,
experimental_request_user_input_enabled,
code_mode,
use_experimental_unified_exec_tool,
background_terminal_max_timeout,
ghost_snapshot,
@@ -1,6 +1,27 @@
use super::ContextualUserFragment;
use std::fmt::Display;
/// Maximum size of the extension's model-facing generated-image path hint.
const MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES: usize = 1024;
/// Returns the extension's model-facing hint, or omits it if the path makes it too large.
pub fn extension_image_generation_output_hint(
image_output_dir: impl Display,
image_output_path: impl Display,
) -> Option<String> {
let hint = image_generation_hint(image_output_dir, image_output_path);
(hint.len() <= MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES).then_some(hint)
}
fn image_generation_hint(
image_output_dir: impl Display,
image_output_path: impl Display,
) -> String {
format!(
"Generated images are saved to {image_output_dir} as {image_output_path} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
)
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ImageGenerationInstructions {
image_output_dir: String,
@@ -30,9 +51,6 @@ impl ContextualUserFragment for ImageGenerationInstructions {
}
fn body(&self) -> String {
format!(
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
self.image_output_dir, self.image_output_path
)
image_generation_hint(&self.image_output_dir, &self.image_output_path)
}
}
+1
View File
@@ -44,6 +44,7 @@ pub(crate) use environment_context::EnvironmentContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
pub use image_generation_instructions::extension_image_generation_output_hint;
pub use internal_model_context::InternalContextSource;
pub use internal_model_context::InternalModelContextFragment;
pub use internal_model_context::InvalidInternalContextSource;
+7 -1
View File
@@ -386,6 +386,7 @@ impl ContextManager {
output: truncate_function_output_payload(output, policy_with_serialization_budget),
},
ResponseItem::Message { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
@@ -474,7 +475,8 @@ pub(crate) fn truncate_function_output_payload(
fn is_api_message(message: &ResponseItem) -> bool {
match message {
ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::FunctionCallOutput { .. }
ResponseItem::AgentMessage { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
@@ -720,11 +722,15 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::Other => false,
}
}
pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
if matches!(item, ResponseItem::AgentMessage { .. }) {
return true;
}
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
+4 -13
View File
@@ -22,7 +22,6 @@ use codex_execpolicy::blocking_append_network_rule;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_shell_command::is_dangerous_command::command_might_be_dangerous;
use codex_shell_command::is_safe_command::is_known_safe_command;
@@ -121,7 +120,6 @@ pub(crate) enum ExecPolicyCommandOrigin {
pub(crate) struct UnmatchedCommandContext<'a> {
pub(crate) approval_policy: AskForApproval,
pub(crate) permission_profile: &'a PermissionProfile,
pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub(crate) sandbox_cwd: &'a Path,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) used_complex_parsing: bool,
@@ -242,7 +240,6 @@ pub(crate) struct ExecApprovalRequest<'a> {
pub(crate) command: &'a [String],
pub(crate) approval_policy: AskForApproval,
pub(crate) permission_profile: PermissionProfile,
pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub(crate) sandbox_cwd: &'a Path,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) prefix_rule: Option<Vec<String>>,
@@ -277,7 +274,6 @@ impl ExecPolicyManager {
command,
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
prefix_rule,
@@ -298,7 +294,6 @@ impl ExecPolicyManager {
UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
@@ -636,12 +631,12 @@ pub(crate) fn render_decision_for_unmatched_command(
let UnmatchedCommandContext {
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin,
} = context;
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
let is_known_safe = match command_origin {
ExecPolicyCommandOrigin::Generic => is_known_safe_command(command),
#[cfg(windows)]
@@ -652,12 +647,8 @@ pub(crate) fn render_decision_for_unmatched_command(
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
// here.
let environment_lacks_sandbox_protections = cfg!(windows)
&& profile_is_managed_read_only(
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
);
let environment_lacks_sandbox_protections =
cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd);
if is_known_safe
&& !used_complex_parsing
@@ -751,9 +742,9 @@ pub(crate) fn render_decision_for_unmatched_command(
fn profile_is_managed_read_only(
permission_profile: &PermissionProfile,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_cwd: &Path,
) -> bool {
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
matches!(permission_profile, PermissionProfile::Managed { .. })
&& matches!(
file_system_sandbox_policy.kind,
+3 -76
View File
@@ -19,6 +19,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
@@ -104,31 +105,6 @@ async fn write_project_trust_config(
.await
}
fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}])
}
fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::workspace_write(
&[],
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
)
}
fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::unrestricted()
}
fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::external_sandbox()
}
async fn test_config() -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::without_managed_config_for_tests()
@@ -665,7 +641,6 @@ async fn evaluates_bash_lc_inner_commands() {
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -761,7 +736,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() {
command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -785,7 +759,6 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -809,7 +782,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec![
"python3".to_string(),
@@ -837,7 +809,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches(
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["python3".to_string()]),
},
@@ -862,7 +833,6 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() {
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -893,7 +863,6 @@ EOF"#
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -927,7 +896,6 @@ EOF"#
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -967,7 +935,6 @@ prefix_rule(
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -986,7 +953,6 @@ async fn exec_approval_requirement_prefers_execpolicy_match() {
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1014,7 +980,6 @@ prefix_rule(pattern=["git"], decision="allow")
command: vec![git_path, "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1048,7 +1013,6 @@ prefix_rule(pattern=["git"], decision="prompt")
command: vec![disallowed_git_path.clone(), "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1075,7 +1039,6 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
},
@@ -1098,7 +1061,6 @@ async fn exec_approval_requirement_respects_approval_policy() {
command: vec!["rm".to_string()],
approval_policy: AskForApproval::Never,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1126,7 +1088,6 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
mcp_elicitations: true,
}),
permission_profile: &PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
@@ -1137,9 +1098,8 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
}
#[test]
fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalation_prompts() {
let command = vec!["madeup-cmd".to_string()];
let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
Decision::Prompt,
@@ -1147,8 +1107,7 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
&command,
UnmatchedCommandContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: &PermissionProfile::Disabled,
file_system_sandbox_policy: &restricted_file_system_policy,
permission_profile: &PermissionProfile::read_only(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
@@ -1169,7 +1128,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() {
UnmatchedCommandContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: &PermissionProfile::workspace_write(),
file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
@@ -1202,7 +1160,6 @@ fn managed_cwd_write_profile_is_not_read_only() {
assert!(!profile_is_managed_read_only(
&permission_profile,
&file_system_sandbox_policy,
Path::new("/tmp/project")
));
}
@@ -1233,7 +1190,6 @@ fn managed_unresolvable_write_profile_is_still_read_only() {
assert!(profile_is_managed_read_only(
&permission_profile,
&file_system_sandbox_policy,
Path::new("/tmp/project")
));
}
@@ -1250,7 +1206,6 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
prefix_rule: None,
},
@@ -1273,7 +1228,6 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re
command: vec!["echo".to_string(), "hello".to_string()],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -1303,7 +1257,6 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -1329,7 +1282,6 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -1365,7 +1317,6 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision()
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -1403,7 +1354,6 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled(
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -1428,7 +1378,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1454,7 +1403,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1484,7 +1432,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1514,7 +1461,6 @@ async fn request_rule_uses_prefix_rule() {
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
@@ -1547,7 +1493,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands(
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
@@ -1587,7 +1532,6 @@ async fn heuristics_apply_when_other_commands_match_policy() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1663,7 +1607,6 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol
command: command.clone(),
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1683,7 +1626,6 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1707,7 +1649,6 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1737,7 +1678,6 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri
command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1761,7 +1701,6 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1785,7 +1724,6 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow()
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1816,7 +1754,6 @@ prefix_rule(pattern=["cat"], decision="allow")
command: command.clone(),
approval_policy,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1848,7 +1785,6 @@ prefix_rule(pattern=["bash"], decision="allow")
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2014,7 +1950,6 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() {
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2078,7 +2013,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
command: &sneaky_command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
@@ -2103,7 +2037,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
command: &dangerous_command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
@@ -2124,7 +2057,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
command: &dangerous_command,
approval_policy: AskForApproval::Never,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
@@ -2146,7 +2078,6 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
permission_profile: PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2171,7 +2102,6 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
permission_profile: PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2188,7 +2118,6 @@ struct ExecApprovalRequirementScenario {
command: Vec<String>,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
file_system_sandbox_policy: FileSystemSandboxPolicy,
sandbox_permissions: SandboxPermissions,
prefix_rule: Option<Vec<String>>,
}
@@ -2212,7 +2141,6 @@ async fn exec_approval_requirement_for_command(
command,
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
} = test;
@@ -2224,7 +2152,6 @@ async fn exec_approval_requirement_for_command(
command: &command,
approval_policy,
permission_profile,
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions,
prefix_rule,
@@ -15,7 +15,6 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() {
],
approval_policy: AskForApproval::Never,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -39,7 +38,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -81,7 +79,6 @@ fn unmatched_safe_powershell_words_are_allowed() {
UnmatchedCommandContext {
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: &PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
used_complex_parsing: false,
@@ -110,7 +107,6 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
+4 -3
View File
@@ -702,7 +702,7 @@ pub(super) async fn run_guardian_review_session(
.supported_reasoning_efforts
.iter()
.any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low),
Some(preset.default_reasoning_effort),
Some(preset.default_reasoning_effort.clone()),
);
(review_model_id.to_string(), reasoning_effort)
} else {
@@ -712,7 +712,8 @@ pub(super) async fn run_guardian_review_session(
.iter()
.any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low),
turn.reasoning_effort
.or(turn.model_info.default_reasoning_level),
.clone()
.or_else(|| turn.model_info.default_reasoning_level.clone()),
);
(
model_override
@@ -725,7 +726,7 @@ pub(super) async fn run_guardian_review_session(
turn.config.as_ref(),
live_network_config.clone(),
guardian_model.as_str(),
guardian_reasoning_effort,
guardian_reasoning_effort.clone(),
);
let guardian_config = match guardian_config {
Ok(config) => config,
+8 -6
View File
@@ -29,6 +29,7 @@ use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::LoadedAgentsMd;
use crate::codex_delegate::run_codex_thread_interactive;
use crate::config::Config;
use crate::config::Constrained;
@@ -146,7 +147,7 @@ struct GuardianReviewSessionReuseKey {
permissions: Permissions,
developer_instructions: Option<String>,
base_instructions: Option<String>,
user_instructions: Option<String>,
user_instructions: Option<LoadedAgentsMd>,
compact_prompt: Option<String>,
cwd: AbsolutePathBuf,
mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
@@ -166,7 +167,7 @@ impl GuardianReviewSessionReuseKey {
model_context_window: spawn_config.model_context_window,
model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit,
model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope,
model_reasoning_effort: spawn_config.model_reasoning_effort,
model_reasoning_effort: spawn_config.model_reasoning_effort.clone(),
model_reasoning_summary: spawn_config.model_reasoning_summary,
permissions: spawn_config.permissions.clone(),
developer_instructions: spawn_config.developer_instructions.clone(),
@@ -657,7 +658,8 @@ async fn run_review_on_session(
let guardian_reasoning_effort = if model_info.supports_reasoning_summaries {
params
.reasoning_effort
.or(model_info.default_reasoning_level)
.clone()
.or_else(|| model_info.default_reasoning_level.clone())
} else {
None
};
@@ -741,7 +743,7 @@ async fn run_review_on_session(
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: params.model.clone(),
reasoning_effort: params.reasoning_effort,
reasoning_effort: params.reasoning_effort.clone(),
developer_instructions: None,
},
}),
@@ -1106,7 +1108,7 @@ mod tests {
async fn test_review_params() -> GuardianReviewSessionParams {
let (session, turn) = crate::session::tests::make_session_and_context().await;
let model = turn.model_info.slug.clone();
let reasoning_effort = turn.reasoning_effort;
let reasoning_effort = turn.reasoning_effort.clone();
let reasoning_summary = turn.reasoning_summary;
let personality = turn.personality;
#[allow(deprecated)]
@@ -1115,7 +1117,7 @@ mod tests {
turn.config.as_ref(),
/*live_network_config*/ None,
model.as_str(),
reasoning_effort,
reasoning_effort.clone(),
)
.expect("guardian config");
+4 -1
View File
@@ -22,6 +22,8 @@ mod config_lock;
pub use codex_thread::CodexThread;
pub use codex_thread::CodexThreadSettingsOverrides;
pub use codex_thread::ThreadConfigSnapshot;
pub use codex_thread::TryStartTurnIfIdleError;
pub use codex_thread::TryStartTurnIfIdleRejectionReason;
pub use session::turn_context::TurnContext;
mod agent;
mod attestation;
@@ -81,7 +83,6 @@ mod sandbox_tags;
pub mod sandboxing;
mod session_prefix;
mod session_startup_prewarm;
mod shell_detect;
pub mod skills;
pub(crate) use skills::SkillInjections;
pub(crate) use skills::SkillLoadOutcome;
@@ -97,6 +98,7 @@ pub(crate) use skills::manager;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
pub(crate) use skills::skills_load_input_from_config;
mod stream_events_utils;
pub use stream_events_utils::image_generation_artifact_path;
pub mod test_support;
mod unified_exec;
pub mod windows_sandbox;
@@ -128,6 +130,7 @@ pub(crate) mod agents_md;
pub use agents_md::AgentsMdManager;
pub use agents_md::DEFAULT_AGENTS_MD_FILENAME;
pub use agents_md::LOCAL_AGENTS_MD_FILENAME;
pub use agents_md::LoadedAgentsMd;
mod rollout;
pub(crate) mod safety;
mod session_rollout_init_error;
+9 -1
View File
@@ -129,7 +129,7 @@ fn save_config_resolved_fields(
) -> anyhow::Result<()> {
lock_config.web_search = Some(config.web_search_mode.value());
lock_config.model_provider = Some(config.model_provider_id.clone());
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort.clone();
lock_config.model_verbosity = config.model_verbosity;
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
@@ -251,6 +251,14 @@ mod tests {
spec.key
);
}
assert_eq!(
features.code_mode,
Some(FeatureToml::Enabled(
sc.original_config_do_not_use
.features
.enabled(Feature::CodeMode)
))
);
let multi_agent_v2 = features
.multi_agent_v2
+45 -7
View File
@@ -1,9 +1,12 @@
use super::input_queue::TurnInput;
use super::session::Session;
use super::turn_context::TurnContext;
use crate::codex_thread::TryStartTurnIfIdleError;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::state::ActiveTurn;
use crate::state::TurnState;
use crate::tasks::RegularTask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ResponseItem;
use std::sync::Arc;
@@ -32,22 +35,40 @@ impl Session {
}
}
/// Starts a regular turn with the provided items only if the session is idle.
/// Starts a regular turn with the provided items only if automatic idle work
/// is allowed for the current session state.
///
/// This is the shared gate for extension-initiated idle work. It refuses to
/// start a turn when user/client-triggered work is queued, any task is still
/// active, or the session is currently in Plan mode. Active Review tasks are
/// covered by the active-task check because Review turns are not steerable.
pub(crate) async fn try_start_turn_if_idle(
self: &Arc<Self>,
input: Vec<ResponseItem>,
) -> Result<(), Vec<ResponseItem>> {
) -> Result<(), TryStartTurnIfIdleError> {
if input.is_empty() {
return Ok(());
}
if self.input_queue.has_trigger_turn_mailbox_items().await {
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
if self.collaboration_mode().await.mode == ModeKind::Plan {
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PlanMode,
input,
));
}
let turn_state = {
let mut active_turn = self.active_turn.lock().await;
if active_turn.is_some() {
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::Busy,
input,
));
}
let active_turn = active_turn.get_or_insert_with(ActiveTurn::default);
Arc::clone(&active_turn.turn_state)
@@ -56,18 +77,32 @@ impl Session {
if self.input_queue.has_trigger_turn_mailbox_items().await {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
let turn_context = self
.new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string())
.await;
if turn_context.collaboration_mode.mode == ModeKind::Plan {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PlanMode,
input,
));
}
self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
if self.input_queue.has_trigger_turn_mailbox_items().await {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
let still_reserved = {
let active_turn = self.active_turn.lock().await;
@@ -77,7 +112,10 @@ impl Session {
};
if !still_reserved {
self.clear_reserved_idle_turn(&turn_state).await;
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::Busy,
input,
));
}
self.input_queue
+32 -7
View File
@@ -359,6 +359,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnModerationMetadataEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolEnvironmentMode;
@@ -507,12 +508,13 @@ impl Codex {
let primary_environment = environment_selections.primary_environment();
let mut user_instruction_warnings = Vec::new();
let user_instructions = AgentsMdManager::new(&config)
.user_instructions(
primary_environment.as_deref(),
&mut user_instruction_warnings,
)
.await;
let user_instructions = if let Some(primary_environment) = primary_environment {
AgentsMdManager::new(&config)
.user_instructions(primary_environment.as_ref(), &mut user_instruction_warnings)
.await
} else {
None
};
config.startup_warnings.extend(user_instruction_warnings);
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
@@ -578,7 +580,7 @@ impl Codex {
mode: ModeKind::Default,
settings: Settings {
model: model.clone(),
reasoning_effort: config.model_reasoning_effort,
reasoning_effort: config.model_reasoning_effort.clone(),
developer_instructions: None,
},
};
@@ -800,6 +802,17 @@ impl Codex {
state.session_configuration.thread_config_snapshot()
}
pub(crate) async fn instruction_sources(&self) -> Vec<AbsolutePathBuf> {
let state = self.session.state.lock().await;
state
.session_configuration
.user_instructions
.as_ref()
.map_or_else(Vec::new, |instructions| {
instructions.sources().cloned().collect()
})
}
pub(crate) async fn thread_environment_selections(&self) -> Vec<TurnEnvironmentSelection> {
let state = self.session.state.lock().await;
state.session_configuration.environments.clone()
@@ -2625,6 +2638,15 @@ impl Session {
.await;
}
pub(crate) async fn emit_turn_moderation_metadata(
self: &Arc<Self>,
turn_context: &Arc<TurnContext>,
metadata: TurnModerationMetadataEvent,
) {
self.send_event(turn_context, EventMsg::TurnModerationMetadata(metadata))
.await;
}
pub(crate) async fn replace_history(
&self,
items: Vec<ResponseItem>,
@@ -3364,6 +3386,9 @@ pub(crate) fn emit_subagent_session_started(
session_id: session_id.to_string(),
thread_id: thread_id.to_string(),
parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()),
forked_from_thread_id: thread_config
.forked_from_thread_id
.map(|thread_id| thread_id.to_string()),
product_client_id: client_name.clone(),
client_name,
client_version,
+10 -2
View File
@@ -1,4 +1,5 @@
use super::*;
use codex_core_skills::HostLoadedSkills;
use codex_protocol::openai_models::ToolMode;
use std::sync::atomic::AtomicBool;
@@ -73,7 +74,7 @@ pub(super) async fn spawn_review_thread(
let auth_manager_for_context = auth_manager.clone();
let provider_for_context = provider.clone();
let session_telemetry_for_context = session_telemetry.clone();
let reasoning_effort = per_turn_config.model_reasoning_effort;
let reasoning_effort = per_turn_config.model_reasoning_effort.clone();
let reasoning_summary = per_turn_config
.model_reasoning_summary
.unwrap_or(model_info.default_reasoning_summary);
@@ -100,6 +101,13 @@ pub(super) async fn spawn_review_thread(
parent_turn_context.network.is_some(),
));
let extension_data = Arc::new(codex_extension_api::ExtensionData::new(
review_turn_id.clone(),
));
extension_data.insert(HostLoadedSkills::new(
parent_turn_context.turn_skills.outcome.clone(),
));
let review_turn_context = TurnContext {
sub_id: review_turn_id.clone(),
trace_id: current_span_trace_id(),
@@ -143,7 +151,7 @@ pub(super) async fn spawn_review_thread(
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
truncation_policy: model_info.truncation_policy.into(),
turn_metadata_state,
extension_data: Arc::new(codex_extension_api::ExtensionData::new(review_turn_id)),
extension_data,
turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()),
turn_timing_state: Arc::new(TurnTimingState::default()),
server_model_warning_emitted: AtomicBool::new(false),
@@ -74,7 +74,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)];
@@ -113,7 +113,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let turn_id = previous_context_item
@@ -963,7 +963,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
@@ -1042,7 +1042,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}))
.expect("serialize expected reference context item")
@@ -1071,7 +1071,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
@@ -1190,7 +1190,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
@@ -1308,7 +1308,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
@@ -1467,7 +1467,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let previous_turn_id = previous_context_item
+5 -2
View File
@@ -1,5 +1,6 @@
use super::input_queue::InputQueue;
use super::*;
use crate::agents_md::LoadedAgentsMd;
use crate::config::ConstraintError;
use crate::goals::GoalRuntimeState;
use crate::skills::SkillError;
@@ -54,8 +55,9 @@ pub(crate) struct SessionConfiguration {
/// Developer instructions that supplement the base instructions.
pub(super) developer_instructions: Option<String>,
/// Model instructions that are appended to the base instructions.
pub(super) user_instructions: Option<String>,
/// Model instructions that are appended to the base instructions and the
/// files that supplied them.
pub(super) user_instructions: Option<LoadedAgentsMd>,
/// Personality preference for the model.
pub(super) personality: Option<Personality>,
@@ -185,6 +187,7 @@ impl SessionConfiguration {
personality: self.personality,
collaboration_mode: self.collaboration_mode.clone(),
session_source: self.session_source.clone(),
forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id,
thread_source: self.thread_source,
}
+174 -13
View File
@@ -1,5 +1,6 @@
use super::turn_context::TurnEnvironment;
use super::*;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::test_config;
@@ -2558,7 +2559,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let turn_id = previous_context_item
@@ -3145,7 +3146,7 @@ async fn set_rate_limits_retains_previous_credits() {
let model = get_model_offline_for_tests(config.model.as_deref());
let model_info =
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
let reasoning_effort = config.model_reasoning_effort;
let reasoning_effort = config.model_reasoning_effort.clone();
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
@@ -3253,7 +3254,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
let model = get_model_offline_for_tests(config.model.as_deref());
let model_info =
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
let reasoning_effort = config.model_reasoning_effort;
let reasoning_effort = config.model_reasoning_effort.clone();
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
@@ -3784,7 +3785,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
let model = get_model_offline_for_tests(config.model.as_deref());
let model_info =
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
let reasoning_effort = config.model_reasoning_effort;
let reasoning_effort = config.model_reasoning_effort.clone();
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
@@ -3830,6 +3831,85 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
}
}
#[tokio::test]
async fn emit_subagent_session_started_includes_fork_lineage_from_session_configuration() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/analytics-events/events"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let analytics_events_client = AnalyticsEventsClient::new(
auth_manager,
server.uri(),
/*analytics_enabled*/ Some(true),
);
let parent_thread_id = ThreadId::new();
let forked_from_thread_id = ThreadId::new();
let child_thread_id = ThreadId::new();
let mut session_configuration = make_session_configuration_for_tests().await;
session_configuration.forked_from_thread_id = Some(forked_from_thread_id);
emit_subagent_session_started(
&analytics_events_client,
AppServerClientMetadata {
client_name: Some("codex-tui".to_string()),
client_version: Some("1.0.0".to_string()),
},
SessionId::from(child_thread_id),
child_thread_id,
Some(parent_thread_id),
session_configuration.thread_config_snapshot(),
SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
},
);
let event = timeout(Duration::from_secs(1), async {
'wait_for_event: loop {
if let Some(requests) = server.received_requests().await {
for request in requests {
let payload: serde_json::Value =
serde_json::from_slice(&request.body).expect("valid analytics payload");
if let Some(event) = payload["events"].as_array().and_then(|events| {
events
.iter()
.find(|event| event["event_type"] == "codex_thread_initialized")
}) {
break 'wait_for_event event.clone();
}
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("subagent initialization analytics should be emitted");
assert_eq!(
event["event_params"]["parent_thread_id"],
parent_thread_id.to_string()
);
assert_eq!(
event["event_params"]["forked_from_thread_id"],
forked_from_thread_id.to_string()
);
}
fn turn_environments_for_tests(
environment: &Arc<codex_exec_server::Environment>,
cwd: &codex_utils_absolute_path::AbsolutePathBuf,
@@ -4534,7 +4614,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: config.model_reasoning_effort,
reasoning_effort: config.model_reasoning_effort.clone(),
developer_instructions: None,
},
};
@@ -4636,7 +4716,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let model = get_model_offline_for_tests(config.model.as_deref());
let model_info =
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
let reasoning_effort = config.model_reasoning_effort;
let reasoning_effort = config.model_reasoning_effort.clone();
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
@@ -4879,7 +4959,7 @@ async fn make_session_with_config_and_rx(
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: config.model_reasoning_effort,
reasoning_effort: config.model_reasoning_effort.clone(),
developer_instructions: None,
},
};
@@ -4984,7 +5064,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: config.model_reasoning_effort,
reasoning_effort: config.model_reasoning_effort.clone(),
developer_instructions: None,
},
};
@@ -5919,7 +5999,7 @@ async fn user_turn_updates_approvals_reviewer() {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: turn_context.model_info.slug.clone(),
reasoning_effort: config.model_reasoning_effort,
reasoning_effort: config.model_reasoning_effort.clone(),
developer_instructions: None,
},
}),
@@ -6726,7 +6806,7 @@ where
let model = get_model_offline_for_tests(config.model.as_deref());
let model_info =
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
let reasoning_effort = config.model_reasoning_effort;
let reasoning_effort = config.model_reasoning_effort.clone();
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
@@ -8770,7 +8850,90 @@ async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() {
.await
.expect_err("active turn should reject idle-only input");
assert_eq!(vec![item], err);
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input());
assert_eq!(
Vec::<TurnInput>::new(),
sess.input_queue.get_pending_input(&sess.active_turn).await
);
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = sess.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = sess.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
}
let item = user_message("synthetic idle input");
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("plan mode should reject automatic idle input");
assert_eq!(TryStartTurnIfIdleRejectionReason::PlanMode, err.reason());
assert_eq!(vec![item], err.into_input());
assert!(sess.active_turn.lock().await.is_none());
assert_eq!(
Vec::<TurnInput>::new(),
sess.input_queue.get_pending_input(&sess.active_turn).await
);
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
sess.input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root(),
Vec::new(),
"pending trigger".to_string(),
/*trigger_turn*/ true,
))
.await;
let item = user_message("synthetic idle input");
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("pending trigger-turn mail should reject automatic idle input");
assert_eq!(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
err.reason()
);
assert_eq!(vec![item], err.into_input());
assert!(sess.active_turn.lock().await.is_none());
assert!(sess.input_queue.has_trigger_turn_mailbox_items().await);
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Review,
listen_to_cancellation_token: true,
},
)
.await;
let item = user_message("synthetic idle input");
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("active review turn should reject automatic idle input");
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input());
assert_eq!(
Vec::<TurnInput>::new(),
sess.input_queue.get_pending_input(&sess.active_turn).await
@@ -10705,7 +10868,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc");
turn_context_mut.permission_profile = PermissionProfile::Disabled;
let file_system_sandbox_policy = turn_context.file_system_sandbox_policy();
let command = session.user_shell().derive_exec_args(
command_script,
turn_context.config.permissions.allow_login_shell,
@@ -10717,7 +10879,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
command: &command,
approval_policy: turn_context.approval_policy.value(),
permission_profile: turn_context.permission_profile(),
file_system_sandbox_policy: &file_system_sandbox_policy,
#[allow(deprecated)]
sandbox_cwd: turn_context.cwd.as_path(),
sandbox_permissions: SandboxPermissions::UseDefault,
+23 -15
View File
@@ -69,6 +69,7 @@ use codex_analytics::InvocationType;
use codex_analytics::TurnResolvedConfigFact;
use codex_analytics::build_track_events_context;
use codex_async_utils::OrCancelExt;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputEnvironment;
use codex_features::Feature;
@@ -535,6 +536,9 @@ async fn build_skills_and_plugins(
)
.await;
let injected_host_skill_prompts = turn_context
.extension_data
.get::<InjectedHostSkillPrompts>();
let SkillInjections {
items: skill_injections,
warnings: skill_warnings,
@@ -591,7 +595,16 @@ async fn build_skills_and_plugins(
.track_plugin_used(tracking.clone(), plugin);
}
let mut injection_items = skill_items;
let mut injection_items: Vec<ResponseItem> = match injected_host_skill_prompts {
Some(injected_host_skill_prompts) => skill_injections
.iter()
.filter(|skill| !injected_host_skill_prompts.contains_path(&skill.path))
.map(|skill| {
ContextualUserFragment::into(crate::context::SkillInstructions::from(skill))
})
.collect(),
None => skill_items,
};
injection_items.extend(plugin_items);
injection_items.extend(extension_injection_items);
Some((injection_items, explicitly_enabled_connectors))
@@ -685,7 +698,7 @@ async fn track_turn_resolved_config_analytics(
permission_profile: turn_context.permission_profile(),
#[allow(deprecated)]
permission_profile_cwd: turn_context.cwd.to_path_buf(),
reasoning_effort: turn_context.reasoning_effort,
reasoning_effort: turn_context.reasoning_effort.clone(),
reasoning_summary: Some(turn_context.reasoning_summary),
service_tier: turn_context
.config
@@ -1379,6 +1392,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::ModelReroute(_)
| EventMsg::ModelVerification(_)
| EventMsg::TurnModerationMetadata(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::TurnStarted(_)
@@ -1786,7 +1800,7 @@ async fn try_run_sampling_request(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_effort.clone(),
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
turn_metadata_header,
@@ -1814,7 +1828,6 @@ async fn try_run_sampling_request(
!sess.services.extensions.turn_item_contributors().is_empty();
let mut active_item_is_streaming_to_client = false;
let receiving_span = trace_span!("receiving_stream");
let mut completed_response_id: Option<String> = None;
let outcome: CodexResult<SamplingRequestResult> = loop {
let handle_responses = trace_span!(
parent: &receiving_span,
@@ -1912,6 +1925,7 @@ async fn try_run_sampling_request(
role == "assistant" && matches!(phase, Some(MessagePhase::Commentary))
}
ResponseItem::Reasoning { .. } => true,
ResponseItem::AgentMessage { .. } => false,
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
@@ -2044,6 +2058,10 @@ async fn try_run_sampling_request(
.await;
}
}
ResponseEvent::TurnModerationMetadata(metadata) => {
sess.emit_turn_moderation_metadata(&turn_context, metadata)
.await;
}
ResponseEvent::ServerReasoningIncluded(included) => {
sess.set_server_reasoning_included(included).await;
}
@@ -2058,9 +2076,9 @@ async fn try_run_sampling_request(
sess.services.models_manager.refresh_if_new_etag(etag).await;
}
ResponseEvent::Completed {
response_id,
token_usage,
end_turn,
..
} => {
flush_assistant_text_segments_all(
&sess,
@@ -2076,7 +2094,6 @@ async fn try_run_sampling_request(
if let Some(false) = end_turn {
needs_follow_up = true;
}
completed_response_id = Some(response_id);
break Ok(SamplingRequestResult {
needs_follow_up,
last_agent_message,
@@ -2200,15 +2217,6 @@ async fn try_run_sampling_request(
)
.await;
if sess
.features
.enabled(Feature::ResponsesWebsocketResponseProcessed)
&& outcome.is_ok()
&& let Some(response_id) = completed_response_id.as_deref()
{
client_session.send_response_processed(response_id).await;
}
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
if should_emit_token_count {
+19 -11
View File
@@ -1,7 +1,9 @@
use super::*;
use crate::SkillLoadOutcome;
use crate::agents_md::LoadedAgentsMd;
use crate::config::GhostSnapshotConfig;
use crate::environment_selection::ResolvedTurnEnvironments;
use codex_core_skills::HostLoadedSkills;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::SessionId;
@@ -134,7 +136,8 @@ impl TurnContext {
pub(crate) fn effective_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
if self.model_info.supports_reasoning_summaries {
self.reasoning_effort
.or(self.model_info.default_reasoning_level)
.clone()
.or_else(|| self.model_info.default_reasoning_level.clone())
} else {
None
}
@@ -194,28 +197,29 @@ impl TurnContext {
let supported_reasoning_levels = model_info
.supported_reasoning_levels
.iter()
.map(|preset| preset.effort)
.map(|preset| preset.effort.clone())
.collect::<Vec<_>>();
let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort {
let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort.clone()
{
if supported_reasoning_levels.contains(&current_reasoning_effort) {
Some(current_reasoning_effort)
} else {
supported_reasoning_levels
.get(supported_reasoning_levels.len().saturating_sub(1) / 2)
.copied()
.or(model_info.default_reasoning_level)
.cloned()
.or_else(|| model_info.default_reasoning_level.clone())
}
} else {
supported_reasoning_levels
.get(supported_reasoning_levels.len().saturating_sub(1) / 2)
.copied()
.or(model_info.default_reasoning_level)
.cloned()
.or_else(|| model_info.default_reasoning_level.clone())
};
config.model_reasoning_effort = reasoning_effort;
config.model_reasoning_effort = reasoning_effort.clone();
let collaboration_mode = self.collaboration_mode.with_updates(
Some(model.clone()),
Some(reasoning_effort),
Some(reasoning_effort.clone()),
/*developer_instructions*/ None,
);
let features = self.features.clone();
@@ -361,7 +365,7 @@ impl TurnContext {
collaboration_mode: Some(self.collaboration_mode.clone()),
multi_agent_version: Some(self.multi_agent_version),
realtime_active: Some(self.realtime_active),
effort: self.reasoning_effort,
effort: self.reasoning_effort.clone(),
summary: ReasoningSummaryConfig::Auto,
}
}
@@ -526,6 +530,7 @@ impl Session {
));
let (current_date, timezone) = local_time_context();
let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone()));
extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome)));
TurnContext {
sub_id,
trace_id: current_span_trace_id(),
@@ -549,7 +554,10 @@ impl Session {
app_server_client_name: session_configuration.app_server_client_name.clone(),
developer_instructions: session_configuration.developer_instructions.clone(),
compact_prompt: session_configuration.compact_prompt.clone(),
user_instructions: session_configuration.user_instructions.clone(),
user_instructions: session_configuration
.user_instructions
.as_ref()
.map(LoadedAgentsMd::text),
collaboration_mode: session_configuration.collaboration_mode.clone(),
multi_agent_version,
personality: session_configuration.personality,
+1 -1
View File
@@ -271,7 +271,7 @@ async fn schedule_startup_prewarm_inner(
&startup_prompt,
&startup_turn_context.model_info,
&startup_turn_context.session_telemetry,
startup_turn_context.reasoning_effort,
startup_turn_context.reasoning_effort.clone(),
startup_turn_context.reasoning_summary,
startup_turn_context.config.service_tier.clone(),
startup_turn_metadata_header.as_deref(),
+16 -312
View File
@@ -1,19 +1,12 @@
use crate::shell_detect::detect_shell_type;
use crate::shell_snapshot::ShellSnapshot;
use codex_shell_command::shell_detect::DetectedShell;
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::watch;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum ShellType {
Zsh,
Bash,
PowerShell,
Sh,
Cmd,
}
pub use codex_shell_command::shell_detect::ShellType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shell {
@@ -29,13 +22,7 @@ pub struct Shell {
impl Shell {
pub fn name(&self) -> &'static str {
match self.shell_type {
ShellType::Zsh => "zsh",
ShellType::Bash => "bash",
ShellType::PowerShell => "powershell",
ShellType::Sh => "sh",
ShellType::Cmd => "cmd",
}
self.shell_type.name()
}
/// Takes a string of shell and returns the full list of command args to
@@ -88,319 +75,36 @@ impl PartialEq for Shell {
impl Eq for Shell {}
#[cfg(unix)]
fn get_user_shell_path() -> Option<PathBuf> {
let uid = unsafe { libc::getuid() };
use std::ffi::CStr;
use std::mem::MaybeUninit;
use std::ptr;
let mut passwd = MaybeUninit::<libc::passwd>::uninit();
// We cannot use getpwuid here: it returns pointers into libc-managed
// storage, which is not safe to read concurrently on all targets (the musl
// static build used by the CLI can segfault when parallel callers race on
// that buffer). getpwuid_r keeps the passwd data in caller-owned memory.
let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
let buffer_len = usize::try_from(suggested_buffer_len)
.ok()
.filter(|len| *len > 0)
.unwrap_or(1024);
let mut buffer = vec![0; buffer_len];
loop {
let mut result = ptr::null_mut();
let status = unsafe {
libc::getpwuid_r(
uid,
passwd.as_mut_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
if status == 0 {
if result.is_null() {
return None;
}
let passwd = unsafe { passwd.assume_init_ref() };
if passwd.pw_shell.is_null() {
return None;
}
let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) }
.to_string_lossy()
.into_owned();
return Some(PathBuf::from(shell_path));
}
if status != libc::ERANGE {
return None;
}
// Retry with a larger buffer until libc can materialize the passwd entry.
let new_len = buffer.len().checked_mul(2)?;
if new_len > 1024 * 1024 {
return None;
}
buffer.resize(new_len, 0);
}
}
#[cfg(not(unix))]
fn get_user_shell_path() -> Option<PathBuf> {
None
}
fn file_exists(path: &PathBuf) -> Option<PathBuf> {
if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
Some(PathBuf::from(path))
} else {
None
}
}
fn get_shell_path(
shell_type: ShellType,
provided_path: Option<&PathBuf>,
binary_name: &str,
fallback_paths: &[&str],
) -> Option<PathBuf> {
// If exact provided path exists, use it
if provided_path.and_then(file_exists).is_some() {
return provided_path.cloned();
}
// Check if the shell we are trying to load is user's default shell
// if just use it
let default_shell_path = get_user_shell_path();
if let Some(default_shell_path) = default_shell_path
&& detect_shell_type(&default_shell_path) == Some(shell_type)
&& file_exists(&default_shell_path).is_some()
{
return Some(default_shell_path);
}
if let Ok(path) = which::which(binary_name) {
return Some(path);
}
for path in fallback_paths {
//check exists
if let Some(path) = file_exists(&PathBuf::from(path)) {
return Some(path);
impl From<DetectedShell> for Shell {
fn from(detected: DetectedShell) -> Self {
Self {
shell_type: detected.shell_type,
shell_path: detected.shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
}
}
None
}
const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"];
fn get_zsh_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Zsh,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"];
fn get_bash_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Bash,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"];
fn get_sh_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Sh,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
// Note the `pwsh` and `powershell` fallback paths are where the respective
// shells are commonly installed on GitHub Actions Windows runners, but may not
// be present on all Windows machines:
// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell
#[cfg(windows)]
const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#];
#[cfg(not(windows))]
const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"];
#[cfg(windows)]
const POWERSHELL_FALLBACK_PATHS: &[&str] =
&[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#];
#[cfg(not(windows))]
const POWERSHELL_FALLBACK_PATHS: &[&str] = &[];
fn get_powershell_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS)
.or_else(|| {
get_shell_path(
ShellType::PowerShell,
path,
"powershell",
POWERSHELL_FALLBACK_PATHS,
)
});
shell_path.map(|shell_path| Shell {
shell_type: ShellType::PowerShell,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
fn get_cmd_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Cmd,
shell_path,
shell_snapshot: empty_shell_snapshot_receiver(),
})
}
#[cfg(all(test, unix))]
fn ultimate_fallback_shell() -> Shell {
if cfg!(windows) {
Shell {
shell_type: ShellType::Cmd,
shell_path: PathBuf::from("cmd.exe"),
shell_snapshot: empty_shell_snapshot_receiver(),
}
} else {
Shell {
shell_type: ShellType::Sh,
shell_path: PathBuf::from("/bin/sh"),
shell_snapshot: empty_shell_snapshot_receiver(),
}
}
codex_shell_command::shell_detect::ultimate_fallback_shell().into()
}
pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell {
detect_shell_type(shell_path)
.and_then(|shell_type| get_shell(shell_type, Some(shell_path)))
.unwrap_or(ultimate_fallback_shell())
codex_shell_command::shell_detect::get_shell_by_model_provided_path(shell_path).into()
}
pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option<Shell> {
match shell_type {
ShellType::Zsh => get_zsh_shell(path),
ShellType::Bash => get_bash_shell(path),
ShellType::PowerShell => get_powershell_shell(path),
ShellType::Sh => get_sh_shell(path),
ShellType::Cmd => get_cmd_shell(path),
}
codex_shell_command::shell_detect::get_shell(shell_type, path).map(Into::into)
}
pub fn default_user_shell() -> Shell {
default_user_shell_from_path(get_user_shell_path())
codex_shell_command::shell_detect::default_user_shell().into()
}
#[cfg(all(test, target_os = "macos"))]
fn default_user_shell_from_path(user_shell_path: Option<PathBuf>) -> Shell {
if cfg!(windows) {
get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or(ultimate_fallback_shell())
} else {
let user_default_shell = user_shell_path
.and_then(|shell| detect_shell_type(&shell))
.and_then(|shell_type| get_shell(shell_type, /*path*/ None));
let shell_with_fallback = if cfg!(target_os = "macos") {
user_default_shell
.or_else(|| get_shell(ShellType::Zsh, /*path*/ None))
.or_else(|| get_shell(ShellType::Bash, /*path*/ None))
} else {
user_default_shell
.or_else(|| get_shell(ShellType::Bash, /*path*/ None))
.or_else(|| get_shell(ShellType::Zsh, /*path*/ None))
};
shell_with_fallback.unwrap_or(ultimate_fallback_shell())
}
}
#[cfg(test)]
mod detect_shell_type_tests {
use super::*;
#[test]
fn test_detect_shell_type() {
assert_eq!(
detect_shell_type(&PathBuf::from("zsh")),
Some(ShellType::Zsh)
);
assert_eq!(
detect_shell_type(&PathBuf::from("bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(&PathBuf::from("pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("powershell")),
Some(ShellType::PowerShell)
);
assert_eq!(detect_shell_type(&PathBuf::from("fish")), None);
assert_eq!(detect_shell_type(&PathBuf::from("other")), None);
assert_eq!(
detect_shell_type(&PathBuf::from("/bin/zsh")),
Some(ShellType::Zsh)
);
assert_eq!(
detect_shell_type(&PathBuf::from("/bin/bash")),
Some(ShellType::Bash)
);
assert_eq!(
detect_shell_type(&PathBuf::from("powershell.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from(if cfg!(windows) {
"C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
} else {
"/usr/local/bin/pwsh"
})),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("pwsh.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("/usr/local/bin/pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("/bin/sh")),
Some(ShellType::Sh)
);
assert_eq!(detect_shell_type(&PathBuf::from("sh")), Some(ShellType::Sh));
assert_eq!(
detect_shell_type(&PathBuf::from("cmd")),
Some(ShellType::Cmd)
);
assert_eq!(
detect_shell_type(&PathBuf::from("cmd.exe")),
Some(ShellType::Cmd)
);
}
codex_shell_command::shell_detect::default_user_shell_from_path(user_shell_path).into()
}
#[cfg(test)]
-24
View File
@@ -1,24 +0,0 @@
use crate::shell::ShellType;
use std::path::Path;
use std::path::PathBuf;
pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option<ShellType> {
match shell_path.as_os_str().to_str() {
Some("zsh") => Some(ShellType::Zsh),
Some("sh") => Some(ShellType::Sh),
Some("cmd") => Some(ShellType::Cmd),
Some("bash") => Some(ShellType::Bash),
Some("pwsh") => Some(ShellType::PowerShell),
Some("powershell") => Some(ShellType::PowerShell),
_ => {
let shell_name = shell_path.file_stem();
if let Some(shell_name) = shell_name {
let shell_name_path = Path::new(shell_name);
if shell_name_path != Path::new(shell_path) {
return detect_shell_type(&shell_name_path.to_path_buf());
}
}
None
}
}
}
+3 -5
View File
@@ -151,9 +151,7 @@ impl ShellSnapshot {
});
// Make the new snapshot.
if let Err(err) =
write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await
{
if let Err(err) = write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await {
tracing::warn!(
"Failed to create shell snapshot for {}: {err:?}",
shell.name()
@@ -203,7 +201,7 @@ async fn write_shell_snapshot(
if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd {
bail!("Shell snapshot not supported yet for {shell_type:?}");
}
let shell = get_shell(shell_type.clone(), /*path*/ None)
let shell = get_shell(shell_type, /*path*/ None)
.with_context(|| format!("No available shell for {shell_type:?}"))?;
let raw_snapshot = capture_snapshot(&shell, cwd).await?;
@@ -225,7 +223,7 @@ async fn write_shell_snapshot(
}
async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result<String> {
let shell_type = shell.shell_type.clone();
let shell_type = shell.shell_type;
match shell_type {
ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script(), cwd).await,
ShellType::Bash => run_shell_script(shell, &bash_snapshot_script(), cwd).await,
+2 -1
View File
@@ -38,7 +38,8 @@ use tracing::warn;
const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images";
pub(crate) fn image_generation_artifact_path(
/// Returns the host-owned default artifact path for a generated image.
pub fn image_generation_artifact_path(
codex_home: &AbsolutePathBuf,
session_id: &str,
call_id: &str,
@@ -83,7 +83,7 @@ async fn handle_spawn_agent(
sender_thread_id: session.thread_id,
prompt: prompt.clone(),
model: args.model.clone().unwrap_or_default(),
reasoning_effort: args.reasoning_effort.unwrap_or_default(),
reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(),
}
.into(),
)
@@ -94,14 +94,18 @@ async fn handle_spawn_agent(
config.service_tier = Some(service_tier.clone());
}
if args.fork_context {
reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?;
reject_full_fork_spawn_overrides(
role_name,
args.model.as_deref(),
args.reasoning_effort.clone(),
)?;
} else {
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut config,
args.model.as_deref(),
args.reasoning_effort,
args.reasoning_effort.clone(),
)
.await?;
apply_role_to_config(&mut config, role_name)
@@ -174,7 +178,7 @@ async fn handle_spawn_agent(
.unwrap_or_else(|| args.model.clone().unwrap_or_default());
let effective_reasoning_effort = agent_snapshot
.as_ref()
.and_then(|snapshot| snapshot.reasoning_effort)
.and_then(|snapshot| snapshot.reasoning_effort.clone())
.unwrap_or(args.reasoning_effort.unwrap_or_default());
let nickname = new_agent_nickname.clone();
session
@@ -224,7 +224,8 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallE
config.model_provider = turn.provider.info().clone();
config.model_reasoning_effort = turn
.reasoning_effort
.or(turn.model_info.default_reasoning_level);
.clone()
.or_else(|| turn.model_info.default_reasoning_level.clone());
config.model_reasoning_summary = Some(turn.reasoning_summary);
config.developer_instructions = turn.developer_instructions.clone();
config.compact_prompt = turn.compact_prompt.clone();
@@ -304,7 +305,7 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides(
validate_spawn_agent_reasoning_effort(
&selected_model_name,
&selected_model_info.supported_reasoning_levels,
reasoning_effort,
&reasoning_effort,
)?;
config.model_reasoning_effort = Some(reasoning_effort);
} else {
@@ -318,7 +319,7 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides(
validate_spawn_agent_reasoning_effort(
&turn.model_info.slug,
&turn.model_info.supported_reasoning_levels,
reasoning_effort,
&reasoning_effort,
)?;
config.model_reasoning_effort = Some(reasoning_effort);
}
@@ -404,11 +405,11 @@ fn find_spawn_agent_model_name(
fn validate_spawn_agent_reasoning_effort(
model: &str,
supported_reasoning_levels: &[ReasoningEffortPreset],
requested_reasoning_effort: ReasoningEffort,
requested_reasoning_effort: &ReasoningEffort,
) -> Result<(), FunctionCallError> {
if supported_reasoning_levels
.iter()
.any(|preset| preset.effort == requested_reasoning_effort)
.any(|preset| &preset.effort == requested_reasoning_effort)
{
return Ok(());
}
@@ -17,6 +17,7 @@ const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str =
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str =
"Service tier override for the new agent. Omit unless explicitly requested.";
const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5;
const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64;
#[derive(Debug, Clone, Default)]
pub struct SpawnAgentToolOptions {
@@ -166,7 +167,8 @@ pub fn create_send_message_tool() -> ToolSpec {
"message".to_string(),
JsonSchema::string(Some(
"Message text to queue on the target agent.".to_string(),
)),
))
.with_encrypted(),
),
]);
@@ -198,13 +200,14 @@ pub fn create_followup_task_tool() -> ToolSpec {
"message".to_string(),
JsonSchema::string(Some(
"Message text to send to the target agent.".to_string(),
)),
))
.with_encrypted(),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "followup_task".to_string(),
description: "Send a follow-up task 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."
description: "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes."
.to_string(),
strict: false,
defer_loading: None,
@@ -594,7 +597,10 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
BTreeMap::from([
(
"message".to_string(),
JsonSchema::string(Some("Initial plain-text task for the new agent.".to_string())),
JsonSchema::string(Some(
"Initial plain-text task for the new agent.".to_string(),
))
.with_encrypted(),
),
(
"agent_type".to_string(),
@@ -762,13 +768,20 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
let model_descriptions = visible_models
.into_iter()
.map(|model| {
let default_reasoning_effort = model.default_reasoning_effort;
let default_reasoning_effort = &model.default_reasoning_effort;
let efforts = model
.supported_reasoning_efforts
.iter()
.map(|preset| {
let effort = preset.effort;
if effort == default_reasoning_effort {
let effort = preset.effort.as_str();
let effort = match effort
.char_indices()
.nth(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION)
{
Some((index, _)) => &effort[..index],
None => effort,
};
if &preset.effort == default_reasoning_effort {
format!("{effort} (default)")
} else {
effort.to_string()
@@ -81,6 +81,12 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
assert!(!description.contains("hidden-model"));
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message"));
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
@@ -141,6 +147,12 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
None
);
assert_eq!(
properties
.get("model")
@@ -186,6 +198,27 @@ fn spawn_agent_tool_caps_visible_model_summaries() {
assert!(!description.contains("`sixth-model`"));
}
#[test]
fn spawn_agent_tool_caps_reasoning_effort_value_length() {
let mut model = model_preset("visible", /*show_in_picker*/ true);
let custom_effort = ReasoningEffort::Custom(
"é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION + 1),
);
model.default_reasoning_effort = custom_effort.clone();
model.supported_reasoning_efforts = vec![ReasoningEffortPreset {
effort: custom_effort,
description: "Model-defined".to_string(),
}];
assert_eq!(
spawn_agent_models_description(&[model]),
format!(
"Available model overrides (optional; inherited parent model is preferred):\n- `visible-model`: visible description Reasoning efforts: {} (default). Service tiers: priority.",
"é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION)
)
);
}
#[test]
fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
@@ -238,6 +271,12 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
.expect("send_message should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
@@ -257,6 +296,7 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
fn followup_task_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
name,
description,
parameters,
output_schema,
..
@@ -265,6 +305,10 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() {
panic!("followup_task should be a function tool");
};
assert_eq!(name, "followup_task");
assert_eq!(
description,
"Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes."
);
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
@@ -275,6 +319,12 @@ 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_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
assert!(!properties.contains_key("items"));
assert_eq!(
parameters.required.as_ref(),
@@ -1,4 +1,5 @@
use super::*;
use crate::LoadedAgentsMd;
use crate::ThreadManager;
use crate::config::AgentRoleConfig;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
@@ -1151,7 +1152,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"message": "encrypted-spawn-message",
"task_name": "test_process"
})),
))
@@ -1187,7 +1188,8 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/test_process"
&& communication.other_recipients.is_empty()
&& communication.content == "inspect this repo"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("encrypted-spawn-message")
&& communication.trigger_turn
)
}));
@@ -1199,7 +1201,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
"send_message",
function_payload(json!({
"target": "test_process",
"message": "continue"
"message": "encrypted-send-message"
})),
))
.await
@@ -1213,7 +1215,8 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/test_process"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("encrypted-send-message")
&& !communication.trigger_turn
)
}));
@@ -1395,7 +1398,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
"send_message",
function_payload(json!({
"target": "/root",
"message": "done"
"message": "encrypted-done"
})),
))
.await
@@ -1409,7 +1412,8 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
if communication.author == child_path
&& communication.recipient == AgentPath::root()
&& communication.other_recipients.is_empty()
&& communication.content == "done"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("encrypted-done")
&& !communication.trigger_turn
)
}));
@@ -1499,7 +1503,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
}
#[tokio::test]
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
async fn multi_agent_v2_list_agents_returns_completed_status_without_encrypted_spawn_preview() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -1585,10 +1589,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
.find(|agent| agent.agent_name == "/root/worker")
.expect("worker agent should be listed");
assert_eq!(worker.agent_status, json!({"completed": "done"}));
assert_eq!(
worker.last_task_message.as_deref(),
Some("inspect this repo")
);
assert_eq!(worker.last_task_message, None);
assert_eq!(success, Some(true));
}
@@ -1867,7 +1868,8 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/worker"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("continue")
&& !communication.trigger_turn
)));
}
@@ -4373,7 +4375,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() {
expected.base_instructions = Some(base_instructions.text);
expected.model = Some(turn.model_info.slug.clone());
expected.model_provider = turn.provider.info().clone();
expected.model_reasoning_effort = turn.reasoning_effort;
expected.model_reasoning_effort = turn.reasoning_effort.clone();
expected.model_reasoning_summary = Some(turn.reasoning_summary);
expected.developer_instructions = turn.developer_instructions.clone();
expected.compact_prompt = turn.compact_prompt.clone();
@@ -4399,7 +4401,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() {
async fn build_agent_spawn_config_preserves_base_user_instructions() {
let (_session, mut turn) = make_session_and_context().await;
let mut base_config = (*turn.config).clone();
base_config.user_instructions = Some("base-user".to_string());
base_config.user_instructions = Some(LoadedAgentsMd::new_user(
"base-user".to_string(),
base_config.codex_home.join("AGENTS.md"),
));
turn.user_instructions = Some("resolved-user".to_string());
turn.config = Arc::new(base_config.clone());
let base_instructions = BaseInstructions {
@@ -4427,7 +4432,7 @@ async fn build_agent_resume_config_clears_base_instructions() {
expected.base_instructions = None;
expected.model = Some(turn.model_info.slug.clone());
expected.model_provider = turn.provider.info().clone();
expected.model_reasoning_effort = turn.reasoning_effort;
expected.model_reasoning_effort = turn.reasoning_effort.clone();
expected.model_reasoning_summary = Some(turn.reasoning_summary);
expected.developer_instructions = turn.developer_instructions.clone();
expected.compact_prompt = turn.compact_prompt.clone();
@@ -22,6 +22,7 @@ use codex_protocol::protocol::CollabCloseBeginEvent;
use codex_protocol::protocol::CollabCloseEndEvent;
use codex_protocol::protocol::CollabWaitingBeginEvent;
use codex_protocol::protocol::CollabWaitingEndEvent;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use serde::Deserialize;
@@ -42,3 +43,17 @@ mod message_tool;
mod send_message;
mod spawn;
pub(crate) mod wait;
pub(super) fn communication_from_tool_message(
author: AgentPath,
recipient: AgentPath,
message: String,
) -> InterAgentCommunication {
InterAgentCommunication::new_encrypted(
author,
recipient,
Vec::new(),
message,
/*trigger_turn*/ true,
)
}
@@ -1,4 +1,4 @@
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
//! Shared argument parsing and dispatch for the v2 agent messaging tools.
//!
//! `send_message` and `followup_task` share the same submission path and differ only in whether the
//! resulting `InterAgentCommunication` should wake the target immediately.
@@ -55,20 +55,21 @@ fn message_content(message: String) -> Result<String, FunctionCallError> {
Ok(message)
}
/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`.
/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`.
pub(crate) async fn handle_message_string_tool(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
target: String,
message: String,
) -> Result<FunctionToolOutput, FunctionCallError> {
let prompt = message_content(message)?;
let message = message_content(message)?;
let ToolInvocation {
session,
turn,
call_id,
..
} = invocation;
let prompt = String::new();
let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?;
let receiver_agent = session
.services
@@ -101,15 +102,11 @@ pub(crate) async fn handle_message_string_tool(
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
})?;
let communication = InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
);
let author = turn
.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root);
let communication = communication_from_tool_message(author, receiver_agent_path, message);
let result = session
.services
.agent_control
@@ -1,7 +1,6 @@
use super::*;
use crate::agent::control::SpawnAgentForkMode;
use crate::agent::control::SpawnAgentOptions;
use crate::agent::control::render_input_preview;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
@@ -9,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
use crate::turn_timing::now_unix_timestamp_ms;
use codex_protocol::AgentPath;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
use codex_tools::ToolSpec;
@@ -61,8 +59,9 @@ async fn handle_spawn_agent(
.map(str::trim)
.filter(|role| !role.is_empty());
let message = args.message.clone();
let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?;
let prompt = render_input_preview(&initial_operation);
let prompt = String::new();
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
@@ -75,7 +74,7 @@ async fn handle_spawn_agent(
sender_thread_id: session.thread_id,
prompt: prompt.clone(),
model: args.model.clone().unwrap_or_default(),
reasoning_effort: args.reasoning_effort.unwrap_or_default(),
reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(),
}
.into(),
)
@@ -86,14 +85,18 @@ async fn handle_spawn_agent(
config.service_tier = Some(service_tier.clone());
}
if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) {
reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?;
reject_full_fork_spawn_overrides(
role_name,
args.model.as_deref(),
args.reasoning_effort.clone(),
)?;
} else {
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut config,
args.model.as_deref(),
args.reasoning_effort,
args.reasoning_effort.clone(),
)
.await?;
apply_role_to_config(&mut config, role_name)
@@ -125,17 +128,12 @@ async fn handle_spawn_agent(
.iter()
.all(|item| matches!(item, UserInput::Text { .. })) =>
{
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
recipient,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
),
}
let author = turn
.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root);
let communication = communication_from_tool_message(author, recipient, message);
Op::InterAgentCommunication { communication }
}
(_, initial_operation) => initial_operation,
},
@@ -188,7 +186,7 @@ async fn handle_spawn_agent(
.unwrap_or_else(|| args.model.clone().unwrap_or_default());
let effective_reasoning_effort = agent_snapshot
.as_ref()
.and_then(|snapshot| snapshot.reasoning_effort)
.and_then(|snapshot| snapshot.reasoning_effort.clone())
.unwrap_or(args.reasoning_effort.unwrap_or_default());
let nickname = new_agent_nickname.clone();
session
@@ -160,7 +160,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
);
emitter.begin(event_ctx).await;
let file_system_sandbox_policy = turn.file_system_sandbox_policy();
let exec_approval_requirement = session
.services
.exec_policy
@@ -168,7 +167,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
command: &exec_params.command,
approval_policy: turn.approval_policy.value(),
permission_profile: turn.permission_profile(),
file_system_sandbox_policy: &file_system_sandbox_policy,
#[allow(deprecated)]
sandbox_cwd: turn.cwd.as_path(),
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
@@ -182,7 +182,7 @@ impl ToolExecutor<ToolInvocation> for ShellCommandHandler {
session.thread_id,
turn.config.permissions.allow_login_shell,
)?;
let shell_type = Some(session.user_shell().shell_type.clone());
let shell_type = Some(session.user_shell().shell_type);
run_exec_like(RunExecLikeArgs {
tool_name,
exec_params,
@@ -122,7 +122,7 @@ pub(crate) fn get_command(
let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref());
Ok(ResolvedCommand {
command: shell.derive_exec_args(&args.cmd, use_login_shell),
shell_type: shell.shell_type.clone(),
shell_type: shell.shell_type,
})
}
UnifiedExecShellMode::ZshFork(zsh_fork_config) => {
+81 -5
View File
@@ -39,6 +39,7 @@ use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
use std::time::Instant;
pub(crate) struct ToolOrchestrator {
sandbox: SandboxManager,
@@ -256,6 +257,7 @@ impl ToolOrchestrator {
network_denial_cancellation_token: None,
};
let initial_attempt_start = Instant::now();
let (first_result, first_deferred_network_approval) = Self::run_attempt(
tool,
req,
@@ -264,6 +266,7 @@ impl ToolOrchestrator {
managed_network_active,
)
.await;
let initial_duration = initial_attempt_start.elapsed();
match first_result {
Ok(out) => {
// We have a successful initial result
@@ -284,12 +287,26 @@ impl ToolOrchestrator {
None
};
if network_policy_decision.is_some() && network_approval_context.is_none() {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
})));
}
if !tool.escalate_on_failure() {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
@@ -312,6 +329,13 @@ impl ToolOrchestrator {
ExecApprovalRequirement::NeedsApproval { .. }
);
if !allow_on_request_network_prompt {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
@@ -319,6 +343,13 @@ impl ToolOrchestrator {
}
}
if !unsandboxed_allowed && network_approval_context.is_none() {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
@@ -400,15 +431,51 @@ impl ToolOrchestrator {
};
// Second attempt.
let escalated_attempt_start = Instant::now();
let (retry_result, retry_deferred_network_approval) =
Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active)
.await;
retry_result.map(|output| OrchestratorRunResult {
output,
deferred_network_approval: retry_deferred_network_approval,
})
let escalated_duration = escalated_attempt_start.elapsed();
match retry_result {
Ok(output) => {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"escalated",
initial_duration,
Some(escalated_duration),
);
Ok(OrchestratorRunResult {
output,
deferred_network_approval: retry_deferred_network_approval,
})
}
Err(err) => {
if let Some(outcome) = sandbox_outcome_from_tool_error(&err) {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
outcome,
initial_duration,
Some(escalated_duration),
);
}
Err(err)
}
}
}
Err(err) => {
if let Some(outcome) = sandbox_outcome_from_tool_error(&err) {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
outcome,
initial_duration,
/*escalated_duration*/ None,
);
}
Err(err)
}
Err(err) => Err(err),
}
}
@@ -509,6 +576,15 @@ impl ToolOrchestrator {
}
}
fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> {
match err {
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"),
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => Some("timed_out"),
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Signal(_))) => Some("signal"),
ToolError::Rejected(_) | ToolError::Codex(_) => None,
}
}
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
// Keep approval reason terse and stable for UX/tests, but accept the
// output so we can evolve heuristics later without touching call sites.
@@ -622,7 +622,6 @@ impl EscalationPolicy for CoreShellActionProvider {
InterceptedExecPolicyContext {
approval_policy: self.approval_policy,
permission_profile: self.permission_profile.clone(),
file_system_sandbox_policy: &self.file_system_sandbox_policy,
sandbox_cwd: self.sandbox_policy_cwd.as_path(),
sandbox_permissions: self.approval_sandbox_permissions,
enable_shell_wrapper_parsing:
@@ -677,7 +676,6 @@ fn evaluate_intercepted_exec_policy(
let InterceptedExecPolicyContext {
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
enable_shell_wrapper_parsing,
@@ -705,7 +703,6 @@ fn evaluate_intercepted_exec_policy(
crate::exec_policy::UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
@@ -727,7 +724,6 @@ fn evaluate_intercepted_exec_policy(
struct InterceptedExecPolicyContext<'a> {
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
sandbox_cwd: &'a Path,
sandbox_permissions: SandboxPermissions,
enable_shell_wrapper_parsing: bool,
@@ -522,7 +522,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars
InterceptedExecPolicyContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: sandbox_cwd.as_path(),
sandbox_permissions: SandboxPermissions::UseDefault,
enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing,
@@ -575,7 +574,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled()
InterceptedExecPolicyContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: sandbox_cwd.as_path(),
sandbox_permissions: SandboxPermissions::UseDefault,
enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing,
@@ -619,7 +617,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
InterceptedExecPolicyContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: sandbox_cwd.as_path(),
sandbox_permissions: SandboxPermissions::UseDefault,
enable_shell_wrapper_parsing: false,
@@ -747,7 +744,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default(
let argv = ["printf".to_string(), "hello".to_string()];
let approval_policy = AskForApproval::OnRequest;
let permission_profile = PermissionProfile::workspace_write();
let file_system_sandbox_policy = read_only_file_system_sandbox_policy();
let sandbox_cwd = test_sandbox_cwd();
let preapproved = evaluate_intercepted_exec_policy(
@@ -757,7 +753,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default(
InterceptedExecPolicyContext {
approval_policy,
permission_profile: permission_profile.clone(),
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_cwd: sandbox_cwd.as_path(),
sandbox_permissions: super::approval_sandbox_permissions(
SandboxPermissions::WithAdditionalPermissions,
@@ -773,7 +768,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default(
InterceptedExecPolicyContext {
approval_policy,
permission_profile,
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_cwd: sandbox_cwd.as_path(),
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
enable_shell_wrapper_parsing: false,
@@ -808,7 +802,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"])
InterceptedExecPolicyContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: sandbox_cwd.as_path(),
sandbox_permissions: SandboxPermissions::UseDefault,
enable_shell_wrapper_parsing: false,
+33 -17
View File
@@ -207,7 +207,12 @@ fn build_model_visible_specs_and_registry(
if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure)
{
let spec = runtime.spec();
specs.push(spec_for_model_request(turn_context, exposure, spec));
specs.push(spec_for_model_request(
turn_context,
exposure,
&tool_name,
spec,
));
}
}
specs.extend(hosted_specs);
@@ -226,12 +231,14 @@ fn build_model_visible_specs_and_registry(
fn spec_for_model_request(
turn_context: &TurnContext,
exposure: ToolExposure,
tool_name: &ToolName,
spec: ToolSpec,
) -> ToolSpec {
if matches!(
turn_context.tool_mode,
ToolMode::CodeMode | ToolMode::CodeModeOnly
) && exposure != ToolExposure::DirectModelOnly
&& !is_excluded_from_code_mode(turn_context, tool_name)
&& codex_code_mode::is_code_mode_nested_tool(spec.name())
{
codex_tools::augment_tool_spec_for_code_mode(spec)
@@ -405,10 +412,19 @@ fn is_hidden_by_code_mode_only(
))
}
fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool {
tool_name.namespace.as_ref().is_some_and(|namespace| {
turn_context
.config
.code_mode
.excluded_tool_namespaces
.contains(namespace)
})
}
fn build_code_mode_executors(
turn_context: &TurnContext,
executors: &[Arc<dyn CoreToolRuntime>],
deferred_tools_available: bool,
) -> Vec<Arc<dyn CoreToolRuntime>> {
if !matches!(
turn_context.tool_mode,
@@ -419,6 +435,8 @@ fn build_code_mode_executors(
let mut code_mode_nested_tool_specs = Vec::new();
let mut exec_prompt_tool_specs = Vec::new();
let mut deferred_tools_available = false;
let deferred_tools_guidance_enabled = search_tool_enabled(turn_context);
for executor in executors {
let exposure = executor.exposure();
if exposure == ToolExposure::DirectModelOnly {
@@ -428,9 +446,19 @@ fn build_code_mode_executors(
if exposure == ToolExposure::Hidden {
continue;
}
if is_excluded_from_code_mode(turn_context, &executor.tool_name()) {
continue;
}
let spec = executor.spec();
if exposure != ToolExposure::Deferred {
if exposure == ToolExposure::Deferred {
// Only show deferred-tool guidance when supported and an included spec is usable by code mode.
deferred_tools_available |= deferred_tools_guidance_enabled
&& !collect_code_mode_exec_prompt_tool_definitions(std::iter::once(&spec))
.is_empty();
} else {
exec_prompt_tool_specs.push(spec.clone());
}
code_mode_nested_tool_specs.push(spec);
@@ -734,10 +762,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
SpawnAgentHandler::new(SpawnAgentToolOptions {
available_models: turn_context.available_models.clone(),
agent_type_description,
hide_agent_type_model_reasoning: turn_context
.config
.multi_agent_v2
.hide_spawn_agent_metadata,
hide_agent_type_model_reasoning: false,
include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled,
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
max_concurrent_threads_per_session: max_concurrent_threads_per_session(
@@ -839,16 +864,7 @@ fn prepend_code_mode_executors(
planned_tools: &mut PlannedTools,
) {
let turn_context = context.turn_context;
let deferred_tools_available = search_tool_enabled(turn_context)
&& planned_tools
.runtimes()
.iter()
.any(|executor| executor.exposure() == ToolExposure::Deferred);
let code_mode_executors = build_code_mode_executors(
turn_context,
planned_tools.runtimes(),
deferred_tools_available,
);
let code_mode_executors = build_code_mode_executors(turn_context, planned_tools.runtimes());
planned_tools.runtimes.splice(0..0, code_mode_executors);
}
@@ -938,6 +938,42 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() {
);
}
#[tokio::test]
async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() {
let plan = probe_with(
|turn| {
set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);
set_feature(turn, Feature::Collab, /*enabled*/ false);
turn.model_info.supports_search_tool = true;
update_config(turn, |config| {
config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()];
});
},
ToolPlanInputs {
dynamic_tools: vec![dynamic_tool(
Some("excluded"),
"lookup",
/*defer_loading*/ true,
)],
..ToolPlanInputs::default()
},
)
.await;
let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else {
panic!("expected code mode exec tool");
};
assert!(
!exec
.description
.contains("Some deferred nested tools may be omitted")
);
plan.assert_registered_contains(&[
&ToolName::namespaced("excluded", "lookup").to_string(),
"tool_search",
]);
}
#[tokio::test]
async fn multi_agent_feature_selects_one_agent_tool_family() {
let v1 = probe(|turn| {
@@ -967,6 +1003,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
"wait_agent".to_string(),
]
);
let ToolSpec::Namespace(namespace) = v1.visible_spec(MULTI_AGENT_V1_NAMESPACE) else {
panic!("expected v1 multi-agent namespace");
};
let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) =
namespace.tools.iter().find(|tool| {
matches!(
tool,
ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent"
)
})
else {
panic!("expected v1 spawn_agent function");
};
let properties = spawn_agent
.parameters
.properties
.as_ref()
.expect("spawn_agent should use object params");
for property in ["agent_type", "model", "reasoning_effort", "service_tier"] {
assert!(
properties.contains_key(property),
"expected v1 spawn_agent to expose `{property}`"
);
}
let v2 = probe(|turn| {
set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);
@@ -1011,6 +1071,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
);
}
#[tokio::test]
async fn multi_agent_v2_message_schemas_are_encrypted() {
let plan = probe(|turn| {
set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);
})
.await;
for tool_name in ["spawn_agent", "send_message", "followup_task"] {
let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else {
panic!("expected {tool_name} function spec");
};
let properties = tool
.parameters
.properties
.as_ref()
.expect("tool should use object params");
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
}
}
#[tokio::test]
async fn tool_mode_selector_overrides_feature_flags() {
let direct = probe(|turn| {
+2
View File
@@ -147,6 +147,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool {
ResponseEvent::Created
| ResponseEvent::ServerModel(_)
| ResponseEvent::ModelVerifications(_)
| ResponseEvent::TurnModerationMetadata(_)
| ResponseEvent::ServerReasoningIncluded(_)
| ResponseEvent::ToolCallInputDelta { .. }
| ResponseEvent::Completed { .. }
@@ -177,6 +178,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
})
})
}
ResponseItem::AgentMessage { .. } => false,
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
@@ -1011,7 +1011,6 @@ impl UnifiedExecProcessManager {
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone());
let file_system_sandbox_policy = context.turn.file_system_sandbox_policy();
let exec_approval_requirement = context
.session
.services
@@ -1020,7 +1019,6 @@ impl UnifiedExecProcessManager {
command: &request.command,
approval_policy: context.turn.approval_policy.value(),
permission_profile: context.turn.permission_profile(),
file_system_sandbox_policy: &file_system_sandbox_policy,
// The process cwd may be model-controlled. Policy resolution
// stays anchored to the selected turn environment cwd instead.
sandbox_cwd: request.sandbox_cwd.as_path(),
@@ -1034,7 +1032,7 @@ impl UnifiedExecProcessManager {
.await;
let req = UnifiedExecToolRequest {
command: request.command.clone(),
shell_type: request.shell_type.clone(),
shell_type: request.shell_type,
hook_command: request.hook_command.clone(),
process_id: request.process_id,
cwd,