1use 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 hook_manager: Arc<HookManager>,
49 plugin_registry: Arc<RwLock<PluginRegistry>>,
51 trajectories: Arc<RwLock<HashMap<String, Trajectory>>>,
53 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 pub fn set_stream_sink(&self, tx: Option<crate::agent::stream::AgentStreamTx>) {
108 *self.stream_sink.write().unwrap() = tx;
109 }
110
111 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 {
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 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 pub fn provider_name(&self) -> &str {
226 self.provider.name()
227 }
228
229 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 pub fn hook_manager(&self) -> &Arc<HookManager> {
265 &self.hook_manager
266 }
267
268 pub fn plugin_registry(&self) -> &Arc<RwLock<PluginRegistry>> {
270 &self.plugin_registry
271 }
272
273 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 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 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 self.hook_manager
565 .emit(&LifecycleEvent::AgentStart(
566 msg.chat_id.clone(),
567 msg.text.clone(),
568 ))
569 .await;
570
571 if self.agent_config.trajectory_enabled {
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 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 {
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 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 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 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 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 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 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 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 {
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; }
857 }
858
859 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 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 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 pub async fn get_all_trajectories(&self) -> Vec<Trajectory> {
1020 let trajs = self.trajectories.read().await;
1021 trajs.values().cloned().collect()
1022 }
1023
1024 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
1035fn 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 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}