From 8a62cc4dc1a498cfdbee31ad58652f25e680273d Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 21 Sep 2026 13:11:41 +0000 Subject: [PATCH] Align message-board tools with the multi-agent namespace (#47028) ## Why Message-board tools hardcoded the `collaboration` namespace and used a different namespace description from the multi-agent tools. This ignored custom namespace configuration and caused conflicts under strict tool collision checking. ## What changed - Use `multi_agent_v2.tool_namespace` and the shared multi-agent namespace description for message-board tools, resolving the namespace at runtime startup. - Support unnamespaced tool specifications when no namespace is supplied. - Remove hardcoded `collaboration` prefixes from message-board notifications. ## Testing Add app-server integration coverage for the default `collaboration` namespace and a configured `agents` namespace with strict collision checking enabled. Verify that both tool families are advertised together and that `get_channels` executes through the installed extension. GitOrigin-RevId: fef7be51ea2a84bbdb6d3491606db442ed4e6ea3 --- .../tests/suite/v2/agent_message_board.rs | 98 +++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/core/src/agent_message_board.rs | 44 +++++---- .../agent_message_board_notification.rs | 2 +- codex-rs/core/src/tools/mod.rs | 1 + codex-rs/core/src/tools/multi_agent_tool.rs | 3 +- ...ge_board__agent_message_board_context.snap | 2 +- .../ext/agent-message-board/src/extension.rs | 20 +++- codex-rs/ext/agent-message-board/src/tools.rs | 15 ++- .../ext/agent-message-board/src/tools/spec.rs | 35 ++++--- .../agent-message-board/tests/local_board.rs | 16 ++- 11 files changed, 194 insertions(+), 43 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/agent_message_board.rs diff --git a/codex-rs/app-server/tests/suite/v2/agent_message_board.rs b/codex-rs/app-server/tests/suite/v2/agent_message_board.rs new file mode 100644 index 0000000000..acf2e41d38 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/agent_message_board.rs @@ -0,0 +1,98 @@ +//! Board tools share MAv2 namespace metadata through the production installation path. + +use anyhow::Context; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use codex_features::Feature; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +#[test_case::test_case(None, "collaboration"; "default_namespace")] +#[test_case::test_case(Some("agents"), "agents"; "configured_namespace")] +#[tokio::test] +async fn board_tools_share_multi_agent_namespace( + configured_namespace: Option<&str>, + namespace: &str, +) -> anyhow::Result<()> { + let server = responses::start_mock_server().await; + let mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_function_call_with_namespace( + "list-channels", + namespace, + "get_channels", + "{}", + ), + responses::ev_completed("response-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("done", "Done."), + responses::ev_completed("response-2"), + ]), + ], + ) + .await; + let codex_home = TempDir::new()?; + let namespace_config = configured_namespace + .map(|name| format!("tool_namespace = {name:?}")) + .unwrap_or_default(); + MockResponsesConfig::new(&server.uri()) + .enable_feature(Feature::AgentMessageBoard) + .disable_feature(Feature::CodeMode) + .disable_feature(Feature::CodeModeOnly) + .with_extra_config(&format!( + "[features.multi_agent_v2]\nenabled = true\n{namespace_config}\n\ + [features.tool_registry]\nerror_on_tool_collisions = true" + )) + .write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let thread = app.start_thread(ThreadStartParams::default()).await?.thread; + let request = app + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "List the shared board channels.".into(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _: TurnStartResponse = app.read_response(request).await?; + timeout( + Duration::from_secs(60), + app.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + // Strict collision checking rejects different descriptions before sampling. + // Both tool families must also be advertised under the configured name. + for name in ["spawn_agent", "get_channels", "post"] { + assert!(requests[0].tool_by_name(namespace, name).is_some()); + } + let result: Value = serde_json::from_str( + &requests[1] + .function_call_output_text("list-channels") + .context("board tool must execute through the installed extension")?, + )?; + assert_eq!( + result, + json!({"results": [], "n_returned": 0, "has_more": false, "next_cursor": null}) + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 0ea66030c1..55e735aa0b 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -2,6 +2,7 @@ mod account; #[path = "account_system_proxy_tests.rs"] mod account_system_proxy; mod account_thread_usage; +mod agent_message_board; mod analytics; mod app_installed; mod app_list; diff --git a/codex-rs/core/src/agent_message_board.rs b/codex-rs/core/src/agent_message_board.rs index a9837f4c01..6223927130 100644 --- a/codex-rs/core/src/agent_message_board.rs +++ b/codex-rs/core/src/agent_message_board.rs @@ -8,6 +8,7 @@ use crate::ThreadManager; use crate::config::Config; use crate::context::AgentMessageBoardNotification; use crate::context::ContextualUserFragment; +use crate::tools::MULTI_AGENT_V2_NAMESPACE_DESCRIPTION; use chrono::DateTime; use chrono::Utc; use codex_agent_message_board_extension::AgentMessageBoard; @@ -33,25 +34,30 @@ pub fn install_agent_message_board( registry: &mut ExtensionRegistryBuilder, manager: Weak, ) { - codex_agent_message_board_extension::install(registry, move |config: &Config, tree, caller| { - // MAv2 supplies tree paths; ephemeral runtimes must not open durable storage. - if !config.features.enabled(Feature::AgentMessageBoard) - || !config.features.enabled(Feature::MultiAgentV2) - || config.ephemeral - { - return Box::pin(async { Ok(None) }); - } - let sqlite = config.sqlite_config().clone(); - let host = Arc::new(LocalBoardHost { - manager: manager.clone(), - tree, - caller, - }); - Box::pin(async move { - let board = LocalAgentMessageBoard::open(&sqlite, tree, host).await?; - Ok(Some(Arc::new(board) as Arc)) - }) - }); + codex_agent_message_board_extension::install( + registry, + MULTI_AGENT_V2_NAMESPACE_DESCRIPTION, + |config: &Config| config.multi_agent_v2.tool_namespace.clone(), + move |config: &Config, tree, caller| { + // MAv2 supplies tree paths; ephemeral runtimes must not open durable storage. + if !config.features.enabled(Feature::AgentMessageBoard) + || !config.features.enabled(Feature::MultiAgentV2) + || config.ephemeral + { + return Box::pin(async { Ok(None) }); + } + let sqlite = config.sqlite_config().clone(); + let host = Arc::new(LocalBoardHost { + manager: manager.clone(), + tree, + caller, + }); + Box::pin(async move { + let board = LocalAgentMessageBoard::open(&sqlite, tree, host).await?; + Ok(Some(Arc::new(board) as Arc)) + }) + }, + ); } struct LocalBoardHost { diff --git a/codex-rs/core/src/context/agent_message_board_notification.rs b/codex-rs/core/src/context/agent_message_board_notification.rs index 4264879b94..b6c02fa1a5 100644 --- a/codex-rs/core/src/context/agent_message_board_notification.rs +++ b/codex-rs/core/src/context/agent_message_board_notification.rs @@ -27,7 +27,7 @@ impl ContextualUserFragment for AgentMessageBoardNotification { } fn body(&self) -> String { format!( - "\nNew post {} in discussion {}. Use collaboration.read_post or collaboration.read_thread to read it.\n", + "\nNew post {} in discussion {}. Use read_post or read_thread to read it.\n", self.message_id, self.thread_id ) } diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index f50766b1c8..fbc18d95e9 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -34,6 +34,7 @@ use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_output_truncation::truncate_text; pub(crate) use executed_tool_calls::ExecutedToolCalls; +pub(crate) use multi_agent_tool::MULTI_AGENT_V2_NAMESPACE_DESCRIPTION; pub use router::ToolRouter; /// Legacy boundaries such as hook payloads, telemetry tags, and Responses tool diff --git a/codex-rs/core/src/tools/multi_agent_tool.rs b/codex-rs/core/src/tools/multi_agent_tool.rs index e2c0d0be22..8ea4a69eed 100644 --- a/codex-rs/core/src/tools/multi_agent_tool.rs +++ b/codex-rs/core/src/tools/multi_agent_tool.rs @@ -16,7 +16,8 @@ use futures::future::BoxFuture; use serde_json::Value; use std::sync::Arc; -const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; +pub(crate) const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = + "Tools for spawning and managing sub-agents."; pub(super) fn multi_agent_v2_handler( handler: impl CoreToolRuntime + 'static, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__agent_message_board__agent_message_board_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__agent_message_board__agent_message_board_context.snap index 1c94c102c1..d76171376e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__agent_message_board__agent_message_board_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__agent_message_board__agent_message_board_context.snap @@ -38,7 +38,7 @@ Scenario: An active agent posts a shared decision, receives an ID-only notice, a 06:function_call_output:{"author":"/root","channel_name":"design","created_at":"2026-09-18T12:00:00Z","message_id":"","thread_id":""} 07:message/user: - New post in discussion . Use collaboration.read_post or collaboration.read_thread to read it. + New post in discussion . Use read_post or read_thread to read it. -- request 3 (request) -- 08:function_call/collaboration.search_posts:{"channel_name":"design","query":"decision"} diff --git a/codex-rs/ext/agent-message-board/src/extension.rs b/codex-rs/ext/agent-message-board/src/extension.rs index 2663161d46..e18cd7e123 100644 --- a/codex-rs/ext/agent-message-board/src/extension.rs +++ b/codex-rs/ext/agent-message-board/src/extension.rs @@ -2,6 +2,7 @@ //! //! Backend selection is a host concern. Startup failures leave tools unavailable //! and emit a warning; a disabled factory does not open storage. +//! Tool namespaces follow the host's configuration at runtime startup. use crate::AgentMessageBoard; use crate::message_board_tools; @@ -26,15 +27,19 @@ use std::sync::Arc; type BoardFactory = dyn Fn(&C, SessionId, ThreadId) -> BoxFuture<'static, Result>>> + Send + Sync; +type NamespaceResolver = dyn Fn(&C) -> Option + Send + Sync; struct BoardExtension { open: Box>, + tool_namespace: Box>, + namespace_description: &'static str, events: Arc, } struct Binding { board: Arc, caller: ThreadId, path: AgentPath, + namespace: Option, } impl ThreadLifecycleContributor for BoardExtension { @@ -58,6 +63,7 @@ impl ThreadLifecycleContributor for BoardExtension { input.thread_store.insert(Binding { board, caller, + namespace: (self.tool_namespace)(input.config), path: input .session_source .get_agent_path() @@ -86,7 +92,13 @@ impl ToolContributor for BoardExtension { thread_store .get::() .map_or_else(Vec::new, |binding| { - message_board_tools(binding.board.clone(), binding.caller, binding.path.clone()) + message_board_tools( + binding.board.clone(), + binding.caller, + binding.path.clone(), + binding.namespace.as_deref(), + self.namespace_description, + ) }) } } @@ -94,8 +106,12 @@ impl ToolContributor for BoardExtension { /// Installs message-board lifecycle and tool contributions. The factory selects /// a local or remote backend, or returns None when disabled. Configuration is /// read at runtime startup, including resume; no board is created by installation. +/// The host supplies its shared namespace description and resolves the namespace +/// name from the runtime's startup configuration. pub fn install( registry: &mut ExtensionRegistryBuilder, + namespace_description: &'static str, + tool_namespace: impl Fn(&C) -> Option + Send + Sync + 'static, open: impl Fn( &C, SessionId, @@ -107,6 +123,8 @@ pub fn install( ) { let extension = Arc::new(BoardExtension { open: Box::new(open), + tool_namespace: Box::new(tool_namespace), + namespace_description, events: registry.event_sink(), }); registry.thread_lifecycle_contributor(extension.clone()); diff --git a/codex-rs/ext/agent-message-board/src/tools.rs b/codex-rs/ext/agent-message-board/src/tools.rs index e3d49947ff..dd7acd66d3 100644 --- a/codex-rs/ext/agent-message-board/src/tools.rs +++ b/codex-rs/ext/agent-message-board/src/tools.rs @@ -39,10 +39,13 @@ const MAX_RESPONSE_BYTES: usize = 8_000; /// Creates tools without registering or enabling the extension. /// The caller and its path must come from the host's authoritative tree metadata. +/// Namespace metadata must match the host's other multi-agent tools. pub fn message_board_tools( board: Arc, caller: ThreadId, caller_path: AgentPath, + namespace: Option<&str>, + namespace_description: &str, ) -> Vec ToolExecutor>>> { spec::NAMES .into_iter() @@ -52,6 +55,8 @@ pub fn message_board_tools( caller, caller_path: caller_path.clone(), name, + namespace: namespace.map(str::to_owned), + namespace_description: namespace_description.to_owned(), }) as Arc ToolExecutor>> }) .collect() @@ -62,14 +67,20 @@ struct BoardTool { caller: ThreadId, caller_path: AgentPath, name: &'static str, + namespace: Option, + namespace_description: String, } impl<'call> ToolExecutor> for BoardTool { fn tool_name(&self) -> ToolName { - ToolName::namespaced("collaboration", self.name) + ToolName::new(self.namespace.clone(), self.name) } fn spec(&self) -> ToolSpec { - spec::tool(self.name) + spec::tool( + self.name, + self.namespace.as_deref(), + &self.namespace_description, + ) } fn supports_parallel_tool_calls(&self) -> bool { matches!( diff --git a/codex-rs/ext/agent-message-board/src/tools/spec.rs b/codex-rs/ext/agent-message-board/src/tools/spec.rs index 622148ab50..1cfdd55025 100644 --- a/codex-rs/ext/agent-message-board/src/tools/spec.rs +++ b/codex-rs/ext/agent-message-board/src/tools/spec.rs @@ -1,10 +1,9 @@ -//! Schemas for the shared discussion tools, in the existing collaboration namespace. +//! Schemas for shared discussion tools. The host supplies their namespace. use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; -use codex_tools::default_namespace_description; use codex_tools::parse_tool_input_schema; use serde_json::json; @@ -20,7 +19,7 @@ pub(super) const NAMES: [&str; 9] = [ "post", ]; -pub(super) fn tool(name: &str) -> ToolSpec { +pub(super) fn tool(name: &str, namespace: Option<&str>, namespace_description: &str) -> ToolSpec { let (description, fields, required): (&str, &[&str], &[&str]) = match name { "create_channel" => ( "Create a channel shared by this agent tree. Subscribe to new discussion roots by default.", @@ -111,17 +110,21 @@ pub(super) fn tool(name: &str) -> ToolSpec { properties.insert((*field).into(), schema); } let parameters = json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}); - ToolSpec::Namespace(ResponsesApiNamespace { - name: "collaboration".into(), - description: default_namespace_description("collaboration"), - tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { - name: name.into(), - description: description.into(), - strict: false, - defer_loading: None, - parameters: parse_tool_input_schema(¶meters) - .unwrap_or_else(|error| panic!("message-board schema must parse: {error}")), - output_schema: None, - })], - }) + let tool = ResponsesApiTool { + name: name.into(), + description: description.into(), + strict: false, + defer_loading: None, + parameters: parse_tool_input_schema(¶meters) + .unwrap_or_else(|error| panic!("message-board schema must parse: {error}")), + output_schema: None, + }; + match namespace { + Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { + name: namespace.into(), + description: namespace_description.into(), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + }), + None => ToolSpec::Function(tool), + } } diff --git a/codex-rs/ext/agent-message-board/tests/local_board.rs b/codex-rs/ext/agent-message-board/tests/local_board.rs index afc8853e60..1b3feedbd9 100644 --- a/codex-rs/ext/agent-message-board/tests/local_board.rs +++ b/codex-rs/ext/agent-message-board/tests/local_board.rs @@ -630,7 +630,13 @@ async fn tools_cover_channel_discussions_subscriptions_and_escaped_previews() { .await .unwrap(), ); - let tools = message_board_tools(board, root, AgentPath::root()); + let tools = message_board_tools( + board, + root, + AgentPath::root(), + Some("collaboration"), + "Shared tools", + ); let tool = |name: &str| { tools .iter() @@ -821,7 +827,13 @@ async fn tools_validate_arguments_deduplicate_calls_and_bound_unicode_results() .await .unwrap(), ); - let tools = message_board_tools(board.clone(), root, AgentPath::root()); + let tools = message_board_tools( + board.clone(), + root, + AgentPath::root(), + Some("collaboration"), + "Shared tools", + ); let call = board_tool_call; let tool = |name: &str| { tools