1use std::sync::Arc;
30
31use clawft_plugin::CancellationToken;
32use tracing::{debug, error, info, warn};
33
34use clawft_platform::Platform;
35use clawft_types::config::AgentsConfig;
36use clawft_types::error::ClawftError;
37use clawft_types::event::{InboundMessage, OutboundMessage};
38use clawft_types::provider::ContentBlock;
39use clawft_types::routing::AuthContext;
40
41use crate::bus::MessageBus;
42use crate::pipeline::permissions::PermissionResolver;
43use crate::pipeline::traits::{ChatRequest, LlmMessage, PipelineRegistry};
44use crate::session::SessionManager;
45use crate::tools::registry::ToolRegistry;
46
47use super::context::ContextBuilder;
48use super::verification;
49
50pub trait AutoDelegation: Send + Sync {
65 fn should_delegate(&self, content: &str) -> Option<serde_json::Value>;
70}
71
72const MAX_TOOL_RESULT_BYTES: usize = 65_536;
74
75const VOICE_MODE_PROMPT: &str = "\
81# Voice Mode
82
83You are responding via voice. Your answer will be spoken aloud by a text-to-speech engine.
84
85Rules:
86- Respond in natural, conversational language as if speaking to someone in person.
87- Keep responses to 1-3 sentences for simple questions. Use more for complex topics, but stay concise.
88- Use contractions: say \"it's\", \"you'll\", \"that's\", \"I'd\" instead of formal equivalents.
89- Never use markdown formatting: no bold, italic, headers, bullet lists, numbered lists, or code fences.
90- Never read URLs, file paths, or raw code aloud. Instead, describe what it does or say \"I can show that on screen\".
91- For numbers, use natural speech: \"about seventy-two degrees\" not \"72F\", \"around three hundred\" not \"300\".
92- Use natural transitions instead of lists: \"there are a couple of things\" or \"first... and then...\" rather than enumerating with dashes or numbers.
93- If you need to share code, a table, or structured data, give a brief spoken summary and note that the details are on screen.
94- Do not start with \"Sure!\" or \"Of course!\". Just answer naturally.
95- Do not narrate your actions (\"Let me search for...\"). Just provide the answer.
96- Sound warm and natural, not robotic or formal.";
97
98#[derive(Debug)]
100struct ToolLoopResult {
101 text: String,
103 hallucinations: usize,
105 verified_successes: usize,
107}
108
109pub struct AgentLoop<P: Platform> {
131 config: AgentsConfig,
132 platform: Arc<P>,
133 bus: Arc<MessageBus>,
134 pipeline: PipelineRegistry,
135 tools: Arc<ToolRegistry>,
136 context: ContextBuilder<P>,
137 sessions: Arc<SessionManager<P>>,
138 permission_resolver: PermissionResolver,
139 cancel: Option<CancellationToken>,
140 auto_delegation: Option<Arc<dyn AutoDelegation>>,
146}
147
148impl<P: Platform> AgentLoop<P> {
149 #[allow(clippy::too_many_arguments)]
161 pub fn new(
162 config: AgentsConfig,
163 platform: Arc<P>,
164 bus: Arc<MessageBus>,
165 pipeline: PipelineRegistry,
166 tools: Arc<ToolRegistry>,
167 context: ContextBuilder<P>,
168 sessions: Arc<SessionManager<P>>,
169 permission_resolver: PermissionResolver,
170 ) -> Self {
171 Self {
172 config,
173 platform,
174 bus,
175 pipeline,
176 tools,
177 context,
178 sessions,
179 permission_resolver,
180 cancel: None,
181 auto_delegation: None,
182 }
183 }
184
185 pub fn with_cancel(mut self, token: CancellationToken) -> Self {
188 self.cancel = Some(token);
189 self
190 }
191
192 pub fn with_auto_delegation(mut self, delegation: Arc<dyn AutoDelegation>) -> Self {
197 self.auto_delegation = Some(delegation);
198 self
199 }
200
201 pub fn config(&self) -> &AgentsConfig {
203 &self.config
204 }
205
206 pub fn platform(&self) -> &Arc<P> {
208 &self.platform
209 }
210
211 pub fn bus(&self) -> &Arc<MessageBus> {
213 &self.bus
214 }
215
216 pub async fn run(&self) -> clawft_types::Result<()> {
223 info!("agent loop started, waiting for messages");
224
225 loop {
226 let msg = if let Some(ref token) = self.cancel {
227 #[cfg(feature = "native")]
228 {
229 tokio::select! {
230 biased;
231 _ = token.cancelled() => {
232 info!("agent loop cancelled via token, exiting");
233 break;
234 }
235 msg = self.bus.consume_inbound() => msg,
236 }
237 }
238 #[cfg(not(feature = "native"))]
239 {
240 if token.is_cancelled() {
242 info!("agent loop cancelled via token, exiting");
243 break;
244 }
245 self.bus.consume_inbound().await
246 }
247 } else {
248 self.bus.consume_inbound().await
249 };
250
251 match msg {
252 Some(msg) => {
253 debug!(
254 channel = %msg.channel,
255 chat_id = %msg.chat_id,
256 "processing inbound message"
257 );
258 if let Err(e) = self.process_message(msg).await {
259 error!("failed to process message: {}", e);
260 }
261 }
262 None => {
263 info!("inbound channel closed, agent loop exiting");
264 break;
265 }
266 }
267 }
268
269 Ok(())
270 }
271
272 async fn process_message(&self, msg: InboundMessage) -> clawft_types::Result<()> {
277 let session_key = msg.session_key();
278
279 if let Some(ref auto_del) = self.auto_delegation
284 && let Some(delegate_args) = auto_del.should_delegate(&msg.content)
285 {
286 info!(
287 task = %msg.content,
288 "auto-delegation triggered, invoking delegate_task"
289 );
290 return self.run_auto_delegation(&msg, delegate_args).await;
291 }
292
293 let mut session = self.sessions.get_or_create(&session_key).await?;
295
296 let context_messages = self.context.build_messages(&session, &[]).await;
299
300 session.add_message("user", &msg.content, None);
302
303 let mut messages: Vec<LlmMessage> = context_messages;
305
306 if let Some(instructions) = msg
310 .metadata
311 .get("skill_instructions")
312 .and_then(|v| v.as_str())
313 && !instructions.is_empty()
314 {
315 debug!("injecting skill instructions from metadata");
316 messages.push(LlmMessage {
317 role: "system".into(),
318 content: format!("# Active Skill Instructions\n\n{instructions}"),
319 tool_call_id: None,
320 tool_calls: None,
321 });
322 }
323
324 if msg.chat_id == "voice" {
330 debug!("injecting voice mode system prompt");
331 messages.push(LlmMessage {
332 role: "system".into(),
333 content: VOICE_MODE_PROMPT.into(),
334 tool_call_id: None,
335 tool_calls: None,
336 });
337 }
338
339 messages.push(LlmMessage {
341 role: "user".into(),
342 content: msg.content.clone(),
343 tool_call_id: None,
344 tool_calls: None,
345 });
346
347 let auth_context = self.resolve_auth_context(&msg);
351
352 let tool_schemas = match msg
355 .metadata
356 .get("allowed_tools")
357 .and_then(|v| v.as_array())
358 {
359 Some(tools_arr) => {
360 let allowed: Vec<String> = tools_arr
361 .iter()
362 .filter_map(|v| v.as_str().map(String::from))
363 .collect();
364 if allowed.is_empty() {
365 self.tools.schemas()
366 } else {
367 debug!(allowed_tools = ?allowed, "filtering tools for skill");
368 self.tools.schemas_for_tools(&allowed)
369 }
370 }
371 None => self.tools.schemas(),
372 };
373
374 let hallucination_score = session
376 .metadata
377 .get(verification::HALLUCINATION_SCORE_KEY)
378 .and_then(|v| v.as_f64())
379 .unwrap_or(0.0) as f32;
380 let complexity_boost = verification::score_to_boost(hallucination_score);
381
382 if complexity_boost > 0.0 {
383 debug!(
384 hallucination_score,
385 complexity_boost,
386 "applying hallucination complexity boost"
387 );
388 }
389
390 let request = ChatRequest {
392 messages,
393 tools: tool_schemas,
394 model: Some(self.config.defaults.model.clone()),
395 max_tokens: Some(self.config.defaults.max_tokens),
396 temperature: Some(self.config.defaults.temperature),
397 auth_context: Some(auth_context),
398 complexity_boost,
399 };
400
401 let tool_result = self.run_tool_loop(request).await?;
403
404 if tool_result.hallucinations > 0 || tool_result.verified_successes > 0 {
406 let new_score = verification::update_hallucination_score(
407 hallucination_score,
408 tool_result.hallucinations,
409 tool_result.verified_successes,
410 verification::HALLUCINATION_EMA_ALPHA,
411 );
412 session.metadata.insert(
413 verification::HALLUCINATION_SCORE_KEY.to_string(),
414 serde_json::json!(new_score),
415 );
416 debug!(
417 old_score = hallucination_score,
418 new_score,
419 hallucinations = tool_result.hallucinations,
420 verified = tool_result.verified_successes,
421 "updated hallucination score"
422 );
423 }
424
425 session.add_message("assistant", &tool_result.text, None);
427
428 self.sessions.save_session(&session).await?;
430
431 let outbound = OutboundMessage {
433 channel: msg.channel.clone(),
434 chat_id: msg.chat_id.clone(),
435 content: tool_result.text,
436 reply_to: None,
437 media: vec![],
438 metadata: Default::default(),
439 };
440 self.bus.dispatch_outbound(outbound)?;
441
442 debug!(session_key = %session_key, "message processed successfully");
443
444 Ok(())
445 }
446
447 async fn run_auto_delegation(
454 &self,
455 msg: &InboundMessage,
456 delegate_args: serde_json::Value,
457 ) -> clawft_types::Result<()> {
458 let session_key = msg.session_key();
459
460 let mut session = self.sessions.get_or_create(&session_key).await?;
462 session.add_message("user", &msg.content, None);
463
464 let auth = self.resolve_auth_context(msg);
466 let permissions = Some(&auth.permissions);
467
468 let response_text = match self
470 .tools
471 .execute("delegate_task", delegate_args, permissions)
472 .await
473 {
474 Ok(result) => {
475 if let Some(response) = result.get("response").and_then(|v| v.as_str()) {
477 response.to_string()
478 } else {
479 serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
481 }
482 }
483 Err(e) => {
484 warn!(error = %e, "auto-delegation failed, falling through to local LLM");
485 format!("Delegation failed: {e}. The task could not be routed to the delegate.")
489 }
490 };
491
492 session.add_message("assistant", &response_text, None);
494 self.sessions.save_session(&session).await?;
495
496 let outbound = OutboundMessage {
498 channel: msg.channel.clone(),
499 chat_id: msg.chat_id.clone(),
500 content: response_text,
501 reply_to: None,
502 media: vec![],
503 metadata: Default::default(),
504 };
505 self.bus.dispatch_outbound(outbound)?;
506
507 debug!(session_key = %session_key, "auto-delegated message processed");
508 Ok(())
509 }
510
511 fn resolve_auth_context(&self, msg: &InboundMessage) -> AuthContext {
526 let allow_from_match = msg
530 .metadata
531 .get("allow_from_match")
532 .and_then(|v| v.as_bool())
533 .unwrap_or(false);
534 self.permission_resolver
535 .resolve_auth_context(&msg.sender_id, &msg.channel, allow_from_match)
536 }
537
538 fn workspace_path(&self) -> std::path::PathBuf {
540 let raw = &self.config.defaults.workspace;
541 if let Some(rest) = raw.strip_prefix("~/")
542 && let Some(home) = self.platform.fs().home_dir()
543 {
544 return home.join(rest);
545 }
546 std::path::PathBuf::from(raw)
547 }
548
549 async fn run_tool_loop(
561 &self,
562 mut request: ChatRequest,
563 ) -> clawft_types::Result<ToolLoopResult> {
564 let max_iterations = self.config.defaults.max_tool_iterations.max(1) as usize;
565 let mut total_hallucinations: usize = 0;
566 let mut total_verified: usize = 0;
567 let workspace = self.workspace_path();
568
569 for iteration in 0..max_iterations {
570 let response = self.pipeline.complete(&request).await?;
571
572 let tool_calls: Vec<(String, String, serde_json::Value)> = response
574 .content
575 .iter()
576 .filter_map(|block| {
577 if let ContentBlock::ToolUse { id, name, input } = block {
578 Some((id.clone(), name.clone(), input.clone()))
579 } else {
580 None
581 }
582 })
583 .collect();
584
585 if tool_calls.is_empty() {
586 let text = response
588 .content
589 .iter()
590 .filter_map(|block| {
591 if let ContentBlock::Text { text } = block {
592 Some(text.as_str())
593 } else {
594 None
595 }
596 })
597 .collect::<Vec<_>>()
598 .join("");
599
600 debug!(iteration, "tool loop complete, returning text response");
601 return Ok(ToolLoopResult {
602 text,
603 hallucinations: total_hallucinations,
604 verified_successes: total_verified,
605 });
606 }
607
608 debug!(
609 iteration,
610 tool_count = tool_calls.len(),
611 "executing tool calls"
612 );
613
614 let assistant_tool_calls: Vec<serde_json::Value> = tool_calls
618 .iter()
619 .map(|(id, name, input)| {
620 serde_json::json!({
621 "id": id,
622 "type": "function",
623 "function": {
624 "name": name,
625 "arguments": serde_json::to_string(input).unwrap_or_default(),
626 }
627 })
628 })
629 .collect();
630
631 let assistant_text: String = response
633 .content
634 .iter()
635 .filter_map(|block| {
636 if let ContentBlock::Text { text } = block {
637 Some(text.as_str())
638 } else {
639 None
640 }
641 })
642 .collect::<Vec<_>>()
643 .join("");
644
645 request.messages.push(LlmMessage {
646 role: "assistant".into(),
647 content: assistant_text,
648 tool_call_id: None,
649 tool_calls: Some(assistant_tool_calls),
650 });
651
652 let permissions = request
654 .auth_context
655 .as_ref()
656 .map(|ctx| &ctx.permissions);
657
658 let futures: Vec<_> = tool_calls
659 .iter()
660 .map(|(id, name, input)| {
661 let tools = &self.tools;
662 async move {
663 let result = tools.execute(name, input.clone(), permissions).await;
664 let result_json = match result {
665 Ok(val) => {
666 let truncated =
667 crate::security::truncate_result(val, MAX_TOOL_RESULT_BYTES);
668 serde_json::to_string(&truncated).unwrap_or_default()
669 }
670 Err(e) => {
671 error!(tool = %name, error = %e, "tool execution failed");
672 serde_json::json!({"error": e.to_string()}).to_string()
673 }
674 };
675 (id.clone(), name.clone(), result_json)
676 }
677 })
678 .collect();
679
680 let results = futures_util::future::join_all(futures).await;
681
682 let verification_results = verification::verify_write_results(
684 self.platform.fs(),
685 &workspace,
686 &results,
687 )
688 .await;
689
690 let hallucinated_ids: std::collections::HashSet<String> = verification_results
692 .iter()
693 .filter(|v| !v.verified)
694 .map(|v| v.tool_call_id.clone())
695 .collect();
696
697 for vr in &verification_results {
699 if vr.verified {
700 total_verified += 1;
701 } else {
702 total_hallucinations += 1;
703 warn!(
704 tool_call_id = %vr.tool_call_id,
705 path = %vr.claimed_path.display(),
706 "VERIFICATION FAILED: file does not exist (hallucinated write)"
707 );
708 }
709 }
710
711 for (id, _name, result_json) in &results {
712 let content = if hallucinated_ids.contains(id) {
713 serde_json::json!({
715 "error": "VERIFICATION FAILED: the file you claimed to write does not exist on disk. The write was hallucinated. Please retry the write operation."
716 }).to_string()
717 } else {
718 result_json.clone()
719 };
720
721 request.messages.push(LlmMessage {
722 role: "tool".into(),
723 content,
724 tool_call_id: Some(id.clone()),
725 tool_calls: None,
726 });
727 }
728 }
729
730 Err(ClawftError::Provider {
731 message: format!("max tool iterations ({}) exceeded", max_iterations),
732 })
733 }
734
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use crate::agent::memory::MemoryStore;
741 use crate::agent::skills::SkillsLoader;
742 use crate::pipeline::traits::{
743 AssembledContext, LearningBackend, LearningSignal, LlmTransport, ModelRouter, Pipeline,
744 QualityScore, QualityScorer, ResponseOutcome, RoutingDecision, TaskClassifier, TaskProfile,
745 TaskType, Trajectory, TransportRequest,
746 };
747 use crate::tools::registry::Tool;
748 use async_trait::async_trait;
749 use clawft_platform::NativePlatform;
750 use clawft_types::config::AgentDefaults;
751 use clawft_types::provider::{LlmResponse, StopReason, Usage};
752 use std::collections::HashMap;
753 use std::path::PathBuf;
754 use std::sync::atomic::{AtomicU64, Ordering};
755
756 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
757
758 fn temp_dir(prefix: &str) -> PathBuf {
759 let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
760 let pid = std::process::id();
761 std::env::temp_dir().join(format!("clawft_loop_test_{prefix}_{pid}_{id}"))
762 }
763
764 fn test_config() -> AgentsConfig {
765 AgentsConfig {
766 defaults: AgentDefaults {
767 workspace: "~/.clawft/workspace".into(),
768 model: "test-model".into(),
769 max_tokens: 4096,
770 temperature: 0.5,
771 max_tool_iterations: 10,
772 memory_window: 50,
773 },
774 }
775 }
776
777 struct MockClassifier;
780 impl TaskClassifier for MockClassifier {
781 fn classify(&self, _request: &ChatRequest) -> TaskProfile {
782 TaskProfile {
783 task_type: TaskType::Chat,
784 complexity: 0.3,
785 keywords: vec![],
786 }
787 }
788 }
789
790 struct MockRouter;
791 #[async_trait]
792 impl ModelRouter for MockRouter {
793 async fn route(&self, _request: &ChatRequest, _profile: &TaskProfile) -> RoutingDecision {
794 RoutingDecision {
795 provider: "test".into(),
796 model: "test-model".into(),
797 reason: "mock".into(),
798 ..Default::default()
799 }
800 }
801 fn update(&self, _d: &RoutingDecision, _o: &ResponseOutcome) {}
802 }
803
804 struct MockAssembler;
805 #[async_trait]
806 impl crate::pipeline::traits::ContextAssembler for MockAssembler {
807 async fn assemble(
808 &self,
809 request: &ChatRequest,
810 _profile: &TaskProfile,
811 ) -> AssembledContext {
812 AssembledContext {
813 messages: request.messages.clone(),
814 token_estimate: 100,
815 truncated: false,
816 }
817 }
818 }
819
820 struct MockTransport {
822 response_text: String,
823 }
824
825 impl MockTransport {
826 fn new(text: &str) -> Self {
827 Self {
828 response_text: text.into(),
829 }
830 }
831 }
832
833 #[async_trait]
834 impl LlmTransport for MockTransport {
835 async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
836 Ok(LlmResponse {
837 id: "mock-resp".into(),
838 content: vec![ContentBlock::Text {
839 text: self.response_text.clone(),
840 }],
841 stop_reason: StopReason::EndTurn,
842 usage: Usage {
843 input_tokens: 10,
844 output_tokens: 5,
845 total_tokens: 0,
846 },
847 metadata: HashMap::new(),
848 })
849 }
850 }
851
852 struct MockToolTransport {
854 call_count: std::sync::atomic::AtomicUsize,
855 }
856
857 impl MockToolTransport {
858 fn new() -> Self {
859 Self {
860 call_count: std::sync::atomic::AtomicUsize::new(0),
861 }
862 }
863 }
864
865 #[async_trait]
866 impl LlmTransport for MockToolTransport {
867 async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
868 let count = self
869 .call_count
870 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
871 if count == 0 {
872 Ok(LlmResponse {
874 id: "mock-tool-resp".into(),
875 content: vec![ContentBlock::ToolUse {
876 id: "call-1".into(),
877 name: "echo".into(),
878 input: serde_json::json!({"text": "hello"}),
879 }],
880 stop_reason: StopReason::ToolUse,
881 usage: Usage {
882 input_tokens: 10,
883 output_tokens: 5,
884 total_tokens: 0,
885 },
886 metadata: HashMap::new(),
887 })
888 } else {
889 Ok(LlmResponse {
891 id: "mock-final-resp".into(),
892 content: vec![ContentBlock::Text {
893 text: "tool result processed".into(),
894 }],
895 stop_reason: StopReason::EndTurn,
896 usage: Usage {
897 input_tokens: 20,
898 output_tokens: 8,
899 total_tokens: 0,
900 },
901 metadata: HashMap::new(),
902 })
903 }
904 }
905 }
906
907 struct InfiniteToolTransport;
909
910 #[async_trait]
911 impl LlmTransport for InfiniteToolTransport {
912 async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
913 Ok(LlmResponse {
914 id: "infinite".into(),
915 content: vec![ContentBlock::ToolUse {
916 id: "call-inf".into(),
917 name: "echo".into(),
918 input: serde_json::json!({"text": "loop"}),
919 }],
920 stop_reason: StopReason::ToolUse,
921 usage: Usage {
922 input_tokens: 5,
923 output_tokens: 3,
924 total_tokens: 0,
925 },
926 metadata: HashMap::new(),
927 })
928 }
929 }
930
931 struct MockScorer;
932 impl QualityScorer for MockScorer {
933 fn score(&self, _req: &ChatRequest, _resp: &LlmResponse) -> QualityScore {
934 QualityScore {
935 overall: 1.0,
936 relevance: 1.0,
937 coherence: 1.0,
938 }
939 }
940 }
941
942 struct MockLearner;
943 impl LearningBackend for MockLearner {
944 fn record(&self, _t: &Trajectory) {}
945 fn adapt(&self, _s: &LearningSignal) {}
946 }
947
948 fn make_pipeline(transport: Arc<dyn LlmTransport>) -> PipelineRegistry {
949 let pipeline = Pipeline {
950 classifier: Arc::new(MockClassifier),
951 router: Arc::new(MockRouter),
952 assembler: Arc::new(MockAssembler),
953 transport,
954 scorer: Arc::new(MockScorer),
955 learner: Arc::new(MockLearner),
956 };
957 PipelineRegistry::new(pipeline)
958 }
959
960 struct EchoTool;
963
964 #[async_trait]
965 impl Tool for EchoTool {
966 fn name(&self) -> &str {
967 "echo"
968 }
969 fn description(&self) -> &str {
970 "Echo back the input text"
971 }
972 fn parameters(&self) -> serde_json::Value {
973 serde_json::json!({
974 "type": "object",
975 "properties": {
976 "text": { "type": "string" }
977 },
978 "required": ["text"]
979 })
980 }
981 async fn execute(
982 &self,
983 args: serde_json::Value,
984 ) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
985 let text = args
986 .get("text")
987 .and_then(|v| v.as_str())
988 .unwrap_or("(no text)");
989 Ok(serde_json::json!({"output": text}))
990 }
991 }
992
993 async fn make_agent_loop(
995 transport: Arc<dyn LlmTransport>,
996 prefix: &str,
997 ) -> (AgentLoop<NativePlatform>, PathBuf) {
998 let dir = temp_dir(prefix);
999 let platform = Arc::new(NativePlatform::new());
1000 let bus = Arc::new(MessageBus::new());
1001
1002 let sessions_dir = dir.join("sessions");
1003 let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
1004
1005 let memory = Arc::new(MemoryStore::with_paths(
1006 dir.join("memory").join("MEMORY.md"),
1007 dir.join("memory").join("HISTORY.md"),
1008 platform.clone(),
1009 ));
1010 let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
1011 let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
1012
1013 let mut tools = ToolRegistry::new();
1014 tools.register(Arc::new(EchoTool));
1015
1016 let pipeline = make_pipeline(transport);
1017
1018 let agent = AgentLoop::new(
1019 test_config(),
1020 platform,
1021 bus,
1022 pipeline,
1023 Arc::new(tools),
1024 context,
1025 Arc::new(sessions),
1026 PermissionResolver::default_resolver(),
1027 );
1028 (agent, dir)
1029 }
1030
1031 #[test]
1032 fn new_creates_agent_loop_with_all_deps() {
1033 let rt = tokio::runtime::Runtime::new().unwrap();
1034 rt.block_on(async {
1035 let transport = Arc::new(MockTransport::new("hello"));
1036 let (agent, dir) = make_agent_loop(transport, "new_all").await;
1037
1038 assert_eq!(agent.config().defaults.model, "test-model");
1039 assert_eq!(agent.config().defaults.max_tokens, 4096);
1040 assert_eq!(agent.config().defaults.max_tool_iterations, 10);
1041
1042 let _ = tokio::fs::remove_dir_all(&dir).await;
1043 });
1044 }
1045
1046 #[test]
1047 fn config_accessor_returns_config() {
1048 let rt = tokio::runtime::Runtime::new().unwrap();
1049 rt.block_on(async {
1050 let transport = Arc::new(MockTransport::new("hello"));
1051 let (agent, dir) = make_agent_loop(transport, "config_acc").await;
1052
1053 assert_eq!(agent.config().defaults.memory_window, 50);
1054 assert_eq!(agent.config().defaults.workspace, "~/.clawft/workspace");
1055
1056 let _ = tokio::fs::remove_dir_all(&dir).await;
1057 });
1058 }
1059
1060 #[test]
1061 fn platform_accessor_returns_platform() {
1062 let rt = tokio::runtime::Runtime::new().unwrap();
1063 rt.block_on(async {
1064 let transport = Arc::new(MockTransport::new("hello"));
1065 let (agent, dir) = make_agent_loop(transport, "platform_acc").await;
1066
1067 let _p = agent.platform();
1069
1070 let _ = tokio::fs::remove_dir_all(&dir).await;
1071 });
1072 }
1073
1074 #[tokio::test]
1075 async fn process_message_produces_outbound() {
1076 let transport = Arc::new(MockTransport::new("Hello from LLM!"));
1077 let (agent, dir) = make_agent_loop(transport, "process_msg").await;
1078
1079 let inbound = InboundMessage {
1081 channel: "test".into(),
1082 sender_id: "user1".into(),
1083 chat_id: "chat1".into(),
1084 content: "hi there".into(),
1085 timestamp: chrono::Utc::now(),
1086 media: vec![],
1087 metadata: HashMap::new(),
1088 };
1089 agent.bus.publish_inbound(inbound).unwrap();
1090
1091 let msg = agent.bus.consume_inbound().await.unwrap();
1093 agent.process_message(msg).await.unwrap();
1094
1095 let outbound = agent.bus.consume_outbound().await.unwrap();
1097 assert_eq!(outbound.channel, "test");
1098 assert_eq!(outbound.chat_id, "chat1");
1099 assert_eq!(outbound.content, "Hello from LLM!");
1100
1101 let _ = tokio::fs::remove_dir_all(&dir).await;
1102 }
1103
1104 #[tokio::test]
1105 async fn process_message_executes_tool_loop() {
1106 let transport = Arc::new(MockToolTransport::new());
1107 let (agent, dir) = make_agent_loop(transport, "tool_loop").await;
1108
1109 let inbound = InboundMessage {
1110 channel: "test".into(),
1111 sender_id: "user1".into(),
1112 chat_id: "chat1".into(),
1113 content: "use echo tool".into(),
1114 timestamp: chrono::Utc::now(),
1115 media: vec![],
1116 metadata: HashMap::new(),
1117 };
1118 agent.bus.publish_inbound(inbound).unwrap();
1119
1120 let msg = agent.bus.consume_inbound().await.unwrap();
1121 agent.process_message(msg).await.unwrap();
1122
1123 let outbound = agent.bus.consume_outbound().await.unwrap();
1124 assert_eq!(outbound.content, "tool result processed");
1125
1126 let _ = tokio::fs::remove_dir_all(&dir).await;
1127 }
1128
1129 #[tokio::test]
1130 async fn run_tool_loop_respects_max_iterations() {
1131 let transport = Arc::new(InfiniteToolTransport);
1132 let (agent, dir) = make_agent_loop(transport, "max_iter").await;
1133
1134 let request = ChatRequest {
1135 messages: vec![LlmMessage {
1136 role: "user".into(),
1137 content: "loop forever".into(),
1138 tool_call_id: None,
1139 tool_calls: None,
1140 }],
1141 tools: vec![],
1142 model: Some("test-model".into()),
1143 max_tokens: Some(4096),
1144 temperature: Some(0.5),
1145 auth_context: None,
1146 complexity_boost: 0.0,
1147 };
1148
1149 let result = agent.run_tool_loop(request).await;
1150 assert!(result.is_err());
1151 let err_msg = result.unwrap_err().to_string();
1152 assert!(
1153 err_msg.contains("max tool iterations"),
1154 "error should mention max iterations: {}",
1155 err_msg
1156 );
1157
1158 let _ = tokio::fs::remove_dir_all(&dir).await;
1159 }
1160
1161 #[tokio::test]
1162 async fn run_exits_when_bus_closes() {
1163 let transport = Arc::new(MockTransport::new("hello"));
1164 let (agent, dir) = make_agent_loop(transport, "bus_close").await;
1165
1166 assert!(
1179 agent
1180 .bus()
1181 .inbound_sender()
1182 .send(InboundMessage {
1183 channel: "test".into(),
1184 sender_id: "u".into(),
1185 chat_id: "c".into(),
1186 content: "msg".into(),
1187 timestamp: chrono::Utc::now(),
1188 media: vec![],
1189 metadata: HashMap::new(),
1190 })
1191 .await
1192 .is_ok()
1193 );
1194
1195 let _ = tokio::fs::remove_dir_all(&dir).await;
1196 }
1197
1198 #[tokio::test]
1199 async fn process_message_saves_session() {
1200 let transport = Arc::new(MockTransport::new("saved response"));
1201 let (agent, dir) = make_agent_loop(transport, "session_save").await;
1202
1203 let inbound = InboundMessage {
1204 channel: "telegram".into(),
1205 sender_id: "user1".into(),
1206 chat_id: "chat42".into(),
1207 content: "remember this".into(),
1208 timestamp: chrono::Utc::now(),
1209 media: vec![],
1210 metadata: HashMap::new(),
1211 };
1212 agent.bus.publish_inbound(inbound).unwrap();
1213
1214 let msg = agent.bus.consume_inbound().await.unwrap();
1215 agent.process_message(msg).await.unwrap();
1216
1217 let session = agent
1219 .sessions
1220 .get_or_create("telegram:chat42")
1221 .await
1222 .unwrap();
1223 assert!(session.messages.len() >= 2);
1225
1226 let _ = tokio::fs::remove_dir_all(&dir).await;
1227 }
1228
1229 #[test]
1230 fn agent_loop_is_send() {
1231 fn assert_send<T: Send>() {}
1232 assert_send::<AgentLoop<NativePlatform>>();
1233 }
1234
1235 struct OversizedToolTransport {
1239 call_count: std::sync::atomic::AtomicUsize,
1240 }
1241
1242 impl OversizedToolTransport {
1243 fn new() -> Self {
1244 Self {
1245 call_count: std::sync::atomic::AtomicUsize::new(0),
1246 }
1247 }
1248 }
1249
1250 #[async_trait]
1251 impl LlmTransport for OversizedToolTransport {
1252 async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1253 let count = self
1254 .call_count
1255 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1256 if count == 0 {
1257 Ok(LlmResponse {
1259 id: "oversized-resp".into(),
1260 content: vec![ContentBlock::ToolUse {
1261 id: "call-big".into(),
1262 name: "big_output".into(),
1263 input: serde_json::json!({}),
1264 }],
1265 stop_reason: StopReason::ToolUse,
1266 usage: Usage {
1267 input_tokens: 10,
1268 output_tokens: 5,
1269 total_tokens: 0,
1270 },
1271 metadata: HashMap::new(),
1272 })
1273 } else {
1274 let last_msg = request.messages.last();
1277 let content_text = last_msg
1278 .map(|m| m.content.as_str())
1279 .unwrap_or("no tool result");
1280
1281 Ok(LlmResponse {
1282 id: "final-resp".into(),
1283 content: vec![ContentBlock::Text {
1284 text: format!("tool_result_len:{}", content_text.len()),
1285 }],
1286 stop_reason: StopReason::EndTurn,
1287 usage: Usage {
1288 input_tokens: 20,
1289 output_tokens: 8,
1290 total_tokens: 0,
1291 },
1292 metadata: HashMap::new(),
1293 })
1294 }
1295 }
1296 }
1297
1298 struct BigOutputTool;
1300
1301 #[async_trait]
1302 impl Tool for BigOutputTool {
1303 fn name(&self) -> &str {
1304 "big_output"
1305 }
1306 fn description(&self) -> &str {
1307 "Returns a very large output"
1308 }
1309 fn parameters(&self) -> serde_json::Value {
1310 serde_json::json!({
1311 "type": "object",
1312 "properties": {}
1313 })
1314 }
1315 async fn execute(
1316 &self,
1317 _args: serde_json::Value,
1318 ) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
1319 let big_string = "x".repeat(200_000);
1321 Ok(serde_json::json!({"data": big_string}))
1322 }
1323 }
1324
1325 #[tokio::test]
1326 async fn tool_result_truncation_enforced() {
1327 let dir = temp_dir("truncation");
1328 let platform = Arc::new(NativePlatform::new());
1329 let bus = Arc::new(MessageBus::new());
1330
1331 let sessions_dir = dir.join("sessions");
1332 let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
1333
1334 let memory = Arc::new(MemoryStore::with_paths(
1335 dir.join("memory").join("MEMORY.md"),
1336 dir.join("memory").join("HISTORY.md"),
1337 platform.clone(),
1338 ));
1339 let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
1340 let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
1341
1342 let mut tools = ToolRegistry::new();
1343 tools.register(Arc::new(BigOutputTool));
1344
1345 let pipeline = make_pipeline(Arc::new(OversizedToolTransport::new()));
1346
1347 let agent = AgentLoop::new(
1348 test_config(),
1349 platform,
1350 bus,
1351 pipeline,
1352 Arc::new(tools),
1353 context,
1354 Arc::new(sessions),
1355 PermissionResolver::default_resolver(),
1356 );
1357
1358 let request = ChatRequest {
1359 messages: vec![LlmMessage {
1360 role: "user".into(),
1361 content: "trigger big tool".into(),
1362 tool_call_id: None,
1363 tool_calls: None,
1364 }],
1365 tools: vec![],
1366 model: Some("test-model".into()),
1367 max_tokens: Some(4096),
1368 temperature: Some(0.5),
1369 auth_context: None,
1370 complexity_boost: 0.0,
1371 };
1372
1373 let tool_result = agent.run_tool_loop(request).await.unwrap();
1374 let result = &tool_result.text;
1375
1376 assert!(
1379 result.starts_with("tool_result_len:"),
1380 "response should contain truncated tool result length: {result}"
1381 );
1382 let len_str = result.strip_prefix("tool_result_len:").unwrap();
1383 let result_len: usize = len_str.parse().unwrap();
1384 assert!(
1385 result_len <= MAX_TOOL_RESULT_BYTES,
1386 "tool result ({result_len} bytes) should be truncated to <= {} bytes",
1387 MAX_TOOL_RESULT_BYTES
1388 );
1389
1390 let _ = tokio::fs::remove_dir_all(&dir).await;
1391 }
1392
1393 struct E2eRecordingTransport {
1399 call_count: std::sync::atomic::AtomicUsize,
1400 recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
1402 }
1403
1404 impl E2eRecordingTransport {
1405 fn new() -> Self {
1406 Self {
1407 call_count: std::sync::atomic::AtomicUsize::new(0),
1408 recorded_requests: std::sync::Mutex::new(Vec::new()),
1409 }
1410 }
1411
1412 fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
1413 self.recorded_requests.lock().unwrap().clone()
1414 }
1415 }
1416
1417 #[async_trait]
1418 impl LlmTransport for E2eRecordingTransport {
1419 async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1420 self.recorded_requests
1422 .lock()
1423 .unwrap()
1424 .push(request.messages.clone());
1425
1426 let count = self
1427 .call_count
1428 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1429
1430 if count == 0 {
1431 Ok(LlmResponse {
1433 id: "e2e-resp-1".into(),
1434 content: vec![ContentBlock::ToolUse {
1435 id: "call-e2e-1".into(),
1436 name: "echo".into(),
1437 input: serde_json::json!({"text": "e2e-ping"}),
1438 }],
1439 stop_reason: StopReason::ToolUse,
1440 usage: Usage {
1441 input_tokens: 15,
1442 output_tokens: 10,
1443 total_tokens: 0,
1444 },
1445 metadata: HashMap::new(),
1446 })
1447 } else {
1448 Ok(LlmResponse {
1450 id: "e2e-resp-2".into(),
1451 content: vec![ContentBlock::Text {
1452 text: "I received the tool output successfully".into(),
1453 }],
1454 stop_reason: StopReason::EndTurn,
1455 usage: Usage {
1456 input_tokens: 25,
1457 output_tokens: 12,
1458 total_tokens: 0,
1459 },
1460 metadata: HashMap::new(),
1461 })
1462 }
1463 }
1464 }
1465
1466 struct MultiToolE2eTransport {
1469 call_count: std::sync::atomic::AtomicUsize,
1470 recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
1471 }
1472
1473 impl MultiToolE2eTransport {
1474 fn new() -> Self {
1475 Self {
1476 call_count: std::sync::atomic::AtomicUsize::new(0),
1477 recorded_requests: std::sync::Mutex::new(Vec::new()),
1478 }
1479 }
1480
1481 fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
1482 self.recorded_requests.lock().unwrap().clone()
1483 }
1484 }
1485
1486 #[async_trait]
1487 impl LlmTransport for MultiToolE2eTransport {
1488 async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1489 self.recorded_requests
1490 .lock()
1491 .unwrap()
1492 .push(request.messages.clone());
1493
1494 let count = self
1495 .call_count
1496 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1497
1498 if count == 0 {
1499 Ok(LlmResponse {
1501 id: "multi-tool-resp-1".into(),
1502 content: vec![
1503 ContentBlock::ToolUse {
1504 id: "call-mt-1".into(),
1505 name: "echo".into(),
1506 input: serde_json::json!({"text": "first"}),
1507 },
1508 ContentBlock::ToolUse {
1509 id: "call-mt-2".into(),
1510 name: "echo".into(),
1511 input: serde_json::json!({"text": "second"}),
1512 },
1513 ],
1514 stop_reason: StopReason::ToolUse,
1515 usage: Usage {
1516 input_tokens: 20,
1517 output_tokens: 15,
1518 total_tokens: 0,
1519 },
1520 metadata: HashMap::new(),
1521 })
1522 } else {
1523 Ok(LlmResponse {
1524 id: "multi-tool-resp-2".into(),
1525 content: vec![ContentBlock::Text {
1526 text: "processed both tools".into(),
1527 }],
1528 stop_reason: StopReason::EndTurn,
1529 usage: Usage {
1530 input_tokens: 30,
1531 output_tokens: 10,
1532 total_tokens: 0,
1533 },
1534 metadata: HashMap::new(),
1535 })
1536 }
1537 }
1538 }
1539
1540 #[tokio::test]
1544 async fn e2e_tool_roundtrip_message_chain() {
1545 let transport = Arc::new(E2eRecordingTransport::new());
1546 let transport_ref = transport.clone();
1547 let (agent, dir) = make_agent_loop(transport, "e2e_chain").await;
1548
1549 let inbound = InboundMessage {
1553 channel: "cli".into(),
1554 sender_id: "e2e-user".into(),
1555 chat_id: "e2e-chat".into(),
1556 content: "please use the echo tool".into(),
1557 timestamp: chrono::Utc::now(),
1558 media: vec![],
1559 metadata: HashMap::new(),
1560 };
1561 agent.bus.publish_inbound(inbound).unwrap();
1562 let msg = agent.bus.consume_inbound().await.unwrap();
1563 agent.process_message(msg).await.unwrap();
1564
1565 let outbound = agent.bus.consume_outbound().await.unwrap();
1567 assert_eq!(outbound.content, "I received the tool output successfully");
1568 assert_eq!(outbound.channel, "cli");
1569 assert_eq!(outbound.chat_id, "e2e-chat");
1570
1571 let snapshots = transport_ref.snapshots();
1573 assert_eq!(
1574 snapshots.len(),
1575 2,
1576 "transport should be called twice (tool_use -> text)"
1577 );
1578
1579 let first_call = &snapshots[0];
1581 assert!(
1582 first_call.iter().any(|m| m.role == "user"),
1583 "first call should contain user message"
1584 );
1585
1586 let second_call = &snapshots[1];
1588 let tool_result_msg = second_call
1589 .iter()
1590 .find(|m| m.role == "tool")
1591 .expect("second call should contain a tool result message");
1592
1593 assert_eq!(
1595 tool_result_msg.tool_call_id.as_deref(),
1596 Some("call-e2e-1"),
1597 "tool result should reference the tool call ID"
1598 );
1599
1600 assert!(
1602 tool_result_msg.content.contains("e2e-ping"),
1603 "tool result should contain the echoed text: {}",
1604 tool_result_msg.content
1605 );
1606
1607 let session = agent
1609 .sessions
1610 .get_or_create("cli:e2e-chat")
1611 .await
1612 .unwrap();
1613 assert!(
1614 session.messages.len() >= 2,
1615 "session should have at least user + assistant messages, got {}",
1616 session.messages.len()
1617 );
1618
1619 let _ = tokio::fs::remove_dir_all(&dir).await;
1620 }
1621
1622 #[tokio::test]
1625 async fn e2e_multi_tool_calls_all_results_returned() {
1626 let transport = Arc::new(MultiToolE2eTransport::new());
1627 let transport_ref = transport.clone();
1628 let (agent, dir) = make_agent_loop(transport, "e2e_multi").await;
1629
1630 let inbound = InboundMessage {
1633 channel: "cli".into(),
1634 sender_id: "user".into(),
1635 chat_id: "chat".into(),
1636 content: "use echo twice".into(),
1637 timestamp: chrono::Utc::now(),
1638 media: vec![],
1639 metadata: HashMap::new(),
1640 };
1641 agent.bus.publish_inbound(inbound).unwrap();
1642 let msg = agent.bus.consume_inbound().await.unwrap();
1643 agent.process_message(msg).await.unwrap();
1644
1645 let outbound = agent.bus.consume_outbound().await.unwrap();
1646 assert_eq!(outbound.content, "processed both tools");
1647
1648 let snapshots = transport_ref.snapshots();
1650 assert_eq!(snapshots.len(), 2);
1651
1652 let second_call = &snapshots[1];
1653 let tool_results: Vec<&LlmMessage> =
1654 second_call.iter().filter(|m| m.role == "tool").collect();
1655
1656 assert_eq!(
1657 tool_results.len(),
1658 2,
1659 "second call should have 2 tool result messages"
1660 );
1661
1662 let ids: Vec<&str> = tool_results
1664 .iter()
1665 .filter_map(|m| m.tool_call_id.as_deref())
1666 .collect();
1667 assert!(
1668 ids.contains(&"call-mt-1"),
1669 "should have result for call-mt-1"
1670 );
1671 assert!(
1672 ids.contains(&"call-mt-2"),
1673 "should have result for call-mt-2"
1674 );
1675
1676 let first_result = tool_results
1678 .iter()
1679 .find(|m| m.tool_call_id.as_deref() == Some("call-mt-1"))
1680 .unwrap();
1681 assert!(
1682 first_result.content.contains("first"),
1683 "first tool result should contain 'first': {}",
1684 first_result.content
1685 );
1686
1687 let second_result = tool_results
1688 .iter()
1689 .find(|m| m.tool_call_id.as_deref() == Some("call-mt-2"))
1690 .unwrap();
1691 assert!(
1692 second_result.content.contains("second"),
1693 "second tool result should contain 'second': {}",
1694 second_result.content
1695 );
1696
1697 let _ = tokio::fs::remove_dir_all(&dir).await;
1698 }
1699
1700 #[tokio::test]
1703 async fn e2e_direct_text_response_no_tools() {
1704 let transport = Arc::new(MockTransport::new("Direct answer from LLM"));
1705 let (agent, dir) = make_agent_loop(transport, "e2e_no_tools").await;
1706
1707 let inbound = InboundMessage {
1708 channel: "direct".into(),
1709 sender_id: "user".into(),
1710 chat_id: "chat".into(),
1711 content: "what is 2+2?".into(),
1712 timestamp: chrono::Utc::now(),
1713 media: vec![],
1714 metadata: HashMap::new(),
1715 };
1716 agent.bus.publish_inbound(inbound).unwrap();
1717 let msg = agent.bus.consume_inbound().await.unwrap();
1718 agent.process_message(msg).await.unwrap();
1719
1720 let outbound = agent.bus.consume_outbound().await.unwrap();
1721 assert_eq!(outbound.content, "Direct answer from LLM");
1722 assert_eq!(outbound.channel, "direct");
1723
1724 let session = agent.sessions.get_or_create("direct:chat").await.unwrap();
1726 let roles: Vec<String> = session
1727 .messages
1728 .iter()
1729 .filter_map(|m| m.get("role").and_then(|v| v.as_str()).map(String::from))
1730 .collect();
1731 assert!(
1732 roles.iter().any(|r| r == "user"),
1733 "session should have user message"
1734 );
1735 assert!(
1736 roles.iter().any(|r| r == "assistant"),
1737 "session should have assistant message"
1738 );
1739
1740 let _ = tokio::fs::remove_dir_all(&dir).await;
1741 }
1742
1743 struct FailingToolTransport {
1746 call_count: std::sync::atomic::AtomicUsize,
1747 recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
1748 }
1749
1750 impl FailingToolTransport {
1751 fn new() -> Self {
1752 Self {
1753 call_count: std::sync::atomic::AtomicUsize::new(0),
1754 recorded_requests: std::sync::Mutex::new(Vec::new()),
1755 }
1756 }
1757
1758 fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
1759 self.recorded_requests.lock().unwrap().clone()
1760 }
1761 }
1762
1763 #[async_trait]
1764 impl LlmTransport for FailingToolTransport {
1765 async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1766 self.recorded_requests
1767 .lock()
1768 .unwrap()
1769 .push(request.messages.clone());
1770
1771 let count = self
1772 .call_count
1773 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1774
1775 if count == 0 {
1776 Ok(LlmResponse {
1778 id: "fail-resp-1".into(),
1779 content: vec![ContentBlock::ToolUse {
1780 id: "call-fail-1".into(),
1781 name: "nonexistent_tool".into(),
1782 input: serde_json::json!({}),
1783 }],
1784 stop_reason: StopReason::ToolUse,
1785 usage: Usage {
1786 input_tokens: 10,
1787 output_tokens: 5,
1788 total_tokens: 0,
1789 },
1790 metadata: HashMap::new(),
1791 })
1792 } else {
1793 Ok(LlmResponse {
1795 id: "fail-resp-2".into(),
1796 content: vec![ContentBlock::Text {
1797 text: "I see the tool failed, let me help differently".into(),
1798 }],
1799 stop_reason: StopReason::EndTurn,
1800 usage: Usage {
1801 input_tokens: 20,
1802 output_tokens: 12,
1803 total_tokens: 0,
1804 },
1805 metadata: HashMap::new(),
1806 })
1807 }
1808 }
1809 }
1810
1811 #[tokio::test]
1812 async fn e2e_tool_execution_failure_handled_gracefully() {
1813 let transport = Arc::new(FailingToolTransport::new());
1814 let transport_ref = transport.clone();
1815 let (agent, dir) = make_agent_loop(transport, "e2e_fail").await;
1816
1817 let inbound = InboundMessage {
1818 channel: "fail".into(),
1819 sender_id: "user".into(),
1820 chat_id: "chat".into(),
1821 content: "try a tool".into(),
1822 timestamp: chrono::Utc::now(),
1823 media: vec![],
1824 metadata: HashMap::new(),
1825 };
1826 agent.bus.publish_inbound(inbound).unwrap();
1827 let msg = agent.bus.consume_inbound().await.unwrap();
1828 agent.process_message(msg).await.unwrap();
1829
1830 let outbound = agent.bus.consume_outbound().await.unwrap();
1831 assert_eq!(
1832 outbound.content,
1833 "I see the tool failed, let me help differently"
1834 );
1835
1836 let snapshots = transport_ref.snapshots();
1838 assert_eq!(snapshots.len(), 2);
1839
1840 let second_call = &snapshots[1];
1841 let tool_result = second_call
1842 .iter()
1843 .find(|m| m.role == "tool")
1844 .expect("second call should have a tool result with the error");
1845
1846 assert!(
1847 tool_result.content.contains("error"),
1848 "tool result should contain error message: {}",
1849 tool_result.content
1850 );
1851
1852 let _ = tokio::fs::remove_dir_all(&dir).await;
1853 }
1854
1855 fn make_inbound(channel: &str, sender_id: &str) -> InboundMessage {
1859 InboundMessage {
1860 channel: channel.into(),
1861 sender_id: sender_id.into(),
1862 chat_id: "test-chat".into(),
1863 content: "test message".into(),
1864 timestamp: chrono::Utc::now(),
1865 media: vec![],
1866 metadata: HashMap::new(),
1867 }
1868 }
1869
1870 fn resolve_default(msg: &InboundMessage) -> AuthContext {
1873 let resolver = PermissionResolver::default_resolver();
1874 resolver.resolve_auth_context(&msg.sender_id, &msg.channel, false)
1875 }
1876
1877 #[test]
1879 fn test_resolve_auth_context_cli() {
1880 let msg = make_inbound("cli", "local");
1881 let ctx = resolve_default(&msg);
1882
1883 assert_eq!(ctx.sender_id, "local");
1884 assert_eq!(ctx.channel, "cli");
1885 assert_eq!(ctx.permissions.level, 2, "CLI should get admin (level 2)");
1886 assert!(
1887 ctx.permissions.tool_access.contains(&"*".to_string()),
1888 "CLI admin should have wildcard tool access"
1889 );
1890 assert_eq!(
1891 ctx.permissions.rate_limit, 0,
1892 "CLI admin should have no rate limit"
1893 );
1894 }
1895
1896 #[test]
1898 fn test_resolve_auth_context_empty_sender() {
1899 let msg = make_inbound("telegram", "");
1900 let ctx = resolve_default(&msg);
1901
1902 assert_eq!(ctx.sender_id, "");
1903 assert_eq!(ctx.channel, "telegram");
1904 assert_eq!(
1905 ctx.permissions.level, 0,
1906 "empty sender_id should get zero-trust (level 0)"
1907 );
1908 }
1909
1910 #[test]
1912 fn test_resolve_auth_context_gateway_channel() {
1913 let msg = make_inbound("gateway", "api_key_user");
1914 let ctx = resolve_default(&msg);
1915
1916 assert_eq!(ctx.sender_id, "api_key_user");
1917 assert_eq!(ctx.channel, "gateway");
1918 assert_eq!(
1919 ctx.permissions.level, 0,
1920 "gateway users should get zero-trust (level 0) by default"
1921 );
1922 assert!(
1923 ctx.permissions.tool_access.is_empty(),
1924 "zero-trust should have no tool access"
1925 );
1926 }
1927
1928 #[test]
1933 fn test_resolve_auth_context_telegram_preserves_identity() {
1934 let msg = make_inbound("telegram", "12345");
1935 let ctx = resolve_default(&msg);
1936
1937 assert_eq!(ctx.sender_id, "12345");
1938 assert_eq!(ctx.channel, "telegram");
1939 assert_eq!(
1940 ctx.permissions.level, 0,
1941 "non-CLI channel gets zero-trust with default resolver"
1942 );
1943 }
1944
1945 #[test]
1947 fn test_resolve_auth_context_discord() {
1948 let msg = make_inbound("discord", "snowflake_987654321");
1949 let ctx = resolve_default(&msg);
1950
1951 assert_eq!(ctx.sender_id, "snowflake_987654321");
1952 assert_eq!(ctx.channel, "discord");
1953 assert_eq!(ctx.permissions.level, 0);
1954 }
1955
1956 #[test]
1958 fn test_resolve_auth_context_slack() {
1959 let msg = make_inbound("slack", "U12345");
1960 let ctx = resolve_default(&msg);
1961
1962 assert_eq!(ctx.sender_id, "U12345");
1963 assert_eq!(ctx.channel, "slack");
1964 assert_eq!(ctx.permissions.level, 0);
1965 }
1966
1967 #[tokio::test]
1971 async fn test_auth_context_attached_to_chat_request() {
1972 let transport = Arc::new(MockTransport::new("auth-verified"));
1973 let (agent, dir) = make_agent_loop(transport, "auth_attach").await;
1974
1975 let inbound = make_inbound("cli", "local");
1976 agent.bus.publish_inbound(inbound).unwrap();
1977
1978 let msg = agent.bus.consume_inbound().await.unwrap();
1979 agent.process_message(msg).await.unwrap();
1980
1981 let outbound = agent.bus.consume_outbound().await.unwrap();
1984 assert_eq!(outbound.content, "auth-verified");
1985 assert_eq!(outbound.channel, "cli");
1986
1987 let _ = tokio::fs::remove_dir_all(&dir).await;
1988 }
1989
1990 #[test]
1993 fn error_json_escapes_double_quotes() {
1994 let error_msg = r#"file "foo" not found"#;
1995 let json_str = serde_json::json!({"error": error_msg}).to_string();
1996 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1997 assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
1998 }
1999
2000 #[test]
2001 fn error_json_escapes_backslashes() {
2002 let error_msg = r"path C:\Users\test";
2003 let json_str = serde_json::json!({"error": error_msg}).to_string();
2004 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2005 assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
2006 }
2007
2008 #[test]
2009 fn error_json_escapes_newlines() {
2010 let error_msg = "line 1\nline 2";
2011 let json_str = serde_json::json!({"error": error_msg}).to_string();
2012 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2013 assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
2014 }
2015
2016 #[test]
2017 fn error_json_escapes_all_special_chars() {
2018 let error_msg = "quote: \" backslash: \\ newline: \n tab: \t null: \0";
2019 let json_str = serde_json::json!({"error": error_msg}).to_string();
2020 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2021 assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
2022 }
2023
2024 #[test]
2025 fn error_json_has_single_error_key() {
2026 let error_msg = "something went wrong";
2027 let json_str = serde_json::json!({"error": error_msg}).to_string();
2028 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2029 let obj = parsed.as_object().unwrap();
2030 assert_eq!(obj.len(), 1, "should have exactly one key");
2031 assert!(obj.contains_key("error"));
2032 }
2033
2034 #[tokio::test]
2036 async fn test_auth_context_non_cli_attaches_zero_trust() {
2037 let transport = Arc::new(MockTransport::new("zero-trust-ok"));
2038 let (agent, dir) = make_agent_loop(transport, "auth_zt").await;
2039
2040 let inbound = make_inbound("telegram", "user42");
2041 agent.bus.publish_inbound(inbound).unwrap();
2042
2043 let msg = agent.bus.consume_inbound().await.unwrap();
2044 agent.process_message(msg).await.unwrap();
2045
2046 let outbound = agent.bus.consume_outbound().await.unwrap();
2047 assert_eq!(outbound.content, "zero-trust-ok");
2048 assert_eq!(outbound.channel, "telegram");
2049
2050 let _ = tokio::fs::remove_dir_all(&dir).await;
2051 }
2052
2053 struct MockAutoDelegation;
2057
2058 impl AutoDelegation for MockAutoDelegation {
2059 fn should_delegate(&self, content: &str) -> Option<serde_json::Value> {
2060 let lower = content.to_lowercase();
2061 if lower.contains("swarm") || lower.contains("deploy") {
2062 Some(serde_json::json!({"task": content}))
2063 } else {
2064 None
2065 }
2066 }
2067 }
2068
2069 struct MockDelegateTaskTool;
2071
2072 #[async_trait]
2073 impl Tool for MockDelegateTaskTool {
2074 fn name(&self) -> &str {
2075 "delegate_task"
2076 }
2077 fn description(&self) -> &str {
2078 "Mock delegate_task for testing"
2079 }
2080 fn parameters(&self) -> serde_json::Value {
2081 serde_json::json!({
2082 "type": "object",
2083 "properties": {
2084 "task": { "type": "string" }
2085 },
2086 "required": ["task"]
2087 })
2088 }
2089 async fn execute(
2090 &self,
2091 args: serde_json::Value,
2092 ) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
2093 let task = args
2094 .get("task")
2095 .and_then(|v| v.as_str())
2096 .unwrap_or("(unknown)");
2097 Ok(serde_json::json!({
2098 "status": "delegated",
2099 "target": "claude",
2100 "response": format!("Delegated: {task}"),
2101 "task": task,
2102 }))
2103 }
2104 }
2105
2106 async fn make_auto_delegation_agent(
2108 transport: Arc<dyn LlmTransport>,
2109 prefix: &str,
2110 ) -> (AgentLoop<NativePlatform>, PathBuf) {
2111 let dir = temp_dir(prefix);
2112 let platform = Arc::new(NativePlatform::new());
2113 let bus = Arc::new(MessageBus::new());
2114
2115 let sessions_dir = dir.join("sessions");
2116 let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
2117
2118 let memory = Arc::new(MemoryStore::with_paths(
2119 dir.join("memory").join("MEMORY.md"),
2120 dir.join("memory").join("HISTORY.md"),
2121 platform.clone(),
2122 ));
2123 let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
2124 let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
2125
2126 let mut tools = ToolRegistry::new();
2127 tools.register(Arc::new(EchoTool));
2128 tools.register(Arc::new(MockDelegateTaskTool));
2129
2130 let pipeline = make_pipeline(transport);
2131
2132 let agent = AgentLoop::new(
2133 test_config(),
2134 platform,
2135 bus,
2136 pipeline,
2137 Arc::new(tools),
2138 context,
2139 Arc::new(sessions),
2140 PermissionResolver::default_resolver(),
2141 )
2142 .with_auto_delegation(Arc::new(MockAutoDelegation));
2143
2144 (agent, dir)
2145 }
2146
2147 #[tokio::test]
2149 async fn auto_delegation_routes_matching_message() {
2150 let transport = Arc::new(MockTransport::new("should NOT see this"));
2151 let (agent, dir) = make_auto_delegation_agent(transport, "auto_del_match").await;
2152
2153 let inbound = InboundMessage {
2155 channel: "cli".into(),
2156 sender_id: "local".into(),
2157 chat_id: "test".into(),
2158 content: "run a swarm security review".into(),
2159 timestamp: chrono::Utc::now(),
2160 media: vec![],
2161 metadata: HashMap::new(),
2162 };
2163 agent.bus.publish_inbound(inbound).unwrap();
2164 let msg = agent.bus.consume_inbound().await.unwrap();
2165 agent.process_message(msg).await.unwrap();
2166
2167 let outbound = agent.bus.consume_outbound().await.unwrap();
2168 assert!(
2169 outbound.content.contains("Delegated:"),
2170 "response should be from delegate_task, got: {}",
2171 outbound.content
2172 );
2173 assert!(
2174 outbound.content.contains("swarm"),
2175 "delegated response should include the original task"
2176 );
2177
2178 let _ = tokio::fs::remove_dir_all(&dir).await;
2179 }
2180
2181 #[tokio::test]
2183 async fn auto_delegation_skips_non_matching_message() {
2184 let transport = Arc::new(MockTransport::new("LLM response"));
2185 let (agent, dir) = make_auto_delegation_agent(transport, "auto_del_skip").await;
2186
2187 let inbound = InboundMessage {
2189 channel: "cli".into(),
2190 sender_id: "local".into(),
2191 chat_id: "test".into(),
2192 content: "hello world".into(),
2193 timestamp: chrono::Utc::now(),
2194 media: vec![],
2195 metadata: HashMap::new(),
2196 };
2197 agent.bus.publish_inbound(inbound).unwrap();
2198 let msg = agent.bus.consume_inbound().await.unwrap();
2199 agent.process_message(msg).await.unwrap();
2200
2201 let outbound = agent.bus.consume_outbound().await.unwrap();
2202 assert_eq!(
2203 outbound.content, "LLM response",
2204 "non-matching message should go through normal LLM pipeline"
2205 );
2206
2207 let _ = tokio::fs::remove_dir_all(&dir).await;
2208 }
2209
2210 #[tokio::test]
2212 async fn no_auto_delegation_uses_llm() {
2213 let transport = Arc::new(MockTransport::new("normal LLM"));
2214 let (agent, dir) = make_agent_loop(transport, "no_auto_del").await;
2215
2216 let inbound = InboundMessage {
2218 channel: "cli".into(),
2219 sender_id: "local".into(),
2220 chat_id: "test".into(),
2221 content: "run a swarm task".into(),
2222 timestamp: chrono::Utc::now(),
2223 media: vec![],
2224 metadata: HashMap::new(),
2225 };
2226 agent.bus.publish_inbound(inbound).unwrap();
2227 let msg = agent.bus.consume_inbound().await.unwrap();
2228 agent.process_message(msg).await.unwrap();
2229
2230 let outbound = agent.bus.consume_outbound().await.unwrap();
2231 assert_eq!(outbound.content, "normal LLM");
2232
2233 let _ = tokio::fs::remove_dir_all(&dir).await;
2234 }
2235}