//! Production-grade agent inference loop. //! //! `AgentLoop` manages the full cycle: receive message → build context → //! call LLM → execute tools → respond → compress context. Supports //! interactive (stdin/stdout) and non-interactive (gateway/sub-agent) modes, //! retry logic with exponential backoff, error classification, and streaming //! response delivery. use std::sync::Arc; use std::time::Duration; use gyandoot_browser::BrowserClient; use gyandoot_core::{ AgentConfig, AgentError, AgentResult, AgentStatus, ConversationContext, Message, MessageRole, }; use gyandoot_memory::palace::MemPalace; use gyandoot_providers::{Provider, ProviderResponse}; use gyandoot_security::sanitizer::Sanitizer; use gyandoot_skills::SkillsManager; use tokio::sync::Mutex; use tracing::{debug, error, info, span, warn, Level}; use crate::context::ContextCompressor; use crate::tools::ToolExecutor; // ─── CLI color constants ────────────────────────────────────────────── #[allow(dead_code)] mod cli_color { pub const RESET: &str = "\x1b[0m"; pub const BOLD: &str = "\x1b[1m"; pub const DIM: &str = "\x1b[2m"; pub const CYAN: &str = "\x1b[36m"; pub const BRIGHT_CYAN: &str = "\x1b[96m"; pub const BRIGHT_MAGENTA: &str = "\x1b[95m"; pub const GREEN: &str = "\x1b[32m"; pub const YELLOW: &str = "\x1b[33m"; } // ─── Error classification ─────────────────────────────────────────────── /// Categories of errors the agent loop can encounter during LLM calls. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AgentErrorKind { /// Provider rejected authentication credentials. Auth, /// API rate-limit exceeded. RateLimit, /// Request timed out (network or provider-side). Timeout, /// Server returned a 5xx or similar transient error. ServerError, /// Context window exceeded the model's maximum capacity. ContextOverflow, /// All other / unclassified errors. Other, } impl AgentErrorKind { /// Classify a provider- or agent-error string into a structured kind. pub fn classify(error_text: &str) -> Self { let lower = error_text.to_lowercase(); if lower.contains("rate limit") || lower.contains("rate_limit") || lower.contains("429") { Self::RateLimit } else if lower.contains("auth") || lower.contains("unauthorized") || lower.contains("401") || lower.contains("api key") { Self::Auth } else if lower.contains("timeout") || lower.contains("timed out") || lower.contains("connection reset") { Self::Timeout } else if lower.contains("5") || lower.contains("server error") || lower.contains("internal server error") { Self::ServerError } else if lower.contains("context length") || lower.contains("maximum context") || lower.contains("token limit") || lower.contains("context overflow") { Self::ContextOverflow } else { Self::Other } } /// True for errors that are safe to retry. pub fn is_retryable(&self) -> bool { matches!(self, Self::RateLimit | Self::Timeout | Self::ServerError | Self::Other) } } // ─── AgentLoop ────────────────────────────────────────────────────────── /// Configuration for the agent-loop retry behaviour. #[derive(Debug, Clone)] pub struct RetryConfig { /// Maximum number of retry attempts (including the initial call). pub max_attempts: u32, /// Base delay in milliseconds for exponential back-off. pub base_delay_ms: u64, /// Maximum delay cap in milliseconds. pub max_delay_ms: u64, } impl Default for RetryConfig { fn default() -> Self { Self { max_attempts: 3, base_delay_ms: 1000, max_delay_ms: 10000, } } } /// Structured output from a conversation turn (non-interactive callers). #[derive(Debug, Clone)] pub struct ConversationOutput { pub text: String, pub tool_results: Vec, } // ─── AgentLoop ────────────────────────────────────────────────────────── /// The core agent inference loop. /// /// Reads messages, builds conversation context, calls the LLM provider, /// executes tools, and manages context window limits. pub struct AgentLoop { config: AgentConfig, provider: Box, tool_executor: Arc>, context_compressor: ContextCompressor, memory: Arc>, _skills_manager: SkillsManager, sanitizer: Sanitizer, status: Arc>, retry_cfg: RetryConfig, #[allow(dead_code)] browser_client: Option, /// Accumulated messages for stateful session mode (TUI / multi-turn). session_messages: Mutex>>, } impl AgentLoop { /// Create a new agent loop from a fully-resolved configuration. pub fn new(config: AgentConfig) -> Self { let provider = create_provider(&config); let tool_executor = Arc::new(Mutex::new(ToolExecutor::with_defaults())); let context_compressor = ContextCompressor::new(config.max_tokens); let memory = Arc::new(Mutex::new(MemPalace::new("default"))); let skills_manager = SkillsManager::new(); Self { config, provider, tool_executor, context_compressor, memory, _skills_manager: skills_manager, sanitizer: Sanitizer::new(gyandoot_security::sanitizer::SanitizerConfig::default()), status: Arc::new(Mutex::new(AgentStatus::Idle)), retry_cfg: RetryConfig::default(), browser_client: None, session_messages: Mutex::new(None), } } /// Create an agent loop with a custom retry policy. pub fn with_retry(mut self, cfg: RetryConfig) -> Self { self.retry_cfg = cfg; self } // ── Interactive loop (stdin → stdout) ────────────────────────────── /// Run the agent loop interactively, reading from stdin and writing to /// stdout until the user types `exit` / `quit` or an unrecoverable error /// occurs. pub async fn run(&self) -> anyhow::Result<()> { let span = span!(Level::INFO, "agent_loop", agent = %self.config.name); let _guard = span.enter(); info!("Agent loop started (interactive)"); // ── Auto-init Camoufox and register browser tools ────────────── { let mut tool_executor = self.tool_executor.lock().await; match crate::browser_manager::init_browser(self.config.browser.node_path.as_deref()).await { crate::browser_manager::BrowserInitResult::Ready(client) => { info!("Camoufox browser ready — registering web_search and web_browse tools"); crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone()); // Store client for potential later use // (browser_client field is set via unsafe trick — skip for now) } crate::browser_manager::BrowserInitResult::Unavailable(msg) => { warn!("Camoufox not available: {}", msg); } } } // ── Identity & memory injection on startup ───────────────────── let (agent_name, agent_role, user_name, user_role) = { let memory = self.memory.lock().await; let (_l0, _) = memory.identity_get().unwrap_or_default(); // Priority: config identity > config.agent.name > defaults let a_name = if !self.config.identity.agent_name.is_empty() { self.config.identity.agent_name.clone() } else { self.config.name.clone() }; let a_role = if !self.config.identity.agent_role.is_empty() { self.config.identity.agent_role.clone() } else { "AI Assistant built by Gyannetra".to_string() }; let u_name = if !self.config.identity.user_name.is_empty() { self.config.identity.user_name.clone() } else { "the User".to_string() }; let u_role = if !self.config.identity.user_role.is_empty() { self.config.identity.user_role.clone() } else { "User".to_string() }; (a_name, a_role, u_name, u_role) }; // Build system prompt with identity + tool capabilities + dialogue memory let mut system_prompt = self.config.system_prompt.clone(); if !agent_name.is_empty() { system_prompt = format!( "{}\n\n## Your Identity\n- Name: {}\n- Role: {}\n- You are speaking to: {} ({})\n\n## Your Capabilities\nYou have access to these tools:\n- **web_search** — Search the web using anti-detection browser. Use for any online query.\n- **web_browse** — Visit and interact with any website. Use for scraping pages.\n- **bash** — Execute shell commands (curl, python, git)\n- **read/write/edit** — File operations\n\n**For web tasks:** Always use `web_search` or `web_browse` first. If they fail, fall back to `bash` with Python requests.\n\nDo NOT say you can't access websites unless both `web_search`/`web_browse` AND `bash` with Python have failed.\n\n## Memory\nYou have a verbatim dialogue log. Use past conversations as context.\",", system_prompt, agent_name, if agent_role.is_empty() { "AI Assistant" } else { &agent_role }, if user_name.is_empty() { "the User" } else { &user_name }, if user_role.is_empty() { "User" } else { &user_role }, ); } // Inject recent dialogue from verbatim memory { let memory = self.memory.lock().await; if let Ok(dialogue) = memory.dialogue_read(2000).await { if !dialogue.is_empty() { system_prompt = format!( "{}\n\n## Past Conversations (Verbatim Memory)\n{}\n---\nContinue the conversation naturally.", system_prompt, dialogue ); } } } let mut messages: Vec = vec![Message::new(MessageRole::System, &system_prompt)]; loop { // 1. Read user input let user_input = self.read_input().await?; // 2. Handle built-in slash commands if user_input.starts_with('/') { let should_quit = self.handle_command(&user_input).await; if should_quit { break; } continue; } // 3. Check exit conditions if self.should_exit(&user_input) { info!("User requested exit"); break; } // 3. Sanitize let clean_input = self.sanitizer.sanitize_input(&user_input, &gyandoot_security::sanitizer::SanitizerConfig::default()); messages.push(Message::new(MessageRole::User, clean_input.clone())); // 4. Run the core turn (LLM + tool execution) let new_messages = self.execute_turn(messages).await?; messages = new_messages; // 5. Store conversation turn in memory (transcript + verbatim dialogue) { let memory = self.memory.lock().await; memory .transcript_append("user", &user_input) .await .unwrap_or_else(|e| warn!("Failed to store user message: {e}")); memory .dialogue_append("user", &user_input) .await .unwrap_or_else(|e| warn!("Failed to store user dialogue: {e}")); if let Some(last) = messages.last() { if last.role == MessageRole::Assistant { memory .transcript_append("assistant", &last.content) .await .unwrap_or_else(|e| warn!("Failed to store assistant message: {e}")); memory .dialogue_append("assistant", &last.content) .await .unwrap_or_else(|e| warn!("Failed to store assistant dialogue: {e}")); } } } // 6. Compress context if token budget is tight let total_tokens: usize = messages.iter().map(|m| estimate_tokens(&m.content)).sum(); let limit = self.config.max_tokens; if total_tokens > (limit * 3 / 4) { debug!( "Context at ~{:.0}% of limit ({total_tokens}/{limit}), compressing", (total_tokens as f64 / limit as f64) * 100.0 ); messages = self.context_compressor.compress(messages, limit).await; } // 7. Update status *self.status.lock().await = AgentStatus::Idle; } info!("Agent loop finished"); Ok(()) } // ── Non-interactive (gateway / sub-agent mode) ───────────────────── /// Run a single conversation turn given an incoming user message. pub async fn run_conversation( &self, message: &str, ) -> AgentResult { let span = span!(Level::INFO, "conversation", agent = %self.config.name); let _guard = span.enter(); debug!("run_conversation: processing message"); let mut messages = vec![ Message::new(MessageRole::System, &self.config.system_prompt), Message::new(MessageRole::User, message), ]; let (result_messages, output) = self.execute_turn_with_output(messages).await?; messages = result_messages; // Store in memory { let memory = self.memory.lock().await; memory .transcript_append("user", message) .await .unwrap_or_else(|e| warn!("Failed to store user message: {e}")); if let Some(last) = messages.last() { if last.role == MessageRole::Assistant { memory .transcript_append("assistant", &last.content) .await .unwrap_or_else(|e| warn!("Failed to store assistant message: {e}")); } } } *self.status.lock().await = AgentStatus::Idle; Ok(output) } /// Get the current agent status. pub async fn get_status(&self) -> AgentStatus { self.status.lock().await.clone() } /// Read-only access to agent configuration. pub fn config(&self) -> &AgentConfig { &self.config } // ── Stateful session methods (for TUI / multi-turn callers) ───────── /// Initialize a conversation session: browser init, identity injection, /// memory recall, and system prompt construction. Must be called before /// the first `session_turn()`. pub async fn init_session(&self) -> AgentResult<()> { // Browser init { let mut tool_executor = self.tool_executor.lock().await; match crate::browser_manager::init_browser(self.config.browser.node_path.as_deref()).await { crate::browser_manager::BrowserInitResult::Ready(client) => { info!("Camoufox browser ready — registering web_search and web_browse tools"); crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone()); } crate::browser_manager::BrowserInitResult::Unavailable(msg) => { warn!("Camoufox not available: {}", msg); } } } // Identity & memory injection let (agent_name, agent_role, user_name, user_role) = { let memory = self.memory.lock().await; let (_l0, _) = memory.identity_get().unwrap_or_default(); let a_name = if !self.config.identity.agent_name.is_empty() { self.config.identity.agent_name.clone() } else { self.config.name.clone() }; let a_role = if !self.config.identity.agent_role.is_empty() { self.config.identity.agent_role.clone() } else { "AI Assistant built by Gyannetra".to_string() }; let u_name = if !self.config.identity.user_name.is_empty() { self.config.identity.user_name.clone() } else { "the User".to_string() }; let u_role = if !self.config.identity.user_role.is_empty() { self.config.identity.user_role.clone() } else { "User".to_string() }; (a_name, a_role, u_name, u_role) }; // Build system prompt with identity + capabilities let mut system_prompt = self.config.system_prompt.clone(); if !agent_name.is_empty() { system_prompt = format!( "{}\n\n## Your Identity\n- Name: {}\n- Role: {}\n- You are speaking to: {} ({})\n\n## Your Capabilities\nYou have access to these tools:\n- **web_search** — Search the web using anti-detection browser. Use for any online query.\n- **web_browse** — Visit and interact with any website. Use for scraping pages.\n- **bash** — Execute shell commands (curl, python, git)\n- **read/write/edit** — File operations\n\n**For web tasks:** Always use `web_search` or `web_browse` first. If they fail, fall back to `bash` with Python requests.\n\nDo NOT say you can't access websites unless both `web_search`/`web_browse` AND `bash` with Python have failed.\n\n## Memory\nYou have a verbatim dialogue log. Use past conversations as context.\n", system_prompt, agent_name, if agent_role.is_empty() { "AI Assistant" } else { &agent_role }, if user_name.is_empty() { "the User" } else { &user_name }, if user_role.is_empty() { "User" } else { &user_role }, ); } // Inject recent dialogue from memory { let memory = self.memory.lock().await; if let Ok(dialogue) = memory.dialogue_read(2000).await { if !dialogue.is_empty() { system_prompt = format!( "{}\n\n## Past Conversations (Verbatim Memory)\n{}\n---\nContinue the conversation naturally.", system_prompt, dialogue ); } } } let messages = vec![Message::new(MessageRole::System, &system_prompt)]; *self.session_messages.lock().await = Some(messages); info!("Session initialised with identity and memory"); Ok(()) } /// Run a stateful conversation turn. Accumulates messages across turns /// (unlike `run_conversation` which is stateless). Returns the assistant /// response text. pub async fn session_turn(&self, user_message: &str) -> AgentResult { let span = span!(Level::INFO, "session_turn", agent = %self.config.name); let _guard = span.enter(); // Ensure session is initialised let mut session_guard = self.session_messages.lock().await; if session_guard.is_none() { drop(session_guard); self.init_session().await?; session_guard = self.session_messages.lock().await; } let mut messages = session_guard.take().unwrap_or_default(); // Sanitize and add user message let clean_input = self.sanitizer.sanitize_input(user_message, &gyandoot_security::sanitizer::SanitizerConfig::default()); messages.push(Message::new(MessageRole::User, clean_input.clone())); // Run the core turn let new_messages = self.execute_turn(messages).await?; messages = new_messages; // Extract assistant's last text response let assistant_text = messages .iter() .rev() .find(|m| m.role == MessageRole::Assistant) .map(|m| m.content.clone()) .unwrap_or_default(); // Store in memory { let memory = self.memory.lock().await; memory .transcript_append("user", user_message) .await .unwrap_or_else(|e| warn!("Failed to store user message: {e}")); memory .dialogue_append("user", user_message) .await .unwrap_or_else(|e| warn!("Failed to store user dialogue: {e}")); if !assistant_text.is_empty() { memory .transcript_append("assistant", &assistant_text) .await .unwrap_or_else(|e| warn!("Failed to store assistant message: {e}")); memory .dialogue_append("assistant", &assistant_text) .await .unwrap_or_else(|e| warn!("Failed to store assistant dialogue: {e}")); } } // Compress context if token budget is tight let total_tokens: usize = messages.iter().map(|m| estimate_tokens(&m.content)).sum(); let limit = self.config.max_tokens; if total_tokens > (limit * 3 / 4) { debug!( "Context at ~{:.0}% of limit ({total_tokens}/{limit}), compressing", (total_tokens as f64 / limit as f64) * 100.0 ); messages = self.context_compressor.compress(messages, limit).await; } // Save messages back for next turn *session_guard = Some(messages); *self.status.lock().await = AgentStatus::Idle; Ok(assistant_text) } // ── Internal methods ─────────────────────────────────────────────── /// Execute a single turn: LLM call → tool calls → LLM follow-up. async fn execute_turn(&self, mut messages: Vec) -> AgentResult> { let context = self .context_compressor .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; // Show thinking animation while waiting for LLM let spinner_handle = self.start_thinking_indicator(); let response = self.call_with_retry(context).await; self.stop_thinking_indicator(spinner_handle).await; let response = response?; messages.push(Message::new(MessageRole::Assistant, &response.content) .with_tool_calls(response.tool_calls.clone().unwrap_or_default())); if let Some(calls) = response.tool_calls { if !calls.is_empty() { *self.status.lock().await = AgentStatus::WaitingForTool; for call in calls { debug!("Executing tool: {}", call.name); let result = self.tool_executor.lock().await.execute(&call).await.map_err(|e| { AgentError::ToolExecution(format!("{}: {}", call.name, e)) })?; let output = if result.success { result.output } else { format!("ERROR: {}", result.error.unwrap_or_default()) }; messages.push( Message::new(MessageRole::Tool, &output) .with_tool_result(output), ); } let follow_context = self .context_compressor .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; let follow_up = self.call_with_retry(follow_context).await?; messages.push( Message::new(MessageRole::Assistant, &follow_up.content) .with_tool_calls(follow_up.tool_calls.unwrap_or_default()), ); self.display_output(&follow_up.content); } else { self.display_output(&response.content); } } else { self.display_output(&response.content); } Ok(messages) } /// Execute a single turn and return structured output. async fn execute_turn_with_output( &self, mut messages: Vec, ) -> AgentResult<(Vec, ConversationOutput)> { let context = self .context_compressor .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; let response = self.call_with_retry(context).await?; let mut assistant_text = response.content.clone(); let mut tool_results: Vec = Vec::new(); messages.push(Message::new(MessageRole::Assistant, &response.content) .with_tool_calls(response.tool_calls.clone().unwrap_or_default())); if let Some(calls) = response.tool_calls { if !calls.is_empty() { *self.status.lock().await = AgentStatus::WaitingForTool; for call in calls { let result = self.tool_executor.lock().await.execute(&call).await.map_err(|e| { AgentError::ToolExecution(format!("{}: {}", call.name, e)) })?; let output = if result.success { result.output.clone() } else { format!("ERROR: {}", result.error.unwrap_or_default()) }; tool_results.push(output.clone()); messages.push( Message::new(MessageRole::Tool, &output) .with_tool_result(output), ); } let follow_context = self .context_compressor .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens); *self.status.lock().await = AgentStatus::Thinking; let follow_up = self.call_with_retry(follow_context).await?; assistant_text = follow_up.content.clone(); messages.push( Message::new(MessageRole::Assistant, &follow_up.content) .with_tool_calls(follow_up.tool_calls.unwrap_or_default()), ); } } Ok((messages, ConversationOutput { text: assistant_text, tool_results })) } /// Call the LLM provider with automatic retry. async fn call_with_retry( &self, context: ConversationContext, ) -> AgentResult { let mut last_error = None; for attempt in 1..=self.retry_cfg.max_attempts { match self.provider.chat(context.clone()).await { Ok(response) => return Ok(response), Err(e) => { let error_text = e.to_string(); let kind = AgentErrorKind::classify(&error_text); if !kind.is_retryable() { return Err(AgentError::Provider(format!( "Non-retryable provider error: {error_text}" ))); } if attempt < self.retry_cfg.max_attempts { let delay = calculate_backoff( attempt, self.retry_cfg.base_delay_ms, self.retry_cfg.max_delay_ms, ); warn!( "Provider call failed (attempt {}/{}, kind={:?}), retrying in {}ms: {}", attempt, self.retry_cfg.max_attempts, kind, delay, error_text ); tokio::time::sleep(Duration::from_millis(delay)).await; last_error = Some(e); } else { error!( "Provider call failed after {} attempts: {}", self.retry_cfg.max_attempts, error_text ); return Err(AgentError::Provider(format!( "Failed after {} retries: {error_text}", self.retry_cfg.max_attempts - 1 ))); } } } } Err(AgentError::Provider(format!( "Unexpected retry exit: {:?}", last_error ))) } /// Display output to stdout with GyanDoot prefix. fn display_output(&self, output: &str) { println!( "\n{bold}{green}G{reset}> {output}\n", bold = cli_color::BOLD, green = cli_color::GREEN, reset = cli_color::RESET, output = output, ); } /// Start the thinking animation (spinner on stderr). /// Returns a handle to stop the animation. fn start_thinking_indicator(&self) -> tokio::sync::watch::Sender { use std::io::Write; let (tx, mut rx) = tokio::sync::watch::channel(true); let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; tokio::spawn(async move { let mut frame_idx = 0; let start = std::time::Instant::now(); while *rx.borrow_and_update() { let spinner = frames[frame_idx % frames.len()]; frame_idx += 1; let elapsed = start.elapsed().as_secs_f64(); eprint!( "\r{magenta}{spinner}{reset} {bold}answering{reset} ({elapsed:.1}s) ", magenta = cli_color::BRIGHT_MAGENTA, spinner = spinner, reset = cli_color::RESET, bold = cli_color::BOLD, elapsed = elapsed, ); std::io::stderr().flush().ok(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; } // Clear the spinner line eprint!("\r\x1b[K"); std::io::stderr().flush().ok(); }); tx } /// Stop the thinking animation. async fn stop_thinking_indicator(&self, handle: tokio::sync::watch::Sender) { let _ = handle.send(false); // Small delay for the spinner task to finish and clear the line tokio::time::sleep(std::time::Duration::from_millis(50)).await; } /// Read a line of input from stdin. async fn read_input(&self) -> anyhow::Result { use std::io::Write; use tokio::io::AsyncBufReadExt; // Show input prompt with leading blank line for spacing if atty::is(atty::Stream::Stdin) { // Small extra delay ensures spinner \r is fully cleared tokio::time::sleep(std::time::Duration::from_millis(30)).await; print!( "\n{bold}{cyan}»{reset} ", bold = cli_color::BOLD, cyan = cli_color::BRIGHT_CYAN, reset = cli_color::RESET, ); std::io::stdout().flush().ok(); } let mut line = String::new(); let mut reader = tokio::io::BufReader::new(tokio::io::stdin()); reader.read_line(&mut line).await?; Ok(line.trim().to_string()) } /// Check if the user wants to exit. fn should_exit(&self, input: &str) -> bool { matches!(input.to_lowercase().as_str(), "exit" | "quit" | "bye" | "/quit") } /// Handle a slash command. Returns true if the user wants to exit. async fn handle_command(&self, cmd: &str) -> bool { match cmd.to_lowercase().as_str() { "/quit" | "/exit" => { info!("User requested exit via slash command"); true } "/help" => { println!( "\n{bold}{cyan}╔════════════════════════════════════╗\ \n║ GyanDoot — Help ║\ \n╚════════════════════════════════════╝{reset}\n\ \n{bold}Commands:{reset}\ \n {green}»{reset} Type anything to chat with the AI\ \n {green}/quit{reset} Exit GyanDoot\ \n {green}/help{reset} Show this help message\n\ \n{bold}Tips:{reset}\ \n • Use {cyan}web_search{reset} for online queries\ \n • Use {cyan}web_browse{reset} to visit websites\ \n • Type {yellow}exit{reset}, {yellow}quit{reset}, or {yellow}bye{reset} to exit\n", bold = cli_color::BOLD, cyan = cli_color::BRIGHT_CYAN, green = cli_color::GREEN, yellow = cli_color::YELLOW, reset = cli_color::RESET, ); false } _ => { println!( "\n{yellow}Unknown command:{reset} {cmd}\n\ Type {green}/help{reset} for available commands.\n", yellow = cli_color::YELLOW, reset = cli_color::RESET, cmd = cmd, green = cli_color::GREEN, ); false } } } } /// Calculate exponential backoff with jitter. fn calculate_backoff(attempt: u32, base_ms: u64, max_ms: u64) -> u64 { let exponential = base_ms * 2u64.pow(attempt.saturating_sub(1)); let clamped = exponential.min(max_ms); let jitter = rand::random::() * 0.2 - 0.1; (clamped as f64 * (1.0 + jitter)) as u64 } /// Rough token estimator (~4 chars per token). fn estimate_tokens(text: &str) -> usize { (text.len() / 4).max(1) } // ─── Provider factory ────────────────────────────────────────────────── fn create_provider(config: &AgentConfig) -> Box { match config.provider.default.as_str() { "openai" => { let cfg = config.provider.openai.clone().unwrap_or_default(); Box::new(gyandoot_providers::openai::OpenAIProvider::new( cfg.api_key, cfg.model, cfg.base_url, )) } "anthropic" => { let cfg = config.provider.anthropic.clone().unwrap_or_default(); Box::new(gyandoot_providers::anthropic::AnthropicProvider::new( cfg.api_key, cfg.model, )) } "ollama" => { let cfg = config.provider.ollama.clone().unwrap_or_default(); Box::new(gyandoot_providers::ollama::OllamaProvider::new( cfg.base_url, cfg.model, cfg.api_key.clone(), )) } other => { tracing::warn!("Unknown provider '{other}', falling back to OpenAI"); Box::new(gyandoot_providers::openai::OpenAIProvider::new( String::new(), config.model.clone(), None, )) } } } // ─── Tests ────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use gyandoot_core::config::IdentitySection; use gyandoot_core::{ config::AgentConfig, config::BrowserSection, config::KanbanRootConfig, config::MemoryRootConfig, config::PlatformRootConfig, config::ProviderRootConfig, config::SecurityRootConfig, config::SkillsRootConfig, Message, MessageRole, Role, ToolCall, ToolDefinition, }; fn test_config() -> AgentConfig { AgentConfig { name: "test-agent".to_string(), model: "gpt-4o".to_string(), max_tokens: 4096, temperature: 0.7, system_prompt: "You are a test agent.".to_string(), provider: ProviderRootConfig::default(), platform: PlatformRootConfig::default(), memory: MemoryRootConfig::default(), security: SecurityRootConfig::default(), kanban: KanbanRootConfig::default(), skills: SkillsRootConfig::default(), identity: IdentitySection::default(), browser: BrowserSection::default(), } } #[test] fn test_retry_config_defaults() { let cfg = RetryConfig::default(); assert_eq!(cfg.max_attempts, 3); assert!(cfg.base_delay_ms > 0); assert!(cfg.max_delay_ms > 0); } #[test] fn test_estimate_tokens() { assert_eq!(estimate_tokens("abcd"), 1); assert_eq!(estimate_tokens(""), 1); let long = "a".repeat(400); assert_eq!(estimate_tokens(&long), 100); } #[test] fn test_calculate_backoff() { let delay = calculate_backoff(1, 1000, 10000); assert!(delay >= 900 && delay <= 1100); let delay3 = calculate_backoff(3, 1000, 10000); assert!(delay3 <= 10000); } #[test] fn test_error_classification() { assert_eq!(AgentErrorKind::classify("rate limit exceeded"), AgentErrorKind::RateLimit); assert_eq!(AgentErrorKind::classify("401 Unauthorized"), AgentErrorKind::Auth); assert_eq!(AgentErrorKind::classify("timeout after 30s"), AgentErrorKind::Timeout); assert_eq!(AgentErrorKind::classify("500 Internal Server Error"), AgentErrorKind::ServerError); assert_eq!(AgentErrorKind::classify("unknown error"), AgentErrorKind::Other); } #[test] fn test_retryable() { assert!(AgentErrorKind::RateLimit.is_retryable()); assert!(AgentErrorKind::Timeout.is_retryable()); assert!(AgentErrorKind::ServerError.is_retryable()); assert!(!AgentErrorKind::Auth.is_retryable()); assert!(!AgentErrorKind::ContextOverflow.is_retryable()); } }