Skip to main content

nexo_core/agent/
llm_behavior.rs

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};
26/// Build the JSON payload channel plugins consume from
27/// `plugin.outbound.<channel>.<instance?>`. The `text` variant
28/// keeps the legacy shape (`{to, text, kind: "text", session_id}`)
29/// so back-compat with old microapp consumers stays intact; new
30/// variants land additively under `kind`.
31fn 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            // Surface the transcript under `text` so audit /
55            // takeover dashboards that read `text` keep working
56            // even when the actual wire payload is audio.
57            "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/// Aggregate result of running every `*_inbound_transform`
76/// tool for one inbound message. `new_text` is `Some` when at
77/// least one transformer rewrote the text; `system_addenda`
78/// is the per-turn system-prompt fragments the transformers
79/// optionally returned (concatenated into one extra system
80/// section just before the LLM call).
81#[derive(Debug, Default)]
82struct InboundTransformOutcome {
83    new_text: Option<String>,
84    system_addenda: Vec<String>,
85}
86
87/// Decide whether a session is a private DM (main) or a shared surface.
88/// `MEMORY.md` loads only for `Main` — shared scopes strip it at load time.
89fn session_scope_for(msg: &InboundMessage) -> SessionScope {
90    // Agent-to-agent delegation arrives with source_plugin="agent": the peer
91    // agent is never the human, so MEMORY.md must stay out.
92    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        // Generic cache-break trigger across providers/models:
166        // cache-read dropped by >50% turn-over-turn.
167        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    /// Sidecar policy: which tools are cacheable / parallel-safe.
238    /// `ToolPolicy::disabled()` is the back-compat default — nothing
239    /// cached, nothing parallel, identical behavior to pre-policy.
240    tool_policy: Arc<super::tool_policy::ToolPolicy>,
241    /// Cached relevance filter. Built once when `with_tool_policy` is
242    /// called (tool set is stable over process lifetime). `None`
243    /// means relevance filtering is disabled — every call passes the
244    /// full catalog. Held under `RwLock` so a future hot-reload API
245    /// can swap the index without rebuilding the behavior struct.
246    tool_filter: Arc<tokio::sync::RwLock<Option<super::tool_filter::ToolFilter>>>,
247    /// Hot path for workspace bundle reads. When `Some`, run_turn
248    /// fetches via the cache (in-memory + notify invalidation); when
249    /// `None`, falls back to a fresh `WorkspaceLoader` every turn
250    /// (legacy behavior, kept for tests and bootstrap paths).
251    workspace_cache: Option<Arc<super::workspace_cache::WorkspaceCache>>,
252    /// When true, system prompt is emitted as
253    /// `Vec<PromptBlock>` with `cache_control` breakpoints, and the
254    /// tool catalog is marked cacheable. When false, the legacy flat
255    /// `system_prompt: String` path runs (no provider-level caching).
256    prompt_cache_enabled: bool,
257    /// Pre-flight token counter. When `Some`, every request
258    /// is sized before send; the estimated count is emitted as
259    /// `llm_prompt_tokens_estimated` and post-response we record drift
260    /// vs the provider's reported total. When `None`, counting is
261    /// skipped entirely (zero overhead).
262    token_counter: Option<Arc<dyn nexo_llm::TokenCounter>>,
263    /// Online history compaction. All three must be wired
264    /// together (compactor + store + runtime config). When any is
265    /// missing, the compaction path is silently skipped — the agent
266    /// loop falls back to the legacy "send the whole history" mode.
267    compactor: Option<Arc<super::compaction::LlmCompactor>>,
268    compaction_store: Option<Arc<nexo_memory::CompactionStore>>,
269    compaction_runtime: CompactionRuntime,
270    /// Runtime circuit-breaker state (Sync-safe).
271    compaction_failures: std::sync::atomic::AtomicU32,
272    compaction_last_turn: std::sync::Mutex<Option<u32>>,
273    cache_break_tracker: Mutex<CacheBreakTracker>,
274    /// Post-turn memory-extraction hook. When set,
275    /// every successful `run_turn` ticks the extractor and (when
276    /// `memory_dir` is also set + `reply_text` is `Some`) fires
277    /// `extract(...)` against the conversation transcript.
278    /// `Arc<dyn MemoryExtractor>` is provider-agnostic — the
279    /// concrete `ExtractMemories` impl from `nexo-driver-loop` is
280    /// the one we ship today, but any impl works.
281    memory_extractor: Option<Arc<dyn MemoryExtractor>>,
282    /// Destination root for extracted memories. Set
283    /// together with `memory_extractor` via
284    /// `with_memory_extractor`. `None` keeps `tick()` firing
285    /// (cadence stays sane) but skips the actual `extract`
286    /// call so we never write outside an explicit dir.
287    memory_dir: Option<PathBuf>,
288    /// Optional mutation observer.
289    /// When set, every successful `compaction_store.insert` fires a
290    /// `SqliteCompactions/Insert` event keyed on the session id so
291    /// downstream subscribers can correlate the row to the agent
292    /// turn that produced it. Best-effort: a hook failure is
293    /// swallowed by the trait contract and never blocks the
294    /// compaction loop.
295    mutation_hook: Option<Arc<dyn nexo_driver_types::MemoryMutationHook>>,
296    /// Tenant string passed to the mutation hook. Defaults to
297    /// `"default"`; multi-tenant SaaS wires the per-binding tenant
298    /// at boot via `with_mutation_hook`.
299    mutation_tenant: String,
300    /// Plugin-contributed skill roots threaded from
301    /// `wire_plugin_registry` boot output. Empty when no plugin
302    /// discovery is configured. `prepare_system_prompt()` consumes
303    /// this via `SkillLoader::with_plugin_roots(self.plugin_skill_roots.clone())`
304    /// so plugin-contributed skills become discoverable to every
305    /// agent without operator-level skills_dir duplication.
306    plugin_skill_roots: Vec<PathBuf>,
307    /// Outbound reply transform pipeline. Each transformer runs in
308    /// registration order before the reply hits the channel topic.
309    /// Empty by default — transformers are opt-in via
310    /// `with_reply_transformers`.
311    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/// Flattened compaction config that the agent loop reads on
336/// every turn. Lives alongside the behavior so a hot-reload can swap
337/// the whole struct via `Arc::make_mut`.
338#[derive(Debug, Clone)]
339pub struct CompactionRuntime {
340    pub enabled: bool,
341    /// Trigger threshold in tokens. When the pre-flight estimate
342    /// crosses this, run compaction before the request.
343    pub compact_at_tokens: u32,
344    /// Minimum tail to preserve verbatim, in chars (≈4 chars/token).
345    /// `find_safe_boundary` walks from the end until reaching this.
346    pub tail_keep_chars: usize,
347    /// Per-tool-result hard cap, in chars. Above this, the body is
348    /// replaced by a `[truncated NNN bytes]` marker pre-send.
349    pub tool_result_max_chars: usize,
350    /// Microcompact threshold. Tool results above this
351    /// byte size are summarized before sending the next LLM request.
352    pub micro_threshold_bytes: usize,
353    /// Maximum summary body retained for one microcompacted tool result.
354    pub micro_summary_max_chars: usize,
355    /// Optional model override for microcompact. Empty = current turn model.
356    pub micro_model: String,
357    /// Lock TTL for `CompactionStore::try_acquire_lock`. Above this
358    /// after a crash, the next acquire wins automatically.
359    pub lock_ttl_seconds: u32,
360    /// Override of the summary model. Empty = reuse the agent's main
361    /// model.
362    pub summarizer_model: String,
363    // ── autoCompact ─────────────────────────────────────
364    /// Token-pct trigger (0.0 disables token trigger).
365    pub auto_token_pct: f32,
366    /// Age trigger in minutes (0 disables age trigger).
367    pub auto_max_age_minutes: u64,
368    /// Safety margin below effective context window.
369    pub auto_buffer_tokens: u64,
370    /// Minimum turns between consecutive auto-compactions.
371    pub auto_min_turns_between: u32,
372    /// Consecutive failures that trip the circuit breaker.
373    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,       // ≈20K tokens
382            tool_result_max_chars: 60_000, // ≈15K tokens; per-turn pre-send only
383            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    /// Install the outbound reply transform chain. Replaces any
426    /// previously-installed chain. Pass
427    /// `OutboundReplyTransformChain::empty()` to disable.
428    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    /// Counterpart of `discover_reply_transform_tools` for inbound.
437    /// Tools whose name ends in `_inbound_transform` get invoked
438    /// once per inbound message — useful for STT, OCR, language
439    /// detection, etc. Convention preserves the daemon's "no extra
440    /// RPC" stance: register a regular tool with the suffix and the
441    /// framework picks it up.
442    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    /// Run each inbound transformer in order. Each receives JSON
454    /// `{ context: { ... }, text: "<current>", media: { kind, path,
455    /// mime_type } | null }` and may return:
456    ///   * `{ ok: true, text: "<new>" }` — replace the inbound text.
457    ///   * `{ ok: true, passthrough: true }` — leave unchanged.
458    ///   * `{ ok: false, error: "<msg>" }` — short-circuit; caller
459    ///     keeps the original text and surfaces a warn log.
460    /// Returns `Ok(Some(new))` when at least one transformer
461    /// rewrote, `Ok(None)` for pure passthrough, `Err` on rejection.
462    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            // ISO-639-1 hint from `agents.yaml.<id>.language` so
477            // transformers can localise their output (e.g.
478            // voice_mode picks ES/EN system addendum).
479            "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                    // Optional `system_addendum` lets a transformer
541                    // append extra instructions to THIS turn's
542                    // system prompt (e.g. voice-mode marker
543                    // syntax). Per-turn — never persisted to the
544                    // agent's SOUL.md.
545                    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    /// Surface every registered tool whose name ends in
579    /// `_reply_transform`. Microapps use this naming convention to
580    /// plug into the outbound reply pipeline without requiring a
581    /// dedicated registration RPC. Sorted alphabetically so chain
582    /// order is deterministic across daemon restarts.
583    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    /// Run each tool transformer in order. Each tool receives JSON
595    /// `{ "context": <OutboundReplyContext>, "reply": <OutboundReplyKind> }`
596    /// and must return either:
597    ///   * `{ "ok": true, "reply": <OutboundReplyKind> }` — proceed
598    ///     with the (possibly rewritten) reply.
599    ///   * `{ "ok": true, "passthrough": true }` — leave unchanged.
600    ///   * `{ "ok": false, "error": <string> }` — reject; chain
601    ///     short-circuits and the outbound is dropped.
602    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    /// Install the plugin-contributed skill roots
700    /// returned by `wire_plugin_registry`. Empty vec preserves
701    /// legacy behavior (operator's `skills_dir` is the only
702    /// source). Operator-priority is preserved by the loader's
703    /// search order — see `SkillLoader::candidate_paths`.
704    pub fn with_plugin_skill_roots(mut self, roots: Vec<PathBuf>) -> Self {
705        self.plugin_skill_roots = roots;
706        self
707    }
708
709    /// Wire the mutation observer. Successful `compaction_store.insert`
710    /// calls fire a
711    /// `SqliteCompactions/Insert` event onto
712    /// `nexo.memory.mutated.<agent_id>` with the session id as the
713    /// correlation key. Best-effort: hook failures are swallowed.
714    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    /// Wire post-turn memory extraction. When set, every successful
725    /// `run_turn` ticks the extractor and fires extraction against
726    /// `memory_dir`. Mirrors driver-loop's per-turn wire; both
727    /// engines share the same `Arc<ExtractMemories>` so cadence +
728    /// circuit breaker + in-progress mutex stay coherent across
729    /// paths.
730    ///
731    /// Provider-agnostic: `Arc<dyn MemoryExtractor>` keeps any
732    /// concrete impl pluggable (today `ExtractMemories` from
733    /// `nexo-driver-loop`).
734    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    /// Wire the online compactor. All three handles must be
786    /// supplied together; passing `enabled: true` in `runtime` without
787    /// the wiring is a no-op (logged on first turn so the gap is
788    /// visible). `summarizer` is the LLM client used to produce the
789    /// summary itself — most operators reuse the agent's main model;
790    /// pass a dedicated cheaper client to save spend.
791    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    /// Attach a `TokenCounter`. Boot time pick this from
803    /// `nexo_llm::token_counter::build()` based on
804    /// `llm.context_optimization.token_counter.backend`. When omitted,
805    /// pre-flight sizing is skipped (zero metrics, zero overhead).
806    pub fn with_token_counter(mut self, counter: Arc<dyn nexo_llm::TokenCounter>) -> Self {
807        self.token_counter = Some(counter);
808        self
809    }
810    /// Attach the shared workspace cache. When set, `run_turn` reads
811    /// the workspace bundle via `WorkspaceCache::get` (warm Arc, no
812    /// disk I/O on the hot path); when omitted, falls back to a fresh
813    /// `WorkspaceLoader` every turn (legacy / test path).
814    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    /// Opt the agent into provider-level prompt caching.
822    /// Driven from `llm.context_optimization.prompt_cache.enabled` (or
823    /// the per-agent override). Defaults to false so
824    /// the legacy non-cached path stays the safe fallback.
825    pub fn with_prompt_cache(mut self, enabled: bool) -> Self {
826        self.prompt_cache_enabled = enabled;
827        self
828    }
829    /// Attach a tool-execution policy. Controls caching + parallel
830    /// execution of tool calls. Defaults to a no-op policy.
831    ///
832    /// Pre-builds the relevance filter (if enabled) so the per-turn
833    /// hot path stays O(1) instead of re-tokenizing the full tool
834    /// catalog on every message.
835    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    /// Rebuild the relevance filter index — call after the tool set
846    /// changes (extension hot-reload, runtime registration). Idempotent.
847    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    /// Attach an extension hook registry. Without this, hook fire sites are
862    /// no-ops and behavior is identical to pre-11.6 operation.
863    pub fn with_hooks(mut self, hooks: Arc<super::hook_registry::HookRegistry>) -> Self {
864        self.hooks = Some(hooks);
865        self
866    }
867    /// Attach per-tool rate limiter. Denied calls
868    /// surface as `outcome="rate_limited"` and are not routed to the
869    /// handler.
870    pub fn with_rate_limiter(mut self, rl: Arc<super::rate_limit::ToolRateLimiter>) -> Self {
871        self.rate_limiter = Some(rl);
872        self
873    }
874    /// Attach the JSON Schema args validator.
875    /// Denied calls surface as `outcome="invalid_args"` with the path
876    /// of the offending field(s) in the result, so the LLM can retry.
877    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    /// Execute a single tool call end-to-end: hooks → rate limit →
885    /// schema → cache lookup → handler → cache store. Caller picks the
886    /// concurrency pattern (serial vs `join_all`).
887    ///
888    /// Returns a structured result so control-flow tools (notably
889    /// `Sleep`) can stop the LLM loop without brittle string parsing.
890    /// Telemetry + `after_tool_call` hook are fired by the caller so
891    /// those observations stay in LLM-emitted order even when we
892    /// parallelise.
893    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        // Centralised plan-mode gate. Runs before any other check so
909        // the refusal message stays consistent with the structured
910        // `PlanModeRefusal` shape regardless of which downstream gate
911        // would have matched. The Bash classifier verdict is `None`
912        // here — `gate_tool_call` treats `Bash + None` as fail-safe
913        // blocking ("default to blocking if classifier returns
914        // Unknown").
915        {
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        // Defense-in-depth: enforce the per-binding `allowed_tools`
939        // list at execution time. The tool was already hidden from
940        // the LLM's tool_defs for this binding (see filter below at
941        // the turn-entry point), so a matching call usually means the
942        // model is hallucinating the name — returning a clear error
943        // keeps the turn bounded instead of either executing the
944        // forbidden tool or letting the model retry the same call.
945        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        // before_tool_call hook.
960        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        // Rate-limit lookup is binding-aware. The
982        // limiter resolves per-binding overrides
983        // (`ctx.effective.tool_rate_limits`) before the global
984        // pattern set; bucket cardinality is per
985        // `(agent, binding_id, tool)` so a single binding can't
986        // starve other bindings on the same agent.
987        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            // Turn-log marker on denial so
1006            // operator audit queries can identify which
1007            // `(binding, tool)` pairs hit caps most. The marker
1008            // is wire-shape stable; downstream billing pipelines
1009            // parse the format documented on `format_rate_limit_hit`.
1010            //
1011            // Resolved rps lookup: we redo the resolve here purely
1012            // for the marker — the limiter consumed the bucket
1013            // already and we don't need its f64 internally. Falls
1014            // back to 0.0 when the configured pattern is gone (race
1015            // with hot-reload); the marker still carries enough
1016            // signal to identify the binding and tool.
1017            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                            // Apply per-call timeout from policy — a slow
1087                            // tool call is cancelled rather than blocking
1088                            // the parallel batch indefinitely.
1089                            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        // Surface every tool invocation at INFO so operators can
1130        // diagnose "why did the agent not call X" or "why did X fail
1131        // silently" without flipping the whole crate to debug.
1132        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        // Inbound transform chain — auto-discovered by tool naming
1166        // convention (`*_inbound_transform`). Each tool sees the
1167        // current text + any media attachment and can rewrite the
1168        // text before the LLM runs (e.g. STT for voice notes).
1169        // Cheap on the no-op path: when no transformer tools are
1170        // registered, this is a single tools.names() lookup.
1171        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        // before_message hook. Extensions can short-circuit the
1204        // turn (e.g. content filter, rate-limiter, observability gate).
1205        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        // Append the user transcript entry IMMEDIATELY, before the
1228        // LLM call. The legacy code path appended both user
1229        // and assistant entries together AFTER the LLM produced a
1230        // reply, which meant the firehose `TranscriptAppended` event
1231        // for the user message only fired once the assistant turn
1232        // finished — operator dashboards saw both messages pop in
1233        // simultaneously. Splitting the append lets the user entry
1234        // broadcast at intake time so the operator sees inbound
1235        // messages live, not batched with the bot's reply. Failure
1236        // to append must NOT break the turn — transcripts are
1237        // auxiliary state.
1238        {
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        // If session is new (empty history) and long-term memory is available,
1274        // prepend recent interactions from disk so the agent remembers past conversations.
1275        let mut prefix_messages: Vec<ChatMessage> = Vec::new();
1276        // Build the initial system message from three sources, in priority
1277        // order: workspace bundle (IDENTITY/SOUL/USER/AGENTS/recent notes/MEMORY),
1278        // then optional local skills, then inline `system_prompt`. All parts
1279        // are merged into one system ChatMessage to keep prompt caching stable.
1280        // Collect the system prompt into named sections so
1281        // we can hand them to `prompt_assembly::build_blocks` with
1282        // explicit `CachePolicy` per block. Empty sections fall out
1283        // and never occupy a cache breakpoint.
1284        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            // Hot path: prefer the shared cache (Arc, no disk I/O).
1293            // Legacy fallback: fresh loader every turn — kept so tests
1294            // and bootstrap that don't wire a cache still work.
1295            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        // Per-binding skills: pull the list from the effective policy so
1326        // a narrow binding can boot with zero skills loaded while a
1327        // wider binding on the same agent injects the full catalogue.
1328        // skills_dir stays agent-level because skills are physical files
1329        // shared across every binding.
1330        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                    // Tenant-scoped skill resolution: per-tenant skills
1342                    // (`<root>/<tenant_id>/<name>/`) win over
1343                    // global, and legacy `<root>/<name>/`
1344                    // remains as fallback for un-migrated
1345                    // deployments.
1346                    .with_tenant_id(ctx.config.tenant_id.clone())
1347                    // Append plugin-contributed skill roots from
1348                    // `wire_plugin_registry`.
1349                    // Operator-priority is preserved because
1350                    // `candidate_paths` searches the operator
1351                    // chain (tenant + global + legacy) before
1352                    // any plugin root.
1353                    .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        // Peer directory — auto-rendered `# PEERS` block listing other
1361        // agents in the process. The LLM learns who it can delegate to
1362        // without the user having to hand-write `AGENTS.md`.
1363        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        // Per-binding system prompt: agent-level base with an optional
1369        // `# CHANNEL ADDENDUM` block appended by EffectiveBindingPolicy.
1370        // Legacy bindingless code paths see the plain agent prompt via
1371        // from_agent_defaults.
1372        let system_prompt = effective.system_prompt.trim();
1373        if !system_prompt.is_empty() {
1374            binding_glue_parts.push(system_prompt.to_string());
1375        }
1376        // Per-binding output language directive. Workspace docs stay in
1377        // English (so recall, dreaming, and dev tooling read them
1378        // unchanged); this block tells the model to reply in the
1379        // configured language instead. Resolved with binding > agent
1380        // > none precedence inside EffectiveBindingPolicy.
1381        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        // Link understanding. When the agent has it
1390        // enabled and the user message contains URLs, fetch each one
1391        // and inject a `# LINK CONTEXT` block so the LLM has grounded
1392        // facts to reason over. Lives in `channel_meta_parts` so it
1393        // sits in the per-turn (non-cached) section of the prompt —
1394        // every turn fetches fresh and the cache is keyed on URL,
1395        // not on the prompt blob.
1396        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        // Inbound metadata — give the LLM the current sender so it
1420        // doesn't have to ask ("¿cuál es tu teléfono?") when the
1421        // channel already carries it (WhatsApp JID, Telegram user id,
1422        // email address). The runtime injects this every turn so even
1423        // mid-conversation it's always current. Lives in its own
1424        // (short-TTL) block because it varies per turn.
1425        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        // Inject the canonical plan-mode hint while
1435        // plan mode is on. Frozen string keeps the prompt cache warm.
1436        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        // Inject proactive + coordinator hints once (frozen
1440        // strings → prompt-cache-friendly, same pattern as plan_mode).
1441        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        // Assistant-mode addendum. Append the resolved
1452        // text (operator override or bundled default) when the
1453        // boot-immutable flag is on. Same prompt-cache rules as the
1454        // proactive/coordinator hints — the addendum is stable across
1455        // turns so the cache stays warm.
1456        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        // Brief-mode "talking to the user" section.
1461        // Skipped when the assistant-mode addendum already covers
1462        // the same instruction (avoid duplicating the directive).
1463        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        // Inject a stub block listing deferred tools by name
1485        // + description so the model can discover them via ToolSearch.
1486        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        // Per-turn system addenda contributed by `*_inbound_transform`
1491        // tools. Used today by voice_mode to teach the LLM the
1492        // marker syntax only when the conversation is in voice
1493        // mode — avoids polluting the operator's static SOUL.md
1494        // with conditional instructions.
1495        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        // Legacy flat string for providers that don't honor
1500        // `system_blocks` (and as a back-compat path when prompt_cache
1501        // is disabled). Cheap to build — `flatten_blocks` walks the
1502        // same Vec we just assembled.
1503        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        // Pre-flight compaction trigger. Only runs when the
1523        // compactor is wired AND enabled in runtime config. Estimates
1524        // the would-be request size (system blocks + history); when
1525        // it exceeds `compact_at_tokens` OR the session is older than
1526        // `auto_max_age_minutes`, runs the summarizer on
1527        // `history[..tail_start]`, persists an audit row, and replaces
1528        // the head with a stored summary. The summary then gets
1529        // injected into `messages` below as a user/assistant pair so
1530        // role alternation stays valid for Anthropic.
1531        // Gate on BOTH the boot-wired flag AND the
1532        // current snapshot's resolved enable. A hot-reload that flips
1533        // `compaction: false` takes effect on this turn without
1534        // rebuilding the behavior. Legacy paths without a snapshot
1535        // (tests, heartbeat bootstrap) treat the live flag as `true`
1536        // so the boot-wired enable stays the only gate.
1537        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            // ── autoCompact triggers ───────────────────
1568            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            // Circuit breaker: skip when too many consecutive failures.
1577            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            // Anti-storm: respect min turns between compactions.
1584            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                                // Fire the mutation event only when
1649                                // the insert actually committed. Use
1650                                // session.id as the correlation key
1651                                // since compactions_v1 has no UUID
1652                                // primary key.
1653                                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                                // Reset circuit breaker on success.
1667                                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                                // Increment circuit breaker.
1687                                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        // Build message list: historical prefix + compacted summary
1719        // (when present) + current session turns.
1720        let mut messages: Vec<ChatMessage> = prefix_messages;
1721        if let Some(summary) = session.compacted_summary.as_ref() {
1722            // Inject as user/assistant pair so Anthropic's strict
1723            // alternation rule never sees user-user. The synthetic
1724            // ack tells the model the summary is authoritative
1725            // context, not a fresh user request.
1726            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        // Attach inbound media to the latest user turn. Gemini consumes
1740        // image/audio/video parts inline; providers that do not support
1741        // a media kind simply skip it while keeping the text turn.
1742        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        // Per-binding model override: ctx.effective carries the model
1754        // string resolved by EffectiveBindingPolicy. Agent-level config
1755        // is only consulted via the `effective_policy()` fallback when
1756        // the context was built outside of a matched binding (heartbeat
1757        // bootstrap, tests). The provider stays at whatever the agent
1758        // was booted with — boot validation rejects bindings that try
1759        // to change `model.provider` because the LLM client is wired
1760        // once per agent. Switching only the model name works because
1761        // providers ship multiple model variants behind a single API.
1762        let effective_policy = ctx.effective_policy();
1763        let model = effective_policy.model.model.clone();
1764        // Prefer the pre-filtered per-binding registry attached by
1765        // AgentRuntime (see `with_tool_base`). Falls back to the
1766        // behavior's base registry + a per-turn filter when the
1767        // runtime wasn't given a tool base (legacy tests, no-LLM
1768        // behaviors). Both paths produce the same visible surface;
1769        // the cached path skips the clone-per-turn.
1770        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        // The relevance filter index is built
1780        // once at agent boot (see `with_tool_policy`). We just borrow
1781        // the prebuilt index here and score against a query built
1782        // from the current user message plus the last few turns of
1783        // conversation history so multi-turn threads ("and in
1784        // Medellín?") don't lose weather tools because the literal
1785        // message is short.
1786        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                    // Tail of conversation for context; cap lookback
1793                    // so a long session doesn't push the query into
1794                    // irrelevant domains.
1795                    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            // Microcompact oversized tool results in the
1817            // request clone only. The canonical in-memory messages keep
1818            // the full body and the tool_call_id/name correlation stays
1819            // intact in the compacted clone.
1820            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            // Wire the structured prompt + tool catalog
1875            // caching opt-in. Provider clients that don't honor the
1876            // fields fall back to flat `system_prompt`; the fields
1877            // are otherwise inert.
1878            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            // Pre-flight token sizing. Counted on
1897            // (system_blocks + messages); count_tokens-backed
1898            // counters cache the stable prefix so 95%+ of the bytes
1899            // are a memory hit. Emits the estimate as a gauge; drift
1900            // vs actual lands in the histogram below after the
1901            // response.
1902            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            // Consume the streaming API in the
1933            // main loop so provider-native SSE paths are exercised
1934            // end-to-end (chat() remains the fallback in the trait).
1935            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            // Emit cache hit/miss metrics whenever the
1943            // provider returned `CacheUsage`. Off-by-default providers
1944            // pass `None` here, so dashboards only see real activity.
1945            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            // Drift observation. Only meaningful when we
1966            // actually estimated and the provider actually reported a
1967            // total. `prompt_tokens` on Anthropic already folds cache
1968            // read+creation into the total, so the comparison stays
1969            // apples-to-apples regardless of cache hit status.
1970            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                    // Preserve the full tool_call metadata (id + name +
1997                    // arguments) so the next turn can emit matching
1998                    // `tool_use` blocks on the Anthropic wire. A pure
1999                    // text "[tool:foo]" summary loses the id and makes
2000                    // MiniMax reject the follow-up tool_result.
2001                    messages.push(ChatMessage::assistant_tool_calls(
2002                        calls.clone(),
2003                        String::new(),
2004                    ));
2005                    // Partition calls: parallel-safe batch runs
2006                    // concurrently (bounded by `parallel.max_in_flight`
2007                    // to protect downstream endpoints), the rest stays
2008                    // sequential (side-effect tools). Results merge
2009                    // back in the original LLM-emitted order so
2010                    // tool_use_id correlation stays consistent on the
2011                    // Anthropic wire.
2012                    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                    // Prime the in-flight window.
2031                    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                    // Push tool_result messages in original order —
2059                    // also run `after_tool_call` hook + telemetry here
2060                    // so observers see calls in the order the LLM
2061                    // emitted them.
2062                    for (i, call) in calls.iter().enumerate() {
2063                        // Defensive: if a future bug leaves an index
2064                        // unscheduled (par/seq partition miss), synthesize
2065                        // an error tool_result so the agent loop keeps
2066                        // running. Panicking here kills the whole agent
2067                        // over one missing dispatch — not worth it.
2068                        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        // Persist user + assistant turns to long-term memory if available
2149        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        // Persist turn to the session transcript when the
2160        // operator has configured a transcripts_dir. Failures are logged
2161        // but never break the reply — transcripts are auxiliary state.
2162        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            // Tag the writer with the owning tenant so emitted
2175            // `TranscriptAppended`
2176            // events carry `tenant_id`. `None` for
2177            // single-tenant agents.
2178            .with_tenant_id(ctx.config.tenant_id.clone());
2179            // Chain the firehose emitter onto
2180            // the writer so every `append_entry` reaches the
2181            // bootstrap's broadcast (live SSE subscribers) and
2182            // the durable `SqliteAgentEventLog` (admin RPC
2183            // backfill). Without this `.with_emitter` call the
2184            // writer falls back to `NoopAgentEventEmitter` and
2185            // microapps with `agent_events_subscribe_all` see no
2186            // live updates.
2187            if let Some(ref em) = ctx.event_emitter {
2188                writer = writer.with_emitter(em.clone());
2189            }
2190            // user_entry was already appended at
2191            // the top of `run_turn` (right after the before_message
2192            // hook) so the firehose `TranscriptAppended` for the
2193            // inbound fires LIVE rather than batched with the
2194            // assistant reply. The post-LLM block here is now
2195            // assistant-only.
2196            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                // When the inbound came from a labelled plugin instance
2223                // (e.g. `plugin.inbound.telegram.sales`), the matching
2224                // bot subscribes to `plugin.outbound.telegram.sales` —
2225                // publish there so only the originating bot replies. A
2226                // missing/empty instance falls back to the legacy topic.
2227                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                // Build the per-turn reply kind + transform context.
2234                // Pull `language` from `EffectiveBindingPolicy` so a binding
2235                // override (`InboundBinding.language`) propagates to
2236                // the reply ctx instead of always inheriting the
2237                // agent-level value. `resolve_language(agent, binding)`
2238                // already implements `binding > agent > None`
2239                // precedence inside `effective.rs`.
2240                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                // 1. Pre-registered Rust transformers (today empty;
2254                //    direct-link future extensions wire here).
2255                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                // 2. Tool-discovered transformers — any registered
2274                //    tool whose name ends in `_reply_transform` gets
2275                //    invoked with `{context, reply}`. Microapps use
2276                //    this convention to plug in TTS / DLP / persona
2277                //    decorators without touching the framework.
2278                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        // after_message hook (advisory). Only fire when we
2318        // actually produced a reply; silent turns don't trigger it.
2319        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        // Post-turn memory extraction. Mirrors driver-loop's wire;
2347        // both engines share the same `Arc<dyn MemoryExtractor>` so
2348        // cadence + circuit breaker + in-progress mutex stay coherent
2349        // across paths. `tick()` runs every turn (cadence stays sane
2350        // even when extract gates skip); `extract(...)` only fires
2351        // when `memory_dir` is set AND `reply_text` carries the
2352        // assistant turn text. Provider-agnostic — the trait operates
2353        // on transcript text, no LLM provider assumption.
2354        // `turn_index = 0` is a sentinel (regular AgentRuntime does
2355        // not yet track per-session turn counters).
2356        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            // Followup ticks are scheduler-driven
2423            // (no end-user) → InternalSystem.
2424            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}
2499/// Turn an `InboundMedia` into an `Attachment` ready for the LLM wire.
2500/// Image / audio / video attachments ride on the provider wire directly
2501/// (Gemini accepts all three inline; Anthropic accepts images today —
2502/// non-image blocks are ignored by the Anthropic builder). Documents and
2503/// anything else flow through dedicated skills (whisper / pdf-extract /
2504/// video-frames) which read `media.path` out of band.
2505fn 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}
2579/// Best-effort MIME guess from extension, falling back to `default`.
2580fn 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        // images
2588        "png" => "image/png".into(),
2589        "webp" => "image/webp".into(),
2590        "gif" => "image/gif".into(),
2591        "jpg" | "jpeg" => "image/jpeg".into(),
2592        // audio
2593        "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        // video
2599        "mp4" | "m4v" => "video/mp4".into(),
2600        "webm" => "video/webm".into(),
2601        "mov" => "video/quicktime".into(),
2602        _ => default.to_string(),
2603    }
2604}
2605/// Tool handlers return `serde_json::Value`. Calling `.to_string()` on
2606/// a `Value::String` leaks the JSON quoting (`"hello"` instead of
2607/// `hello`). The rest of the pipeline expects plain text, so strip the
2608/// quotes for the string case and serialize everything else normally.
2609fn 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    // ── MemoryExtractor wire ──
2774
2775    /// Minimal mock that records `tick` + `extract` calls so
2776    /// tests can assert the post-turn wire fired.
2777    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        // Simulate the post-turn wire by calling tick + extract
2848        // directly. Verifies the trait Arc<dyn> dispatch path
2849        // compiles AND the side effects land where the wire
2850        // expects them.
2851        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}