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