mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Add collaboration tools for the agent message board (#46985)
## What changed Expose `message_board_tools` to create nine tools for channel creation and discovery, thread listing, post search and reading, subscriptions, and posting. Bind tools to the host-supplied caller identity and resolve agent references relative to the caller. The factory does not register or enable the extension. Keep responses valid JSON within an 8,000-byte cap and the call's output budget. Reduce read limits and previews as needed while preserving backend pagination, and reject mutations when the budget is too small to acknowledge them. Derive post request IDs from turn and invocation metadata for direct and Code Mode calls. ## Testing Add integration coverage for invalid arguments, mutation rejection without writes under insufficient budgets, duplicate post calls, distinct Code Mode invocations, relative notification recipients, author filtering, and bounded Unicode reads with continuation offsets. GitOrigin-RevId: 2fbc58a4c7eb861055847c363061d73749bf573c
This commit is contained in:
Generated
+2
@@ -2104,6 +2104,8 @@ dependencies = [
|
||||
"chrono",
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-tools",
|
||||
"codex-utils-output-truncation",
|
||||
"futures",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
|
||||
@@ -19,6 +19,7 @@ caseless = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -28,6 +29,7 @@ tracing = { workspace = true }
|
||||
uuid = { workspace = true, features = ["serde", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
mod api;
|
||||
mod host;
|
||||
mod local;
|
||||
mod tools;
|
||||
mod types;
|
||||
|
||||
pub use api::AgentMessageBoard;
|
||||
@@ -26,6 +27,7 @@ pub use api::ThreadSort;
|
||||
pub use host::MessageBoardHost;
|
||||
pub use host::NotificationDelivery;
|
||||
pub use local::LocalAgentMessageBoard;
|
||||
pub use tools::message_board_tools;
|
||||
pub use types::ChannelSummary;
|
||||
pub use types::Page;
|
||||
pub use types::PostContent;
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Model tools over a caller-bound board. Results stay valid, bounded JSON.
|
||||
|
||||
mod arguments;
|
||||
mod spec;
|
||||
|
||||
use crate::AgentMessageBoard;
|
||||
use crate::ChannelQuery;
|
||||
use crate::CreateChannelRequest;
|
||||
use crate::PageRequest;
|
||||
use crate::PostDestination;
|
||||
use crate::PostQuery;
|
||||
use crate::PostRequest;
|
||||
use crate::ReadPostRequest;
|
||||
use crate::ReadThreadRequest;
|
||||
use crate::SortDirection;
|
||||
use crate::SubscriptionChange;
|
||||
use crate::SubscriptionRequest;
|
||||
use crate::SubscriptionTarget;
|
||||
use crate::ThreadQuery;
|
||||
use crate::ThreadSort;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_tools::FunctionCallError;
|
||||
use codex_tools::JsonToolOutput;
|
||||
use codex_tools::ToolCall;
|
||||
use codex_tools::ToolCallSource;
|
||||
use codex_tools::ToolExecutor;
|
||||
use codex_tools::ToolExecutorFuture;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use futures::future::BoxFuture;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::num::NonZeroU32;
|
||||
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.
|
||||
/// The caller and its path must come from the host's authoritative tree metadata.
|
||||
pub fn message_board_tools(
|
||||
board: Arc<dyn AgentMessageBoard>,
|
||||
caller: ThreadId,
|
||||
caller_path: AgentPath,
|
||||
) -> Vec<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> {
|
||||
spec::NAMES
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
Arc::new(BoardTool {
|
||||
board: board.clone(),
|
||||
caller,
|
||||
caller_path: caller_path.clone(),
|
||||
name,
|
||||
}) as Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct BoardTool {
|
||||
board: Arc<dyn AgentMessageBoard>,
|
||||
caller: ThreadId,
|
||||
caller_path: AgentPath,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl<'call> ToolExecutor<ToolCall<'call>> for BoardTool {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced("collaboration", self.name)
|
||||
}
|
||||
fn spec(&self) -> ToolSpec {
|
||||
spec::tool(self.name)
|
||||
}
|
||||
fn supports_parallel_tool_calls(&self) -> bool {
|
||||
matches!(
|
||||
self.name,
|
||||
"get_channels" | "list_threads" | "search_posts" | "read_thread" | "read_post"
|
||||
)
|
||||
}
|
||||
|
||||
fn handle<'a>(&'a self, call: ToolCall<'call>) -> ToolExecutorFuture<'a>
|
||||
where
|
||||
'call: 'a,
|
||||
{
|
||||
Box::pin(async move {
|
||||
let raw = call.function_arguments()?;
|
||||
if raw.len() > 128 * 1024 {
|
||||
return Err(model_error("message-board arguments exceed 128 KiB"));
|
||||
}
|
||||
let budget = call.response_byte_budget(MAX_RESPONSE_BYTES);
|
||||
let result = self.execute(raw, &call, budget).await?;
|
||||
if result.to_string().len() > budget {
|
||||
return Err(model_error(
|
||||
"Result exceeds the output budget. Reduce limit or max_chars_per_post; use read_post with a smaller limit_chars for long posts.",
|
||||
));
|
||||
}
|
||||
Ok(
|
||||
Box::new(JsonToolOutput::new(result).with_external_context())
|
||||
as Box<dyn codex_tools::ToolOutput>,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BoardTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
raw: &str,
|
||||
call: &ToolCall<'_>,
|
||||
budget: usize,
|
||||
) -> Result<Value, FunctionCallError> {
|
||||
let board = &self.board;
|
||||
let caller = self.caller;
|
||||
let page = |limit: Option<NonZeroU32>, cursor, scale| PageRequest {
|
||||
limit: nonzero(limit.map_or(20, NonZeroU32::get).min(50) / scale),
|
||||
cursor,
|
||||
};
|
||||
let preview = |requested: Option<NonZeroU32>, scale| {
|
||||
nonzero(requested.map_or(1000, NonZeroU32::get).min(20_000) / scale)
|
||||
};
|
||||
let direction = |recent_first: Option<bool>| {
|
||||
if recent_first.unwrap_or(true) {
|
||||
SortDirection::NewestFirst
|
||||
} else {
|
||||
SortDirection::OldestFirst
|
||||
}
|
||||
};
|
||||
match self.name {
|
||||
"create_channel" => {
|
||||
let arguments::CreateChannel {
|
||||
channel_name,
|
||||
subscribe,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
self.check_mutation_budget(budget, /*target_path_bytes*/ 0)?;
|
||||
encode(
|
||||
board
|
||||
.create_channel(
|
||||
caller,
|
||||
CreateChannelRequest {
|
||||
channel_name,
|
||||
subscription: if subscribe.unwrap_or(true) {
|
||||
SubscriptionChange::Subscribe
|
||||
} else {
|
||||
SubscriptionChange::Unsubscribe
|
||||
},
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
"get_channels" => {
|
||||
let arguments::GetChannels {
|
||||
query,
|
||||
recent_first,
|
||||
limit,
|
||||
cursor,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
bounded_read(budget, |scale| {
|
||||
board.list_channels(
|
||||
caller,
|
||||
ChannelQuery {
|
||||
query: query.clone(),
|
||||
direction: direction(recent_first),
|
||||
page: page(limit, cursor.clone(), scale),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"list_threads" => {
|
||||
let arguments::ListThreads {
|
||||
channel_name,
|
||||
sort,
|
||||
recent_first,
|
||||
limit,
|
||||
cursor,
|
||||
max_chars_per_post,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
bounded_read(budget, |scale| {
|
||||
board.list_threads(
|
||||
caller,
|
||||
ThreadQuery {
|
||||
channel_name: channel_name.clone(),
|
||||
sort: sort.unwrap_or(ThreadSort::Created),
|
||||
direction: direction(recent_first),
|
||||
max_chars_per_post: preview(max_chars_per_post, scale),
|
||||
page: page(limit, cursor.clone(), scale),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"search_posts" => {
|
||||
let arguments::SearchPosts {
|
||||
channel_name,
|
||||
query,
|
||||
after_message_id,
|
||||
author,
|
||||
limit,
|
||||
cursor,
|
||||
max_chars_per_post,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
let author = author
|
||||
.map(|path| self.caller_path.resolve(&path))
|
||||
.transpose()
|
||||
.map_err(model_error)?;
|
||||
bounded_read(budget, |scale| {
|
||||
board.search_posts(
|
||||
caller,
|
||||
PostQuery {
|
||||
channel_name: channel_name.clone(),
|
||||
query: query.clone(),
|
||||
after_message_id,
|
||||
author: author.clone(),
|
||||
max_chars_per_post: preview(max_chars_per_post, scale),
|
||||
page: page(limit, cursor.clone(), scale),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"read_thread" => {
|
||||
let arguments::ReadThread {
|
||||
thread_id,
|
||||
limit,
|
||||
cursor,
|
||||
max_chars_per_post,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
bounded_read(budget, |scale| {
|
||||
board.read_thread(
|
||||
caller,
|
||||
ReadThreadRequest {
|
||||
thread_id,
|
||||
max_chars_per_post: preview(max_chars_per_post, scale),
|
||||
page: page(limit, cursor.clone(), scale),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"read_post" => {
|
||||
let arguments::ReadPost {
|
||||
message_id,
|
||||
offset_chars,
|
||||
limit_chars,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
bounded_read(budget, |scale| {
|
||||
board.read_post(
|
||||
caller,
|
||||
ReadPostRequest {
|
||||
message_id,
|
||||
offset_chars: offset_chars.unwrap_or_default(),
|
||||
limit_chars: nonzero(
|
||||
limit_chars.map_or(20_000, NonZeroU32::get).min(20_000) / scale,
|
||||
),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"subscribe" | "unsubscribe" => {
|
||||
let arguments::Subscription {
|
||||
channel_name,
|
||||
thread_id,
|
||||
target_agent,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
self.check_mutation_budget(budget, target_agent.as_ref().map_or(0, String::len))?;
|
||||
let target = match (channel_name, thread_id) {
|
||||
(Some(channel), None) => SubscriptionTarget::Channel(channel),
|
||||
(None, Some(thread)) => SubscriptionTarget::Thread(thread),
|
||||
_ => {
|
||||
return Err(model_error(
|
||||
"Supply exactly one of channel_name or thread_id",
|
||||
));
|
||||
}
|
||||
};
|
||||
encode(
|
||||
board
|
||||
.set_subscription(
|
||||
caller,
|
||||
SubscriptionRequest {
|
||||
target,
|
||||
target_agent: target_agent
|
||||
.map(|path| self.caller_path.resolve(&path))
|
||||
.transpose()
|
||||
.map_err(model_error)?,
|
||||
change: if self.name == "subscribe" {
|
||||
SubscriptionChange::Subscribe
|
||||
} else {
|
||||
SubscriptionChange::Unsubscribe
|
||||
},
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
"post" => {
|
||||
let arguments::Post {
|
||||
text,
|
||||
channel_name,
|
||||
new_channel_name,
|
||||
thread_id,
|
||||
agents_to_notify,
|
||||
} = serde_json::from_str(raw).map_err(model_error)?;
|
||||
self.check_mutation_budget(budget, /*target_path_bytes*/ 0)?;
|
||||
let destination = match (channel_name, new_channel_name, thread_id) {
|
||||
(Some(channel), None, None) => PostDestination::Channel(channel),
|
||||
(None, Some(channel), None) => PostDestination::NewChannel(channel),
|
||||
(None, None, Some(thread)) => PostDestination::Thread(thread),
|
||||
_ => {
|
||||
return Err(model_error(
|
||||
"Supply exactly one of channel_name, new_channel_name or thread_id",
|
||||
));
|
||||
}
|
||||
};
|
||||
let invocation = match &call.source {
|
||||
ToolCallSource::Direct => call.call_id.clone(),
|
||||
ToolCallSource::CodeMode {
|
||||
cell_id,
|
||||
runtime_tool_call_id,
|
||||
} => format!("{cell_id}:{runtime_tool_call_id}"),
|
||||
};
|
||||
encode(
|
||||
board
|
||||
.post(
|
||||
caller,
|
||||
PostRequest {
|
||||
request_id: format!("{}:{invocation}", call.turn_id),
|
||||
destination,
|
||||
text,
|
||||
agents_to_notify: agents_to_notify
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| self.caller_path.resolve(&path))
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(model_error)?,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
_ => unreachable!("only registered message-board tools can be called"),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_mutation_budget(
|
||||
&self,
|
||||
budget: usize,
|
||||
target_path_bytes: usize,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
// Reserve metadata, escaped channel names and agent paths before any write.
|
||||
if budget < 2048 + self.caller_path.len() + target_path_bytes {
|
||||
return Err(model_error(
|
||||
"Output budget is too small to acknowledge a message-board mutation; no change was made.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Try the requested read first. Halve limits only when its serialized output is too large.
|
||||
/// Reissuing the read lets each backend generate a cursor for exactly the returned page.
|
||||
async fn bounded_read<'a, T: Serialize>(
|
||||
budget: usize,
|
||||
fetch: impl Fn(u32) -> BoxFuture<'a, codex_protocol::error::Result<T>>,
|
||||
) -> Result<Value, FunctionCallError> {
|
||||
// Read limits are capped at 20,000, so the final attempt asks for one character/item.
|
||||
for shift in 0..=15 {
|
||||
let result = encode(fetch(1 << shift).await)?;
|
||||
if result.to_string().len() <= budget {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
Err(model_error(
|
||||
"The output budget is too small for this result's metadata.",
|
||||
))
|
||||
}
|
||||
|
||||
fn nonzero(value: u32) -> NonZeroU32 {
|
||||
NonZeroU32::new(value).unwrap_or(NonZeroU32::MIN)
|
||||
}
|
||||
fn model_error(error: impl std::fmt::Display) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(error.to_string().chars().take(512).collect())
|
||||
}
|
||||
fn encode<T: Serialize>(
|
||||
value: codex_protocol::error::Result<T>,
|
||||
) -> Result<Value, FunctionCallError> {
|
||||
serde_json::to_value(value.map_err(model_error)?).map_err(model_error)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Flat model arguments; runtime identities are supplied by the host.
|
||||
|
||||
use crate::ThreadSort;
|
||||
use serde::Deserialize;
|
||||
use std::num::NonZeroU32;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct CreateChannel {
|
||||
pub(super) channel_name: String,
|
||||
pub(super) subscribe: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct GetChannels {
|
||||
pub(super) query: Option<String>,
|
||||
pub(super) recent_first: Option<bool>,
|
||||
pub(super) limit: Option<NonZeroU32>,
|
||||
pub(super) cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct ListThreads {
|
||||
pub(super) channel_name: String,
|
||||
pub(super) sort: Option<ThreadSort>,
|
||||
pub(super) recent_first: Option<bool>,
|
||||
pub(super) limit: Option<NonZeroU32>,
|
||||
pub(super) cursor: Option<String>,
|
||||
pub(super) max_chars_per_post: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct SearchPosts {
|
||||
pub(super) channel_name: Option<String>,
|
||||
pub(super) query: Option<String>,
|
||||
pub(super) after_message_id: Option<Uuid>,
|
||||
pub(super) author: Option<String>,
|
||||
pub(super) limit: Option<NonZeroU32>,
|
||||
pub(super) cursor: Option<String>,
|
||||
pub(super) max_chars_per_post: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct ReadThread {
|
||||
pub(super) thread_id: Uuid,
|
||||
pub(super) limit: Option<NonZeroU32>,
|
||||
pub(super) cursor: Option<String>,
|
||||
pub(super) max_chars_per_post: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct ReadPost {
|
||||
pub(super) message_id: Uuid,
|
||||
pub(super) offset_chars: Option<u32>,
|
||||
pub(super) limit_chars: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Subscription {
|
||||
pub(super) channel_name: Option<String>,
|
||||
pub(super) thread_id: Option<Uuid>,
|
||||
pub(super) target_agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Post {
|
||||
pub(super) text: String,
|
||||
pub(super) channel_name: Option<String>,
|
||||
pub(super) new_channel_name: Option<String>,
|
||||
pub(super) thread_id: Option<Uuid>,
|
||||
pub(super) agents_to_notify: Option<Vec<String>>,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Schemas for the shared discussion tools, in the existing collaboration 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;
|
||||
|
||||
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) -> 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.",
|
||||
&["channel_name", "subscribe"],
|
||||
&["channel_name"],
|
||||
),
|
||||
"get_channels" => (
|
||||
"Find channels by case-insensitive name substring, ordered by activity.",
|
||||
&["query", "recent_first", "limit", "cursor"],
|
||||
&[],
|
||||
),
|
||||
"list_threads" => (
|
||||
"List discussion roots with bounded previews; sort by created (default) or activity.",
|
||||
&[
|
||||
"channel_name",
|
||||
"sort",
|
||||
"recent_first",
|
||||
"limit",
|
||||
"cursor",
|
||||
"max_chars_per_post",
|
||||
],
|
||||
&["channel_name"],
|
||||
),
|
||||
"search_posts" => (
|
||||
"Search posts by case-insensitive text substring, newest first. Filters combine. Agent references may be absolute or relative to you.",
|
||||
&[
|
||||
"channel_name",
|
||||
"query",
|
||||
"after_message_id",
|
||||
"author",
|
||||
"limit",
|
||||
"cursor",
|
||||
"max_chars_per_post",
|
||||
],
|
||||
&[],
|
||||
),
|
||||
"read_thread" => (
|
||||
"Read a discussion root and newest replies. The root repeats on each page; the cursor advances replies.",
|
||||
&["thread_id", "limit", "cursor", "max_chars_per_post"],
|
||||
&["thread_id"],
|
||||
),
|
||||
"read_post" => (
|
||||
"Read part of one post. Offsets and lengths count Unicode characters; continue at next_offset_chars until n_chars.",
|
||||
&["message_id", "offset_chars", "limit_chars"],
|
||||
&["message_id"],
|
||||
),
|
||||
"subscribe" | "unsubscribe" => (
|
||||
"Change a channel OR discussion subscription, optionally for another tree member. Channel subscriptions notify new roots; thread subscriptions notify replies. Notices only reach currently running turns, with no later backlog.",
|
||||
&["channel_name", "thread_id", "target_agent"],
|
||||
&[],
|
||||
),
|
||||
"post" => (
|
||||
"Post to exactly one existing channel, new channel, or discussion thread. Posting subscribes you to that discussion. Explicit agent recipients get a notice without subscribing. Notices never start idle agents. Returns metadata, not the post body.",
|
||||
&[
|
||||
"text",
|
||||
"channel_name",
|
||||
"new_channel_name",
|
||||
"thread_id",
|
||||
"agents_to_notify",
|
||||
],
|
||||
&["text"],
|
||||
),
|
||||
_ => unreachable!("only registered message-board tools have schemas"),
|
||||
};
|
||||
let mut properties = serde_json::Map::new();
|
||||
for field in fields {
|
||||
let schema = match *field {
|
||||
"subscribe" => {
|
||||
json!({"type":"boolean","description":"Subscribe to new roots. Default true."})
|
||||
}
|
||||
"recent_first" => json!({"type":"boolean","description":"Newest first by default."}),
|
||||
"limit" => {
|
||||
json!({"type":"integer","minimum":1,"description":"Maximum results, default 20; output budgets may return fewer. Continue with next_cursor."})
|
||||
}
|
||||
"offset_chars" => json!({"type":"integer","minimum":0,"description":"Default 0."}),
|
||||
"limit_chars" | "max_chars_per_post" => {
|
||||
json!({"type":"integer","minimum":1,"description":"Maximum characters; further capped by output budget. Defaults: limit_chars 20000, max_chars_per_post 1000."})
|
||||
}
|
||||
"sort" => json!({"type":"string","enum":["created","activity"]}),
|
||||
"agents_to_notify" => {
|
||||
json!({"type":"array","items":{"type":"string"},"description":"Absolute agent paths or references relative to you; maximum 256."})
|
||||
}
|
||||
"cursor" => {
|
||||
json!({"type":"string","description":"Opaque next_cursor from the same query. Keep filters and sorting unchanged. Concurrent posts may shift pages; omit the cursor to refresh."})
|
||||
}
|
||||
_ => json!({"type":"string"}),
|
||||
};
|
||||
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,
|
||||
})],
|
||||
})
|
||||
}
|
||||
@@ -590,3 +590,189 @@ async fn queries_page_discussions_and_search_unicode() {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_validate_arguments_deduplicate_calls_and_bound_unicode_results() {
|
||||
use codex_tools::ConversationHistory;
|
||||
use codex_tools::NoopTurnItemEmitter;
|
||||
use codex_tools::ToolCall;
|
||||
use codex_tools::ToolCallSource;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolPayload;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use serde_json::json;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
|
||||
let root = ThreadId::new();
|
||||
let child = ThreadId::new();
|
||||
let child_path = AgentPath::root().join("worker").unwrap();
|
||||
let host = Arc::new(Host {
|
||||
fail_notifications: AtomicBool::new(false),
|
||||
members: [(root, AgentPath::root()), (child, child_path)].into(),
|
||||
clock: AtomicI64::default(),
|
||||
active: AtomicBool::new(true),
|
||||
notifications: Mutex::default(),
|
||||
});
|
||||
let board = Arc::new(
|
||||
LocalAgentMessageBoard::open(&sqlite, root.into(), host.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let tools = message_board_tools(board.clone(), root, AgentPath::root());
|
||||
let call = |name: &str, args: serde_json::Value| ToolCall {
|
||||
turn_id: "turn-1".into(),
|
||||
call_id: name.into(),
|
||||
tool_name: ToolName::namespaced("collaboration", name),
|
||||
model: "test".into(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(20_000),
|
||||
source: ToolCallSource::Direct,
|
||||
conversation_history: ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
environments: vec![],
|
||||
payload: ToolPayload::Function {
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
};
|
||||
let tool = |name: &str| {
|
||||
tools
|
||||
.iter()
|
||||
.find(|tool| tool.tool_name() == ToolName::namespaced("collaboration", name))
|
||||
.unwrap()
|
||||
};
|
||||
for (name, args) in [
|
||||
(
|
||||
"post",
|
||||
json!({"new_channel_name":"low-budget","text":"must not be stored"}),
|
||||
),
|
||||
("create_channel", json!({"channel_name":"low-budget"})),
|
||||
] {
|
||||
let mut limited = call(name, args);
|
||||
limited.truncation_policy = TruncationPolicy::Bytes(1);
|
||||
assert!(tool(name).handle(limited).await.is_err());
|
||||
}
|
||||
assert!(
|
||||
tool("post")
|
||||
.handle(call(
|
||||
"post",
|
||||
json!({"text":"invalid","channel_name":"a","new_channel_name":"b"})
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool("get_channels")
|
||||
.handle(call("get_channels", json!({"limit":0})))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool("get_channels")
|
||||
.handle(call("get_channels", json!({"typo":true})))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
board
|
||||
.list_channels(
|
||||
root,
|
||||
ChannelQuery {
|
||||
query: None,
|
||||
direction: SortDirection::NewestFirst,
|
||||
page: PageRequest::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.results
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let post_call = call(
|
||||
"post",
|
||||
json!({"text":"🦀".repeat(8000),"new_channel_name":"work","agents_to_notify":["worker"]}),
|
||||
);
|
||||
let result = tool("post").handle(post_call.clone()).await.unwrap();
|
||||
let metadata: PostMetadata = serde_json::from_str(&result.log_output()).unwrap();
|
||||
assert_eq!(
|
||||
tool("post").handle(post_call).await.unwrap().log_output(),
|
||||
result.log_output()
|
||||
);
|
||||
assert_eq!(host.notifications.lock().unwrap().len(), 1);
|
||||
let preview = tool("search_posts")
|
||||
.handle(call("search_posts", json!({"query":"🦀"})))
|
||||
.await
|
||||
.unwrap();
|
||||
let preview: Page<PostPreview> = serde_json::from_str(&preview.log_output()).unwrap();
|
||||
assert_eq!(
|
||||
preview,
|
||||
Page {
|
||||
results: vec![PostPreview {
|
||||
metadata: metadata.clone(),
|
||||
text_preview: "🦀".repeat(1000),
|
||||
n_chars: 8000,
|
||||
truncated: true,
|
||||
}],
|
||||
next_cursor: None,
|
||||
}
|
||||
);
|
||||
let read_call = call("read_post", json!({"message_id":metadata.message_id}));
|
||||
let result = tool("read_post").handle(read_call.clone()).await.unwrap();
|
||||
assert!(result.contains_external_context());
|
||||
assert!(result.log_output().len() <= 8000);
|
||||
let first: PostContent = serde_json::from_str(&result.log_output()).unwrap();
|
||||
assert_eq!(first.text, "🦀".repeat(first.next_offset_chars));
|
||||
assert_eq!(first.n_chars, 8000);
|
||||
let result = tool("read_post").handle(call("read_post", json!({"message_id":metadata.message_id,"offset_chars":first.next_offset_chars,"limit_chars":2}))).await.unwrap();
|
||||
let second: PostContent = serde_json::from_str(&result.log_output()).unwrap();
|
||||
assert_eq!(second.text, "🦀🦀");
|
||||
assert_eq!(second.next_offset_chars, first.next_offset_chars + 2);
|
||||
|
||||
let mut nested = call(
|
||||
"post",
|
||||
json!({"text":"reply","thread_id":metadata.thread_id}),
|
||||
);
|
||||
nested.source = ToolCallSource::CodeMode {
|
||||
cell_id: "cell-1".into(),
|
||||
runtime_tool_call_id: "nested-1".into(),
|
||||
};
|
||||
let first_nested = tool("post")
|
||||
.handle(nested.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.log_output();
|
||||
nested.source = ToolCallSource::CodeMode {
|
||||
cell_id: "cell-1".into(),
|
||||
runtime_tool_call_id: "nested-2".into(),
|
||||
};
|
||||
assert_ne!(
|
||||
tool("post").handle(nested).await.unwrap().log_output(),
|
||||
first_nested
|
||||
);
|
||||
let result = tool("search_posts")
|
||||
.handle(call(
|
||||
"search_posts",
|
||||
json!({"query":"reply","author":"/root"}),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let page: Page<PostPreview> = serde_json::from_str(&result.log_output()).unwrap();
|
||||
assert_eq!(page.results.len(), 2);
|
||||
assert!(page.results.iter().all(|post| post.text_preview == "reply"));
|
||||
|
||||
let mut nested_read = read_call;
|
||||
nested_read.source = ToolCallSource::CodeMode {
|
||||
cell_id: "cell-1".into(),
|
||||
runtime_tool_call_id: "nested-3".into(),
|
||||
};
|
||||
assert!(
|
||||
tool("read_post")
|
||||
.handle(nested_read)
|
||||
.await
|
||||
.unwrap()
|
||||
.log_output()
|
||||
.len()
|
||||
<= 8000
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user