Skip to main content

apollo/agent/
loop_runner.rs

1//! Agent loop — the core execution engine.
2//! Processes incoming messages, calls LLM, executes tools, sends responses.
3//! Supports progress callbacks, self-healing, guardrails, compaction, and
4//! lifecycle hooks.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use sha2::{Digest, Sha256};
11use tokio::sync::mpsc;
12use tokio::sync::RwLock;
13
14use crate::agent::compaction::{Compactor, ContextInfo, DefaultCompactor};
15use crate::agent::hooks::{run_post_hooks, run_pre_hooks, HookDecision, ToolHook};
16use crate::agent::mode::{
17    is_approval, is_rejection, AgentMode, NullChannel, PendingPlan, PendingPlans,
18};
19use crate::agent::stream::{emit, AgentStreamEvent};
20use crate::channels::{Channel, IncomingMessage, OutgoingMessage};
21use crate::cost::{CostTracker, TokenUsage};
22use crate::memory::MemoryBackend;
23use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
24use crate::providers::{ChatMessage, ChatRequest, Provider};
25use crate::skills;
26use crate::tools::guardrails::{GuardrailDecision, ToolGuardrails};
27use crate::tools::Tool;
28use crate::trajectory::Trajectory;
29
30/// Warn the LLM after this many identical tool calls (guardrails handle this now)
31const LOOP_WARN_THRESHOLD: usize = 5;
32/// Hard stop after this many identical consecutive tool calls (guardrails handle this now)
33const LOOP_BREAK_THRESHOLD: usize = 8;
34
35/// Agent execution state machine
36#[derive(Debug, Clone, PartialEq)]
37enum AgentState {
38    Planning,
39    Executing,
40    Summarizing,
41    Direct,
42}
43
44pub struct AgentRunner {
45    provider: Arc<dyn Provider>,
46    pub tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
47    memory: Arc<dyn MemoryBackend>,
48    pub system_prompt: Arc<RwLock<String>>,
49    model: std::sync::RwLock<String>,
50    default_model: String,
51    workspace: PathBuf,
52    pub skills: Arc<RwLock<Vec<skills::Skill>>>,
53    cost_tracker: Arc<CostTracker>,
54    pub steering_queue: Arc<std::sync::Mutex<Vec<String>>>,
55    pub agent_config: crate::config::AgentConfig,
56    mode: Arc<std::sync::RwLock<AgentMode>>,
57    #[cfg(feature = "swarm")]
58    pub swarm: Arc<std::sync::RwLock<Option<Arc<crate::swarm::SwarmCoordinator>>>>,
59    pending_plans: PendingPlans,
60    hooks: Arc<std::sync::RwLock<Vec<Arc<dyn ToolHook>>>>,
61    stream_sink: Arc<std::sync::RwLock<Option<crate::agent::stream::AgentStreamTx>>>,
62    // ── New integrations ──
63    /// Per-chat guardrail state
64    guardrails: Arc<RwLock<HashMap<String, ToolGuardrails>>>,
65    /// Pluggable compactor (default: DefaultCompactor)
66    compactor: Option<Arc<dyn Compactor>>,
67    /// Lifecycle hook manager (from plugin system)
68    hook_manager: Arc<HookManager>,
69    /// Plugin registry for lifecycle events
70    plugin_registry: Arc<RwLock<PluginRegistry>>,
71    /// Current trajectory being recorded (per chat)
72    trajectories: Arc<RwLock<HashMap<String, Trajectory>>>,
73    memory_ideas: crate::config::MemoryIdeasConfig,
74    group_chat: crate::config::GroupChatConfig,
75    #[cfg(feature = "zkr-memory")]
76    zkr: Option<Arc<crate::memory::zkr::ZkrStore>>,
77    #[cfg(feature = "zkr-memory")]
78    zkr_config: crate::config::ZkrConfig,
79    session_note_workspace: Option<PathBuf>,
80}
81
82impl AgentRunner {
83    #[allow(clippy::too_many_arguments)]
84    pub fn new(
85        provider: Arc<dyn Provider>,
86        tools: Vec<Arc<dyn Tool>>,
87        memory: Arc<dyn MemoryBackend>,
88        system_prompt: impl Into<String>,
89        model: impl Into<String>,
90    ) -> Self {
91        let model_str = model.into();
92        Self {
93            provider,
94            tools: Arc::new(RwLock::new(tools)),
95            memory,
96            system_prompt: Arc::new(RwLock::new(system_prompt.into())),
97            default_model: model_str.clone(),
98            model: std::sync::RwLock::new(model_str),
99            workspace: PathBuf::from("."),
100            skills: Arc::new(RwLock::new(Vec::new())),
101            cost_tracker: Arc::new(CostTracker::new()),
102            steering_queue: Arc::new(std::sync::Mutex::new(Vec::new())),
103            agent_config: crate::config::AgentConfig::default(),
104            mode: Arc::new(std::sync::RwLock::new(AgentMode::default())),
105            #[cfg(feature = "swarm")]
106            swarm: Arc::new(std::sync::RwLock::new(None)),
107            pending_plans: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
108            hooks: Arc::new(std::sync::RwLock::new(Vec::new())),
109            stream_sink: Arc::new(std::sync::RwLock::new(None)),
110            guardrails: Arc::new(RwLock::new(HashMap::new())),
111            compactor: Some(Arc::new(DefaultCompactor::new())),
112            hook_manager: Arc::new(HookManager::new()),
113            plugin_registry: Arc::new(RwLock::new(PluginRegistry::new())),
114            trajectories: Arc::new(RwLock::new(HashMap::new())),
115            memory_ideas: crate::config::MemoryIdeasConfig::default(),
116            group_chat: crate::config::GroupChatConfig::default(),
117            #[cfg(feature = "zkr-memory")]
118            zkr: None,
119            #[cfg(feature = "zkr-memory")]
120            zkr_config: crate::config::ZkrConfig::default(),
121            session_note_workspace: None,
122        }
123    }
124
125    // ── Existing setters ──
126
127    pub fn set_stream_sink(&self, tx: Option<crate::agent::stream::AgentStreamTx>) {
128        *self.stream_sink.write().unwrap() = tx;
129    }
130
131    pub fn stream_sink(&self) -> Option<crate::agent::stream::AgentStreamTx> {
132        self.stream_sink.read().unwrap().clone()
133    }
134
135    #[cfg(feature = "swarm")]
136    pub fn with_swarm(self, coordinator: Arc<crate::swarm::SwarmCoordinator>) -> Self {
137        *self.swarm.write().unwrap() = Some(coordinator);
138        self
139    }
140
141    pub fn with_config(mut self, config: crate::config::AgentConfig) -> Self {
142        self.agent_config = config;
143        self
144    }
145
146    pub fn with_mode(self, mode: AgentMode) -> Self {
147        *self.mode.write().unwrap() = mode;
148        self
149    }
150
151    pub async fn with_plugin_registry(self, registry: PluginRegistry) -> Self {
152        *self.plugin_registry.write().await = registry;
153        self
154    }
155
156    pub fn with_compactor(mut self, compactor: Arc<dyn Compactor>) -> Self {
157        self.compactor = Some(compactor);
158        self
159    }
160
161    pub fn get_mode(&self) -> AgentMode {
162        self.mode.read().unwrap().clone()
163    }
164
165    pub fn set_mode(&self, mode: AgentMode) {
166        *self.mode.write().unwrap() = mode;
167    }
168
169    pub fn mode_handle(&self) -> Arc<std::sync::RwLock<AgentMode>> {
170        self.mode.clone()
171    }
172
173    pub fn add_hook(&self, hook: Arc<dyn ToolHook>) {
174        self.hooks.write().unwrap().push(hook);
175    }
176
177    pub fn steer(&self, message: String) {
178        self.steering_queue.lock().unwrap().push(message);
179    }
180
181    pub fn with_workspace(mut self, workspace: PathBuf) -> Self {
182        self.session_note_workspace = Some(workspace.clone());
183        self.workspace = workspace;
184        self
185    }
186
187    pub fn with_memory_ideas(mut self, cfg: crate::config::MemoryIdeasConfig) -> Self {
188        self.memory_ideas = cfg;
189        self
190    }
191
192    pub fn with_group_chat(mut self, cfg: crate::config::GroupChatConfig) -> Self {
193        self.group_chat = cfg;
194        self
195    }
196
197    #[cfg(feature = "zkr-memory")]
198    pub fn with_zkr(
199        mut self,
200        store: Option<Arc<crate::memory::zkr::ZkrStore>>,
201        cfg: crate::config::ZkrConfig,
202    ) -> Self {
203        self.zkr = store;
204        self.zkr_config = cfg;
205        self
206    }
207
208    pub async fn with_skills(self, skills: Vec<skills::Skill>) -> Self {
209        *self.skills.write().await = skills;
210        self
211    }
212
213    pub fn cost_tracker(&self) -> Arc<CostTracker> {
214        self.cost_tracker.clone()
215    }
216
217    pub async fn get_cost_summary(&self) -> crate::cost::CostSummary {
218        self.cost_tracker.summary().await
219    }
220
221    pub fn get_model(&self) -> String {
222        self.model.read().unwrap().clone()
223    }
224
225    pub fn get_default_model(&self) -> &str {
226        &self.default_model
227    }
228
229    pub fn set_model(&self, model: impl Into<String>) {
230        *self.model.write().unwrap() = model.into();
231    }
232
233    pub fn reset_model(&self) {
234        *self.model.write().unwrap() = self.default_model.clone();
235    }
236
237    pub async fn list_tools(&self) -> Vec<String> {
238        self.tools
239            .read()
240            .await
241            .iter()
242            .map(|t| t.name().to_string())
243            .collect()
244    }
245
246    pub async fn add_tool(&self, tool: Arc<dyn Tool>) {
247        self.tools.write().await.push(tool);
248    }
249
250    /// Access the hook manager (for plugin tool registration)
251    pub fn hook_manager(&self) -> &Arc<HookManager> {
252        &self.hook_manager
253    }
254
255    /// Get the plugin registry
256    pub fn plugin_registry(&self) -> &Arc<RwLock<PluginRegistry>> {
257        &self.plugin_registry
258    }
259
260    // ── Deploy coding swarm ──
261
262    pub async fn deploy_coding_swarm(
263        self: Arc<Self>,
264        tasks: Vec<String>,
265        base_chat_id: &str,
266        parallelism: usize,
267    ) -> Vec<(String, String)> {
268        #[cfg(feature = "swarm")]
269        {
270            let swarm_opt = {
271                let s = self.swarm.read().unwrap();
272                s.clone()
273            };
274
275            if let Some(coordinator) = swarm_opt {
276                tracing::info!("Deploying swarm via SwarmCoordinator (lane-based)");
277                return coordinator
278                    .deploy_parallel_agents(self, tasks, base_chat_id, parallelism)
279                    .await;
280            }
281        }
282
283        tracing::info!("Deploying swarm via direct spawning (fallback)");
284        let parallelism = parallelism.max(1);
285        let mut all_results = Vec::new();
286
287        for (chunk_idx, chunk) in tasks.chunks(parallelism).enumerate() {
288            let handles: Vec<_> = chunk
289                .iter()
290                .enumerate()
291                .map(|(i, task)| {
292                    let runner = self.clone();
293                    let chat_id = format!("{}_sw{}_{}", base_chat_id, chunk_idx, i);
294                    let task = task.clone();
295                    tokio::spawn(async move {
296                        let msg = IncomingMessage {
297                            id: format!("sw_{}_{}", chunk_idx, i),
298                            sender_id: "swarm".to_string(),
299                            sender_name: None,
300                            chat_id,
301                            text: task.clone(),
302                            is_group: false,
303                            reply_to: None,
304                            timestamp: chrono::Utc::now(),
305                        };
306                        let null_ch = NullChannel::new("swarm");
307                        let result = runner
308                            .handle_message(&msg, &null_ch)
309                            .await
310                            .unwrap_or_else(|e| format!("⚠️ Agent error: {}", e));
311                        (task, result)
312                    })
313                })
314                .collect();
315
316            for handle in handles {
317                match handle.await {
318                    Ok(result) => all_results.push(result),
319                    Err(e) => tracing::warn!("Swarm worker panicked: {}", e),
320                }
321            }
322        }
323
324        all_results
325    }
326
327    // ── Run ──
328
329    pub async fn run(&self, channel: &mut dyn Channel) -> anyhow::Result<()> {
330        let mut rx = channel.start().await?;
331        tracing::info!("Agent started on channel: {}", channel.name());
332
333        while let Some(msg) = rx.recv().await {
334            let _ = channel.send_typing(&msg.chat_id).await;
335
336            match self.handle_message(&msg.clone(), channel).await {
337                Ok(response) => {
338                    if response.trim().is_empty() {
339                        continue;
340                    }
341                    channel
342                        .send(OutgoingMessage {
343                            chat_id: msg.chat_id.clone(),
344                            text: response,
345                            reply_to: Some(msg.id.clone()),
346                        })
347                        .await?;
348                }
349                Err(e) => {
350                    tracing::error!("Error handling message: {}", e);
351                    channel
352                        .send(OutgoingMessage {
353                            chat_id: msg.chat_id,
354                            text: format!("Error: {}", e),
355                            reply_to: Some(msg.id),
356                        })
357                        .await?;
358                }
359            }
360        }
361
362        channel.stop().await?;
363        Ok(())
364    }
365
366    pub async fn run_with_extra_rx(
367        &self,
368        channel: &mut dyn Channel,
369        mut extra_rx: mpsc::Receiver<IncomingMessage>,
370    ) -> anyhow::Result<()> {
371        let mut rx = channel.start().await?;
372        tracing::info!(
373            "Agent started on channel: {} (with heartbeat)",
374            channel.name()
375        );
376
377        loop {
378            let msg = tokio::select! {
379                Some(msg) = rx.recv() => msg,
380                Some(msg) = extra_rx.recv() => msg,
381                else => break,
382            };
383
384            let _ = channel.send_typing(&msg.chat_id).await;
385
386            match self.handle_message(&msg, channel).await {
387                Ok(response) => {
388                    if msg.sender_id == "system" && response.contains("HEARTBEAT_OK") {
389                        tracing::debug!("Heartbeat: agent responded OK, skipping output");
390                        continue;
391                    }
392                    if response.trim().is_empty() {
393                        continue;
394                    }
395                    channel
396                        .send(OutgoingMessage {
397                            chat_id: msg.chat_id.clone(),
398                            text: response,
399                            reply_to: Some(msg.id.clone()),
400                        })
401                        .await?;
402                }
403                Err(e) => {
404                    tracing::error!("Error handling message: {}", e);
405                    if msg.sender_id != "system" {
406                        channel
407                            .send(OutgoingMessage {
408                                chat_id: msg.chat_id,
409                                text: format!("Error: {}", e),
410                                reply_to: Some(msg.id),
411                            })
412                            .await?;
413                    }
414                }
415            }
416        }
417
418        channel.stop().await?;
419        Ok(())
420    }
421
422    pub async fn run_with_runtime_rx(
423        &self,
424        channel: &mut dyn Channel,
425        mut extra_rx: mpsc::Receiver<IncomingMessage>,
426        mut cron_rx: mpsc::Receiver<crate::cron_scheduler::DueJob>,
427        scheduler: Arc<crate::cron_scheduler::CronScheduler>,
428    ) -> anyhow::Result<()> {
429        let mut rx = channel.start().await?;
430        loop {
431            enum RuntimeInput {
432                Message(IncomingMessage),
433                Cron(crate::cron_scheduler::DueJob),
434            }
435            let input = tokio::select! {
436                Some(msg) = rx.recv() => RuntimeInput::Message(msg),
437                Some(msg) = extra_rx.recv() => RuntimeInput::Message(msg),
438                Some(job) = cron_rx.recv() => RuntimeInput::Cron(job),
439                else => break,
440            };
441            match input {
442                RuntimeInput::Message(msg) => {
443                    let _ = channel.send_typing(&msg.chat_id).await;
444                    match self.handle_message(&msg, channel).await {
445                        Ok(response) if !response.trim().is_empty() => {
446                            channel
447                                .send(OutgoingMessage {
448                                    chat_id: msg.chat_id,
449                                    text: response,
450                                    reply_to: Some(msg.id),
451                                })
452                                .await?;
453                        }
454                        Ok(_) => {}
455                        Err(error) if msg.sender_id != "system" => {
456                            channel
457                                .send(OutgoingMessage {
458                                    chat_id: msg.chat_id,
459                                    text: format!("Error: {error}"),
460                                    reply_to: Some(msg.id),
461                                })
462                                .await?;
463                        }
464                        Err(error) => tracing::error!("Error handling message: {error}"),
465                    }
466                }
467                RuntimeInput::Cron(due) => {
468                    let job = due.job;
469                    let job_id = job.id.clone().unwrap_or_default();
470                    let run_token = job.run_token.clone().unwrap_or_default();
471                    if job.channel != channel.name() {
472                        scheduler.release_run(&job_id, &run_token).await?;
473                        continue;
474                    }
475                    let msg = IncomingMessage {
476                        id: format!("cron-{job_id}"),
477                        sender_id: "scheduler".to_string(),
478                        sender_name: Some("Scheduler".to_string()),
479                        chat_id: job.chat_id.clone(),
480                        text: job.task.clone(),
481                        is_group: false,
482                        reply_to: None,
483                        timestamp: chrono::Utc::now(),
484                    };
485                    match self
486                        .handle_message_with_model(
487                            &msg,
488                            channel,
489                            (!job.model.is_empty()).then_some(job.model.as_str()),
490                        )
491                        .await
492                    {
493                        Ok(response) => {
494                            if !response.trim().is_empty() {
495                                if let Err(error) = channel
496                                    .send(OutgoingMessage {
497                                        chat_id: job.chat_id,
498                                        text: response,
499                                        reply_to: None,
500                                    })
501                                    .await
502                                {
503                                    scheduler
504                                        .fail_run(&job_id, &run_token, &error.to_string())
505                                        .await?;
506                                    continue;
507                                }
508                            }
509                            scheduler
510                                .mark_run(&job_id, &run_token, &job.schedule)
511                                .await?;
512                        }
513                        Err(error) => {
514                            scheduler
515                                .fail_run(&job_id, &run_token, &error.to_string())
516                                .await?
517                        }
518                    }
519                }
520            }
521        }
522        channel.stop().await?;
523        Ok(())
524    }
525
526    // ── Handle single message ──
527
528    pub async fn handle_message(
529        &self,
530        msg: &IncomingMessage,
531        channel: &dyn Channel,
532    ) -> anyhow::Result<String> {
533        self.handle_message_with_model(msg, channel, None).await
534    }
535
536    pub async fn handle_message_with_model(
537        &self,
538        msg: &IncomingMessage,
539        channel: &dyn Channel,
540        model: Option<&str>,
541    ) -> anyhow::Result<String> {
542        let stream = self.stream_sink();
543        emit(
544            &stream,
545            AgentStreamEvent::Status {
546                message: "Thinking…".into(),
547            },
548        );
549
550        // Emit lifecycle event: agent start
551        self.hook_manager
552            .emit(&LifecycleEvent::AgentStart(
553                msg.chat_id.clone(),
554                msg.text.clone(),
555            ))
556            .await;
557
558        // Initialize per-chat guardrails
559        {
560            let mut gr = self.guardrails.write().await;
561            gr.entry(msg.chat_id.clone()).or_insert_with(|| {
562                ToolGuardrails::new(crate::tools::guardrails::GuardrailConfig::default())
563            });
564        }
565
566        // Initialize per-chat trajectory
567        {
568            let mut trajs = self.trajectories.write().await;
569            if !trajs.contains_key(&msg.chat_id) {
570                let t = Trajectory::new(
571                    format!("traj_{}", chrono::Utc::now().timestamp()),
572                    msg.chat_id.clone(),
573                    self.get_model(),
574                );
575                trajs.insert(msg.chat_id.clone(), t);
576            }
577        }
578
579        let draft_id: Option<String> = if channel.supports_draft_updates() {
580            channel.send_draft(&msg.chat_id, "⏳").await.unwrap_or(None)
581        } else {
582            let _ = channel.send_typing(&msg.chat_id).await;
583            None
584        };
585
586        if msg.is_group && !crate::context::should_respond(msg) {
587            tracing::debug!(
588                "Skipping ambient group message without assistant context: {}",
589                msg.id
590            );
591            return Ok(String::new());
592        }
593
594        let (effective_text, resume_plan, resume_model) = {
595            let mut plans = self.pending_plans.lock().unwrap();
596            if let Some(pending) = plans.get(&msg.chat_id) {
597                if pending.is_expired() {
598                    plans.remove(&msg.chat_id);
599                    (msg.text.clone(), None, None)
600                } else if is_approval(&msg.text) {
601                    let pending = plans.remove(&msg.chat_id).unwrap();
602                    tracing::info!("Plan approved for chat_id={}", msg.chat_id);
603                    (
604                        pending.original_message,
605                        Some(pending.plan),
606                        Some(pending.preferred_model),
607                    )
608                } else if is_rejection(&msg.text) {
609                    plans.remove(&msg.chat_id);
610                    return Ok("Plan cancelled. What would you like to do instead?".to_string());
611                } else {
612                    plans.remove(&msg.chat_id);
613                    (msg.text.clone(), None, None)
614                }
615            } else {
616                (msg.text.clone(), None, None)
617            }
618        };
619        let resuming_from_plan = resume_plan.is_some();
620
621        let mode = self.get_mode();
622        let hooks_snapshot: Vec<Arc<dyn ToolHook>> = self.hooks.read().unwrap().clone();
623
624        // ── Build messages ──
625
626        let base_prompt = self.system_prompt.read().await.clone();
627        #[cfg(feature = "zkr-memory")]
628        let system_prompt = if self.zkr_config.self_improve {
629            if let Some(store) = &self.zkr {
630                match store.augment_prompt(&effective_text, &base_prompt).await {
631                    Ok(augmented) => augmented,
632                    Err(error) => {
633                        tracing::warn!("self-improve augmentation failed: {error}");
634                        base_prompt
635                    }
636                }
637            } else {
638                base_prompt
639            }
640        } else {
641            base_prompt
642        };
643        #[cfg(not(feature = "zkr-memory"))]
644        let system_prompt = base_prompt;
645        let mut messages = vec![ChatMessage::system(&system_prompt)];
646        if let Some(guidance) = crate::context::routing_guidance(msg.is_group, channel.name()) {
647            messages.push(ChatMessage::system(guidance));
648        }
649
650        if let Some(mode_prompt) = mode.system_prompt_injection() {
651            messages.push(ChatMessage::system(mode_prompt));
652        }
653
654        // Skill injection with template preprocessing
655        {
656            let skills = self.skills.read().await;
657            if let Some(skill) = skills::match_skill(&skills, &effective_text) {
658                if let Some(content) = skills::load_skill_content(skill) {
659                    let preprocessed = skills::preprocess_skill_content(
660                        &content,
661                        skill.location.parent(),
662                        Some(&msg.chat_id),
663                        Some(&self.workspace),
664                    );
665                    messages.push(ChatMessage::system(format!(
666                        "# Active Skill: {}\n{}\n\nFollow the instructions above for this skill.",
667                        skill.name, preprocessed
668                    )));
669                    tracing::info!("Skill matched: {} (preprocessed)", skill.name);
670                }
671            }
672        }
673        if msg.is_group {
674            if let Some(group_memory) = self.load_group_memory(&msg.chat_id).await? {
675                if !group_memory.trim().is_empty() {
676                    messages.push(ChatMessage::system(crate::context::group_memory_prompt(
677                        &msg.chat_id,
678                        &group_memory,
679                    )));
680                }
681            }
682        }
683
684        let history = crate::memory::context_inject::merged_history(
685            &self.memory,
686            &msg.chat_id,
687            self.memory_ideas.principal_id.as_deref(),
688            self.agent_config.max_history_messages,
689        )
690        .await?;
691        for (role, content) in history {
692            match role.as_str() {
693                "user" => messages.push(ChatMessage::user(&content)),
694                "assistant" => messages.push(ChatMessage::assistant(&content)),
695                _ => {}
696            }
697        }
698
699        let mut user_turn = effective_text.clone();
700        if self.memory_ideas.inject_context {
701            let blocks = crate::memory::context_inject::personal_context_blocks(
702                &self.memory,
703                crate::memory::context_inject::InjectConfig {
704                    workspace: &self.workspace,
705                    principal_id: self.memory_ideas.principal_id.as_deref(),
706                    graph_recall_limit: self.memory_ideas.graph_recall_limit,
707                },
708                &effective_text,
709            )
710            .await;
711            if !blocks.is_empty() {
712                user_turn = format!("{user_turn}\n\n{}", blocks.join("\n\n"));
713            }
714        }
715        #[cfg(feature = "zkr-memory")]
716        if self.zkr_config.inject_recall {
717            if let Some(store) = &self.zkr {
718                match store
719                    .context(&effective_text, self.zkr_config.recall_limit)
720                    .await
721                {
722                    Ok(Some(context)) => user_turn = format!("{user_turn}\n\n{context}"),
723                    Ok(None) => {}
724                    Err(error) => tracing::warn!("zkr recall failed: {error}"),
725                }
726            }
727        }
728        messages.push(ChatMessage::user(&user_turn));
729
730        let tool_specs: Vec<crate::tools::ToolSpec> =
731            self.tools.read().await.iter().map(|t| t.spec()).collect();
732        let tools_snapshot: Vec<Arc<dyn Tool>> = self.tools.read().await.iter().cloned().collect();
733        let main_model = model
734            .map(str::to_string)
735            .unwrap_or_else(|| self.model.read().unwrap().clone());
736
737        // ═══════════════════════════════════════════════════════
738        // STATE MACHINE: Planning → Executing → Summarizing
739        // ═══════════════════════════════════════════════════════
740
741        let needs_tools = if resuming_from_plan {
742            true
743        } else {
744            self.classify_request(&effective_text, &main_model).await
745        };
746        let mut state = if needs_tools {
747            AgentState::Planning
748        } else {
749            AgentState::Direct
750        };
751        tracing::info!("Initial state: {:?}", state);
752
753        let mut _plan: Option<String> = None;
754        let mut execution_model = main_model.clone();
755
756        if let Some(ref plan) = resume_plan {
757            messages.push(ChatMessage::system(format!(
758                "APPROVED EXECUTION PLAN (follow these steps):\n{}",
759                plan
760            )));
761            execution_model = resume_model.unwrap_or_else(|| main_model.clone());
762            state = AgentState::Executing;
763            tracing::info!("Resuming with approved plan, model={}", execution_model);
764        } else if state == AgentState::Planning {
765            let swarm_model_choice = if mode.is_swarm() {
766                "   - SWARM: for tasks that can be parallelized across multiple independent agents\n"
767            } else {
768                ""
769            };
770            let plan_prompt = format!(
771                "You are a planning assistant. Analyze this request and output TWO things:\n\n\
772                1. MODEL_CHOICE: Pick one execution model:\n\
773                   - SONNET: for general tasks, file ops, web, simple edits, queries\n\
774                   - OPUS: for complex coding, architecture, multi-file refactors, debugging hard bugs\n\
775                   - VIBEMANIA: for building features, creating projects, coding tasks that need autonomous agents\n\
776                {swarm_choice}\
777                2. PLAN: A brief numbered step-by-step plan.\n\
778                   - If VIBEMANIA: the plan should be a single step: delegate to vibemania/subspace with the goal\n\
779                   - If SWARM: the plan should list each independent subtask for parallel agents\n\
780                   - If SONNET/OPUS: list what tools to use and in what order\n\n\
781                Format your response EXACTLY like:\n\
782                MODEL_CHOICE: SONNET\n\
783                PLAN:\n\
784                1. step one\n\
785                2. step two\n\n\
786                Available tools: {tools}\n\n\
787                User request: {request}",
788                swarm_choice = swarm_model_choice,
789                tools = tool_specs.iter().map(|t| format!("{} ({})", t.name, t.description.chars().take(50).collect::<String>())).collect::<Vec<_>>().join(", "),
790                request = effective_text
791            );
792
793            let plan_messages = [ChatMessage::user(&plan_prompt)];
794            let plan_request = ChatRequest {
795                messages: &plan_messages,
796                tools: None,
797                model: &self.agent_config.fast_model,
798                temperature: 0.8,
799                max_tokens: Some(500),
800            };
801
802            match self.provider.chat(&plan_request).await {
803                Ok(resp) => {
804                    let p = resp.text.unwrap_or_default();
805                    tracing::info!("Plan: {}", &p[..p.len().min(300)]);
806
807                    if let Some(usage) = &resp.usage {
808                        let _ = self
809                            .cost_tracker
810                            .record(
811                                &self.agent_config.fast_model,
812                                TokenUsage {
813                                    input_tokens: usage.input_tokens as usize,
814                                    output_tokens: usage.output_tokens as usize,
815                                    total_tokens: (usage.input_tokens + usage.output_tokens)
816                                        as usize,
817                                },
818                            )
819                            .await;
820                    }
821
822                    let p_upper = p.to_uppercase();
823                    if p_upper.contains("MODEL_CHOICE: OPUS")
824                        || p_upper.contains("MODEL_CHOICE:OPUS")
825                    {
826                        execution_model = self.agent_config.heavy_model.clone();
827                        tracing::info!("Planner chose OPUS for execution");
828                    } else if p_upper.contains("MODEL_CHOICE: SWARM")
829                        || p_upper.contains("MODEL_CHOICE:SWARM")
830                    {
831                        tracing::info!("Planner chose SWARM — routing to coding_swarm tool");
832                        messages.push(ChatMessage::system(
833                            "IMPORTANT: This task can be parallelized. Use the `coding_swarm` tool \
834                            to execute subtasks in parallel agents. \n\
835                            Decompose the original goal into a list of independent tasks for the swarm."
836                                .to_string(),
837                        ));
838                    } else if p_upper.contains("MODEL_CHOICE: VIBEMANIA")
839                        || p_upper.contains("MODEL_CHOICE:VIBEMANIA")
840                    {
841                        tracing::info!("Planner chose VIBEMANIA — routing to vibemania tool");
842                        messages.push(ChatMessage::system(
843                            "IMPORTANT: This is a complex coding task. Use the `vibemania` tool \
844                            to handle this autonomously. \n\
845                            Do NOT write code yourself — delegate to vibemania."
846                                .to_string(),
847                        ));
848                    }
849
850                    if mode.prefer_heavy_model() && execution_model == main_model {
851                        execution_model = self.agent_config.heavy_model.clone();
852                        tracing::info!("Coding mode: upgraded to heavy model");
853                    }
854
855                    messages.push(ChatMessage::system(format!(
856                        "EXECUTION PLAN (follow these steps):\n{}",
857                        p
858                    )));
859                    _plan = Some(p.clone());
860                    state = AgentState::Executing;
861
862                    if mode.plan_approval() {
863                        {
864                            let mut plans = self.pending_plans.lock().unwrap();
865                            plans.retain(|_, v| !v.is_expired());
866                            plans.insert(
867                                msg.chat_id.clone(),
868                                PendingPlan {
869                                    plan: p.clone(),
870                                    original_message: effective_text.clone(),
871                                    preferred_model: execution_model.clone(),
872                                    created_at: std::time::Instant::now(),
873                                },
874                            );
875                        }
876                        let plan_response = format!(
877                            "**Coding Plan**\n\n{}\n\n---\nReply **go** to execute or **cancel** to abort.",
878                            p
879                        );
880                        if let Some(ref mid) = draft_id {
881                            let _ = channel
882                                .finalize_draft(&msg.chat_id, mid, &plan_response)
883                                .await;
884                            return Ok(String::new());
885                        }
886                        return Ok(plan_response);
887                    }
888                }
889                Err(e) => {
890                    tracing::warn!("Planning failed ({}), falling back to direct", e);
891                    state = AgentState::Direct;
892                }
893            }
894        }
895
896        // ═══════════════════════════════════════════════════════
897        // EXECUTION LOOP (with self-healing + guardrails)
898        // ═══════════════════════════════════════════════════════
899
900        let mut tool_call_history: Vec<String> = Vec::new();
901        let mut compactions_done: usize = 0;
902        // Self-healing tracking: per-tool consecutive error count for this execution
903        let mut healing_attempts_left = 3;
904
905        for round in 0..self.agent_config.max_rounds {
906            // ── Steering queue ──
907            {
908                let mut queue = self.steering_queue.lock().unwrap();
909                if !queue.is_empty() {
910                    for steer_msg in queue.drain(..) {
911                        tracing::info!("Steering: {}", &steer_msg[..steer_msg.len().min(80)]);
912                        messages.push(ChatMessage::user(format!(
913                            "⚡ STEERING — new instruction from user (prioritize this): {}",
914                            steer_msg
915                        )));
916                    }
917                }
918            }
919
920            // ── Context budget check with compactor ──
921            let context_chars: usize = messages.iter().map(|m| m.content.len()).sum();
922            let max_chars = self.agent_config.max_context_chars;
923            if let Some(ref compactor) = self.compactor {
924                let info = ContextInfo {
925                    message_count: messages.len(),
926                    total_chars: context_chars,
927                    max_chars,
928                    compactions_done,
929                };
930                if compactor.should_compress(&info) {
931                    tracing::info!(
932                        "Compacting at round {} ({} chars, {} msgs)",
933                        round + 1,
934                        context_chars,
935                        messages.len()
936                    );
937                    let result = compactor.compress(&messages, Some(&effective_text)).await;
938                    if result.did_compact {
939                        messages = result.messages;
940                        compactions_done += 1;
941                    }
942                }
943            } else {
944                // Fallback: original inline compaction
945                if context_chars > max_chars {
946                    tracing::info!(
947                        "Compacting at round {} ({} chars)",
948                        round + 1,
949                        context_chars
950                    );
951                    messages = self.compact_messages(messages, &effective_text).await?;
952                    compactions_done += 1;
953                }
954            }
955
956            // ── Select model ──
957            let (model, temperature) = match state {
958                AgentState::Planning => (self.agent_config.fast_model.clone(), 0.8),
959                AgentState::Executing => (execution_model.clone(), 0.2),
960                AgentState::Summarizing => (self.agent_config.fast_model.clone(), 0.7),
961                AgentState::Direct => (main_model.clone(), 0.7),
962            };
963
964            tracing::info!(
965                "[{:?}] round {} {} msgs, ~{} chars, model={}",
966                state,
967                round + 1,
968                messages.len(),
969                messages.iter().map(|m| m.content.len()).sum::<usize>(),
970                model
971            );
972
973            // ── LLM call ──
974            let request = ChatRequest {
975                messages: &messages,
976                tools: if tool_specs.is_empty() || state == AgentState::Summarizing {
977                    None
978                } else {
979                    Some(&tool_specs)
980                },
981                model: &model,
982                temperature,
983                max_tokens: Some(8192),
984            };
985
986            let response = self.provider.chat(&request).await?;
987
988            if let Some(usage) = &response.usage {
989                let _ = self
990                    .cost_tracker
991                    .record(
992                        &model,
993                        TokenUsage {
994                            input_tokens: usage.input_tokens as usize,
995                            output_tokens: usage.output_tokens as usize,
996                            total_tokens: (usage.input_tokens + usage.output_tokens) as usize,
997                        },
998                    )
999                    .await;
1000            }
1001
1002            if !response.has_tool_calls() {
1003                let text = response.text.unwrap_or_default();
1004
1005                match state {
1006                    AgentState::Executing => {
1007                        if round >= 3 {
1008                            tracing::info!(
1009                                "Execution done after {} rounds, summarizing",
1010                                round + 1
1011                            );
1012                            state = AgentState::Summarizing;
1013                            messages.push(ChatMessage::assistant(text));
1014                            messages.push(ChatMessage::user(
1015                                "Now provide a clean, concise final response to the user. \
1016                                Summarize what you did and the results. Be brief and direct."
1017                                    .to_string(),
1018                            ));
1019                            continue;
1020                        }
1021                        tracing::info!("Done after {} round(s) [{:?}]", round + 1, state);
1022                        self.finish_execution(msg, &text, &draft_id, channel)
1023                            .await?;
1024                        return Ok(String::new());
1025                    }
1026                    AgentState::Summarizing | AgentState::Direct | AgentState::Planning => {
1027                        tracing::info!("Done after {} round(s) [{:?}]", round + 1, state);
1028                        self.finish_execution(msg, &text, &draft_id, channel)
1029                            .await?;
1030                        return Ok(String::new());
1031                    }
1032                }
1033            }
1034
1035            // ── TOOL EXECUTION ──
1036
1037            // Legacy loop detection (backup for guardrails)
1038            for tc in &response.tool_calls {
1039                let hash = format!(
1040                    "{}:{}",
1041                    tc.name,
1042                    &tc.arguments[..tc.arguments.len().min(200)]
1043                );
1044                tool_call_history.push(hash);
1045            }
1046            if tool_call_history.len() >= LOOP_BREAK_THRESHOLD {
1047                let last = &tool_call_history[tool_call_history.len() - 1];
1048                let consecutive = tool_call_history
1049                    .iter()
1050                    .rev()
1051                    .take_while(|h| *h == last)
1052                    .count();
1053                if consecutive >= LOOP_BREAK_THRESHOLD {
1054                    tracing::warn!("Loop: {} identical calls, breaking", consecutive);
1055                    return Ok(format!(
1056                        "Loop detected ({} identical {} calls). Stopping.",
1057                        consecutive, response.tool_calls[0].name
1058                    ));
1059                }
1060                if consecutive >= LOOP_WARN_THRESHOLD {
1061                    messages.push(ChatMessage::user(
1062                        "WARNING: You're repeating tool calls. Stop and answer with what you have."
1063                            .to_string(),
1064                    ));
1065                }
1066            }
1067
1068            if !channel.supports_draft_updates() {
1069                let _ = channel.send_typing(&msg.chat_id).await;
1070            }
1071
1072            // Build assistant tool_use message
1073            {
1074                let mut content_blocks: Vec<serde_json::Value> = Vec::new();
1075                if let Some(text) = &response.text {
1076                    if !text.is_empty() {
1077                        content_blocks.push(serde_json::json!({
1078                            "type": "text", "text": text,
1079                        }));
1080                    }
1081                }
1082                for tc in &response.tool_calls {
1083                    content_blocks.push(serde_json::json!({
1084                        "type": "tool_use",
1085                        "id": &tc.id,
1086                        "name": &tc.name,
1087                        "input": serde_json::from_str::<serde_json::Value>(&tc.arguments).unwrap_or_default(),
1088                    }));
1089                }
1090                messages.push(ChatMessage {
1091                    role: "assistant_tool_use".to_string(),
1092                    content: String::new(),
1093                    tool_use_id: Some(serde_json::to_string(&content_blocks).unwrap_or_default()),
1094                });
1095            }
1096
1097            tracing::info!(
1098                "Tools: {}",
1099                response
1100                    .tool_calls
1101                    .iter()
1102                    .map(|tc| tc.name.as_str())
1103                    .collect::<Vec<_>>()
1104                    .join(", ")
1105            );
1106
1107            let mut progress_lines: Vec<String> = Vec::new();
1108            let mut tool_errors: Vec<String> = Vec::new();
1109
1110            for tc in &response.tool_calls {
1111                let _ = self
1112                    .hook_manager
1113                    .emit(&LifecycleEvent::BeforeToolCall(
1114                        tc.name.clone(),
1115                        tc.arguments.clone(),
1116                    ))
1117                    .await;
1118
1119                if let (true, Some(ref mid)) = (channel.supports_draft_updates(), &draft_id) {
1120                    let hint = extract_tool_hint(&tc.name, &tc.arguments);
1121                    let start_line = if hint.is_empty() {
1122                        format!("⏳ {}\n", tc.name)
1123                    } else {
1124                        format!("⏳ {}: {}\n", tc.name, hint)
1125                    };
1126                    progress_lines.push(start_line.clone());
1127                    let _ = channel
1128                        .update_draft_progress(&msg.chat_id, mid, &progress_lines.join(""))
1129                        .await;
1130                }
1131
1132                emit(
1133                    &stream,
1134                    AgentStreamEvent::ToolStart {
1135                        name: tc.name.clone(),
1136                        hint: extract_tool_hint(&tc.name, &tc.arguments),
1137                    },
1138                );
1139
1140                let started = std::time::Instant::now();
1141
1142                // ── Plugin pre-tool check ──
1143                let pre_tool_decision = {
1144                    let registry = self.plugin_registry.read().await;
1145                    registry.check_pre_tool(&tc.name, &tc.arguments).await
1146                };
1147
1148                // ── Run tool ──
1149                let result = match pre_tool_decision {
1150                    crate::plugin::HookDecision::Block(reason) => {
1151                        tracing::info!("Plugin blocked '{}': {}", tc.name, reason);
1152                        crate::tools::ToolResult::error(format!("Blocked by plugin: {}", reason))
1153                    }
1154                    crate::plugin::HookDecision::Allow => {
1155                        match run_pre_hooks(&hooks_snapshot, &tc.name, &tc.arguments).await {
1156                            HookDecision::Block(reason) => {
1157                                tracing::info!("Hook blocked '{}': {}", tc.name, reason);
1158                                crate::tools::ToolResult::error(format!(
1159                                    "Blocked by policy: {}",
1160                                    reason
1161                                ))
1162                            }
1163                            HookDecision::Allow => {
1164                                if let Some(tool) =
1165                                    tools_snapshot.iter().find(|t| t.name() == tc.name)
1166                                {
1167                                    match tool.execute(&tc.arguments).await {
1168                                        Ok(r) => r,
1169                                        Err(e) => crate::tools::ToolResult::error(format!(
1170                                            "Tool error: {}",
1171                                            e
1172                                        )),
1173                                    }
1174                                } else {
1175                                    crate::tools::ToolResult::error(format!(
1176                                        "Unknown tool: {}",
1177                                        tc.name
1178                                    ))
1179                                }
1180                            }
1181                        }
1182                    }
1183                };
1184
1185                run_post_hooks(&hooks_snapshot, &tc.name, &tc.arguments, &result).await;
1186
1187                // ── Post-tool lifecycle + guardrails ──
1188                let _ = self
1189                    .hook_manager
1190                    .emit(&LifecycleEvent::AfterToolCall(
1191                        tc.name.clone(),
1192                        tc.arguments.clone(),
1193                        result.clone(),
1194                    ))
1195                    .await;
1196
1197                {
1198                    let mut gr = self.guardrails.write().await;
1199                    if let Some(g) = gr.get_mut(&msg.chat_id) {
1200                        let decision =
1201                            g.observe(&tc.name, &tc.arguments, &result.output, result.is_error);
1202                        match decision {
1203                            GuardrailDecision::Stop(reason) => {
1204                                tracing::warn!("Guardrail stop: {}", reason);
1205                                self.finish_execution(msg, &reason, &draft_id, channel)
1206                                    .await?;
1207                                return Ok(reason);
1208                            }
1209                            GuardrailDecision::Warn(warning) => {
1210                                tracing::warn!("Guardrail warn: {}", warning);
1211                                messages.push(ChatMessage::user(warning));
1212                            }
1213                            GuardrailDecision::Proceed => {}
1214                        }
1215                    }
1216                }
1217
1218                // ── Post-tool plugin notification ──
1219                {
1220                    let registry = self.plugin_registry.read().await;
1221                    registry
1222                        .notify_post_tool(&tc.name, &tc.arguments, &result)
1223                        .await;
1224                }
1225
1226                let elapsed = started.elapsed().as_secs();
1227                emit(
1228                    &stream,
1229                    AgentStreamEvent::ToolEnd {
1230                        name: tc.name.clone(),
1231                        ok: !result.is_error,
1232                        elapsed_secs: elapsed,
1233                    },
1234                );
1235
1236                // ── Track trajectory ──
1237                {
1238                    let mut trajs = self.trajectories.write().await;
1239                    if let Some(t) = trajs.get_mut(&msg.chat_id) {
1240                        t.record_tool_step(
1241                            response.text.clone(),
1242                            tc.name.clone(),
1243                            crate::redaction::redact_tool_payload(&tc.name, &tc.arguments),
1244                            crate::redaction::redact_tool_payload(&tc.name, &result.output),
1245                            !result.is_error,
1246                        );
1247                    }
1248                }
1249
1250                if let (true, Some(ref mid)) = (channel.supports_draft_updates(), &draft_id) {
1251                    let done_line = if result.is_error {
1252                        format!("❌ {} ({}s)\n", tc.name, elapsed)
1253                    } else {
1254                        format!("✅ {} ({}s)\n", tc.name, elapsed)
1255                    };
1256                    let prefix = format!("⏳ {}", tc.name);
1257                    if let Some(pos) = progress_lines.iter().rposition(|l| l.starts_with(&prefix)) {
1258                        progress_lines[pos] = done_line;
1259                    } else {
1260                        progress_lines.push(done_line);
1261                    }
1262                    let _ = channel
1263                        .update_draft_progress(&msg.chat_id, mid, &progress_lines.join(""))
1264                        .await;
1265                }
1266
1267                // ── Self-healing: track tool errors for re-prompt ──
1268                if result.is_error && automatic_retry_allowed(&tc.name, &result.output) {
1269                    tool_errors.push(format!(
1270                        "'{}' failed: {}",
1271                        tc.name,
1272                        &result.output[..result.output.len().min(200)]
1273                    ));
1274                }
1275
1276                let truncated_output =
1277                    if result.output.len() > self.agent_config.max_tool_result_chars {
1278                        format!(
1279                            "{}...\n⚠️ [Truncated {} → {} chars]",
1280                            &result.output[..self.agent_config.max_tool_result_chars],
1281                            result.output.len(),
1282                            self.agent_config.max_tool_result_chars
1283                        )
1284                    } else {
1285                        result.output.clone()
1286                    };
1287
1288                messages.push(ChatMessage::tool_result(&tc.id, &truncated_output));
1289            }
1290
1291            // ── Self-healing: if tools failed, re-prompt with error context ──
1292            if !tool_errors.is_empty() && healing_attempts_left > 0 {
1293                let error_summary = tool_errors.join("\n");
1294                tracing::info!(
1295                    "Self-healing: {} tool errors, {} attempts left",
1296                    tool_errors.len(),
1297                    healing_attempts_left
1298                );
1299                messages.push(ChatMessage::user(format!(
1300                    "The following tool call(s) failed:\n{}\n\n\
1301                    Please try a different approach. Previous steps are still valid — \
1302                    don't repeat what already worked unless needed.",
1303                    error_summary
1304                )));
1305                healing_attempts_left -= 1;
1306                // Reset guardrails per-turn state for next iteration
1307                {
1308                    let mut gr = self.guardrails.write().await;
1309                    if let Some(g) = gr.get_mut(&msg.chat_id) {
1310                        g.reset();
1311                    }
1312                }
1313                continue; // Re-prompt without incrementing the tool_call_history-based loop
1314            }
1315
1316            if state == AgentState::Direct {
1317                state = AgentState::Executing;
1318            }
1319        }
1320
1321        // ── Circuit breaker ──
1322        tracing::warn!(
1323            "Circuit breaker after {} rounds",
1324            self.agent_config.max_rounds
1325        );
1326        self.persist_conversation(
1327            msg,
1328            &format!("Hit {} rounds.", self.agent_config.max_rounds),
1329        )
1330        .await?;
1331
1332        // Mark trajectory as unsuccessful
1333        {
1334            let mut trajs = self.trajectories.write().await;
1335            if let Some(t) = trajs.get_mut(&msg.chat_id) {
1336                t.success = false;
1337            }
1338        }
1339
1340        let circuit_msg = format!(
1341            "⚠️ Hit {} rounds ({} compactions). Break into smaller tasks?",
1342            self.agent_config.max_rounds, compactions_done
1343        );
1344        if let Some(ref mid) = draft_id {
1345            let _ = channel
1346                .finalize_draft(&msg.chat_id, mid, &circuit_msg)
1347                .await;
1348            return Ok(String::new());
1349        }
1350        Ok(circuit_msg)
1351    }
1352
1353    /// Finish execution — persist, emit events, finalize draft
1354    async fn finish_execution(
1355        &self,
1356        msg: &IncomingMessage,
1357        text: &str,
1358        draft_id: &Option<String>,
1359        channel: &dyn Channel,
1360    ) -> anyhow::Result<()> {
1361        self.persist_conversation(msg, text).await?;
1362
1363        // Mark trajectory as successful, record final response
1364        {
1365            let mut trajs = self.trajectories.write().await;
1366            if let Some(t) = trajs.get_mut(&msg.chat_id) {
1367                t.success = true;
1368                t.record_response(text.to_string());
1369                t.iterations = t.tool_calls; // Approximate iterations as tool calls
1370            }
1371        }
1372
1373        // Emit lifecycle event
1374        self.hook_manager
1375            .emit(&LifecycleEvent::AgentDone(
1376                msg.chat_id.clone(),
1377                text.to_string(),
1378            ))
1379            .await;
1380        if let Some(ws) = &self.session_note_workspace {
1381            let preview: String = text.chars().take(200).collect();
1382            if !preview.is_empty() {
1383                let _ =
1384                    crate::memory::session_note::append_session_note(ws, &msg.chat_id, &preview);
1385            }
1386        }
1387
1388        let stream = self.stream_sink();
1389        emit(
1390            &stream,
1391            AgentStreamEvent::Done {
1392                response: text.to_string(),
1393            },
1394        );
1395
1396        if let Some(ref mid) = draft_id {
1397            let _ = channel.finalize_draft(&msg.chat_id, mid, text).await;
1398        }
1399
1400        #[cfg(feature = "zkr-memory")]
1401        if self.zkr_config.self_improve {
1402            if let Some(store) = &self.zkr {
1403                let _ = store
1404                    .record_reflection(&msg.text, "agent turn", text, "completed")
1405                    .await;
1406            }
1407        }
1408
1409        Ok(())
1410    }
1411
1412    async fn classify_request(&self, text: &str, _model: &str) -> bool {
1413        if self.get_mode().always_plan() {
1414            return true;
1415        }
1416
1417        let lower = text.to_lowercase();
1418        let word_count = text.split_whitespace().count();
1419
1420        if word_count <= 5 {
1421            return false;
1422        }
1423
1424        let tool_keywords = [
1425            "read ",
1426            "write ",
1427            "edit ",
1428            "create ",
1429            "build ",
1430            "fix ",
1431            "search ",
1432            "fetch ",
1433            "check ",
1434            "run ",
1435            "execute ",
1436            "install ",
1437            "deploy ",
1438            "find ",
1439            "list ",
1440            "show me ",
1441            "what's in ",
1442            "look at ",
1443            "file",
1444            "code",
1445            "commit",
1446            "git ",
1447            "grep",
1448            "curl",
1449        ];
1450        for kw in &tool_keywords {
1451            if lower.contains(kw) {
1452                return true;
1453            }
1454        }
1455
1456        if word_count >= 15
1457            && (lower.contains('?') || lower.contains("can you") || lower.contains("please"))
1458        {
1459            return true;
1460        }
1461
1462        false
1463    }
1464
1465    async fn persist_conversation(
1466        &self,
1467        msg: &IncomingMessage,
1468        response: &str,
1469    ) -> anyhow::Result<()> {
1470        self.memory
1471            .store_conversation_batch(&[
1472                (&msg.chat_id, &msg.sender_id, "user", &msg.text),
1473                (&msg.chat_id, "assistant", "assistant", response),
1474            ])
1475            .await?;
1476        #[cfg(feature = "zkr-memory")]
1477        if self.zkr_config.auto_capture {
1478            if let Some(store) = &self.zkr {
1479                if let Err(error) = store
1480                    .capture_turn(
1481                        &msg.chat_id,
1482                        &msg.id,
1483                        &msg.text,
1484                        response,
1485                        msg.timestamp.timestamp(),
1486                    )
1487                    .await
1488                {
1489                    tracing::warn!("zkr turn capture failed: {error}");
1490                }
1491            }
1492        }
1493        if msg.is_group {
1494            self.update_group_memory(msg, response).await?;
1495        }
1496        Ok(())
1497    }
1498
1499    async fn load_group_memory(&self, chat_id: &str) -> anyhow::Result<Option<String>> {
1500        let key = crate::context::group_memory_key(chat_id);
1501        Ok(self
1502            .memory
1503            .recall(&self.group_chat.rolling_memory_namespace, &key)
1504            .await?
1505            .map(|entry| entry.value))
1506    }
1507
1508    async fn update_group_memory(
1509        &self,
1510        msg: &IncomingMessage,
1511        response: &str,
1512    ) -> anyhow::Result<()> {
1513        let existing = self
1514            .load_group_memory(&msg.chat_id)
1515            .await?
1516            .unwrap_or_default();
1517        let updated = Self::rolling_group_memory(
1518            &existing,
1519            msg,
1520            response,
1521            self.group_chat.rolling_memory_max_chars,
1522        );
1523        let key = crate::context::group_memory_key(&msg.chat_id);
1524        self.memory
1525            .store(
1526                &self.group_chat.rolling_memory_namespace,
1527                &key,
1528                &updated,
1529                None,
1530            )
1531            .await?;
1532        Ok(())
1533    }
1534
1535    fn rolling_group_memory(
1536        existing: &str,
1537        msg: &IncomingMessage,
1538        response: &str,
1539        max_chars: usize,
1540    ) -> String {
1541        let sender = msg
1542            .sender_name
1543            .as_deref()
1544            .filter(|name| !name.trim().is_empty())
1545            .unwrap_or(&msg.sender_id);
1546        let mut text = String::new();
1547        if !existing.trim().is_empty() {
1548            text.push_str(existing.trim());
1549            text.push_str("\n\n");
1550        }
1551        text.push_str(&format!("[{sender}] user: {}\n", msg.text.trim()));
1552        text.push_str(&format!("assistant: {}", response.trim()));
1553        if text.chars().count() <= max_chars {
1554            return text;
1555        }
1556        let tail: String = text
1557            .chars()
1558            .rev()
1559            .take(max_chars)
1560            .collect::<Vec<_>>()
1561            .into_iter()
1562            .rev()
1563            .collect();
1564        if let Some(idx) = tail.find('\n') {
1565            tail[idx + 1..].to_string()
1566        } else {
1567            tail
1568        }
1569    }
1570
1571    /// Compact conversation messages — fallback when no compactor is set
1572    async fn compact_messages(
1573        &self,
1574        messages: Vec<ChatMessage>,
1575        original_task: &str,
1576    ) -> anyhow::Result<Vec<ChatMessage>> {
1577        let info = ContextInfo {
1578            message_count: messages.len(),
1579            total_chars: messages.iter().map(|m| m.content.len()).sum(),
1580            max_chars: self.agent_config.max_context_chars,
1581            compactions_done: 0,
1582        };
1583        if let Some(ref compactor) = self.compactor {
1584            if compactor.should_compress(&info) {
1585                let result = compactor.compress(&messages, Some(original_task)).await;
1586                if result.did_compact {
1587                    tracing::info!(
1588                        "Compactor '{}' compressed {} → {} messages",
1589                        compactor.name(),
1590                        messages.len(),
1591                        result.messages.len()
1592                    );
1593                    return Ok(result.messages);
1594                }
1595            }
1596        }
1597        // Fallback: inline summarization via fast model
1598        let keep_recent = 6;
1599        if messages.len() <= keep_recent + 2 {
1600            return Ok(messages);
1601        }
1602        let system_msgs: Vec<&ChatMessage> =
1603            messages.iter().filter(|m| m.role == "system").collect();
1604        let non_system: Vec<&ChatMessage> =
1605            messages.iter().filter(|m| m.role != "system").collect();
1606        if non_system.len() <= keep_recent {
1607            return Ok(messages);
1608        }
1609        let (old_msgs, recent_msgs) = non_system.split_at(non_system.len() - keep_recent);
1610        let mut summary_input = String::new();
1611        for m in old_msgs {
1612            let role_label = match m.role.as_str() {
1613                "user" => "User",
1614                "assistant" | "assistant_tool_use" => "Assistant",
1615                "tool_result" => "Tool Result",
1616                _ => &m.role,
1617            };
1618            let content = if m.content.len() > 500 {
1619                format!("{}...", &m.content[..500])
1620            } else {
1621                m.content.clone()
1622            };
1623            summary_input.push_str(&format!("[{}]: {}\n", role_label, content));
1624        }
1625        let compaction_prompt = format!(
1626            "Summarize this conversation concisely. The original task was: \"{}\"\n\n\
1627            Focus on: what was accomplished, what tools were used, key results, and what's still pending.\n\n\
1628            Conversation:\n{}",
1629            original_task, summary_input
1630        );
1631        let compact_messages = [ChatMessage::user(&compaction_prompt)];
1632        let compact_request = ChatRequest {
1633            messages: &compact_messages,
1634            tools: None,
1635            model: &self.agent_config.fast_model,
1636            temperature: 0.3,
1637            max_tokens: Some(1000),
1638        };
1639        let summary = match self.provider.chat(&compact_request).await {
1640            Ok(resp) => {
1641                if let Some(usage) = &resp.usage {
1642                    let _ = self
1643                        .cost_tracker
1644                        .record(
1645                            &self.agent_config.fast_model,
1646                            TokenUsage {
1647                                input_tokens: usage.input_tokens as usize,
1648                                output_tokens: usage.output_tokens as usize,
1649                                total_tokens: (usage.input_tokens + usage.output_tokens) as usize,
1650                            },
1651                        )
1652                        .await;
1653                }
1654                resp.text
1655                    .unwrap_or_else(|| "Failed to summarize.".to_string())
1656            }
1657            Err(e) => {
1658                tracing::warn!("Compaction failed: {}, falling back to truncation", e);
1659                format!(
1660                    "[Previous {} messages truncated to save context]",
1661                    old_msgs.len()
1662                )
1663            }
1664        };
1665        let mut compacted = Vec::new();
1666        for sm in &system_msgs {
1667            compacted.push((*sm).clone());
1668        }
1669        compacted.push(ChatMessage::user(format!(
1670            "[Conversation compacted — {} earlier messages summarized]\n\n{}",
1671            old_msgs.len(),
1672            summary
1673        )));
1674        compacted.push(ChatMessage::assistant(
1675            "Understood, continuing from the summary.".to_string(),
1676        ));
1677        compacted.extend(recent_msgs.iter().map(|m| (*m).clone()));
1678        Ok(compacted)
1679    }
1680
1681    // ── Trajectory access ──
1682
1683    /// Get trajectory for a chat (for export)
1684    pub async fn get_trajectory(&self, chat_id: &str) -> Option<Trajectory> {
1685        let trajs = self.trajectories.read().await;
1686        trajs.get(chat_id).cloned()
1687    }
1688
1689    /// Get all trajectories
1690    pub async fn get_all_trajectories(&self) -> Vec<Trajectory> {
1691        let trajs = self.trajectories.read().await;
1692        trajs.values().cloned().collect()
1693    }
1694
1695    /// Save trajectory to disk
1696    pub async fn save_trajectory(&self, chat_id: &str, dir: &Path) -> anyhow::Result<()> {
1697        if let Some(traj) = self.get_trajectory(chat_id).await {
1698            let path = dir.join(trajectory_filename(chat_id));
1699            traj.save_to_file(&path)?;
1700            tracing::info!("Trajectory saved: {:?}", path);
1701        }
1702        Ok(())
1703    }
1704}
1705
1706// ── Helper ──
1707
1708fn trajectory_filename(chat_id: &str) -> String {
1709    format!("traj_{:x}.json", Sha256::digest(chat_id.as_bytes()))
1710}
1711
1712fn extract_tool_hint(name: &str, arguments: &str) -> String {
1713    let v: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
1714    let hint = match name {
1715        "shell" | "bash" | "exec" => v
1716            .get("command")
1717            .or_else(|| v.get("cmd"))
1718            .and_then(|s| s.as_str()),
1719        "web_search" | "search" => v
1720            .get("query")
1721            .or_else(|| v.get("q"))
1722            .and_then(|s| s.as_str()),
1723        "web_fetch" | "fetch" => v.get("url").and_then(|s| s.as_str()),
1724        "file_ops" | "read" | "write" | "edit" => v
1725            .get("path")
1726            .or_else(|| v.get("file_path"))
1727            .and_then(|s| s.as_str()),
1728        "vibemania" => v.get("goal").and_then(|s| s.as_str()),
1729        _ => v
1730            .as_object()
1731            .and_then(|o| o.values().next())
1732            .and_then(|v| v.as_str()),
1733    };
1734    hint.map(|s| {
1735        let s = s.trim();
1736        if s.len() > 60 {
1737            format!("{}…", &s[..57])
1738        } else {
1739            s.to_string()
1740        }
1741    })
1742    .unwrap_or_default()
1743}
1744
1745fn automatic_retry_allowed(tool: &str, output: &str) -> bool {
1746    tool != "praefectus"
1747        || serde_json::from_str::<serde_json::Value>(output)
1748            .ok()
1749            .and_then(|value| value.get("retry_safe")?.as_bool())
1750            == Some(true)
1751}
1752
1753#[cfg(test)]
1754mod retry_tests {
1755    use std::path::Path;
1756
1757    use super::{automatic_retry_allowed, trajectory_filename};
1758
1759    #[test]
1760    fn trajectory_filename_confines_external_chat_ids() {
1761        let filename = trajectory_filename("x/../../outside");
1762        assert_eq!(
1763            Path::new(&filename).parent(),
1764            Some(Path::new("")),
1765            "trajectory filename must be one path component"
1766        );
1767        assert_eq!(filename.len(), "traj_".len() + 64 + ".json".len());
1768        assert!(filename.starts_with("traj_"));
1769        assert!(filename.ends_with(".json"));
1770        assert!(!filename.contains(".."));
1771        assert!(!filename.contains('/'));
1772        assert!(!filename.contains('\\'));
1773    }
1774
1775    #[test]
1776    fn praefectus_uncertainty_is_not_automatically_retried() {
1777        assert!(!automatic_retry_allowed(
1778            "praefectus",
1779            r#"{"retry_safe":false}"#
1780        ));
1781        assert!(!automatic_retry_allowed("praefectus", "unstructured error"));
1782        assert!(automatic_retry_allowed(
1783            "praefectus",
1784            r#"{"retry_safe":true}"#
1785        ));
1786        assert!(automatic_retry_allowed("shell", "failed"));
1787    }
1788}