1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use sha2::{Digest, Sha256};
11use tokio::sync::mpsc;
12use tokio::sync::RwLock;
13
14use crate::agent::compaction::{Compactor, ContextInfo, DefaultCompactor};
15use crate::agent::hooks::ToolHook;
16use crate::agent::mode::{
17 is_approval, is_rejection, AgentMode, NullChannel, PendingPlan, PendingPlans,
18};
19use crate::agent::stream::{emit, AgentStreamEvent};
20use crate::channels::{Channel, IncomingMessage, OutgoingMessage};
21use crate::cost::{CostTracker, TokenUsage};
22use crate::memory::MemoryBackend;
23use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
24use crate::providers::{ChatMessage, ChatRequest, Provider};
25use crate::skills;
26use crate::text::{truncate_chars, truncate_chars_counted};
27use crate::tools::guardrails::{GuardrailDecision, ToolGuardrails};
28use crate::tools::Tool;
29use crate::trajectory::Trajectory;
30
31const LOOP_WARN_THRESHOLD: usize = 5;
33const LOOP_BREAK_THRESHOLD: usize = 8;
35
36#[derive(Debug, Clone, PartialEq)]
38enum AgentState {
39 Planning,
40 Executing,
41 Summarizing,
42 Direct,
43}
44
45fn compaction_excerpt(content: &str) -> String {
49 if content.chars().count() > 500 {
50 format!("{}...", truncate_chars(content, 500))
51 } else {
52 content.to_string()
53 }
54}
55
56pub struct AgentRunner {
57 provider: Arc<dyn Provider>,
58 pub tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
59 memory: Arc<dyn MemoryBackend>,
60 pub system_prompt: Arc<RwLock<String>>,
61 model: std::sync::RwLock<String>,
62 default_model: String,
63 workspace: PathBuf,
64 pub skills: Arc<RwLock<Vec<skills::Skill>>>,
65 cost_tracker: Arc<CostTracker>,
66 pub steering_queue: Arc<std::sync::Mutex<Vec<String>>>,
67 pub agent_config: crate::config::AgentConfig,
68 mode: Arc<std::sync::RwLock<AgentMode>>,
69 #[cfg(feature = "swarm")]
70 pub swarm: Arc<std::sync::RwLock<Option<Arc<crate::swarm::SwarmCoordinator>>>>,
71 pending_plans: PendingPlans,
72 hooks: Arc<std::sync::RwLock<Vec<Arc<dyn ToolHook>>>>,
73 stream_sink: Arc<std::sync::RwLock<Option<crate::agent::stream::AgentStreamTx>>>,
74 guardrails: Arc<RwLock<HashMap<String, ToolGuardrails>>>,
77 compactor: Option<Arc<dyn Compactor>>,
79 hook_manager: Arc<HookManager>,
81 plugin_registry: Arc<RwLock<PluginRegistry>>,
83 trajectories: Arc<RwLock<HashMap<String, Trajectory>>>,
85 memory_ideas: crate::config::MemoryIdeasConfig,
86 group_chat: crate::config::GroupChatConfig,
87 #[cfg(feature = "zkr-memory")]
88 zkr: Option<Arc<crate::memory::zkr::ZkrStore>>,
89 #[cfg(feature = "zkr-memory")]
90 zkr_config: crate::config::ZkrConfig,
91 session_note_workspace: Option<PathBuf>,
92}
93
94impl AgentRunner {
95 #[allow(clippy::too_many_arguments)]
96 pub fn new(
97 provider: Arc<dyn Provider>,
98 tools: Vec<Arc<dyn Tool>>,
99 memory: Arc<dyn MemoryBackend>,
100 system_prompt: impl Into<String>,
101 model: impl Into<String>,
102 ) -> Self {
103 let model_str = model.into();
104 Self {
105 provider,
106 tools: Arc::new(RwLock::new(tools)),
107 memory,
108 system_prompt: Arc::new(RwLock::new(system_prompt.into())),
109 default_model: model_str.clone(),
110 model: std::sync::RwLock::new(model_str),
111 workspace: PathBuf::from("."),
112 skills: Arc::new(RwLock::new(Vec::new())),
113 cost_tracker: Arc::new(CostTracker::new()),
114 steering_queue: Arc::new(std::sync::Mutex::new(Vec::new())),
115 agent_config: crate::config::AgentConfig::default(),
116 mode: Arc::new(std::sync::RwLock::new(AgentMode::default())),
117 #[cfg(feature = "swarm")]
118 swarm: Arc::new(std::sync::RwLock::new(None)),
119 pending_plans: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
120 hooks: Arc::new(std::sync::RwLock::new(Vec::new())),
121 stream_sink: Arc::new(std::sync::RwLock::new(None)),
122 guardrails: Arc::new(RwLock::new(HashMap::new())),
123 compactor: Some(Arc::new(DefaultCompactor::new())),
124 hook_manager: Arc::new(HookManager::new()),
125 plugin_registry: Arc::new(RwLock::new(PluginRegistry::new())),
126 trajectories: Arc::new(RwLock::new(HashMap::new())),
127 memory_ideas: crate::config::MemoryIdeasConfig::default(),
128 group_chat: crate::config::GroupChatConfig::default(),
129 #[cfg(feature = "zkr-memory")]
130 zkr: None,
131 #[cfg(feature = "zkr-memory")]
132 zkr_config: crate::config::ZkrConfig::default(),
133 session_note_workspace: None,
134 }
135 }
136
137 pub fn set_stream_sink(&self, tx: Option<crate::agent::stream::AgentStreamTx>) {
140 *self.stream_sink.write().unwrap() = tx;
141 }
142
143 pub fn stream_sink(&self) -> Option<crate::agent::stream::AgentStreamTx> {
146 crate::agent::stream::current_turn_sink()
147 .or_else(|| self.stream_sink.read().unwrap().clone())
148 }
149
150 #[cfg(feature = "swarm")]
151 pub fn with_swarm(self, coordinator: Arc<crate::swarm::SwarmCoordinator>) -> Self {
152 *self.swarm.write().unwrap() = Some(coordinator);
153 self
154 }
155
156 pub fn with_config(mut self, config: crate::config::AgentConfig) -> Self {
157 if config.engine() == crate::config::AgentEngine::Rx4 {
158 tracing::info!("agent engine: rx4 (rotary harness owns the loop)");
159 }
160 self.agent_config = config;
161 self
162 }
163
164 pub fn with_mode(self, mode: AgentMode) -> Self {
165 *self.mode.write().unwrap() = mode;
166 self
167 }
168
169 pub async fn with_plugin_registry(self, registry: PluginRegistry) -> Self {
170 *self.plugin_registry.write().await = registry;
171 self
172 }
173
174 pub fn with_compactor(mut self, compactor: Arc<dyn Compactor>) -> Self {
175 self.compactor = Some(compactor);
176 self
177 }
178
179 pub fn get_mode(&self) -> AgentMode {
180 self.mode.read().unwrap().clone()
181 }
182
183 pub fn set_mode(&self, mode: AgentMode) {
184 *self.mode.write().unwrap() = mode;
185 }
186
187 pub fn mode_handle(&self) -> Arc<std::sync::RwLock<AgentMode>> {
188 self.mode.clone()
189 }
190
191 pub fn add_hook(&self, hook: Arc<dyn ToolHook>) {
192 self.hooks.write().unwrap().push(hook);
193 }
194
195 pub fn steer(&self, message: String) {
196 self.steering_queue.lock().unwrap().push(message);
197 }
198
199 pub fn with_workspace(mut self, workspace: PathBuf) -> Self {
200 self.session_note_workspace = Some(workspace.clone());
201 self.workspace = workspace;
202 self
203 }
204
205 pub fn with_memory_ideas(mut self, cfg: crate::config::MemoryIdeasConfig) -> Self {
206 self.memory_ideas = cfg;
207 self
208 }
209
210 pub fn with_group_chat(mut self, cfg: crate::config::GroupChatConfig) -> Self {
211 self.group_chat = cfg;
212 self
213 }
214
215 #[cfg(feature = "zkr-memory")]
216 pub fn with_zkr(
217 mut self,
218 store: Option<Arc<crate::memory::zkr::ZkrStore>>,
219 cfg: crate::config::ZkrConfig,
220 ) -> Self {
221 self.zkr = store;
222 self.zkr_config = cfg;
223 self
224 }
225
226 pub async fn with_skills(self, skills: Vec<skills::Skill>) -> Self {
227 *self.skills.write().await = skills;
228 self
229 }
230
231 pub fn cost_tracker(&self) -> Arc<CostTracker> {
232 self.cost_tracker.clone()
233 }
234
235 pub async fn get_cost_summary(&self) -> crate::cost::CostSummary {
236 self.cost_tracker.summary().await
237 }
238
239 pub fn provider_name(&self) -> &str {
241 self.provider.name()
242 }
243
244 pub fn memory(&self) -> &Arc<dyn MemoryBackend> {
246 &self.memory
247 }
248
249 pub fn get_model(&self) -> String {
250 self.model.read().unwrap().clone()
251 }
252
253 pub fn get_default_model(&self) -> &str {
254 &self.default_model
255 }
256
257 pub fn set_model(&self, model: impl Into<String>) {
258 *self.model.write().unwrap() = model.into();
259 }
260
261 pub fn reset_model(&self) {
262 *self.model.write().unwrap() = self.default_model.clone();
263 }
264
265 pub async fn list_tools(&self) -> Vec<String> {
266 self.tools
267 .read()
268 .await
269 .iter()
270 .map(|t| t.name().to_string())
271 .collect()
272 }
273
274 pub async fn add_tool(&self, tool: Arc<dyn Tool>) {
275 self.tools.write().await.push(tool);
276 }
277
278 pub fn hook_manager(&self) -> &Arc<HookManager> {
280 &self.hook_manager
281 }
282
283 pub fn plugin_registry(&self) -> &Arc<RwLock<PluginRegistry>> {
285 &self.plugin_registry
286 }
287
288 pub async fn deploy_coding_swarm(
291 self: Arc<Self>,
292 tasks: Vec<String>,
293 base_chat_id: &str,
294 parallelism: usize,
295 ) -> Vec<(String, String)> {
296 #[cfg(feature = "swarm")]
297 {
298 let swarm_opt = {
299 let s = self.swarm.read().unwrap();
300 s.clone()
301 };
302
303 if let Some(coordinator) = swarm_opt {
304 tracing::info!("Deploying swarm via SwarmCoordinator (lane-based)");
305 return coordinator
306 .deploy_parallel_agents(self, tasks, base_chat_id, parallelism)
307 .await;
308 }
309 }
310
311 tracing::info!("Deploying swarm via direct spawning (fallback)");
312 let parallelism = parallelism.max(1);
313 let mut all_results = Vec::new();
314
315 for (chunk_idx, chunk) in tasks.chunks(parallelism).enumerate() {
316 let handles: Vec<_> = chunk
317 .iter()
318 .enumerate()
319 .map(|(i, task)| {
320 let runner = self.clone();
321 let chat_id = format!("{}_sw{}_{}", base_chat_id, chunk_idx, i);
322 let task = task.clone();
323 tokio::spawn(async move {
324 let msg = IncomingMessage {
325 id: format!("sw_{}_{}", chunk_idx, i),
326 sender_id: "swarm".to_string(),
327 sender_name: None,
328 chat_id,
329 text: task.clone(),
330 is_group: false,
331 reply_to: None,
332 timestamp: chrono::Utc::now(),
333 };
334 let null_ch = NullChannel::new("swarm");
335 let result = runner
336 .handle_message(&msg, &null_ch)
337 .await
338 .unwrap_or_else(|e| format!("⚠️ Agent error: {}", e));
339 (task, result)
340 })
341 })
342 .collect();
343
344 for handle in handles {
345 match handle.await {
346 Ok(result) => all_results.push(result),
347 Err(e) => tracing::warn!("Swarm worker panicked: {}", e),
348 }
349 }
350 }
351
352 all_results
353 }
354
355 pub async fn run(&self, channel: &mut dyn Channel) -> anyhow::Result<()> {
358 let mut rx = channel.start().await?;
359 tracing::info!("Agent started on channel: {}", channel.name());
360
361 while let Some(msg) = rx.recv().await {
362 let _ = channel.send_typing(&msg.chat_id).await;
363
364 match self.handle_message(&msg.clone(), channel).await {
365 Ok(response) => {
366 if response.trim().is_empty() {
367 continue;
368 }
369 channel
370 .send(OutgoingMessage {
371 chat_id: msg.chat_id.clone(),
372 text: response,
373 reply_to: Some(msg.id.clone()),
374 })
375 .await?;
376 }
377 Err(e) => {
378 tracing::error!("Error handling message: {}", e);
379 channel
380 .send(OutgoingMessage {
381 chat_id: msg.chat_id,
382 text: format!("Error: {}", e),
383 reply_to: Some(msg.id),
384 })
385 .await?;
386 }
387 }
388 }
389
390 channel.stop().await?;
391 Ok(())
392 }
393
394 pub async fn run_with_extra_rx(
395 &self,
396 channel: &mut dyn Channel,
397 mut extra_rx: mpsc::Receiver<IncomingMessage>,
398 ) -> anyhow::Result<()> {
399 let mut rx = channel.start().await?;
400 tracing::info!(
401 "Agent started on channel: {} (with heartbeat)",
402 channel.name()
403 );
404
405 loop {
406 let msg = tokio::select! {
407 Some(msg) = rx.recv() => msg,
408 Some(msg) = extra_rx.recv() => msg,
409 else => break,
410 };
411
412 let _ = channel.send_typing(&msg.chat_id).await;
413
414 match self.handle_message(&msg, channel).await {
415 Ok(response) => {
416 if msg.sender_id == "system" && response.contains("HEARTBEAT_OK") {
417 tracing::debug!("Heartbeat: agent responded OK, skipping output");
418 continue;
419 }
420 if response.trim().is_empty() {
421 continue;
422 }
423 channel
424 .send(OutgoingMessage {
425 chat_id: msg.chat_id.clone(),
426 text: response,
427 reply_to: Some(msg.id.clone()),
428 })
429 .await?;
430 }
431 Err(e) => {
432 tracing::error!("Error handling message: {}", e);
433 if msg.sender_id != "system" {
434 channel
435 .send(OutgoingMessage {
436 chat_id: msg.chat_id,
437 text: format!("Error: {}", e),
438 reply_to: Some(msg.id),
439 })
440 .await?;
441 }
442 }
443 }
444 }
445
446 channel.stop().await?;
447 Ok(())
448 }
449
450 pub async fn run_with_runtime_rx(
451 &self,
452 channel: &mut dyn Channel,
453 mut extra_rx: mpsc::Receiver<IncomingMessage>,
454 mut cron_rx: mpsc::Receiver<crate::cron_scheduler::DueJob>,
455 scheduler: Arc<crate::cron_scheduler::CronScheduler>,
456 ) -> anyhow::Result<()> {
457 let mut rx = channel.start().await?;
458 loop {
459 enum RuntimeInput {
460 Message(IncomingMessage),
461 Cron(crate::cron_scheduler::DueJob),
462 }
463 let input = tokio::select! {
464 Some(msg) = rx.recv() => RuntimeInput::Message(msg),
465 Some(msg) = extra_rx.recv() => RuntimeInput::Message(msg),
466 Some(job) = cron_rx.recv() => RuntimeInput::Cron(job),
467 else => break,
468 };
469 match input {
470 RuntimeInput::Message(msg) => {
471 let _ = channel.send_typing(&msg.chat_id).await;
472 match self.handle_message(&msg, channel).await {
473 Ok(response) if !response.trim().is_empty() => {
474 channel
475 .send(OutgoingMessage {
476 chat_id: msg.chat_id,
477 text: response,
478 reply_to: Some(msg.id),
479 })
480 .await?;
481 }
482 Ok(_) => {}
483 Err(error) if msg.sender_id != "system" => {
484 channel
485 .send(OutgoingMessage {
486 chat_id: msg.chat_id,
487 text: format!("Error: {error}"),
488 reply_to: Some(msg.id),
489 })
490 .await?;
491 }
492 Err(error) => tracing::error!("Error handling message: {error}"),
493 }
494 }
495 RuntimeInput::Cron(due) => {
496 let job = due.job;
497 let job_id = job.id.clone().unwrap_or_default();
498 let run_token = job.run_token.clone().unwrap_or_default();
499 if job.channel != channel.name() {
500 scheduler.release_run(&job_id, &run_token).await?;
501 continue;
502 }
503 let msg = IncomingMessage {
504 id: format!("cron-{job_id}"),
505 sender_id: "scheduler".to_string(),
506 sender_name: Some("Scheduler".to_string()),
507 chat_id: job.chat_id.clone(),
508 text: job.task.clone(),
509 is_group: false,
510 reply_to: None,
511 timestamp: chrono::Utc::now(),
512 };
513 match self
514 .handle_message_with_model(
515 &msg,
516 channel,
517 (!job.model.is_empty()).then_some(job.model.as_str()),
518 )
519 .await
520 {
521 Ok(response) => {
522 if !response.trim().is_empty() {
523 if let Err(error) = channel
524 .send(OutgoingMessage {
525 chat_id: job.chat_id,
526 text: response,
527 reply_to: None,
528 })
529 .await
530 {
531 scheduler
532 .fail_run(&job_id, &run_token, &error.to_string())
533 .await?;
534 continue;
535 }
536 }
537 scheduler
538 .mark_run(&job_id, &run_token, &job.schedule)
539 .await?;
540 }
541 Err(error) => {
542 scheduler
543 .fail_run(&job_id, &run_token, &error.to_string())
544 .await?
545 }
546 }
547 }
548 }
549 }
550 channel.stop().await?;
551 Ok(())
552 }
553
554 pub async fn handle_message(
557 &self,
558 msg: &IncomingMessage,
559 channel: &dyn Channel,
560 ) -> anyhow::Result<String> {
561 self.handle_message_with_model(msg, channel, None).await
562 }
563
564 pub async fn handle_message_with_model(
565 &self,
566 msg: &IncomingMessage,
567 channel: &dyn Channel,
568 model: Option<&str>,
569 ) -> anyhow::Result<String> {
570 let stream = self.stream_sink();
571 emit(
572 &stream,
573 AgentStreamEvent::Status {
574 message: "Thinking…".into(),
575 },
576 );
577
578 self.hook_manager
580 .emit(&LifecycleEvent::AgentStart(
581 msg.chat_id.clone(),
582 msg.text.clone(),
583 ))
584 .await;
585
586 {
588 let mut gr = self.guardrails.write().await;
589 gr.entry(msg.chat_id.clone()).or_insert_with(|| {
590 ToolGuardrails::new(crate::tools::guardrails::GuardrailConfig::default())
591 });
592 }
593
594 {
596 let mut trajs = self.trajectories.write().await;
597 if !trajs.contains_key(&msg.chat_id) {
598 let t = Trajectory::new(
599 format!("traj_{}", chrono::Utc::now().timestamp()),
600 msg.chat_id.clone(),
601 self.get_model(),
602 );
603 trajs.insert(msg.chat_id.clone(), t);
604 }
605 }
606
607 let draft_id: Option<String> = if channel.supports_draft_updates() {
608 channel.send_draft(&msg.chat_id, "⏳").await.unwrap_or(None)
609 } else {
610 let _ = channel.send_typing(&msg.chat_id).await;
611 None
612 };
613
614 if msg.is_group && !crate::context::should_respond(msg) {
615 tracing::debug!(
616 "Skipping ambient group message without assistant context: {}",
617 msg.id
618 );
619 return Ok(String::new());
620 }
621
622 let (effective_text, resume_plan, resume_model) = {
623 let mut plans = self.pending_plans.lock().unwrap();
624 if let Some(pending) = plans.get(&msg.chat_id) {
625 if pending.is_expired() {
626 plans.remove(&msg.chat_id);
627 (msg.text.clone(), None, None)
628 } else if is_approval(&msg.text) {
629 let pending = plans.remove(&msg.chat_id).unwrap();
630 tracing::info!("Plan approved for chat_id={}", msg.chat_id);
631 (
632 pending.original_message,
633 Some(pending.plan),
634 Some(pending.preferred_model),
635 )
636 } else if is_rejection(&msg.text) {
637 plans.remove(&msg.chat_id);
638 return Ok("Plan cancelled. What would you like to do instead?".to_string());
639 } else {
640 plans.remove(&msg.chat_id);
641 (msg.text.clone(), None, None)
642 }
643 } else {
644 (msg.text.clone(), None, None)
645 }
646 };
647 let resuming_from_plan = resume_plan.is_some();
648
649 let mode = self.get_mode();
650 let hooks_snapshot: Vec<Arc<dyn ToolHook>> = self.hooks.read().unwrap().clone();
651
652 let base_prompt = self.system_prompt.read().await.clone();
655 #[cfg(feature = "zkr-memory")]
656 let system_prompt = if self.zkr_config.self_improve {
657 if let Some(store) = &self.zkr {
658 match store.augment_prompt(&effective_text, &base_prompt).await {
659 Ok(augmented) => augmented,
660 Err(error) => {
661 tracing::warn!("self-improve augmentation failed: {error}");
662 base_prompt
663 }
664 }
665 } else {
666 base_prompt
667 }
668 } else {
669 base_prompt
670 };
671 #[cfg(not(feature = "zkr-memory"))]
672 let system_prompt = base_prompt;
673 let mut messages = vec![ChatMessage::system(&system_prompt)];
674 if let Some(guidance) = crate::context::routing_guidance(msg.is_group, channel.name()) {
675 messages.push(ChatMessage::system(guidance));
676 }
677
678 if let Some(mode_prompt) = mode.system_prompt_injection() {
679 messages.push(ChatMessage::system(mode_prompt));
680 }
681
682 {
684 let matched = {
685 let skills = self.skills.read().await;
686 skills::match_skill(&skills, &effective_text)
687 .map(|skill| (skill.name.clone(), skill.location.clone()))
688 };
689 if let Some((skill_name, location)) = matched {
690 let chat_id = msg.chat_id.clone();
691 let workspace = self.workspace.clone();
692 let preprocessed = tokio::task::spawn_blocking(move || {
693 let content = std::fs::read_to_string(&location).ok()?;
694 Some(skills::preprocess_skill_content(
695 &content,
696 location.parent(),
697 Some(&chat_id),
698 Some(&workspace),
699 ))
700 })
701 .await?;
702 if let Some(preprocessed) = preprocessed {
703 messages.push(ChatMessage::system(format!(
704 "# Active Skill: {}\n{}\n\nFollow the instructions above for this skill.",
705 skill_name, preprocessed
706 )));
707 tracing::info!("Skill matched: {} (preprocessed)", skill_name);
708 }
709 }
710 }
711 if msg.is_group {
712 if let Some(group_memory) = self.load_group_memory(&msg.chat_id).await? {
713 if !group_memory.trim().is_empty() {
714 messages.push(ChatMessage::system(crate::context::group_memory_prompt(
715 &msg.chat_id,
716 &group_memory,
717 )));
718 }
719 }
720 }
721
722 let history = crate::memory::context_inject::merged_history(
723 &self.memory,
724 &msg.chat_id,
725 self.memory_ideas.principal_id.as_deref(),
726 self.agent_config.max_history_messages,
727 )
728 .await?;
729 for (role, content) in history {
730 match role.as_str() {
731 "user" => messages.push(ChatMessage::user(&content)),
732 "assistant" => messages.push(ChatMessage::assistant(&content)),
733 _ => {}
734 }
735 }
736
737 let mut user_turn = effective_text.clone();
738 if self.memory_ideas.inject_context {
739 let blocks = crate::memory::context_inject::personal_context_blocks(
740 &self.memory,
741 crate::memory::context_inject::InjectConfig {
742 workspace: &self.workspace,
743 principal_id: self.memory_ideas.principal_id.as_deref(),
744 graph_recall_limit: self.memory_ideas.graph_recall_limit,
745 },
746 &effective_text,
747 )
748 .await;
749 if !blocks.is_empty() {
750 user_turn = format!("{user_turn}\n\n{}", blocks.join("\n\n"));
751 }
752 }
753 #[cfg(feature = "zkr-memory")]
754 if self.zkr_config.inject_recall {
755 if let Some(store) = &self.zkr {
756 match store
757 .context(&effective_text, self.zkr_config.recall_limit)
758 .await
759 {
760 Ok(Some(context)) => user_turn = format!("{user_turn}\n\n{context}"),
761 Ok(None) => {}
762 Err(error) => tracing::warn!("zkr recall failed: {error}"),
763 }
764 }
765 }
766 messages.push(ChatMessage::user(&user_turn));
767
768 let tool_specs: Vec<crate::tools::ToolSpec> =
769 self.tools.read().await.iter().map(|t| t.spec()).collect();
770 let tools_snapshot: Vec<Arc<dyn Tool>> = self.tools.read().await.iter().cloned().collect();
771 let main_model = model
772 .map(str::to_string)
773 .unwrap_or_else(|| self.model.read().unwrap().clone());
774
775 if self.agent_config.engine() == crate::config::AgentEngine::Rx4 {
778 let text = self
779 .run_via_rotary(&messages, &tools_snapshot, &main_model)
780 .await?;
781 return self.finish_execution(msg, &text, &draft_id, channel).await;
782 }
783
784 let needs_tools = if resuming_from_plan {
789 true
790 } else {
791 self.classify_request(&effective_text, &main_model).await
792 };
793 let mut state = if needs_tools {
794 AgentState::Planning
795 } else {
796 AgentState::Direct
797 };
798 tracing::info!("Initial state: {:?}", state);
799
800 let mut _plan: Option<String> = None;
801 let mut execution_model = main_model.clone();
802
803 if let Some(ref plan) = resume_plan {
804 messages.push(ChatMessage::system(format!(
805 "APPROVED EXECUTION PLAN (follow these steps):\n{}",
806 plan
807 )));
808 execution_model = resume_model.unwrap_or_else(|| main_model.clone());
809 state = AgentState::Executing;
810 tracing::info!("Resuming with approved plan, model={}", execution_model);
811 } else if state == AgentState::Planning {
812 let swarm_model_choice = if mode.is_swarm() {
813 " - SWARM: for tasks that can be parallelized across multiple independent agents\n"
814 } else {
815 ""
816 };
817 let plan_prompt = format!(
818 "You are a planning assistant. Analyze this request and output TWO things:\n\n\
819 1. MODEL_CHOICE: Pick one execution model:\n\
820 - SONNET: for general tasks, file ops, web, simple edits, queries\n\
821 - OPUS: for complex coding, architecture, multi-file refactors, debugging hard bugs\n\
822 - VIBEMANIA: for building features, creating projects, coding tasks that need autonomous agents\n\
823 {swarm_choice}\
824 2. PLAN: A brief numbered step-by-step plan.\n\
825 - If VIBEMANIA: the plan should be a single step: delegate to vibemania/subspace with the goal\n\
826 - If SWARM: the plan should list each independent subtask for parallel agents\n\
827 - If SONNET/OPUS: list what tools to use and in what order\n\n\
828 Format your response EXACTLY like:\n\
829 MODEL_CHOICE: SONNET\n\
830 PLAN:\n\
831 1. step one\n\
832 2. step two\n\n\
833 Available tools: {tools}\n\n\
834 User request: {request}",
835 swarm_choice = swarm_model_choice,
836 tools = tool_specs.iter().map(|t| format!("{} ({})", t.name, t.description.chars().take(50).collect::<String>())).collect::<Vec<_>>().join(", "),
837 request = effective_text
838 );
839
840 let plan_messages = [ChatMessage::user(&plan_prompt)];
841 let plan_request = ChatRequest {
842 messages: &plan_messages,
843 tools: None,
844 model: &self.agent_config.fast_model,
845 temperature: 0.8,
846 max_tokens: Some(500),
847 };
848
849 match self.provider.chat(&plan_request).await {
850 Ok(resp) => {
851 let p = resp.text.unwrap_or_default();
852 tracing::info!("Plan: {}", truncate_chars(&p, 300));
853
854 if let Some(usage) = &resp.usage {
855 let _ = self
856 .cost_tracker
857 .record(
858 &self.agent_config.fast_model,
859 TokenUsage {
860 input_tokens: usage.input_tokens as usize,
861 output_tokens: usage.output_tokens as usize,
862 total_tokens: (usage.input_tokens + usage.output_tokens)
863 as usize,
864 },
865 )
866 .await;
867 }
868
869 let p_upper = p.to_uppercase();
870 if p_upper.contains("MODEL_CHOICE: OPUS")
871 || p_upper.contains("MODEL_CHOICE:OPUS")
872 {
873 execution_model = self.agent_config.heavy_model.clone();
874 tracing::info!("Planner chose OPUS for execution");
875 } else if p_upper.contains("MODEL_CHOICE: SWARM")
876 || p_upper.contains("MODEL_CHOICE:SWARM")
877 {
878 tracing::info!("Planner chose SWARM — routing to coding_swarm tool");
879 messages.push(ChatMessage::system(
880 "IMPORTANT: This task can be parallelized. Use the `coding_swarm` tool \
881 to execute subtasks in parallel agents. \n\
882 Decompose the original goal into a list of independent tasks for the swarm."
883 .to_string(),
884 ));
885 } else if p_upper.contains("MODEL_CHOICE: VIBEMANIA")
886 || p_upper.contains("MODEL_CHOICE:VIBEMANIA")
887 {
888 tracing::info!("Planner chose VIBEMANIA — routing to vibemania tool");
889 messages.push(ChatMessage::system(
890 "IMPORTANT: This is a complex coding task. Use the `vibemania` tool \
891 to handle this autonomously. \n\
892 Do NOT write code yourself — delegate to vibemania."
893 .to_string(),
894 ));
895 }
896
897 if mode.prefer_heavy_model() && execution_model == main_model {
898 execution_model = self.agent_config.heavy_model.clone();
899 tracing::info!("Coding mode: upgraded to heavy model");
900 }
901
902 messages.push(ChatMessage::system(format!(
903 "EXECUTION PLAN (follow these steps):\n{}",
904 p
905 )));
906 _plan = Some(p.clone());
907 state = AgentState::Executing;
908
909 if mode.plan_approval() {
910 {
911 let mut plans = self.pending_plans.lock().unwrap();
912 plans.retain(|_, v| !v.is_expired());
913 plans.insert(
914 msg.chat_id.clone(),
915 PendingPlan {
916 plan: p.clone(),
917 original_message: effective_text.clone(),
918 preferred_model: execution_model.clone(),
919 created_at: std::time::Instant::now(),
920 },
921 );
922 }
923 let plan_response = format!(
924 "**Coding Plan**\n\n{}\n\n---\nReply **go** to execute or **cancel** to abort.",
925 p
926 );
927 if let Some(ref mid) = draft_id {
928 let _ = channel
929 .finalize_draft(&msg.chat_id, mid, &plan_response)
930 .await;
931 return Ok(String::new());
932 }
933 return Ok(plan_response);
934 }
935 }
936 Err(e) => {
937 tracing::warn!("Planning failed ({}), falling back to direct", e);
938 state = AgentState::Direct;
939 }
940 }
941 }
942
943 let mut tool_call_history: Vec<String> = Vec::new();
948 let mut compactions_done: usize = 0;
949 let mut healing_attempts_left = 3;
951
952 for round in 0..self.agent_config.max_rounds {
953 {
955 let mut queue = self.steering_queue.lock().unwrap();
956 if !queue.is_empty() {
957 for steer_msg in queue.drain(..) {
958 tracing::info!(
959 chars = steer_msg.chars().count(),
960 "Steering message queued"
961 );
962 messages.push(ChatMessage::user(format!(
963 "⚡ STEERING — new instruction from user (prioritize this): {}",
964 steer_msg
965 )));
966 }
967 }
968 }
969
970 let context_chars: usize = messages.iter().map(|m| m.content.len()).sum();
972 let max_chars = self.agent_config.max_context_chars;
973 if let Some(ref compactor) = self.compactor {
974 let info = ContextInfo {
975 message_count: messages.len(),
976 total_chars: context_chars,
977 max_chars,
978 compactions_done,
979 };
980 if compactor.should_compress(&info) {
981 tracing::info!(
982 "Compacting at round {} ({} chars, {} msgs)",
983 round + 1,
984 context_chars,
985 messages.len()
986 );
987 let result = compactor.compress(&messages, Some(&effective_text)).await;
988 if result.did_compact {
989 messages = result.messages;
990 compactions_done += 1;
991 }
992 }
993 } else {
994 if context_chars > max_chars {
996 tracing::info!(
997 "Compacting at round {} ({} chars)",
998 round + 1,
999 context_chars
1000 );
1001 messages = self.compact_messages(messages, &effective_text).await?;
1002 compactions_done += 1;
1003 }
1004 }
1005
1006 let (model, temperature) = match state {
1008 AgentState::Planning => (self.agent_config.fast_model.clone(), 0.8),
1009 AgentState::Executing => (execution_model.clone(), 0.2),
1010 AgentState::Summarizing => (self.agent_config.fast_model.clone(), 0.7),
1011 AgentState::Direct => (main_model.clone(), 0.7),
1012 };
1013
1014 tracing::info!(
1015 "[{:?}] round {} {} msgs, ~{} chars, model={}",
1016 state,
1017 round + 1,
1018 messages.len(),
1019 messages.iter().map(|m| m.content.len()).sum::<usize>(),
1020 model
1021 );
1022
1023 let request = ChatRequest {
1025 messages: &messages,
1026 tools: if tool_specs.is_empty() || state == AgentState::Summarizing {
1027 None
1028 } else {
1029 Some(&tool_specs)
1030 },
1031 model: &model,
1032 temperature,
1033 max_tokens: Some(8192),
1034 };
1035
1036 let mut response = self.provider.chat(&request).await?;
1037
1038 if !response.has_tool_calls() {
1042 let (text, recovered) =
1043 crate::streaming_parser::recover_tool_calls(response.text_or_empty());
1044 if !recovered.is_empty() {
1045 tracing::debug!("recovered {} XML tool call(s) from text", recovered.len());
1046 response.text = (!text.trim().is_empty()).then_some(text);
1047 response.tool_calls = recovered
1048 .into_iter()
1049 .map(|c| crate::providers::ToolCall {
1050 id: c.id,
1051 name: c.name,
1052 arguments: c.arguments,
1053 })
1054 .collect();
1055 }
1056 }
1057
1058 if let Some(usage) = &response.usage {
1059 let _ = self
1060 .cost_tracker
1061 .record(
1062 &model,
1063 TokenUsage {
1064 input_tokens: usage.input_tokens as usize,
1065 output_tokens: usage.output_tokens as usize,
1066 total_tokens: (usage.input_tokens + usage.output_tokens) as usize,
1067 },
1068 )
1069 .await;
1070 }
1071
1072 if !response.has_tool_calls() {
1073 let text = response.text.unwrap_or_default();
1074
1075 match state {
1076 AgentState::Executing => {
1077 if round >= 3 {
1078 tracing::info!(
1079 "Execution done after {} rounds, summarizing",
1080 round + 1
1081 );
1082 state = AgentState::Summarizing;
1083 messages.push(ChatMessage::assistant(text));
1084 messages.push(ChatMessage::user(
1085 "Now provide a clean, concise final response to the user. \
1086 Summarize what you did and the results. Be brief and direct."
1087 .to_string(),
1088 ));
1089 continue;
1090 }
1091 tracing::info!("Done after {} round(s) [{:?}]", round + 1, state);
1092 return self.finish_execution(msg, &text, &draft_id, channel).await;
1093 }
1094 AgentState::Summarizing | AgentState::Direct | AgentState::Planning => {
1095 tracing::info!("Done after {} round(s) [{:?}]", round + 1, state);
1096 return self.finish_execution(msg, &text, &draft_id, channel).await;
1097 }
1098 }
1099 }
1100
1101 for tc in &response.tool_calls {
1105 let hash = format!("{}:{}", tc.name, truncate_chars(&tc.arguments, 200));
1106 tool_call_history.push(hash);
1107 }
1108 if tool_call_history.len() >= LOOP_BREAK_THRESHOLD {
1109 let last = &tool_call_history[tool_call_history.len() - 1];
1110 let consecutive = tool_call_history
1111 .iter()
1112 .rev()
1113 .take_while(|h| *h == last)
1114 .count();
1115 if consecutive >= LOOP_BREAK_THRESHOLD {
1116 tracing::warn!("Loop: {} identical calls, breaking", consecutive);
1117 return Ok(format!(
1118 "Loop detected ({} identical {} calls). Stopping.",
1119 consecutive, response.tool_calls[0].name
1120 ));
1121 }
1122 if consecutive >= LOOP_WARN_THRESHOLD {
1123 messages.push(ChatMessage::user(
1124 "WARNING: You're repeating tool calls. Stop and answer with what you have."
1125 .to_string(),
1126 ));
1127 }
1128 }
1129
1130 if !channel.supports_draft_updates() {
1131 let _ = channel.send_typing(&msg.chat_id).await;
1132 }
1133
1134 {
1136 let mut content_blocks: Vec<serde_json::Value> = Vec::new();
1137 if let Some(text) = &response.text {
1138 if !text.is_empty() {
1139 content_blocks.push(serde_json::json!({
1140 "type": "text", "text": text,
1141 }));
1142 }
1143 }
1144 for tc in &response.tool_calls {
1145 content_blocks.push(serde_json::json!({
1146 "type": "tool_use",
1147 "id": &tc.id,
1148 "name": &tc.name,
1149 "input": serde_json::from_str::<serde_json::Value>(&tc.arguments).unwrap_or_default(),
1150 }));
1151 }
1152 messages.push(ChatMessage {
1153 role: "assistant_tool_use".to_string(),
1154 content: String::new(),
1155 tool_use_id: Some(serde_json::to_string(&content_blocks).unwrap_or_default()),
1156 });
1157 }
1158
1159 tracing::info!(
1160 "Tools: {}",
1161 response
1162 .tool_calls
1163 .iter()
1164 .map(|tc| tc.name.as_str())
1165 .collect::<Vec<_>>()
1166 .join(", ")
1167 );
1168
1169 let mut progress_lines: Vec<String> = Vec::new();
1170 let mut tool_errors: Vec<String> = Vec::new();
1171
1172 let hook_ctx = crate::agent::rotary_bridge::ToolHookContext::new(
1175 hooks_snapshot.clone(),
1176 Some(Arc::clone(&self.plugin_registry)),
1177 )
1178 .with_hook_manager(Arc::clone(&self.hook_manager))
1179 .with_stream(stream.clone());
1180
1181 for tc in &response.tool_calls {
1182 if let (true, Some(ref mid)) = (channel.supports_draft_updates(), &draft_id) {
1183 let hint = extract_tool_hint(&tc.name, &tc.arguments);
1184 let start_line = if hint.is_empty() {
1185 format!("⏳ {}\n", tc.name)
1186 } else {
1187 format!("⏳ {}: {}\n", tc.name, hint)
1188 };
1189 progress_lines.push(start_line.clone());
1190 let _ = channel
1191 .update_draft_progress(&msg.chat_id, mid, &progress_lines.join(""))
1192 .await;
1193 }
1194
1195 let started = std::time::Instant::now();
1196
1197 let result = crate::agent::rotary_bridge::execute_tool_with_hooks(
1199 &hook_ctx,
1200 &tc.name,
1201 &tc.arguments,
1202 tools_snapshot.iter().find(|t| t.name() == tc.name),
1203 )
1204 .await;
1205
1206 {
1208 let mut gr = self.guardrails.write().await;
1209 if let Some(g) = gr.get_mut(&msg.chat_id) {
1210 let decision =
1211 g.observe(&tc.name, &tc.arguments, &result.output, result.is_error);
1212 match decision {
1213 GuardrailDecision::Stop(reason) => {
1214 tracing::warn!("Guardrail stop: {}", reason);
1215 return self
1216 .finish_execution(msg, &reason, &draft_id, channel)
1217 .await;
1218 }
1219 GuardrailDecision::Warn(warning) => {
1220 tracing::warn!("Guardrail warn: {}", warning);
1221 messages.push(ChatMessage::user(warning));
1222 }
1223 GuardrailDecision::Proceed => {}
1224 }
1225 }
1226 }
1227
1228 let elapsed = started.elapsed().as_secs();
1229
1230 {
1232 let mut trajs = self.trajectories.write().await;
1233 if let Some(t) = trajs.get_mut(&msg.chat_id) {
1234 t.record_tool_step(
1235 response.text.clone(),
1236 tc.name.clone(),
1237 crate::redaction::redact_tool_payload(&tc.name, &tc.arguments),
1238 crate::redaction::redact_tool_payload(&tc.name, &result.output),
1239 !result.is_error,
1240 );
1241 }
1242 }
1243
1244 if let (true, Some(ref mid)) = (channel.supports_draft_updates(), &draft_id) {
1245 let done_line = if result.is_error {
1246 format!("❌ {} ({}s)\n", tc.name, elapsed)
1247 } else {
1248 format!("✅ {} ({}s)\n", tc.name, elapsed)
1249 };
1250 let prefix = format!("⏳ {}", tc.name);
1251 if let Some(pos) = progress_lines.iter().rposition(|l| l.starts_with(&prefix)) {
1252 progress_lines[pos] = done_line;
1253 } else {
1254 progress_lines.push(done_line);
1255 }
1256 let _ = channel
1257 .update_draft_progress(&msg.chat_id, mid, &progress_lines.join(""))
1258 .await;
1259 }
1260
1261 if result.is_error && automatic_retry_allowed(&tc.name, &result.output) {
1263 tool_errors.push(format!(
1264 "'{}' failed: {}",
1265 tc.name,
1266 truncate_chars(&result.output, 200)
1267 ));
1268 }
1269
1270 let limit = self.agent_config.max_tool_result_chars;
1271 let truncated_output = match truncate_chars_counted(&result.output, limit) {
1272 Some((head, dropped)) => {
1273 format!("{head}...\n⚠️ [Truncated: {dropped} chars dropped, {limit} kept]")
1274 }
1275 None => result.output.clone(),
1276 };
1277
1278 messages.push(ChatMessage::tool_result(&tc.id, &truncated_output));
1279 }
1280
1281 if !tool_errors.is_empty() && healing_attempts_left > 0 {
1283 let error_summary = tool_errors.join("\n");
1284 tracing::info!(
1285 "Self-healing: {} tool errors, {} attempts left",
1286 tool_errors.len(),
1287 healing_attempts_left
1288 );
1289 messages.push(ChatMessage::user(format!(
1290 "The following tool call(s) failed:\n{}\n\n\
1291 Please try a different approach. Previous steps are still valid — \
1292 don't repeat what already worked unless needed.",
1293 error_summary
1294 )));
1295 healing_attempts_left -= 1;
1296 {
1298 let mut gr = self.guardrails.write().await;
1299 if let Some(g) = gr.get_mut(&msg.chat_id) {
1300 g.reset();
1301 }
1302 }
1303 continue; }
1305
1306 if state == AgentState::Direct {
1307 state = AgentState::Executing;
1308 }
1309 }
1310
1311 tracing::warn!(
1313 "Circuit breaker after {} rounds",
1314 self.agent_config.max_rounds
1315 );
1316 self.persist_conversation(
1317 msg,
1318 &format!("Hit {} rounds.", self.agent_config.max_rounds),
1319 )
1320 .await?;
1321
1322 {
1324 let mut trajs = self.trajectories.write().await;
1325 if let Some(t) = trajs.get_mut(&msg.chat_id) {
1326 t.success = false;
1327 }
1328 }
1329
1330 let circuit_msg = format!(
1331 "⚠️ Hit {} rounds ({} compactions). Break into smaller tasks?",
1332 self.agent_config.max_rounds, compactions_done
1333 );
1334 if let Some(ref mid) = draft_id {
1335 let _ = channel
1336 .finalize_draft(&msg.chat_id, mid, &circuit_msg)
1337 .await;
1338 return Ok(String::new());
1339 }
1340 Ok(circuit_msg)
1341 }
1342
1343 async fn run_via_rotary(
1354 &self,
1355 messages: &[ChatMessage],
1356 tools: &[Arc<dyn Tool>],
1357 model: &str,
1358 ) -> anyhow::Result<String> {
1359 use crate::agent::rotary_bridge::{RotaryAgentBridge, RotaryBridgeConfig};
1360
1361 let system_prompt = messages
1364 .iter()
1365 .filter(|m| m.role == "system")
1366 .map(|m| m.content.as_str())
1367 .collect::<Vec<_>>()
1368 .join("\n\n");
1369
1370 let mut history: Vec<ChatMessage> = messages
1371 .iter()
1372 .filter(|m| m.role != "system")
1373 .cloned()
1374 .collect();
1375 let prompt = history
1376 .pop()
1377 .ok_or_else(|| anyhow::anyhow!("no user turn to run through rx4"))?;
1378
1379 let mut bridge = RotaryAgentBridge::new(RotaryBridgeConfig {
1380 provider: Arc::clone(&self.provider),
1381 tools: tools.to_vec(),
1382 system_prompt,
1383 model: model.to_string(),
1384 workspace: self.workspace.clone(),
1385 max_tool_iterations: self.agent_config.max_rounds,
1386 hook_ctx: crate::agent::rotary_bridge::ToolHookContext::new(
1388 self.hooks.read().unwrap().clone(),
1389 Some(Arc::clone(&self.plugin_registry)),
1390 )
1391 .with_hook_manager(Arc::clone(&self.hook_manager))
1392 .with_stream(self.stream_sink()),
1393 });
1394
1395 bridge
1396 .run_prompt_with_history(&prompt.content, &history)
1397 .await
1398 }
1399
1400 async fn finish_execution(
1402 &self,
1403 msg: &IncomingMessage,
1404 text: &str,
1405 draft_id: &Option<String>,
1406 channel: &dyn Channel,
1407 ) -> anyhow::Result<String> {
1408 self.persist_conversation(msg, text).await?;
1409
1410 {
1412 let mut trajs = self.trajectories.write().await;
1413 if let Some(t) = trajs.get_mut(&msg.chat_id) {
1414 t.success = true;
1415 t.record_response(text.to_string());
1416 t.iterations = t.tool_calls; }
1418 }
1419
1420 self.hook_manager
1422 .emit(&LifecycleEvent::AgentDone(
1423 msg.chat_id.clone(),
1424 text.to_string(),
1425 ))
1426 .await;
1427 if let Some(ws) = &self.session_note_workspace {
1428 let preview: String = text.chars().take(200).collect();
1429 if !preview.is_empty() {
1430 let _ =
1431 crate::memory::session_note::append_session_note(ws, &msg.chat_id, &preview);
1432 }
1433 }
1434
1435 let stream = self.stream_sink();
1436 emit(
1437 &stream,
1438 AgentStreamEvent::Done {
1439 response: text.to_string(),
1440 },
1441 );
1442
1443 let delivered_via_draft = if let Some(ref mid) = draft_id {
1447 let _ = channel.finalize_draft(&msg.chat_id, mid, text).await;
1448 true
1449 } else {
1450 false
1451 };
1452
1453 #[cfg(feature = "zkr-memory")]
1454 if self.zkr_config.self_improve {
1455 if let Some(store) = &self.zkr {
1456 let _ = store
1457 .record_reflection(&msg.text, "agent turn", text, "completed")
1458 .await;
1459 }
1460 }
1461
1462 if delivered_via_draft {
1463 Ok(String::new())
1464 } else {
1465 Ok(text.to_string())
1466 }
1467 }
1468
1469 async fn classify_request(&self, text: &str, _model: &str) -> bool {
1470 if self.get_mode().always_plan() {
1471 return true;
1472 }
1473
1474 let lower = text.to_lowercase();
1475 let word_count = text.split_whitespace().count();
1476
1477 if word_count <= 5 {
1478 return false;
1479 }
1480
1481 let tool_keywords = [
1482 "read ",
1483 "write ",
1484 "edit ",
1485 "create ",
1486 "build ",
1487 "fix ",
1488 "search ",
1489 "fetch ",
1490 "check ",
1491 "run ",
1492 "execute ",
1493 "install ",
1494 "deploy ",
1495 "find ",
1496 "list ",
1497 "show me ",
1498 "what's in ",
1499 "look at ",
1500 "file",
1501 "code",
1502 "commit",
1503 "git ",
1504 "grep",
1505 "curl",
1506 ];
1507 for kw in &tool_keywords {
1508 if lower.contains(kw) {
1509 return true;
1510 }
1511 }
1512
1513 if word_count >= 15
1514 && (lower.contains('?') || lower.contains("can you") || lower.contains("please"))
1515 {
1516 return true;
1517 }
1518
1519 false
1520 }
1521
1522 async fn persist_conversation(
1523 &self,
1524 msg: &IncomingMessage,
1525 response: &str,
1526 ) -> anyhow::Result<()> {
1527 self.memory
1528 .store_conversation_batch(&[
1529 (&msg.chat_id, &msg.sender_id, "user", &msg.text),
1530 (&msg.chat_id, "assistant", "assistant", response),
1531 ])
1532 .await?;
1533 #[cfg(feature = "zkr-memory")]
1534 if self.zkr_config.auto_capture {
1535 if let Some(store) = &self.zkr {
1536 if let Err(error) = store
1537 .capture_turn(
1538 &msg.chat_id,
1539 &msg.id,
1540 &msg.text,
1541 response,
1542 msg.timestamp.timestamp(),
1543 )
1544 .await
1545 {
1546 tracing::warn!("zkr turn capture failed: {error}");
1547 }
1548 }
1549 }
1550 if msg.is_group {
1551 self.update_group_memory(msg, response).await?;
1552 }
1553 Ok(())
1554 }
1555
1556 async fn load_group_memory(&self, chat_id: &str) -> anyhow::Result<Option<String>> {
1557 let key = crate::context::group_memory_key(chat_id);
1558 Ok(self
1559 .memory
1560 .recall(&self.group_chat.rolling_memory_namespace, &key)
1561 .await?
1562 .map(|entry| entry.value))
1563 }
1564
1565 async fn update_group_memory(
1566 &self,
1567 msg: &IncomingMessage,
1568 response: &str,
1569 ) -> anyhow::Result<()> {
1570 let existing = self
1571 .load_group_memory(&msg.chat_id)
1572 .await?
1573 .unwrap_or_default();
1574 let updated = Self::rolling_group_memory(
1575 &existing,
1576 msg,
1577 response,
1578 self.group_chat.rolling_memory_max_chars,
1579 );
1580 let key = crate::context::group_memory_key(&msg.chat_id);
1581 self.memory
1582 .store(
1583 &self.group_chat.rolling_memory_namespace,
1584 &key,
1585 &updated,
1586 None,
1587 )
1588 .await?;
1589 Ok(())
1590 }
1591
1592 fn rolling_group_memory(
1593 existing: &str,
1594 msg: &IncomingMessage,
1595 response: &str,
1596 max_chars: usize,
1597 ) -> String {
1598 let sender = msg
1599 .sender_name
1600 .as_deref()
1601 .filter(|name| !name.trim().is_empty())
1602 .unwrap_or(&msg.sender_id);
1603 let mut text = String::new();
1604 if !existing.trim().is_empty() {
1605 text.push_str(existing.trim());
1606 text.push_str("\n\n");
1607 }
1608 text.push_str(&format!("[{sender}] user: {}\n", msg.text.trim()));
1609 text.push_str(&format!("assistant: {}", response.trim()));
1610 if text.chars().count() <= max_chars {
1611 return text;
1612 }
1613 let tail: String = text
1614 .chars()
1615 .rev()
1616 .take(max_chars)
1617 .collect::<Vec<_>>()
1618 .into_iter()
1619 .rev()
1620 .collect();
1621 if let Some(idx) = tail.find('\n') {
1622 tail[idx + 1..].to_string()
1623 } else {
1624 tail
1625 }
1626 }
1627
1628 async fn compact_messages(
1630 &self,
1631 messages: Vec<ChatMessage>,
1632 original_task: &str,
1633 ) -> anyhow::Result<Vec<ChatMessage>> {
1634 let info = ContextInfo {
1635 message_count: messages.len(),
1636 total_chars: messages.iter().map(|m| m.content.len()).sum(),
1637 max_chars: self.agent_config.max_context_chars,
1638 compactions_done: 0,
1639 };
1640 if let Some(ref compactor) = self.compactor {
1641 if compactor.should_compress(&info) {
1642 let result = compactor.compress(&messages, Some(original_task)).await;
1643 if result.did_compact {
1644 tracing::info!(
1645 "Compactor '{}' compressed {} → {} messages",
1646 compactor.name(),
1647 messages.len(),
1648 result.messages.len()
1649 );
1650 return Ok(result.messages);
1651 }
1652 }
1653 }
1654 let keep_recent = 6;
1656 if messages.len() <= keep_recent + 2 {
1657 return Ok(messages);
1658 }
1659 let system_msgs: Vec<&ChatMessage> =
1660 messages.iter().filter(|m| m.role == "system").collect();
1661 let non_system: Vec<&ChatMessage> =
1662 messages.iter().filter(|m| m.role != "system").collect();
1663 if non_system.len() <= keep_recent {
1664 return Ok(messages);
1665 }
1666 let mut split = non_system.len() - keep_recent;
1671 while split < non_system.len() && non_system[split].is_tool_result() {
1672 split += 1;
1673 }
1674 let (old_msgs, recent_msgs) = non_system.split_at(split);
1675 let mut summary_input = String::new();
1676 for m in old_msgs {
1677 let role_label = match m.role.as_str() {
1678 "user" => "User",
1679 "assistant" | "assistant_tool_use" => "Assistant",
1680 "tool_result" => "Tool Result",
1681 _ => &m.role,
1682 };
1683 let content = compaction_excerpt(&m.content);
1684 summary_input.push_str(&format!("[{}]: {}\n", role_label, content));
1685 }
1686 let compaction_prompt = format!(
1687 "Summarize this conversation concisely. The original task was: \"{}\"\n\n\
1688 Focus on: what was accomplished, what tools were used, key results, and what's still pending.\n\n\
1689 Conversation:\n{}",
1690 original_task, summary_input
1691 );
1692 let compact_messages = [ChatMessage::user(&compaction_prompt)];
1693 let compact_request = ChatRequest {
1694 messages: &compact_messages,
1695 tools: None,
1696 model: &self.agent_config.fast_model,
1697 temperature: 0.3,
1698 max_tokens: Some(1000),
1699 };
1700 let summary = match self.provider.chat(&compact_request).await {
1701 Ok(resp) => {
1702 if let Some(usage) = &resp.usage {
1703 let _ = self
1704 .cost_tracker
1705 .record(
1706 &self.agent_config.fast_model,
1707 TokenUsage {
1708 input_tokens: usage.input_tokens as usize,
1709 output_tokens: usage.output_tokens as usize,
1710 total_tokens: (usage.input_tokens + usage.output_tokens) as usize,
1711 },
1712 )
1713 .await;
1714 }
1715 resp.text
1716 .unwrap_or_else(|| "Failed to summarize.".to_string())
1717 }
1718 Err(e) => {
1719 tracing::warn!("Compaction failed: {}, falling back to truncation", e);
1720 format!(
1721 "[Previous {} messages truncated to save context]",
1722 old_msgs.len()
1723 )
1724 }
1725 };
1726 let mut compacted = Vec::new();
1727 for sm in &system_msgs {
1728 compacted.push((*sm).clone());
1729 }
1730 compacted.push(ChatMessage::user(format!(
1731 "[Conversation compacted — {} earlier messages summarized]\n\n{}",
1732 old_msgs.len(),
1733 summary
1734 )));
1735 compacted.push(ChatMessage::assistant(
1736 "Understood, continuing from the summary.".to_string(),
1737 ));
1738 compacted.extend(recent_msgs.iter().map(|m| (*m).clone()));
1739 Ok(compacted)
1740 }
1741
1742 pub async fn get_trajectory(&self, chat_id: &str) -> Option<Trajectory> {
1746 let trajs = self.trajectories.read().await;
1747 trajs.get(chat_id).cloned()
1748 }
1749
1750 pub async fn get_all_trajectories(&self) -> Vec<Trajectory> {
1752 let trajs = self.trajectories.read().await;
1753 trajs.values().cloned().collect()
1754 }
1755
1756 pub async fn save_trajectory(&self, chat_id: &str, dir: &Path) -> anyhow::Result<()> {
1758 if let Some(traj) = self.get_trajectory(chat_id).await {
1759 let path = dir.join(trajectory_filename(chat_id));
1760 traj.save_to_file(&path)?;
1761 tracing::info!("Trajectory saved: {:?}", path);
1762 }
1763 Ok(())
1764 }
1765}
1766
1767fn trajectory_filename(chat_id: &str) -> String {
1770 format!("traj_{:x}.json", Sha256::digest(chat_id.as_bytes()))
1771}
1772
1773pub(crate) fn extract_tool_hint(name: &str, arguments: &str) -> String {
1774 let v: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
1775 let hint = match name {
1776 "shell" | "bash" | "exec" => v
1777 .get("command")
1778 .or_else(|| v.get("cmd"))
1779 .and_then(|s| s.as_str()),
1780 "web_search" | "search" => v
1781 .get("query")
1782 .or_else(|| v.get("q"))
1783 .and_then(|s| s.as_str()),
1784 "web_fetch" | "fetch" => v.get("url").and_then(|s| s.as_str()),
1785 "file_ops" | "read" | "write" | "edit" => v
1786 .get("path")
1787 .or_else(|| v.get("file_path"))
1788 .and_then(|s| s.as_str()),
1789 "vibemania" => v.get("goal").and_then(|s| s.as_str()),
1790 _ => v
1791 .as_object()
1792 .and_then(|o| o.values().next())
1793 .and_then(|v| v.as_str()),
1794 };
1795 hint.map(|s| {
1796 let s = s.trim();
1797 if s.chars().count() > 60 {
1798 format!("{}…", truncate_chars(s, 57))
1799 } else {
1800 s.to_string()
1801 }
1802 })
1803 .unwrap_or_default()
1804}
1805
1806fn automatic_retry_allowed(tool: &str, output: &str) -> bool {
1807 tool != "praefectus"
1808 || serde_json::from_str::<serde_json::Value>(output)
1809 .ok()
1810 .and_then(|value| value.get("retry_safe")?.as_bool())
1811 == Some(true)
1812}
1813
1814#[cfg(test)]
1815mod retry_tests {
1816 use std::path::Path;
1817
1818 use super::{
1819 automatic_retry_allowed, compaction_excerpt, trajectory_filename, truncate_chars,
1820 ChatMessage,
1821 };
1822
1823 #[test]
1824 fn truncating_multibyte_tool_output_does_not_panic() {
1825 let output = "日本語".repeat(200);
1827 assert_eq!(truncate_chars(&output, 200).chars().count(), 200);
1828 assert_eq!(truncate_chars("hi", 200), "hi");
1829 assert_eq!(truncate_chars("héllo", 2), "hé");
1830 }
1831
1832 #[test]
1833 fn compaction_boundary_never_orphans_a_tool_result() {
1834 let non_system = [
1837 ChatMessage::user("a"),
1838 ChatMessage::assistant("calls tool"),
1839 ChatMessage::tool_result("id-1", "out"),
1840 ChatMessage::assistant("done"),
1841 ];
1842 let keep_recent = 2;
1843 let mut split = non_system.len() - keep_recent;
1844 while split < non_system.len() && non_system[split].is_tool_result() {
1845 split += 1;
1846 }
1847 assert_eq!(split, 3);
1848 assert!(!non_system[split].is_tool_result());
1849 }
1850
1851 #[test]
1852 fn trajectory_filename_confines_external_chat_ids() {
1853 let filename = trajectory_filename("x/../../outside");
1854 assert_eq!(
1855 Path::new(&filename).parent(),
1856 Some(Path::new("")),
1857 "trajectory filename must be one path component"
1858 );
1859 assert_eq!(filename.len(), "traj_".len() + 64 + ".json".len());
1860 assert!(filename.starts_with("traj_"));
1861 assert!(filename.ends_with(".json"));
1862 assert!(!filename.contains(".."));
1863 assert!(!filename.contains('/'));
1864 assert!(!filename.contains('\\'));
1865 }
1866
1867 #[test]
1868 fn praefectus_uncertainty_is_not_automatically_retried() {
1869 assert!(!automatic_retry_allowed(
1870 "praefectus",
1871 r#"{"retry_safe":false}"#
1872 ));
1873 assert!(!automatic_retry_allowed("praefectus", "unstructured error"));
1874 assert!(automatic_retry_allowed(
1875 "praefectus",
1876 r#"{"retry_safe":true}"#
1877 ));
1878 assert!(automatic_retry_allowed("shell", "failed"));
1879 }
1880
1881 #[test]
1882 fn compaction_excerpt_handles_multibyte_content() {
1883 let cjk = "日".repeat(400);
1884 let excerpt = compaction_excerpt(&cjk);
1885 assert_eq!(excerpt, cjk);
1886
1887 let long = "日".repeat(600);
1888 let excerpt = compaction_excerpt(&long);
1889 assert_eq!(excerpt.chars().count(), 503);
1890 assert!(excerpt.ends_with("..."));
1891
1892 let emoji = "👋".repeat(501);
1893 assert_eq!(compaction_excerpt(&emoji).chars().count(), 503);
1894
1895 assert_eq!(compaction_excerpt("short"), "short");
1896 }
1897}