1use super::behavior::{AgentBehavior, AgentTurnControl};
2use super::context::AgentContext;
3use super::skills::{render_system_blocks as render_skill_blocks, SkillLoader};
4use super::tool_registry::ToolRegistry;
5use super::transcripts::{TranscriptEntry, TranscriptRole, TranscriptWriter};
6use super::types::{InboundMessage, MessagePriority, RunTrigger};
7use super::workspace::{SessionScope, WorkspaceLoader};
8use crate::session::types::{Interaction, Role};
9use crate::telemetry::{
10 inc_llm_requests_total, observe_cache_usage, observe_llm_latency_ms,
11 observe_prompt_tokens_drift, observe_prompt_tokens_estimated,
12};
13use async_trait::async_trait;
14use chrono::Utc;
15use nexo_broker::{BrokerHandle, Event};
16use nexo_driver_types::{GoalId, MemoryExtractor};
17use nexo_llm::{
18 collect_stream, Attachment, CachePolicy, ChatMessage, ChatRequest, ChatRole, LlmClient,
19 ResponseContent,
20};
21use nexo_memory::EmailFollowupEntry;
22use std::collections::HashMap;
23use std::hash::{Hash, Hasher};
24use std::path::PathBuf;
25use std::sync::{Arc, Mutex};
26fn build_outbound_payload(
32 reply: &nexo_tool_meta::reply_kind::OutboundReplyKind,
33 to: Option<&str>,
34 session_id: uuid::Uuid,
35) -> serde_json::Value {
36 use base64::{engine::general_purpose::STANDARD as B64, Engine};
37 use nexo_tool_meta::reply_kind::OutboundReplyKind;
38 match reply {
39 OutboundReplyKind::Text { body } => serde_json::json!({
40 "to": to,
41 "text": body,
42 "kind": "text",
43 "session_id": session_id,
44 }),
45 OutboundReplyKind::VoiceNote {
46 audio_bytes,
47 mimetype,
48 transcript,
49 } => serde_json::json!({
50 "to": to,
51 "kind": "voice_note",
52 "audio_bytes_b64": B64.encode(audio_bytes),
53 "mimetype": mimetype,
54 "text": transcript,
58 "session_id": session_id,
59 }),
60 OutboundReplyKind::Image {
61 bytes,
62 mimetype,
63 caption,
64 } => serde_json::json!({
65 "to": to,
66 "kind": "image",
67 "image_bytes_b64": B64.encode(bytes),
68 "mimetype": mimetype,
69 "caption": caption,
70 "session_id": session_id,
71 }),
72 }
73}
74
75#[derive(Debug, Default)]
82struct InboundTransformOutcome {
83 new_text: Option<String>,
84 system_addenda: Vec<String>,
85}
86
87fn session_scope_for(msg: &InboundMessage) -> SessionScope {
90 if msg.source_plugin == "agent" {
93 return SessionScope::Shared;
94 }
95 SessionScope::Main
96}
97
98const MAX_TRACKED_CACHE_BREAK_SESSIONS: usize = 256;
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101struct CacheBreakRequestContext {
102 provider: String,
103 model: String,
104 system_hash: u64,
105}
106
107impl CacheBreakRequestContext {
108 fn from_request(provider: &str, model: &str, req: &ChatRequest) -> Self {
109 Self {
110 provider: provider.to_string(),
111 model: model.to_string(),
112 system_hash: prompt_shape_hash(req),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118struct CacheBreakSnapshot {
119 req: CacheBreakRequestContext,
120 cache_read_input_tokens: u32,
121 cache_creation_input_tokens: u32,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125struct CacheBreakEvent {
126 previous_provider: String,
127 new_provider: String,
128 previous_model: String,
129 new_model: String,
130 previous_cache_read_input_tokens: u32,
131 cache_read_input_tokens: u32,
132 cache_creation_input_tokens: u32,
133 drop_pct: u32,
134 provider_changed: bool,
135 model_changed: bool,
136 system_prompt_changed: bool,
137 suspected_breaker: String,
138}
139
140#[derive(Debug, Default)]
141struct CacheBreakTracker {
142 by_session: HashMap<String, CacheBreakSnapshot>,
143}
144
145impl CacheBreakTracker {
146 fn observe(
147 &mut self,
148 session_id: &str,
149 current: CacheBreakSnapshot,
150 ) -> Option<CacheBreakEvent> {
151 if !self.by_session.contains_key(session_id)
152 && self.by_session.len() >= MAX_TRACKED_CACHE_BREAK_SESSIONS
153 {
154 if let Some(oldest_key) = self.by_session.keys().next().cloned() {
155 self.by_session.remove(&oldest_key);
156 }
157 }
158 let previous = self
159 .by_session
160 .insert(session_id.to_string(), current.clone())?;
161 let prev_read = previous.cache_read_input_tokens;
162 if prev_read == 0 {
163 return None;
164 }
165 if u64::from(current.cache_read_input_tokens).saturating_mul(2) >= u64::from(prev_read) {
168 return None;
169 }
170 let provider_changed = previous.req.provider != current.req.provider;
171 let model_changed = previous.req.model != current.req.model;
172 let system_prompt_changed = previous.req.system_hash != current.req.system_hash;
173 let mut breakers: Vec<&str> = Vec::new();
174 if provider_changed {
175 breakers.push("provider_swap");
176 }
177 if model_changed {
178 breakers.push("model_swap");
179 }
180 if system_prompt_changed {
181 breakers.push("system_prompt_mutation");
182 }
183 let suspected_breaker = if breakers.is_empty() {
184 "unknown".to_string()
185 } else {
186 breakers.join(",")
187 };
188 let drop_pct = ((u64::from(prev_read.saturating_sub(current.cache_read_input_tokens))
189 * 100)
190 / u64::from(prev_read)) as u32;
191 Some(CacheBreakEvent {
192 previous_provider: previous.req.provider,
193 new_provider: current.req.provider,
194 previous_model: previous.req.model,
195 new_model: current.req.model,
196 previous_cache_read_input_tokens: prev_read,
197 cache_read_input_tokens: current.cache_read_input_tokens,
198 cache_creation_input_tokens: current.cache_creation_input_tokens,
199 drop_pct,
200 provider_changed,
201 model_changed,
202 system_prompt_changed,
203 suspected_breaker,
204 })
205 }
206}
207
208fn cache_policy_tag(policy: CachePolicy) -> u8 {
209 match policy {
210 CachePolicy::None => 0,
211 CachePolicy::Ephemeral5m => 1,
212 CachePolicy::Ephemeral1h => 2,
213 }
214}
215
216fn prompt_shape_hash(req: &ChatRequest) -> u64 {
217 let mut h = std::collections::hash_map::DefaultHasher::new();
218 if let Some(system) = req.system_prompt.as_deref() {
219 "system_prompt".hash(&mut h);
220 system.hash(&mut h);
221 }
222 for block in &req.system_blocks {
223 "system_block".hash(&mut h);
224 block.label.hash(&mut h);
225 block.text.hash(&mut h);
226 cache_policy_tag(block.cache).hash(&mut h);
227 }
228 h.finish()
229}
230pub struct LlmAgentBehavior {
231 llm: Arc<dyn LlmClient>,
232 tools: Arc<ToolRegistry>,
233 hooks: Option<Arc<super::hook_registry::HookRegistry>>,
234 max_tool_iterations: usize,
235 rate_limiter: Option<Arc<super::rate_limit::ToolRateLimiter>>,
236 schema_validator: Option<Arc<super::schema_validator::ToolArgsValidator>>,
237 tool_policy: Arc<super::tool_policy::ToolPolicy>,
241 tool_filter: Arc<tokio::sync::RwLock<Option<super::tool_filter::ToolFilter>>>,
247 workspace_cache: Option<Arc<super::workspace_cache::WorkspaceCache>>,
252 prompt_cache_enabled: bool,
257 token_counter: Option<Arc<dyn nexo_llm::TokenCounter>>,
263 compactor: Option<Arc<super::compaction::LlmCompactor>>,
268 compaction_store: Option<Arc<nexo_memory::CompactionStore>>,
269 compaction_runtime: CompactionRuntime,
270 compaction_failures: std::sync::atomic::AtomicU32,
272 compaction_last_turn: std::sync::Mutex<Option<u32>>,
273 cache_break_tracker: Mutex<CacheBreakTracker>,
274 memory_extractor: Option<Arc<dyn MemoryExtractor>>,
282 memory_dir: Option<PathBuf>,
288 mutation_hook: Option<Arc<dyn nexo_driver_types::MemoryMutationHook>>,
296 mutation_tenant: String,
300 plugin_skill_roots: Vec<PathBuf>,
307 reply_transform_chain: super::reply_transform::OutboundReplyTransformChain,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315enum RunTurnOutcome {
316 Reply(Option<String>),
317 Sleep { duration_ms: u64, reason: String },
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
321struct ToolExecutionResult {
322 result: String,
323 tool_err: Option<String>,
324 outcome: &'static str,
325 duration_ms: u64,
326 sleep: Option<SleepSignal>,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
330struct SleepSignal {
331 duration_ms: u64,
332 reason: String,
333}
334
335#[derive(Debug, Clone)]
339pub struct CompactionRuntime {
340 pub enabled: bool,
341 pub compact_at_tokens: u32,
344 pub tail_keep_chars: usize,
347 pub tool_result_max_chars: usize,
350 pub micro_threshold_bytes: usize,
353 pub micro_summary_max_chars: usize,
355 pub micro_model: String,
357 pub lock_ttl_seconds: u32,
360 pub summarizer_model: String,
363 pub auto_token_pct: f32,
366 pub auto_max_age_minutes: u64,
368 pub auto_buffer_tokens: u64,
370 pub auto_min_turns_between: u32,
372 pub auto_max_consecutive_failures: u32,
374}
375
376impl Default for CompactionRuntime {
377 fn default() -> Self {
378 Self {
379 enabled: false,
380 compact_at_tokens: 75_000,
381 tail_keep_chars: 80_000, tool_result_max_chars: 60_000, micro_threshold_bytes: 16 * 1024,
384 micro_summary_max_chars: 2048,
385 micro_model: String::new(),
386 lock_ttl_seconds: 300,
387 summarizer_model: String::new(),
388 auto_token_pct: 0.80,
389 auto_max_age_minutes: 120,
390 auto_buffer_tokens: 13_000,
391 auto_min_turns_between: 5,
392 auto_max_consecutive_failures: 3,
393 }
394 }
395}
396impl LlmAgentBehavior {
397 pub fn new(llm: Arc<dyn LlmClient>, tools: Arc<ToolRegistry>) -> Self {
398 Self {
399 llm,
400 tools,
401 hooks: None,
402 max_tool_iterations: 10,
403 rate_limiter: None,
404 schema_validator: None,
405 tool_policy: super::tool_policy::ToolPolicy::disabled(),
406 tool_filter: Arc::new(tokio::sync::RwLock::new(None)),
407 workspace_cache: None,
408 prompt_cache_enabled: false,
409 token_counter: None,
410 compactor: None,
411 compaction_store: None,
412 compaction_runtime: CompactionRuntime::default(),
413 compaction_failures: std::sync::atomic::AtomicU32::new(0),
414 compaction_last_turn: std::sync::Mutex::new(None),
415 cache_break_tracker: Mutex::new(CacheBreakTracker::default()),
416 memory_extractor: None,
417 memory_dir: None,
418 mutation_hook: None,
419 mutation_tenant: "default".into(),
420 plugin_skill_roots: Vec::new(),
421 reply_transform_chain: super::reply_transform::OutboundReplyTransformChain::empty(),
422 }
423 }
424
425 pub fn with_reply_transformers(
429 mut self,
430 chain: super::reply_transform::OutboundReplyTransformChain,
431 ) -> Self {
432 self.reply_transform_chain = chain;
433 self
434 }
435
436 fn discover_inbound_transform_tools(&self) -> Vec<String> {
443 let mut names: Vec<String> = self
444 .tools
445 .names()
446 .into_iter()
447 .filter(|n| n.ends_with("_inbound_transform"))
448 .collect();
449 names.sort();
450 names
451 }
452
453 async fn run_tool_inbound_transforms(
463 &self,
464 tool_names: &[String],
465 msg: &InboundMessage,
466 ctx: &AgentContext,
467 ) -> Result<InboundTransformOutcome, String> {
468 let context = serde_json::json!({
469 "agent_id": ctx.agent_id,
470 "session_id": msg.session_id.to_string(),
471 "channel": msg.source_plugin,
472 "instance": msg.source_instance,
473 "sender_id": msg.sender_id,
474 "tenant_id": ctx.config.tenant_id,
475 "conversation_key": format!("{}:session:{}", ctx.agent_id, msg.session_id),
476 "language": ctx.config.language,
480 });
481 let media = msg.media.as_ref().map(|m| {
482 serde_json::json!({
483 "kind": m.kind,
484 "path": m.path,
485 "mime_type": m.mime_type,
486 })
487 });
488 let mut current_text = msg.text.clone();
489 let mut mutated = false;
490 let mut system_addenda: Vec<String> = Vec::new();
491 for name in tool_names {
492 let Some((_def, handler)) = self.tools.get(name) else {
493 continue;
494 };
495 let args = serde_json::json!({
496 "context": context,
497 "text": current_text,
498 "media": media,
499 });
500 let started = std::time::Instant::now();
501 let result = handler.call(ctx, args).await;
502 let elapsed_ms = started.elapsed().as_millis() as u64;
503 match result {
504 Ok(value) => {
505 if value.get("ok").and_then(|v| v.as_bool()) != Some(true) {
506 let err_msg = value
507 .get("error")
508 .and_then(|v| v.as_str())
509 .unwrap_or("(no error message)")
510 .to_string();
511 tracing::warn!(
512 agent_id = %ctx.agent_id,
513 tool = %name,
514 elapsed_ms,
515 error = %err_msg,
516 "tool inbound transform rejected"
517 );
518 return Err(err_msg);
519 }
520 if value.get("passthrough").and_then(|v| v.as_bool()) == Some(true) {
521 tracing::debug!(
522 agent_id = %ctx.agent_id,
523 tool = %name,
524 elapsed_ms,
525 "tool inbound transform passthrough"
526 );
527 continue;
528 }
529 if let Some(new_text) = value.get("text").and_then(|v| v.as_str()) {
530 tracing::info!(
531 agent_id = %ctx.agent_id,
532 tool = %name,
533 elapsed_ms,
534 new_text_len = new_text.len(),
535 "tool inbound transform rewrote text"
536 );
537 current_text = new_text.to_string();
538 mutated = true;
539 }
540 if let Some(addendum) = value
546 .get("system_addendum")
547 .and_then(|v| v.as_str())
548 .filter(|s| !s.trim().is_empty())
549 {
550 tracing::info!(
551 agent_id = %ctx.agent_id,
552 tool = %name,
553 elapsed_ms,
554 addendum_len = addendum.len(),
555 "tool inbound transform contributed system addendum"
556 );
557 system_addenda.push(addendum.to_string());
558 }
559 }
560 Err(e) => {
561 tracing::warn!(
562 agent_id = %ctx.agent_id,
563 tool = %name,
564 elapsed_ms,
565 error = %e,
566 "tool inbound transform handler errored"
567 );
568 return Err(format!("{name} handler error: {e}"));
569 }
570 }
571 }
572 Ok(InboundTransformOutcome {
573 new_text: if mutated { Some(current_text) } else { None },
574 system_addenda,
575 })
576 }
577
578 fn discover_reply_transform_tools(&self) -> Vec<String> {
584 let mut names: Vec<String> = self
585 .tools
586 .names()
587 .into_iter()
588 .filter(|n| n.ends_with("_reply_transform"))
589 .collect();
590 names.sort();
591 names
592 }
593
594 async fn run_tool_reply_transforms(
603 &self,
604 tool_names: &[String],
605 transform_ctx: &nexo_tool_meta::reply_kind::OutboundReplyContext,
606 mut reply: nexo_tool_meta::reply_kind::OutboundReplyKind,
607 ctx: &AgentContext,
608 ) -> Result<nexo_tool_meta::reply_kind::OutboundReplyKind, String> {
609 for name in tool_names {
610 let Some((_def, handler)) = self.tools.get(name) else {
611 continue;
612 };
613 let args = serde_json::json!({
614 "context": transform_ctx,
615 "reply": reply,
616 });
617 let started = std::time::Instant::now();
618 let result = handler.call(ctx, args).await;
619 let elapsed_ms = started.elapsed().as_millis() as u64;
620 match result {
621 Ok(value) => {
622 if value.get("ok").and_then(|v| v.as_bool()) != Some(true) {
623 let err_msg = value
624 .get("error")
625 .and_then(|v| v.as_str())
626 .unwrap_or("(no error message)")
627 .to_string();
628 tracing::warn!(
629 agent_id = %ctx.agent_id,
630 tool = %name,
631 elapsed_ms,
632 error = %err_msg,
633 "tool reply transform rejected"
634 );
635 return Err(err_msg);
636 }
637 if value.get("passthrough").and_then(|v| v.as_bool()) == Some(true) {
638 tracing::debug!(
639 agent_id = %ctx.agent_id,
640 tool = %name,
641 elapsed_ms,
642 "tool reply transform passthrough"
643 );
644 continue;
645 }
646 match value.get("reply") {
647 Some(reply_value) => {
648 match serde_json::from_value::<
649 nexo_tool_meta::reply_kind::OutboundReplyKind,
650 >(reply_value.clone())
651 {
652 Ok(next) => {
653 tracing::info!(
654 agent_id = %ctx.agent_id,
655 tool = %name,
656 elapsed_ms,
657 new_kind = next.kind_label(),
658 "tool reply transform applied"
659 );
660 reply = next;
661 }
662 Err(e) => {
663 tracing::warn!(
664 agent_id = %ctx.agent_id,
665 tool = %name,
666 elapsed_ms,
667 error = %e,
668 "tool reply transform returned malformed reply"
669 );
670 return Err(format!("malformed reply from {name}: {e}"));
671 }
672 }
673 }
674 None => {
675 tracing::debug!(
676 agent_id = %ctx.agent_id,
677 tool = %name,
678 elapsed_ms,
679 "tool reply transform returned ok with no reply (treated as passthrough)"
680 );
681 }
682 }
683 }
684 Err(e) => {
685 tracing::warn!(
686 agent_id = %ctx.agent_id,
687 tool = %name,
688 elapsed_ms,
689 error = %e,
690 "tool reply transform handler errored"
691 );
692 return Err(format!("{name} handler error: {e}"));
693 }
694 }
695 }
696 Ok(reply)
697 }
698
699 pub fn with_plugin_skill_roots(mut self, roots: Vec<PathBuf>) -> Self {
705 self.plugin_skill_roots = roots;
706 self
707 }
708
709 pub fn with_mutation_hook(
715 mut self,
716 hook: Arc<dyn nexo_driver_types::MemoryMutationHook>,
717 tenant: impl Into<String>,
718 ) -> Self {
719 self.mutation_hook = Some(hook);
720 self.mutation_tenant = tenant.into();
721 self
722 }
723
724 pub fn with_memory_extractor(
735 mut self,
736 extractor: Arc<dyn MemoryExtractor>,
737 memory_dir: PathBuf,
738 ) -> Self {
739 self.memory_extractor = Some(extractor);
740 self.memory_dir = Some(memory_dir);
741 self
742 }
743
744 fn maybe_log_cache_break(
745 &self,
746 agent_id: &str,
747 session_id: &str,
748 req_ctx: CacheBreakRequestContext,
749 cache_read_input_tokens: u32,
750 cache_creation_input_tokens: u32,
751 ) {
752 let current = CacheBreakSnapshot {
753 req: req_ctx,
754 cache_read_input_tokens,
755 cache_creation_input_tokens,
756 };
757 let event = {
758 let mut tracker = match self.cache_break_tracker.lock() {
759 Ok(g) => g,
760 Err(poisoned) => poisoned.into_inner(),
761 };
762 tracker.observe(session_id, current)
763 };
764 if let Some(event) = event {
765 tracing::warn!(
766 target: "llm.cache_break",
767 agent_id = agent_id,
768 session_id = session_id,
769 previous_provider = %event.previous_provider,
770 new_provider = %event.new_provider,
771 previous_model = %event.previous_model,
772 new_model = %event.new_model,
773 previous_cache_read_input_tokens = event.previous_cache_read_input_tokens,
774 cache_read_input_tokens = event.cache_read_input_tokens,
775 cache_creation_input_tokens = event.cache_creation_input_tokens,
776 drop_pct = event.drop_pct,
777 provider_changed = event.provider_changed,
778 model_changed = event.model_changed,
779 system_prompt_changed = event.system_prompt_changed,
780 suspected_breaker = %event.suspected_breaker,
781 "llm.cache_break"
782 );
783 }
784 }
785 pub fn with_compaction(
792 mut self,
793 summarizer: Arc<dyn LlmClient>,
794 store: Arc<nexo_memory::CompactionStore>,
795 runtime: CompactionRuntime,
796 ) -> Self {
797 self.compactor = Some(Arc::new(super::compaction::LlmCompactor::new(summarizer)));
798 self.compaction_store = Some(store);
799 self.compaction_runtime = runtime;
800 self
801 }
802 pub fn with_token_counter(mut self, counter: Arc<dyn nexo_llm::TokenCounter>) -> Self {
807 self.token_counter = Some(counter);
808 self
809 }
810 pub fn with_workspace_cache(
815 mut self,
816 cache: Arc<super::workspace_cache::WorkspaceCache>,
817 ) -> Self {
818 self.workspace_cache = Some(cache);
819 self
820 }
821 pub fn with_prompt_cache(mut self, enabled: bool) -> Self {
826 self.prompt_cache_enabled = enabled;
827 self
828 }
829 pub fn with_tool_policy(mut self, p: Arc<super::tool_policy::ToolPolicy>) -> Self {
836 let rel = p.relevance_config().clone();
837 if rel.enabled {
838 let tool_defs = self.tools.to_tool_defs();
839 let filter = super::tool_filter::ToolFilter::build(rel, &tool_defs);
840 self.tool_filter = Arc::new(tokio::sync::RwLock::new(Some(filter)));
841 }
842 self.tool_policy = p;
843 self
844 }
845 pub async fn rebuild_tool_filter(&self) {
848 let rel = self.tool_policy.relevance_config().clone();
849 if !rel.enabled {
850 *self.tool_filter.write().await = None;
851 return;
852 }
853 let tool_defs = self.tools.to_tool_defs();
854 let filter = super::tool_filter::ToolFilter::build(rel, &tool_defs);
855 *self.tool_filter.write().await = Some(filter);
856 }
857 pub fn with_max_iterations(mut self, n: usize) -> Self {
858 self.max_tool_iterations = n;
859 self
860 }
861 pub fn with_hooks(mut self, hooks: Arc<super::hook_registry::HookRegistry>) -> Self {
864 self.hooks = Some(hooks);
865 self
866 }
867 pub fn with_rate_limiter(mut self, rl: Arc<super::rate_limit::ToolRateLimiter>) -> Self {
871 self.rate_limiter = Some(rl);
872 self
873 }
874 pub fn with_schema_validator(
878 mut self,
879 v: Arc<super::schema_validator::ToolArgsValidator>,
880 ) -> Self {
881 self.schema_validator = Some(v);
882 self
883 }
884 async fn execute_one_call(
894 &self,
895 call: &nexo_llm::ToolCall,
896 msg: &InboundMessage,
897 ctx: &AgentContext,
898 ) -> ToolExecutionResult {
899 let args = inject_runtime_tool_args(&call.name, call.arguments.clone(), msg);
900 tracing::debug!(
901 agent_id = %ctx.agent_id,
902 session_id = %msg.session_id,
903 message_id = %msg.id,
904 tool = %call.name,
905 tool_call_id = %call.id,
906 "tool call dispatch"
907 );
908 {
916 let state = ctx.plan_mode.read().await;
917 if let Some(refusal) = crate::plan_mode::gate_tool_call(&state, &call.name, None) {
918 let body = serde_json::json!({
919 "is_error": true,
920 "kind": "plan_mode_refusal",
921 "refusal": refusal,
922 });
923 let err = format!("plan_mode: refused {} ({:?})", call.name, refusal.tool_kind);
924 tracing::info!(
925 agent_id = %ctx.agent_id,
926 tool = %call.name,
927 "plan_mode gate refused tool call"
928 );
929 return ToolExecutionResult {
930 result: body.to_string(),
931 tool_err: Some(err),
932 outcome: "plan_mode_refused",
933 duration_ms: 0,
934 sleep: None,
935 };
936 }
937 }
938 let effective_tools = ctx.effective_policy();
946 if !effective_tools.tool_allowed(&call.name) {
947 let msg_str = format!(
948 "tool `{}` is not available on this binding (agent `{}`)",
949 call.name, ctx.agent_id
950 );
951 return ToolExecutionResult {
952 result: msg_str.clone(),
953 tool_err: Some(msg_str),
954 outcome: "not_allowed",
955 duration_ms: 0,
956 sleep: None,
957 };
958 }
959 let mut skip_call = None;
961 if let Some(hooks) = &self.hooks {
962 let ev = serde_json::json!({
963 "agent_id": ctx.agent_id,
964 "session_id": msg.session_id.to_string(),
965 "tool_name": call.name,
966 "arguments": args,
967 });
968 if let super::hook_registry::HookOutcome::Aborted { plugin_id, reason } =
969 hooks.fire("before_tool_call", ev).await
970 {
971 skip_call = Some(format!(
972 "tool `{}` blocked by extension `{}`: {}",
973 call.name,
974 plugin_id,
975 reason.unwrap_or_else(|| "(no reason)".into())
976 ));
977 }
978 }
979 let started_tool = std::time::Instant::now();
980 let call_ctx = ctx.clone().with_session_id(msg.session_id);
981 let binding_id_owned = ctx.binding.as_ref().and_then(|b| b.binding_id.clone());
988 let per_binding_override = ctx
989 .effective
990 .as_ref()
991 .and_then(|p| p.tool_rate_limits.clone());
992 let rate_allowed = match &self.rate_limiter {
993 Some(rl) if skip_call.is_none() => {
994 rl.try_acquire_with_binding(
995 &ctx.agent_id,
996 binding_id_owned.as_deref(),
997 &call.name,
998 per_binding_override.as_ref(),
999 )
1000 .await
1001 }
1002 _ => true,
1003 };
1004 if !rate_allowed {
1005 let rps_for_marker = per_binding_override
1018 .as_ref()
1019 .and_then(|over| {
1020 over.patterns
1021 .iter()
1022 .find(|(p, _)| super::rate_limit::glob_matches(p, &call.name))
1023 .or_else(|| over.patterns.get_key_value("_default"))
1024 .map(|(_, spec)| spec.rps)
1025 })
1026 .unwrap_or(0.0);
1027 tracing::info!(
1028 agent_id = %ctx.agent_id,
1029 marker = %nexo_tool_meta::format_rate_limit_hit(
1030 &call.name,
1031 binding_id_owned.as_deref(),
1032 rps_for_marker,
1033 ),
1034 "tool call rate-limited"
1035 );
1036 }
1037 let schema_error: Option<String> = match &self.schema_validator {
1038 Some(v) if skip_call.is_none() && rate_allowed => {
1039 if let Some((def, _)) = self.tools.get(&call.name) {
1040 match v.validate(&def, &args) {
1041 Ok(()) => None,
1042 Err(errs) => Some(errs.join("; ")),
1043 }
1044 } else {
1045 None
1046 }
1047 }
1048 _ => None,
1049 };
1050 let cache_hit: Option<serde_json::Value> =
1051 if skip_call.is_none() && rate_allowed && schema_error.is_none() {
1052 self.tool_policy.cache_get(&ctx.agent_id, &call.name, &args)
1053 } else {
1054 None
1055 };
1056 let (result, tool_err, outcome, sleep) = match (skip_call, schema_error) {
1057 (Some(msg_str), _) => (
1058 msg_str,
1059 Some("blocked-by-hook".to_string()),
1060 "blocked",
1061 None,
1062 ),
1063 (None, _) if !rate_allowed => {
1064 let msg_str = format!(
1065 "rate limited: exceeded configured rps for tool '{}'",
1066 call.name
1067 );
1068 (msg_str.clone(), Some(msg_str), "rate_limited", None)
1069 }
1070 (None, Some(errs)) => {
1071 let msg = format!("invalid arguments: {errs}");
1072 (msg.clone(), Some(msg), "invalid_args", None)
1073 }
1074 (None, None) => {
1075 if let Some(v) = cache_hit {
1076 tracing::debug!(
1077 agent_id = %ctx.agent_id,
1078 tool = %call.name,
1079 "tool cache hit"
1080 );
1081 let sleep = sleep_signal_from_value(&v);
1082 (stringify_tool_result(&v), None, "cache_hit", sleep)
1083 } else {
1084 match self.tools.get(&call.name) {
1085 Some((_, handler)) => {
1086 let to = std::time::Duration::from_secs(
1090 self.tool_policy.parallel_config().call_timeout_secs,
1091 );
1092 match tokio::time::timeout(to, handler.call(&call_ctx, args.clone()))
1093 .await
1094 {
1095 Ok(Ok(v)) => {
1096 self.tool_policy.cache_put(
1097 &ctx.agent_id,
1098 &call.name,
1099 &args,
1100 v.clone(),
1101 );
1102 let sleep = sleep_signal_from_value(&v);
1103 (stringify_tool_result(&v), None, "ok", sleep)
1104 }
1105 Ok(Err(e)) => {
1106 (format!("error: {e}"), Some(e.to_string()), "error", None)
1107 }
1108 Err(_) => {
1109 let msg = format!(
1110 "timeout after {}s for tool '{}'",
1111 to.as_secs(),
1112 call.name
1113 );
1114 (msg.clone(), Some(msg), "timeout", None)
1115 }
1116 }
1117 }
1118 None => (
1119 format!("unknown tool: {}", call.name),
1120 Some(format!("unknown tool: {}", call.name)),
1121 "unknown",
1122 None,
1123 ),
1124 }
1125 }
1126 }
1127 };
1128 let duration_ms = started_tool.elapsed().as_millis() as u64;
1129 let preview: String = result.chars().take(160).collect::<String>();
1133 tracing::info!(
1134 agent_id = %ctx.agent_id,
1135 tool = %call.name,
1136 outcome,
1137 duration_ms,
1138 error = tool_err.as_deref().unwrap_or(""),
1139 result_preview = %preview,
1140 "tool executed"
1141 );
1142 ToolExecutionResult {
1143 result,
1144 tool_err,
1145 outcome,
1146 duration_ms,
1147 sleep,
1148 }
1149 }
1150 async fn run_turn(
1151 &self,
1152 ctx: &AgentContext,
1153 mut msg: InboundMessage,
1154 publish_reply: bool,
1155 ) -> anyhow::Result<RunTurnOutcome> {
1156 tracing::info!(
1157 agent_id = %ctx.agent_id,
1158 session_id = %msg.session_id,
1159 message_id = %msg.id,
1160 trigger = ?msg.trigger,
1161 source_plugin = %msg.source_plugin,
1162 publish_reply,
1163 "agent turn started"
1164 );
1165 let inbound_transform_tools = self.discover_inbound_transform_tools();
1172 let mut per_turn_system_addenda: Vec<String> = Vec::new();
1173 if !inbound_transform_tools.is_empty() {
1174 match self
1175 .run_tool_inbound_transforms(&inbound_transform_tools, &msg, ctx)
1176 .await
1177 {
1178 Ok(outcome) => {
1179 if let Some(new_text) = outcome.new_text {
1180 let preview: String = new_text.chars().take(400).collect();
1181 tracing::info!(
1182 agent_id = %ctx.agent_id,
1183 session_id = %msg.session_id,
1184 original_len = msg.text.len(),
1185 new_len = new_text.len(),
1186 new_text = %preview,
1187 "inbound transform rewrote text"
1188 );
1189 msg.text = new_text;
1190 }
1191 per_turn_system_addenda = outcome.system_addenda;
1192 }
1193 Err(e) => {
1194 tracing::warn!(
1195 agent_id = %ctx.agent_id,
1196 session_id = %msg.session_id,
1197 error = %e,
1198 "inbound transform chain rejected; continuing with original text"
1199 );
1200 }
1201 }
1202 }
1203 if let Some(hooks) = &self.hooks {
1206 let event = serde_json::json!({
1207 "agent_id": ctx.agent_id,
1208 "session_id": msg.session_id.to_string(),
1209 "text": msg.text,
1210 "source": msg.source_plugin,
1211 });
1212 if let super::hook_registry::HookOutcome::Aborted { plugin_id, reason } =
1213 hooks.fire("before_message", event).await
1214 {
1215 tracing::warn!(
1216 agent_id = %ctx.agent_id,
1217 session_id = %msg.session_id,
1218 message_id = %msg.id,
1219 ext = %plugin_id,
1220 reason = ?reason,
1221 "before_message hook aborted the turn",
1222 );
1223 return Ok(RunTurnOutcome::Reply(None));
1224 }
1225 }
1226 let mut session = ctx.sessions.get_or_create(msg.session_id, &ctx.agent_id);
1227 {
1239 let transcripts_dir = ctx.config.transcripts_dir.trim();
1240 if !transcripts_dir.is_empty() {
1241 let redactor = ctx
1242 .redactor
1243 .clone()
1244 .unwrap_or_else(|| std::sync::Arc::new(super::redaction::Redactor::disabled()));
1245 let mut writer = TranscriptWriter::with_extras(
1246 transcripts_dir,
1247 &ctx.agent_id,
1248 redactor,
1249 ctx.transcripts_index.clone(),
1250 )
1251 .with_tenant_id(ctx.config.tenant_id.clone());
1252 if let Some(ref em) = ctx.event_emitter {
1253 writer = writer.with_emitter(em.clone());
1254 }
1255 let user_entry = TranscriptEntry {
1256 timestamp: Utc::now(),
1257 role: TranscriptRole::User,
1258 content: msg.text.clone(),
1259 message_id: Some(msg.id),
1260 source_plugin: msg.source_plugin.clone(),
1261 sender_id: msg.sender_id.clone(),
1262 };
1263 if let Err(e) = writer.append_entry(msg.session_id, user_entry).await {
1264 tracing::warn!(
1265 agent_id = %ctx.agent_id,
1266 session_id = %msg.session_id,
1267 error = %e,
1268 "transcript append (user, early) failed"
1269 );
1270 }
1271 }
1272 }
1273 let mut prefix_messages: Vec<ChatMessage> = Vec::new();
1276 let mut workspace_section: Option<String> = None;
1285 let mut skills_section: Option<String> = None;
1286 let mut binding_glue_parts: Vec<String> = Vec::new();
1287 let mut channel_meta_parts: Vec<String> = Vec::new();
1288
1289 let workspace_path = ctx.config.workspace.trim();
1290 if !workspace_path.is_empty() {
1291 let scope = session_scope_for(&msg);
1292 let bundle_result = if let Some(cache) = self.workspace_cache.as_ref() {
1296 cache
1297 .get(
1298 std::path::Path::new(workspace_path),
1299 scope,
1300 &ctx.config.extra_docs,
1301 )
1302 .await
1303 .map(Some)
1304 } else {
1305 WorkspaceLoader::new(workspace_path)
1306 .load_with_extras(scope, &ctx.config.extra_docs)
1307 .await
1308 .map(|b| Some(std::sync::Arc::new(b)))
1309 };
1310 match bundle_result {
1311 Ok(Some(bundle)) => {
1312 if let Some(blocks) = bundle.render_system_blocks() {
1313 workspace_section = Some(blocks);
1314 }
1315 }
1316 Ok(None) => {}
1317 Err(e) => tracing::warn!(
1318 agent_id = %ctx.agent_id,
1319 workspace = workspace_path,
1320 error = %e,
1321 "workspace load failed — falling back to system_prompt only"
1322 ),
1323 }
1324 }
1325 let effective = ctx.effective_policy();
1331 if !effective.skills.is_empty() {
1332 let skills_dir = ctx.config.skills_dir.trim();
1333 if skills_dir.is_empty() {
1334 tracing::warn!(
1335 agent_id = %ctx.agent_id,
1336 "skills configured but skills_dir is empty; skipping skill injection"
1337 );
1338 } else {
1339 let loader = SkillLoader::new(skills_dir)
1340 .with_overrides(ctx.config.skill_overrides.clone())
1341 .with_tenant_id(ctx.config.tenant_id.clone())
1347 .with_plugin_roots(self.plugin_skill_roots.clone());
1354 let loaded = loader.load_many(&effective.skills).await;
1355 if let Some(blocks) = render_skill_blocks(&loaded) {
1356 skills_section = Some(blocks);
1357 }
1358 }
1359 }
1360 if let Some(peers) = ctx.peers.as_ref() {
1364 if let Some(block) = peers.render_for(&ctx.agent_id, &effective.allowed_delegates) {
1365 binding_glue_parts.push(block);
1366 }
1367 }
1368 let system_prompt = effective.system_prompt.trim();
1373 if !system_prompt.is_empty() {
1374 binding_glue_parts.push(system_prompt.to_string());
1375 }
1376 if let Some(lang) = effective.language.as_deref() {
1382 binding_glue_parts.push(format!(
1383 "# OUTPUT LANGUAGE\n\nRespond to the user in {lang}. \
1384 Workspace docs (IDENTITY, SOUL, MEMORY, USER, AGENTS) and \
1385 tool descriptions are in English — read them as-is, but \
1386 your turn-final reply to the user must be in {lang}."
1387 ));
1388 }
1389 if effective.link_understanding.enabled {
1397 if let Some(extractor) = ctx.link_extractor.as_ref() {
1398 let urls = crate::link_understanding::detect_urls(
1399 &msg.text,
1400 effective.link_understanding.max_links_per_turn,
1401 );
1402 if !urls.is_empty() {
1403 let cfg = effective.link_understanding.clone();
1404 let extractor = Arc::clone(extractor);
1405 let mut summaries = Vec::with_capacity(urls.len());
1406 for u in urls {
1407 if let Some(s) = extractor.fetch(&u, &cfg).await {
1408 summaries.push(s);
1409 }
1410 }
1411 let block = crate::link_understanding::render_block(&summaries);
1412 if !block.is_empty() {
1413 channel_meta_parts.push(block);
1414 }
1415 }
1416 }
1417 }
1418
1419 if let Some(sender) = msg.sender_id.as_deref() {
1426 if !sender.is_empty() {
1427 channel_meta_parts.push(format!(
1428 "# CONTEXTO DEL CANAL\n\nRemitente ({}): {}\n\nUsá este identificador como \"número del cliente\" cuando un prompt hable de capturar el teléfono.",
1429 msg.source_plugin,
1430 sender
1431 ));
1432 }
1433 }
1434 if let Some(hint) = crate::plan_mode::plan_mode_system_hint(&*ctx.plan_mode.read().await) {
1437 channel_meta_parts.push(hint.to_string());
1438 }
1439 if let Some(hint) =
1442 crate::agent::proactive_hint::proactive_system_hint(ctx.proactive_enabled)
1443 {
1444 channel_meta_parts.push(hint.to_string());
1445 }
1446 if let Some(hint) =
1447 crate::agent::proactive_hint::coordinator_system_hint(ctx.binding_role.as_deref())
1448 {
1449 channel_meta_parts.push(hint.to_string());
1450 }
1451 let assistant_addendum_appended = ctx.assistant.should_append_addendum();
1457 if assistant_addendum_appended {
1458 channel_meta_parts.push((*ctx.assistant.addendum).clone());
1459 }
1460 if let Some(section) = crate::agent::send_user_message_tool::brief_system_section(
1464 ctx.config.brief.as_ref(),
1465 assistant_addendum_appended,
1466 ) {
1467 channel_meta_parts.push(section.to_string());
1468 }
1469 let prompt_inputs = super::prompt_assembly::PromptInputs {
1470 workspace: workspace_section,
1471 skills: skills_section,
1472 binding_glue: if binding_glue_parts.is_empty() {
1473 None
1474 } else {
1475 Some(binding_glue_parts.join("\n\n"))
1476 },
1477 channel_meta: if channel_meta_parts.is_empty() {
1478 None
1479 } else {
1480 Some(channel_meta_parts.join("\n\n"))
1481 },
1482 };
1483 let mut system_blocks = super::prompt_assembly::build_blocks(prompt_inputs);
1484 let registry_for_deferred = ctx.effective_tools.as_ref().unwrap_or(&self.tools);
1487 if let Some(summary) = registry_for_deferred.deferred_tools_summary() {
1488 system_blocks.push(nexo_llm::PromptBlock::plain("deferred_tools", summary));
1489 }
1490 if !per_turn_system_addenda.is_empty() {
1496 let merged = per_turn_system_addenda.join("\n\n");
1497 system_blocks.push(nexo_llm::PromptBlock::plain("per_turn_addendum", merged));
1498 }
1499 let flat_system = nexo_llm::flatten_blocks(&system_blocks);
1504 if !flat_system.is_empty() {
1505 prefix_messages.push(ChatMessage::system(flat_system));
1506 }
1507 if session.history.is_empty() {
1508 if let Some(ref memory) = ctx.memory {
1509 if let Ok(past) = memory.load_interactions(msg.session_id, 20).await {
1510 for i in &past {
1511 match i.role.as_str() {
1512 "user" => prefix_messages.push(ChatMessage::user(&i.content)),
1513 "assistant" => prefix_messages.push(ChatMessage::assistant(&i.content)),
1514 _ => {}
1515 }
1516 }
1517 }
1518 }
1519 }
1520 session.push(Interaction::new(Role::User, &msg.text));
1521
1522 let live_compaction = ctx
1538 .context_optimization
1539 .map(|co| co.compaction)
1540 .unwrap_or(true);
1541 if let (true, true, Some(compactor), Some(compaction_store)) = (
1542 self.compaction_runtime.enabled,
1543 live_compaction,
1544 self.compactor.as_ref(),
1545 self.compaction_store.as_ref(),
1546 ) {
1547 let est = if let Some(counter) = self.token_counter.as_ref() {
1548 let blocks_n = counter.count_blocks(&system_blocks).await.unwrap_or(0);
1549 let hist_msgs: Vec<ChatMessage> = session
1550 .history
1551 .iter()
1552 .filter_map(|i| match i.role {
1553 Role::User => Some(ChatMessage::user(&i.content)),
1554 Role::Assistant => Some(ChatMessage::assistant(&i.content)),
1555 Role::Tool => None,
1556 })
1557 .collect();
1558 let msg_n = counter
1559 .count_messages(&effective.model.model, &hist_msgs)
1560 .await
1561 .unwrap_or(0);
1562 blocks_n.saturating_add(msg_n)
1563 } else {
1564 0
1565 };
1566
1567 let token_trigger = est >= self.compaction_runtime.compact_at_tokens;
1569 let age_minutes = chrono::Utc::now()
1570 .signed_duration_since(session.created_at)
1571 .num_minutes()
1572 .max(0) as u64;
1573 let age_trigger = self.compaction_runtime.auto_max_age_minutes > 0
1574 && age_minutes >= self.compaction_runtime.auto_max_age_minutes;
1575
1576 let failures = self
1578 .compaction_failures
1579 .load(std::sync::atomic::Ordering::Relaxed);
1580 let breaker_tripped = self.compaction_runtime.auto_max_consecutive_failures > 0
1581 && failures >= self.compaction_runtime.auto_max_consecutive_failures;
1582
1583 let current_turns = session.history.len() as u32;
1585 let last_turn: Option<u32> = *self.compaction_last_turn.lock().unwrap();
1586 let min_gap_ok = match last_turn {
1587 Some(last) => {
1588 current_turns.saturating_sub(last)
1589 >= self.compaction_runtime.auto_min_turns_between
1590 }
1591 None => true,
1592 };
1593
1594 let should_compact = (token_trigger || age_trigger) && !breaker_tripped && min_gap_ok;
1595
1596 if should_compact {
1597 if let Some(boundary) = super::compaction::find_safe_boundary(
1598 &session.history,
1599 self.compaction_runtime.tail_keep_chars,
1600 ) {
1601 let store = compaction_store;
1602 let acquired = store
1603 .try_acquire_lock(
1604 session.id,
1605 &format!("pid:{}", std::process::id()),
1606 self.compaction_runtime.lock_ttl_seconds,
1607 )
1608 .await
1609 .unwrap_or(false);
1610 if acquired {
1611 let started = std::time::Instant::now();
1612 let model = if self.compaction_runtime.summarizer_model.is_empty() {
1613 effective.model.model.clone()
1614 } else {
1615 self.compaction_runtime.summarizer_model.clone()
1616 };
1617 let budget = super::compaction::CompactionBudget {
1618 target_tokens: self.compaction_runtime.compact_at_tokens,
1619 tail_keep_tokens: (self.compaction_runtime.tail_keep_chars / 4) as u32,
1620 model: model.clone(),
1621 };
1622 let result = compactor.compact(&session.history, boundary, &budget).await;
1623 let elapsed_ms = started.elapsed().as_millis() as u64;
1624 match result {
1625 Ok(r) => {
1626 let row = nexo_memory::CompactionRow {
1627 session_id: session.id.to_string(),
1628 compacted_at: chrono::Utc::now().timestamp_millis(),
1629 head_turn_count: r.head_turns_summarized as i64,
1630 tail_start_index: r.tail_start_index as i64,
1631 summary: r.summary.clone(),
1632 model_used: model,
1633 input_tokens: r.input_tokens as i64,
1634 output_tokens: r.output_tokens as i64,
1635 };
1636 let insert_ok = match store.insert(&row).await {
1637 Ok(()) => true,
1638 Err(e) => {
1639 tracing::warn!(
1640 error = %e,
1641 session_id = %session.id,
1642 "compaction succeeded but persist failed; \
1643 applying anyway and continuing"
1644 );
1645 false
1646 }
1647 };
1648 if insert_ok {
1654 if let Some(hook) = &self.mutation_hook {
1655 hook.on_mutation(
1656 &ctx.agent_id,
1657 &self.mutation_tenant,
1658 nexo_driver_types::MemoryMutationScope::SqliteCompactions,
1659 nexo_driver_types::MemoryMutationOp::Insert,
1660 &session.id.to_string(),
1661 )
1662 .await;
1663 }
1664 }
1665 session.apply_compaction(r.summary, r.tail_start_index);
1666 self.compaction_failures
1668 .store(0, std::sync::atomic::Ordering::Relaxed);
1669 *self.compaction_last_turn.lock().unwrap() =
1670 Some(session.history.len() as u32);
1671 crate::telemetry::observe_compaction(
1672 &ctx.agent_id,
1673 "ok",
1674 elapsed_ms,
1675 );
1676 tracing::info!(
1677 session_id = %session.id,
1678 head_turns = r.head_turns_summarized,
1679 duration_ms = elapsed_ms,
1680 trigger = if token_trigger { "token" } else { "age" },
1681 age_minutes = age_minutes,
1682 "compaction applied"
1683 );
1684 }
1685 Err(e) => {
1686 let new_failures = self
1688 .compaction_failures
1689 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1690 .saturating_add(1);
1691 crate::telemetry::observe_compaction(
1692 &ctx.agent_id,
1693 "failed",
1694 elapsed_ms,
1695 );
1696 tracing::warn!(
1697 error = %e,
1698 session_id = %session.id,
1699 consecutive_failures = new_failures,
1700 "compaction failed — continuing with original history"
1701 );
1702 }
1703 }
1704 let _ = store.release_lock(session.id).await;
1705 } else {
1706 crate::telemetry::observe_compaction(&ctx.agent_id, "lock_held", 0);
1707 tracing::debug!(
1708 session_id = %session.id,
1709 "compaction lock held by another holder; skipping"
1710 );
1711 }
1712 } else {
1713 crate::telemetry::observe_compaction(&ctx.agent_id, "no_boundary", 0);
1714 }
1715 }
1716 }
1717
1718 let mut messages: Vec<ChatMessage> = prefix_messages;
1721 if let Some(summary) = session.compacted_summary.as_ref() {
1722 messages.push(ChatMessage::user(format!(
1727 "<COMPACTED SUMMARY OF EARLIER TURNS>\n{}\n</COMPACTED SUMMARY>",
1728 summary
1729 )));
1730 messages.push(ChatMessage::assistant(
1731 "Got it — continuing from the summary above.",
1732 ));
1733 }
1734 messages.extend(session.history.iter().filter_map(|i| match i.role {
1735 Role::User => Some(ChatMessage::user(&i.content)),
1736 Role::Assistant => Some(ChatMessage::assistant(&i.content)),
1737 Role::Tool => None,
1738 }));
1739 if let Some(media) = msg.media.as_ref() {
1743 if let Some(att) = build_media_attachment(media) {
1744 if let Some(last_user) = messages
1745 .iter_mut()
1746 .rev()
1747 .find(|m| matches!(m.role, ChatRole::User))
1748 {
1749 last_user.attachments.push(att);
1750 }
1751 }
1752 }
1753 let effective_policy = ctx.effective_policy();
1763 let model = effective_policy.model.model.clone();
1764 let tool_defs: Vec<_> = match ctx.effective_tools.as_ref() {
1771 Some(pre) => pre.to_tool_defs_non_deferred(),
1772 None => self
1773 .tools
1774 .to_tool_defs_non_deferred()
1775 .into_iter()
1776 .filter(|d| effective_policy.tool_allowed(&d.name))
1777 .collect(),
1778 };
1779 let filtered_tools = {
1787 let filter_guard = self.tool_filter.read().await;
1788 match filter_guard.as_ref() {
1789 Some(filter) if filter.enabled() => {
1790 let mut query = String::with_capacity(msg.text.len() + 256);
1791 query.push_str(&msg.text);
1792 const CTX_LOOKBACK: usize = 3;
1796 for i in session.history.iter().rev().take(CTX_LOOKBACK) {
1797 query.push(' ');
1798 query.push_str(&i.content);
1799 }
1800 let picked = filter.filter(&query, &tool_defs);
1801 tracing::info!(
1802 agent_id = %ctx.agent_id,
1803 session_id = %msg.session_id,
1804 full = tool_defs.len(),
1805 kept = picked.len(),
1806 "tool relevance filter applied"
1807 );
1808 picked
1809 }
1810 _ => tool_defs.clone(),
1811 }
1812 };
1813 let mut reply_text: Option<String> = None;
1814 let mut sleep_signal: Option<SleepSignal> = None;
1815 for iteration in 0..self.max_tool_iterations {
1816 let mut messages_for_send = messages.clone();
1821 if live_compaction && self.compaction_runtime.micro_threshold_bytes > 0 {
1822 let stats = if self.compaction_runtime.micro_model.is_empty() {
1823 super::compaction::clear_large_compactable_tool_results(
1824 &mut messages_for_send,
1825 self.compaction_runtime.micro_threshold_bytes,
1826 )
1827 } else {
1828 let budget = super::compaction::MicroCompactBudget {
1829 threshold_bytes: self.compaction_runtime.micro_threshold_bytes,
1830 summary_max_chars: self.compaction_runtime.micro_summary_max_chars,
1831 model: self.compaction_runtime.micro_model.clone(),
1832 };
1833 let stats = super::compaction::microcompact_large_tool_results(
1834 &mut messages_for_send,
1835 self.llm.as_ref(),
1836 &budget,
1837 )
1838 .await;
1839 if stats.failed > 0 {
1840 super::compaction::clear_large_compactable_tool_results(
1841 &mut messages_for_send,
1842 self.compaction_runtime.micro_threshold_bytes,
1843 )
1844 } else {
1845 stats
1846 }
1847 };
1848 if stats.compacted > 0 {
1849 crate::telemetry::observe_compaction(
1850 &ctx.agent_id,
1851 "tool_result_microcompact",
1852 0,
1853 );
1854 tracing::info!(
1855 agent_id = %ctx.agent_id,
1856 compacted = stats.compacted,
1857 original_bytes = stats.original_bytes,
1858 compacted_bytes = stats.compacted_bytes,
1859 "microcompacted tool results before LLM request"
1860 );
1861 }
1862 }
1863 if self.compaction_runtime.tool_result_max_chars > 0 {
1864 let truncated = super::compaction::truncate_large_tool_results(
1865 &mut messages_for_send,
1866 self.compaction_runtime.tool_result_max_chars,
1867 );
1868 if truncated > 0 {
1869 crate::telemetry::observe_compaction(&ctx.agent_id, "tool_result_truncated", 0);
1870 }
1871 }
1872 let mut req = ChatRequest::new(&model, messages_for_send);
1873 req.tools = filtered_tools.clone();
1874 let live_prompt_cache = ctx
1879 .context_optimization
1880 .map(|co| co.prompt_cache)
1881 .unwrap_or(true);
1882 if self.prompt_cache_enabled && live_prompt_cache {
1883 req.system_blocks = system_blocks.clone();
1884 req.cache_tools = !filtered_tools.is_empty();
1885 }
1886 tracing::debug!(
1887 agent_id = %ctx.agent_id,
1888 session_id = %msg.session_id,
1889 message_id = %msg.id,
1890 iteration,
1891 "llm chat request"
1892 );
1893 let provider = self.llm.provider();
1894 let model_label = self.llm.model_id();
1895 inc_llm_requests_total(&ctx.agent_id, provider, model_label);
1896 let estimated_tokens: u32 = if let Some(counter) = self.token_counter.as_ref() {
1903 let blocks_total = match counter.count_blocks(&system_blocks).await {
1904 Ok(n) => n,
1905 Err(e) => {
1906 tracing::debug!(error = %e, "pre-flight count_blocks failed");
1907 0
1908 }
1909 };
1910 let messages_total = match counter.count_messages(&model, &messages).await {
1911 Ok(n) => n,
1912 Err(e) => {
1913 tracing::debug!(error = %e, "pre-flight count_messages failed");
1914 0
1915 }
1916 };
1917 let total = blocks_total.saturating_add(messages_total);
1918 observe_prompt_tokens_estimated(
1919 &ctx.agent_id,
1920 provider,
1921 model_label,
1922 total,
1923 counter.is_exact(),
1924 );
1925 total
1926 } else {
1927 0
1928 };
1929 let cache_break_req_ctx =
1930 CacheBreakRequestContext::from_request(provider, model_label, &req);
1931 let started_at = std::time::Instant::now();
1932 let response = collect_stream(self.llm.stream(req).await?).await?;
1936 observe_llm_latency_ms(
1937 &ctx.agent_id,
1938 provider,
1939 model_label,
1940 started_at.elapsed().as_millis() as u64,
1941 );
1942 if let Some(cu) = response.cache_usage.as_ref() {
1946 observe_cache_usage(&ctx.agent_id, provider, model_label, cu);
1947 }
1948 let cache_read_input_tokens = response
1949 .cache_usage
1950 .as_ref()
1951 .map(|u| u.cache_read_input_tokens)
1952 .unwrap_or(0);
1953 let cache_creation_input_tokens = response
1954 .cache_usage
1955 .as_ref()
1956 .map(|u| u.cache_creation_input_tokens)
1957 .unwrap_or(0);
1958 self.maybe_log_cache_break(
1959 &ctx.agent_id,
1960 &msg.session_id.to_string(),
1961 cache_break_req_ctx,
1962 cache_read_input_tokens,
1963 cache_creation_input_tokens,
1964 );
1965 if estimated_tokens > 0 && response.usage.prompt_tokens > 0 {
1971 observe_prompt_tokens_drift(
1972 &ctx.agent_id,
1973 provider,
1974 model_label,
1975 estimated_tokens,
1976 response.usage.prompt_tokens,
1977 );
1978 }
1979 match response.content {
1980 ResponseContent::Text(text) => {
1981 reply_text = Some(text.clone());
1982 messages.push(ChatMessage::assistant(&text));
1983 break;
1984 }
1985 ResponseContent::ToolCalls(calls) => {
1986 let tool_names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect();
1987 tracing::info!(
1988 agent_id = %ctx.agent_id,
1989 session_id = %msg.session_id,
1990 message_id = %msg.id,
1991 tool_calls = calls.len(),
1992 tool_names = ?tool_names,
1993 iteration,
1994 "llm requested tool calls"
1995 );
1996 messages.push(ChatMessage::assistant_tool_calls(
2002 calls.clone(),
2003 String::new(),
2004 ));
2005 use futures::stream::{FuturesUnordered, StreamExt};
2013 use std::collections::HashMap;
2014 use std::pin::Pin;
2015 type BoxedCallFut<'a> = Pin<
2016 Box<
2017 dyn std::future::Future<Output = (usize, ToolExecutionResult)>
2018 + Send
2019 + 'a,
2020 >,
2021 >;
2022 let (par_idx, seq_idx): (Vec<usize>, Vec<usize>) = (0..calls.len())
2023 .partition(|i| self.tool_policy.is_parallel_safe(&calls[*i].name));
2024 let par_cap = self.tool_policy.parallel_config().max_in_flight;
2025 let mut in_flight: FuturesUnordered<BoxedCallFut<'_>> = FuturesUnordered::new();
2026 let mut results_by_idx: HashMap<usize, ToolExecutionResult> = HashMap::new();
2027 let mut par_queue = par_idx.into_iter();
2028 let msg_ref: &InboundMessage = &msg;
2029 let calls_ref: &[nexo_llm::ToolCall] = &calls;
2030 while in_flight.len() < par_cap.max(1) {
2032 match par_queue.next() {
2033 Some(i) => {
2034 let c = &calls_ref[i];
2035 let fut: BoxedCallFut<'_> = Box::pin(async move {
2036 (i, self.execute_one_call(c, msg_ref, ctx).await)
2037 });
2038 in_flight.push(fut);
2039 }
2040 None => break,
2041 }
2042 }
2043 while let Some((i, r)) = in_flight.next().await {
2044 results_by_idx.insert(i, r);
2045 if let Some(next_i) = par_queue.next() {
2046 let c = &calls_ref[next_i];
2047 let fut: BoxedCallFut<'_> = Box::pin(async move {
2048 (next_i, self.execute_one_call(c, msg_ref, ctx).await)
2049 });
2050 in_flight.push(fut);
2051 }
2052 }
2053 for i in seq_idx {
2054 let c = &calls[i];
2055 let r = self.execute_one_call(c, &msg, ctx).await;
2056 results_by_idx.insert(i, r);
2057 }
2058 for (i, call) in calls.iter().enumerate() {
2063 let tool_result = results_by_idx.remove(&i).unwrap_or_else(|| {
2069 tracing::error!(
2070 session_id = %msg.session_id,
2071 tool = %call.name,
2072 index = i,
2073 "tool call dispatch slot missing — emitting synthetic error"
2074 );
2075 ToolExecutionResult {
2076 result: serde_json::json!({
2077 "error": "internal: tool dispatch slot missing",
2078 })
2079 .to_string(),
2080 tool_err: Some("tool dispatch slot missing".to_string()),
2081 outcome: "error",
2082 duration_ms: 0,
2083 sleep: None,
2084 }
2085 });
2086 crate::telemetry::inc_tool_calls_total(
2087 &ctx.agent_id,
2088 &call.name,
2089 tool_result.outcome,
2090 );
2091 crate::telemetry::observe_tool_latency_ms(
2092 &ctx.agent_id,
2093 &call.name,
2094 tool_result.duration_ms,
2095 );
2096 if let Some(hooks) = &self.hooks {
2097 let ev = serde_json::json!({
2098 "agent_id": ctx.agent_id,
2099 "session_id": msg.session_id.to_string(),
2100 "tool_name": call.name,
2101 "duration_ms": tool_result.duration_ms,
2102 "result": tool_result.result.clone(),
2103 "error": tool_result.tool_err.clone(),
2104 });
2105 if let crate::agent::HookOutcome::Aborted { plugin_id, reason } =
2106 hooks.fire("after_tool_call", ev).await
2107 {
2108 tracing::warn!(
2109 plugin = %plugin_id,
2110 reason = ?reason,
2111 hook = "after_tool_call",
2112 "extension hook aborted the chain"
2113 );
2114 }
2115 }
2116 if let Some(sleep) = tool_result.sleep.clone() {
2117 sleep_signal = Some(sleep);
2118 }
2119 messages.push(ChatMessage::tool_result(
2120 &call.id,
2121 &call.name,
2122 tool_result.result,
2123 ));
2124 }
2125 if sleep_signal.is_some() {
2126 tracing::info!(
2127 agent_id = %ctx.agent_id,
2128 session_id = %msg.session_id,
2129 message_id = %msg.id,
2130 "sleep tool requested proactive wake; stopping llm loop"
2131 );
2132 break;
2133 }
2134 if iteration + 1 >= self.max_tool_iterations {
2135 tracing::warn!(
2136 session_id = %msg.session_id,
2137 "max tool iterations reached without text response"
2138 );
2139 break;
2140 }
2141 }
2142 }
2143 }
2144 if let Some(ref text) = reply_text {
2145 session.push(Interaction::new(Role::Assistant, text));
2146 }
2147 ctx.sessions.update(session);
2148 if let Some(ref memory) = ctx.memory {
2150 let _ = memory
2151 .save_interaction(msg.session_id, &ctx.agent_id, "user", &msg.text)
2152 .await;
2153 if let Some(ref text) = reply_text {
2154 let _ = memory
2155 .save_interaction(msg.session_id, &ctx.agent_id, "assistant", text)
2156 .await;
2157 }
2158 }
2159 let transcripts_dir = ctx.config.transcripts_dir.trim();
2163 if !transcripts_dir.is_empty() {
2164 let redactor = ctx
2165 .redactor
2166 .clone()
2167 .unwrap_or_else(|| std::sync::Arc::new(super::redaction::Redactor::disabled()));
2168 let mut writer = TranscriptWriter::with_extras(
2169 transcripts_dir,
2170 &ctx.agent_id,
2171 redactor,
2172 ctx.transcripts_index.clone(),
2173 )
2174 .with_tenant_id(ctx.config.tenant_id.clone());
2179 if let Some(ref em) = ctx.event_emitter {
2188 writer = writer.with_emitter(em.clone());
2189 }
2190 if let Some(ref text) = reply_text {
2197 let assistant_entry = TranscriptEntry {
2198 timestamp: Utc::now(),
2199 role: TranscriptRole::Assistant,
2200 content: text.clone(),
2201 message_id: None,
2202 source_plugin: msg.source_plugin.clone(),
2203 sender_id: None,
2204 };
2205 if let Err(e) = writer.append_entry(msg.session_id, assistant_entry).await {
2206 tracing::warn!(
2207 agent_id = %ctx.agent_id,
2208 session_id = %msg.session_id,
2209 error = %e,
2210 "transcript append (assistant) failed"
2211 );
2212 }
2213 }
2214 }
2215 if publish_reply {
2216 if let Some(text) = reply_text.clone() {
2217 let plugin = if msg.source_plugin.is_empty() {
2218 "default"
2219 } else {
2220 &msg.source_plugin
2221 };
2222 let topic = match msg.source_instance.as_deref() {
2228 Some(inst) if !inst.is_empty() => {
2229 format!("plugin.outbound.{}.{}", plugin, inst)
2230 }
2231 _ => format!("plugin.outbound.{}", plugin),
2232 };
2233 let resolved_language = ctx.effective_policy().language.clone();
2241 let transform_ctx = nexo_tool_meta::reply_kind::OutboundReplyContext {
2242 agent_id: ctx.agent_id.clone(),
2243 session_id: msg.session_id.to_string(),
2244 channel: plugin.to_string(),
2245 instance: msg.source_instance.clone(),
2246 recipient: msg.sender_id.clone(),
2247 tenant_id: ctx.config.tenant_id.clone(),
2248 conversation_key: format!("{}:session:{}", ctx.agent_id, msg.session_id),
2249 language: resolved_language,
2250 };
2251 let mut current_reply =
2252 nexo_tool_meta::reply_kind::OutboundReplyKind::text(text.clone());
2253 if !self.reply_transform_chain.is_empty() {
2256 match self
2257 .reply_transform_chain
2258 .run(&transform_ctx, current_reply.clone())
2259 .await
2260 {
2261 Ok(r) => current_reply = r,
2262 Err(e) => {
2263 tracing::warn!(
2264 agent_id = %ctx.agent_id,
2265 session_id = %msg.session_id,
2266 error = %e,
2267 "reply transform chain rejected reply; dropping"
2268 );
2269 return Ok(RunTurnOutcome::Reply(reply_text));
2270 }
2271 }
2272 }
2273 let transform_tools = self.discover_reply_transform_tools();
2279 if !transform_tools.is_empty() {
2280 match self
2281 .run_tool_reply_transforms(
2282 &transform_tools,
2283 &transform_ctx,
2284 current_reply,
2285 ctx,
2286 )
2287 .await
2288 {
2289 Ok(r) => current_reply = r,
2290 Err(e) => {
2291 tracing::warn!(
2292 agent_id = %ctx.agent_id,
2293 session_id = %msg.session_id,
2294 error = %e,
2295 "tool reply transform rejected reply; dropping"
2296 );
2297 return Ok(RunTurnOutcome::Reply(reply_text));
2298 }
2299 }
2300 }
2301 let final_reply = current_reply;
2302 let payload =
2303 build_outbound_payload(&final_reply, msg.sender_id.as_deref(), msg.session_id);
2304 let mut event = Event::new(&topic, &ctx.agent_id, payload);
2305 event.session_id = Some(msg.session_id);
2306 ctx.broker.publish(&topic, event).await?;
2307 tracing::info!(
2308 agent_id = %ctx.agent_id,
2309 session_id = %msg.session_id,
2310 message_id = %msg.id,
2311 topic = %topic,
2312 reply_kind = final_reply.kind_label(),
2313 "agent reply published"
2314 );
2315 }
2316 }
2317 if let (Some(hooks), Some(text_out)) = (&self.hooks, reply_text.as_ref()) {
2320 let ev = serde_json::json!({
2321 "agent_id": ctx.agent_id,
2322 "session_id": msg.session_id.to_string(),
2323 "text_in": msg.text,
2324 "text_out": text_out,
2325 });
2326 if let crate::agent::HookOutcome::Aborted { plugin_id, reason } =
2327 hooks.fire("after_message", ev).await
2328 {
2329 tracing::warn!(
2330 plugin = %plugin_id,
2331 reason = ?reason,
2332 hook = "after_message",
2333 "extension hook aborted the chain"
2334 );
2335 }
2336 }
2337 tracing::info!(
2338 agent_id = %ctx.agent_id,
2339 session_id = %msg.session_id,
2340 message_id = %msg.id,
2341 produced_reply = reply_text.is_some(),
2342 sleep_requested = sleep_signal.is_some(),
2343 "agent turn finished"
2344 );
2345
2346 if let Some(extractor) = &self.memory_extractor {
2357 extractor.tick();
2358 if let (Some(dir), Some(text)) = (&self.memory_dir, reply_text.as_ref()) {
2359 let goal_id = GoalId(msg.session_id);
2360 Arc::clone(extractor).extract(goal_id, 0, text.clone(), dir.clone());
2361 }
2362 }
2363
2364 if let Some(sleep) = sleep_signal {
2365 Ok(RunTurnOutcome::Sleep {
2366 duration_ms: sleep.duration_ms,
2367 reason: sleep.reason,
2368 })
2369 } else {
2370 Ok(RunTurnOutcome::Reply(reply_text))
2371 }
2372 }
2373}
2374#[async_trait]
2375impl AgentBehavior for LlmAgentBehavior {
2376 async fn on_heartbeat(&self, ctx: &AgentContext) -> anyhow::Result<()> {
2377 tracing::debug!(agent_id = %ctx.agent_id, "heartbeat tick");
2378 let Some(memory) = ctx.memory.as_ref() else {
2379 return Ok(());
2380 };
2381 let due = memory
2382 .claim_due_reminders(&ctx.agent_id, Utc::now(), 32)
2383 .await?;
2384 for reminder in due {
2385 let topic = format!("plugin.outbound.{}", reminder.plugin);
2386 let payload = serde_json::json!({
2387 "to": reminder.recipient,
2388 "text": reminder.message,
2389 "session_id": reminder.session_id,
2390 });
2391 let mut event = Event::new(&topic, &ctx.agent_id, payload);
2392 event.session_id = Some(reminder.session_id);
2393 if let Err(e) = ctx.broker.publish(&topic, event).await {
2394 let _ = memory.release_reminder_claim(reminder.id).await;
2395 return Err(e.into());
2396 }
2397 let marked = memory.mark_reminder_delivered(reminder.id).await?;
2398 if marked {
2399 tracing::info!(
2400 agent_id = %ctx.agent_id,
2401 reminder_id = %reminder.id,
2402 plugin = %reminder.plugin,
2403 "delivered due reminder"
2404 );
2405 }
2406 }
2407 let due_followups = memory
2408 .claim_due_email_followups(&ctx.agent_id, Utc::now(), 16)
2409 .await?;
2410 for followup in due_followups {
2411 let attempt_number = followup.attempts.saturating_add(1);
2412 let flow_id = followup.flow_id;
2413 let mut tick = InboundMessage::new(
2414 followup.session_id,
2415 &ctx.agent_id,
2416 build_followup_tick_prompt(&followup, attempt_number),
2417 );
2418 tick.trigger = RunTrigger::Tick;
2419 tick.source_plugin = "followup".to_string();
2420 tick.source_instance = followup.source_instance.clone();
2421 tick.priority = MessagePriority::Later;
2422 tick.inbound =
2425 Some(nexo_tool_meta::InboundMessageMeta::internal_system().with_ts(Utc::now()));
2426
2427 match self.run_turn(ctx, tick, false).await {
2428 Ok(_) => {
2429 let exhausted = attempt_number >= followup.max_attempts;
2430 let next_check = if exhausted {
2431 None
2432 } else {
2433 Some(Utc::now() + secs_to_chrono(followup.check_every_secs))
2434 };
2435 let applied = memory
2436 .advance_email_followup_attempt(flow_id, next_check, None)
2437 .await?;
2438 if exhausted && applied {
2439 tracing::info!(
2440 agent_id = %ctx.agent_id,
2441 flow_id = %flow_id,
2442 attempts = followup.max_attempts,
2443 "email follow-up exhausted max attempts"
2444 );
2445 } else if !applied {
2446 tracing::debug!(
2447 agent_id = %ctx.agent_id,
2448 flow_id = %flow_id,
2449 "email follow-up no longer active after autonomous turn"
2450 );
2451 }
2452 }
2453 Err(e) => {
2454 let next_check = Utc::now() + secs_to_chrono(followup.check_every_secs);
2455 let _ = memory
2456 .requeue_email_followup_after_error(flow_id, next_check, &e.to_string())
2457 .await;
2458 tracing::warn!(
2459 agent_id = %ctx.agent_id,
2460 flow_id = %flow_id,
2461 error = %e,
2462 "email follow-up turn failed; re-queued"
2463 );
2464 }
2465 }
2466 }
2467 Ok(())
2468 }
2469 async fn on_message_control(
2470 &self,
2471 ctx: &AgentContext,
2472 msg: InboundMessage,
2473 ) -> anyhow::Result<AgentTurnControl> {
2474 match self.run_turn(ctx, msg, true).await? {
2475 RunTurnOutcome::Reply(_) => Ok(AgentTurnControl::Done),
2476 RunTurnOutcome::Sleep {
2477 duration_ms,
2478 reason,
2479 } => Ok(AgentTurnControl::Sleep {
2480 duration_ms,
2481 reason,
2482 }),
2483 }
2484 }
2485 async fn on_message(&self, ctx: &AgentContext, msg: InboundMessage) -> anyhow::Result<()> {
2486 self.run_turn(ctx, msg, true).await?;
2487 Ok(())
2488 }
2489 async fn decide(&self, ctx: &AgentContext, msg: &InboundMessage) -> anyhow::Result<String> {
2490 match self.run_turn(ctx, msg.clone(), false).await? {
2491 RunTurnOutcome::Reply(reply) => Ok(reply.unwrap_or_default()),
2492 RunTurnOutcome::Sleep { .. } => Ok(String::new()),
2493 }
2494 }
2495 async fn on_event(&self, _ctx: &AgentContext, _event: Event) -> anyhow::Result<()> {
2496 Ok(())
2497 }
2498}
2499fn build_media_attachment(media: &super::types::InboundMedia) -> Option<Attachment> {
2506 let kind_hint = media.kind.as_str();
2507 let mime_hint = media.mime_type.as_deref();
2508 let (att_kind, mime) = if kind_hint == "photo"
2509 || kind_hint == "sticker"
2510 || mime_hint.map(|m| m.starts_with("image/")).unwrap_or(false)
2511 {
2512 (
2513 "image",
2514 mime_hint
2515 .map(str::to_string)
2516 .unwrap_or_else(|| guess_mime(&media.path, "image/jpeg")),
2517 )
2518 } else if kind_hint == "voice"
2519 || kind_hint == "audio"
2520 || mime_hint.map(|m| m.starts_with("audio/")).unwrap_or(false)
2521 {
2522 (
2523 "audio",
2524 mime_hint
2525 .map(str::to_string)
2526 .unwrap_or_else(|| guess_mime(&media.path, "audio/ogg")),
2527 )
2528 } else if kind_hint == "video"
2529 || kind_hint == "video_note"
2530 || kind_hint == "animation"
2531 || mime_hint.map(|m| m.starts_with("video/")).unwrap_or(false)
2532 {
2533 (
2534 "video",
2535 mime_hint
2536 .map(str::to_string)
2537 .unwrap_or_else(|| guess_mime(&media.path, "video/mp4")),
2538 )
2539 } else {
2540 return None;
2541 };
2542 let mut att = Attachment {
2543 kind: att_kind.to_string(),
2544 mime_type: mime,
2545 data: nexo_llm::AttachmentData::Path {
2546 path: media.path.clone(),
2547 },
2548 };
2549 if let Err(e) = att.materialize() {
2550 tracing::warn!(path = %media.path, kind = att_kind, error = %e, "failed to materialize inbound media; skipping");
2551 return None;
2552 }
2553 Some(att)
2554}
2555
2556fn secs_to_chrono(raw_secs: u64) -> chrono::Duration {
2557 let secs = raw_secs.max(60).min(i64::MAX as u64) as i64;
2558 chrono::Duration::seconds(secs)
2559}
2560
2561fn build_followup_tick_prompt(flow: &EmailFollowupEntry, attempt_number: u32) -> String {
2562 let instance = flow
2563 .source_instance
2564 .as_deref()
2565 .filter(|s| !s.is_empty())
2566 .unwrap_or("default");
2567 format!(
2568 "[followup_tick]\nflow_id: {}\nthread_root_id: {}\ninstance: {}\nrecipient: {}\nattempt: {}/{}\ninstruction: {}\n\nTarea: revisa el hilo en email instance={}, si el cliente ya respondió o el caso está resuelto llama cancel_followup {{ flow_id }}, si no respondió envía follow-up manteniendo threading.",
2569 flow.flow_id,
2570 flow.thread_root_id,
2571 instance,
2572 flow.recipient,
2573 attempt_number,
2574 flow.max_attempts,
2575 flow.instruction,
2576 instance,
2577 )
2578}
2579fn guess_mime(path: &str, default: &str) -> String {
2581 let lower = path.to_ascii_lowercase();
2582 let ext = std::path::Path::new(&lower)
2583 .extension()
2584 .and_then(|s| s.to_str())
2585 .unwrap_or("");
2586 match ext {
2587 "png" => "image/png".into(),
2589 "webp" => "image/webp".into(),
2590 "gif" => "image/gif".into(),
2591 "jpg" | "jpeg" => "image/jpeg".into(),
2592 "oga" | "ogg" | "opus" => "audio/ogg".into(),
2594 "mp3" => "audio/mpeg".into(),
2595 "m4a" => "audio/mp4".into(),
2596 "wav" => "audio/wav".into(),
2597 "flac" => "audio/flac".into(),
2598 "mp4" | "m4v" => "video/mp4".into(),
2600 "webm" => "video/webm".into(),
2601 "mov" => "video/quicktime".into(),
2602 _ => default.to_string(),
2603 }
2604}
2605fn stringify_tool_result(v: &serde_json::Value) -> String {
2610 match v {
2611 serde_json::Value::String(s) => s.clone(),
2612 other => other.to_string(),
2613 }
2614}
2615
2616fn sleep_signal_from_value(value: &serde_json::Value) -> Option<SleepSignal> {
2617 if !super::sleep_tool::is_sleep_result(value) {
2618 return None;
2619 }
2620 Some(SleepSignal {
2621 duration_ms: super::sleep_tool::extract_sleep_ms(value)?,
2622 reason: value
2623 .get("reason")
2624 .and_then(|v| v.as_str())
2625 .unwrap_or("sleep requested")
2626 .to_string(),
2627 })
2628}
2629
2630fn inject_runtime_tool_args(
2631 tool_name: &str,
2632 mut args: serde_json::Value,
2633 msg: &InboundMessage,
2634) -> serde_json::Value {
2635 if tool_name != "schedule_reminder" && tool_name != "delegate" {
2636 return args;
2637 }
2638 let Some(map) = args.as_object_mut() else {
2639 return args;
2640 };
2641 map.entry("session_id".to_string())
2642 .or_insert_with(|| serde_json::json!(msg.session_id.to_string()));
2643 map.entry("source_plugin".to_string())
2644 .or_insert_with(|| serde_json::json!(msg.source_plugin));
2645 map.entry("recipient".to_string())
2646 .or_insert_with(|| serde_json::json!(msg.sender_id));
2647 if tool_name == "delegate" {
2648 let ctx = map
2649 .entry("context".to_string())
2650 .or_insert_with(|| serde_json::json!({}));
2651 if let Some(ctx_map) = ctx.as_object_mut() {
2652 ctx_map
2653 .entry("session_id".to_string())
2654 .or_insert_with(|| serde_json::json!(msg.session_id.to_string()));
2655 ctx_map
2656 .entry("source_plugin".to_string())
2657 .or_insert_with(|| serde_json::json!(msg.source_plugin));
2658 ctx_map
2659 .entry("sender_id".to_string())
2660 .or_insert_with(|| serde_json::json!(msg.sender_id));
2661 }
2662 }
2663 args
2664}
2665
2666#[cfg(test)]
2667mod tests {
2668 use super::super::types::InboundMedia;
2669 use super::*;
2670 use nexo_llm::AttachmentData;
2671
2672 fn temp_media_file(name: &str, bytes: &[u8]) -> tempfile::NamedTempFile {
2673 let file = tempfile::Builder::new()
2674 .prefix("media-")
2675 .suffix(name)
2676 .tempfile()
2677 .expect("create temp file");
2678 std::fs::write(file.path(), bytes).expect("write media bytes");
2679 file
2680 }
2681
2682 #[test]
2683 fn build_media_attachment_voice_materializes_as_audio() {
2684 let file = temp_media_file(".ogg", b"ogg-bytes");
2685 let media = InboundMedia {
2686 kind: "voice".into(),
2687 path: file.path().display().to_string(),
2688 mime_type: None,
2689 };
2690 let att = build_media_attachment(&media).expect("voice media should attach");
2691 assert_eq!(att.kind, "audio");
2692 assert_eq!(att.mime_type, "audio/ogg");
2693 match att.data {
2694 AttachmentData::Base64 { base64 } => assert!(!base64.is_empty()),
2695 other => panic!("expected Base64 attachment, got {other:?}"),
2696 }
2697 }
2698
2699 #[test]
2700 fn build_media_attachment_video_uses_kind_and_guessed_mime() {
2701 let file = temp_media_file(".WEBM", b"webm-bytes");
2702 let media = InboundMedia {
2703 kind: "video_note".into(),
2704 path: file.path().display().to_string(),
2705 mime_type: None,
2706 };
2707 let att = build_media_attachment(&media).expect("video_note media should attach");
2708 assert_eq!(att.kind, "video");
2709 assert_eq!(att.mime_type, "video/webm");
2710 }
2711
2712 #[test]
2713 fn build_media_attachment_ignores_unsupported_kind() {
2714 let file = temp_media_file(".pdf", b"%PDF");
2715 let media = InboundMedia {
2716 kind: "document".into(),
2717 path: file.path().display().to_string(),
2718 mime_type: Some("application/pdf".into()),
2719 };
2720 assert!(build_media_attachment(&media).is_none());
2721 }
2722
2723 #[test]
2724 fn sleep_signal_maps_sentinel_without_parsing_stringified_result() {
2725 let signal = sleep_signal_from_value(&serde_json::json!({
2726 "__nexo_sleep__": true,
2727 "duration_ms": 270_000,
2728 "reason": "waiting for work"
2729 }))
2730 .expect("sleep sentinel should map");
2731
2732 assert_eq!(
2733 signal,
2734 SleepSignal {
2735 duration_ms: 270_000,
2736 reason: "waiting for work".into()
2737 }
2738 );
2739 assert!(sleep_signal_from_value(&serde_json::json!({"text": "normal"})).is_none());
2740 }
2741
2742 fn req_for_cache_break(system: &str) -> ChatRequest {
2743 let mut req = ChatRequest::new("claude-sonnet-4-5", vec![ChatMessage::user("hola")]);
2744 req.system_prompt = Some(system.to_string());
2745 req
2746 }
2747
2748 #[test]
2749 fn cache_break_tracker_hit_run_is_noop() {
2750 let mut tracker = CacheBreakTracker::default();
2751 let first = CacheBreakSnapshot {
2752 req: CacheBreakRequestContext::from_request(
2753 "anthropic",
2754 "claude-sonnet-4-5",
2755 &req_for_cache_break("stable"),
2756 ),
2757 cache_read_input_tokens: 8_000,
2758 cache_creation_input_tokens: 0,
2759 };
2760 let second = CacheBreakSnapshot {
2761 req: CacheBreakRequestContext::from_request(
2762 "anthropic",
2763 "claude-sonnet-4-5",
2764 &req_for_cache_break("stable"),
2765 ),
2766 cache_read_input_tokens: 7_500,
2767 cache_creation_input_tokens: 0,
2768 };
2769 assert!(tracker.observe("sess-1", first).is_none());
2770 assert!(tracker.observe("sess-1", second).is_none());
2771 }
2772
2773 struct MockExtractor {
2778 tick_count: std::sync::atomic::AtomicU32,
2779 extract_count: std::sync::atomic::AtomicU32,
2780 last_extract: std::sync::Mutex<Option<(GoalId, u32, String, std::path::PathBuf)>>,
2781 }
2782
2783 impl Default for MockExtractor {
2784 fn default() -> Self {
2785 Self {
2786 tick_count: std::sync::atomic::AtomicU32::new(0),
2787 extract_count: std::sync::atomic::AtomicU32::new(0),
2788 last_extract: std::sync::Mutex::new(None),
2789 }
2790 }
2791 }
2792
2793 impl MemoryExtractor for MockExtractor {
2794 fn tick(&self) {
2795 self.tick_count
2796 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2797 }
2798
2799 fn extract(
2800 self: Arc<Self>,
2801 goal_id: GoalId,
2802 turn_index: u32,
2803 messages_text: String,
2804 memory_dir: std::path::PathBuf,
2805 ) {
2806 self.extract_count
2807 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2808 *self.last_extract.lock().unwrap() =
2809 Some((goal_id, turn_index, messages_text, memory_dir));
2810 }
2811 }
2812
2813 fn dummy_behavior() -> LlmAgentBehavior {
2814 struct DummyClient;
2815 #[async_trait]
2816 impl LlmClient for DummyClient {
2817 async fn chat(&self, _req: ChatRequest) -> anyhow::Result<nexo_llm::ChatResponse> {
2818 anyhow::bail!("dummy client — unused in M4 tests")
2819 }
2820 fn model_id(&self) -> &str {
2821 "dummy"
2822 }
2823 }
2824 let llm: Arc<dyn LlmClient> = Arc::new(DummyClient);
2825 let tools = Arc::new(crate::agent::ToolRegistry::default());
2826 LlmAgentBehavior::new(llm, tools)
2827 }
2828
2829 #[test]
2830 fn with_memory_extractor_populates_both_fields() {
2831 let mock: Arc<dyn MemoryExtractor> = Arc::new(MockExtractor::default());
2832 let dir = std::path::PathBuf::from("/tmp/nexo-test/memory");
2833 let b = dummy_behavior().with_memory_extractor(Arc::clone(&mock), dir.clone());
2834 assert!(b.memory_extractor.is_some());
2835 assert_eq!(b.memory_dir.as_deref(), Some(dir.as_path()));
2836 }
2837
2838 #[test]
2839 fn default_behavior_has_no_memory_extractor() {
2840 let b = dummy_behavior();
2841 assert!(b.memory_extractor.is_none());
2842 assert!(b.memory_dir.is_none());
2843 }
2844
2845 #[test]
2846 fn memory_extractor_records_tick_and_extract_calls() {
2847 let mock = Arc::new(MockExtractor::default());
2852 let extractor: Arc<dyn MemoryExtractor> = Arc::clone(&mock) as Arc<dyn MemoryExtractor>;
2853 extractor.tick();
2854 Arc::clone(&extractor).extract(
2855 GoalId(uuid::Uuid::nil()),
2856 0,
2857 "transcript".into(),
2858 std::path::PathBuf::from("/tmp/nexo-test/memory"),
2859 );
2860 assert_eq!(mock.tick_count.load(std::sync::atomic::Ordering::SeqCst), 1);
2861 assert_eq!(
2862 mock.extract_count.load(std::sync::atomic::Ordering::SeqCst),
2863 1
2864 );
2865 let last = mock.last_extract.lock().unwrap().clone().unwrap();
2866 assert_eq!(last.1, 0);
2867 assert_eq!(last.2, "transcript");
2868 }
2869
2870 #[test]
2871 fn cache_break_tracker_break_run_flags_system_mutation() {
2872 let mut tracker = CacheBreakTracker::default();
2873 let first = CacheBreakSnapshot {
2874 req: CacheBreakRequestContext::from_request(
2875 "anthropic",
2876 "claude-sonnet-4-5",
2877 &req_for_cache_break("stable"),
2878 ),
2879 cache_read_input_tokens: 8_000,
2880 cache_creation_input_tokens: 0,
2881 };
2882 let second = CacheBreakSnapshot {
2883 req: CacheBreakRequestContext::from_request(
2884 "anthropic",
2885 "claude-sonnet-4-5",
2886 &req_for_cache_break("mutated"),
2887 ),
2888 cache_read_input_tokens: 3_000,
2889 cache_creation_input_tokens: 200,
2890 };
2891 assert!(tracker.observe("sess-1", first).is_none());
2892 let ev = tracker
2893 .observe("sess-1", second)
2894 .expect("expected cache-break event");
2895 assert!(ev.system_prompt_changed);
2896 assert!(ev.suspected_breaker.contains("system_prompt_mutation"));
2897 assert_eq!(ev.previous_cache_read_input_tokens, 8_000);
2898 assert_eq!(ev.cache_read_input_tokens, 3_000);
2899 }
2900}