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
This commit is contained in:
jif
2026-09-21 13:27:39 +00:00
committed by copyberry
parent 23a6b705ba
commit 8a62cc4dc1
11 changed files with 194 additions and 43 deletions
@@ -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(())
}
@@ -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;
+25 -19
View File
@@ -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<Config>,
manager: Weak<ThreadManager>,
) {
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<dyn AgentMessageBoard>))
})
});
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<dyn AgentMessageBoard>))
})
},
);
}
struct LocalBoardHost {
@@ -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
)
}
+1
View File
@@ -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
+2 -1
View File
@@ -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,
@@ -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":"<UUID 1>","thread_id":"<UUID 1>"}
07:message/user:
<agent_message_board_notification>
New post <UUID 1> in discussion <UUID 1>. Use collaboration.read_post or collaboration.read_thread to read it.
New post <UUID 1> in discussion <UUID 1>. Use read_post or read_thread to read it.
</agent_message_board_notification>
-- request 3 (request) --
08:function_call/collaboration.search_posts:{"channel_name":"design","query":"decision"}
@@ -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<C> = dyn Fn(&C, SessionId, ThreadId) -> BoxFuture<'static, Result<Option<Arc<dyn AgentMessageBoard>>>>
+ Send
+ Sync;
type NamespaceResolver<C> = dyn Fn(&C) -> Option<String> + Send + Sync;
struct BoardExtension<C> {
open: Box<BoardFactory<C>>,
tool_namespace: Box<NamespaceResolver<C>>,
namespace_description: &'static str,
events: Arc<dyn ExtensionEventSink>,
}
struct Binding {
board: Arc<dyn AgentMessageBoard>,
caller: ThreadId,
path: AgentPath,
namespace: Option<String>,
}
impl<C: Sync> ThreadLifecycleContributor<C> for BoardExtension<C> {
@@ -58,6 +63,7 @@ impl<C: Sync> ThreadLifecycleContributor<C> for BoardExtension<C> {
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<C: Sync> ToolContributor for BoardExtension<C> {
thread_store
.get::<Binding>()
.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<C: Sync> ToolContributor for BoardExtension<C> {
/// 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<C: Sync + 'static>(
registry: &mut ExtensionRegistryBuilder<C>,
namespace_description: &'static str,
tool_namespace: impl Fn(&C) -> Option<String> + Send + Sync + 'static,
open: impl Fn(
&C,
SessionId,
@@ -107,6 +123,8 @@ pub fn install<C: Sync + 'static>(
) {
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());
+13 -2
View File
@@ -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<dyn AgentMessageBoard>,
caller: ThreadId,
caller_path: AgentPath,
namespace: Option<&str>,
namespace_description: &str,
) -> Vec<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> {
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<dyn for<'call> ToolExecutor<ToolCall<'call>>>
})
.collect()
@@ -62,14 +67,20 @@ struct BoardTool {
caller: ThreadId,
caller_path: AgentPath,
name: &'static str,
namespace: Option<String>,
namespace_description: String,
}
impl<'call> ToolExecutor<ToolCall<'call>> 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!(
@@ -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(&parameters)
.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(&parameters)
.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),
}
}
@@ -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