use super::context::AgentContext;
use super::inbox::{inbox_subject, InboxMessage, MAX_BODY_BYTES, MIN_BODY_CHARS};
use super::tool_registry::ToolHandler;
use async_trait::async_trait;
use chrono::Utc;
use nexo_broker::types::Event;
use nexo_broker::BrokerHandle;
use nexo_driver_types::GoalId;
use nexo_llm::ToolDef;
use serde_json::{json, Value};
use std::sync::Arc;
pub const SEND_TO_PEER_TOOL_NAME: &str = "send_to_peer";
pub type PeerGoalLookup = Arc<dyn Fn(&str) -> Vec<GoalId> + Send + Sync + 'static>;
pub struct SendToPeerTool {
lookup: PeerGoalLookup,
}
impl SendToPeerTool {
pub fn new(lookup: PeerGoalLookup) -> Self {
Self { lookup }
}
pub fn tool_def() -> ToolDef {
ToolDef {
name: SEND_TO_PEER_TOOL_NAME.to_string(),
description: "Send a fire-and-forget message to another agent's inbox. \
The receiver processes the message at the start of its next turn. \
Use `list_peers` first to discover valid `to:` targets. Plain text \
output of yours is NOT visible to other agents — `send_to_peer` is \
the ONLY way to communicate with them. Self-sends are rejected. Empty \
bodies are rejected. Bodies > 64 KB are rejected."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "Peer agent id (matches an entry in `list_peers`)."
},
"message": {
"type": "string",
"description": "Plain text message body (1-65536 chars)."
},
"correlation_id": {
"type": "string",
"description": "Optional UUID for request/response correlation."
}
},
"required": ["to", "message"],
"additionalProperties": false
}),
}
}
}
#[async_trait]
impl ToolHandler for SendToPeerTool {
async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
let to = args
.get("to")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("send_to_peer: `to` is required"))?
.trim();
if to.is_empty() {
anyhow::bail!("send_to_peer: `to` cannot be empty");
}
if to == ctx.agent_id {
anyhow::bail!("send_to_peer: cannot send to self (`{to}`)");
}
let body = args
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("send_to_peer: `message` is required"))?;
if body.chars().count() < MIN_BODY_CHARS {
anyhow::bail!("send_to_peer: `message` is empty");
}
if body.len() > MAX_BODY_BYTES {
anyhow::bail!(
"send_to_peer: `message` is {} bytes, max {}",
body.len(),
MAX_BODY_BYTES
);
}
let correlation_id = args
.get("correlation_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
if let Some(dir) = &ctx.peers {
let known = dir.peers().iter().any(|p| p.id == to);
if !known {
return Ok(json!({
"delivered_to": [],
"unreachable_reasons": [format!("unknown agent_id `{}`", to)]
}));
}
}
let goals = (self.lookup)(to);
if goals.is_empty() {
return Ok(json!({
"delivered_to": [],
"unreachable_reasons": [format!("no live goals for agent_id `{}`", to)]
}));
}
let from_goal = ctx.session_id.map(GoalId).unwrap_or_else(GoalId::new);
let now = Utc::now();
let mut delivered = Vec::with_capacity(goals.len());
let mut unreachable: Vec<String> = Vec::new();
for goal in goals {
let msg = InboxMessage {
from_agent_id: ctx.agent_id.clone(),
from_goal_id: from_goal,
to_agent_id: to.to_string(),
body: body.to_string(),
sent_at: now,
correlation_id,
};
let payload = match serde_json::to_value(&msg) {
Ok(v) => v,
Err(e) => {
unreachable.push(format!("serialise failed for goal {}: {}", goal.0, e));
continue;
}
};
let event = Event::new(inbox_subject(goal), &ctx.agent_id, payload);
let topic = inbox_subject(goal);
match ctx.broker.publish(&topic, event).await {
Ok(_) => delivered.push(goal.0.to_string()),
Err(e) => {
unreachable.push(format!("broker publish failed for goal {}: {}", goal.0, e))
}
}
}
Ok(json!({
"delivered_to": delivered,
"unreachable_reasons": unreachable,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::peer_directory::{PeerDirectory, PeerSummary};
use crate::session::SessionManager;
use nexo_broker::AnyBroker;
use nexo_config::types::agents::{
AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig,
};
use std::time::Duration;
fn lookup_returns_one_goal(_id: &str) -> Vec<GoalId> {
vec![GoalId(uuid::Uuid::new_v4())]
}
fn lookup_returns_empty(_id: &str) -> Vec<GoalId> {
Vec::new()
}
fn mk_lookup(f: fn(&str) -> Vec<GoalId>) -> PeerGoalLookup {
Arc::new(f)
}
fn mk_peers() -> Arc<PeerDirectory> {
PeerDirectory::new(vec![
PeerSummary {
id: "researcher".into(),
description: "research agent".into(),
},
PeerSummary {
id: "writer".into(),
description: "writer agent".into(),
},
])
}
fn mk_ctx() -> AgentContext {
let cfg = Arc::new(AgentConfig {
id: "kate".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: String::new(),
workspace: String::new(),
skills: vec![],
skills_dir: "./skills".into(),
skill_overrides: Default::default(),
transcripts_dir: String::new(),
dreaming: Default::default(),
workspace_git: Default::default(),
tool_rate_limits: None,
tool_args_validation: None,
extra_docs: Vec::new(),
inbound_bindings: Vec::new(),
allowed_tools: Vec::new(),
sender_rate_limit: None,
allowed_delegates: Vec::new(),
accept_delegates_from: Vec::new(),
description: String::new(),
outbound_allowlist: Default::default(),
google_auth: None,
credentials: Default::default(),
link_understanding: serde_json::Value::Null,
web_search: serde_json::Value::Null,
pairing_policy: serde_json::Value::Null,
language: None,
context_optimization: None,
dispatch_policy: Default::default(),
plan_mode: Default::default(),
remote_triggers: Vec::new(),
lsp: nexo_config::types::lsp::LspPolicy::default(),
config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
team: nexo_config::types::team::TeamPolicy::default(),
proactive: Default::default(),
repl: Default::default(),
auto_dream: None,
assistant_mode: None,
away_summary: None,
brief: None,
channels: None,
auto_approve: false,
extract_memories: None,
event_subscribers: Vec::new(),
tenant_id: None,
extensions_config: std::collections::BTreeMap::new(),
active: true,
});
let broker = AnyBroker::local();
let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let mut ctx = AgentContext::new("kate", cfg, broker, sessions);
ctx.peers = Some(mk_peers());
ctx.session_id = Some(uuid::Uuid::new_v4());
ctx
}
#[test]
fn tool_def_shape() {
let def = SendToPeerTool::tool_def();
assert_eq!(def.name, "send_to_peer");
let req = def.parameters["required"].as_array().unwrap();
assert!(req.iter().any(|v| v == "to"));
assert!(req.iter().any(|v| v == "message"));
}
#[tokio::test]
async fn empty_to_errors() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let err = tool
.call(&ctx, json!({"to": "", "message": "x"}))
.await
.unwrap_err();
assert!(err.to_string().contains("cannot be empty"));
}
#[tokio::test]
async fn missing_to_errors() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let err = tool.call(&ctx, json!({"message": "x"})).await.unwrap_err();
assert!(err.to_string().contains("`to` is required"));
}
#[tokio::test]
async fn missing_message_errors() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let err = tool
.call(&ctx, json!({"to": "researcher"}))
.await
.unwrap_err();
assert!(err.to_string().contains("`message` is required"));
}
#[tokio::test]
async fn empty_message_errors() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let err = tool
.call(&ctx, json!({"to": "researcher", "message": ""}))
.await
.unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[tokio::test]
async fn self_send_rejected() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let err = tool
.call(&ctx, json!({"to": "kate", "message": "hello me"}))
.await
.unwrap_err();
assert!(err.to_string().contains("send to self"));
}
#[tokio::test]
async fn unknown_agent_id_returns_unreachable() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let v = tool
.call(&ctx, json!({"to": "ghost", "message": "anyone there"}))
.await
.unwrap();
assert_eq!(v["delivered_to"].as_array().unwrap().len(), 0);
let reasons = v["unreachable_reasons"].as_array().unwrap();
assert!(reasons[0].as_str().unwrap().contains("unknown agent_id"));
}
#[tokio::test]
async fn no_live_goals_returns_unreachable() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_empty));
let ctx = mk_ctx();
let v = tool
.call(&ctx, json!({"to": "researcher", "message": "ping"}))
.await
.unwrap();
assert_eq!(v["delivered_to"].as_array().unwrap().len(), 0);
let reasons = v["unreachable_reasons"].as_array().unwrap();
assert!(reasons[0].as_str().unwrap().contains("no live goals"));
}
#[tokio::test]
async fn live_peer_publishes_and_returns_delivered() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let v = tool
.call(&ctx, json!({"to": "researcher", "message": "task #1"}))
.await
.unwrap();
assert_eq!(v["delivered_to"].as_array().unwrap().len(), 1);
assert_eq!(v["unreachable_reasons"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn oversize_message_rejected() {
let tool = SendToPeerTool::new(mk_lookup(lookup_returns_one_goal));
let ctx = mk_ctx();
let huge = "x".repeat(MAX_BODY_BYTES + 1);
let err = tool
.call(&ctx, json!({"to": "researcher", "message": huge}))
.await
.unwrap_err();
assert!(err.to_string().contains("max"));
}
#[tokio::test]
async fn correlation_id_round_trips() {
let captured_corr: std::sync::Mutex<Option<uuid::Uuid>> = std::sync::Mutex::new(None);
let captured = Arc::new(captured_corr);
let ctx = mk_ctx();
let goal = GoalId(uuid::Uuid::new_v4());
let lookup: PeerGoalLookup = {
let g = goal;
Arc::new(move |_id: &str| vec![g])
};
let topic = inbox_subject(goal);
let mut sub = ctx.broker.subscribe(&topic).await.unwrap();
let cap = Arc::clone(&captured);
let handle = tokio::spawn(async move {
if let Ok(Some(ev)) =
tokio::time::timeout(std::time::Duration::from_secs(2), sub.next()).await
{
let msg: InboxMessage = serde_json::from_value(ev.payload).unwrap();
*cap.lock().unwrap() = msg.correlation_id;
}
});
let corr = uuid::Uuid::new_v4();
let tool = SendToPeerTool::new(lookup);
let _ = tool
.call(
&ctx,
json!({
"to": "researcher",
"message": "ping",
"correlation_id": corr.to_string(),
}),
)
.await
.unwrap();
let _ = handle.await;
let captured_value = *captured.lock().unwrap();
assert_eq!(captured_value, Some(corr));
}
}