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
@@ -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