P0 deep-dive: core, providers, agent, memory, TUI, setup wizard
DEEP DIVE — Agent Loop (gyandoot-agent): - Real conversation loop with interactive (run()) and non-interactive (run_conversation()) modes - Retry logic with exponential backoff and error classification (RateLimit, Auth, Timeout, ServerError, ContextOverflow) - Context window management — token estimation, 75% threshold compression - 7 built-in tools: Read, Write, Edit, Bash, Grep, Find, Ls - BashTool with destructive-command security blocks - ContextCompressor — prune tool results, summarize middle messages DEEP DIVE — Memory (gyandoot-memory): - Real MemPalace with ~/.gyandoot/palace/wings/<wing>/ directory tree - Daily transcript files (YYYY-MM-DD_transcript.md) with append-only format - L0/L1 identity persistence (< 157 tokens for L0) - Session digest to drawer.md - File persistence support for transcripts and identity store DEEP DIVE — Core (gyandoot-core): - GyandootConfig with TOML deserialization for all sections - ConfigLoader with env > profile > file > default chain - Rich types: Message, ToolCall, ToolResult, SessionId - Comprehensive error types with thiserror derive DEEP DIVE — Providers (gyandoot-providers): - Provider trait with chat/chat_stream/models - OpenAI provider with streaming SSE and function calling - Anthropic provider with Messages API + tool use - Ollama provider with merge_system/no_tools auto-detect - SseParser, StreamEvent, TokenStream for SSE parsing - ProviderFactory for creation from config NEW — TUI crate (gyandoot-tui): - Banner: Kapil-approved robot head ASCII art with ANSI colors - Setup wizard: 7 providers, interactive config, connection test - Terminal helpers: spinner, confirm, text input, selection menu - Ratatui TUI: keyboard input, commands, status display All 12 crates + CLI binary compile with 0 errors.
This commit is contained in:
@@ -1,47 +1,134 @@
|
||||
//! The core agent inference loop.
|
||||
//! Production-grade agent inference loop.
|
||||
//!
|
||||
//! The `AgentLoop` manages the cycle: receive message → build context →
|
||||
//! call LLM → process tool calls → repeat or respond.
|
||||
//! `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_core::{
|
||||
AgentConfig, AgentStatus, ConversationContext, Message, MessageRole, ToolCall, ToolResult,
|
||||
AgentConfig, AgentError, AgentResult, AgentStatus, ConversationContext, Message,
|
||||
MessageRole,
|
||||
};
|
||||
use gyandoot_memory::palace::MemPalace;
|
||||
use gyandoot_providers::Provider;
|
||||
use gyandoot_providers::{Provider, ProviderResponse};
|
||||
use gyandoot_security::sanitizer::Sanitizer;
|
||||
use gyandoot_skills::SkillsManager;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, error, info, span, Level};
|
||||
use tracing::{debug, error, info, span, warn, Level};
|
||||
|
||||
use crate::context::ContextCompressor;
|
||||
use crate::tools::ToolExecutor;
|
||||
|
||||
/// The main agent execution loop.
|
||||
// ─── 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: 30_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main agent execution loop, usable in both interactive and
|
||||
/// non-interactive contexts.
|
||||
pub struct AgentLoop {
|
||||
config: AgentConfig,
|
||||
provider: Box<dyn Provider + Send + Sync>,
|
||||
tool_executor: ToolExecutor,
|
||||
context_compressor: ContextCompressor,
|
||||
memory: Arc<Mutex<MemPalace>>,
|
||||
skills_manager: SkillsManager,
|
||||
_skills_manager: SkillsManager,
|
||||
sanitizer: Sanitizer,
|
||||
status: Arc<Mutex<AgentStatus>>,
|
||||
retry_cfg: RetryConfig,
|
||||
}
|
||||
|
||||
impl AgentLoop {
|
||||
/// Create a new agent loop with the given configuration.
|
||||
///
|
||||
/// This initializes the LLM provider, tool executor, memory system, and
|
||||
/// skills manager based on the provided config.
|
||||
/// Create a new agent loop from a fully-resolved configuration.
|
||||
pub fn new(config: AgentConfig) -> Self {
|
||||
let provider = create_provider(&config);
|
||||
let tool_executor = ToolExecutor::new();
|
||||
let context_compressor = ContextCompressor::new();
|
||||
let memory = Arc::new(Mutex::new(MemPalace::new()));
|
||||
let tool_executor = 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();
|
||||
let sanitizer = Sanitizer::new();
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -49,96 +136,81 @@ impl AgentLoop {
|
||||
tool_executor,
|
||||
context_compressor,
|
||||
memory,
|
||||
skills_manager,
|
||||
sanitizer,
|
||||
_skills_manager: skills_manager,
|
||||
sanitizer: Sanitizer::new(),
|
||||
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
||||
retry_cfg: RetryConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent loop interactively, reading from stdin and writing to stdout.
|
||||
///
|
||||
/// This is the primary entry point for CLI usage. The loop reads user input,
|
||||
/// calls the LLM, executes any requested tools, and continues until the
|
||||
/// user exits or an error occurs.
|
||||
/// 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)");
|
||||
|
||||
info!("Agent loop started");
|
||||
|
||||
let mut messages: Vec<Message> = vec![
|
||||
Message::new(MessageRole::System, &self.config.system_prompt),
|
||||
];
|
||||
let mut messages: Vec<Message> =
|
||||
vec![Message::new(MessageRole::System, &self.config.system_prompt)];
|
||||
|
||||
loop {
|
||||
// Read user input
|
||||
// 1. Read user input
|
||||
let user_input = self.read_input().await?;
|
||||
|
||||
// Check for exit conditions
|
||||
// 2. Check exit conditions
|
||||
if self.should_exit(&user_input) {
|
||||
info!("User requested exit");
|
||||
break;
|
||||
}
|
||||
|
||||
// Sanitize input
|
||||
// 3. Sanitize
|
||||
let clean_input = self.sanitizer.sanitize(&user_input);
|
||||
|
||||
// Add user message
|
||||
messages.push(Message::new(MessageRole::User, clean_input));
|
||||
|
||||
// Build conversation context
|
||||
let context = ConversationContext {
|
||||
messages: messages.clone(),
|
||||
system_prompt: self.config.system_prompt.clone(),
|
||||
tools: self.tool_executor.definitions(),
|
||||
max_tokens: self.config.max_tokens,
|
||||
};
|
||||
// 4. Run the core turn (LLM + tool execution)
|
||||
let new_messages = self.execute_turn(messages).await?;
|
||||
messages = new_messages;
|
||||
|
||||
// Call the LLM
|
||||
*self.status.lock().await = AgentStatus::Thinking;
|
||||
let response = self.provider.chat(context).await?;
|
||||
|
||||
// Process assistant response
|
||||
messages.push(Message::new(MessageRole::Assistant, &response.content));
|
||||
|
||||
// Handle tool calls
|
||||
if let Some(tool_calls) = response.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
debug!("Executing tool: {}", tool_call.name);
|
||||
let result = self.tool_executor.execute(&tool_call).await?;
|
||||
messages.push(
|
||||
Message::new(MessageRole::Tool, &result.output)
|
||||
.with_tool_result(result.output.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// Call LLM again with tool results
|
||||
let context = ConversationContext {
|
||||
messages: messages.clone(),
|
||||
system_prompt: self.config.system_prompt.clone(),
|
||||
tools: self.tool_executor.definitions(),
|
||||
max_tokens: self.config.max_tokens,
|
||||
};
|
||||
|
||||
let follow_up = self.provider.chat(context).await?;
|
||||
messages.push(Message::new(MessageRole::Assistant, &follow_up.content));
|
||||
self.display_output(&follow_up.content);
|
||||
} else {
|
||||
self.display_output(&response.content);
|
||||
}
|
||||
|
||||
// Store in memory
|
||||
// 5. Store conversation turn in memory
|
||||
{
|
||||
let mut memory = self.memory.lock().await;
|
||||
memory.store_conversation_turn(&messages).await?;
|
||||
let memory = self.memory.lock().await;
|
||||
memory
|
||||
.transcript_append("user", &user_input)
|
||||
.await
|
||||
.unwrap_or_else(|e| warn!("Failed to store user message: {e}"));
|
||||
// Store last assistant response
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compress context if too large
|
||||
if self.should_compress(&messages) {
|
||||
debug!("Context size threshold reached, compressing");
|
||||
messages = self.context_compressor.compress(messages).await;
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -146,11 +218,263 @@ impl AgentLoop {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read input from stdin (CLI mode).
|
||||
// ── Non-interactive (gateway / sub-agent mode) ─────────────────────
|
||||
|
||||
/// Run a single conversation turn given an incoming user message.
|
||||
/// Returns the assistant response(s) and any tool outputs.
|
||||
///
|
||||
/// This is the entry point used by the gateway (Discord, Telegram, etc.)
|
||||
/// and by sub-agents that receive delegated tasks.
|
||||
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> =
|
||||
vec![Message::new(MessageRole::System, &self.config.system_prompt)];
|
||||
|
||||
// Load recent context from memory for continuity
|
||||
{
|
||||
let memory = self.memory.lock().await;
|
||||
if let Ok(transcript) = memory.get_transcript(4096, 10).await {
|
||||
if !transcript.is_empty() {
|
||||
messages.push(Message::new(
|
||||
MessageRole::System,
|
||||
format!("[Recent conversation context]\n{transcript}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(Message::new(MessageRole::User, message));
|
||||
|
||||
// Execute turn with retry
|
||||
let (final_messages, output) = self
|
||||
.execute_turn_with_output(messages)
|
||||
.await?;
|
||||
|
||||
// Store in memory
|
||||
{
|
||||
let memory = self.memory.lock().await;
|
||||
memory
|
||||
.transcript_append("user", message)
|
||||
.await
|
||||
.unwrap_or_else(|e| warn!("Failed to store message: {e}"));
|
||||
if let Some(last) = final_messages.last() {
|
||||
if last.role == MessageRole::Assistant {
|
||||
memory
|
||||
.transcript_append("assistant", &last.content)
|
||||
.await
|
||||
.unwrap_or_else(|e| warn!("Failed to store response: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Get the current agent status.
|
||||
pub async fn get_status(&self) -> AgentStatus {
|
||||
self.status.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Provide access to tool definitions (e.g. for OpenAI function-calling).
|
||||
pub fn tool_definitions(&self) -> Vec<gyandoot_core::ToolDefinition> {
|
||||
self.tool_executor.definitions()
|
||||
}
|
||||
|
||||
/// Provide a reference to the tool executor.
|
||||
pub fn tool_executor(&self) -> &ToolExecutor {
|
||||
&self.tool_executor
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────
|
||||
|
||||
/// Execute a single turn: LLM call → tool calls → LLM follow-up.
|
||||
/// Returns the updated message list.
|
||||
async fn execute_turn(&self, mut messages: Vec<Message>) -> AgentResult<Vec<Message>> {
|
||||
let _limit = self.config.max_tokens;
|
||||
|
||||
// Build context and call LLM (with retry)
|
||||
let context = self
|
||||
.context_compressor
|
||||
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions());
|
||||
|
||||
*self.status.lock().await = AgentStatus::Thinking;
|
||||
let response = self.call_with_retry(context).await?;
|
||||
|
||||
messages.push(Message::new(MessageRole::Assistant, &response.content)
|
||||
.with_tool_calls(response.tool_calls.clone().unwrap_or_default()));
|
||||
|
||||
// If there are tool calls, execute them and loop back
|
||||
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.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),
|
||||
);
|
||||
}
|
||||
|
||||
// Follow-up call with tool results
|
||||
let follow_context = self
|
||||
.context_compressor
|
||||
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions());
|
||||
|
||||
*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 the structured output (for
|
||||
/// non-interactive callers).
|
||||
async fn execute_turn_with_output(
|
||||
&self,
|
||||
mut messages: Vec<Message>,
|
||||
) -> AgentResult<(Vec<Message>, ConversationOutput)> {
|
||||
let _limit = self.config.max_tokens;
|
||||
|
||||
let context = self
|
||||
.context_compressor
|
||||
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions());
|
||||
|
||||
*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.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.definitions());
|
||||
|
||||
*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 and exponential back-off.
|
||||
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);
|
||||
|
||||
// Non-retryable errors bubble up immediately.
|
||||
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={kind:?}), \
|
||||
retrying in {delay}ms: {error_text}",
|
||||
attempt,
|
||||
self.retry_cfg.max_attempts,
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||
last_error = Some(e);
|
||||
} else {
|
||||
error!(
|
||||
"Provider call failed after {} attempts: {error_text}",
|
||||
self.retry_cfg.max_attempts
|
||||
);
|
||||
return Err(AgentError::Provider(format!(
|
||||
"Failed after {} retries: {error_text}",
|
||||
self.retry_cfg.max_attempts
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(AgentError::Provider(format!(
|
||||
"Exhausted retries: {:?}",
|
||||
last_error.map(|e| e.to_string())
|
||||
)))
|
||||
}
|
||||
|
||||
/// Read a single line from stdin with a prompt.
|
||||
async fn read_input(&self) -> anyhow::Result<String> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut stdout = tokio::io::stdout();
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut stdout = tokio::io::stdout();
|
||||
stdout.write_all(b"\n> ").await?;
|
||||
stdout.flush().await?;
|
||||
|
||||
@@ -158,76 +482,79 @@ impl AgentLoop {
|
||||
let reader = tokio::io::BufReader::new(stdin);
|
||||
let mut lines = reader.lines();
|
||||
let mut input = String::new();
|
||||
|
||||
if let Some(line) = lines.next_line().await? {
|
||||
input = line;
|
||||
}
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
/// Check if the input indicates the user wants to exit.
|
||||
fn should_exit(&self, input: &str) -> bool {
|
||||
matches!(input.trim().to_lowercase().as_str(), "exit" | "quit" | "/exit" | "/quit")
|
||||
matches!(
|
||||
input.trim().to_lowercase().as_str(),
|
||||
"exit" | "quit" | "/exit" | "/quit"
|
||||
)
|
||||
}
|
||||
|
||||
/// Display output to the user.
|
||||
fn display_output(&self, output: &str) {
|
||||
println!("\n{}", output);
|
||||
}
|
||||
|
||||
/// Determine if context compression is needed based on message count and token estimate.
|
||||
fn should_compress(&self, messages: &[Message]) -> bool {
|
||||
// Rough estimate: ~4 chars per token, compress when over 75% of max
|
||||
let estimated_tokens: usize = messages.iter().map(|m| m.content.len() / 4).sum();
|
||||
estimated_tokens > (self.config.max_tokens * 3 / 4)
|
||||
}
|
||||
|
||||
/// Get the current agent status.
|
||||
pub async fn get_status(&self) -> AgentStatus {
|
||||
self.status.lock().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the appropriate LLM provider based on configuration.
|
||||
// ─── ConversationOutput (for non-interactive callers) ───────────────────
|
||||
|
||||
/// The result of a single `run_conversation` call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConversationOutput {
|
||||
/// The final assistant response text.
|
||||
pub text: String,
|
||||
/// Any tool outputs generated during the turn.
|
||||
pub tool_results: Vec<String>,
|
||||
}
|
||||
|
||||
// ─── Backoff calculation ───────────────────────────────────────────────
|
||||
|
||||
/// Calculate exponential back-off with jitter.
|
||||
fn calculate_backoff(attempt: u32, base_ms: u64, max_ms: u64) -> u64 {
|
||||
let delay = base_ms.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
|
||||
let clamped = delay.min(max_ms);
|
||||
// Add small jitter (±10%)
|
||||
let jitter = rand::random::<f64>() * 0.2 - 0.1; // -10% to +10%
|
||||
(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 openai_config = config
|
||||
.provider
|
||||
.openai
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let cfg = config.provider.openai.clone().unwrap_or_default();
|
||||
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
|
||||
openai_config.api_key.clone(),
|
||||
openai_config.model.clone(),
|
||||
openai_config.base_url.clone(),
|
||||
cfg.api_key,
|
||||
cfg.model,
|
||||
cfg.base_url,
|
||||
))
|
||||
}
|
||||
"anthropic" => {
|
||||
let anthropic_config = config
|
||||
.provider
|
||||
.anthropic
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let cfg = config.provider.anthropic.clone().unwrap_or_default();
|
||||
Box::new(gyandoot_providers::anthropic::AnthropicProvider::new(
|
||||
anthropic_config.api_key.clone(),
|
||||
anthropic_config.model.clone(),
|
||||
cfg.api_key,
|
||||
cfg.model,
|
||||
))
|
||||
}
|
||||
"ollama" => {
|
||||
let ollama_config = config
|
||||
.provider
|
||||
.ollama
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let cfg = config.provider.ollama.clone().unwrap_or_default();
|
||||
Box::new(gyandoot_providers::ollama::OllamaProvider::new(
|
||||
ollama_config.base_url.clone(),
|
||||
ollama_config.model.clone(),
|
||||
cfg.base_url,
|
||||
cfg.model,
|
||||
))
|
||||
}
|
||||
other => {
|
||||
tracing::warn!("Unknown provider '{}', falling back to OpenAI", other);
|
||||
tracing::warn!("Unknown provider '{other}', falling back to OpenAI");
|
||||
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
|
||||
String::new(),
|
||||
config.model.clone(),
|
||||
@@ -237,10 +564,48 @@ fn create_provider(config: &AgentConfig) -> Box<dyn Provider + Send + Sync> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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 waiting"), AgentErrorKind::Timeout);
|
||||
assert_eq!(AgentErrorKind::classify("500 server error"), AgentErrorKind::ServerError);
|
||||
assert_eq!(AgentErrorKind::classify("context length exceeded"), AgentErrorKind::ContextOverflow);
|
||||
assert_eq!(AgentErrorKind::classify("unknown error"), AgentErrorKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retryable_classification() {
|
||||
assert!(AgentErrorKind::RateLimit.is_retryable());
|
||||
assert!(AgentErrorKind::Timeout.is_retryable());
|
||||
assert!(!AgentErrorKind::Auth.is_retryable());
|
||||
assert!(!AgentErrorKind::ContextOverflow.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_is_monotonic() {
|
||||
let d1 = calculate_backoff(1, 1000, 30_000);
|
||||
let d2 = calculate_backoff(2, 1000, 30_000);
|
||||
let d3 = calculate_backoff(3, 1000, 30_000);
|
||||
// With jitter these might not be strictly monotonic, but should be roughly so.
|
||||
assert!(d1 >= 900 && d1 <= 1100); // ~1000ms ±10%
|
||||
assert!(d2 >= 1800 && d2 <= 2200); // ~2000ms ±10%
|
||||
assert!(d3 >= 3600 && d3 <= 4400); // ~4000ms ±10%
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_tokens() {
|
||||
assert_eq!(estimate_tokens("abcd"), 1);
|
||||
assert_eq!(estimate_tokens(""), 1); // at least 1
|
||||
assert!(estimate_tokens("hello world this is a test") > 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_exit() {
|
||||
let config = AgentConfig::default();
|
||||
@@ -249,15 +614,24 @@ mod tests {
|
||||
"gpt-4o".to_string(),
|
||||
None,
|
||||
);
|
||||
let _agent = AgentLoop {
|
||||
let tool_executor = ToolExecutor::with_defaults();
|
||||
let context_compressor = ContextCompressor::new(4096);
|
||||
|
||||
let loop_ = AgentLoop {
|
||||
config,
|
||||
provider: Box::new(provider),
|
||||
tool_executor: ToolExecutor::new(),
|
||||
context_compressor: ContextCompressor::new(),
|
||||
memory: Arc::new(Mutex::new(MemPalace::new())),
|
||||
skills_manager: SkillsManager::new(&gyandoot_core::SkillsConfig::default()),
|
||||
tool_executor,
|
||||
context_compressor,
|
||||
memory: Arc::new(Mutex::new(MemPalace::new("default"))),
|
||||
_skills_manager: SkillsManager::new(),
|
||||
sanitizer: Sanitizer::new(),
|
||||
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
||||
retry_cfg: RetryConfig::default(),
|
||||
};
|
||||
|
||||
assert!(loop_.should_exit("exit"));
|
||||
assert!(loop_.should_exit("quit"));
|
||||
assert!(loop_.should_exit(" /exit "));
|
||||
assert!(!loop_.should_exit("hello"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user