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