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