#![cfg(all(feature = "comms", any(unix, windows)))]
use std::sync::Arc;
use rmcp::ErrorData as McpError;
use rmcp::model::CallToolResult;
use tokio::sync::Mutex;
use super::ServerState;
use super::helpers::json_result;
use super::mode::{AgentsMode, reject_unsupported};
use super::types_comms::{
AgentListParams, AgentListResponse, AgentRegisterParams, AgentRegisterResponse, AgentSummary, AgentsParams,
CursorAdvance, InboxAckParams, InboxAckResponse, InboxReadParams, InboxReadResponse, InboxWaitParams,
InboxWaitResponse, MessageFrontMatter, MessageGetParams, MessageGetResponse, ThreadArchiveParams,
ThreadArchiveResponse, ThreadHistoryParams, ThreadHistoryResponse, ThreadJoinParams, ThreadLeaveParams,
ThreadListParams, ThreadListResponse, ThreadMemberChangeResponse, ThreadMemberParams, ThreadMembersParams,
ThreadMembersResponse, ThreadMembershipResponse, ThreadPostParams, ThreadPostResponse, ThreadStartParams,
ThreadStartResponse, ThreadSummary,
};
use crate::comms::client::{CommsClient, scope_context_for};
use crate::comms::ids::AgentId;
use crate::comms::model::now_micros;
const DEFAULT_LIMIT: u32 = 100;
const DELIVERY_SCAN_LIMIT: u32 = 200;
const DELIVERY_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
const DELIVERY_NOTICE_LIMIT: usize = 5;
const CONNECT_BUDGET: std::time::Duration = std::time::Duration::from_secs(15);
const DELIVERY_CONNECT_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
const DELIVERY_BUDGET: std::time::Duration = std::time::Duration::from_millis(200);
const DEFAULT_SINCE_HOURS: u32 = 24;
const DEFAULT_WAIT_SECS: u32 = 30;
const MAX_WAIT_SECS: u32 = 40;
const MICROS_PER_HOUR: i64 = 3_600_000_000;
fn since_cutoff(since_hours: Option<u32>) -> Option<i64> {
let hours = since_hours.unwrap_or(DEFAULT_SINCE_HOURS);
if hours == 0 {
None
} else {
Some(now_micros() - i64::from(hours) * MICROS_PER_HOUR)
}
}
pub(super) fn comms_err(error: impl std::fmt::Display) -> McpError {
McpError::internal_error(format!("comms: {error}"), None)
}
async fn connect_comms_client(state: &ServerState, target: AgentId) -> Result<CommsClient, McpError> {
let (remote, cwd) = scope_context_for(&state.shared.root);
if state.shared.host.is_some() {
let paths = crate::comms::singleton::resolve_paths().map_err(comms_err)?;
return CommsClient::connect(&paths, target, remote, cwd)
.await
.map_err(comms_err);
}
CommsClient::ensure_and_connect(target, remote, cwd)
.await
.map_err(comms_err)
}
pub(super) fn validate_thread_dimensions(
subject: Option<&str>,
path: Option<&str>,
members: &[AgentId],
creator: &AgentId,
) -> Result<(), McpError> {
let has_subject = subject.is_some_and(|s| !s.is_empty());
let has_path = path.is_some_and(|p| !p.is_empty());
let has_members = members.iter().any(|m| m != creator);
let count = [has_subject, has_path, has_members].iter().filter(|b| **b).count();
if count >= 2 {
Ok(())
} else {
Err(comms_err(
"`agents` mode=\"thread_start\" requires at least 2 of `subject` / `path` / `members` (a \
member other than yourself); supply at least two",
))
}
}
pub(super) async fn resolve_comms_client(
state: &ServerState,
as_agent: Option<String>,
) -> Result<Arc<Mutex<CommsClient>>, McpError> {
let target = match as_agent {
Some(raw) => AgentId::parse(raw.clone()).map_err(|e| comms_err(format!("invalid as_agent {raw:?}: {e}")))?,
None => AgentId::parse(state.agent_id.clone())
.map_err(|e| comms_err(format!("invalid agent id {:?}: {e}", state.agent_id)))?,
};
let cell = {
let mut map = state.comms_clients.lock().await;
match map.get(&target) {
Some(cell) => cell.clone(),
None => {
let cell = Arc::new(tokio::sync::OnceCell::new());
map.put(target.clone(), cell.clone());
cell
}
}
};
let handle = cell
.get_or_try_init(|| async {
let client = tokio::time::timeout(CONNECT_BUDGET, connect_comms_client(state, target.clone()))
.await
.map_err(|_| {
comms_err(format!(
"broker unresponsive: connect did not finish within {}s (retryable)",
CONNECT_BUDGET.as_secs()
))
})??;
Ok::<_, McpError>(Arc::new(Mutex::new(client)))
})
.await?;
Ok(handle.clone())
}
pub(super) async fn take_delivery_notice(state: &ServerState) -> Option<String> {
let mut probe = state.delivery_probe.try_lock().ok()?;
if probe
.last_probe
.is_some_and(|at| at.elapsed() < DELIVERY_PROBE_INTERVAL)
{
return None;
}
probe.last_probe = Some(std::time::Instant::now());
if probe.client.is_none() {
match tokio::time::timeout(DELIVERY_CONNECT_BUDGET, connect_ephemeral_client(state)).await {
Ok(Ok(client)) => probe.client = Some(client),
_ => return None,
}
}
let page = match tokio::time::timeout(DELIVERY_BUDGET, probe_inbox(&mut probe.client)).await {
Ok(Ok(page)) => page,
_ => {
probe.client = None;
return None;
}
};
drop(probe);
let (messages, unread, _) = page;
let mut delivered = state.delivered_notifications.lock().await;
let unseen: Vec<_> = messages
.into_iter()
.filter(|message| !delivered.contains(&message.meta.id))
.collect();
if unseen.is_empty() {
return None;
}
let shown = unseen.len().min(DELIVERY_NOTICE_LIMIT);
let mut notice = String::from("New basemind agent-comms message(s):\n");
for message in unseen.iter().take(shown) {
delivered.put(message.meta.id.clone(), ());
notice.push_str(&format!(
"- [{}#{}] from {}: {}\n",
message.meta.thread.as_str(),
message.seq,
message.meta.from.as_str(),
message.meta.subject
));
}
let overflow = unseen.len().saturating_sub(shown).saturating_add(unread as usize);
if overflow > 0 {
notice.push_str(&format!(
"- {overflow} more unread; use agents mode inbox to review them.\n"
));
}
notice.push_str("Use agents mode message with the message id to read a body; ack only after handling it.");
Some(notice)
}
async fn probe_inbox(
slot: &mut Option<CommsClient>,
) -> Result<
(
Vec<crate::comms::protocol::SeqMeta>,
u32,
Option<crate::comms::cursor::Cursor>,
),
McpError,
> {
let client = slot
.as_mut()
.ok_or_else(|| comms_err("delivery probe has no connection"))?;
client
.read_inbox(None, None, None, DELIVERY_SCAN_LIMIT, false, None)
.await
.map_err(comms_err)
}
pub(super) async fn connect_ephemeral_client(state: &ServerState) -> Result<CommsClient, McpError> {
let target = AgentId::parse(state.agent_id.clone())
.map_err(|e| comms_err(format!("invalid agent id {:?}: {e}", state.agent_id)))?;
connect_comms_client(state, target).await
}
#[cfg(feature = "memory")]
pub(super) async fn dispatch_memory_op(
state: &ServerState,
op: crate::comms::memory_proto::MemoryOp,
) -> Result<crate::comms::memory_proto::MemoryOutcome, McpError> {
if let Some(host) = &state.shared.host {
let host = Arc::clone(host);
let root = state.shared.root.clone();
let scope = state.shared.scope.clone();
return tokio::task::spawn_blocking(move || host.host_memory(&root, &scope, op))
.await
.map_err(|error| McpError::internal_error(format!("host memory task panicked: {error}"), None))?
.map_err(|error| McpError::internal_error(format!("host memory: {error}"), None));
}
let client = resolve_comms_client(state, None).await?;
let mut guard = client.lock().await;
guard
.memory_op(state.shared.root.clone(), state.shared.scope.clone(), op)
.await
.map_err(comms_err)
}
#[cfg(feature = "memory")]
pub(super) async fn dispatch_governance_op(
state: &ServerState,
op: crate::comms::proposals_proto::GovernanceOp,
) -> Result<crate::comms::proposals_proto::GovernanceOutcome, McpError> {
if let Some(host) = &state.shared.host {
let host = Arc::clone(host);
let root = state.shared.root.clone();
let scope = state.shared.scope.clone();
return tokio::task::spawn_blocking(move || host.host_governance(&root, &scope, op))
.await
.map_err(|error| McpError::internal_error(format!("host governance task panicked: {error}"), None))?
.map_err(|error| McpError::internal_error(format!("host governance: {error}"), None));
}
let client = resolve_comms_client(state, None).await?;
let mut guard = client.lock().await;
guard
.governance_op(state.shared.root.clone(), state.shared.scope.clone(), op)
.await
.map_err(comms_err)
}
fn clamp_limit(limit: Option<u32>) -> u32 {
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, crate::comms::daemon::MAX_LIMIT)
}
fn reject_foreign_fields(mode: AgentsMode, present: &[(&str, bool)], allowed: &[&str]) -> Result<(), McpError> {
let foreign: Vec<(&str, bool)> = present
.iter()
.filter(|(field, _)| !allowed.contains(field))
.copied()
.collect();
reject_unsupported(AgentsMode::DOMAIN, mode.as_str(), &foreign)
}
fn require_field<T>(mode: AgentsMode, field: &str, value: Option<T>) -> Result<T, McpError> {
value.ok_or_else(|| {
McpError::invalid_params(
format!("`{}` mode=\"{}\" requires `{field}`", AgentsMode::DOMAIN, mode.as_str()),
None,
)
})
}
pub(super) async fn run_agents(
state: &ServerState,
params: AgentsParams,
cancel: &tokio_util::sync::CancellationToken,
) -> Result<CallToolResult, McpError> {
let AgentsParams {
mode,
thread,
member,
members,
message_id,
message_ids,
to_seq,
subject,
subject_contains,
path,
body,
tags,
reply_to,
idempotency_key,
include_archived,
mark_read,
cursor,
limit,
since_hours,
timeout_secs,
name,
description,
version,
skills,
apply,
message_ttl_hours,
thread_idle_hours,
thread_retention_hours,
agent_ttl_hours,
claim_ttl_hours,
as_agent,
} = params;
let present = [
("thread", thread.is_some()),
("member", member.is_some()),
("members", members.is_some()),
("message_id", message_id.is_some()),
("message_ids", message_ids.is_some()),
("to_seq", to_seq.is_some()),
("subject", subject.is_some()),
("subject_contains", subject_contains.is_some()),
("path", path.is_some()),
("body", body.is_some()),
("tags", tags.is_some()),
("reply_to", reply_to.is_some()),
("idempotency_key", idempotency_key.is_some()),
("include_archived", include_archived.is_some()),
("mark_read", mark_read.is_some()),
("cursor", cursor.is_some()),
("limit", limit.is_some()),
("since_hours", since_hours.is_some()),
("timeout_secs", timeout_secs.is_some()),
("name", name.is_some()),
("description", description.is_some()),
("version", version.is_some()),
("skills", skills.is_some()),
("apply", apply.is_some()),
("message_ttl_hours", message_ttl_hours.is_some()),
("thread_idle_hours", thread_idle_hours.is_some()),
("thread_retention_hours", thread_retention_hours.is_some()),
("agent_ttl_hours", agent_ttl_hours.is_some()),
("claim_ttl_hours", claim_ttl_hours.is_some()),
];
reject_foreign_fields(mode, &present, allowed_fields(mode))?;
validate_cleanup_apply(mode, apply)?;
match mode {
AgentsMode::Register => {
run_agent_register(
state,
AgentRegisterParams {
name: name.unwrap_or_default(),
description: description.unwrap_or_default(),
version: version.unwrap_or_default(),
skills: skills.unwrap_or_default(),
as_agent,
},
)
.await
}
AgentsMode::List => run_agent_list(state, AgentListParams { thread, as_agent }).await,
AgentsMode::ThreadStart => {
run_thread_start(
state,
ThreadStartParams {
subject,
path,
members: members.unwrap_or_default(),
as_agent,
},
)
.await
}
AgentsMode::ThreadList => {
run_thread_list(
state,
ThreadListParams {
subject_contains,
include_archived: include_archived.unwrap_or(false),
as_agent,
},
)
.await
}
AgentsMode::Join => {
run_thread_join(
state,
ThreadJoinParams {
thread: require_field(mode, "thread", thread)?,
as_agent,
},
)
.await
}
AgentsMode::Leave => {
run_thread_leave(
state,
ThreadLeaveParams {
thread: require_field(mode, "thread", thread)?,
as_agent,
},
)
.await
}
AgentsMode::Members => {
run_thread_members(
state,
ThreadMembersParams {
thread: require_field(mode, "thread", thread)?,
as_agent,
},
)
.await
}
AgentsMode::AddMember => {
run_thread_add_member(
state,
ThreadMemberParams {
thread: require_field(mode, "thread", thread)?,
member: require_field(mode, "member", member)?,
as_agent,
},
)
.await
}
AgentsMode::RemoveMember => {
run_thread_remove_member(
state,
ThreadMemberParams {
thread: require_field(mode, "thread", thread)?,
member: require_field(mode, "member", member)?,
as_agent,
},
)
.await
}
AgentsMode::Archive => {
run_thread_archive(
state,
ThreadArchiveParams {
thread: require_field(mode, "thread", thread)?,
as_agent,
},
)
.await
}
AgentsMode::Post => {
run_thread_post(
state,
ThreadPostParams {
thread: require_field(mode, "thread", thread)?,
subject: require_field(mode, "subject", subject)?,
body,
tags,
reply_to,
idempotency_key,
as_agent,
},
)
.await
}
AgentsMode::History => {
run_thread_history(
state,
ThreadHistoryParams {
thread: require_field(mode, "thread", thread)?,
cursor,
limit,
since_hours,
as_agent,
},
)
.await
}
AgentsMode::Message => {
run_message_get(
state,
MessageGetParams {
message_id: require_field(mode, "message_id", message_id)?,
as_agent,
},
)
.await
}
AgentsMode::Inbox => {
run_inbox_read(
state,
InboxReadParams {
cursor,
limit,
mark_read: mark_read.unwrap_or(false),
since_hours,
as_agent,
},
)
.await
}
AgentsMode::Ack => {
run_inbox_ack(
state,
InboxAckParams {
message_ids: message_ids.unwrap_or_default(),
thread,
to_seq,
as_agent,
},
)
.await
}
AgentsMode::Wait => {
run_inbox_wait(
state,
cancel,
InboxWaitParams {
timeout_secs,
thread,
since_hours,
cursor,
as_agent,
},
)
.await
}
AgentsMode::Cleanup => {
let handle = resolve_comms_client(state, as_agent).await?;
let mut client = handle.lock().await;
let report = client
.cleanup_agents(
apply.unwrap_or(false),
hours_or_default(message_ttl_hours, crate::comms::store::MESSAGE_TTL),
hours_or_default(thread_idle_hours, crate::comms::store::THREAD_IDLE_TTL),
hours_or_default(thread_retention_hours, crate::comms::store::THREAD_RETENTION_TTL),
hours_or_default(agent_ttl_hours, crate::comms::store::EPHEMERAL_AGENT_TTL),
hours_or_default(claim_ttl_hours, crate::comms::identity::CLAIM_TTL),
)
.await
.map_err(comms_err)?;
json_result(&report)
}
AgentsMode::Status => {
let handle = resolve_comms_client(state, as_agent).await?;
let mut client = handle.lock().await;
let report = client
.agents_status(hours_or_default(
agent_ttl_hours,
crate::comms::store::EPHEMERAL_AGENT_TTL,
))
.await
.map_err(comms_err)?;
json_result(&report)
}
}
}
fn allowed_fields(mode: AgentsMode) -> &'static [&'static str] {
match mode {
AgentsMode::Register => &["name", "description", "version", "skills"],
AgentsMode::List => &["thread"],
AgentsMode::ThreadStart => &["subject", "path", "members"],
AgentsMode::ThreadList => &["subject_contains", "include_archived"],
AgentsMode::Join | AgentsMode::Leave | AgentsMode::Members | AgentsMode::Archive => &["thread"],
AgentsMode::AddMember | AgentsMode::RemoveMember => &["thread", "member"],
AgentsMode::Post => &["thread", "subject", "body", "tags", "reply_to", "idempotency_key"],
AgentsMode::History => &["thread", "cursor", "limit", "since_hours"],
AgentsMode::Message => &["message_id"],
AgentsMode::Inbox => &["cursor", "limit", "mark_read", "since_hours"],
AgentsMode::Ack => &["message_ids", "thread", "to_seq"],
AgentsMode::Wait => &["thread", "timeout_secs", "since_hours", "cursor"],
AgentsMode::Cleanup => &[
"apply",
"message_ttl_hours",
"thread_idle_hours",
"thread_retention_hours",
"agent_ttl_hours",
"claim_ttl_hours",
],
AgentsMode::Status => &["agent_ttl_hours"],
}
}
fn hours_or_default(hours: Option<u32>, default: std::time::Duration) -> u64 {
hours.map_or(default.as_secs(), |value| u64::from(value).saturating_mul(60 * 60))
}
fn validate_cleanup_apply(mode: AgentsMode, apply: Option<bool>) -> Result<(), McpError> {
if mode == AgentsMode::Cleanup && apply == Some(true) {
return Err(comms_err(
"`agents` mode=\"cleanup\" is preview-only over MCP; use the CLI with `--apply`",
));
}
Ok(())
}
#[path = "helpers_comms_ops.rs"]
mod ops;
use ops::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_name_the_mode_and_field_when_a_required_sibling_is_missing() {
let error = require_field(AgentsMode::Post, "subject", None::<String>).expect_err("a bodyless post fails");
assert_eq!(error.message.to_string(), "`agents` mode=\"post\" requires `subject`");
}
#[test]
fn should_reject_a_post_whose_body_was_supplied_but_empty() {
for empty in ["", " ", "\n\n", " \t \n "] {
let error = reject_empty_body(Some(empty)).expect_err("an empty body is a caller mistake");
assert_eq!(
error.message.to_string(),
"`agents` mode=\"post\" was given an empty `body`; omit `body` entirely for a subject-only post",
"input {empty:?} must be refused with the corrective message"
);
}
}
#[test]
fn should_accept_an_omitted_body_and_any_body_with_content() {
reject_empty_body(None).expect("a subject-only post stays legal");
reject_empty_body(Some("real content")).expect("a normal body posts");
reject_empty_body(Some("\n# heading\n")).expect("padded content is still content");
}
#[test]
fn should_reject_a_field_that_belongs_to_another_mode() {
let error = reject_foreign_fields(
AgentsMode::Inbox,
&[("thread", true), ("limit", true)],
allowed_fields(AgentsMode::Inbox),
)
.expect_err("`thread` is an `ack`/`wait` field, not an `inbox` one");
let message = error.message.to_string();
assert_eq!(message, "`agents` mode `inbox` does not accept `thread`");
}
#[test]
fn should_allow_as_agent_on_every_mode_without_repeating_it_per_allow_list() {
for mode in AgentsMode::ALL {
assert!(
!allowed_fields(*mode).contains(&"as_agent"),
"`as_agent` is universal and must not be listed per mode ({mode})"
);
}
}
#[test]
fn cleanup_rejects_apply_over_mcp_before_connecting() {
let error = validate_cleanup_apply(AgentsMode::Cleanup, Some(true)).expect_err("apply must be CLI-only");
assert_eq!(
error.message.to_string(),
"comms: `agents` mode=\"cleanup\" is preview-only over MCP; use the CLI with `--apply`"
);
}
}