Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion codex-rs/core/src/agent/role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,26 @@ pub(crate) mod spawn_tool_spec {
/// Builds the spawn-agent tool description text from built-in and configured roles.
pub(crate) fn build(user_defined_agent_roles: &BTreeMap<String, AgentRoleConfig>) -> String {
let built_in_roles = built_in::configs();
build_from_configs(built_in_roles, user_defined_agent_roles)
build_from_configs(built_in_roles, user_defined_agent_roles, format_role)
}

/// Builds the spawn-agent tool description without role configuration metadata.
pub(crate) fn build_without_metadata(
user_defined_agent_roles: &BTreeMap<String, AgentRoleConfig>,
) -> String {
let built_in_roles = built_in::configs();
build_from_configs(
built_in_roles,
user_defined_agent_roles,
format_role_without_metadata,
)
}

// This function is not inlined for testing purpose.
fn build_from_configs(
built_in_roles: &BTreeMap<String, AgentRoleConfig>,
user_defined_roles: &BTreeMap<String, AgentRoleConfig>,
format_role: fn(&str, &AgentRoleConfig) -> String,
) -> String {
let mut seen = BTreeSet::new();
let mut formatted_roles = Vec::new();
Expand Down Expand Up @@ -300,6 +313,14 @@ pub(crate) mod spawn_tool_spec {
format!("{name}: no description")
}
}

fn format_role_without_metadata(name: &str, declaration: &AgentRoleConfig) -> String {
if let Some(description) = &declaration.description {
format!("{name}: {{\n{description}\n}}")
} else {
format!("{name}: no description")
}
}
}

mod built_in {
Expand Down
4 changes: 3 additions & 1 deletion codex-rs/core/src/tools/handlers/multi_agents_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,9 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
}

fn hide_spawn_agent_metadata_options(properties: &mut BTreeMap<String, JsonSchema>) {
properties.remove("agent_type");
// `agent_type` is the selector for configured roles, not an optional runtime
// override. Hiding it makes discovered roles impossible to choose even
// though both V1 and V2 handlers still validate and apply the field.
properties.remove("model");
properties.remove("provider");
properties.remove("reasoning_effort");
Expand Down
11 changes: 9 additions & 2 deletions codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() {
}

#[test]
fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
fn spawn_agent_tool_keeps_agent_type_while_hiding_spawn_metadata() {
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
agent_type_description: "role help".to_string(),
Expand All @@ -282,6 +282,7 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
}) = tool
else {
Expand All @@ -292,14 +293,20 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
.as_ref()
.expect("spawn_agent should use object params");

assert!(!properties.contains_key("agent_type"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::string(Some("role help".to_string())))
);
assert!(!properties.contains_key("model"));
assert!(!properties.contains_key("provider"));
assert!(!properties.contains_key("reasoning_effort"));
assert!(!properties.contains_key("service_tier"));
assert!(!properties.contains_key("auth_profile"));
assert!(!description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE));
assert!(!description.contains("Available model overrides"));
let output_schema = output_schema.expect("spawn_agent output schema");
assert_eq!(output_schema["required"], json!(["task_name"]));
assert!(output_schema["properties"].get("nickname").is_none());
}

#[test]
Expand Down
31 changes: 25 additions & 6 deletions codex-rs/core/src/tools/spec_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,20 @@ fn agent_type_description(
}
}

fn agent_type_description_without_metadata(
turn_context: &TurnContext,
default_agent_type_description: &str,
) -> String {
let agent_type_description = crate::agent::role::spawn_tool_spec::build_without_metadata(
&turn_context.config.agent_roles,
);
if agent_type_description.is_empty() {
default_agent_type_description.to_string()
} else {
agent_type_description
}
}

fn is_hidden_by_code_mode(
turn_context: &TurnContext,
tool_name: &ToolName,
Expand Down Expand Up @@ -961,17 +975,22 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
let tool_namespace = namespace_tools_enabled(turn_context)
.then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref())
.flatten();
let agent_type_description =
agent_type_description(turn_context, context.default_agent_type_description);
let hide_spawn_agent_metadata =
turn_context.config.multi_agent_v2.hide_spawn_agent_metadata;
let agent_type_description = if hide_spawn_agent_metadata {
agent_type_description_without_metadata(
turn_context,
context.default_agent_type_description,
)
} else {
agent_type_description(turn_context, context.default_agent_type_description)
};
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(
SpawnAgentHandlerV2::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: hide_spawn_agent_metadata,
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(
Expand Down
78 changes: 78 additions & 0 deletions codex-rs/core/src/tools/spec_plan_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ use codex_tools::ToolSpec;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;

use crate::config::ConfigBuilder;
use crate::session::tests::make_session_and_context;
use crate::session::turn_context::TurnContext;
use crate::tools::context::ToolPayload;
Expand Down Expand Up @@ -1421,6 +1423,82 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
);
}

#[tokio::test]
async fn multi_agent_v2_default_spawn_schema_keeps_custom_roles_selectable() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let agents_dir = codex_home.path().join("agents");
std::fs::create_dir(&agents_dir)?;
let role_path = agents_dir.join("synthetic.toml");
std::fs::write(
&role_path,
r#"name = "synthetic"
description = "Synthetic workspace role."
developer_instructions = "Stay focused."
model = "metadata-role-model"
model_provider = "openai"
model_reasoning_effort = "high"
service_tier = "priority"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
let role = config
.agent_roles
.get("synthetic")
.expect("config-backed role should be discovered");
assert_eq!(
role.description.as_deref(),
Some("Synthetic workspace role.")
);
assert_eq!(role.config_file.as_deref(), Some(role_path.as_path()));

let plan = probe(move |turn| {
turn.config = Arc::new(config);
set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);
assert!(turn.config.multi_agent_v2.hide_spawn_agent_metadata);
})
.await;

let ToolSpec::Function(spawn_agent) = plan.visible_spec("spawn_agent") else {
panic!("expected v2 spawn_agent function");
};
let properties = spawn_agent
.parameters
.properties
.as_ref()
.expect("spawn_agent should use object params");
let agent_type_description = properties
.get("agent_type")
.and_then(|schema| schema.description.as_deref())
.expect("default v2 spawn_agent should expose agent_type");
assert!(agent_type_description.contains("synthetic: {\nSynthetic workspace role.\n}"));
for hidden_role_metadata in ["metadata-role-model", "`openai`", "`high`", "`priority`"] {
assert!(
!agent_type_description.contains(hidden_role_metadata),
"expected default v2 agent_type description to hide `{hidden_role_metadata}`"
);
}

for hidden_override in [
"model",
"provider",
"reasoning_effort",
"service_tier",
"auth_profile",
] {
assert!(
!properties.contains_key(hidden_override),
"expected default v2 spawn_agent to keep `{hidden_override}` hidden"
);
}

Ok(())
}

#[tokio::test]
async fn multi_agent_v2_message_schemas_are_encrypted() {
let plan = probe(|turn| {
Expand Down
Loading