use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::{Duration as ChronoDuration, Utc};
use nexo_broker::{AnyBroker, BrokerHandle};
use nexo_config::types::agents::{AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig};
use nexo_core::agent::tool_policy::{CacheConfig, ParallelConfig, ToolPolicy, ToolPolicyConfig};
use nexo_core::agent::{
Agent, AgentBehavior, AgentContext, AgentMessage, AgentPayload, AgentRouter, AgentRuntime,
DelegationTool, InboundMessage, LlmAgentBehavior, ToolHandler, ToolRegistry,
};
use nexo_core::session::SessionManager;
use nexo_llm::{
ChatRequest, ChatResponse, FinishReason, LlmClient, ResponseContent, TokenUsage, ToolCall,
ToolDef,
};
use nexo_memory::LongTermMemory;
use serde_json::Value;
use uuid::Uuid;
struct StubLlm(String);
#[async_trait]
impl LlmClient for StubLlm {
async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
Ok(ChatResponse {
content: ResponseContent::Text(self.0.clone()),
usage: TokenUsage::default(),
finish_reason: FinishReason::Stop,
cache_usage: None,
})
}
fn model_id(&self) -> &str {
"stub"
}
}
use std::sync::Mutex;
struct ToolThenTextLlm {
calls: Mutex<u32>,
}
#[async_trait]
impl LlmClient for ToolThenTextLlm {
async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
let mut c = self.calls.lock().unwrap();
*c += 1;
if *c == 1 {
Ok(ChatResponse {
content: ResponseContent::ToolCalls(vec![ToolCall {
id: "tc1".into(),
name: "ping".into(),
arguments: serde_json::json!({}),
}]),
usage: TokenUsage::default(),
finish_reason: FinishReason::ToolUse,
cache_usage: None,
})
} else {
Ok(ChatResponse {
content: ResponseContent::Text("pong".into()),
usage: TokenUsage::default(),
finish_reason: FinishReason::Stop,
cache_usage: None,
})
}
}
fn model_id(&self) -> &str {
"tool-stub"
}
}
struct ReminderThenTextLlm;
#[async_trait]
impl LlmClient for ReminderThenTextLlm {
async fn chat(&self, req: ChatRequest) -> anyhow::Result<ChatResponse> {
let has_tool_result = req
.messages
.iter()
.any(|m| m.role == nexo_llm::ChatRole::Tool);
if !has_tool_result {
Ok(ChatResponse {
content: ResponseContent::ToolCalls(vec![ToolCall {
id: "rem1".into(),
name: "schedule_reminder".into(),
arguments: serde_json::json!({
"at": "10m",
"message": "drink water"
}),
}]),
usage: TokenUsage::default(),
finish_reason: FinishReason::ToolUse,
cache_usage: None,
})
} else {
Ok(ChatResponse {
content: ResponseContent::Text("ok, te lo recordaré".into()),
usage: TokenUsage::default(),
finish_reason: FinishReason::Stop,
cache_usage: None,
})
}
}
fn model_id(&self) -> &str {
"reminder-stub"
}
}
struct DelegateThenTextLlm;
#[async_trait]
impl LlmClient for DelegateThenTextLlm {
async fn chat(&self, req: ChatRequest) -> anyhow::Result<ChatResponse> {
let has_tool_result = req
.messages
.iter()
.any(|m| m.role == nexo_llm::ChatRole::Tool);
if !has_tool_result {
Ok(ChatResponse {
content: ResponseContent::ToolCalls(vec![ToolCall {
id: "d1".into(),
name: "delegate".into(),
arguments: serde_json::json!({
"agent_id": "agent-b",
"task": "analyze this"
}),
}]),
usage: TokenUsage::default(),
finish_reason: FinishReason::ToolUse,
cache_usage: None,
})
} else {
Ok(ChatResponse {
content: ResponseContent::Text("delegated and done".into()),
usage: TokenUsage::default(),
finish_reason: FinishReason::Stop,
cache_usage: None,
})
}
}
fn model_id(&self) -> &str {
"delegate-stub"
}
}
fn make_context(broker: AnyBroker) -> AgentContext {
let cfg = Arc::new(AgentConfig {
id: "test-agent".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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
AgentContext::new("test-agent", cfg, broker, sessions)
}
fn make_msg(session_id: Uuid, plugin: &str) -> InboundMessage {
let mut m = InboundMessage::new(session_id, "test-agent", "hello");
m.source_plugin = plugin.to_string();
m.sender_id = Some("user-1".into());
m
}
fn make_msg_no_sender(session_id: Uuid, plugin: &str) -> InboundMessage {
let mut m = InboundMessage::new(session_id, "test-agent", "hello");
m.source_plugin = plugin.to_string();
m.sender_id = None;
m
}
struct CapturingLlm {
captured: Arc<Mutex<Vec<ChatRequest>>>,
reply: String,
}
#[async_trait]
impl LlmClient for CapturingLlm {
async fn chat(&self, req: ChatRequest) -> anyhow::Result<ChatResponse> {
self.captured.lock().unwrap().push(req);
Ok(ChatResponse {
content: ResponseContent::Text(self.reply.clone()),
usage: TokenUsage::default(),
finish_reason: FinishReason::Stop,
cache_usage: None,
})
}
fn model_id(&self) -> &str {
"capturing-stub"
}
}
#[tokio::test]
async fn system_prompt_prepended_to_llm_request() {
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let cfg = Arc::new(AgentConfig {
id: "test-agent".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: "You are Kate, a caring assistant.".into(),
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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new("test-agent", cfg, broker, sessions);
let captured: Arc<Mutex<Vec<ChatRequest>>> = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(llm) as Arc<dyn LlmClient>,
Arc::new(ToolRegistry::new()),
);
behavior
.on_message(&ctx, make_msg_no_sender(Uuid::new_v4(), "whatsapp"))
.await
.unwrap();
let reqs = captured.lock().unwrap();
assert_eq!(reqs.len(), 1);
let first = &reqs[0].messages[0];
assert_eq!(first.role, nexo_llm::ChatRole::System);
assert_eq!(first.content, "You are Kate, a caring assistant.");
}
#[tokio::test]
async fn output_language_directive_renders_when_configured() {
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let cfg = Arc::new(AgentConfig {
id: "test-agent".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: "You are Kate.".into(),
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: Some("es".into()),
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new("test-agent", cfg, broker, sessions);
let captured: Arc<Mutex<Vec<ChatRequest>>> = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(llm) as Arc<dyn LlmClient>,
Arc::new(ToolRegistry::new()),
);
behavior
.on_message(&ctx, make_msg_no_sender(Uuid::new_v4(), "whatsapp"))
.await
.unwrap();
let reqs = captured.lock().unwrap();
let system = reqs[0]
.messages
.iter()
.find(|m| m.role == nexo_llm::ChatRole::System)
.expect("at least one System message");
assert!(
system.content.contains("You are Kate."),
"persona block present"
);
assert!(
system.content.contains("# OUTPUT LANGUAGE"),
"language block header present, got:\n{}",
system.content
);
assert!(
system.content.contains("Respond to the user in es"),
"directive mentions the configured language"
);
}
#[tokio::test]
async fn empty_system_prompt_emits_no_system_message() {
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let ctx = make_context(broker); let captured: Arc<Mutex<Vec<ChatRequest>>> = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(llm) as Arc<dyn LlmClient>,
Arc::new(ToolRegistry::new()),
);
behavior
.on_message(&ctx, make_msg_no_sender(Uuid::new_v4(), "whatsapp"))
.await
.unwrap();
let reqs = captured.lock().unwrap();
assert!(
reqs[0]
.messages
.iter()
.all(|m| m.role != nexo_llm::ChatRole::System),
"no System message expected when system_prompt is empty"
);
}
#[tokio::test]
async fn workspace_bundle_prepended_to_system_message() -> anyhow::Result<()> {
let tmp = std::env::temp_dir().join(format!("llm-behavior-ws-{}", Uuid::new_v4()));
tokio::fs::create_dir_all(&tmp).await?;
tokio::fs::write(
tmp.join("IDENTITY.md"),
"- **Name:** Kate\n- **Emoji:** 🐙\n",
)
.await?;
tokio::fs::write(tmp.join("SOUL.md"), "have opinions, skip filler").await?;
tokio::fs::write(
tmp.join("MEMORY.md"),
"Cristian prefers Spanish conversations.",
)
.await?;
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let cfg = Arc::new(AgentConfig {
id: "test-agent".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: "Always reply in the user's language.".into(),
workspace: tmp.to_string_lossy().into_owned(),
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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new("test-agent", cfg, broker, sessions);
let captured: Arc<Mutex<Vec<ChatRequest>>> = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(llm) as Arc<dyn LlmClient>,
Arc::new(ToolRegistry::new()),
);
behavior
.on_message(&ctx, make_msg(Uuid::new_v4(), "whatsapp"))
.await
.unwrap();
{
let reqs = captured.lock().unwrap();
let system = &reqs[0].messages[0];
assert_eq!(system.role, nexo_llm::ChatRole::System);
let body = &system.content;
assert!(
body.contains("# IDENTITY"),
"IDENTITY block missing: {body}"
);
assert!(body.contains("name=Kate"));
assert!(body.contains("# SOUL"));
assert!(body.contains("have opinions"));
assert!(body.contains("# MEMORY"));
assert!(body.contains("Cristian prefers Spanish"));
let soul_idx = body.find("# SOUL").unwrap();
let sp_idx = body.find("Always reply in the user's language").unwrap();
assert!(
sp_idx > soul_idx,
"system_prompt must come after workspace blocks"
);
}
tokio::fs::remove_dir_all(&tmp).await.ok();
Ok(())
}
#[tokio::test]
async fn skills_loaded_between_workspace_and_system_prompt() -> anyhow::Result<()> {
let workspace = std::env::temp_dir().join(format!("llm-behavior-skills-ws-{}", Uuid::new_v4()));
tokio::fs::create_dir_all(&workspace).await?;
tokio::fs::write(workspace.join("SOUL.md"), "be concise").await?;
let skills_root =
std::env::temp_dir().join(format!("llm-behavior-skills-root-{}", Uuid::new_v4()));
tokio::fs::create_dir_all(skills_root.join("weather")).await?;
tokio::fs::write(
skills_root.join("weather").join("SKILL.md"),
"Use this skill for weather requests only.",
)
.await?;
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let cfg = Arc::new(AgentConfig {
id: "test-agent".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: "Always answer in Spanish.".into(),
workspace: workspace.to_string_lossy().into_owned(),
skills: vec!["weather".into()],
skills_dir: skills_root.to_string_lossy().into_owned(),
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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new("test-agent", cfg, broker, sessions);
let captured: Arc<Mutex<Vec<ChatRequest>>> = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(llm) as Arc<dyn LlmClient>,
Arc::new(ToolRegistry::new()),
);
behavior
.on_message(&ctx, make_msg(Uuid::new_v4(), "whatsapp"))
.await
.unwrap();
{
let reqs = captured.lock().unwrap();
let body = &reqs[0].messages[0].content;
assert!(body.contains("# SOUL"));
assert!(body.contains("# SKILLS"));
assert!(body.contains("## weather"));
assert!(body.contains("Use this skill for weather requests only."));
let soul_idx = body.find("# SOUL").unwrap();
let skills_idx = body.find("# SKILLS").unwrap();
let prompt_idx = body.find("Always answer in Spanish.").unwrap();
assert!(
skills_idx > soul_idx,
"skills block must come after workspace"
);
assert!(
prompt_idx > skills_idx,
"system_prompt must come after skills"
);
}
tokio::fs::remove_dir_all(&workspace).await.ok();
tokio::fs::remove_dir_all(&skills_root).await.ok();
Ok(())
}
#[tokio::test]
async fn workspace_memory_skipped_when_source_is_peer_agent() -> anyhow::Result<()> {
let tmp = std::env::temp_dir().join(format!("llm-behavior-peer-{}", Uuid::new_v4()));
tokio::fs::create_dir_all(&tmp).await?;
tokio::fs::write(tmp.join("SOUL.md"), "be helpful").await?;
tokio::fs::write(tmp.join("MEMORY.md"), "PRIVATE: user's bank info").await?;
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let cfg = Arc::new(AgentConfig {
id: "test-agent".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: String::new(),
workspace: tmp.to_string_lossy().into_owned(),
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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new("test-agent", cfg, broker, sessions);
let captured: Arc<Mutex<Vec<ChatRequest>>> = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(llm) as Arc<dyn LlmClient>,
Arc::new(ToolRegistry::new()),
);
let mut msg = InboundMessage::new(Uuid::new_v4(), "test-agent", "hola");
msg.source_plugin = "agent".into();
msg.sender_id = Some("peer-agent".into());
behavior.on_message(&ctx, msg).await.unwrap();
{
let reqs = captured.lock().unwrap();
let body = &reqs[0].messages[0].content;
assert!(body.contains("# SOUL"), "SOUL.md should still be present");
assert!(
!body.contains("PRIVATE: user's bank info"),
"MEMORY.md must not leak via peer-agent delegation"
);
}
tokio::fs::remove_dir_all(&tmp).await.ok();
Ok(())
}
#[tokio::test]
async fn transcript_written_when_dir_configured() -> anyhow::Result<()> {
use nexo_core::agent::{TranscriptLine, TranscriptRole, TranscriptWriter};
let transcripts =
std::env::temp_dir().join(format!("llm-behavior-transcripts-{}", Uuid::new_v4()));
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let cfg = Arc::new(AgentConfig {
id: "test-agent".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: transcripts.to_string_lossy().into_owned(),
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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new("test-agent", cfg, broker, sessions);
let behavior = LlmAgentBehavior::new(
Arc::new(StubLlm("hola, cómo estás?".into())) as Arc<dyn LlmClient>,
Arc::new(ToolRegistry::new()),
);
let session_id = Uuid::new_v4();
let msg = make_msg(session_id, "whatsapp");
behavior.on_message(&ctx, msg).await.unwrap();
let writer = TranscriptWriter::new(&transcripts, "test-agent");
let lines = writer.read_session(session_id).await?;
assert_eq!(lines.len(), 3, "header + user + assistant expected");
let roles: Vec<TranscriptRole> = lines
.iter()
.filter_map(|l| match l {
TranscriptLine::Entry(e) => Some(e.role),
_ => None,
})
.collect();
assert_eq!(roles, vec![TranscriptRole::User, TranscriptRole::Assistant]);
tokio::fs::remove_dir_all(&transcripts).await.ok();
Ok(())
}
#[tokio::test]
async fn text_reply_published_to_outbound_topic() {
let broker = AnyBroker::local();
let mut sub = broker.subscribe("plugin.outbound.whatsapp").await.unwrap();
let ctx = make_context(broker);
let tools = Arc::new(ToolRegistry::new());
let behavior = LlmAgentBehavior::new(
Arc::new(StubLlm("hello back".into())) as Arc<dyn LlmClient>,
tools,
);
let msg = make_msg(Uuid::new_v4(), "whatsapp");
behavior.on_message(&ctx, msg).await.unwrap();
let event = tokio::time::timeout(Duration::from_millis(200), sub.next())
.await
.expect("timed out")
.expect("no event");
assert_eq!(event.topic, "plugin.outbound.whatsapp");
assert_eq!(event.payload["text"], "hello back");
assert_eq!(event.payload["to"], "user-1");
}
#[tokio::test]
async fn tool_call_then_text_reply() {
let broker = AnyBroker::local();
let mut sub = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let ctx = make_context(broker);
let tools = Arc::new(ToolRegistry::new());
struct PingHandler;
#[async_trait]
impl ToolHandler for PingHandler {
async fn call(&self, _ctx: &AgentContext, _args: Value) -> anyhow::Result<Value> {
Ok(serde_json::json!("pong"))
}
}
tools.register(
ToolDef {
name: "ping".into(),
description: "ping".into(),
parameters: serde_json::json!({}),
},
PingHandler,
);
let behavior = LlmAgentBehavior::new(
Arc::new(ToolThenTextLlm {
calls: Mutex::new(0),
}) as Arc<dyn LlmClient>,
tools,
);
let mut msg = InboundMessage::new(Uuid::new_v4(), "test-agent", "ping please");
msg.source_plugin = "telegram".into();
msg.sender_id = Some("u2".into());
behavior.on_message(&ctx, msg).await.unwrap();
let event = tokio::time::timeout(Duration::from_millis(200), sub.next())
.await
.expect("timed out")
.expect("no event");
assert_eq!(event.payload["text"], "pong");
}
#[tokio::test]
async fn session_history_persists_across_messages() {
let broker = AnyBroker::local();
let mut _sub = broker.subscribe("plugin.outbound.test").await.unwrap();
let ctx = make_context(broker);
let tools = Arc::new(ToolRegistry::new());
let behavior =
LlmAgentBehavior::new(Arc::new(StubLlm("ok".into())) as Arc<dyn LlmClient>, tools);
let session_id = Uuid::new_v4();
for _ in 0..3 {
let msg = make_msg(session_id, "test");
behavior.on_message(&ctx, msg).await.unwrap();
}
let session = ctx.sessions.get(session_id).unwrap();
assert_eq!(session.history.len(), 6);
}
#[tokio::test]
async fn heartbeat_delivers_due_reminders_once() {
let broker = AnyBroker::local();
let mut sub = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let cfg = Arc::new(AgentConfig {
id: "test-agent".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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let memory = Arc::new(LongTermMemory::open(":memory:").await.unwrap());
let ctx =
AgentContext::new("test-agent", cfg, broker, sessions).with_memory(Arc::clone(&memory));
let session_id = Uuid::new_v4();
let reminder_id = memory
.schedule_reminder(
"test-agent",
session_id,
"telegram",
"u-heartbeat",
"drink water",
Utc::now() - ChronoDuration::seconds(1),
)
.await
.unwrap();
let tools = Arc::new(ToolRegistry::new());
let behavior = LlmAgentBehavior::new(
Arc::new(StubLlm("unused".into())) as Arc<dyn LlmClient>,
tools,
);
behavior.on_heartbeat(&ctx).await.unwrap();
let event = tokio::time::timeout(Duration::from_millis(200), sub.next())
.await
.expect("timed out")
.expect("no event");
assert_eq!(event.payload["text"], "drink water");
assert_eq!(event.payload["to"], "u-heartbeat");
let due = memory
.list_due_reminders("test-agent", Utc::now(), 10)
.await
.unwrap();
assert!(due.is_empty());
assert!(!memory.mark_reminder_delivered(reminder_id).await.unwrap());
behavior.on_heartbeat(&ctx).await.unwrap();
let second = tokio::time::timeout(Duration::from_millis(50), sub.next()).await;
assert!(second.is_err(), "reminder was delivered twice");
}
#[tokio::test]
async fn schedule_reminder_tool_uses_current_conversation_context() {
let broker = AnyBroker::local();
let cfg = Arc::new(AgentConfig {
id: "test-agent".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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let memory = Arc::new(LongTermMemory::open(":memory:").await.unwrap());
let ctx =
AgentContext::new("test-agent", cfg, broker, sessions).with_memory(Arc::clone(&memory));
let tools = Arc::new(ToolRegistry::new());
tools.register(
nexo_core::agent::HeartbeatTool::tool_def(),
nexo_core::agent::HeartbeatTool::new(Arc::clone(&memory)),
);
let behavior =
LlmAgentBehavior::new(Arc::new(ReminderThenTextLlm) as Arc<dyn LlmClient>, tools);
let mut msg = InboundMessage::new(
Uuid::new_v4(),
"test-agent",
"recuerdame beber agua en 10 minutos",
);
msg.source_plugin = "telegram".into();
msg.sender_id = Some("u-reminder".into());
behavior.on_message(&ctx, msg.clone()).await.unwrap();
let due = memory
.list_due_reminders("test-agent", Utc::now() + ChronoDuration::minutes(11), 10)
.await
.unwrap();
assert_eq!(due.len(), 1);
assert_eq!(due[0].plugin, "telegram");
assert_eq!(due[0].recipient, "u-reminder");
assert_eq!(due[0].session_id, msg.session_id);
assert_eq!(due[0].message, "drink water");
}
#[tokio::test]
async fn llm_can_call_delegate_tool_and_receive_result() {
let broker = AnyBroker::local();
let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
struct ResponderBehavior;
#[async_trait]
impl AgentBehavior for ResponderBehavior {
async fn decide(
&self,
_ctx: &AgentContext,
msg: &InboundMessage,
) -> anyhow::Result<String> {
Ok(format!("agent-b handled: {}", msg.text))
}
}
let cfg_b = AgentConfig {
id: "agent-b".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,
locale_prompts: Default::default(),
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 runtime_b = AgentRuntime::new(
Arc::new(Agent::new(cfg_b, ResponderBehavior)),
broker.clone(),
Arc::clone(&sessions),
);
runtime_b.start().await.unwrap();
let cfg_a = Arc::new(AgentConfig {
id: "agent-a".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,
locale_prompts: Default::default(),
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 mut sub = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let router = Arc::new(AgentRouter::new());
let ctx_a = AgentContext::new("agent-a", cfg_a, broker.clone(), Arc::clone(&sessions))
.with_router(Arc::clone(&router));
let mut route_sub = broker.subscribe("agent.route.agent-a").await.unwrap();
let router_resolver = Arc::clone(&router);
tokio::spawn(async move {
if let Some(event) = route_sub.next().await {
if let Ok(msg) = serde_json::from_value::<AgentMessage>(event.payload) {
if let AgentPayload::Result { output, .. } = msg.payload {
let _ = router_resolver.resolve(msg.correlation_id, output);
}
}
}
});
let tools = Arc::new(ToolRegistry::new());
tools.register(DelegationTool::tool_def(), DelegationTool);
let behavior_a =
LlmAgentBehavior::new(Arc::new(DelegateThenTextLlm) as Arc<dyn LlmClient>, tools);
let mut msg = InboundMessage::new(Uuid::new_v4(), "agent-a", "delegate this");
msg.source_plugin = "telegram".into();
msg.sender_id = Some("u-delegate".into());
behavior_a.on_message(&ctx_a, msg).await.unwrap();
let event = tokio::time::timeout(Duration::from_millis(400), sub.next())
.await
.expect("timed out")
.expect("no event");
assert_eq!(event.payload["text"], "delegated and done");
assert_eq!(event.payload["to"], "u-delegate");
runtime_b.stop().await;
}
struct ToolCallTwiceThenText {
calls: Mutex<u32>,
}
#[async_trait]
impl LlmClient for ToolCallTwiceThenText {
async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
let mut c = self.calls.lock().unwrap();
*c += 1;
if *c <= 2 {
Ok(ChatResponse {
content: ResponseContent::ToolCalls(vec![ToolCall {
id: format!("tc{}", *c),
name: "ext_cached_echo".into(),
arguments: serde_json::json!({"q": "same"}),
}]),
usage: TokenUsage::default(),
finish_reason: FinishReason::ToolUse,
cache_usage: None,
})
} else {
Ok(ChatResponse {
content: ResponseContent::Text("done".into()),
usage: TokenUsage::default(),
finish_reason: FinishReason::Stop,
cache_usage: None,
})
}
}
fn model_id(&self) -> &str {
"cache-stub"
}
}
struct TwoToolsInOneCall {
calls: Mutex<u32>,
}
#[async_trait]
impl LlmClient for TwoToolsInOneCall {
async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
let mut c = self.calls.lock().unwrap();
*c += 1;
if *c == 1 {
Ok(ChatResponse {
content: ResponseContent::ToolCalls(vec![
ToolCall {
id: "p1".into(),
name: "ext_slow_a".into(),
arguments: serde_json::json!({}),
},
ToolCall {
id: "p2".into(),
name: "ext_slow_b".into(),
arguments: serde_json::json!({}),
},
]),
usage: TokenUsage::default(),
finish_reason: FinishReason::ToolUse,
cache_usage: None,
})
} else {
Ok(ChatResponse {
content: ResponseContent::Text("both done".into()),
usage: TokenUsage::default(),
finish_reason: FinishReason::Stop,
cache_usage: None,
})
}
}
fn model_id(&self) -> &str {
"parallel-stub"
}
}
#[tokio::test]
async fn cached_tool_call_short_circuits_handler() {
use std::sync::atomic::{AtomicU32, Ordering};
let broker = AnyBroker::local();
let mut sub = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let ctx = make_context(broker);
let tools = Arc::new(ToolRegistry::new());
struct CountingEcho {
calls: Arc<AtomicU32>,
}
#[async_trait]
impl ToolHandler for CountingEcho {
async fn call(&self, _ctx: &AgentContext, _args: Value) -> anyhow::Result<Value> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(serde_json::json!("echoed"))
}
}
let counter = Arc::new(AtomicU32::new(0));
tools.register(
ToolDef {
name: "ext_cached_echo".into(),
description: "Echo".into(),
parameters: serde_json::json!({}),
},
CountingEcho {
calls: Arc::clone(&counter),
},
);
let policy_cfg = ToolPolicyConfig {
cache: CacheConfig {
ttl_secs: 60,
tools: vec!["ext_cached_*".into()],
max_entries: 10,
max_value_bytes: 0,
},
parallel_safe: vec![],
parallel: Default::default(),
relevance: Default::default(),
per_agent: Default::default(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(ToolCallTwiceThenText {
calls: Mutex::new(0),
}) as Arc<dyn LlmClient>,
tools,
)
.with_tool_policy(ToolPolicy::from_config(&policy_cfg));
let mut msg = InboundMessage::new(Uuid::new_v4(), "test-agent", "echo please");
msg.source_plugin = "telegram".into();
msg.sender_id = Some("u-cache".into());
behavior.on_message(&ctx, msg).await.unwrap();
tokio::time::timeout(Duration::from_millis(500), sub.next())
.await
.expect("timed out")
.expect("no event");
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"expected one handler invocation (second was cached)"
);
}
#[tokio::test]
async fn parallel_safe_tools_run_concurrently() {
use std::sync::atomic::{AtomicU32, Ordering};
let broker = AnyBroker::local();
let mut sub = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let ctx = make_context(broker);
let tools = Arc::new(ToolRegistry::new());
struct Sleepy {
sleep_ms: u64,
fired: Arc<AtomicU32>,
}
#[async_trait]
impl ToolHandler for Sleepy {
async fn call(&self, _ctx: &AgentContext, _args: Value) -> anyhow::Result<Value> {
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
self.fired.fetch_add(1, Ordering::SeqCst);
Ok(serde_json::json!("slow-done"))
}
}
let counter = Arc::new(AtomicU32::new(0));
tools.register(
ToolDef {
name: "ext_slow_a".into(),
description: "slow a".into(),
parameters: serde_json::json!({}),
},
Sleepy {
sleep_ms: 120,
fired: Arc::clone(&counter),
},
);
tools.register(
ToolDef {
name: "ext_slow_b".into(),
description: "slow b".into(),
parameters: serde_json::json!({}),
},
Sleepy {
sleep_ms: 120,
fired: Arc::clone(&counter),
},
);
let policy_cfg = ToolPolicyConfig {
cache: CacheConfig::default(),
parallel_safe: vec!["ext_slow_*".into()],
parallel: ParallelConfig {
max_in_flight: 4,
call_timeout_secs: 5,
},
relevance: Default::default(),
per_agent: Default::default(),
};
let behavior = LlmAgentBehavior::new(
Arc::new(TwoToolsInOneCall {
calls: Mutex::new(0),
}) as Arc<dyn LlmClient>,
tools,
)
.with_tool_policy(ToolPolicy::from_config(&policy_cfg));
let mut msg = InboundMessage::new(Uuid::new_v4(), "test-agent", "run both");
msg.source_plugin = "telegram".into();
msg.sender_id = Some("u-par".into());
let start = std::time::Instant::now();
behavior.on_message(&ctx, msg).await.unwrap();
let event = tokio::time::timeout(Duration::from_millis(800), sub.next())
.await
.expect("timed out")
.expect("no event");
let elapsed = start.elapsed();
assert_eq!(event.payload["text"], "both done");
assert_eq!(counter.load(Ordering::SeqCst), 2);
assert!(
elapsed < Duration::from_millis(230),
"parallel execution took {:?} — looks sequential",
elapsed,
);
}
#[tokio::test]
async fn reply_routes_to_instance_specific_outbound_topic() {
let broker = AnyBroker::local();
let mut sub_sales = broker
.subscribe("plugin.outbound.telegram.sales")
.await
.unwrap();
let mut sub_legacy = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let ctx = make_context(broker);
let tools = Arc::new(ToolRegistry::new());
let behavior = LlmAgentBehavior::new(
Arc::new(StubLlm("hola desde ventas".into())) as Arc<dyn LlmClient>,
tools,
);
let mut msg = InboundMessage::new(Uuid::new_v4(), "test-agent", "hi sales");
msg.source_plugin = "telegram".into();
msg.source_instance = Some("sales".into());
msg.sender_id = Some("u1".into());
behavior.on_message(&ctx, msg).await.unwrap();
let event = tokio::time::timeout(Duration::from_millis(300), sub_sales.next())
.await
.expect("timed out waiting for sales reply")
.expect("no event on sales topic");
assert_eq!(event.payload["text"], "hola desde ventas");
let race = tokio::time::timeout(Duration::from_millis(50), sub_legacy.next()).await;
assert!(
race.is_err(),
"reply leaked to legacy outbound topic; multi-bot routing broken",
);
}
#[tokio::test]
async fn reply_falls_back_to_legacy_outbound_when_no_instance() {
let broker = AnyBroker::local();
let mut sub_legacy = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let ctx = make_context(broker);
let tools = Arc::new(ToolRegistry::new());
let behavior = LlmAgentBehavior::new(
Arc::new(StubLlm("legacy reply".into())) as Arc<dyn LlmClient>,
tools,
);
let mut msg = InboundMessage::new(Uuid::new_v4(), "test-agent", "hi");
msg.source_plugin = "telegram".into();
msg.source_instance = None;
msg.sender_id = Some("u1".into());
behavior.on_message(&ctx, msg).await.unwrap();
let event = tokio::time::timeout(Duration::from_millis(300), sub_legacy.next())
.await
.expect("timed out")
.expect("no event");
assert_eq!(event.payload["text"], "legacy reply");
}
#[tokio::test]
async fn delegation_rejects_target_outside_allowed_delegates() {
use nexo_core::agent::AgentRouter;
use nexo_core::agent::DelegationTool;
let broker = AnyBroker::local();
let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let cfg = Arc::new(AgentConfig {
id: "agent-a".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!["soporte_*".into()],
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,
locale_prompts: Default::default(),
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 router = Arc::new(AgentRouter::new());
let ctx = AgentContext::new("agent-a", cfg, broker, sessions).with_router(router);
let tool = DelegationTool;
let err = tool
.call(
&ctx,
serde_json::json!({
"agent_id": "ventas",
"task": "sell something",
}),
)
.await
.expect_err("delegation should have been rejected by allowlist");
let err_msg = format!("{err}");
assert!(
err_msg.contains("not allowed to delegate"),
"got: {err_msg}"
);
assert!(err_msg.contains("ventas"), "got: {err_msg}");
let err2 = tool
.call(
&ctx,
serde_json::json!({
"agent_id": "soporte_nivel1",
"task": "help this user",
"timeout_ms": 50,
}),
)
.await
.expect_err("no peer agent-b running → should still fail");
let err2_msg = format!("{err2}");
assert!(
!err2_msg.contains("not allowed to delegate"),
"allowlist-matching target should reach router, got permission error: {err2_msg}",
);
}
#[tokio::test]
async fn peer_directory_renders_into_system_prompt() {
use nexo_core::agent::{PeerDirectory, PeerSummary};
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let peers = PeerDirectory::new(vec![
PeerSummary {
id: "boss".into(),
description: "takes decisions".into(),
},
PeerSummary {
id: "ventas".into(),
description: "closes deals".into(),
},
PeerSummary {
id: "soporte_lvl1".into(),
description: "first-line".into(),
},
]);
let cfg = Arc::new(AgentConfig {
id: "ventas".into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: "Be helpful.".into(),
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!["soporte_*".into()],
accept_delegates_from: Vec::new(),
description: "sales desk".into(),
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,
locale_prompts: Default::default(),
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 sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new("ventas", cfg, broker, sessions).with_peers(peers);
let captured = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let tools = Arc::new(ToolRegistry::new());
let behavior = LlmAgentBehavior::new(Arc::new(llm) as Arc<dyn LlmClient>, tools);
let mut msg = InboundMessage::new(Uuid::new_v4(), "ventas", "hi");
msg.source_plugin = "telegram".into();
msg.sender_id = Some("u1".into());
behavior.on_message(&ctx, msg).await.unwrap();
let req = captured.lock().unwrap().remove(0);
let sys = req
.messages
.iter()
.find(|m| m.role == nexo_llm::ChatRole::System)
.expect("system message present");
let sys_text = sys.content.as_str();
assert!(
sys_text.contains("# PEERS"),
"peers block missing: {sys_text}"
);
assert!(!sys_text.contains("`ventas`"));
assert!(sys_text.contains("✗ `boss`"));
assert!(sys_text.contains("✓ `soporte_lvl1`"));
assert!(sys_text.contains("first-line"));
assert!(sys_text.contains("Be helpful."));
}
#[tokio::test]
async fn retain_matching_prunes_tools_from_llm_request() {
let broker = AnyBroker::local();
let _sub = broker.subscribe("plugin.outbound.telegram").await.unwrap();
let ctx = make_context(broker);
struct Noop;
#[async_trait]
impl ToolHandler for Noop {
async fn call(&self, _: &AgentContext, _: Value) -> anyhow::Result<Value> {
Ok(Value::Null)
}
}
fn td(name: &str) -> ToolDef {
ToolDef {
name: name.into(),
description: "stub".into(),
parameters: serde_json::json!({"type": "object"}),
}
}
let tools = Arc::new(ToolRegistry::new());
tools.register(td("memory_recall"), Noop);
tools.register(td("ext_github_comment"), Noop);
tools.register(td("ext_weather_forecast"), Noop);
tools.register(td("delegate"), Noop);
assert_eq!(tools.to_tool_defs().len(), 4);
let removed = tools.retain_matching(&["memory_*".into(), "delegate".into()]);
assert_eq!(removed, 2, "github and weather should be pruned");
assert_eq!(tools.to_tool_defs().len(), 2);
let captured = Arc::new(Mutex::new(Vec::new()));
let llm = CapturingLlm {
captured: Arc::clone(&captured),
reply: "ok".into(),
};
let behavior = LlmAgentBehavior::new(Arc::new(llm) as Arc<dyn LlmClient>, tools);
let mut msg = InboundMessage::new(Uuid::new_v4(), "test-agent", "hi");
msg.source_plugin = "telegram".into();
msg.sender_id = Some("u1".into());
behavior.on_message(&ctx, msg).await.unwrap();
let req = captured.lock().unwrap().remove(0);
let names: Vec<&str> = req.tools.iter().map(|t| t.name.as_str()).collect();
assert!(
names.contains(&"memory_recall"),
"allowlisted tool missing: {names:?}"
);
assert!(
names.contains(&"delegate"),
"allowlisted tool missing: {names:?}"
);
assert!(
!names.contains(&"ext_github_comment"),
"pruned tool leaked to LLM: {names:?}"
);
assert!(
!names.contains(&"ext_weather_forecast"),
"pruned tool leaked to LLM: {names:?}"
);
}