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