nexo-core 0.1.18

Agent runtime: event bus, sessions, plugin trait, heartbeat, A2A delegation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! Phase 80.11 — `send_to_peer` LLM tool.
//!
//! Fire-and-forget peer-to-peer messaging via NATS subject
//! `agent.inbox.<goal_id>`. Resolves the `to:` argument to a peer
//! agent_id, looks up the peer's running goals (caller-provided
//! lookup fn), and publishes the message to each goal's inbox
//! subject. Returns the list of `delivered_to` goals plus
//! `unreachable_reasons` for goals or peers that couldn't accept.
//!
//! Receive side (subscriber + buffer + next-turn injection) is the
//! deferred 80.11.b follow-up. Today's MVP ships the publisher half.
//!
//! # Provider-agnostic
//!
//! Pure JSON tool surface + NATS subject contract. Works under any
//! LLM provider — the tool's behaviour is below the LLM round-trip.

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";

/// Lookup function: given an agent_id, return the live goal_ids of
/// that peer at call time. Empty `Vec` means the peer has no live
/// goals (the receiver is offline / not yet spawned). Caller wires
/// this to `nexo_agent_registry::AgentRegistry::list` filtered by
/// `agent_id` + Running status — kept as a closure so the tool
/// stays free of an agent-registry dep.
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());

        // Verify peer is in the directory at all (fast-path
        // unreachable if peer name doesn't match anything).
        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)]
                }));
            }
        }

        // Caller-provided lookup → goals_to_deliver.
        let goals = (self.lookup)(to);
        if goals.is_empty() {
            return Ok(json!({
                "delivered_to": [],
                "unreachable_reasons": [format!("no live goals for agent_id `{}`", to)]
            }));
        }

        // Sender's own goal_id — best-effort from session id when
        // present. None when the agent is mid-turn without a session
        // (e.g. heartbeat path); we still record provenance via
        // `from_agent_id`.
        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);

        // Subscribe to the inbox subject so we can assert the wire
        // payload carries the correlation_id we passed.
        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();
        // Give the subscriber a moment.
        let _ = handle.await;
        let captured_value = *captured.lock().unwrap();
        assert_eq!(captured_value, Some(corr));
    }
}