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_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 pub fn set_stream_sink(&self, tx: Option<crate::agent::stream::AgentStreamTx>) {
105 *self.stream_sink.write().unwrap() = tx;
106 }
107
108 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 {
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 pub fn provider_name(&self) -> &str {
214 self.provider.name()
215 }
216
217 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 pub fn hook_manager(&self) -> &Arc<HookManager> {
253 &self.hook_manager
254 }
255
256 pub fn plugin_registry(&self) -> &Arc<RwLock<PluginRegistry>> {
258 &self.plugin_registry
259 }
260
261 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 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 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 self.hook_manager
553 .emit(&LifecycleEvent::AgentStart(
554 msg.chat_id.clone(),
555 msg.text.clone(),
556 ))
557 .await;
558
559 {
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 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 {
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 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 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 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 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 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 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 {
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; }
823 }
824
825 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 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 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 pub async fn get_all_trajectories(&self) -> Vec<Trajectory> {
981 let trajs = self.trajectories.read().await;
982 trajs.values().cloned().collect()
983 }
984
985 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
996fn 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 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}