Support remote agent message boards in multi-agent sessions (#49267)

## What changed

Add `features.multi_agent_v2.message_board_remote` with a board URL and a bearer token supplied directly or through an environment variable. When configured, use the remote board in preference to local or in-memory storage, including in ephemeral sessions, while preserving the existing board tools and session identity.

Ignore remote board settings in project-local configuration and use the runtime HTTP client and clock for remote requests.

## Testing

Add a mock-server integration test and snapshot covering an ephemeral session posting through the existing tool, with the expected bearer authentication, session identity, timestamp, and returned post.

GitOrigin-RevId: 263413d90a1ee4c0bdba8c1b08ba9f68bdd7a986
This commit is contained in:
jif
2026-09-29 13:52:04 +00:00
committed by copyberry
parent 5f7d5add2a
commit 68e1a421f5
14 changed files with 259 additions and 3 deletions
+2
View File
@@ -2899,6 +2899,7 @@ dependencies = [
"bm25",
"chrono",
"codex-agent-graph-store",
"codex-agent-message-board-client",
"codex-agent-message-board-extension",
"codex-agent-roles",
"codex-analytics",
@@ -3370,6 +3371,7 @@ version = "0.0.0"
dependencies = [
"codex-network-proxy",
"codex-protocol",
"codex-utils-redacted-string",
"pretty_assertions",
"schemars 0.8.22",
"serde",
+1
View File
@@ -206,6 +206,7 @@ codex-websocket-client = { path = "websocket-client" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-agent-extension = { path = "ext/agent" }
codex-agent-message-board-client = { path = "agent-message-board-client" }
codex-agent-message-board-extension = { path = "ext/agent-message-board" }
codex-connectors-extension = { path = "ext/connectors" }
codex-context-fragments = { path = "context-fragments" }
+7
View File
@@ -1157,6 +1157,13 @@ fn sanitize_project_config(
{
ignored_keys.push("features.shell_snapshot".to_string());
}
if let Some(multi_agent) = features
.get_mut("multi_agent_v2")
.and_then(TomlValue::as_table_mut)
&& multi_agent.remove("message_board_remote").is_some()
{
ignored_keys.push("features.multi_agent_v2.message_board_remote".to_string());
}
for key in ["respect_system_proxy", "system_proxy_fallback"] {
if features.remove(key).is_some() {
ignored_keys.push(format!("features.{key}"));
+1
View File
@@ -69,6 +69,7 @@ codex-skills = { workspace = true }
codex-skills-extension = { workspace = true }
codex-state = { workspace = true }
codex-agent-message-board-extension = { workspace = true }
codex-agent-message-board-client = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-thread-store = { workspace = true }
codex-tools = { workspace = true }
+29
View File
@@ -2795,6 +2795,14 @@
"description": "Keep the message board in memory for a training session, including ephemeral sessions.",
"type": "boolean"
},
"message_board_remote": {
"allOf": [
{
"$ref": "#/definitions/RemoteMessageBoardConfigToml"
}
],
"description": "Use a session-scoped remote board instead of local storage."
},
"min_wait_timeout_ms": {
"format": "int64",
"maximum": 3600000.0,
@@ -3842,6 +3850,27 @@
}
]
},
"RemoteMessageBoardConfigToml": {
"additionalProperties": false,
"description": "Connection to a board provisioned by the research host.",
"properties": {
"bearer_token": {
"description": "Board credential supplied directly by a runtime config override.",
"type": "string"
},
"bearer_token_env_var": {
"description": "Read the credential from this environment variable instead of bearer_token.",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"ResumeCwdMode": {
"description": "Working directory to use when resuming or forking a session.",
"oneOf": [
+31 -3
View File
@@ -11,6 +11,8 @@ use crate::context::ContextualUserFragment;
use crate::tools::MULTI_AGENT_V2_NAMESPACE_DESCRIPTION;
use chrono::DateTime;
use chrono::Utc;
use codex_agent_message_board_client::AccessToken;
use codex_agent_message_board_client::RemoteAgentMessageBoard;
use codex_agent_message_board_extension::AgentMessageBoard;
use codex_agent_message_board_extension::InMemoryMessageBoards;
use codex_agent_message_board_extension::LocalAgentMessageBoard;
@@ -19,6 +21,7 @@ use codex_agent_message_board_extension::NotificationDelivery;
use codex_agent_message_board_extension::PostPreview;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_http_client::ClientRouteClass;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
@@ -43,14 +46,18 @@ pub fn install_agent_message_board(
|config: &Config| config.multi_agent_v2.tool_namespace.clone(),
move |config: &Config, tree, caller| {
let in_memory = config.multi_agent_v2.message_board_in_memory;
// MAv2 supplies tree paths; ephemeral runtimes must not open durable storage.
// MAv2 supplies tree paths; ephemeral runtimes must not open local SQLite.
if !config.features.enabled(Feature::AgentMessageBoard)
|| !config.features.enabled(Feature::MultiAgentV2)
|| (config.ephemeral && !in_memory)
|| (config.ephemeral
&& !in_memory
&& config.multi_agent_v2.message_board_remote.is_none())
{
return Box::pin(async { Ok(None) });
}
let sqlite = config.sqlite_config().clone();
let remote = config.multi_agent_v2.message_board_remote.clone();
let http_factory = config.http_client_factory();
let in_memory_boards = Arc::clone(&in_memory_boards);
let host = Arc::new(LocalBoardHost {
manager: manager.clone(),
@@ -58,7 +65,28 @@ pub fn install_agent_message_board(
caller,
});
Box::pin(async move {
let board: Arc<dyn AgentMessageBoard> = if in_memory {
let board: Arc<dyn AgentMessageBoard> = if let Some(remote) = remote {
let token = match remote.bearer_token_env_var {
Some(name) => std::env::var(name).map_err(|_| CodexErr::InvalidRequest(
"message-board credential environment variable is missing or invalid".into(),
))?,
None => remote.bearer_token.ok_or_else(|| CodexErr::InvalidRequest(
"remote message board requires a credential".into(),
))?.into_inner(),
};
let token = AccessToken::new(token)
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
let http = http_factory
.build_client(&remote.url, ClientRouteClass::Api)
.map_err(|err| CodexErr::Io(std::io::Error::other(err)))?;
let board = RemoteAgentMessageBoard::new(http, &remote.url, tree, token)
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?
.with_clock(move |caller| {
let host = host.clone();
Box::pin(async move { host.current_time(caller).await })
});
Arc::new(board)
} else if in_memory {
Arc::new(in_memory_boards.open(tree, host).await)
} else {
Arc::new(LocalAgentMessageBoard::open(&sqlite, tree, host).await?)
+3
View File
@@ -1346,6 +1346,7 @@ pub struct MultiAgentV2Config {
pub wait_agent_enabled: bool,
pub disable_direct_message: bool,
pub message_board_in_memory: bool,
pub message_board_remote: Option<codex_features::RemoteMessageBoardConfigToml>,
pub non_code_mode_only: bool,
}
@@ -1367,6 +1368,7 @@ impl MultiAgentV2Config {
wait_agent_enabled: true,
disable_direct_message: false,
message_board_in_memory: false,
message_board_remote: None,
non_code_mode_only: true,
}
}
@@ -2838,6 +2840,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
wait_agent_enabled,
disable_direct_message,
message_board_in_memory,
message_board_remote: base.and_then(|config| config.message_board_remote.clone()),
non_code_mode_only,
}
}
@@ -29,6 +29,9 @@ use serde_json::Value;
use serde_json::json;
use tokio::sync::oneshot;
#[path = "scenarios_agent_message_board_remote.rs"]
mod remote;
enum BoardClock {
Available,
Unavailable,
@@ -0,0 +1,125 @@
//! Runs existing board tools against a research-provisioned remote board.
use super::BoardClock;
use super::configure;
use super::done;
use super::tool;
use anyhow::Context;
use codex_features::RemoteMessageBoardConfigToml;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::SnapshotEntry;
use core_test_support::responses;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::sync::Arc;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_partial_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_board_uses_the_existing_tools_and_session_identity() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let board = responses::start_mock_server().await;
let token = "research-board-credential-for-runtime-test";
let url = board.uri();
let root = test_codex()
.with_config(move |config| {
configure(config);
config.ephemeral = true;
config.current_time_reminder = Some(codex_core::config::CurrentTimeReminderConfig {
clock_source: codex_features::CurrentTimeSource::External,
..Default::default()
});
config.multi_agent_v2.message_board_remote = Some(RemoteMessageBoardConfigToml {
url,
bearer_token: Some(token.into()),
bearer_token_env_var: None,
});
})
.with_external_time_provider(Arc::new(BoardClock::Available))
.build_with_auto_env(&server)
.await?;
let root_id = root.session_configured.thread_id;
let post = json!({
"message_id": "00000000-0000-4000-8000-000000000001",
"thread_id": "00000000-0000-4000-8000-000000000001",
"author": "/root", "channel_name": "design", "created_at": "2026-09-18T12:00:00Z",
});
Mock::given(method("POST"))
.and(path(format!("/v1/boards/{root_id}/call")))
.and(header("authorization", format!("Bearer {token}")))
.and(body_partial_json(json!({
"caller": root_id,
"timestamp": "2026-09-18T12:00:00Z",
"method": "post",
"params": {
"destination": {"NewChannel": "design"},
"text": "A remote decision.", "agents_to_notify": []
}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(&post))
.expect(1)
.mount(&board)
.await;
let model = responses::mount_sse_sequence(
&server,
vec![
tool(
"remote-post",
"post",
json!({"new_channel_name":"design", "text":"A remote decision."}),
),
done(),
],
)
.await;
root.submit_turn("Post the remote decision.").await?;
let requests = model.requests();
let output: Value = serde_json::from_str(
&requests
.last()
.context("model request")?
.function_call_output_text("remote-post")
.context("tool output")?,
)?;
assert_eq!(output, post);
// Normalize tool JSON key order across Cargo and Bazel feature sets.
let mut bodies = requests
.iter()
.map(responses::ResponsesRequest::body_json)
.collect::<Vec<_>>();
for body in &mut bodies {
for item in body["input"].as_array_mut().context("request input")? {
if item["type"] == "function_call_output" {
let mut output: Value =
serde_json::from_str(item["output"].as_str().context("output")?)?;
output.sort_all_objects();
item["output"] = Value::String(output.to_string());
}
}
}
insta::assert_snapshot!(
"remote_board_tools",
context_snapshot::format_context_snapshot(
"Existing board tools use a provisioned remote board.",
&bodies.iter().map(SnapshotEntry::body).collect::<Vec<_>>(),
&ContextSnapshotOptions::default().rewrite_known_segments(),
)
);
root.codex.shutdown_and_wait().await?;
assert_eq!(
board
.received_requests()
.await
.context("board requests")?
.len(),
1
);
Ok(())
}
@@ -0,0 +1,37 @@
---
source: core/tests/suite/scenarios_agent_message_board_remote.rs
expression: "context_snapshot::format_context_snapshot(\"Existing board tools use a provisioned remote board.\",\n&bodies.iter().map(SnapshotEntry::body).collect::<Vec<_>>(),\n&ContextSnapshotOptions::default().rewrite_known_segments(),)"
---
Scenario: Existing board tools use a provisioned remote board.
## Window 1
-- request 1 (request) --
00:message/developer:
<PERMISSIONS_INSTRUCTIONS>
01:message/developer:
You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
Child agents can also spawn their own sub-agents.
<OMITTED 16 LINES; ~234 TOKENS; hash=b2e94283a8c22327>
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...fork_turns` to `"none"` or a positive integer string. [hash=db3545796947884f]
02:message/developer:
<MULTI_AGENT_MODE>
03:message/user:
<ENVIRONMENT_CONTEXT>
04:message/user:
Post the remote decision.
-- request 2 (request) --
05:function_call/collaboration.post:{"new_channel_name":"design","text":"A remote decision."}
06:function_call_output:{"author":"/root","channel_name":"design","created_at":"2026-09-18T12:00:00Z","message_id":"<UUID 1>","thread_id":"<UUID 1>"}
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
codex-network-proxy = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-redacted-string = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
toml = { workspace = true }
+17
View File
@@ -2,12 +2,26 @@ use crate::FeatureConfig;
use crate::FeatureToml;
use codex_network_proxy::CredentialProviderConfig;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_redacted_string::RedactedString;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::num::NonZeroUsize;
/// Connection to a board provisioned by the research host.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RemoteMessageBoardConfigToml {
pub url: String,
/// Board credential supplied directly by a runtime config override.
#[serde(skip_serializing_if = "Option::is_none")]
pub bearer_token: Option<RedactedString>,
/// Read the credential from this environment variable instead of bearer_token.
#[serde(skip_serializing_if = "Option::is_none")]
pub bearer_token_env_var: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ToolRegistryConfigToml {
@@ -303,6 +317,9 @@ pub struct MultiAgentV2ConfigToml {
/// Keep the message board in memory for a training session, including ephemeral sessions.
#[serde(skip_serializing_if = "Option::is_none")]
pub message_board_in_memory: Option<bool>,
/// Use a session-scoped remote board instead of local storage.
#[serde(skip_serializing_if = "Option::is_none")]
pub message_board_remote: Option<RemoteMessageBoardConfigToml>,
#[serde(skip_serializing_if = "Option::is_none")]
pub non_code_mode_only: Option<bool>,
}
+1
View File
@@ -32,6 +32,7 @@ pub use feature_configs::NetworkProxyDomainPermissionToml;
pub use feature_configs::NetworkProxyModeToml;
pub use feature_configs::NetworkProxyUnixSocketPermissionToml;
pub use feature_configs::NonPrefixedMcpToolNamesConfigToml;
pub use feature_configs::RemoteMessageBoardConfigToml;
use feature_configs::RemovedAppsMcpPathOverrideConfigToml;
pub use feature_configs::RolloutBudgetConfigToml;
pub use feature_configs::SleepToolConfigToml;
+1
View File
@@ -774,6 +774,7 @@ non_code_mode_only = true
wait_agent_enabled: Some(false),
disable_direct_message: Some(true),
message_board_in_memory: Some(true),
message_board_remote: None,
non_code_mode_only: Some(true),
}))
);