use std::sync::Arc;
use clawft_plugin::CancellationToken;
use tracing::{debug, error, info, warn};
use clawft_platform::Platform;
use clawft_types::config::AgentsConfig;
use clawft_types::error::ClawftError;
use clawft_types::event::{InboundMessage, OutboundMessage};
use clawft_types::provider::ContentBlock;
use clawft_types::routing::AuthContext;
use crate::bus::MessageBus;
use crate::pipeline::permissions::PermissionResolver;
use crate::pipeline::traits::{ChatRequest, LlmMessage, PipelineRegistry};
use crate::session::SessionManager;
use crate::tools::registry::ToolRegistry;
use super::context::ContextBuilder;
use super::verification;
pub trait AutoDelegation: Send + Sync {
fn should_delegate(&self, content: &str) -> Option<serde_json::Value>;
}
const MAX_TOOL_RESULT_BYTES: usize = 65_536;
const VOICE_MODE_PROMPT: &str = "\
# Voice Mode
You are responding via voice. Your answer will be spoken aloud by a text-to-speech engine.
Rules:
- Respond in natural, conversational language as if speaking to someone in person.
- Keep responses to 1-3 sentences for simple questions. Use more for complex topics, but stay concise.
- Use contractions: say \"it's\", \"you'll\", \"that's\", \"I'd\" instead of formal equivalents.
- Never use markdown formatting: no bold, italic, headers, bullet lists, numbered lists, or code fences.
- Never read URLs, file paths, or raw code aloud. Instead, describe what it does or say \"I can show that on screen\".
- For numbers, use natural speech: \"about seventy-two degrees\" not \"72F\", \"around three hundred\" not \"300\".
- Use natural transitions instead of lists: \"there are a couple of things\" or \"first... and then...\" rather than enumerating with dashes or numbers.
- If you need to share code, a table, or structured data, give a brief spoken summary and note that the details are on screen.
- Do not start with \"Sure!\" or \"Of course!\". Just answer naturally.
- Do not narrate your actions (\"Let me search for...\"). Just provide the answer.
- Sound warm and natural, not robotic or formal.";
#[derive(Debug)]
struct ToolLoopResult {
text: String,
hallucinations: usize,
verified_successes: usize,
}
pub struct AgentLoop<P: Platform> {
config: AgentsConfig,
platform: Arc<P>,
bus: Arc<MessageBus>,
pipeline: PipelineRegistry,
tools: Arc<ToolRegistry>,
context: ContextBuilder<P>,
sessions: Arc<SessionManager<P>>,
permission_resolver: PermissionResolver,
cancel: Option<CancellationToken>,
auto_delegation: Option<Arc<dyn AutoDelegation>>,
}
impl<P: Platform> AgentLoop<P> {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: AgentsConfig,
platform: Arc<P>,
bus: Arc<MessageBus>,
pipeline: PipelineRegistry,
tools: Arc<ToolRegistry>,
context: ContextBuilder<P>,
sessions: Arc<SessionManager<P>>,
permission_resolver: PermissionResolver,
) -> Self {
Self {
config,
platform,
bus,
pipeline,
tools,
context,
sessions,
permission_resolver,
cancel: None,
auto_delegation: None,
}
}
pub fn with_cancel(mut self, token: CancellationToken) -> Self {
self.cancel = Some(token);
self
}
pub fn with_auto_delegation(mut self, delegation: Arc<dyn AutoDelegation>) -> Self {
self.auto_delegation = Some(delegation);
self
}
pub fn config(&self) -> &AgentsConfig {
&self.config
}
pub fn platform(&self) -> &Arc<P> {
&self.platform
}
pub fn bus(&self) -> &Arc<MessageBus> {
&self.bus
}
pub async fn run(&self) -> clawft_types::Result<()> {
info!("agent loop started, waiting for messages");
loop {
let msg = if let Some(ref token) = self.cancel {
#[cfg(feature = "native")]
{
tokio::select! {
biased;
_ = token.cancelled() => {
info!("agent loop cancelled via token, exiting");
break;
}
msg = self.bus.consume_inbound() => msg,
}
}
#[cfg(not(feature = "native"))]
{
if token.is_cancelled() {
info!("agent loop cancelled via token, exiting");
break;
}
self.bus.consume_inbound().await
}
} else {
self.bus.consume_inbound().await
};
match msg {
Some(msg) => {
debug!(
channel = %msg.channel,
chat_id = %msg.chat_id,
"processing inbound message"
);
if let Err(e) = self.process_message(msg).await {
error!("failed to process message: {}", e);
}
}
None => {
info!("inbound channel closed, agent loop exiting");
break;
}
}
}
Ok(())
}
async fn process_message(&self, msg: InboundMessage) -> clawft_types::Result<()> {
let session_key = msg.session_key();
if let Some(ref auto_del) = self.auto_delegation
&& let Some(delegate_args) = auto_del.should_delegate(&msg.content)
{
info!(
task = %msg.content,
"auto-delegation triggered, invoking delegate_task"
);
return self.run_auto_delegation(&msg, delegate_args).await;
}
let mut session = self.sessions.get_or_create(&session_key).await?;
let context_messages = self.context.build_messages(&session, &[]).await;
session.add_message("user", &msg.content, None);
let mut messages: Vec<LlmMessage> = context_messages;
if let Some(instructions) = msg
.metadata
.get("skill_instructions")
.and_then(|v| v.as_str())
&& !instructions.is_empty()
{
debug!("injecting skill instructions from metadata");
messages.push(LlmMessage {
role: "system".into(),
content: format!("# Active Skill Instructions\n\n{instructions}"),
tool_call_id: None,
tool_calls: None,
});
}
if msg.chat_id == "voice" {
debug!("injecting voice mode system prompt");
messages.push(LlmMessage {
role: "system".into(),
content: VOICE_MODE_PROMPT.into(),
tool_call_id: None,
tool_calls: None,
});
}
messages.push(LlmMessage {
role: "user".into(),
content: msg.content.clone(),
tool_call_id: None,
tool_calls: None,
});
let auth_context = self.resolve_auth_context(&msg);
let tool_schemas = match msg
.metadata
.get("allowed_tools")
.and_then(|v| v.as_array())
{
Some(tools_arr) => {
let allowed: Vec<String> = tools_arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if allowed.is_empty() {
self.tools.schemas()
} else {
debug!(allowed_tools = ?allowed, "filtering tools for skill");
self.tools.schemas_for_tools(&allowed)
}
}
None => self.tools.schemas(),
};
let hallucination_score = session
.metadata
.get(verification::HALLUCINATION_SCORE_KEY)
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32;
let complexity_boost = verification::score_to_boost(hallucination_score);
if complexity_boost > 0.0 {
debug!(
hallucination_score,
complexity_boost,
"applying hallucination complexity boost"
);
}
let request = ChatRequest {
messages,
tools: tool_schemas,
model: Some(self.config.defaults.model.clone()),
max_tokens: Some(self.config.defaults.max_tokens),
temperature: Some(self.config.defaults.temperature),
auth_context: Some(auth_context),
complexity_boost,
};
let tool_result = self.run_tool_loop(request).await?;
if tool_result.hallucinations > 0 || tool_result.verified_successes > 0 {
let new_score = verification::update_hallucination_score(
hallucination_score,
tool_result.hallucinations,
tool_result.verified_successes,
verification::HALLUCINATION_EMA_ALPHA,
);
session.metadata.insert(
verification::HALLUCINATION_SCORE_KEY.to_string(),
serde_json::json!(new_score),
);
debug!(
old_score = hallucination_score,
new_score,
hallucinations = tool_result.hallucinations,
verified = tool_result.verified_successes,
"updated hallucination score"
);
}
session.add_message("assistant", &tool_result.text, None);
self.sessions.save_session(&session).await?;
let outbound = OutboundMessage {
channel: msg.channel.clone(),
chat_id: msg.chat_id.clone(),
content: tool_result.text,
reply_to: None,
media: vec![],
metadata: Default::default(),
};
self.bus.dispatch_outbound(outbound)?;
debug!(session_key = %session_key, "message processed successfully");
Ok(())
}
async fn run_auto_delegation(
&self,
msg: &InboundMessage,
delegate_args: serde_json::Value,
) -> clawft_types::Result<()> {
let session_key = msg.session_key();
let mut session = self.sessions.get_or_create(&session_key).await?;
session.add_message("user", &msg.content, None);
let auth = self.resolve_auth_context(msg);
let permissions = Some(&auth.permissions);
let response_text = match self
.tools
.execute("delegate_task", delegate_args, permissions)
.await
{
Ok(result) => {
if let Some(response) = result.get("response").and_then(|v| v.as_str()) {
response.to_string()
} else {
serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
}
}
Err(e) => {
warn!(error = %e, "auto-delegation failed, falling through to local LLM");
format!("Delegation failed: {e}. The task could not be routed to the delegate.")
}
};
session.add_message("assistant", &response_text, None);
self.sessions.save_session(&session).await?;
let outbound = OutboundMessage {
channel: msg.channel.clone(),
chat_id: msg.chat_id.clone(),
content: response_text,
reply_to: None,
media: vec![],
metadata: Default::default(),
};
self.bus.dispatch_outbound(outbound)?;
debug!(session_key = %session_key, "auto-delegated message processed");
Ok(())
}
fn resolve_auth_context(&self, msg: &InboundMessage) -> AuthContext {
let allow_from_match = msg
.metadata
.get("allow_from_match")
.and_then(|v| v.as_bool())
.unwrap_or(false);
self.permission_resolver
.resolve_auth_context(&msg.sender_id, &msg.channel, allow_from_match)
}
fn workspace_path(&self) -> std::path::PathBuf {
let raw = &self.config.defaults.workspace;
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = self.platform.fs().home_dir()
{
return home.join(rest);
}
std::path::PathBuf::from(raw)
}
async fn run_tool_loop(
&self,
mut request: ChatRequest,
) -> clawft_types::Result<ToolLoopResult> {
let max_iterations = self.config.defaults.max_tool_iterations.max(1) as usize;
let mut total_hallucinations: usize = 0;
let mut total_verified: usize = 0;
let workspace = self.workspace_path();
for iteration in 0..max_iterations {
let response = self.pipeline.complete(&request).await?;
let tool_calls: Vec<(String, String, serde_json::Value)> = response
.content
.iter()
.filter_map(|block| {
if let ContentBlock::ToolUse { id, name, input } = block {
Some((id.clone(), name.clone(), input.clone()))
} else {
None
}
})
.collect();
if tool_calls.is_empty() {
let text = response
.content
.iter()
.filter_map(|block| {
if let ContentBlock::Text { text } = block {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
debug!(iteration, "tool loop complete, returning text response");
return Ok(ToolLoopResult {
text,
hallucinations: total_hallucinations,
verified_successes: total_verified,
});
}
debug!(
iteration,
tool_count = tool_calls.len(),
"executing tool calls"
);
let assistant_tool_calls: Vec<serde_json::Value> = tool_calls
.iter()
.map(|(id, name, input)| {
serde_json::json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(input).unwrap_or_default(),
}
})
})
.collect();
let assistant_text: String = response
.content
.iter()
.filter_map(|block| {
if let ContentBlock::Text { text } = block {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
request.messages.push(LlmMessage {
role: "assistant".into(),
content: assistant_text,
tool_call_id: None,
tool_calls: Some(assistant_tool_calls),
});
let permissions = request
.auth_context
.as_ref()
.map(|ctx| &ctx.permissions);
let futures: Vec<_> = tool_calls
.iter()
.map(|(id, name, input)| {
let tools = &self.tools;
async move {
let result = tools.execute(name, input.clone(), permissions).await;
let result_json = match result {
Ok(val) => {
let truncated =
crate::security::truncate_result(val, MAX_TOOL_RESULT_BYTES);
serde_json::to_string(&truncated).unwrap_or_default()
}
Err(e) => {
error!(tool = %name, error = %e, "tool execution failed");
serde_json::json!({"error": e.to_string()}).to_string()
}
};
(id.clone(), name.clone(), result_json)
}
})
.collect();
let results = futures_util::future::join_all(futures).await;
let verification_results = verification::verify_write_results(
self.platform.fs(),
&workspace,
&results,
)
.await;
let hallucinated_ids: std::collections::HashSet<String> = verification_results
.iter()
.filter(|v| !v.verified)
.map(|v| v.tool_call_id.clone())
.collect();
for vr in &verification_results {
if vr.verified {
total_verified += 1;
} else {
total_hallucinations += 1;
warn!(
tool_call_id = %vr.tool_call_id,
path = %vr.claimed_path.display(),
"VERIFICATION FAILED: file does not exist (hallucinated write)"
);
}
}
for (id, _name, result_json) in &results {
let content = if hallucinated_ids.contains(id) {
serde_json::json!({
"error": "VERIFICATION FAILED: the file you claimed to write does not exist on disk. The write was hallucinated. Please retry the write operation."
}).to_string()
} else {
result_json.clone()
};
request.messages.push(LlmMessage {
role: "tool".into(),
content,
tool_call_id: Some(id.clone()),
tool_calls: None,
});
}
}
Err(ClawftError::Provider {
message: format!("max tool iterations ({}) exceeded", max_iterations),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::memory::MemoryStore;
use crate::agent::skills::SkillsLoader;
use crate::pipeline::traits::{
AssembledContext, LearningBackend, LearningSignal, LlmTransport, ModelRouter, Pipeline,
QualityScore, QualityScorer, ResponseOutcome, RoutingDecision, TaskClassifier, TaskProfile,
TaskType, Trajectory, TransportRequest,
};
use crate::tools::registry::Tool;
use async_trait::async_trait;
use clawft_platform::NativePlatform;
use clawft_types::config::AgentDefaults;
use clawft_types::provider::{LlmResponse, StopReason, Usage};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(prefix: &str) -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("clawft_loop_test_{prefix}_{pid}_{id}"))
}
fn test_config() -> AgentsConfig {
AgentsConfig {
defaults: AgentDefaults {
workspace: "~/.clawft/workspace".into(),
model: "test-model".into(),
max_tokens: 4096,
temperature: 0.5,
max_tool_iterations: 10,
memory_window: 50,
},
}
}
struct MockClassifier;
impl TaskClassifier for MockClassifier {
fn classify(&self, _request: &ChatRequest) -> TaskProfile {
TaskProfile {
task_type: TaskType::Chat,
complexity: 0.3,
keywords: vec![],
}
}
}
struct MockRouter;
#[async_trait]
impl ModelRouter for MockRouter {
async fn route(&self, _request: &ChatRequest, _profile: &TaskProfile) -> RoutingDecision {
RoutingDecision {
provider: "test".into(),
model: "test-model".into(),
reason: "mock".into(),
..Default::default()
}
}
fn update(&self, _d: &RoutingDecision, _o: &ResponseOutcome) {}
}
struct MockAssembler;
#[async_trait]
impl crate::pipeline::traits::ContextAssembler for MockAssembler {
async fn assemble(
&self,
request: &ChatRequest,
_profile: &TaskProfile,
) -> AssembledContext {
AssembledContext {
messages: request.messages.clone(),
token_estimate: 100,
truncated: false,
}
}
}
struct MockTransport {
response_text: String,
}
impl MockTransport {
fn new(text: &str) -> Self {
Self {
response_text: text.into(),
}
}
}
#[async_trait]
impl LlmTransport for MockTransport {
async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
Ok(LlmResponse {
id: "mock-resp".into(),
content: vec![ContentBlock::Text {
text: self.response_text.clone(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
struct MockToolTransport {
call_count: std::sync::atomic::AtomicUsize,
}
impl MockToolTransport {
fn new() -> Self {
Self {
call_count: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait]
impl LlmTransport for MockToolTransport {
async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count == 0 {
Ok(LlmResponse {
id: "mock-tool-resp".into(),
content: vec![ContentBlock::ToolUse {
id: "call-1".into(),
name: "echo".into(),
input: serde_json::json!({"text": "hello"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
total_tokens: 0,
},
metadata: HashMap::new(),
})
} else {
Ok(LlmResponse {
id: "mock-final-resp".into(),
content: vec![ContentBlock::Text {
text: "tool result processed".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 20,
output_tokens: 8,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
}
struct InfiniteToolTransport;
#[async_trait]
impl LlmTransport for InfiniteToolTransport {
async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
Ok(LlmResponse {
id: "infinite".into(),
content: vec![ContentBlock::ToolUse {
id: "call-inf".into(),
name: "echo".into(),
input: serde_json::json!({"text": "loop"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 5,
output_tokens: 3,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
struct MockScorer;
impl QualityScorer for MockScorer {
fn score(&self, _req: &ChatRequest, _resp: &LlmResponse) -> QualityScore {
QualityScore {
overall: 1.0,
relevance: 1.0,
coherence: 1.0,
}
}
}
struct MockLearner;
impl LearningBackend for MockLearner {
fn record(&self, _t: &Trajectory) {}
fn adapt(&self, _s: &LearningSignal) {}
}
fn make_pipeline(transport: Arc<dyn LlmTransport>) -> PipelineRegistry {
let pipeline = Pipeline {
classifier: Arc::new(MockClassifier),
router: Arc::new(MockRouter),
assembler: Arc::new(MockAssembler),
transport,
scorer: Arc::new(MockScorer),
learner: Arc::new(MockLearner),
};
PipelineRegistry::new(pipeline)
}
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echo back the input text"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"]
})
}
async fn execute(
&self,
args: serde_json::Value,
) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
let text = args
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("(no text)");
Ok(serde_json::json!({"output": text}))
}
}
async fn make_agent_loop(
transport: Arc<dyn LlmTransport>,
prefix: &str,
) -> (AgentLoop<NativePlatform>, PathBuf) {
let dir = temp_dir(prefix);
let platform = Arc::new(NativePlatform::new());
let bus = Arc::new(MessageBus::new());
let sessions_dir = dir.join("sessions");
let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
let memory = Arc::new(MemoryStore::with_paths(
dir.join("memory").join("MEMORY.md"),
dir.join("memory").join("HISTORY.md"),
platform.clone(),
));
let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
let mut tools = ToolRegistry::new();
tools.register(Arc::new(EchoTool));
let pipeline = make_pipeline(transport);
let agent = AgentLoop::new(
test_config(),
platform,
bus,
pipeline,
Arc::new(tools),
context,
Arc::new(sessions),
PermissionResolver::default_resolver(),
);
(agent, dir)
}
#[test]
fn new_creates_agent_loop_with_all_deps() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let transport = Arc::new(MockTransport::new("hello"));
let (agent, dir) = make_agent_loop(transport, "new_all").await;
assert_eq!(agent.config().defaults.model, "test-model");
assert_eq!(agent.config().defaults.max_tokens, 4096);
assert_eq!(agent.config().defaults.max_tool_iterations, 10);
let _ = tokio::fs::remove_dir_all(&dir).await;
});
}
#[test]
fn config_accessor_returns_config() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let transport = Arc::new(MockTransport::new("hello"));
let (agent, dir) = make_agent_loop(transport, "config_acc").await;
assert_eq!(agent.config().defaults.memory_window, 50);
assert_eq!(agent.config().defaults.workspace, "~/.clawft/workspace");
let _ = tokio::fs::remove_dir_all(&dir).await;
});
}
#[test]
fn platform_accessor_returns_platform() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let transport = Arc::new(MockTransport::new("hello"));
let (agent, dir) = make_agent_loop(transport, "platform_acc").await;
let _p = agent.platform();
let _ = tokio::fs::remove_dir_all(&dir).await;
});
}
#[tokio::test]
async fn process_message_produces_outbound() {
let transport = Arc::new(MockTransport::new("Hello from LLM!"));
let (agent, dir) = make_agent_loop(transport, "process_msg").await;
let inbound = InboundMessage {
channel: "test".into(),
sender_id: "user1".into(),
chat_id: "chat1".into(),
content: "hi there".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.channel, "test");
assert_eq!(outbound.chat_id, "chat1");
assert_eq!(outbound.content, "Hello from LLM!");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn process_message_executes_tool_loop() {
let transport = Arc::new(MockToolTransport::new());
let (agent, dir) = make_agent_loop(transport, "tool_loop").await;
let inbound = InboundMessage {
channel: "test".into(),
sender_id: "user1".into(),
chat_id: "chat1".into(),
content: "use echo tool".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.content, "tool result processed");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn run_tool_loop_respects_max_iterations() {
let transport = Arc::new(InfiniteToolTransport);
let (agent, dir) = make_agent_loop(transport, "max_iter").await;
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "loop forever".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: Some("test-model".into()),
max_tokens: Some(4096),
temperature: Some(0.5),
auth_context: None,
complexity_boost: 0.0,
};
let result = agent.run_tool_loop(request).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("max tool iterations"),
"error should mention max iterations: {}",
err_msg
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn run_exits_when_bus_closes() {
let transport = Arc::new(MockTransport::new("hello"));
let (agent, dir) = make_agent_loop(transport, "bus_close").await;
assert!(
agent
.bus()
.inbound_sender()
.send(InboundMessage {
channel: "test".into(),
sender_id: "u".into(),
chat_id: "c".into(),
content: "msg".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
})
.await
.is_ok()
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn process_message_saves_session() {
let transport = Arc::new(MockTransport::new("saved response"));
let (agent, dir) = make_agent_loop(transport, "session_save").await;
let inbound = InboundMessage {
channel: "telegram".into(),
sender_id: "user1".into(),
chat_id: "chat42".into(),
content: "remember this".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let session = agent
.sessions
.get_or_create("telegram:chat42")
.await
.unwrap();
assert!(session.messages.len() >= 2);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[test]
fn agent_loop_is_send() {
fn assert_send<T: Send>() {}
assert_send::<AgentLoop<NativePlatform>>();
}
struct OversizedToolTransport {
call_count: std::sync::atomic::AtomicUsize,
}
impl OversizedToolTransport {
fn new() -> Self {
Self {
call_count: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait]
impl LlmTransport for OversizedToolTransport {
async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count == 0 {
Ok(LlmResponse {
id: "oversized-resp".into(),
content: vec![ContentBlock::ToolUse {
id: "call-big".into(),
name: "big_output".into(),
input: serde_json::json!({}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
total_tokens: 0,
},
metadata: HashMap::new(),
})
} else {
let last_msg = request.messages.last();
let content_text = last_msg
.map(|m| m.content.as_str())
.unwrap_or("no tool result");
Ok(LlmResponse {
id: "final-resp".into(),
content: vec![ContentBlock::Text {
text: format!("tool_result_len:{}", content_text.len()),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 20,
output_tokens: 8,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
}
struct BigOutputTool;
#[async_trait]
impl Tool for BigOutputTool {
fn name(&self) -> &str {
"big_output"
}
fn description(&self) -> &str {
"Returns a very large output"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {}
})
}
async fn execute(
&self,
_args: serde_json::Value,
) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
let big_string = "x".repeat(200_000);
Ok(serde_json::json!({"data": big_string}))
}
}
#[tokio::test]
async fn tool_result_truncation_enforced() {
let dir = temp_dir("truncation");
let platform = Arc::new(NativePlatform::new());
let bus = Arc::new(MessageBus::new());
let sessions_dir = dir.join("sessions");
let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
let memory = Arc::new(MemoryStore::with_paths(
dir.join("memory").join("MEMORY.md"),
dir.join("memory").join("HISTORY.md"),
platform.clone(),
));
let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
let mut tools = ToolRegistry::new();
tools.register(Arc::new(BigOutputTool));
let pipeline = make_pipeline(Arc::new(OversizedToolTransport::new()));
let agent = AgentLoop::new(
test_config(),
platform,
bus,
pipeline,
Arc::new(tools),
context,
Arc::new(sessions),
PermissionResolver::default_resolver(),
);
let request = ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "trigger big tool".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: Some("test-model".into()),
max_tokens: Some(4096),
temperature: Some(0.5),
auth_context: None,
complexity_boost: 0.0,
};
let tool_result = agent.run_tool_loop(request).await.unwrap();
let result = &tool_result.text;
assert!(
result.starts_with("tool_result_len:"),
"response should contain truncated tool result length: {result}"
);
let len_str = result.strip_prefix("tool_result_len:").unwrap();
let result_len: usize = len_str.parse().unwrap();
assert!(
result_len <= MAX_TOOL_RESULT_BYTES,
"tool result ({result_len} bytes) should be truncated to <= {} bytes",
MAX_TOOL_RESULT_BYTES
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
struct E2eRecordingTransport {
call_count: std::sync::atomic::AtomicUsize,
recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
}
impl E2eRecordingTransport {
fn new() -> Self {
Self {
call_count: std::sync::atomic::AtomicUsize::new(0),
recorded_requests: std::sync::Mutex::new(Vec::new()),
}
}
fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
self.recorded_requests.lock().unwrap().clone()
}
}
#[async_trait]
impl LlmTransport for E2eRecordingTransport {
async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
self.recorded_requests
.lock()
.unwrap()
.push(request.messages.clone());
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count == 0 {
Ok(LlmResponse {
id: "e2e-resp-1".into(),
content: vec![ContentBlock::ToolUse {
id: "call-e2e-1".into(),
name: "echo".into(),
input: serde_json::json!({"text": "e2e-ping"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 15,
output_tokens: 10,
total_tokens: 0,
},
metadata: HashMap::new(),
})
} else {
Ok(LlmResponse {
id: "e2e-resp-2".into(),
content: vec![ContentBlock::Text {
text: "I received the tool output successfully".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 25,
output_tokens: 12,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
}
struct MultiToolE2eTransport {
call_count: std::sync::atomic::AtomicUsize,
recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
}
impl MultiToolE2eTransport {
fn new() -> Self {
Self {
call_count: std::sync::atomic::AtomicUsize::new(0),
recorded_requests: std::sync::Mutex::new(Vec::new()),
}
}
fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
self.recorded_requests.lock().unwrap().clone()
}
}
#[async_trait]
impl LlmTransport for MultiToolE2eTransport {
async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
self.recorded_requests
.lock()
.unwrap()
.push(request.messages.clone());
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count == 0 {
Ok(LlmResponse {
id: "multi-tool-resp-1".into(),
content: vec![
ContentBlock::ToolUse {
id: "call-mt-1".into(),
name: "echo".into(),
input: serde_json::json!({"text": "first"}),
},
ContentBlock::ToolUse {
id: "call-mt-2".into(),
name: "echo".into(),
input: serde_json::json!({"text": "second"}),
},
],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 20,
output_tokens: 15,
total_tokens: 0,
},
metadata: HashMap::new(),
})
} else {
Ok(LlmResponse {
id: "multi-tool-resp-2".into(),
content: vec![ContentBlock::Text {
text: "processed both tools".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 30,
output_tokens: 10,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
}
#[tokio::test]
async fn e2e_tool_roundtrip_message_chain() {
let transport = Arc::new(E2eRecordingTransport::new());
let transport_ref = transport.clone();
let (agent, dir) = make_agent_loop(transport, "e2e_chain").await;
let inbound = InboundMessage {
channel: "cli".into(),
sender_id: "e2e-user".into(),
chat_id: "e2e-chat".into(),
content: "please use the echo tool".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.content, "I received the tool output successfully");
assert_eq!(outbound.channel, "cli");
assert_eq!(outbound.chat_id, "e2e-chat");
let snapshots = transport_ref.snapshots();
assert_eq!(
snapshots.len(),
2,
"transport should be called twice (tool_use -> text)"
);
let first_call = &snapshots[0];
assert!(
first_call.iter().any(|m| m.role == "user"),
"first call should contain user message"
);
let second_call = &snapshots[1];
let tool_result_msg = second_call
.iter()
.find(|m| m.role == "tool")
.expect("second call should contain a tool result message");
assert_eq!(
tool_result_msg.tool_call_id.as_deref(),
Some("call-e2e-1"),
"tool result should reference the tool call ID"
);
assert!(
tool_result_msg.content.contains("e2e-ping"),
"tool result should contain the echoed text: {}",
tool_result_msg.content
);
let session = agent
.sessions
.get_or_create("cli:e2e-chat")
.await
.unwrap();
assert!(
session.messages.len() >= 2,
"session should have at least user + assistant messages, got {}",
session.messages.len()
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn e2e_multi_tool_calls_all_results_returned() {
let transport = Arc::new(MultiToolE2eTransport::new());
let transport_ref = transport.clone();
let (agent, dir) = make_agent_loop(transport, "e2e_multi").await;
let inbound = InboundMessage {
channel: "cli".into(),
sender_id: "user".into(),
chat_id: "chat".into(),
content: "use echo twice".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.content, "processed both tools");
let snapshots = transport_ref.snapshots();
assert_eq!(snapshots.len(), 2);
let second_call = &snapshots[1];
let tool_results: Vec<&LlmMessage> =
second_call.iter().filter(|m| m.role == "tool").collect();
assert_eq!(
tool_results.len(),
2,
"second call should have 2 tool result messages"
);
let ids: Vec<&str> = tool_results
.iter()
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
assert!(
ids.contains(&"call-mt-1"),
"should have result for call-mt-1"
);
assert!(
ids.contains(&"call-mt-2"),
"should have result for call-mt-2"
);
let first_result = tool_results
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("call-mt-1"))
.unwrap();
assert!(
first_result.content.contains("first"),
"first tool result should contain 'first': {}",
first_result.content
);
let second_result = tool_results
.iter()
.find(|m| m.tool_call_id.as_deref() == Some("call-mt-2"))
.unwrap();
assert!(
second_result.content.contains("second"),
"second tool result should contain 'second': {}",
second_result.content
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn e2e_direct_text_response_no_tools() {
let transport = Arc::new(MockTransport::new("Direct answer from LLM"));
let (agent, dir) = make_agent_loop(transport, "e2e_no_tools").await;
let inbound = InboundMessage {
channel: "direct".into(),
sender_id: "user".into(),
chat_id: "chat".into(),
content: "what is 2+2?".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.content, "Direct answer from LLM");
assert_eq!(outbound.channel, "direct");
let session = agent.sessions.get_or_create("direct:chat").await.unwrap();
let roles: Vec<String> = session
.messages
.iter()
.filter_map(|m| m.get("role").and_then(|v| v.as_str()).map(String::from))
.collect();
assert!(
roles.iter().any(|r| r == "user"),
"session should have user message"
);
assert!(
roles.iter().any(|r| r == "assistant"),
"session should have assistant message"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
struct FailingToolTransport {
call_count: std::sync::atomic::AtomicUsize,
recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
}
impl FailingToolTransport {
fn new() -> Self {
Self {
call_count: std::sync::atomic::AtomicUsize::new(0),
recorded_requests: std::sync::Mutex::new(Vec::new()),
}
}
fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
self.recorded_requests.lock().unwrap().clone()
}
}
#[async_trait]
impl LlmTransport for FailingToolTransport {
async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
self.recorded_requests
.lock()
.unwrap()
.push(request.messages.clone());
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count == 0 {
Ok(LlmResponse {
id: "fail-resp-1".into(),
content: vec![ContentBlock::ToolUse {
id: "call-fail-1".into(),
name: "nonexistent_tool".into(),
input: serde_json::json!({}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
total_tokens: 0,
},
metadata: HashMap::new(),
})
} else {
Ok(LlmResponse {
id: "fail-resp-2".into(),
content: vec![ContentBlock::Text {
text: "I see the tool failed, let me help differently".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 20,
output_tokens: 12,
total_tokens: 0,
},
metadata: HashMap::new(),
})
}
}
}
#[tokio::test]
async fn e2e_tool_execution_failure_handled_gracefully() {
let transport = Arc::new(FailingToolTransport::new());
let transport_ref = transport.clone();
let (agent, dir) = make_agent_loop(transport, "e2e_fail").await;
let inbound = InboundMessage {
channel: "fail".into(),
sender_id: "user".into(),
chat_id: "chat".into(),
content: "try a tool".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(
outbound.content,
"I see the tool failed, let me help differently"
);
let snapshots = transport_ref.snapshots();
assert_eq!(snapshots.len(), 2);
let second_call = &snapshots[1];
let tool_result = second_call
.iter()
.find(|m| m.role == "tool")
.expect("second call should have a tool result with the error");
assert!(
tool_result.content.contains("error"),
"tool result should contain error message: {}",
tool_result.content
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
fn make_inbound(channel: &str, sender_id: &str) -> InboundMessage {
InboundMessage {
channel: channel.into(),
sender_id: sender_id.into(),
chat_id: "test-chat".into(),
content: "test message".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
}
}
fn resolve_default(msg: &InboundMessage) -> AuthContext {
let resolver = PermissionResolver::default_resolver();
resolver.resolve_auth_context(&msg.sender_id, &msg.channel, false)
}
#[test]
fn test_resolve_auth_context_cli() {
let msg = make_inbound("cli", "local");
let ctx = resolve_default(&msg);
assert_eq!(ctx.sender_id, "local");
assert_eq!(ctx.channel, "cli");
assert_eq!(ctx.permissions.level, 2, "CLI should get admin (level 2)");
assert!(
ctx.permissions.tool_access.contains(&"*".to_string()),
"CLI admin should have wildcard tool access"
);
assert_eq!(
ctx.permissions.rate_limit, 0,
"CLI admin should have no rate limit"
);
}
#[test]
fn test_resolve_auth_context_empty_sender() {
let msg = make_inbound("telegram", "");
let ctx = resolve_default(&msg);
assert_eq!(ctx.sender_id, "");
assert_eq!(ctx.channel, "telegram");
assert_eq!(
ctx.permissions.level, 0,
"empty sender_id should get zero-trust (level 0)"
);
}
#[test]
fn test_resolve_auth_context_gateway_channel() {
let msg = make_inbound("gateway", "api_key_user");
let ctx = resolve_default(&msg);
assert_eq!(ctx.sender_id, "api_key_user");
assert_eq!(ctx.channel, "gateway");
assert_eq!(
ctx.permissions.level, 0,
"gateway users should get zero-trust (level 0) by default"
);
assert!(
ctx.permissions.tool_access.is_empty(),
"zero-trust should have no tool access"
);
}
#[test]
fn test_resolve_auth_context_telegram_preserves_identity() {
let msg = make_inbound("telegram", "12345");
let ctx = resolve_default(&msg);
assert_eq!(ctx.sender_id, "12345");
assert_eq!(ctx.channel, "telegram");
assert_eq!(
ctx.permissions.level, 0,
"non-CLI channel gets zero-trust with default resolver"
);
}
#[test]
fn test_resolve_auth_context_discord() {
let msg = make_inbound("discord", "snowflake_987654321");
let ctx = resolve_default(&msg);
assert_eq!(ctx.sender_id, "snowflake_987654321");
assert_eq!(ctx.channel, "discord");
assert_eq!(ctx.permissions.level, 0);
}
#[test]
fn test_resolve_auth_context_slack() {
let msg = make_inbound("slack", "U12345");
let ctx = resolve_default(&msg);
assert_eq!(ctx.sender_id, "U12345");
assert_eq!(ctx.channel, "slack");
assert_eq!(ctx.permissions.level, 0);
}
#[tokio::test]
async fn test_auth_context_attached_to_chat_request() {
let transport = Arc::new(MockTransport::new("auth-verified"));
let (agent, dir) = make_agent_loop(transport, "auth_attach").await;
let inbound = make_inbound("cli", "local");
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.content, "auth-verified");
assert_eq!(outbound.channel, "cli");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[test]
fn error_json_escapes_double_quotes() {
let error_msg = r#"file "foo" not found"#;
let json_str = serde_json::json!({"error": error_msg}).to_string();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
}
#[test]
fn error_json_escapes_backslashes() {
let error_msg = r"path C:\Users\test";
let json_str = serde_json::json!({"error": error_msg}).to_string();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
}
#[test]
fn error_json_escapes_newlines() {
let error_msg = "line 1\nline 2";
let json_str = serde_json::json!({"error": error_msg}).to_string();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
}
#[test]
fn error_json_escapes_all_special_chars() {
let error_msg = "quote: \" backslash: \\ newline: \n tab: \t null: \0";
let json_str = serde_json::json!({"error": error_msg}).to_string();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
}
#[test]
fn error_json_has_single_error_key() {
let error_msg = "something went wrong";
let json_str = serde_json::json!({"error": error_msg}).to_string();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let obj = parsed.as_object().unwrap();
assert_eq!(obj.len(), 1, "should have exactly one key");
assert!(obj.contains_key("error"));
}
#[tokio::test]
async fn test_auth_context_non_cli_attaches_zero_trust() {
let transport = Arc::new(MockTransport::new("zero-trust-ok"));
let (agent, dir) = make_agent_loop(transport, "auth_zt").await;
let inbound = make_inbound("telegram", "user42");
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.content, "zero-trust-ok");
assert_eq!(outbound.channel, "telegram");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
struct MockAutoDelegation;
impl AutoDelegation for MockAutoDelegation {
fn should_delegate(&self, content: &str) -> Option<serde_json::Value> {
let lower = content.to_lowercase();
if lower.contains("swarm") || lower.contains("deploy") {
Some(serde_json::json!({"task": content}))
} else {
None
}
}
}
struct MockDelegateTaskTool;
#[async_trait]
impl Tool for MockDelegateTaskTool {
fn name(&self) -> &str {
"delegate_task"
}
fn description(&self) -> &str {
"Mock delegate_task for testing"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"task": { "type": "string" }
},
"required": ["task"]
})
}
async fn execute(
&self,
args: serde_json::Value,
) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
let task = args
.get("task")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");
Ok(serde_json::json!({
"status": "delegated",
"target": "claude",
"response": format!("Delegated: {task}"),
"task": task,
}))
}
}
async fn make_auto_delegation_agent(
transport: Arc<dyn LlmTransport>,
prefix: &str,
) -> (AgentLoop<NativePlatform>, PathBuf) {
let dir = temp_dir(prefix);
let platform = Arc::new(NativePlatform::new());
let bus = Arc::new(MessageBus::new());
let sessions_dir = dir.join("sessions");
let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
let memory = Arc::new(MemoryStore::with_paths(
dir.join("memory").join("MEMORY.md"),
dir.join("memory").join("HISTORY.md"),
platform.clone(),
));
let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
let mut tools = ToolRegistry::new();
tools.register(Arc::new(EchoTool));
tools.register(Arc::new(MockDelegateTaskTool));
let pipeline = make_pipeline(transport);
let agent = AgentLoop::new(
test_config(),
platform,
bus,
pipeline,
Arc::new(tools),
context,
Arc::new(sessions),
PermissionResolver::default_resolver(),
)
.with_auto_delegation(Arc::new(MockAutoDelegation));
(agent, dir)
}
#[tokio::test]
async fn auto_delegation_routes_matching_message() {
let transport = Arc::new(MockTransport::new("should NOT see this"));
let (agent, dir) = make_auto_delegation_agent(transport, "auto_del_match").await;
let inbound = InboundMessage {
channel: "cli".into(),
sender_id: "local".into(),
chat_id: "test".into(),
content: "run a swarm security review".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert!(
outbound.content.contains("Delegated:"),
"response should be from delegate_task, got: {}",
outbound.content
);
assert!(
outbound.content.contains("swarm"),
"delegated response should include the original task"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn auto_delegation_skips_non_matching_message() {
let transport = Arc::new(MockTransport::new("LLM response"));
let (agent, dir) = make_auto_delegation_agent(transport, "auto_del_skip").await;
let inbound = InboundMessage {
channel: "cli".into(),
sender_id: "local".into(),
chat_id: "test".into(),
content: "hello world".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(
outbound.content, "LLM response",
"non-matching message should go through normal LLM pipeline"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn no_auto_delegation_uses_llm() {
let transport = Arc::new(MockTransport::new("normal LLM"));
let (agent, dir) = make_agent_loop(transport, "no_auto_del").await;
let inbound = InboundMessage {
channel: "cli".into(),
sender_id: "local".into(),
chat_id: "test".into(),
content: "run a swarm task".into(),
timestamp: chrono::Utc::now(),
media: vec![],
metadata: HashMap::new(),
};
agent.bus.publish_inbound(inbound).unwrap();
let msg = agent.bus.consume_inbound().await.unwrap();
agent.process_message(msg).await.unwrap();
let outbound = agent.bus.consume_outbound().await.unwrap();
assert_eq!(outbound.content, "normal LLM");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
}