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