Files
gyandoot-v2/crates/gyandoot-agent/src/agent_loop.rs
Tara Menon 697c2c63b2 feat(browser): cross-platform node_path config for npx resolution
Adds [browser].node_path config option so camoufox-browser can use
an explicit npx path (essential for nvm/fnm/asdf users).

Resolution order (cross-platform):
1. config.browser.node_path — explicit user override
2. 'npx.cmd' on Windows, 'npx' on Linux/macOS — PATH lookup

Changes:
- BrowserSection config: added node_path: Option<String>
- AgentConfig: now carries browser section (was dropped in From<GyandootConfig>)
- browser_manager::init_browser(node_path): accepts optional path
- All call sites updated (run(), init_session(), subagents, tests)
- Error messages now hint at browser.node_path config
2026-06-12 16:08:37 +05:30

970 lines
39 KiB
Rust

//! 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<String>,
}
// ─── 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<dyn Provider + Send + Sync>,
tool_executor: Arc<Mutex<ToolExecutor>>,
context_compressor: ContextCompressor,
memory: Arc<Mutex<MemPalace>>,
_skills_manager: SkillsManager,
sanitizer: Sanitizer,
status: Arc<Mutex<AgentStatus>>,
retry_cfg: RetryConfig,
#[allow(dead_code)]
browser_client: Option<BrowserClient>,
/// Accumulated messages for stateful session mode (TUI / multi-turn).
session_messages: Mutex<Option<Vec<Message>>>,
}
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<Message> =
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<ConversationOutput> {
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<String> {
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<Message>) -> AgentResult<Vec<Message>> {
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<Message>,
) -> AgentResult<(Vec<Message>, 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<String> = 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<ProviderResponse> {
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<bool> {
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<bool>) {
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<String> {
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::<f64>() * 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<dyn Provider + Send + Sync> {
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());
}
}