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