Allow consumers to select message-board tools (#55973)

## Why

`message_board_tools` always exposed all nine discussion tools. Consumers need a way to expose a subset, such as `read_thread` and `list_threads`, over the same board.

## What changed

Accept an ordered slice of the new `MessageBoardTool` enum and optional description overrides in `message_board_tools`. Use the enum for schemas and dispatch, and keep the agent extension's existing tool set and order through `MessageBoardTool::DISCUSSION`.

## Testing

Extend the local-board integration test to verify that a reader-only selection exposes exactly the requested tools in the supplied order and reads the existing discussion.

GitOrigin-RevId: 60026d73768330ff4b694c3a9e99480e3ed7af27
This commit is contained in:
felixxia-oai
2026-10-21 19:37:29 +00:00
committed by copyberry
parent e41e882174
commit 8d06d62e15
6 changed files with 127 additions and 68 deletions
@@ -14,7 +14,8 @@ use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolContributor;
use codex_message_board::AgentMessageBoard;
use codex_message_board::LocalBoardConnection;
use codex_message_board::message_board_tools_with_descriptions;
use codex_message_board::MessageBoardTool;
use codex_message_board::message_board_tools;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
@@ -125,7 +126,8 @@ impl<C> BoardExtension<C> {
thread_store
.get::<Binding>()
.map_or_else(Vec::new, |binding| {
message_board_tools_with_descriptions(
message_board_tools(
&MessageBoardTool::DISCUSSION,
binding.board.clone(),
binding.caller,
binding.path.clone(),
+1 -1
View File
@@ -41,7 +41,7 @@ pub use permissions::BoardPermissions;
pub use permissions::ChannelPermissions;
pub use permissions::PermissionDenied;
pub use tools::message_board_tools;
pub use tools::message_board_tools_with_descriptions;
pub use tools::selection::MessageBoardTool;
pub use types::ChannelPage;
pub use types::ChannelSummary;
pub use types::Page;
+32 -39
View File
@@ -1,8 +1,10 @@
//! Model tools over a caller-bound board. Results stay valid, bounded JSON.
mod arguments;
pub(crate) mod selection;
mod spec;
use self::selection::MessageBoardTool;
use crate::AgentMessageBoard;
use crate::ChannelQuery;
use crate::CreateChannelRequest;
@@ -40,27 +42,12 @@ use std::sync::Arc;
// Includes JSON escaping and metadata; applies equally to direct and Code Mode calls.
const MAX_RESPONSE_BYTES: usize = 8_000;
/// Creates tools without registering or enabling the extension.
/// Creates only the selected tools, in the supplied order, without registering them.
/// 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.
/// Namespace metadata must match the consumer's other tools. Optional model-catalog
/// descriptions override the bundled descriptions without changing tool parameters.
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>>>> {
message_board_tools_with_descriptions(
board,
caller,
caller_path,
namespace,
namespace_description,
/*tool_messages*/ None,
)
}
pub fn message_board_tools_with_descriptions(
tools: &[MessageBoardTool],
board: Arc<dyn AgentMessageBoard>,
caller: ThreadId,
caller_path: AgentPath,
@@ -68,9 +55,11 @@ pub fn message_board_tools_with_descriptions(
namespace_description: &str,
tool_messages: Option<&MultiAgentToolMessages>,
) -> Vec<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> {
spec::NAMES
.into_iter()
.map(|name| {
tools
.iter()
.copied()
.map(|tool| {
let name = tool.name();
let tool_message = tool_messages.and_then(|tools| tools.by_name(name));
if tool_message.is_some_and(|tool| tool.parameters.is_some()) {
tracing::warn!(
@@ -82,7 +71,7 @@ pub fn message_board_tools_with_descriptions(
board: board.clone(),
caller,
caller_path: caller_path.clone(),
name,
tool,
namespace: namespace.map(str::to_owned),
namespace_description: namespace_description.to_owned(),
description: tool_message.and_then(|tool| tool.description.clone()),
@@ -95,7 +84,7 @@ struct BoardTool {
board: Arc<dyn AgentMessageBoard>,
caller: ThreadId,
caller_path: AgentPath,
name: &'static str,
tool: MessageBoardTool,
namespace: Option<String>,
namespace_description: String,
description: Option<String>,
@@ -103,11 +92,11 @@ struct BoardTool {
impl<'call> ToolExecutor<ToolCall<'call>> for BoardTool {
fn tool_name(&self) -> ToolName {
ToolName::new(self.namespace.clone(), self.name)
ToolName::new(self.namespace.clone(), self.tool.name())
}
fn spec(&self) -> ToolSpec {
spec::tool(
self.name,
self.tool,
self.namespace.as_deref(),
&self.namespace_description,
self.description.as_deref(),
@@ -115,8 +104,12 @@ impl<'call> ToolExecutor<ToolCall<'call>> for BoardTool {
}
fn supports_parallel_tool_calls(&self) -> bool {
matches!(
self.name,
"get_channels" | "list_threads" | "search_posts" | "read_thread" | "read_post"
self.tool,
MessageBoardTool::GetChannels
| MessageBoardTool::ListThreads
| MessageBoardTool::SearchPosts
| MessageBoardTool::ReadThread
| MessageBoardTool::ReadPost
)
}
@@ -174,8 +167,8 @@ impl BoardTool {
SortDirection::OldestFirst
}
};
match self.name {
"create_channel" => {
match self.tool {
MessageBoardTool::CreateChannel => {
let arguments::CreateChannel {
channel_name,
description,
@@ -206,7 +199,7 @@ impl BoardTool {
.await,
)
}
"get_channels" => {
MessageBoardTool::GetChannels => {
let arguments::GetChannels {
query,
recent_first,
@@ -226,7 +219,7 @@ impl BoardTool {
})
.await
}
"list_threads" => {
MessageBoardTool::ListThreads => {
let arguments::ListThreads {
channel_name,
sort,
@@ -251,7 +244,7 @@ impl BoardTool {
})
.await
}
"search_posts" => {
MessageBoardTool::SearchPosts => {
let arguments::SearchPosts {
channel_name,
query,
@@ -282,7 +275,7 @@ impl BoardTool {
})
.await
}
"read_thread" => {
MessageBoardTool::ReadThread => {
let arguments::ReadThread {
thread_id,
limit,
@@ -303,7 +296,7 @@ impl BoardTool {
})
.await
}
"read_post" => {
MessageBoardTool::ReadPost => {
let arguments::ReadPost {
message_id,
offset_chars,
@@ -322,7 +315,7 @@ impl BoardTool {
})
.await
}
"subscribe" | "unsubscribe" => {
MessageBoardTool::Subscribe | MessageBoardTool::Unsubscribe => {
let arguments::Subscription {
channel_name,
thread_id,
@@ -348,7 +341,7 @@ impl BoardTool {
.map(|path| self.caller_path.resolve(&path))
.transpose()
.map_err(model_error)?,
change: if self.name == "subscribe" {
change: if self.tool == MessageBoardTool::Subscribe {
SubscriptionChange::Subscribe
} else {
SubscriptionChange::Unsubscribe
@@ -358,7 +351,7 @@ impl BoardTool {
.await,
)
}
"post" => {
MessageBoardTool::Post => {
let arguments::Post {
text,
channel_name,
@@ -404,7 +397,6 @@ impl BoardTool {
.await,
)
}
_ => unreachable!("only registered message-board tools can be called"),
}
}
@@ -451,6 +443,7 @@ fn nonzero(value: u32) -> NonZeroU32 {
fn model_error(error: impl std::fmt::Display) -> FunctionCallError {
FunctionCallError::RespondToModel(error.to_string().chars().take(512).collect())
}
enum BoardToolError {
Model(FunctionCallError),
PermissionDenied(PermissionDenied),
@@ -0,0 +1,46 @@
//! Tool choices supplied by consumers. Each tool has one fixed contract.
/// Shared tools a consumer can expose through `message_board_tools`.
/// Tool selection does not grant storage permissions to the underlying connection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MessageBoardTool {
CreateChannel,
GetChannels,
ListThreads,
SearchPosts,
ReadThread,
ReadPost,
Subscribe,
Unsubscribe,
/// Posts, subscribes the author unless opted out, and notifies recipients.
Post,
}
impl MessageBoardTool {
/// Existing agent discussion tools, in their original registration order.
pub const DISCUSSION: [Self; 9] = [
Self::CreateChannel,
Self::GetChannels,
Self::ListThreads,
Self::SearchPosts,
Self::ReadThread,
Self::ReadPost,
Self::Subscribe,
Self::Unsubscribe,
Self::Post,
];
pub(super) fn name(self) -> &'static str {
match self {
Self::CreateChannel => "create_channel",
Self::GetChannels => "get_channels",
Self::ListThreads => "list_threads",
Self::SearchPosts => "search_posts",
Self::ReadThread => "read_thread",
Self::ReadPost => "read_post",
Self::Subscribe => "subscribe",
Self::Unsubscribe => "unsubscribe",
Self::Post => "post",
}
}
}
+13 -25
View File
@@ -1,5 +1,6 @@
//! Schemas for shared discussion tools. The host supplies their namespace.
use super::selection::MessageBoardTool;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
@@ -7,36 +8,24 @@ use codex_tools::ToolSpec;
use codex_tools::parse_tool_input_schema;
use serde_json::json;
pub(super) const NAMES: [&str; 9] = [
"create_channel",
"get_channels",
"list_threads",
"search_posts",
"read_thread",
"read_post",
"subscribe",
"unsubscribe",
"post",
];
pub(super) fn tool(
name: &str,
tool: MessageBoardTool,
namespace: Option<&str>,
namespace_description: &str,
description_override: Option<&str>,
) -> ToolSpec {
let (description, fields, required): (&str, &[&str], &[&str]) = match name {
"create_channel" => (
let (description, fields, required): (&str, &[&str], &[&str]) = match tool {
MessageBoardTool::CreateChannel => (
"Create a channel where all agents in this collaboration can read and post messages. You are subscribed to new top-level posts by default.",
&["channel_name", "description", "subscribe"],
&["channel_name"],
),
"get_channels" => (
MessageBoardTool::GetChannels => (
"List channels, most recently active first, or search by a case-insensitive part of the name. Creating a channel or posting in it counts as activity.",
&["query", "recent_first", "limit", "cursor"],
&[],
),
"list_threads" => (
MessageBoardTool::ListThreads => (
"List a channel's threads with previews of the first post and latest reply. New threads come first by default; sorting by activity brings threads with recent replies to the top.",
&[
"channel_name",
@@ -48,7 +37,7 @@ pub(super) fn tool(
],
&["channel_name"],
),
"search_posts" => (
MessageBoardTool::SearchPosts => (
"Search top-level posts and replies, newest first. Text matches are case-insensitive substrings; omit the query to see recent activity. You can narrow by channel, author, or posts after a message ID. Results are previews; read_post can retrieve the full text.",
&[
"channel_name",
@@ -61,27 +50,27 @@ pub(super) fn tool(
],
&[],
),
"read_thread" => (
MessageBoardTool::ReadThread => (
"Read a thread using its first post's message ID. Every page includes previews of the first post and the newest replies; the cursor advances through replies.",
&["thread_id", "limit", "cursor", "max_chars_per_post"],
&["thread_id"],
),
"read_post" => (
MessageBoardTool::ReadPost => (
"Read a post or reply by message ID, without needing its channel. Offsets count Unicode characters; continue at next_offset_chars while it is less than n_chars.",
&["message_id", "offset_chars", "limit_chars"],
&["message_id"],
),
"subscribe" => (
MessageBoardTool::Subscribe => (
"Subscribe yourself or another agent to a channel for new top-level posts, or to a thread for replies. Provide exactly one of channel_name or thread_id. Notifications only reach agents with a running turn; missed notifications are not saved.",
&["channel_name", "thread_id", "target_agent"],
&[],
),
"unsubscribe" => (
MessageBoardTool::Unsubscribe => (
"Unsubscribe yourself or another agent from a channel or thread. Provide exactly one of channel_name or thread_id. Posting again does not undo a thread unsubscribe. Agents explicitly named on a post can still receive that notification.",
&["channel_name", "thread_id", "target_agent"],
&[],
),
"post" => (
MessageBoardTool::Post => (
"Start a thread in an existing or new channel, or reply using the first post's message ID as thread_id. Exactly one destination is required. Posting subscribes you to the thread unless you previously unsubscribed. agents_to_notify sends a one-time notification without subscribing recipients or starting idle agents. Returns metadata, not the post text.",
&[
"text",
@@ -92,7 +81,6 @@ pub(super) fn tool(
],
&["text"],
),
_ => unreachable!("only registered message-board tools have schemas"),
};
let mut properties = serde_json::Map::new();
for field in fields {
@@ -130,7 +118,7 @@ pub(super) fn tool(
}
let parameters = json!({"type":"object","properties":properties,"required":required,"additionalProperties":false});
let tool = ResponsesApiTool {
name: name.into(),
name: tool.name().into(),
description: description_override.unwrap_or(description).into(),
strict: false,
defer_loading: None,
+31 -1
View File
@@ -987,11 +987,13 @@ async fn check_tools_cover_channel_discussions_subscriptions_and_escaped_preview
});
let board = backend.open(&sqlite, root.into(), host.clone()).await;
let tools = message_board_tools(
board,
&MessageBoardTool::DISCUSSION,
board.clone(),
root,
AgentPath::root(),
Some("collaboration"),
"Shared tools",
/*tool_messages*/ None,
);
let tool = |name: &str| {
tools
@@ -1101,6 +1103,32 @@ async fn check_tools_cover_channel_discussions_subscriptions_and_escaped_preview
last_message_id: Some(last_reply.message_id),
}
);
// Another consumer exposes just these readers over the same stored discussion.
let tools = message_board_tools(
&[MessageBoardTool::ReadThread, MessageBoardTool::ListThreads],
board,
root,
AgentPath::root(),
Some("collaboration"),
"Shared tools",
/*tool_messages*/ None,
);
assert_eq!(
tools
.iter()
.map(|tool| tool.tool_name())
.collect::<Vec<_>>(),
vec![
ToolName::namespaced("collaboration", "read_thread"),
ToolName::namespaced("collaboration", "list_threads"),
]
);
let tool = |name: &str| {
tools
.iter()
.find(|tool| tool.tool_name() == ToolName::namespaced("collaboration", name))
.unwrap()
};
for (name, args) in [
("list_threads", json!({"channel_name":"Workflow"})),
("read_thread", json!({"thread_id":last.thread_id})),
@@ -1193,11 +1221,13 @@ async fn check_tools_validate_arguments_deduplicate_calls_and_bound_unicode_resu
});
let board = backend.open(&sqlite, root.into(), host.clone()).await;
let tools = message_board_tools(
&MessageBoardTool::DISCUSSION,
board.clone(),
root,
AgentPath::root(),
Some("collaboration"),
"Shared tools",
/*tool_messages*/ None,
);
let call = board_tool_call;
let tool = |name: &str| {