Skip to main content

pawan/agent/
mod.rs

1//! Pawan Agent - The core agent that handles tool-calling loops
2//!
3//! This module provides the main `PawanAgent` which:
4//! - Manages conversation history
5//! - Coordinates tool calling with the LLM via pluggable backends
6//! - Provides streaming responses
7//! - Supports multiple LLM backends (NVIDIA API, Ollama, OpenAI)
8
9pub mod backend;
10mod preflight;
11pub mod session;
12pub mod git_session;
13
14use crate::config::{LlmProvider, PawanConfig};
15use crate::tools::{ToolDefinition, ToolRegistry};
16use crate::{PawanError, Result};
17use backend::openai_compat::{OpenAiCompatBackend, OpenAiCompatConfig};
18use backend::LlmBackend;
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Value};
21use std::path::PathBuf;
22
23/// A message in the conversation
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Message {
26    /// Role of the message sender
27    pub role: Role,
28    /// Content of the message
29    pub content: String,
30    /// Tool calls (if any)
31    #[serde(default)]
32    pub tool_calls: Vec<ToolCallRequest>,
33    /// Tool results (if this is a tool result message)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub tool_result: Option<ToolResultMessage>,
36}
37
38/// Role of a message sender
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40#[serde(rename_all = "lowercase")]
41pub enum Role {
42    System,
43    User,
44    Assistant,
45    Tool,
46}
47
48/// A request to call a tool
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ToolCallRequest {
51    /// Unique ID for this tool call
52    pub id: String,
53    /// Name of the tool to call
54    pub name: String,
55    /// Arguments for the tool
56    pub arguments: Value,
57}
58
59/// Result from a tool execution
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolResultMessage {
62    /// ID of the tool call this result is for
63    pub tool_call_id: String,
64    /// The result content
65    pub content: Value,
66    /// Whether the tool executed successfully
67    pub success: bool,
68}
69
70/// Record of a tool call execution
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ToolCallRecord {
73    /// Unique ID for this tool call
74    pub id: String,
75    /// Name of the tool
76    pub name: String,
77    /// Arguments passed to the tool
78    pub arguments: Value,
79    /// Result from the tool
80    pub result: Value,
81    /// Whether execution was successful
82    pub success: bool,
83    /// Duration in milliseconds
84    pub duration_ms: u64,
85}
86
87/// Token usage from an LLM response
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct TokenUsage {
90    pub prompt_tokens: u64,
91    pub completion_tokens: u64,
92    pub total_tokens: u64,
93    /// Tokens spent on reasoning/thinking (subset of completion_tokens)
94    pub reasoning_tokens: u64,
95    /// Tokens spent on actual content/tool output (completion - reasoning)
96    pub action_tokens: u64,
97}
98
99/// LLM response from a generation request
100#[derive(Debug, Clone)]
101pub struct LLMResponse {
102    /// Text content of the response
103    pub content: String,
104    /// Reasoning/thinking content (separate from visible content)
105    pub reasoning: Option<String>,
106    /// Tool calls requested by the model
107    pub tool_calls: Vec<ToolCallRequest>,
108    /// Reason the response finished
109    pub finish_reason: String,
110    /// Token usage (if available)
111    pub usage: Option<TokenUsage>,
112}
113
114/// Result from a complete agent execution
115#[derive(Debug)]
116pub struct AgentResponse {
117    /// Final text response
118    pub content: String,
119    /// All tool calls made during execution
120    pub tool_calls: Vec<ToolCallRecord>,
121    /// Number of iterations taken
122    pub iterations: usize,
123    /// Cumulative token usage across all iterations
124    pub usage: TokenUsage,
125}
126
127/// Callback for receiving streaming tokens
128pub type TokenCallback = Box<dyn Fn(&str) + Send + Sync>;
129
130/// Callback for receiving tool call updates
131pub type ToolCallback = Box<dyn Fn(&ToolCallRecord) + Send + Sync>;
132
133/// Callback for tool call start notifications
134pub type ToolStartCallback = Box<dyn Fn(&str) + Send + Sync>;
135
136/// A permission request sent from the agent to the UI for approval.
137#[derive(Debug, Clone)]
138pub struct PermissionRequest {
139    /// Tool name requesting permission
140    pub tool_name: String,
141    /// Summary of arguments (e.g. bash command or file path)
142    pub args_summary: String,
143}
144
145/// Callback for requesting tool permission from the user.
146/// Returns true if the tool should be allowed, false to deny.
147pub type PermissionCallback =
148    Box<dyn Fn(PermissionRequest) -> tokio::sync::oneshot::Receiver<bool> + Send + Sync>;
149
150/// The main Pawan agent — handles conversation, tool calling, and self-healing.
151///
152/// This struct represents the core Pawan agent that handles:
153/// - Conversation history management
154/// - Tool calling with the LLM via pluggable backends
155/// - Streaming responses
156/// - Multiple LLM backends (NVIDIA API, Ollama, OpenAI)
157/// - Context management and token counting
158/// - Integration with Eruka for 3-tier memory injection
159pub struct PawanAgent {
160    /// Configuration
161    config: PawanConfig,
162    /// Tool registry
163    tools: ToolRegistry,
164    /// Conversation history
165    history: Vec<Message>,
166    /// Workspace root
167    workspace_root: PathBuf,
168    /// LLM backend
169    backend: Box<dyn LlmBackend>,
170
171    /// Estimated token count for current context
172    context_tokens_estimate: usize,
173
174    /// Eruka bridge for 3-tier memory injection
175    eruka: Option<crate::eruka_bridge::ErukaClient>,
176}
177
178impl PawanAgent {
179    /// Create a new PawanAgent with auto-selected backend
180    pub fn new(config: PawanConfig, workspace_root: PathBuf) -> Self {
181        let tools = ToolRegistry::with_defaults(workspace_root.clone());
182        let system_prompt = config.get_system_prompt();
183        let backend = Self::create_backend(&config, &system_prompt);
184        let eruka = if config.eruka.enabled {
185            Some(crate::eruka_bridge::ErukaClient::new(config.eruka.clone()))
186        } else {
187            None
188        };
189
190        Self {
191            config,
192            tools,
193            history: Vec::new(),
194            workspace_root,
195            backend,
196            context_tokens_estimate: 0,
197            eruka,
198        }
199    }
200
201    /// Create the appropriate backend based on config
202    fn create_backend(config: &PawanConfig, system_prompt: &str) -> Box<dyn LlmBackend> {
203        match config.provider {
204            LlmProvider::Nvidia | LlmProvider::OpenAI | LlmProvider::Mlx => {
205                let (api_url, api_key) = match config.provider {
206                    LlmProvider::Nvidia => {
207                        let url = std::env::var("NVIDIA_API_URL")
208                            .unwrap_or_else(|_| crate::DEFAULT_NVIDIA_API_URL.to_string());
209                        let key = std::env::var("NVIDIA_API_KEY").ok();
210                        if key.is_none() {
211                            tracing::warn!("NVIDIA_API_KEY not set. Add it to .env or export it.");
212                        }
213                        (url, key)
214                    },
215                    LlmProvider::OpenAI => {
216                        let url = config.base_url.clone()
217                            .or_else(|| std::env::var("OPENAI_API_URL").ok())
218                            .unwrap_or_else(|| "https://api.openai.com/v1".to_string());
219                        let key = std::env::var("OPENAI_API_KEY").ok();
220                        (url, key)
221                    },
222                    LlmProvider::Mlx => {
223                        // MLX LM server — Apple Silicon native, always local
224                        let url = config.base_url.clone()
225                            .unwrap_or_else(|| "http://localhost:8080/v1".to_string());
226                        tracing::info!(url = %url, "Using MLX LM server (Apple Silicon native)");
227                        (url, None) // mlx_lm.server requires no API key
228                    },
229                    _ => unreachable!(),
230                };
231                
232                // Build cloud fallback if configured
233                let cloud = config.cloud.as_ref().map(|c| {
234                    let (cloud_url, cloud_key) = match c.provider {
235                        LlmProvider::Nvidia => {
236                            let url = std::env::var("NVIDIA_API_URL")
237                                .unwrap_or_else(|_| crate::DEFAULT_NVIDIA_API_URL.to_string());
238                            let key = std::env::var("NVIDIA_API_KEY").ok();
239                            (url, key)
240                        },
241                        LlmProvider::OpenAI => {
242                            let url = std::env::var("OPENAI_API_URL")
243                                .unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
244                            let key = std::env::var("OPENAI_API_KEY").ok();
245                            (url, key)
246                        },
247                        LlmProvider::Mlx => {
248                            ("http://localhost:8080/v1".to_string(), None)
249                        },
250                        _ => {
251                            tracing::warn!("Cloud fallback only supports nvidia/openai/mlx providers");
252                            ("https://integrate.api.nvidia.com/v1".to_string(), None)
253                        }
254                    };
255                    backend::openai_compat::CloudFallback {
256                        api_url: cloud_url,
257                        api_key: cloud_key,
258                        model: c.model.clone(),
259                        fallback_models: c.fallback_models.clone(),
260                    }
261                });
262
263                Box::new(OpenAiCompatBackend::new(OpenAiCompatConfig {
264                    api_url,
265                    api_key,
266                    model: config.model.clone(),
267                    temperature: config.temperature,
268                    top_p: config.top_p,
269                    max_tokens: config.max_tokens,
270                    system_prompt: system_prompt.to_string(),
271                    // Enforce thinking budget: if set, disable thinking entirely
272                    // and give all tokens to action output
273                    use_thinking: config.thinking_budget == 0 && config.use_thinking_mode(),
274                    max_retries: config.max_retries,
275                    fallback_models: config.fallback_models.clone(),
276                    cloud,
277                }))
278            }
279            LlmProvider::Ollama => {
280                let url = std::env::var("OLLAMA_URL")
281                    .unwrap_or_else(|_| "http://localhost:11434".to_string());
282
283                Box::new(backend::ollama::OllamaBackend::new(
284                    url,
285                    config.model.clone(),
286                    config.temperature,
287                    system_prompt.to_string(),
288                ))
289            }
290        }
291    }
292
293    /// Create with a specific tool registry
294    pub fn with_tools(mut self, tools: ToolRegistry) -> Self {
295        self.tools = tools;
296        self
297    }
298
299    /// Get mutable access to the tool registry (for registering MCP tools)
300    pub fn tools_mut(&mut self) -> &mut ToolRegistry {
301        &mut self.tools
302    }
303
304    /// Create with a custom backend
305    pub fn with_backend(mut self, backend: Box<dyn LlmBackend>) -> Self {
306        self.backend = backend;
307        self
308    }
309
310    /// Get the current conversation history
311    pub fn history(&self) -> &[Message] {
312        &self.history
313    }
314
315    /// Save current conversation as a session, returns session ID
316    pub fn save_session(&self) -> Result<String> {
317        let mut session = session::Session::new(&self.config.model);
318        session.messages = self.history.clone();
319        session.total_tokens = self.context_tokens_estimate as u64;
320        session.save()?;
321        Ok(session.id)
322    }
323
324    /// Resume a saved session by ID
325    pub fn resume_session(&mut self, session_id: &str) -> Result<()> {
326        let session = session::Session::load(session_id)?;
327        self.history = session.messages;
328        self.context_tokens_estimate = session.total_tokens as usize;
329        Ok(())
330    }
331
332    /// Get the configuration
333    pub fn config(&self) -> &PawanConfig {
334        &self.config
335    }
336
337    /// Clear the conversation history
338    pub fn clear_history(&mut self) {
339        self.history.clear();
340    }
341    /// Prune conversation history to reduce context size.
342    /// Uses importance scoring (inspired by claude-code-rust's consolidation engine):
343    /// - Tool results with errors: high importance (learning from failures)
344    /// - User messages: medium importance (intent context)
345    /// - Successful tool results: low importance (can be re-derived)
346    ///
347    /// Keeps system prompt + last 4 messages, summarizes the rest.
348    fn prune_history(&mut self) {
349        let len = self.history.len();
350        if len <= 5 {
351            return; // Nothing to prune
352        }
353
354        let keep_end = 4;
355        let start = 1; // Skip system prompt at index 0
356        let end = len - keep_end;
357        let pruned_count = end - start;
358
359        // Score messages by importance for summary prioritization
360        let mut scored: Vec<(f32, &Message)> = self.history[start..end]
361            .iter()
362            .map(|msg| {
363                let score = Self::message_importance(msg);
364                (score, msg)
365            })
366            .collect();
367        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
368
369        // Build summary from highest-importance messages first (UTF-8 safe)
370        let mut summary = String::with_capacity(2048);
371        for (score, msg) in &scored {
372            let prefix = match msg.role {
373                Role::User => "User: ",
374                Role::Assistant => "Assistant: ",
375                Role::Tool => if *score > 0.7 { "Tool error: " } else { "Tool: " },
376                Role::System => "System: ",
377            };
378            let chunk: String = msg.content.chars().take(200).collect();
379            summary.push_str(prefix);
380            summary.push_str(&chunk);
381            summary.push('\n');
382            if summary.len() > 2000 {
383                let safe_end = summary.char_indices()
384                    .take_while(|(i, _)| *i <= 2000)
385                    .last()
386                    .map(|(i, c)| i + c.len_utf8())
387                    .unwrap_or(0);
388                summary.truncate(safe_end);
389                break;
390            }
391        }
392
393        let summary_msg = Message {
394            role: Role::System,
395            content: format!("Previous conversation summary (pruned {} messages, importance-ranked): {}", pruned_count, summary),
396            tool_calls: vec![],
397            tool_result: None,
398        };
399
400        self.history.drain(start..end);
401        self.history.insert(start, summary_msg);
402
403        tracing::info!(pruned = pruned_count, context_estimate = self.context_tokens_estimate, "Pruned messages from history (importance-ranked)");
404    }
405
406    /// Score a message's importance for pruning decisions (0.0-1.0).
407    /// Higher = more important = kept in summary.
408    fn message_importance(msg: &Message) -> f32 {
409        match msg.role {
410            Role::User => 0.6,       // User intent is moderately important
411            Role::System => 0.3,     // System messages are usually ephemeral
412            Role::Assistant => {
413                if msg.content.contains("error") || msg.content.contains("Error") { 0.8 }
414                else { 0.4 }
415            }
416            Role::Tool => {
417                if let Some(ref result) = msg.tool_result {
418                    if !result.success { 0.9 }  // Failed tools are very important (learning)
419                    else { 0.2 }                 // Successful tools can be re-derived
420                } else {
421                    0.3
422                }
423            }
424        }
425    }
426
427    /// Add a message to history
428    pub fn add_message(&mut self, message: Message) {
429        self.history.push(message);
430    }
431
432    /// Switch the LLM model at runtime. Recreates the backend with the new model.
433    pub fn switch_model(&mut self, model: &str) {
434        self.config.model = model.to_string();
435        let system_prompt = self.config.get_system_prompt();
436        self.backend = Self::create_backend(&self.config, &system_prompt);
437        tracing::info!(model = model, "Model switched at runtime");
438    }
439
440    /// Get the current model name
441    pub fn model_name(&self) -> &str {
442        &self.config.model
443    }
444
445    /// Get tool definitions for the LLM
446    pub fn get_tool_definitions(&self) -> Vec<ToolDefinition> {
447        self.tools.get_definitions()
448    }
449
450    /// Execute a single prompt with tool calling support
451    pub async fn execute(&mut self, user_prompt: &str) -> Result<AgentResponse> {
452        self.execute_with_callbacks(user_prompt, None, None, None)
453            .await
454    }
455
456    /// Execute with optional callbacks for streaming
457    pub async fn execute_with_callbacks(
458        &mut self,
459        user_prompt: &str,
460        on_token: Option<TokenCallback>,
461        on_tool: Option<ToolCallback>,
462        on_tool_start: Option<ToolStartCallback>,
463    ) -> Result<AgentResponse> {
464        self.execute_with_all_callbacks(user_prompt, on_token, on_tool, on_tool_start, None)
465            .await
466    }
467
468    /// Execute with all callbacks, including permission prompt.
469    pub async fn execute_with_all_callbacks(
470        &mut self,
471        user_prompt: &str,
472        on_token: Option<TokenCallback>,
473        on_tool: Option<ToolCallback>,
474        on_tool_start: Option<ToolStartCallback>,
475        on_permission: Option<PermissionCallback>,
476    ) -> Result<AgentResponse> {
477        // Inject Eruka core memory before first LLM call
478        if let Some(eruka) = &self.eruka {
479            if let Err(e) = eruka.inject_core_memory(&mut self.history).await {
480                tracing::warn!("Eruka memory injection failed (non-fatal): {}", e);
481            }
482        }
483
484        self.history.push(Message {
485            role: Role::User,
486            content: user_prompt.to_string(),
487            tool_calls: vec![],
488            tool_result: None,
489        });
490
491        let mut all_tool_calls = Vec::new();
492        let mut total_usage = TokenUsage::default();
493        let mut iterations = 0;
494        let max_iterations = self.config.max_tool_iterations;
495
496        loop {
497            iterations += 1;
498            if iterations > max_iterations {
499                return Err(PawanError::Agent(format!(
500                    "Max tool iterations ({}) exceeded",
501                    max_iterations
502                )));
503            }
504
505            // Budget awareness: when running low on iterations, nudge the model
506            let remaining = max_iterations.saturating_sub(iterations);
507            if remaining == 3 && iterations > 1 {
508                self.history.push(Message {
509                    role: Role::User,
510                    content: format!(
511                        "[SYSTEM] You have {} tool iterations remaining. \
512                         Stop exploring and write the most important output now. \
513                         If you have code to write, write it immediately.",
514                        remaining
515                    ),
516                    tool_calls: vec![],
517                    tool_result: None,
518                });
519            }
520            // Estimate context tokens
521            self.context_tokens_estimate = self.history.iter().map(|m| m.content.len()).sum::<usize>() / 4;
522            if self.context_tokens_estimate > self.config.max_context_tokens {
523                self.prune_history();
524            }
525
526            // Dynamic tool selection: pick the most relevant tools for this query
527            // Extract latest user message for keyword matching
528            let latest_query = self.history.iter().rev()
529                .find(|m| m.role == Role::User)
530                .map(|m| m.content.as_str())
531                .unwrap_or("");
532            let tool_defs = self.tools.select_for_query(latest_query, 12);
533            if iterations == 1 {
534                let tool_names: Vec<&str> = tool_defs.iter().map(|t| t.name.as_str()).collect();
535                tracing::info!(tools = ?tool_names, count = tool_defs.len(), "Selected tools for query");
536            }
537
538            // --- Resilient LLM call: retry on transient failures instead of crashing ---
539            let response = {
540                #[allow(unused_assignments)]
541                let mut last_err = None;
542                let max_llm_retries = 3;
543                let mut attempt = 0;
544                loop {
545                    attempt += 1;
546                    match self.backend.generate(&self.history, &tool_defs, on_token.as_ref()).await {
547                        Ok(resp) => break resp,
548                        Err(e) => {
549                            let err_str = e.to_string();
550                            let is_transient = err_str.contains("timeout")
551                                || err_str.contains("connection")
552                                || err_str.contains("429")
553                                || err_str.contains("500")
554                                || err_str.contains("502")
555                                || err_str.contains("503")
556                                || err_str.contains("504")
557                                || err_str.contains("reset")
558                                || err_str.contains("broken pipe");
559
560                            if is_transient && attempt <= max_llm_retries {
561                                let delay = std::time::Duration::from_secs(2u64.pow(attempt as u32));
562                                tracing::warn!(
563                                    attempt = attempt,
564                                    delay_secs = delay.as_secs(),
565                                    error = err_str.as_str(),
566                                    "LLM call failed (transient) — retrying"
567                                );
568                                tokio::time::sleep(delay).await;
569
570                                // If context is too large, prune before retry
571                                if err_str.contains("context") || err_str.contains("token") {
572                                    tracing::info!("Pruning history before retry (possible context overflow)");
573                                    self.prune_history();
574                                }
575                                continue;
576                            }
577
578                            // Non-transient or max retries exhausted
579                            last_err = Some(e);
580                            break {
581                                // Return a synthetic "give up" response instead of crashing
582                                tracing::error!(
583                                    attempt = attempt,
584                                    error = last_err.as_ref().map(|e| e.to_string()).unwrap_or_default().as_str(),
585                                    "LLM call failed permanently — returning error as content"
586                                );
587                                LLMResponse {
588                                    content: format!(
589                                        "LLM error after {} attempts: {}. The task could not be completed.",
590                                        attempt,
591                                        last_err.as_ref().map(|e| e.to_string()).unwrap_or_default()
592                                    ),
593                                    reasoning: None,
594                                    tool_calls: vec![],
595                                    finish_reason: "error".to_string(),
596                                    usage: None,
597                                }
598                            };
599                        }
600                    }
601                }
602            };
603
604            // Accumulate token usage with thinking/action split
605            if let Some(ref usage) = response.usage {
606                total_usage.prompt_tokens += usage.prompt_tokens;
607                total_usage.completion_tokens += usage.completion_tokens;
608                total_usage.total_tokens += usage.total_tokens;
609                total_usage.reasoning_tokens += usage.reasoning_tokens;
610                total_usage.action_tokens += usage.action_tokens;
611
612                // Log token budget split per iteration
613                if usage.reasoning_tokens > 0 {
614                    tracing::info!(
615                        iteration = iterations,
616                        think = usage.reasoning_tokens,
617                        act = usage.action_tokens,
618                        total = usage.completion_tokens,
619                        "Token budget: think:{} act:{} (total:{})",
620                        usage.reasoning_tokens, usage.action_tokens, usage.completion_tokens
621                    );
622                }
623
624                // Thinking budget enforcement
625                let thinking_budget = self.config.thinking_budget;
626                if thinking_budget > 0 && usage.reasoning_tokens > thinking_budget as u64 {
627                    tracing::warn!(
628                        budget = thinking_budget,
629                        actual = usage.reasoning_tokens,
630                        "Thinking budget exceeded ({}/{} tokens)",
631                        usage.reasoning_tokens, thinking_budget
632                    );
633                }
634            }
635
636            // --- Guardrail: strip thinking blocks from content ---
637            let clean_content = {
638                let mut s = response.content.clone();
639                loop {
640                    let lower = s.to_lowercase();
641                    let open = lower.find("<think>");
642                    let close = lower.find("</think>");
643                    match (open, close) {
644                        (Some(i), Some(j)) if j > i => {
645                            let before = s[..i].trim_end().to_string();
646                            let after = if s.len() > j + 8 { s[j + 8..].trim_start().to_string() } else { String::new() };
647                            s = if before.is_empty() { after } else if after.is_empty() { before } else { format!("{}\n{}", before, after) };
648                        }
649                        _ => break,
650                    }
651                }
652                s
653            };
654
655            if response.tool_calls.is_empty() {
656                // --- Guardrail: detect chatty no-op (content but no tools on early iterations) ---
657                // Only nudge if tools are available AND response looks like planning text (not a real answer)
658                let has_tools = !tool_defs.is_empty();
659                let lower = clean_content.to_lowercase();
660                let planning_prefix = lower.starts_with("let me")
661                    || lower.starts_with("i'll help")
662                    || lower.starts_with("i will help")
663                    || lower.starts_with("sure, i")
664                    || lower.starts_with("okay, i");
665                let looks_like_planning = clean_content.len() > 200 || (planning_prefix && clean_content.len() > 50);
666                if has_tools && looks_like_planning && iterations == 1 && iterations < max_iterations && response.finish_reason != "error" {
667                    tracing::warn!(
668                        "No tool calls at iteration {} (content: {}B) — nudging model to use tools",
669                        iterations, clean_content.len()
670                    );
671                    self.history.push(Message {
672                        role: Role::Assistant,
673                        content: clean_content.clone(),
674                        tool_calls: vec![],
675                        tool_result: None,
676                    });
677                    self.history.push(Message {
678                        role: Role::User,
679                        content: "You must use tools to complete this task. Do NOT just describe what you would do — actually call the tools. Start with bash or read_file.".to_string(),
680                        tool_calls: vec![],
681                        tool_result: None,
682                    });
683                    continue;
684                }
685
686                // --- Guardrail: detect repeated responses ---
687                if iterations > 1 {
688                    let prev_assistant = self.history.iter().rev()
689                        .find(|m| m.role == Role::Assistant && !m.content.is_empty());
690                    if let Some(prev) = prev_assistant {
691                        if prev.content.trim() == clean_content.trim() && iterations < max_iterations {
692                            tracing::warn!("Repeated response detected at iteration {} — injecting correction", iterations);
693                            self.history.push(Message {
694                                role: Role::Assistant,
695                                content: clean_content.clone(),
696                                tool_calls: vec![],
697                                tool_result: None,
698                            });
699                            self.history.push(Message {
700                                role: Role::User,
701                                content: "You gave the same response as before. Try a different approach. Use anchor_text in edit_file_lines, or use insert_after, or use bash with sed.".to_string(),
702                                tool_calls: vec![],
703                                tool_result: None,
704                            });
705                            continue;
706                        }
707                    }
708                }
709
710                self.history.push(Message {
711                    role: Role::Assistant,
712                    content: clean_content.clone(),
713                    tool_calls: vec![],
714                    tool_result: None,
715                });
716
717                return Ok(AgentResponse {
718                    content: clean_content,
719                    tool_calls: all_tool_calls,
720                    iterations,
721                    usage: total_usage,
722                });
723            }
724
725            self.history.push(Message {
726                role: Role::Assistant,
727                content: response.content.clone(),
728                tool_calls: response.tool_calls.clone(),
729                tool_result: None,
730            });
731
732            for tool_call in &response.tool_calls {
733                // Auto-activate extended tools on first use (makes them visible in next iteration)
734                self.tools.activate(&tool_call.name);
735
736                // Check permission (Deny and Prompt-in-headless both block)
737                let perm = crate::config::ToolPermission::resolve(
738                    &tool_call.name, &self.config.permissions
739                );
740                let denied = match perm {
741                    crate::config::ToolPermission::Deny => Some("Tool denied by permission policy"),
742                    crate::config::ToolPermission::Prompt => {
743                        // For bash: auto-allow read-only commands even under Prompt
744                        if tool_call.name == "bash" {
745                            if let Some(cmd) = tool_call.arguments.get("command").and_then(|v| v.as_str()) {
746                                if crate::tools::bash::is_read_only(cmd) {
747                                    tracing::debug!(command = cmd, "Auto-allowing read-only bash command under Prompt permission");
748                                    None
749                                } else if let Some(ref perm_cb) = on_permission {
750                                    // Ask TUI for approval
751                                    let args_summary = cmd.chars().take(120).collect::<String>();
752                                    let rx = perm_cb(PermissionRequest {
753                                        tool_name: tool_call.name.clone(),
754                                        args_summary,
755                                    });
756                                    match rx.await {
757                                        Ok(true) => None,
758                                        _ => Some("User denied tool execution"),
759                                    }
760                                } else {
761                                    Some("Bash command requires user approval (read-only commands auto-allowed)")
762                                }
763                            } else {
764                                Some("Tool requires user approval")
765                            }
766                        } else if let Some(ref perm_cb) = on_permission {
767                            // Ask TUI for approval
768                            let args_summary = tool_call.arguments.to_string().chars().take(120).collect::<String>();
769                            let rx = perm_cb(PermissionRequest {
770                                tool_name: tool_call.name.clone(),
771                                args_summary,
772                            });
773                            match rx.await {
774                                Ok(true) => None,
775                                _ => Some("User denied tool execution"),
776                            }
777                        } else {
778                            // Headless = deny for safety
779                            Some("Tool requires user approval (set permission to 'allow' or use TUI mode)")
780                        }
781                    }
782                    crate::config::ToolPermission::Allow => None,
783                };
784                if let Some(reason) = denied {
785                    let record = ToolCallRecord {
786                        id: tool_call.id.clone(),
787                        name: tool_call.name.clone(),
788                        arguments: tool_call.arguments.clone(),
789                        result: json!({"error": reason}),
790                        success: false,
791                        duration_ms: 0,
792                    };
793
794                    if let Some(ref callback) = on_tool {
795                        callback(&record);
796                    }
797                    all_tool_calls.push(record);
798
799                    self.history.push(Message {
800                        role: Role::Tool,
801                        content: format!("{{\"error\": \"{}\"}}", reason),
802                        tool_calls: vec![],
803                        tool_result: Some(ToolResultMessage {
804                            tool_call_id: tool_call.id.clone(),
805                            content: json!({"error": reason}),
806                            success: false,
807                        }),
808                    });
809                    continue;
810                }
811
812                // Notify tool start
813                if let Some(ref callback) = on_tool_start {
814                    callback(&tool_call.name);
815                }
816
817                // Debug: log tool call args for diagnosis
818                tracing::debug!(
819                    tool = tool_call.name.as_str(),
820                    args_len = serde_json::to_string(&tool_call.arguments).unwrap_or_default().len(),
821                    "Tool call: {}({})",
822                    tool_call.name,
823                    serde_json::to_string(&tool_call.arguments)
824                        .unwrap_or_default()
825                        .chars()
826                        .take(200)
827                        .collect::<String>()
828                );
829
830                // Validate tool arguments using thulp-core (DRY: reuse thulp's validation)
831                if let Some(tool) = self.tools.get(&tool_call.name) {
832                    let schema = tool.parameters_schema();
833                    if let Ok(params) = thulp_core::ToolDefinition::parse_mcp_input_schema(&schema) {
834                        let thulp_def = thulp_core::ToolDefinition {
835                            name: tool_call.name.clone(),
836                            description: String::new(),
837                            parameters: params,
838                        };
839                        if let Err(e) = thulp_def.validate_args(&tool_call.arguments) {
840                            tracing::warn!(
841                                tool = tool_call.name.as_str(),
842                                error = %e,
843                                "Tool argument validation failed (continuing anyway)"
844                            );
845                        }
846                    }
847                }
848
849                let start = std::time::Instant::now();
850
851                // Resilient tool execution: catch panics + errors
852                let result = {
853                    let tool_future = self.tools.execute(&tool_call.name, tool_call.arguments.clone());
854                    // Timeout individual tool calls (prevent hangs)
855                    let timeout_dur = if tool_call.name == "bash" {
856                        std::time::Duration::from_secs(self.config.bash_timeout_secs)
857                    } else {
858                        std::time::Duration::from_secs(30)
859                    };
860                    match tokio::time::timeout(timeout_dur, tool_future).await {
861                        Ok(inner) => inner,
862                        Err(_) => Err(PawanError::Tool(format!(
863                            "Tool '{}' timed out after {}s", tool_call.name, timeout_dur.as_secs()
864                        ))),
865                    }
866                };
867                let duration_ms = start.elapsed().as_millis() as u64;
868
869                let (result_value, success) = match result {
870                    Ok(v) => (v, true),
871                    Err(e) => {
872                        tracing::warn!(tool = tool_call.name.as_str(), error = %e, "Tool execution failed");
873                        (json!({"error": e.to_string(), "tool": tool_call.name, "hint": "Try a different approach or tool"}), false)
874                    }
875                };
876
877                // Truncate tool results that exceed max chars to prevent context bloat
878                let max_result_chars = self.config.max_result_chars;
879                let result_value = truncate_tool_result(result_value, max_result_chars);
880
881
882                let record = ToolCallRecord {
883                    id: tool_call.id.clone(),
884                    name: tool_call.name.clone(),
885                    arguments: tool_call.arguments.clone(),
886                    result: result_value.clone(),
887                    success,
888                    duration_ms,
889                };
890
891                if let Some(ref callback) = on_tool {
892                    callback(&record);
893                }
894
895                all_tool_calls.push(record);
896
897                self.history.push(Message {
898                    role: Role::Tool,
899                    content: serde_json::to_string(&result_value).unwrap_or_default(),
900                    tool_calls: vec![],
901                    tool_result: Some(ToolResultMessage {
902                        tool_call_id: tool_call.id.clone(),
903                        content: result_value,
904                        success,
905                    }),
906                });
907
908                // Compile-gated confidence: after writing a .rs file, auto-run cargo check
909                // and inject the result so the model can self-correct on the same iteration
910                if success && tool_call.name == "write_file" {
911                    let wrote_rs = tool_call.arguments.get("path")
912                        .and_then(|p| p.as_str())
913                        .map(|p| p.ends_with(".rs"))
914                        .unwrap_or(false);
915                    if wrote_rs {
916                        let ws = self.workspace_root.clone();
917                        let check_result = tokio::process::Command::new("cargo")
918                            .arg("check")
919                            .arg("--message-format=short")
920                            .current_dir(&ws)
921                            .output()
922                            .await;
923                        match check_result {
924                            Ok(output) if !output.status.success() => {
925                                let stderr = String::from_utf8_lossy(&output.stderr);
926                                // Only inject first 1500 chars of errors to avoid context bloat
927                                let err_msg: String = stderr.chars().take(1500).collect();
928                                tracing::info!("Compile-gate: cargo check failed after write_file, injecting errors");
929                                self.history.push(Message {
930                                    role: Role::User,
931                                    content: format!(
932                                        "[SYSTEM] cargo check failed after your write_file. Fix the errors:\n```\n{}\n```",
933                                        err_msg
934                                    ),
935                                    tool_calls: vec![],
936                                    tool_result: None,
937                                });
938                            }
939                            Ok(_) => {
940                                tracing::debug!("Compile-gate: cargo check passed");
941                            }
942                            Err(e) => {
943                                tracing::warn!("Compile-gate: cargo check failed to run: {}", e);
944                            }
945                        }
946                    }
947                }
948            }
949        }
950    }
951
952    /// Execute a healing task with real diagnostics
953    pub async fn heal(&mut self) -> Result<AgentResponse> {
954        let healer = crate::healing::Healer::new(
955            self.workspace_root.clone(),
956            self.config.healing.clone(),
957        );
958
959        let diagnostics = healer.get_diagnostics().await?;
960        let failed_tests = healer.get_failed_tests().await?;
961
962        let mut prompt = format!(
963            "I need you to heal this Rust project at: {}
964
965",
966            self.workspace_root.display()
967        );
968
969        if !diagnostics.is_empty() {
970            prompt.push_str(&format!(
971                "## Compilation Issues ({} found)
972{}
973",
974                diagnostics.len(),
975                healer.format_diagnostics_for_prompt(&diagnostics)
976            ));
977        }
978
979        if !failed_tests.is_empty() {
980            prompt.push_str(&format!(
981                "## Failed Tests ({} found)
982{}
983",
984                failed_tests.len(),
985                healer.format_tests_for_prompt(&failed_tests)
986            ));
987        }
988
989        if diagnostics.is_empty() && failed_tests.is_empty() {
990            prompt.push_str("No issues found! Run cargo check and cargo test to verify.
991");
992        }
993
994        prompt.push_str("
995Fix each issue one at a time. Verify with cargo check after each fix.");
996
997        self.execute(&prompt).await
998    }
999    /// Execute healing with retries — calls heal(), checks for remaining errors, retries if needed
1000    pub async fn heal_with_retries(&mut self, max_attempts: usize) -> Result<AgentResponse> {
1001        let mut last_response = self.heal().await?;
1002
1003        for attempt in 1..max_attempts {
1004            let fixer = crate::healing::CompilerFixer::new(self.workspace_root.clone());
1005            let remaining = fixer.check().await?;
1006            let errors: Vec<_> = remaining.iter().filter(|d| d.kind == crate::healing::DiagnosticKind::Error).collect();
1007
1008            if errors.is_empty() {
1009                tracing::info!(attempts = attempt, "Healing complete");
1010                return Ok(last_response);
1011            }
1012
1013            tracing::warn!(errors = errors.len(), attempt = attempt, "Errors remain after heal attempt, retrying");
1014            last_response = self.heal().await?;
1015        }
1016
1017        tracing::info!(attempts = max_attempts, "Healing finished (may still have errors)");
1018        Ok(last_response)
1019    }
1020    /// Execute a task with a specific prompt
1021    pub async fn task(&mut self, task_description: &str) -> Result<AgentResponse> {
1022        let prompt = format!(
1023            r#"I need you to complete the following coding task:
1024
1025{}
1026
1027The workspace is at: {}
1028
1029Please:
10301. First explore the codebase to understand the relevant code
10312. Make the necessary changes
10323. Verify the changes compile with `cargo check`
10334. Run relevant tests if applicable
1034
1035Explain your changes as you go."#,
1036            task_description,
1037            self.workspace_root.display()
1038        );
1039
1040        self.execute(&prompt).await
1041    }
1042
1043    /// Generate a commit message for current changes
1044    pub async fn generate_commit_message(&mut self) -> Result<String> {
1045        let prompt = r#"Please:
10461. Run `git status` to see what files are changed
10472. Run `git diff --cached` to see staged changes (or `git diff` for unstaged)
10483. Generate a concise, descriptive commit message following conventional commits format
1049
1050Only output the suggested commit message, nothing else."#;
1051
1052        let response = self.execute(prompt).await?;
1053        Ok(response.content)
1054    }
1055}
1056
1057/// Truncate a tool result JSON value to fit within max_chars.
1058/// Unlike naive string truncation (which breaks JSON), this truncates string
1059/// *values* within the JSON structure, preserving valid JSON output.
1060fn truncate_tool_result(value: Value, max_chars: usize) -> Value {
1061    let serialized = serde_json::to_string(&value).unwrap_or_default();
1062    if serialized.len() <= max_chars {
1063        return value;
1064    }
1065
1066    // Strategy: find the largest string values and truncate them
1067    match value {
1068        Value::Object(map) => {
1069            let mut result = serde_json::Map::new();
1070            let total = serialized.len();
1071            for (k, v) in map {
1072                if let Value::String(s) = &v {
1073                    if s.len() > 500 {
1074                        // Proportional truncation: shrink large strings
1075                        let target = s.len() * max_chars / total;
1076                        let target = target.max(200); // Keep at least 200 chars
1077                        let truncated: String = s.chars().take(target).collect();
1078                        result.insert(k, json!(format!("{}...[truncated from {} chars]", truncated, s.len())));
1079                        continue;
1080                    }
1081                }
1082                // Recursively truncate nested structures
1083                result.insert(k, truncate_tool_result(v, max_chars));
1084            }
1085            Value::Object(result)
1086        }
1087        Value::String(s) if s.len() > max_chars => {
1088            let truncated: String = s.chars().take(max_chars).collect();
1089            json!(format!("{}...[truncated from {} chars]", truncated, s.len()))
1090        }
1091        Value::Array(arr) if serialized.len() > max_chars => {
1092            // Truncate array: keep first N items that fit
1093            let mut result = Vec::new();
1094            let mut running_len = 2; // "[]"
1095            for item in arr {
1096                let item_str = serde_json::to_string(&item).unwrap_or_default();
1097                running_len += item_str.len() + 1; // +1 for comma
1098                if running_len > max_chars {
1099                    result.push(json!(format!("...[{} more items truncated]", 0)));
1100                    break;
1101                }
1102                result.push(item);
1103            }
1104            Value::Array(result)
1105        }
1106        other => other,
1107    }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113
1114    #[test]
1115    fn test_message_serialization() {
1116        let msg = Message {
1117            role: Role::User,
1118            content: "Hello".to_string(),
1119            tool_calls: vec![],
1120            tool_result: None,
1121        };
1122
1123        let json = serde_json::to_string(&msg).expect("Serialization failed");
1124        assert!(json.contains("user"));
1125        assert!(json.contains("Hello"));
1126    }
1127
1128    #[test]
1129    fn test_tool_call_request() {
1130        let tc = ToolCallRequest {
1131            id: "123".to_string(),
1132            name: "read_file".to_string(),
1133            arguments: json!({"path": "test.txt"}),
1134        };
1135
1136        let json = serde_json::to_string(&tc).expect("Serialization failed");
1137        assert!(json.contains("read_file"));
1138        assert!(json.contains("test.txt"));
1139    }
1140
1141    /// Helper to build an agent with N messages for prune testing.
1142    /// History starts empty; we add a system prompt + (n-1) user/assistant messages = n total.
1143    fn agent_with_messages(n: usize) -> PawanAgent {
1144        let config = PawanConfig::default();
1145        let mut agent = PawanAgent::new(config, PathBuf::from("."));
1146        // Add system prompt as message 0
1147        agent.add_message(Message {
1148            role: Role::System,
1149            content: "System prompt".to_string(),
1150            tool_calls: vec![],
1151            tool_result: None,
1152        });
1153        for i in 1..n {
1154            agent.add_message(Message {
1155                role: if i % 2 == 1 { Role::User } else { Role::Assistant },
1156                content: format!("Message {}", i),
1157                tool_calls: vec![],
1158                tool_result: None,
1159            });
1160        }
1161        assert_eq!(agent.history().len(), n);
1162        agent
1163    }
1164
1165    #[test]
1166    fn test_prune_history_no_op_when_small() {
1167        let mut agent = agent_with_messages(5);
1168        agent.prune_history();
1169        assert_eq!(agent.history().len(), 5, "Should not prune <= 5 messages");
1170    }
1171
1172    #[test]
1173    fn test_prune_history_reduces_messages() {
1174        let mut agent = agent_with_messages(12);
1175        assert_eq!(agent.history().len(), 12);
1176        agent.prune_history();
1177        // Should keep: system prompt (1) + summary (1) + last 4 = 6
1178        assert_eq!(agent.history().len(), 6);
1179    }
1180
1181    #[test]
1182    fn test_prune_history_preserves_system_prompt() {
1183        let mut agent = agent_with_messages(10);
1184        let original_system = agent.history()[0].content.clone();
1185        agent.prune_history();
1186        assert_eq!(agent.history()[0].content, original_system, "System prompt must survive pruning");
1187    }
1188
1189    #[test]
1190    fn test_prune_history_preserves_last_messages() {
1191        let mut agent = agent_with_messages(10);
1192        // Last 4 messages are at indices 6..10 with content "Message 6".."Message 9"
1193        let last4: Vec<String> = agent.history()[6..10].iter().map(|m| m.content.clone()).collect();
1194        agent.prune_history();
1195        // After pruning: [system, summary, msg6, msg7, msg8, msg9]
1196        let after_last4: Vec<String> = agent.history()[2..6].iter().map(|m| m.content.clone()).collect();
1197        assert_eq!(last4, after_last4, "Last 4 messages must be preserved after pruning");
1198    }
1199
1200    #[test]
1201    fn test_prune_history_inserts_summary() {
1202        let mut agent = agent_with_messages(10);
1203        agent.prune_history();
1204        assert_eq!(agent.history()[1].role, Role::System);
1205        assert!(agent.history()[1].content.contains("summary"), "Summary message should contain 'summary'");
1206    }
1207
1208    #[test]
1209    fn test_prune_history_utf8_safe() {
1210        let config = PawanConfig::default();
1211        let mut agent = PawanAgent::new(config, PathBuf::from("."));
1212        // Add system prompt + 10 messages with multi-byte UTF-8 characters
1213        agent.add_message(Message {
1214            role: Role::System, content: "sys".into(), tool_calls: vec![], tool_result: None,
1215        });
1216        for _ in 0..10 {
1217            agent.add_message(Message {
1218                role: Role::User,
1219                content: "こんにちは世界 🌍 ".repeat(50),
1220                tool_calls: vec![],
1221                tool_result: None,
1222            });
1223        }
1224        // This should not panic on char boundary issues
1225        agent.prune_history();
1226        assert!(agent.history().len() < 11, "Should have pruned");
1227        // Verify summary is valid UTF-8
1228        let summary = &agent.history()[1].content;
1229        assert!(summary.is_char_boundary(0));
1230    }
1231
1232    #[test]
1233    fn test_prune_history_exactly_6_messages() {
1234        // 6 messages = 1 more than the no-op threshold of 5
1235        let mut agent = agent_with_messages(6);
1236        agent.prune_history();
1237        // Prunes 1 middle message, replaced by summary: system(1) + summary(1) + last 4 = 6
1238        assert_eq!(agent.history().len(), 6);
1239    }
1240
1241    #[test]
1242    fn test_message_role_roundtrip() {
1243        for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
1244            let json = serde_json::to_string(&role).unwrap();
1245            let back: Role = serde_json::from_str(&json).unwrap();
1246            assert_eq!(role, back);
1247        }
1248    }
1249
1250    #[test]
1251    fn test_agent_response_construction() {
1252        let resp = AgentResponse {
1253            content: String::new(),
1254            tool_calls: vec![],
1255            iterations: 3,
1256            usage: TokenUsage::default(),
1257        };
1258        assert!(resp.content.is_empty());
1259        assert!(resp.tool_calls.is_empty());
1260        assert_eq!(resp.iterations, 3);
1261    }
1262
1263    // --- truncate_tool_result tests ---
1264
1265    #[test]
1266    fn test_truncate_small_result_unchanged() {
1267        let val = json!({"success": true, "output": "hello"});
1268        let result = truncate_tool_result(val.clone(), 8000);
1269        assert_eq!(result, val);
1270    }
1271
1272    #[test]
1273    fn test_truncate_large_string_value() {
1274        let big = "x".repeat(10000);
1275        let val = json!({"stdout": big, "success": true});
1276        let result = truncate_tool_result(val, 2000);
1277        let stdout = result["stdout"].as_str().unwrap();
1278        assert!(stdout.len() < 10000, "Should be truncated");
1279        assert!(stdout.contains("truncated"), "Should indicate truncation");
1280    }
1281
1282    #[test]
1283    fn test_truncate_preserves_valid_json() {
1284        let big = "x".repeat(20000);
1285        let val = json!({"data": big, "meta": "keep"});
1286        let result = truncate_tool_result(val, 5000);
1287        // Result should be valid JSON (no broken strings)
1288        let serialized = serde_json::to_string(&result).unwrap();
1289        let _reparsed: Value = serde_json::from_str(&serialized).unwrap();
1290        // meta should be preserved (it's small)
1291        assert_eq!(result["meta"], "keep");
1292    }
1293
1294    #[test]
1295    fn test_truncate_bare_string() {
1296        let big = json!("x".repeat(10000));
1297        let result = truncate_tool_result(big, 500);
1298        let s = result.as_str().unwrap();
1299        assert!(s.len() <= 600); // 500 + truncation notice
1300        assert!(s.contains("truncated"));
1301    }
1302
1303    #[test]
1304    fn test_truncate_array() {
1305        let items: Vec<Value> = (0..1000).map(|i| json!(format!("item_{}", i))).collect();
1306        let val = Value::Array(items);
1307        let result = truncate_tool_result(val, 500);
1308        let arr = result.as_array().unwrap();
1309        assert!(arr.len() < 1000, "Array should be truncated");
1310    }
1311
1312    // --- message_importance tests ---
1313
1314    #[test]
1315    fn test_importance_failed_tool_highest() {
1316        let msg = Message {
1317            role: Role::Tool,
1318            content: "error".into(),
1319            tool_calls: vec![],
1320            tool_result: Some(ToolResultMessage {
1321                tool_call_id: "1".into(),
1322                content: json!({"error": "failed"}),
1323                success: false,
1324            }),
1325        };
1326        assert!(PawanAgent::message_importance(&msg) > 0.8, "Failed tools should be high importance");
1327    }
1328
1329    #[test]
1330    fn test_importance_successful_tool_lowest() {
1331        let msg = Message {
1332            role: Role::Tool,
1333            content: "ok".into(),
1334            tool_calls: vec![],
1335            tool_result: Some(ToolResultMessage {
1336                tool_call_id: "1".into(),
1337                content: json!({"success": true}),
1338                success: true,
1339            }),
1340        };
1341        assert!(PawanAgent::message_importance(&msg) < 0.3, "Successful tools should be low importance");
1342    }
1343
1344    #[test]
1345    fn test_importance_user_medium() {
1346        let msg = Message { role: Role::User, content: "hello".into(), tool_calls: vec![], tool_result: None };
1347        let score = PawanAgent::message_importance(&msg);
1348        assert!(score > 0.4 && score < 0.8, "User messages should be medium: {}", score);
1349    }
1350
1351    #[test]
1352    fn test_importance_error_assistant_high() {
1353        let msg = Message { role: Role::Assistant, content: "Error: something failed".into(), tool_calls: vec![], tool_result: None };
1354        assert!(PawanAgent::message_importance(&msg) > 0.7, "Error assistant messages should be high importance");
1355    }
1356
1357    #[test]
1358    fn test_importance_ordering() {
1359        let failed_tool = Message { role: Role::Tool, content: "err".into(), tool_calls: vec![], tool_result: Some(ToolResultMessage { tool_call_id: "1".into(), content: json!({}), success: false }) };
1360        let user = Message { role: Role::User, content: "hi".into(), tool_calls: vec![], tool_result: None };
1361        let ok_tool = Message { role: Role::Tool, content: "ok".into(), tool_calls: vec![], tool_result: Some(ToolResultMessage { tool_call_id: "2".into(), content: json!({}), success: true }) };
1362
1363        let f = PawanAgent::message_importance(&failed_tool);
1364        let u = PawanAgent::message_importance(&user);
1365        let s = PawanAgent::message_importance(&ok_tool);
1366        assert!(f > u && u > s, "Ordering should be: failed({}) > user({}) > success({})", f, u, s);
1367    }
1368}