Initial scaffold: GyanDoot v2.0 in Rust

Full Cargo workspace with 11 crates:
- gyandoot-core: Shared types, config, error types
- gyandoot-agent: Agent loop, tool execution, context compression
- gyandoot-providers: LLM provider abstraction (OpenAI, Anthropic, Ollama)
- gyandoot-memory: MemPalace persistent memory system
- gyandoot-platform: Platform adapters (Telegram, Discord, Webhook, CLI)
- gyandoot-security: Credential vault, sanitizer, threat detector
- gyandoot-kanban: Task decomposition, swarm orchestration, diagnostics
- gyandoot-subagents: Sub-agent spawning and orchestration
- gyandoot-skills: Skills discovery, loading, preprocessing, bundles
- gyandoot-profile: Multi-agent profile manager
- gyandoot-gateway: Gateway sessions, routing, access control

Plus: CLI entry point with clap, cross-platform installer script,
KANBAN.md, .gnpl/AGENTS.md

Workspace compiles clean: cargo check passes with 0 errors.
This commit is contained in:
2026-06-11 20:04:12 +05:30
commit 851c838ca5
53 changed files with 8037 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
//! The core agent inference loop.
//!
//! The `AgentLoop` manages the cycle: receive message → build context →
//! call LLM → process tool calls → repeat or respond.
use std::sync::Arc;
use gyandoot_core::{
AgentConfig, AgentStatus, ConversationContext, Message, MessageRole, ToolCall, ToolResult,
};
use gyandoot_memory::palace::MemPalace;
use gyandoot_providers::Provider;
use gyandoot_security::sanitizer::Sanitizer;
use gyandoot_skills::SkillsManager;
use tokio::sync::Mutex;
use tracing::{debug, error, info, span, Level};
use crate::context::ContextCompressor;
use crate::tools::ToolExecutor;
/// The main agent execution loop.
pub struct AgentLoop {
config: AgentConfig,
provider: Box<dyn Provider + Send + Sync>,
tool_executor: ToolExecutor,
context_compressor: ContextCompressor,
memory: Arc<Mutex<MemPalace>>,
skills_manager: SkillsManager,
sanitizer: Sanitizer,
status: Arc<Mutex<AgentStatus>>,
}
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.
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 skills_manager = SkillsManager::new();
let sanitizer = Sanitizer::new();
Self {
config,
provider,
tool_executor,
context_compressor,
memory,
skills_manager,
sanitizer,
status: Arc::new(Mutex::new(AgentStatus::Idle)),
}
}
/// 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.
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");
let mut messages: Vec<Message> = vec![
Message::new(MessageRole::System, &self.config.system_prompt),
];
loop {
// Read user input
let user_input = self.read_input().await?;
// Check for exit conditions
if self.should_exit(&user_input) {
info!("User requested exit");
break;
}
// Sanitize input
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,
};
// 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
{
let mut memory = self.memory.lock().await;
memory.store_conversation_turn(&messages).await?;
}
// Compress context if too large
if self.should_compress(&messages) {
debug!("Context size threshold reached, compressing");
messages = self.context_compressor.compress(messages).await;
}
*self.status.lock().await = AgentStatus::Idle;
}
info!("Agent loop finished");
Ok(())
}
/// Read input from stdin (CLI mode).
async fn read_input(&self) -> anyhow::Result<String> {
use tokio::io::AsyncBufReadExt;
let mut stdout = tokio::io::stdout();
use tokio::io::AsyncWriteExt;
stdout.write_all(b"\n> ").await?;
stdout.flush().await?;
let stdin = tokio::io::stdin();
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")
}
/// 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.
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();
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
openai_config.api_key.clone(),
openai_config.model.clone(),
openai_config.base_url.clone(),
))
}
"anthropic" => {
let anthropic_config = config
.provider
.anthropic
.clone()
.unwrap_or_default();
Box::new(gyandoot_providers::anthropic::AnthropicProvider::new(
anthropic_config.api_key.clone(),
anthropic_config.model.clone(),
))
}
"ollama" => {
let ollama_config = config
.provider
.ollama
.clone()
.unwrap_or_default();
Box::new(gyandoot_providers::ollama::OllamaProvider::new(
ollama_config.base_url.clone(),
ollama_config.model.clone(),
))
}
other => {
tracing::warn!("Unknown provider '{}', falling back to OpenAI", other);
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
String::new(),
config.model.clone(),
None,
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_exit() {
let config = AgentConfig::default();
let provider = gyandoot_providers::openai::OpenAIProvider::new(
"test".to_string(),
"gpt-4o".to_string(),
None,
);
let _agent = 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()),
sanitizer: Sanitizer::new(),
status: Arc::new(Mutex::new(AgentStatus::Idle)),
};
}
}