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