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:
@@ -16,4 +16,7 @@ uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -1,10 +1,248 @@
|
||||
//! Configuration types for GyanDoot agents and platform.
|
||||
//!
|
||||
//! Provides [`GyandootConfig`] as the canonical TOML-deserializable
|
||||
//! configuration, [`ConfigLoader`] for layered resolution
|
||||
//! (env vars > profile > default file > hardcoded defaults), and
|
||||
//! backward-compatible aliases used by the rest of the workspace.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ConfigError;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level agent configuration.
|
||||
// ─── GyandootConfig (canonical TOML schema) ───────────────────────────────
|
||||
|
||||
/// Top-level TOML-deserializable configuration for GyanDoot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GyandootConfig {
|
||||
#[serde(default)]
|
||||
pub agent: AgentSection,
|
||||
#[serde(default)]
|
||||
pub providers: ProvidersSection,
|
||||
#[serde(default)]
|
||||
pub platform: PlatformSection,
|
||||
#[serde(default)]
|
||||
pub memory: MemorySection,
|
||||
#[serde(default)]
|
||||
pub gateway: GatewaySection,
|
||||
#[serde(default)]
|
||||
pub browser: BrowserSection,
|
||||
}
|
||||
|
||||
impl Default for GyandootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: AgentSection::default(),
|
||||
providers: ProvidersSection::default(),
|
||||
platform: PlatformSection::default(),
|
||||
memory: MemorySection::default(),
|
||||
gateway: GatewaySection::default(),
|
||||
browser: BrowserSection::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[agent]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentSection {
|
||||
/// Agent display name.
|
||||
#[serde(default = "default_agent_name")]
|
||||
pub name: String,
|
||||
/// Default model identifier.
|
||||
#[serde(default = "default_agent_model")]
|
||||
pub model: String,
|
||||
/// Maximum tokens per response.
|
||||
#[serde(default = "default_max_tokens")]
|
||||
pub max_tokens: usize,
|
||||
/// Sampling temperature (0.0 – 2.0).
|
||||
#[serde(default = "default_temperature")]
|
||||
pub temperature: f64,
|
||||
/// System prompt prepended to every conversation.
|
||||
#[serde(default)]
|
||||
pub system_prompt: String,
|
||||
}
|
||||
|
||||
fn default_agent_name() -> String {
|
||||
"gyandoot".to_string()
|
||||
}
|
||||
fn default_agent_model() -> String {
|
||||
"gpt-4o".to_string()
|
||||
}
|
||||
fn default_max_tokens() -> usize {
|
||||
4096
|
||||
}
|
||||
fn default_temperature() -> f64 {
|
||||
0.7
|
||||
}
|
||||
|
||||
impl Default for AgentSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: default_agent_name(),
|
||||
model: default_agent_model(),
|
||||
max_tokens: default_max_tokens(),
|
||||
temperature: default_temperature(),
|
||||
system_prompt: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[providers]` section — maps provider name → per-provider config.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvidersSection {
|
||||
/// Default provider key (e.g. "openai").
|
||||
#[serde(default = "default_provider_default")]
|
||||
pub default: String,
|
||||
/// Provider-specific settings keyed by name.
|
||||
#[serde(default, flatten)]
|
||||
pub providers: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
fn default_provider_default() -> String {
|
||||
"openai".to_string()
|
||||
}
|
||||
|
||||
impl Default for ProvidersSection {
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert(
|
||||
"openai".to_string(),
|
||||
toml::Value::Table({
|
||||
let mut m = toml::map::Map::new();
|
||||
m.insert("model".to_string(), toml::Value::String("gpt-4o".to_string()));
|
||||
m
|
||||
}),
|
||||
);
|
||||
Self {
|
||||
default: default_provider_default(),
|
||||
providers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[platform]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlatformSection {
|
||||
/// Default platform (e.g. "cli", "telegram", "discord").
|
||||
#[serde(default = "default_platform_default")]
|
||||
pub default: String,
|
||||
/// Platform-specific config (flattened for extensibility).
|
||||
#[serde(default, flatten)]
|
||||
pub platforms: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
fn default_platform_default() -> String {
|
||||
"cli".to_string()
|
||||
}
|
||||
|
||||
impl Default for PlatformSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default: default_platform_default(),
|
||||
platforms: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[memory]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemorySection {
|
||||
/// Memory backend (e.g. "local", "redis").
|
||||
#[serde(default = "default_memory_backend")]
|
||||
pub backend: String,
|
||||
/// Storage path for local backend.
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
/// Maximum entries before compression / eviction.
|
||||
#[serde(default = "default_memory_max_entries")]
|
||||
pub max_entries: usize,
|
||||
/// Whether auto-compression is enabled.
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_compress: bool,
|
||||
}
|
||||
|
||||
fn default_memory_backend() -> String {
|
||||
"local".to_string()
|
||||
}
|
||||
fn default_memory_max_entries() -> usize {
|
||||
10000
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for MemorySection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: default_memory_backend(),
|
||||
path: None,
|
||||
max_entries: default_memory_max_entries(),
|
||||
auto_compress: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[gateway]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GatewaySection {
|
||||
/// Whether the gateway server is enabled.
|
||||
#[serde(default = "default_false")]
|
||||
pub enabled: bool,
|
||||
/// TCP port for the gateway.
|
||||
#[serde(default = "default_gateway_port")]
|
||||
pub port: u16,
|
||||
/// Bind address / host.
|
||||
#[serde(default = "default_gateway_host")]
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_gateway_port() -> u16 {
|
||||
8765
|
||||
}
|
||||
fn default_gateway_host() -> String {
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
impl Default for GatewaySection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: default_gateway_port(),
|
||||
host: default_gateway_host(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[browser]` section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserSection {
|
||||
/// Whether the browser automation layer is enabled.
|
||||
#[serde(default = "default_false")]
|
||||
pub enabled: bool,
|
||||
/// Camofox URL for browser automation.
|
||||
#[serde(default)]
|
||||
pub camofox_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BrowserSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
camofox_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legacy / backward-compatible config types ──────────────────────────
|
||||
// These are re-exported under their original names so downstream crates
|
||||
// (gyandoot-agent, gyandoot-providers, etc.) continue to compile unchanged.
|
||||
|
||||
/// Top-level agent configuration (backward-compatible).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
pub name: String,
|
||||
@@ -12,42 +250,80 @@ pub struct AgentConfig {
|
||||
pub max_tokens: usize,
|
||||
pub temperature: f64,
|
||||
pub system_prompt: String,
|
||||
pub provider: ProviderConfig,
|
||||
pub platform: PlatformConfig,
|
||||
pub memory: MemoryConfig,
|
||||
pub security: SecurityConfig,
|
||||
pub kanban: KanbanConfig,
|
||||
pub skills: SkillsConfig,
|
||||
pub provider: ProviderRootConfig,
|
||||
pub platform: PlatformRootConfig,
|
||||
pub memory: MemoryRootConfig,
|
||||
pub security: SecurityRootConfig,
|
||||
pub kanban: KanbanRootConfig,
|
||||
pub skills: SkillsRootConfig,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "gyandoot".to_string(),
|
||||
model: "gpt-4o".to_string(),
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
name: default_agent_name(),
|
||||
model: default_agent_model(),
|
||||
max_tokens: default_max_tokens(),
|
||||
temperature: default_temperature(),
|
||||
system_prompt: String::new(),
|
||||
provider: ProviderConfig::default(),
|
||||
platform: PlatformConfig::default(),
|
||||
memory: MemoryConfig::default(),
|
||||
security: SecurityConfig::default(),
|
||||
kanban: KanbanConfig::default(),
|
||||
skills: SkillsConfig::default(),
|
||||
provider: ProviderRootConfig::default(),
|
||||
platform: PlatformRootConfig::default(),
|
||||
memory: MemoryRootConfig::default(),
|
||||
security: SecurityRootConfig::default(),
|
||||
kanban: KanbanRootConfig::default(),
|
||||
skills: SkillsRootConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
impl From<GyandootConfig> for AgentConfig {
|
||||
fn from(cfg: GyandootConfig) -> Self {
|
||||
// Extract provider-specific configs from the providers map
|
||||
let openai = cfg.providers.providers.get("openai").map(|v| OpenAIConfig::from_toml_value(v));
|
||||
let anthropic = cfg.providers.providers.get("anthropic").map(|v| AnthropicConfig::from_toml_value(v));
|
||||
let ollama = cfg.providers.providers.get("ollama").map(|v| OllamaConfig::from_toml_value(v));
|
||||
|
||||
Self {
|
||||
name: cfg.agent.name,
|
||||
model: cfg.agent.model,
|
||||
max_tokens: cfg.agent.max_tokens,
|
||||
temperature: cfg.agent.temperature,
|
||||
system_prompt: cfg.agent.system_prompt,
|
||||
provider: ProviderRootConfig {
|
||||
default: cfg.providers.default,
|
||||
openai,
|
||||
anthropic,
|
||||
ollama,
|
||||
},
|
||||
platform: PlatformRootConfig {
|
||||
default: cfg.platform.default,
|
||||
telegram: None,
|
||||
discord: None,
|
||||
webhook: None,
|
||||
},
|
||||
memory: MemoryRootConfig {
|
||||
backend: cfg.memory.backend,
|
||||
storage_path: cfg.memory.path,
|
||||
max_entries: cfg.memory.max_entries,
|
||||
auto_compress: cfg.memory.auto_compress,
|
||||
},
|
||||
security: SecurityRootConfig::default(),
|
||||
kanban: KanbanRootConfig::default(),
|
||||
skills: SkillsRootConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider configurations (backward-compatible).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub struct ProviderRootConfig {
|
||||
pub default: String,
|
||||
pub openai: Option<OpenAIConfig>,
|
||||
pub anthropic: Option<AnthropicConfig>,
|
||||
pub ollama: Option<OllamaConfig>,
|
||||
}
|
||||
|
||||
impl Default for ProviderConfig {
|
||||
impl Default for ProviderRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default: "openai".to_string(),
|
||||
@@ -58,6 +334,7 @@ impl Default for ProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-provider config struct used by gyandoot-agent's `create_provider`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIConfig {
|
||||
pub api_key: String,
|
||||
@@ -66,6 +343,21 @@ pub struct OpenAIConfig {
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
impl OpenAIConfig {
|
||||
fn from_toml_value(v: &toml::Value) -> Self {
|
||||
let table = match v.as_table() {
|
||||
Some(t) => t,
|
||||
None => return Self::default(),
|
||||
};
|
||||
Self {
|
||||
api_key: table.get("api_key").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
model: table.get("model").and_then(|v| v.as_str()).unwrap_or_else(|| "gpt-4o").to_string(),
|
||||
base_url: table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(3) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpenAIConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -84,6 +376,20 @@ pub struct AnthropicConfig {
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
impl AnthropicConfig {
|
||||
fn from_toml_value(v: &toml::Value) -> Self {
|
||||
let table = match v.as_table() {
|
||||
Some(t) => t,
|
||||
None => return Self::default(),
|
||||
};
|
||||
Self {
|
||||
api_key: table.get("api_key").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
model: table.get("model").and_then(|v| v.as_str()).unwrap_or_else(|| "claude-3-5-sonnet-20241022").to_string(),
|
||||
max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(3) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AnthropicConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -101,6 +407,20 @@ pub struct OllamaConfig {
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
impl OllamaConfig {
|
||||
fn from_toml_value(v: &toml::Value) -> Self {
|
||||
let table = match v.as_table() {
|
||||
Some(t) => t,
|
||||
None => return Self::default(),
|
||||
};
|
||||
Self {
|
||||
base_url: table.get("base_url").and_then(|v| v.as_str()).unwrap_or("http://localhost:11434").to_string(),
|
||||
model: table.get("model").and_then(|v| v.as_str()).unwrap_or("llama3").to_string(),
|
||||
max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(2) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OllamaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -111,16 +431,18 @@ impl Default for OllamaConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform adapter configuration.
|
||||
// Backward-compatible wrappers
|
||||
pub type ProviderConfig = ProviderRootConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlatformConfig {
|
||||
pub struct PlatformRootConfig {
|
||||
pub default: String,
|
||||
pub telegram: Option<TelegramConfig>,
|
||||
pub discord: Option<DiscordConfig>,
|
||||
pub webhook: Option<WebhookConfig>,
|
||||
}
|
||||
|
||||
impl Default for PlatformConfig {
|
||||
impl Default for PlatformRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default: "cli".to_string(),
|
||||
@@ -150,16 +472,15 @@ pub struct WebhookConfig {
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Memory system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryConfig {
|
||||
pub struct MemoryRootConfig {
|
||||
pub backend: String,
|
||||
pub storage_path: Option<PathBuf>,
|
||||
pub max_entries: usize,
|
||||
pub auto_compress: bool,
|
||||
}
|
||||
|
||||
impl Default for MemoryConfig {
|
||||
impl Default for MemoryRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: "local".to_string(),
|
||||
@@ -170,15 +491,14 @@ impl Default for MemoryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Security configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecurityConfig {
|
||||
pub struct SecurityRootConfig {
|
||||
pub vault_path: Option<PathBuf>,
|
||||
pub encryption_enabled: bool,
|
||||
pub threat_detection: bool,
|
||||
}
|
||||
|
||||
impl Default for SecurityConfig {
|
||||
impl Default for SecurityRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vault_path: None,
|
||||
@@ -188,15 +508,14 @@ impl Default for SecurityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kanban task system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KanbanConfig {
|
||||
pub struct KanbanRootConfig {
|
||||
pub enabled: bool,
|
||||
pub max_concurrent_tasks: usize,
|
||||
pub auto_decompose: bool,
|
||||
}
|
||||
|
||||
impl Default for KanbanConfig {
|
||||
impl Default for KanbanRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
@@ -206,15 +525,14 @@ impl Default for KanbanConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skills system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillsConfig {
|
||||
pub struct SkillsRootConfig {
|
||||
pub enabled: bool,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
pub auto_load: bool,
|
||||
}
|
||||
|
||||
impl Default for SkillsConfig {
|
||||
impl Default for SkillsRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
@@ -224,18 +542,218 @@ impl Default for SkillsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from a TOML file.
|
||||
pub fn load_config_from_file(path: &std::path::Path) -> anyhow::Result<AgentConfig> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read config file {}: {}", path.display(), e))?;
|
||||
let config: AgentConfig = toml::from_str(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e))?;
|
||||
Ok(config)
|
||||
// ─── ConfigLoader ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Layered configuration loader.
|
||||
///
|
||||
/// Resolution order (later overrides earlier):
|
||||
/// 1. Hardcoded defaults
|
||||
/// 2. Default file (`~/.config/gyandoot/config.toml`)
|
||||
/// 3. Profile file (`~/.gyandoot/config.yaml` — legacy)
|
||||
/// 4. Environment variables (`GYANDOOT_*`)
|
||||
///
|
||||
/// The final result is a [`GyandootConfig`] which can also be converted
|
||||
/// to the legacy [`AgentConfig`] via `.into()`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigLoader {
|
||||
env_prefix: String,
|
||||
}
|
||||
|
||||
impl Default for ConfigLoader {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigLoader {
|
||||
/// Create a new loader with the default `GYANDOOT_` prefix.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
env_prefix: "GYANDOOT_".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a loader with a custom environment-variable prefix.
|
||||
pub fn with_prefix(prefix: impl Into<String>) -> Self {
|
||||
Self {
|
||||
env_prefix: prefix.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and parse a single TOML config file.
|
||||
pub fn from_file(path: &std::path::Path) -> Result<GyandootConfig, ConfigError> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| ConfigError::FileNotFound {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
toml::from_str(&content).map_err(|e| ConfigError::ParseError {
|
||||
path: path.to_path_buf(),
|
||||
detail: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Override a config with environment variables.
|
||||
///
|
||||
/// Supported variables:
|
||||
/// - `GYANDOOT_AGENT_NAME`, `GYANDOOT_AGENT_MODEL`,
|
||||
/// `GYANDOOT_AGENT_MAX_TOKENS`, `GYANDOOT_AGENT_TEMPERATURE`,
|
||||
/// `GYANDOOT_AGENT_SYSTEM_PROMPT`
|
||||
/// - `GYANDOOT_PROVIDERS_DEFAULT`
|
||||
/// - `GYANDOOT_PLATFORM_DEFAULT`
|
||||
/// - `GYANDOOT_MEMORY_BACKEND`, `GYANDOOT_MEMORY_PATH`
|
||||
/// - `GYANDOOT_GATEWAY_ENABLED`, `GYANDOOT_GATEWAY_PORT`, `GYANDOOT_GATEWAY_HOST`
|
||||
/// - `GYANDOOT_BROWSER_ENABLED`, `GYANDOOT_BROWSER_CAMOFOX_URL`
|
||||
/// - Provider-specific: `GYANDOOT_OPENAI_API_KEY`, `GYANDOOT_OPENAI_MODEL`,
|
||||
/// `GYANDOOT_OPENAI_BASE_URL`, `GYANDOOT_ANTHROPIC_API_KEY`, etc.
|
||||
pub fn from_env(&self, config: &mut GyandootConfig) {
|
||||
macro_rules! env_opt {
|
||||
($key:literal, $target:expr) => {
|
||||
if let Ok(val) = std::env::var(concat!(env_prefix_str!(), $key)) {
|
||||
$target = val;
|
||||
}
|
||||
};
|
||||
}
|
||||
// We can't use concat! with a runtime string, so we build keys manually.
|
||||
let prefix = &self.env_prefix;
|
||||
let p = |k: &str| format!("{prefix}{k}");
|
||||
|
||||
// Agent
|
||||
if let Ok(v) = std::env::var(p("AGENT_NAME")) {
|
||||
config.agent.name = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("AGENT_MODEL")) {
|
||||
config.agent.model = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("AGENT_MAX_TOKENS")) {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
config.agent.max_tokens = n;
|
||||
}
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("AGENT_TEMPERATURE")) {
|
||||
if let Ok(n) = v.parse::<f64>() {
|
||||
config.agent.temperature = n;
|
||||
}
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("AGENT_SYSTEM_PROMPT")) {
|
||||
config.agent.system_prompt = v;
|
||||
}
|
||||
|
||||
// Providers
|
||||
if let Ok(v) = std::env::var(p("PROVIDERS_DEFAULT")) {
|
||||
config.providers.default = v;
|
||||
}
|
||||
|
||||
// Provider-specific API keys / settings stored in the flatten map
|
||||
for (provider_key, env_suffix, toml_field) in [
|
||||
("openai", "OPENAI_API_KEY", "api_key"),
|
||||
("openai", "OPENAI_MODEL", "model"),
|
||||
("openai", "OPENAI_BASE_URL", "base_url"),
|
||||
("anthropic", "ANTHROPIC_API_KEY", "api_key"),
|
||||
("anthropic", "ANTHROPIC_MODEL", "model"),
|
||||
("ollama", "OLLAMA_BASE_URL", "base_url"),
|
||||
("ollama", "OLLAMA_MODEL", "model"),
|
||||
] {
|
||||
if let Ok(val) = std::env::var(p(env_suffix)) {
|
||||
let entry = config
|
||||
.providers
|
||||
.providers
|
||||
.entry(provider_key.to_string())
|
||||
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
|
||||
if let toml::Value::Table(ref mut table) = entry {
|
||||
table.insert(toml_field.to_string(), toml::Value::String(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Platform
|
||||
if let Ok(v) = std::env::var(p("PLATFORM_DEFAULT")) {
|
||||
config.platform.default = v;
|
||||
}
|
||||
|
||||
// Memory
|
||||
if let Ok(v) = std::env::var(p("MEMORY_BACKEND")) {
|
||||
config.memory.backend = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("MEMORY_PATH")) {
|
||||
config.memory.path = Some(PathBuf::from(v));
|
||||
}
|
||||
|
||||
// Gateway
|
||||
if let Ok(v) = std::env::var(p("GATEWAY_ENABLED")) {
|
||||
config.gateway.enabled = v == "true" || v == "1";
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("GATEWAY_PORT")) {
|
||||
if let Ok(n) = v.parse::<u16>() {
|
||||
config.gateway.port = n;
|
||||
}
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("GATEWAY_HOST")) {
|
||||
config.gateway.host = v;
|
||||
}
|
||||
|
||||
// Browser
|
||||
if let Ok(v) = std::env::var(p("BROWSER_ENABLED")) {
|
||||
config.browser.enabled = v == "true" || v == "1";
|
||||
}
|
||||
if let Ok(v) = std::env::var(p("BROWSER_CAMOFOX_URL")) {
|
||||
config.browser.camofox_url = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration using the layered resolution strategy.
|
||||
///
|
||||
/// Order: hardcoded defaults → `~/.config/gyandoot/config.toml` →
|
||||
/// `~/.gyandoot/config.toml` (legacy) → env vars.
|
||||
pub fn load(&self) -> Result<GyandootConfig, ConfigError> {
|
||||
let mut config = GyandootConfig::default();
|
||||
|
||||
// 1. Try XDG-style config file (primary)
|
||||
if let Some(cfg_dir) = dirs::config_dir() {
|
||||
let xdg_path = cfg_dir.join("gyandoot").join("config.toml");
|
||||
if xdg_path.exists() {
|
||||
if let Ok(loaded) = Self::from_file(&xdg_path) {
|
||||
config = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try legacy ~/.gyandoot/config.toml (profile directory)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let legacy_path = home.join(".gyandoot").join("config.toml");
|
||||
if legacy_path.exists() {
|
||||
if let Ok(loaded) = Self::from_file(&legacy_path) {
|
||||
// Legacy overrides XDG for backward compatibility
|
||||
config = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Environment variable overrides
|
||||
self.from_env(&mut config);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load and convert to the legacy [`AgentConfig`].
|
||||
pub fn load_agent_config(&self) -> Result<AgentConfig, ConfigError> {
|
||||
self.load().map(AgentConfig::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: load config from a single TOML file (legacy API).
|
||||
pub fn load_config_from_file(path: &std::path::Path) -> anyhow::Result<AgentConfig> {
|
||||
let loader = ConfigLoader::new();
|
||||
let gyandoot = ConfigLoader::from_file(path)?;
|
||||
Ok(AgentConfig::from(gyandoot))
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
@@ -246,8 +764,97 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_provider_default() {
|
||||
let provider = ProviderConfig::default();
|
||||
let provider = ProviderRootConfig::default();
|
||||
assert_eq!(provider.default, "openai");
|
||||
assert!(provider.openai.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gyandoot_config_defaults() {
|
||||
let cfg = GyandootConfig::default();
|
||||
assert_eq!(cfg.agent.name, "gyandoot");
|
||||
assert_eq!(cfg.agent.max_tokens, 4096);
|
||||
assert_eq!(cfg.gateway.port, 8765);
|
||||
assert!(!cfg.browser.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gyandoot_to_agent_config() {
|
||||
let gyandoot = GyandootConfig::default();
|
||||
let agent: AgentConfig = gyandoot.into();
|
||||
assert_eq!(agent.name, "gyandoot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_loader_from_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.toml");
|
||||
let toml_content = r#"
|
||||
[agent]
|
||||
name = "test-agent"
|
||||
model = "gpt-4o"
|
||||
max_tokens = 2048
|
||||
temperature = 0.5
|
||||
|
||||
[providers]
|
||||
default = "ollama"
|
||||
|
||||
[memory]
|
||||
backend = "redis"
|
||||
path = "/tmp/gyandoot-memory"
|
||||
|
||||
[gateway]
|
||||
enabled = true
|
||||
port = 9999
|
||||
host = "0.0.0.0"
|
||||
|
||||
[browser]
|
||||
enabled = true
|
||||
camofox_url = "http://camofox:9222"
|
||||
"#;
|
||||
std::fs::write(&path, toml_content).unwrap();
|
||||
let config = ConfigLoader::from_file(&path).unwrap();
|
||||
assert_eq!(config.agent.name, "test-agent");
|
||||
assert_eq!(config.agent.max_tokens, 2048);
|
||||
assert_eq!(config.agent.temperature, 0.5);
|
||||
assert_eq!(config.providers.default, "ollama");
|
||||
assert_eq!(config.memory.backend, "redis");
|
||||
assert!(config.gateway.enabled);
|
||||
assert_eq!(config.gateway.port, 9999);
|
||||
assert!(config.browser.enabled);
|
||||
assert_eq!(
|
||||
config.browser.camofox_url.as_deref(),
|
||||
Some("http://camofox:9222")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_loader_file_not_found() {
|
||||
let err = ConfigLoader::from_file(std::path::Path::new("/nonexistent/gyandoot.toml"));
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_loader_invalid_toml() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("bad.toml");
|
||||
std::fs::write(&path, "[[[invalid]]]\n").unwrap();
|
||||
let err = ConfigLoader::from_file(&path);
|
||||
assert!(err.is_err());
|
||||
match err {
|
||||
Err(ConfigError::ParseError { .. }) => {} // expected
|
||||
_ => panic!("Expected ParseError"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_config_from_gyandoot_roundtrip() {
|
||||
let gyandoot = GyandootConfig::default();
|
||||
let agent: AgentConfig = gyandoot.clone().into();
|
||||
// Verify the conversion preserves defaults
|
||||
assert_eq!(agent.name, gyandoot.agent.name);
|
||||
assert_eq!(agent.model, gyandoot.agent.model);
|
||||
assert_eq!(agent.max_tokens, gyandoot.agent.max_tokens);
|
||||
assert_eq!(agent.temperature, gyandoot.agent.temperature);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,43 @@
|
||||
//! Unified error types for GyanDoot.
|
||||
//!
|
||||
//! Each subsystem defines its own error enum using `thiserror`,
|
||||
//! while application-level errors use `anyhow`.
|
||||
//! Each subsystem defines its own error enum using `thiserror` derive,
|
||||
//! providing rich context with `From` conversions between related types.
|
||||
//! Application-level errors use `anyhow`.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
// ─── Config errors ────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration loading and parsing errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConfigError {
|
||||
#[error("Config file not found: {path} ({source})")]
|
||||
FileNotFound {
|
||||
path: std::path::PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("Config parse error in {path}: {detail}")]
|
||||
ParseError {
|
||||
path: std::path::PathBuf,
|
||||
detail: String,
|
||||
},
|
||||
|
||||
#[error("Invalid config value for key '{key}': {detail}")]
|
||||
InvalidValue {
|
||||
key: String,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ConfigError> for CoreError {
|
||||
fn from(e: ConfigError) -> Self {
|
||||
CoreError::Config(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core errors ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Core platform errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CoreError {
|
||||
@@ -30,15 +63,80 @@ pub enum CoreError {
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Agent loop errors.
|
||||
// ─── Provider errors ──────────────────────────────────────────────────────
|
||||
|
||||
/// LLM provider errors with rich HTTP status and auth context.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ProviderError {
|
||||
#[error("API error (HTTP {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Rate limited (retry after {retry_after}s): {message}")]
|
||||
RateLimited {
|
||||
retry_after: u64,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("Request timed out after {duration}s")]
|
||||
Timeout { duration: u64 },
|
||||
|
||||
#[error("Stream error: {0}")]
|
||||
Stream(String),
|
||||
|
||||
#[error("Server error (HTTP {status}): {message}")]
|
||||
ServerError { status: u16, message: String },
|
||||
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
impl ProviderError {
|
||||
/// Attempt to extract `retry_after` header value from a reqwest response.
|
||||
pub fn from_response(status: u16, body: &str) -> Self {
|
||||
if status == 401 || status == 403 {
|
||||
Self::Auth(format!("HTTP {status}: {body}"))
|
||||
} else if status == 429 {
|
||||
Self::RateLimited {
|
||||
retry_after: 30,
|
||||
message: body.to_string(),
|
||||
}
|
||||
} else if status >= 500 {
|
||||
Self::ServerError {
|
||||
status,
|
||||
message: body.to_string(),
|
||||
}
|
||||
} else {
|
||||
Self::Api {
|
||||
status,
|
||||
message: body.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent errors ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Agent loop execution errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AgentError {
|
||||
#[error("LLM provider error: {0}")]
|
||||
Provider(String),
|
||||
|
||||
#[error("Tool not found: {0}")]
|
||||
ToolNotFound(String),
|
||||
|
||||
#[error("Tool execution error: {0}")]
|
||||
ToolExecution(String),
|
||||
|
||||
#[error("Max iterations reached ({0})")]
|
||||
MaxIterations(usize),
|
||||
|
||||
#[error("Context overflow: {0}")]
|
||||
ContextOverflow(String),
|
||||
|
||||
#[error("Context compression error: {0}")]
|
||||
ContextCompression(String),
|
||||
|
||||
@@ -49,66 +147,83 @@ pub enum AgentError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// LLM provider errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ProviderError {
|
||||
#[error("API error: {0}")]
|
||||
Api(String),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Rate limited")]
|
||||
RateLimited,
|
||||
|
||||
#[error("Model unavailable: {0}")]
|
||||
ModelUnavailable(String),
|
||||
|
||||
#[error("Stream error: {0}")]
|
||||
Stream(String),
|
||||
|
||||
#[error("Request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
impl From<ProviderError> for AgentError {
|
||||
fn from(e: ProviderError) -> Self {
|
||||
AgentError::Provider(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Memory errors ────────────────────────────────────────────────────────
|
||||
|
||||
/// Memory system errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MemoryError {
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Failed to store entry: {0}")]
|
||||
Store(String),
|
||||
|
||||
#[error("Query error: {0}")]
|
||||
Query(String),
|
||||
#[error("Failed to retrieve entry: {0}")]
|
||||
Retrieve(String),
|
||||
|
||||
#[error("Index corruption: {0}")]
|
||||
IndexCorruption(String),
|
||||
#[error("Corrupt storage index: {0}")]
|
||||
Corrupt(String),
|
||||
|
||||
#[error("Entry not found: {0}")]
|
||||
EntryNotFound(String),
|
||||
|
||||
#[error("Compression failed: {0}")]
|
||||
Compression(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
// ─── Platform errors ──────────────────────────────────────────────────────
|
||||
|
||||
/// Platform adapter errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PlatformError {
|
||||
#[error("Connection failed: {0}")]
|
||||
Connection(String),
|
||||
|
||||
#[error("Message send failed: {0}")]
|
||||
#[error("Failed to send message: {0}")]
|
||||
Send(String),
|
||||
|
||||
#[error("Failed to receive message: {0}")]
|
||||
Receive(String),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Platform unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
|
||||
#[error("Connection failed: {0}")]
|
||||
Connection(String),
|
||||
|
||||
#[error("Platform unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
// ─── Browser errors ───────────────────────────────────────────────────────
|
||||
|
||||
/// Browser automation errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BrowserError {
|
||||
#[error("Browser connection failed: {0}")]
|
||||
Connection(String),
|
||||
|
||||
#[error("Navigation failed: {0}")]
|
||||
Navigation(String),
|
||||
|
||||
#[error("Element not found: {0}")]
|
||||
ElementNotFound(String),
|
||||
|
||||
#[error("Script execution failed: {0}")]
|
||||
ScriptExecution(String),
|
||||
|
||||
#[error("Snapshot capture failed: {0}")]
|
||||
SnapshotFailed(String),
|
||||
}
|
||||
|
||||
// ─── Other subsystem errors (backward-compatible) ─────────────────────────
|
||||
|
||||
/// Security subsystem errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SecurityError {
|
||||
@@ -217,15 +332,120 @@ pub enum GatewayError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Convenience alias for common result types.
|
||||
// ─── Conversion impls ─────────────────────────────────────────────────────
|
||||
|
||||
// Note: anyhow::Error provides blanket From<E> for all E: StdError,
|
||||
// so explicit From impls would conflict. Use .map_err(Into::into) or
|
||||
// anyhow!(e) at call sites instead.
|
||||
|
||||
// ─── Result type aliases ──────────────────────────────────────────────────
|
||||
|
||||
pub type CoreResult<T> = Result<T, CoreError>;
|
||||
pub type ConfigResult<T> = Result<T, ConfigError>;
|
||||
pub type AgentResult<T> = Result<T, AgentError>;
|
||||
pub type ProviderResult<T> = Result<T, ProviderError>;
|
||||
pub type MemoryResult<T> = Result<T, MemoryError>;
|
||||
pub type PlatformResult<T> = Result<T, PlatformError>;
|
||||
pub type BrowserResult<T> = Result<T, BrowserError>;
|
||||
pub type SecurityResult<T> = Result<T, SecurityError>;
|
||||
pub type KanbanResult<T> = Result<T, KanbanError>;
|
||||
pub type SubAgentResult<T> = Result<T, SubAgentError>;
|
||||
pub type SkillsResult<T> = Result<T, SkillsError>;
|
||||
pub type ProfileResult<T> = Result<T, ProfileError>;
|
||||
pub type GatewayResult<T> = Result<T, GatewayError>;
|
||||
pub type GatewayResult<T> = Result<T, GatewayError>;
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_error_file_not_found() {
|
||||
let err = ConfigError::FileNotFound {
|
||||
path: "/tmp/doesntexist.toml".into(),
|
||||
source: std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("doesntexist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_error_parse() {
|
||||
let err = ConfigError::ParseError {
|
||||
path: "/tmp/bad.toml".into(),
|
||||
detail: "invalid syntax".to_string(),
|
||||
};
|
||||
assert!(err.to_string().contains("bad.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_error_from_response_auth() {
|
||||
let err = ProviderError::from_response(401, "Invalid API key");
|
||||
match err {
|
||||
ProviderError::Auth(_) => {}
|
||||
_ => panic!("Expected Auth error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_error_from_response_rate_limit() {
|
||||
let err = ProviderError::from_response(429, "Too Many Requests");
|
||||
match err {
|
||||
ProviderError::RateLimited { retry_after, .. } => {
|
||||
assert_eq!(retry_after, 30);
|
||||
}
|
||||
_ => panic!("Expected RateLimited error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_error_from_response_server() {
|
||||
let err = ProviderError::from_response(503, "Service Unavailable");
|
||||
match err {
|
||||
ProviderError::ServerError { status, .. } => {
|
||||
assert_eq!(status, 503);
|
||||
}
|
||||
_ => panic!("Expected ServerError"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_error_from_provider() {
|
||||
let provider_err = ProviderError::Auth("bad key".to_string());
|
||||
let agent_err: AgentError = provider_err.into();
|
||||
match agent_err {
|
||||
AgentError::Provider(msg) => assert!(msg.contains("bad key")),
|
||||
_ => panic!("Expected Provider variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_error_into_core() {
|
||||
let cfg_err = ConfigError::FileNotFound {
|
||||
path: "/test.toml".into(),
|
||||
source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"),
|
||||
};
|
||||
let core_err: CoreError = cfg_err.into();
|
||||
assert!(matches!(core_err, CoreError::Config(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_browser_error() {
|
||||
let err = BrowserError::Connection("refused".to_string());
|
||||
assert_eq!(err.to_string(), "Browser connection failed: refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_error_max_iterations() {
|
||||
let err = AgentError::MaxIterations(10);
|
||||
assert_eq!(err.to_string(), "Max iterations reached (10)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_error_from_io() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
|
||||
let mem_err: MemoryError = io_err.into();
|
||||
assert!(matches!(mem_err, MemoryError::Io(_)));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Core type definitions for GyanDoot agents, messages, and sessions.
|
||||
//!
|
||||
//! This module provides the fundamental data structures used across all
|
||||
//! GyanDoot crates: message types, tool definitions, session identifiers,
|
||||
//! provider kinds, and conversation context.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -8,7 +12,9 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for an agent.
|
||||
// ─── Identifiers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Unique identifier for an agent instance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct AgentId(pub Uuid);
|
||||
|
||||
@@ -30,7 +36,7 @@ impl std::fmt::Display for AgentId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for a session.
|
||||
/// Unique identifier for a conversation session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SessionId(pub Uuid);
|
||||
|
||||
@@ -52,44 +58,53 @@ impl std::fmt::Display for SessionId {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Roles ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The role of a message sender in a conversation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MessageRole {
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MessageRole {
|
||||
impl std::fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MessageRole::System => write!(f, "system"),
|
||||
MessageRole::User => write!(f, "user"),
|
||||
MessageRole::Assistant => write!(f, "assistant"),
|
||||
MessageRole::Tool => write!(f, "tool"),
|
||||
Role::System => write!(f, "system"),
|
||||
Role::User => write!(f, "user"),
|
||||
Role::Assistant => write!(f, "assistant"),
|
||||
Role::Tool => write!(f, "tool"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for MessageRole {
|
||||
impl FromStr for Role {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"system" => Ok(MessageRole::System),
|
||||
"user" => Ok(MessageRole::User),
|
||||
"assistant" => Ok(MessageRole::Assistant),
|
||||
"tool" => Ok(MessageRole::Tool),
|
||||
_ => Err(format!("Invalid message role: {}", s)),
|
||||
"system" => Ok(Role::System),
|
||||
"user" => Ok(Role::User),
|
||||
"assistant" => Ok(Role::Assistant),
|
||||
"tool" => Ok(Role::Tool),
|
||||
_ => Err(format!("Invalid message role: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legacy MessageRole alias ─────────────────────────────────────────────
|
||||
|
||||
/// Backward-compatible alias for [`Role`].
|
||||
pub type MessageRole = Role;
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single message in an agent conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub role: MessageRole,
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
pub tool_result: Option<String>,
|
||||
@@ -97,7 +112,8 @@ pub struct Message {
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
|
||||
/// Create a new message with the given role and content.
|
||||
pub fn new(role: Role, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role,
|
||||
content: content.into(),
|
||||
@@ -107,18 +123,47 @@ impl Message {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach tool calls to this message (typically for assistant messages).
|
||||
pub fn with_tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
|
||||
self.tool_calls = Some(calls);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a tool result to this message (typically for tool messages).
|
||||
pub fn with_tool_result(mut self, result: String) -> Self {
|
||||
self.tool_result = Some(result);
|
||||
self
|
||||
}
|
||||
|
||||
/// Convenience: return true if this is a user message.
|
||||
pub fn is_user(&self) -> bool {
|
||||
self.role == Role::User
|
||||
}
|
||||
|
||||
/// Convenience: return true if this is a system message.
|
||||
pub fn is_system(&self) -> bool {
|
||||
self.role == Role::System
|
||||
}
|
||||
|
||||
/// Convenience: return true if this is an assistant message.
|
||||
pub fn is_assistant(&self) -> bool {
|
||||
self.role == Role::Assistant
|
||||
}
|
||||
|
||||
/// Convenience: return true if this is a tool response message.
|
||||
pub fn is_tool(&self) -> bool {
|
||||
self.role == Role::Tool
|
||||
}
|
||||
|
||||
/// Return true if this message has at least one tool call attached.
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
self.tool_calls.as_ref().map_or(false, |c| !c.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool call invoked by the agent.
|
||||
// ─── Tool types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A tool call invoked by the LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
@@ -126,7 +171,18 @@ pub struct ToolCall {
|
||||
pub arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A tool definition that the agent can invoke.
|
||||
impl ToolCall {
|
||||
/// Create a new tool call.
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: serde_json::Value) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool definition that the agent can expose to the LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
@@ -134,6 +190,17 @@ pub struct ToolDefinition {
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
/// Create a new tool definition.
|
||||
pub fn new(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The execution result of a tool.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
@@ -143,6 +210,101 @@ pub struct ToolResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Create a successful tool result.
|
||||
pub fn success(tool_call_id: impl Into<String>, output: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_call_id: tool_call_id.into(),
|
||||
success: true,
|
||||
output: output.into(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a failed tool result.
|
||||
pub fn failure(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_call_id: tool_call_id.into(),
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider types ───────────────────────────────────────────────────────
|
||||
|
||||
/// Per-provider runtime configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub name: String,
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub temperature: f64,
|
||||
pub max_tokens: usize,
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
api_key: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
api_key: api_key.into(),
|
||||
base_url: base_url.into(),
|
||||
model: model.into(),
|
||||
temperature: 0.7,
|
||||
max_tokens: 4096,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM provider identifiers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProviderKind {
|
||||
OpenAI,
|
||||
Anthropic,
|
||||
Ollama,
|
||||
Gemini,
|
||||
DeepSeek,
|
||||
OpenRouter,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
/// Return a human-readable label.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "openai",
|
||||
ProviderKind::Anthropic => "anthropic",
|
||||
ProviderKind::Ollama => "ollama",
|
||||
ProviderKind::Gemini => "gemini",
|
||||
ProviderKind::DeepSeek => "deepseek",
|
||||
ProviderKind::OpenRouter => "openrouter",
|
||||
ProviderKind::Custom(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from a known string, returning `Custom` for unknown values.
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"openai" => ProviderKind::OpenAI,
|
||||
"anthropic" => ProviderKind::Anthropic,
|
||||
"ollama" => ProviderKind::Ollama,
|
||||
"gemini" => ProviderKind::Gemini,
|
||||
"deepseek" => ProviderKind::DeepSeek,
|
||||
"openrouter" => ProviderKind::OpenRouter,
|
||||
other => ProviderKind::Custom(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Platform types ───────────────────────────────────────────────────────
|
||||
|
||||
/// Represents a platform type the agent can connect to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlatformKind {
|
||||
@@ -161,11 +323,24 @@ impl FromStr for PlatformKind {
|
||||
"telegram" => Ok(PlatformKind::Telegram),
|
||||
"discord" => Ok(PlatformKind::Discord),
|
||||
"webhook" => Ok(PlatformKind::Webhook),
|
||||
_ => Err(format!("Unknown platform: {}", s)),
|
||||
_ => Err(format!("Unknown platform: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlatformKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PlatformKind::Cli => write!(f, "cli"),
|
||||
PlatformKind::Telegram => write!(f, "telegram"),
|
||||
PlatformKind::Discord => write!(f, "discord"),
|
||||
PlatformKind::Webhook => write!(f, "webhook"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent status ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Status of an agent or task.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentStatus {
|
||||
@@ -176,6 +351,20 @@ pub enum AgentStatus {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AgentStatus::Idle => write!(f, "idle"),
|
||||
AgentStatus::Running => write!(f, "running"),
|
||||
AgentStatus::Thinking => write!(f, "thinking"),
|
||||
AgentStatus::WaitingForTool => write!(f, "waiting_for_tool"),
|
||||
AgentStatus::Error(e) => write!(f, "error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Conversation context ─────────────────────────────────────────────────
|
||||
|
||||
/// The complete conversation context passed to the LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationContext {
|
||||
@@ -185,6 +374,35 @@ pub struct ConversationContext {
|
||||
pub max_tokens: usize,
|
||||
}
|
||||
|
||||
impl ConversationContext {
|
||||
/// Create a new conversation context.
|
||||
pub fn new(
|
||||
messages: Vec<Message>,
|
||||
system_prompt: impl Into<String>,
|
||||
tools: Vec<ToolDefinition>,
|
||||
max_tokens: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
messages,
|
||||
system_prompt: system_prompt.into(),
|
||||
tools,
|
||||
max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of messages in the context.
|
||||
pub fn message_count(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
/// Return true if there are no messages.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Kanban types ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Task prioritization levels for the Kanban system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Priority {
|
||||
@@ -194,6 +412,18 @@ pub enum Priority {
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Status of a task within the Kanban workflow.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TaskStatus {
|
||||
Backlog,
|
||||
Ready,
|
||||
InProgress,
|
||||
Review,
|
||||
Blocked,
|
||||
Done,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// A task unit in the Kanban system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Task {
|
||||
@@ -210,53 +440,30 @@ pub struct Task {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TaskStatus {
|
||||
Backlog,
|
||||
Ready,
|
||||
InProgress,
|
||||
Review,
|
||||
Blocked,
|
||||
Done,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// LLM provider identifiers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProviderKind {
|
||||
OpenAI,
|
||||
Anthropic,
|
||||
Ollama,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl FromStr for ProviderKind {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"openai" => Ok(ProviderKind::OpenAI),
|
||||
"anthropic" => Ok(ProviderKind::Anthropic),
|
||||
"ollama" => Ok(ProviderKind::Ollama),
|
||||
other => Ok(ProviderKind::Custom(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
// ─── Config maps ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A profile configuration key-value map.
|
||||
pub type ConfigMap = HashMap<String, serde_json::Value>;
|
||||
|
||||
/// Data directory resolver using platform-agnostic paths.
|
||||
// ─── Path helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the GyanDoot data directory using `dirs` (cross-platform).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||
home.join(".local").join("share").join("gyandoot")
|
||||
}
|
||||
|
||||
/// Resolve the GyanDoot config directory using `dirs` (cross-platform).
|
||||
pub fn config_dir() -> PathBuf {
|
||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||
home.join(".config").join("gyandoot")
|
||||
let base = dirs::config_dir().unwrap_or_else(|| {
|
||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||
home.join(".config")
|
||||
});
|
||||
base.join("gyandoot")
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -275,9 +482,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_message_role_from_str() {
|
||||
assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
|
||||
assert_eq!("assistant".parse::<MessageRole>().unwrap(), MessageRole::Assistant);
|
||||
assert!("invalid".parse::<MessageRole>().is_err());
|
||||
assert_eq!("user".parse::<Role>().unwrap(), Role::User);
|
||||
assert_eq!("assistant".parse::<Role>().unwrap(), Role::Assistant);
|
||||
assert!("invalid".parse::<Role>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_role_display() {
|
||||
assert_eq!(Role::System.to_string(), "system");
|
||||
assert_eq!(Role::User.to_string(), "user");
|
||||
assert_eq!(Role::Assistant.to_string(), "assistant");
|
||||
assert_eq!(Role::Tool.to_string(), "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -285,4 +500,111 @@ mod tests {
|
||||
assert_eq!("cli".parse::<PlatformKind>().unwrap(), PlatformKind::Cli);
|
||||
assert_eq!("discord".parse::<PlatformKind>().unwrap(), PlatformKind::Discord);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_new() {
|
||||
let msg = Message::new(Role::User, "hello");
|
||||
assert_eq!(msg.role, Role::User);
|
||||
assert_eq!(msg.content, "hello");
|
||||
assert!(msg.tool_calls.is_none());
|
||||
assert!(msg.tool_result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_with_tool_calls() {
|
||||
let msg = Message::new(Role::Assistant, "thinking...")
|
||||
.with_tool_calls(vec![ToolCall::new("call_1", "get_weather", serde_json::json!({"city": "NYC"}))]);
|
||||
assert!(msg.has_tool_calls());
|
||||
assert_eq!(msg.tool_calls.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_success() {
|
||||
let tr = ToolResult::success("call_1", "output data");
|
||||
assert!(tr.success);
|
||||
assert!(tr.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_failure() {
|
||||
let tr = ToolResult::failure("call_1", "something went wrong");
|
||||
assert!(!tr.success);
|
||||
assert_eq!(tr.error.unwrap(), "something went wrong");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_definition() {
|
||||
let td = ToolDefinition::new("get_weather", "Get current weather", serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string"}
|
||||
}
|
||||
}));
|
||||
assert_eq!(td.name, "get_weather");
|
||||
assert_eq!(td.description, "Get current weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_config() {
|
||||
let pc = ProviderConfig::new("openai", "sk-test", "https://api.openai.com/v1", "gpt-4o");
|
||||
assert_eq!(pc.name, "openai");
|
||||
assert_eq!(pc.temperature, 0.7);
|
||||
assert_eq!(pc.max_tokens, 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_kind_from_str() {
|
||||
assert_eq!(ProviderKind::from_str("openai"), ProviderKind::OpenAI);
|
||||
assert_eq!(ProviderKind::from_str("ANTHROPIC"), ProviderKind::Anthropic);
|
||||
assert_eq!(ProviderKind::from_str("unknown"), ProviderKind::Custom("unknown".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_status_display() {
|
||||
assert_eq!(AgentStatus::Idle.to_string(), "idle");
|
||||
assert_eq!(AgentStatus::Thinking.to_string(), "thinking");
|
||||
assert_eq!(
|
||||
AgentStatus::Error("bad".to_string()).to_string(),
|
||||
"error: bad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_context() {
|
||||
let ctx = ConversationContext::new(
|
||||
vec![Message::new(Role::User, "hi")],
|
||||
"You are an AI.",
|
||||
vec![],
|
||||
4096,
|
||||
);
|
||||
assert_eq!(ctx.message_count(), 1);
|
||||
assert!(!ctx.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_dir_resolution() {
|
||||
let dir = data_dir();
|
||||
assert!(dir.to_string_lossy().contains("gyandoot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_dir_resolution() {
|
||||
let dir = config_dir();
|
||||
assert!(dir.to_string_lossy().contains("gyandoot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_display() {
|
||||
let id = SessionId::new();
|
||||
let s = id.to_string();
|
||||
assert_eq!(s.len(), 36); // uuid v4 format
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_is_helpers() {
|
||||
assert!(Message::new(Role::System, "").is_system());
|
||||
assert!(Message::new(Role::User, "").is_user());
|
||||
assert!(Message::new(Role::Assistant, "").is_assistant());
|
||||
assert!(Message::new(Role::Tool, "").is_tool());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user