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, lifecycle hooks, and trajectory recording.
4//!
5//! The loop itself is owned by the rx4 (rotary) harness; apollo owns everything
6//! around it — system prompt, skill injection, conversation history, memory
7//! recall, tool set, persistence, and lifecycle hooks.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use sha2::{Digest, Sha256};
14use tokio::sync::mpsc;
15use tokio::sync::RwLock;
16
17use crate::agent::hooks::ToolHook;
18use crate::agent::mode::{AgentMode, NullChannel};
19use crate::agent::stream::{emit, AgentStreamEvent};
20use crate::channels::{Channel, Delivery, IncomingMessage, OutgoingMessage};
21use crate::cost::CostTracker;
22use crate::memory::MemoryBackend;
23use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
24use crate::providers::{ChatMessage, Provider};
25use crate::skills;
26use crate::text::truncate_chars;
27use crate::tools::Tool;
28use crate::trajectory::Trajectory;
29
30pub struct AgentRunner {
31    provider: Arc<dyn Provider>,
32    pub tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
33    memory: Arc<dyn MemoryBackend>,
34    pub system_prompt: Arc<RwLock<String>>,
35    model: std::sync::RwLock<String>,
36    default_model: String,
37    workspace: PathBuf,
38    pub skills: Arc<RwLock<Vec<skills::Skill>>>,
39    cost_tracker: Arc<CostTracker>,
40    pub steering_queue: Arc<std::sync::Mutex<Vec<String>>>,
41    pub agent_config: crate::config::AgentConfig,
42    mode: Arc<std::sync::RwLock<AgentMode>>,
43    #[cfg(feature = "swarm")]
44    pub swarm: Arc<std::sync::RwLock<Option<Arc<crate::swarm::SwarmCoordinator>>>>,
45    hooks: Arc<std::sync::RwLock<Vec<Arc<dyn ToolHook>>>>,
46    stream_sink: Arc<std::sync::RwLock<Option<crate::agent::stream::AgentStreamTx>>>,
47    /// Lifecycle hook manager (from plugin system)
48    hook_manager: Arc<HookManager>,
49    /// Plugin registry for lifecycle events
50    plugin_registry: Arc<RwLock<PluginRegistry>>,
51    /// Current trajectory being recorded (per chat)
52    trajectories: Arc<RwLock<HashMap<String, Trajectory>>>,
53    memory_ideas: crate::config::MemoryIdeasConfig,
54    group_chat: crate::config::GroupChatConfig,
55    #[cfg(feature = "zkr-memory")]
56    zkr: Option<Arc<crate::memory::zkr::ZkrStore>>,
57    #[cfg(feature = "zkr-memory")]
58    zkr_config: crate::config::ZkrConfig,
59    session_note_workspace: Option<PathBuf>,
60}
61
62impl AgentRunner {
63    #[allow(clippy::too_many_arguments)]
64    pub fn new(
65        provider: Arc<dyn Provider>,
66        tools: Vec<Arc<dyn Tool>>,
67        memory: Arc<dyn MemoryBackend>,
68        system_prompt: impl Into<String>,
69        model: impl Into<String>,
70    ) -> Self {
71        let model_str = model.into();
72        Self {
73            provider,
74            tools: Arc::new(RwLock::new(tools)),
75            memory,
76            system_prompt: Arc::new(RwLock::new(system_prompt.into())),
77            default_model: model_str.clone(),
78            model: std::sync::RwLock::new(model_str),
79            workspace: PathBuf::from("."),
80            skills: Arc::new(RwLock::new(Vec::new())),
81            cost_tracker: Arc::new(CostTracker::new()),
82            steering_queue: Arc::new(std::sync::Mutex::new(Vec::new())),
83            agent_config: crate::config::AgentConfig::default(),
84            mode: Arc::new(std::sync::RwLock::new(AgentMode::default())),
85            #[cfg(feature = "swarm")]
86            swarm: Arc::new(std::sync::RwLock::new(None)),
87            hooks: Arc::new(std::sync::RwLock::new(Vec::new())),
88            stream_sink: Arc::new(std::sync::RwLock::new(None)),
89            hook_manager: Arc::new(HookManager::new()),
90            plugin_registry: Arc::new(RwLock::new(PluginRegistry::new())),
91            trajectories: Arc::new(RwLock::new(HashMap::new())),
92            memory_ideas: crate::config::MemoryIdeasConfig::default(),
93            group_chat: crate::config::GroupChatConfig::default(),
94            #[cfg(feature = "zkr-memory")]
95            zkr: None,
96            #[cfg(feature = "zkr-memory")]
97            zkr_config: crate::config::ZkrConfig::default(),
98            session_note_workspace: None,
99        }
100    }
101
102    // ── Existing setters ──
103
104    pub fn set_stream_sink(&self, tx: Option<crate::agent::stream::AgentStreamTx>) {
105        *self.stream_sink.write().unwrap() = tx;
106    }
107
108    /// The sink events go to: the per-turn sink of the current task if one is
109    /// scoped, otherwise the process-wide sink.
110    pub fn stream_sink(&self) -> Option<crate::agent::stream::AgentStreamTx> {
111        crate::agent::stream::current_turn_sink()
112            .or_else(|| self.stream_sink.read().unwrap().clone())
113    }
114
115    #[cfg(feature = "swarm")]
116    pub fn with_swarm(self, coordinator: Arc<crate::swarm::SwarmCoordinator>) -> Self {
117        *self.swarm.write().unwrap() = Some(coordinator);
118        self
119    }
120
121    pub fn with_config(mut self, config: crate::config::AgentConfig) -> Self {
122        self.agent_config = config;
123        self
124    }
125
126    pub fn with_mode(self, mode: AgentMode) -> Self {
127        *self.mode.write().unwrap() = mode;
128        self
129    }
130
131    pub async fn with_plugin_registry(self, registry: PluginRegistry) -> Self {
132        *self.plugin_registry.write().await = registry;
133        self
134    }
135
136    pub fn get_mode(&self) -> AgentMode {
137        self.mode.read().unwrap().clone()
138    }
139
140    pub fn set_mode(&self, mode: AgentMode) {
141        *self.mode.write().unwrap() = mode;
142    }
143
144    pub fn mode_handle(&self) -> Arc<std::sync::RwLock<AgentMode>> {
145        self.mode.clone()
146    }
147
148    pub fn add_hook(&self, hook: Arc<dyn ToolHook>) {
149        self.hooks.write().unwrap().push(hook);
150    }
151
152    pub fn steer(&self, message: String) {
153        self.steering_queue.lock().unwrap().push(message);
154    }
155
156    pub fn with_workspace(mut self, workspace: PathBuf) -> Self {
157        self.session_note_workspace = Some(workspace.clone());
158        self.workspace = workspace;
159        self
160    }
161
162    pub fn with_memory_ideas(mut self, cfg: crate::config::MemoryIdeasConfig) -> Self {
163        self.memory_ideas = cfg;
164        self
165    }
166
167    pub fn with_group_chat(mut self, cfg: crate::config::GroupChatConfig) -> Self {
168        self.group_chat = cfg;
169        self
170    }
171
172    #[cfg(feature = "zkr-memory")]
173    pub fn with_zkr(
174        mut self,
175        store: Option<Arc<crate::memory::zkr::ZkrStore>>,
176        cfg: crate::config::ZkrConfig,
177    ) -> Self {
178        self.zkr = store;
179        self.zkr_config = cfg;
180        self
181    }
182
183    pub async fn with_skills(self, skills: Vec<skills::Skill>) -> Self {
184        *self.skills.write().await = skills;
185        self
186    }
187
188    pub fn cost_tracker(&self) -> Arc<CostTracker> {
189        self.cost_tracker.clone()
190    }
191
192    pub async fn get_cost_summary(&self) -> crate::cost::CostSummary {
193        self.cost_tracker.summary().await
194    }
195
196    /// The name of the provider currently backing model calls.
197    pub fn provider_name(&self) -> &str {
198        self.provider.name()
199    }
200
201    /// The conversation store, for callers that need to read or clear history.
202    pub fn memory(&self) -> &Arc<dyn MemoryBackend> {
203        &self.memory
204    }
205
206    pub fn get_model(&self) -> String {
207        self.model.read().unwrap().clone()
208    }
209
210    pub fn get_default_model(&self) -> &str {
211        &self.default_model
212    }
213
214    pub fn set_model(&self, model: impl Into<String>) {
215        *self.model.write().unwrap() = model.into();
216    }
217
218    pub fn reset_model(&self) {
219        *self.model.write().unwrap() = self.default_model.clone();
220    }
221
222    pub async fn list_tools(&self) -> Vec<String> {
223        self.tools
224            .read()
225            .await
226            .iter()
227            .map(|t| t.name().to_string())
228            .collect()
229    }
230
231    pub async fn add_tool(&self, tool: Arc<dyn Tool>) {
232        self.tools.write().await.push(tool);
233    }
234
235    /// Access the hook manager (for plugin tool registration)
236    pub fn hook_manager(&self) -> &Arc<HookManager> {
237        &self.hook_manager
238    }
239
240    /// Get the plugin registry
241    pub fn plugin_registry(&self) -> &Arc<RwLock<PluginRegistry>> {
242        &self.plugin_registry
243    }
244
245    // ── Deploy coding swarm ──
246
247    pub async fn deploy_coding_swarm(
248        self: Arc<Self>,
249        tasks: Vec<String>,
250        base_chat_id: &str,
251        parallelism: usize,
252    ) -> Vec<(String, String)> {
253        #[cfg(feature = "swarm")]
254        {
255            let swarm_opt = {
256                let s = self.swarm.read().unwrap();
257                s.clone()
258            };
259
260            if let Some(coordinator) = swarm_opt {
261                tracing::info!("Deploying swarm via SwarmCoordinator (lane-based)");
262                return coordinator
263                    .deploy_parallel_agents(self, tasks, base_chat_id, parallelism)
264                    .await;
265            }
266        }
267
268        tracing::info!("Deploying swarm via direct spawning (fallback)");
269        let parallelism = parallelism.max(1);
270        let mut all_results = Vec::new();
271
272        for (chunk_idx, chunk) in tasks.chunks(parallelism).enumerate() {
273            let handles: Vec<_> = chunk
274                .iter()
275                .enumerate()
276                .map(|(i, task)| {
277                    let runner = self.clone();
278                    let chat_id = format!("{}_sw{}_{}", base_chat_id, chunk_idx, i);
279                    let task = task.clone();
280                    tokio::spawn(async move {
281                        let msg = IncomingMessage {
282                            id: format!("sw_{}_{}", chunk_idx, i),
283                            sender_id: "swarm".to_string(),
284                            sender_name: None,
285                            chat_id,
286                            text: task.clone(),
287                            is_group: false,
288                            reply_to: None,
289                            timestamp: chrono::Utc::now(),
290                        };
291                        let null_ch = NullChannel::new("swarm");
292                        let result = runner
293                            .handle_message(&msg, &null_ch)
294                            .await
295                            .unwrap_or_else(|e| format!("⚠️ Agent error: {}", e));
296                        (task, result)
297                    })
298                })
299                .collect();
300
301            for handle in handles {
302                match handle.await {
303                    Ok(result) => all_results.push(result),
304                    Err(e) => tracing::warn!("Swarm worker panicked: {}", e),
305                }
306            }
307        }
308
309        all_results
310    }
311
312    // ── Run ──
313
314    pub async fn run(&self, channel: &mut dyn Channel) -> anyhow::Result<()> {
315        let mut rx = channel.start().await?;
316        tracing::info!("Agent started on channel: {}", channel.name());
317
318        while let Some(msg) = rx.recv().await {
319            let _ = channel.send_typing(&msg.chat_id).await;
320
321            match self.handle_message(&msg.clone(), channel).await {
322                Ok(response) => {
323                    if response.trim().is_empty() {
324                        continue;
325                    }
326                    channel
327                        .send(OutgoingMessage {
328                            chat_id: msg.chat_id.clone(),
329                            text: response,
330                            reply_to: Some(msg.id.clone()),
331                        })
332                        .await?;
333                }
334                Err(e) => {
335                    tracing::error!("Error handling message: {}", e);
336                    channel
337                        .send(OutgoingMessage {
338                            chat_id: msg.chat_id,
339                            text: format!("Error: {}", e),
340                            reply_to: Some(msg.id),
341                        })
342                        .await?;
343                }
344            }
345        }
346
347        channel.stop().await?;
348        Ok(())
349    }
350
351    pub async fn run_with_extra_rx(
352        &self,
353        channel: &mut dyn Channel,
354        mut extra_rx: mpsc::Receiver<IncomingMessage>,
355    ) -> anyhow::Result<()> {
356        let mut rx = channel.start().await?;
357        tracing::info!(
358            "Agent started on channel: {} (with heartbeat)",
359            channel.name()
360        );
361
362        loop {
363            let msg = tokio::select! {
364                Some(msg) = rx.recv() => msg,
365                Some(msg) = extra_rx.recv() => msg,
366                else => break,
367            };
368
369            let _ = channel.send_typing(&msg.chat_id).await;
370
371            match self.handle_message(&msg, channel).await {
372                Ok(response) => {
373                    if msg.sender_id == "system" && response.contains("HEARTBEAT_OK") {
374                        tracing::debug!("Heartbeat: agent responded OK, skipping output");
375                        continue;
376                    }
377                    if response.trim().is_empty() {
378                        continue;
379                    }
380                    channel
381                        .send(OutgoingMessage {
382                            chat_id: msg.chat_id.clone(),
383                            text: response,
384                            reply_to: Some(msg.id.clone()),
385                        })
386                        .await?;
387                }
388                Err(e) => {
389                    tracing::error!("Error handling message: {}", e);
390                    if msg.sender_id != "system" {
391                        channel
392                            .send(OutgoingMessage {
393                                chat_id: msg.chat_id,
394                                text: format!("Error: {}", e),
395                                reply_to: Some(msg.id),
396                            })
397                            .await?;
398                    }
399                }
400            }
401        }
402
403        channel.stop().await?;
404        Ok(())
405    }
406
407    pub async fn run_with_runtime_rx(
408        &self,
409        channel: &mut dyn Channel,
410        mut extra_rx: mpsc::Receiver<IncomingMessage>,
411        mut cron_rx: mpsc::Receiver<crate::cron_scheduler::DueJob>,
412        scheduler: Arc<crate::cron_scheduler::CronScheduler>,
413    ) -> anyhow::Result<()> {
414        let mut rx = channel.start().await?;
415        loop {
416            enum RuntimeInput {
417                Message(IncomingMessage),
418                Cron(crate::cron_scheduler::DueJob),
419            }
420            let input = tokio::select! {
421                Some(msg) = rx.recv() => RuntimeInput::Message(msg),
422                Some(msg) = extra_rx.recv() => RuntimeInput::Message(msg),
423                Some(job) = cron_rx.recv() => RuntimeInput::Cron(job),
424                else => break,
425            };
426            match input {
427                RuntimeInput::Message(msg) => {
428                    let _ = channel.send_typing(&msg.chat_id).await;
429                    match self.handle_message(&msg, channel).await {
430                        Ok(response) if !response.trim().is_empty() => {
431                            channel
432                                .send(OutgoingMessage {
433                                    chat_id: msg.chat_id,
434                                    text: response,
435                                    reply_to: Some(msg.id),
436                                })
437                                .await?;
438                        }
439                        Ok(_) => {}
440                        Err(error) if msg.sender_id != "system" => {
441                            channel
442                                .send(OutgoingMessage {
443                                    chat_id: msg.chat_id,
444                                    text: format!("Error: {error}"),
445                                    reply_to: Some(msg.id),
446                                })
447                                .await?;
448                        }
449                        Err(error) => tracing::error!("Error handling message: {error}"),
450                    }
451                }
452                RuntimeInput::Cron(due) => {
453                    let job = due.job;
454                    let job_id = job.id.clone().unwrap_or_default();
455                    let run_token = job.run_token.clone().unwrap_or_default();
456                    if job.channel != channel.name() {
457                        scheduler.release_run(&job_id, &run_token).await?;
458                        continue;
459                    }
460                    let msg = IncomingMessage {
461                        id: format!("cron-{job_id}"),
462                        sender_id: "scheduler".to_string(),
463                        sender_name: Some("Scheduler".to_string()),
464                        chat_id: job.chat_id.clone(),
465                        text: job.task.clone(),
466                        is_group: false,
467                        reply_to: None,
468                        timestamp: chrono::Utc::now(),
469                    };
470                    match self
471                        .handle_message_with_model(
472                            &msg,
473                            channel,
474                            (!job.model.is_empty()).then_some(job.model.as_str()),
475                        )
476                        .await
477                    {
478                        Ok(response) => {
479                            if !response.trim().is_empty() {
480                                if let Err(error) = channel
481                                    .send(OutgoingMessage {
482                                        chat_id: job.chat_id,
483                                        text: response,
484                                        reply_to: None,
485                                    })
486                                    .await
487                                {
488                                    scheduler
489                                        .fail_run(&job_id, &run_token, &error.to_string())
490                                        .await?;
491                                    continue;
492                                }
493                            }
494                            scheduler
495                                .mark_run(&job_id, &run_token, &job.schedule)
496                                .await?;
497                        }
498                        Err(error) => {
499                            scheduler
500                                .fail_run(&job_id, &run_token, &error.to_string())
501                                .await?
502                        }
503                    }
504                }
505            }
506        }
507        channel.stop().await?;
508        Ok(())
509    }
510
511    // ── Handle single message ──
512
513    pub async fn handle_message(
514        &self,
515        msg: &IncomingMessage,
516        channel: &dyn Channel,
517    ) -> anyhow::Result<String> {
518        self.handle_message_with_model(msg, channel, None).await
519    }
520
521    pub async fn handle_message_with_model(
522        &self,
523        msg: &IncomingMessage,
524        channel: &dyn Channel,
525        model: Option<&str>,
526    ) -> anyhow::Result<String> {
527        let stream = self.stream_sink();
528        emit(
529            &stream,
530            AgentStreamEvent::Status {
531                message: "Thinking…".into(),
532            },
533        );
534
535        // Emit lifecycle event: agent start
536        self.hook_manager
537            .emit(&LifecycleEvent::AgentStart(
538                msg.chat_id.clone(),
539                msg.text.clone(),
540            ))
541            .await;
542
543        // Initialize per-chat trajectory
544        {
545            let mut trajs = self.trajectories.write().await;
546            if !trajs.contains_key(&msg.chat_id) {
547                let t = Trajectory::new(
548                    format!("traj_{}", chrono::Utc::now().timestamp()),
549                    msg.chat_id.clone(),
550                    self.get_model(),
551                );
552                trajs.insert(msg.chat_id.clone(), t);
553            }
554        }
555
556        let delivery = Delivery::open(channel, &msg.chat_id, "⏳").await;
557        if delivery.draft().is_none() {
558            let _ = channel.send_typing(&msg.chat_id).await;
559        }
560
561        if msg.is_group && !crate::context::should_respond(msg) {
562            tracing::debug!(
563                "Skipping ambient group message without assistant context: {}",
564                msg.id
565            );
566            return Ok(String::new());
567        }
568
569        let effective_text = msg.text.clone();
570        let mode = self.get_mode();
571
572        // ── Build messages ──
573
574        let base_prompt = self.system_prompt.read().await.clone();
575        #[cfg(feature = "zkr-memory")]
576        let system_prompt = if self.zkr_config.self_improve {
577            if let Some(store) = &self.zkr {
578                match store.augment_prompt(&effective_text, &base_prompt).await {
579                    Ok(augmented) => augmented,
580                    Err(error) => {
581                        tracing::warn!("self-improve augmentation failed: {error}");
582                        base_prompt
583                    }
584                }
585            } else {
586                base_prompt
587            }
588        } else {
589            base_prompt
590        };
591        #[cfg(not(feature = "zkr-memory"))]
592        let system_prompt = base_prompt;
593        let mut messages = vec![ChatMessage::system(&system_prompt)];
594        if let Some(guidance) = crate::context::routing_guidance(msg.is_group, channel.name()) {
595            messages.push(ChatMessage::system(guidance));
596        }
597
598        if let Some(mode_prompt) = mode.system_prompt_injection() {
599            messages.push(ChatMessage::system(mode_prompt));
600        }
601
602        // Skill injection with template preprocessing
603        {
604            let matched = {
605                let skills = self.skills.read().await;
606                skills::match_skill(&skills, &effective_text)
607                    .map(|skill| (skill.name.clone(), skill.location.clone()))
608            };
609            if let Some((skill_name, location)) = matched {
610                let chat_id = msg.chat_id.clone();
611                let workspace = self.workspace.clone();
612                let preprocessed = tokio::task::spawn_blocking(move || {
613                    let content = std::fs::read_to_string(&location).ok()?;
614                    Some(skills::preprocess_skill_content(
615                        &content,
616                        location.parent(),
617                        Some(&chat_id),
618                        Some(&workspace),
619                    ))
620                })
621                .await?;
622                if let Some(preprocessed) = preprocessed {
623                    messages.push(ChatMessage::system(format!(
624                        "# Active Skill: {}\n{}\n\nFollow the instructions above for this skill.",
625                        skill_name, preprocessed
626                    )));
627                    tracing::info!("Skill matched: {} (preprocessed)", skill_name);
628                }
629            }
630        }
631        if msg.is_group {
632            if let Some(group_memory) = self.load_group_memory(&msg.chat_id).await? {
633                if !group_memory.trim().is_empty() {
634                    messages.push(ChatMessage::system(crate::context::group_memory_prompt(
635                        &msg.chat_id,
636                        &group_memory,
637                    )));
638                }
639            }
640        }
641
642        let history = crate::memory::context_inject::merged_history(
643            &self.memory,
644            &msg.chat_id,
645            self.memory_ideas.principal_id.as_deref(),
646            self.agent_config.max_history_messages,
647        )
648        .await?;
649        for (role, content) in history {
650            match role.as_str() {
651                "user" => messages.push(ChatMessage::user(&content)),
652                "assistant" => messages.push(ChatMessage::assistant(&content)),
653                _ => {}
654            }
655        }
656
657        let mut user_turn = effective_text.clone();
658        if self.memory_ideas.inject_context {
659            let blocks = crate::memory::context_inject::personal_context_blocks(
660                &self.memory,
661                crate::memory::context_inject::InjectConfig {
662                    workspace: &self.workspace,
663                    principal_id: self.memory_ideas.principal_id.as_deref(),
664                    graph_recall_limit: self.memory_ideas.graph_recall_limit,
665                },
666                &effective_text,
667            )
668            .await;
669            if !blocks.is_empty() {
670                user_turn = format!("{user_turn}\n\n{}", blocks.join("\n\n"));
671            }
672        }
673        #[cfg(feature = "zkr-memory")]
674        if self.zkr_config.inject_recall {
675            if let Some(store) = &self.zkr {
676                match store
677                    .context(&effective_text, self.zkr_config.recall_limit)
678                    .await
679                {
680                    Ok(Some(context)) => user_turn = format!("{user_turn}\n\n{context}"),
681                    Ok(None) => {}
682                    Err(error) => tracing::warn!("zkr recall failed: {error}"),
683                }
684            }
685        }
686        messages.push(ChatMessage::user(&user_turn));
687
688        let tools_snapshot: Vec<Arc<dyn Tool>> = self.tools.read().await.iter().cloned().collect();
689        let main_model = model
690            .map(str::to_string)
691            .unwrap_or_else(|| self.model.read().unwrap().clone());
692
693        // ── rx4 engine ──
694        // Context assembly above stays apollo's; from here rx4 owns the loop.
695        let text = self
696            .run_via_rotary(&messages, &tools_snapshot, &main_model)
697            .await?;
698        self.finish_execution(msg, &text, &delivery).await
699    }
700
701    /// Run one turn through the rx4 (rotary) harness.
702    ///
703    /// apollo keeps ownership of everything around the loop — system prompt,
704    /// skill injection, conversation history, memory recall, tool set — and
705    /// hands rx4 the assembled conversation. rx4 owns model calls and tool
706    /// cycling from there.
707    ///
708    /// The bridge is built per turn so each chat gets an isolated message
709    /// buffer; registration is in-memory and does no I/O.
710    async fn run_via_rotary(
711        &self,
712        messages: &[ChatMessage],
713        tools: &[Arc<dyn Tool>],
714        model: &str,
715    ) -> anyhow::Result<String> {
716        use crate::agent::rotary_bridge::{RotaryAgentBridge, RotaryBridgeConfig};
717
718        // rx4 takes the system prompt out of band, so collapse apollo's system
719        // messages (base prompt, mode injection, skills, group memory) into one.
720        let system_prompt = messages
721            .iter()
722            .filter(|m| m.role == "system")
723            .map(|m| m.content.as_str())
724            .collect::<Vec<_>>()
725            .join("\n\n");
726
727        let mut history: Vec<ChatMessage> = messages
728            .iter()
729            .filter(|m| m.role != "system")
730            .cloned()
731            .collect();
732        let prompt = history
733            .pop()
734            .ok_or_else(|| anyhow::anyhow!("no user turn to run through rx4"))?;
735
736        let mut bridge = RotaryAgentBridge::new(RotaryBridgeConfig {
737            provider: Arc::clone(&self.provider),
738            tools: tools.to_vec(),
739            system_prompt,
740            model: model.to_string(),
741            workspace: self.workspace.clone(),
742            max_tool_iterations: self.agent_config.max_rounds,
743            auto_compact_after: self.agent_config.auto_compact_after,
744            // Both engines must run the same hooks and emit the same events.
745            hook_ctx: crate::agent::rotary_bridge::ToolHookContext::new(
746                self.hooks.read().unwrap().clone(),
747                Some(Arc::clone(&self.plugin_registry)),
748            )
749            .with_hook_manager(Arc::clone(&self.hook_manager))
750            .with_stream(self.stream_sink()),
751        });
752
753        // ── Steering queue ──
754        // rx4's `messages_handle()` exposes the shared message buffer the tool
755        // loop reads at the top of every iteration, so a message pushed here
756        // while `prompt()` is running is visible to the next tool cycle. This
757        // mirrors the legacy loop's per-round steering drain.
758        let messages_handle = bridge.messages_handle();
759        let steering_queue = Arc::clone(&self.steering_queue);
760        let prompt_fut = bridge.run_prompt_with_history(&prompt.content, &history);
761        tokio::pin!(prompt_fut);
762
763        loop {
764            tokio::select! {
765                biased;
766                result = &mut prompt_fut => {
767                    return result;
768                }
769                _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
770                    let mut queue = steering_queue.lock().unwrap();
771                    if !queue.is_empty() {
772                        for steer_msg in queue.drain(..) {
773                            tracing::info!(
774                                chars = steer_msg.chars().count(),
775                                "Steering message queued"
776                            );
777                            messages_handle.write().push(rx4::provider::Message::user(
778                                format!(
779                                    "⚡ STEERING — new instruction from user (prioritize this): {}",
780                                    steer_msg
781                                ),
782                            ));
783                        }
784                    }
785                }
786            }
787        }
788    }
789
790    /// Finish execution — persist, emit events, finalize draft
791    async fn finish_execution(
792        &self,
793        msg: &IncomingMessage,
794        text: &str,
795        delivery: &Delivery<'_>,
796    ) -> anyhow::Result<String> {
797        self.persist_conversation(msg, text).await?;
798
799        // Mark trajectory as successful, record final response
800        {
801            let mut trajs = self.trajectories.write().await;
802            if let Some(t) = trajs.get_mut(&msg.chat_id) {
803                t.success = true;
804                t.record_response(text.to_string());
805                t.iterations = t.tool_calls; // Approximate iterations as tool calls
806            }
807        }
808
809        // Emit lifecycle event
810        self.hook_manager
811            .emit(&LifecycleEvent::AgentDone(
812                msg.chat_id.clone(),
813                text.to_string(),
814            ))
815            .await;
816        if let Some(ws) = &self.session_note_workspace {
817            let preview: String = text.chars().take(200).collect();
818            if !preview.is_empty() {
819                let _ =
820                    crate::memory::session_note::append_session_note(ws, &msg.chat_id, &preview);
821            }
822        }
823
824        let stream = self.stream_sink();
825        emit(
826            &stream,
827            AgentStreamEvent::Done {
828                response: text.to_string(),
829            },
830        );
831
832        // Draft-capable channels already have the text on screen; returning it
833        // as well would post it twice. Every other channel relies on the
834        // returned string being the reply.
835        let delivered = delivery.deliver(&msg.chat_id, text).await?;
836
837        #[cfg(feature = "zkr-memory")]
838        if self.zkr_config.self_improve {
839            if let Some(store) = &self.zkr {
840                let _ = store
841                    .record_reflection(&msg.text, "agent turn", text, "completed")
842                    .await;
843            }
844        }
845
846        Ok(delivered)
847    }
848
849    async fn persist_conversation(
850        &self,
851        msg: &IncomingMessage,
852        response: &str,
853    ) -> anyhow::Result<()> {
854        self.memory
855            .store_conversation_batch(&[
856                (&msg.chat_id, &msg.sender_id, "user", &msg.text),
857                (&msg.chat_id, "assistant", "assistant", response),
858            ])
859            .await?;
860        #[cfg(feature = "zkr-memory")]
861        if self.zkr_config.auto_capture {
862            if let Some(store) = &self.zkr {
863                if let Err(error) = store
864                    .capture_turn(
865                        &msg.chat_id,
866                        &msg.id,
867                        &msg.text,
868                        response,
869                        msg.timestamp.timestamp(),
870                    )
871                    .await
872                {
873                    tracing::warn!("zkr turn capture failed: {error}");
874                }
875            }
876        }
877        if msg.is_group {
878            self.update_group_memory(msg, response).await?;
879        }
880        Ok(())
881    }
882
883    async fn load_group_memory(&self, chat_id: &str) -> anyhow::Result<Option<String>> {
884        let key = crate::context::group_memory_key(chat_id);
885        Ok(self
886            .memory
887            .recall(&self.group_chat.rolling_memory_namespace, &key)
888            .await?
889            .map(|entry| entry.value))
890    }
891
892    async fn update_group_memory(
893        &self,
894        msg: &IncomingMessage,
895        response: &str,
896    ) -> anyhow::Result<()> {
897        let existing = self
898            .load_group_memory(&msg.chat_id)
899            .await?
900            .unwrap_or_default();
901        let updated = Self::rolling_group_memory(
902            &existing,
903            msg,
904            response,
905            self.group_chat.rolling_memory_max_chars,
906        );
907        let key = crate::context::group_memory_key(&msg.chat_id);
908        self.memory
909            .store(
910                &self.group_chat.rolling_memory_namespace,
911                &key,
912                &updated,
913                None,
914            )
915            .await?;
916        Ok(())
917    }
918
919    fn rolling_group_memory(
920        existing: &str,
921        msg: &IncomingMessage,
922        response: &str,
923        max_chars: usize,
924    ) -> String {
925        let sender = msg
926            .sender_name
927            .as_deref()
928            .filter(|name| !name.trim().is_empty())
929            .unwrap_or(&msg.sender_id);
930        let mut text = String::new();
931        if !existing.trim().is_empty() {
932            text.push_str(existing.trim());
933            text.push_str("\n\n");
934        }
935        text.push_str(&format!("[{sender}] user: {}\n", msg.text.trim()));
936        text.push_str(&format!("assistant: {}", response.trim()));
937        if text.chars().count() <= max_chars {
938            return text;
939        }
940        let tail: String = text
941            .chars()
942            .rev()
943            .take(max_chars)
944            .collect::<Vec<_>>()
945            .into_iter()
946            .rev()
947            .collect();
948        if let Some(idx) = tail.find('\n') {
949            tail[idx + 1..].to_string()
950        } else {
951            tail
952        }
953    }
954
955    // ── Trajectory access ──
956
957    /// Get trajectory for a chat (for export)
958    pub async fn get_trajectory(&self, chat_id: &str) -> Option<Trajectory> {
959        let trajs = self.trajectories.read().await;
960        trajs.get(chat_id).cloned()
961    }
962
963    /// Get all trajectories
964    pub async fn get_all_trajectories(&self) -> Vec<Trajectory> {
965        let trajs = self.trajectories.read().await;
966        trajs.values().cloned().collect()
967    }
968
969    /// Save trajectory to disk
970    pub async fn save_trajectory(&self, chat_id: &str, dir: &Path) -> anyhow::Result<()> {
971        if let Some(traj) = self.get_trajectory(chat_id).await {
972            let path = dir.join(trajectory_filename(chat_id));
973            traj.save_to_file(&path)?;
974            tracing::info!("Trajectory saved: {:?}", path);
975        }
976        Ok(())
977    }
978}
979
980// ── Helper ──
981
982fn trajectory_filename(chat_id: &str) -> String {
983    format!("traj_{:x}.json", Sha256::digest(chat_id.as_bytes()))
984}
985
986pub(crate) fn extract_tool_hint(name: &str, arguments: &str) -> String {
987    let v: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
988    let hint = match name {
989        "shell" | "bash" | "exec" => v
990            .get("command")
991            .or_else(|| v.get("cmd"))
992            .and_then(|s| s.as_str()),
993        "web_search" | "search" => v
994            .get("query")
995            .or_else(|| v.get("q"))
996            .and_then(|s| s.as_str()),
997        "web_fetch" | "fetch" => v.get("url").and_then(|s| s.as_str()),
998        "file_ops" | "read" | "write" | "edit" => v
999            .get("path")
1000            .or_else(|| v.get("file_path"))
1001            .and_then(|s| s.as_str()),
1002        "vibemania" => v.get("goal").and_then(|s| s.as_str()),
1003        _ => v
1004            .as_object()
1005            .and_then(|o| o.values().next())
1006            .and_then(|v| v.as_str()),
1007    };
1008    hint.map(|s| {
1009        let s = s.trim();
1010        if s.chars().count() > 60 {
1011            format!("{}…", truncate_chars(s, 57))
1012        } else {
1013            s.to_string()
1014        }
1015    })
1016    .unwrap_or_default()
1017}
1018
1019#[cfg(test)]
1020mod retry_tests {
1021    use std::path::Path;
1022
1023    use super::{trajectory_filename, truncate_chars};
1024
1025    #[test]
1026    fn truncating_multibyte_tool_output_does_not_panic() {
1027        // Byte-slicing this at 200 would land mid-sequence and panic.
1028        let output = "日本語".repeat(200);
1029        assert_eq!(truncate_chars(&output, 200).chars().count(), 200);
1030        assert_eq!(truncate_chars("hi", 200), "hi");
1031        assert_eq!(truncate_chars("héllo", 2), "hé");
1032    }
1033
1034    #[test]
1035    fn trajectory_filename_confines_external_chat_ids() {
1036        let filename = trajectory_filename("x/../../outside");
1037        assert_eq!(
1038            Path::new(&filename).parent(),
1039            Some(Path::new("")),
1040            "trajectory filename must be one path component"
1041        );
1042        assert_eq!(filename.len(), "traj_".len() + 64 + ".json".len());
1043        assert!(filename.starts_with("traj_"));
1044        assert!(filename.ends_with(".json"));
1045        assert!(!filename.contains(".."));
1046        assert!(!filename.contains('/'));
1047        assert!(!filename.contains('\\'));
1048    }
1049}