From fc26af377fc64cc42ff0709a9fcebefb4f5b6b80 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 13 May 2026 19:49:47 +0200 Subject: [PATCH] feat: expose multi-agent v2 as model-only tools (#22514) ## Why `code_mode_only` filters code-mode nested tools out of the top-level tool list. For multi-agent v2, we need a rollout shape where the collaboration tools remain callable as normal model tools without also being embedded into the code-mode `exec` tool declaration. Related to this: https://openai-corpws.slack.com/archives/C0AQLHB4U75/p1778660267922549 ## What Changed - Adds `features.multi_agent_v2.non_code_mode_only`, including config resolution, profile override handling, and generated schema coverage. - Introduces `ToolExposure::DirectModelOnly` so a tool can be included in the initial model-visible list while staying out of the nested code-mode tool surface. - Applies that exposure to the multi-agent v2 tools when the new flag is set: `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `close_agent`, and `list_agents`. - Updates code-mode-only filtering so direct-model-only tools remain visible while ordinary nested code-mode tools are still hidden. ## Verification - Added config parsing/profile tests for `non_code_mode_only`. - Added tool spec coverage for the code-mode-only multi-agent v2 exposure behavior. --- codex-rs/core/config.schema.json | 3 + codex-rs/core/src/config/config_tests.rs | 6 ++ codex-rs/core/src/config/mod.rs | 7 ++ codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/turn_context.rs | 2 + codex-rs/core/src/tools/registry.rs | 79 +++++++++++++++++++++- codex-rs/core/src/tools/router.rs | 21 ++++-- codex-rs/core/src/tools/spec_plan.rs | 80 ++++++++++++++++------- codex-rs/core/src/tools/spec_tests.rs | 64 ++++++++++++++++++ codex-rs/features/src/feature_configs.rs | 2 + codex-rs/features/src/tests.rs | 3 + codex-rs/tools/src/tool_config.rs | 11 ++++ codex-rs/tools/src/tool_executor.rs | 22 ++++++- 13 files changed, 269 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5c0100c981..faf7ec6817 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1485,6 +1485,9 @@ "minimum": 1.0, "type": "integer" }, + "non_code_mode_only": { + "type": "boolean" + }, "root_agent_usage_hint_text": { "type": "string" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b56ad5afc2..9fc8c2643f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9656,6 +9656,7 @@ usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true +non_code_mode_only = true "#, )?; @@ -9683,6 +9684,7 @@ hide_spawn_agent_metadata = true Some("Subagent guidance.") ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9702,6 +9704,7 @@ usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" subagent_usage_hint_text = "base subagent hint" hide_spawn_agent_metadata = true +non_code_mode_only = false [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 @@ -9711,6 +9714,7 @@ usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" subagent_usage_hint_text = "profile subagent hint" hide_spawn_agent_metadata = false +non_code_mode_only = true "#, )?; @@ -9736,6 +9740,7 @@ hide_spawn_agent_metadata = false Some("profile subagent hint") ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9759,6 +9764,7 @@ enabled = true assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000); assert_eq!(config.agent_max_threads, Some(3)); + assert!(!config.multi_agent_v2.non_code_mode_only); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b42a660e8..3b4e674446 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -846,6 +846,7 @@ pub struct MultiAgentV2Config { pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, + pub non_code_mode_only: bool, } impl Default for MultiAgentV2Config { @@ -859,6 +860,7 @@ impl Default for MultiAgentV2Config { root_agent_usage_hint_text: None, subagent_usage_hint_text: None, hide_spawn_agent_metadata: false, + non_code_mode_only: false, } } } @@ -1995,6 +1997,10 @@ fn resolve_multi_agent_v2_config( .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) .unwrap_or(default.hide_spawn_agent_metadata); + let non_code_mode_only = profile + .and_then(|config| config.non_code_mode_only) + .or_else(|| base.and_then(|config| config.non_code_mode_only)) + .unwrap_or(default.non_code_mode_only); MultiAgentV2Config { max_concurrent_threads_per_session, @@ -2004,6 +2010,7 @@ fn resolve_multi_agent_v2_config( root_agent_usage_hint_text, subagent_usage_hint_text, hide_spawn_agent_metadata, + non_code_mode_only, } } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index f884a95a27..083aac8c04 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread( .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) .with_wait_agent_min_timeout_ms( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 12a1ccb79d..7b22ddcc39 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -218,6 +218,7 @@ impl TurnContext { .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session( config @@ -512,6 +513,7 @@ impl Session { .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session( per_turn_config diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index ad54b5fb77..85c346f48f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -263,6 +263,79 @@ where } } +pub(crate) fn override_tool_exposure( + handler: Arc, + exposure: ToolExposure, +) -> Arc { + if handler.exposure() == exposure { + return handler; + } + + Arc::new(ExposureOverride { handler, exposure }) +} + +struct ExposureOverride { + handler: Arc, + exposure: ToolExposure, +} + +impl RegisteredTool for ExposureOverride { + fn tool_name(&self) -> ToolName { + self.handler.tool_name() + } + + fn spec(&self) -> Option { + self.handler.spec() + } + + fn exposure(&self) -> ToolExposure { + self.exposure + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + self.handler.pre_tool_use_payload(invocation) + } + + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: Value, + ) -> Result { + self.handler + .with_updated_hook_input(invocation, updated_input) + } + + fn telemetry_tags<'a>( + &'a self, + invocation: &'a ToolInvocation, + ) -> BoxFuture<'a, ToolTelemetryTags> { + self.handler.telemetry_tags(invocation) + } + + fn create_diff_consumer(&self) -> Option> { + self.handler.create_diff_consumer() + } + + fn handle_any<'a>( + &'a self, + invocation: ToolInvocation, + ) -> BoxFuture<'a, Result> { + self.handler.handle_any(invocation) + } +} + pub struct ToolRegistry { handlers: HashMap>, } @@ -290,6 +363,10 @@ impl ToolRegistry { self.handlers.get(name).map(Arc::clone) } + pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { + self.handlers.get(name).map(|handler| handler.exposure()) + } + #[cfg(test)] pub(crate) fn has_handler(&self, name: &ToolName) -> bool { self.handler(name).is_some() @@ -584,7 +661,7 @@ impl ToolRegistryBuilder { } if include_spec - && handler.exposure() == ToolExposure::Direct + && handler.exposure().is_direct() && let Some(spec) = handler.spec() { self.push_spec(spec); diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index d69e41ab19..23f0207734 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolArgumentDiffConsumer; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; use crate::tools::spec::build_specs_with_discoverable_tools; use codex_extension_api::ExtensionToolExecutor; @@ -63,10 +64,7 @@ impl ToolRouter { let (specs, registry) = builder.build(); let model_visible_specs = specs .into_iter() - .filter(|spec| { - !config.code_mode_only_enabled - || !codex_code_mode::is_code_mode_nested_tool(spec.name()) - }) + .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) .collect(); Self { @@ -173,6 +171,21 @@ impl ToolRouter { } } +fn is_hidden_by_code_mode_only( + config: &ToolsConfig, + registry: &ToolRegistry, + spec: &ToolSpec, +) -> bool { + if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { + return false; + } + + let exposure = registry + .tool_exposure(&ToolName::plain(spec.name())) + .unwrap_or(ToolExposure::Direct); + exposure != ToolExposure::DirectModelOnly +} + pub(crate) fn extension_tool_executors(session: &Session) -> Vec> { session .services diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c4db408c55..3d37d58b1b 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -43,6 +43,7 @@ use crate::tools::hosted_spec::create_web_search_tool; use crate::tools::registry::RegisteredTool; use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::registry::override_tool_exposure; use crate::tools::spec_plan_types::ToolRegistryBuildParams; use crate::tools::spec_plan_types::agent_type_description; use codex_extension_api::ExtensionToolExecutor; @@ -79,9 +80,9 @@ pub fn build_tool_registry_builder( let mut deferred_search_infos = Vec::new(); for handler in &handlers { match handler.exposure() { - ToolExposure::Direct => { + ToolExposure::Direct | ToolExposure::DirectModelOnly => { if let Some(spec) = handler.spec() { - non_deferred_specs.push(spec); + non_deferred_specs.push((spec, handler.exposure())); } } ToolExposure::Deferred => { @@ -97,21 +98,27 @@ pub fn build_tool_registry_builder( web_search_config: config.web_search_config.as_ref(), web_search_tool_type: config.web_search_tool_type, }) { - non_deferred_specs.push(web_search_tool); + non_deferred_specs.push((web_search_tool, ToolExposure::Direct)); } if config.image_gen_tool { - non_deferred_specs.push(create_image_generation_tool("png")); + non_deferred_specs.push((create_image_generation_tool("png"), ToolExposure::Direct)); } + let non_deferred_specs = non_deferred_specs + .into_iter() + .map(|(spec, exposure)| { + if config.code_mode_enabled && exposure != ToolExposure::DirectModelOnly { + codex_tools::augment_tool_spec_for_code_mode(spec) + } else { + spec + } + }) + .collect(); + for spec in merge_into_namespaces(non_deferred_specs) { if !config.namespace_tools && matches!(spec, ToolSpec::Namespace(_)) { continue; } - let spec = if config.code_mode_enabled { - codex_tools::augment_tool_spec_for_code_mode(spec) - } else { - spec - }; builder.push_spec(spec); } @@ -142,7 +149,14 @@ fn build_code_mode_handlers( let mut code_mode_nested_tool_specs = handlers .iter() - .filter_map(|handler| handler.spec()) + .filter_map(|handler| { + if handler.exposure() == ToolExposure::DirectModelOnly { + return None; + } + + let spec = handler.spec()?; + Some(spec) + }) .collect::>(); code_mode_nested_tool_specs.extend( extension_tool_executors @@ -344,23 +358,32 @@ fn collect_handler_tools( if config.collab_tools { if config.multi_agent_v2 { + let exposure = if config.multi_agent_v2_non_code_mode_only { + ToolExposure::DirectModelOnly + } else { + ToolExposure::Direct + }; let agent_type_description = agent_type_description(config, params.default_agent_type_description); - handlers.push(Arc::new(SpawnAgentHandlerV2::new(SpawnAgentToolOptions { - available_models: config.available_models.clone(), - agent_type_description, - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, - }))); - handlers.push(Arc::new(SendMessageHandlerV2)); - handlers.push(Arc::new(FollowupTaskHandlerV2)); - handlers.push(Arc::new(WaitAgentHandlerV2::new( - params.wait_agent_timeouts, - ))); - handlers.push(Arc::new(CloseAgentHandlerV2)); - handlers.push(Arc::new(ListAgentsHandlerV2)); + handlers.push(multi_agent_v2_handler( + SpawnAgentHandlerV2::new(SpawnAgentToolOptions { + available_models: config.available_models.clone(), + agent_type_description, + hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, + include_usage_hint: config.spawn_agent_usage_hint, + usage_hint_text: config.spawn_agent_usage_hint_text.clone(), + max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, + }), + exposure, + )); + handlers.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler( + WaitAgentHandlerV2::new(params.wait_agent_timeouts), + exposure, + )); + handlers.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure)); } else { let agent_type_description = agent_type_description(config, params.default_agent_type_description); @@ -416,6 +439,13 @@ fn collect_handler_tools( handlers } +fn multi_agent_v2_handler( + handler: impl RegisteredTool + 'static, + exposure: ToolExposure, +) -> Arc { + override_tool_exposure(Arc::new(handler), exposure) +} + fn compare_code_mode_tools( left: &codex_code_mode::ToolDefinition, right: &codex_code_mode::ToolDefinition, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 38645e69bb..d275b89de3 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1384,3 +1384,67 @@ async fn code_mode_only_restricts_model_tools_to_exec_tools() { ) .await; } + +#[tokio::test] +async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { + let config = test_config().await; + let model_info = construct_model_info_offline("gpt-5.4", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); + let router = ToolRouter::from_config( + &tools_config, + ToolRouterParams { + mcp_tools: None, + deferred_mcp_tools: None, + discoverable_tools: None, + extension_tool_executors: Vec::new(), + dynamic_tools: &[], + }, + ); + let model_visible_specs = router.model_visible_specs(); + let tool_names = model_visible_specs + .iter() + .map(ToolSpec::name) + .collect::>(); + + assert_eq!( + tool_names, + vec![ + "exec", + "wait", + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] + ); + + let exec = find_tool(&model_visible_specs, "exec"); + let ToolSpec::Freeform(exec) = exec else { + panic!("exec should be a freeform tool"); + }; + assert!(!exec.description.contains("spawn_agent")); + assert!(!exec.description.contains("wait_agent")); + + let spawn_agent = find_tool(&model_visible_specs, "spawn_agent"); + let ToolSpec::Function(spawn_agent) = spawn_agent else { + panic!("spawn_agent should be a function tool"); + }; + assert!(!spawn_agent.description.contains("exec tool declaration")); +} diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 7665a4ca8b..b50d14f4eb 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -25,6 +25,8 @@ pub struct MultiAgentV2ConfigToml { pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub non_code_mode_only: Option, } impl FeatureConfig for MultiAgentV2ConfigToml { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index a635ca0740..04e236f5ee 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -481,6 +481,7 @@ usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true +non_code_mode_only = true "#, ) .expect("features table should deserialize"); @@ -500,6 +501,7 @@ hide_spawn_agent_metadata = true root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), hide_spawn_agent_metadata: Some(true), + non_code_mode_only: Some(true), })) ); } @@ -535,6 +537,7 @@ usage_hint_enabled = false root_agent_usage_hint_text: None, subagent_usage_hint_text: None, hide_spawn_agent_metadata: None, + non_code_mode_only: None, })) ); } diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 17e0dccaae..390c4ee386 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -117,6 +117,7 @@ pub struct ToolsConfig { pub collab_tools: bool, pub goal_tools: bool, pub multi_agent_v2: bool, + pub multi_agent_v2_non_code_mode_only: bool, pub hide_spawn_agent_metadata: bool, pub spawn_agent_usage_hint: bool, pub spawn_agent_usage_hint_text: Option, @@ -257,6 +258,7 @@ impl ToolsConfig { collab_tools: include_collab_tools, goal_tools: include_goal_tools, multi_agent_v2: include_multi_agent_v2, + multi_agent_v2_non_code_mode_only: false, hide_spawn_agent_metadata: false, spawn_agent_usage_hint: true, spawn_agent_usage_hint_text: None, @@ -314,6 +316,15 @@ impl ToolsConfig { self } + pub fn with_multi_agent_v2_non_code_mode_only( + mut self, + multi_agent_v2_non_code_mode_only: bool, + ) -> Self { + self.multi_agent_v2_non_code_mode_only = + self.multi_agent_v2 && multi_agent_v2_non_code_mode_only; + self + } + pub fn with_goal_tools_allowed(mut self, allowed: bool) -> Self { self.goal_tools = self.goal_tools && allowed; self diff --git a/codex-rs/tools/src/tool_executor.rs b/codex-rs/tools/src/tool_executor.rs index 1d7169ca1d..4dc328523f 100644 --- a/codex-rs/tools/src/tool_executor.rs +++ b/codex-rs/tools/src/tool_executor.rs @@ -5,12 +5,30 @@ use crate::ToolName; use crate::ToolOutput; use crate::ToolSpec; -/// Controls whether a tool is exposed in the initial model-visible tool list -/// or registered for later discovery. +/// Controls where a tool is exposed to the model. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ToolExposure { + /// Include this tool in the initial model-visible tool list. + /// + /// When code mode is enabled, this tool is also available as a nested + /// code-mode tool. Direct, + + /// Register this tool for later discovery, but omit it from the initial + /// model-visible tool list. Deferred, + + /// Include this tool in the initial model-visible tool list only. + /// + /// In code-mode-only sessions, this keeps the tool callable as a normal + /// model tool while excluding it from the nested code-mode tool surface. + DirectModelOnly, +} + +impl ToolExposure { + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct | Self::DirectModelOnly) + } } /// Shared runtime contract for model-visible tools.