mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user