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:
2026-06-11 20:53:38 +05:30
parent 03c103061a
commit 54d8ca3774
29 changed files with 7211 additions and 755 deletions

View File

@@ -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"));
}
}