mirror of
https://github.com/openai/codex.git
synced 2026-10-14 16:38:43 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
@@ -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(¶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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user