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,22 @@
[package]
name = "gyandoot-agent"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "GyanDoot v2 — Core agent loop, tool execution, context compression"
[dependencies]
gyandoot-core = { path = "../gyandoot-core" }
gyandoot-providers = { path = "../gyandoot-providers" }
gyandoot-memory = { path = "../gyandoot-memory" }
gyandoot-security = { path = "../gyandoot-security" }
gyandoot-skills = { path = "../gyandoot-skills" }
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
anyhow.workspace = true
tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
async-trait.workspace = true

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)),
};
}
}

View File

@@ -0,0 +1,162 @@
//! Context compression logic.
//!
//! When the agent's conversation context grows too large, this module
//! intelligently compresses or summarizes older messages while preserving
//! critical information.
use gyandoot_core::Message;
use tracing::debug;
/// Compresses conversation context when it exceeds size thresholds.
pub struct ContextCompressor {
max_messages_before_compress: usize,
}
impl ContextCompressor {
/// Create a new context compressor.
///
/// By default, compression triggers when there are more than 30 messages
/// (roughly corresponding to 15 turns of conversation).
pub fn new() -> Self {
Self {
max_messages_before_compress: 30,
}
}
/// Create a compressor with a custom message threshold.
pub fn with_threshold(max_messages: usize) -> Self {
Self {
max_messages_before_compress: max_messages,
}
}
/// Compress the message list by summarizing older messages.
///
/// Strategy:
/// - Keep the system prompt (first message).
/// - Keep the most recent N messages (recent context).
/// - Summarize the messages in between into a single condensed message.
/// - Preserve all tool call/result pairs.
pub async fn compress(&self, messages: Vec<Message>) -> Vec<Message> {
if messages.len() <= self.max_messages_before_compress {
return messages;
}
debug!(
"Compressing {} messages (threshold: {})",
messages.len(),
self.max_messages_before_compress
);
let mut compressed: Vec<Message> = Vec::with_capacity(self.max_messages_before_compress);
// Always keep the system prompt
if let Some(system) = messages.first() {
compressed.push(system.clone());
}
// Keep the most recent N-2 messages (reserve space for summary)
let recent_count = (self.max_messages_before_compress / 2).max(2);
let keep_start = messages.len().saturating_sub(recent_count);
// Summarize middle messages
let middle: Vec<&Message> = messages[1..keep_start]
.iter()
.filter(|m| m.role != gyandoot_core::MessageRole::Tool)
.collect();
if !middle.is_empty() {
let summary = self.summarize_messages(&middle);
compressed.push(Message::new(
gyandoot_core::MessageRole::System,
format!("[Summarized context from previous conversation: {}]", summary),
));
}
// Append the most recent messages
for msg in messages.iter().skip(keep_start) {
compressed.push(msg.clone());
}
debug!("Compressed to {} messages", compressed.len());
compressed
}
/// Create a text summary from a slice of messages.
fn summarize_messages(&self, messages: &[&Message]) -> String {
let count = messages.len();
let user_msgs: Vec<&str> = messages
.iter()
.filter(|m| m.role == gyandoot_core::MessageRole::User)
.map(|m| m.content.as_str())
.collect();
if user_msgs.is_empty() {
format!("{} previous assistant messages", count)
} else if user_msgs.len() <= 3 {
format!(
"Earlier discussion: {}",
user_msgs.join(" | ")
)
} else {
format!(
"{} previous turns covering topics: {}, ... (and {} more)",
count,
user_msgs[..3].join(", "),
user_msgs.len().saturating_sub(3)
)
}
}
}
impl Default for ContextCompressor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use gyandoot_core::MessageRole;
#[tokio::test]
async fn test_no_compress_below_threshold() {
let compressor = ContextCompressor::with_threshold(10);
let messages: Vec<Message> = (0..5)
.map(|i| Message::new(MessageRole::User, format!("message {}", i)))
.collect();
let result = compressor.compress(messages.clone()).await;
assert_eq!(result.len(), 5);
}
#[tokio::test]
async fn test_compress_above_threshold() {
let compressor = ContextCompressor::with_threshold(10);
let mut messages = vec![Message::new(MessageRole::System, "You are an AI.")];
for i in 0..20 {
messages.push(Message::new(MessageRole::User, format!("user message {}", i)));
messages.push(Message::new(MessageRole::Assistant, format!("response {}", i)));
}
let result = compressor.compress(messages).await;
assert!(result.len() < 20);
assert_eq!(result[0].content, "You are an AI.");
}
#[test]
fn test_summarize_empty() {
let compressor = ContextCompressor::new();
let summary = compressor.summarize_messages(&[]);
assert!(summary.contains("0"));
}
#[test]
fn test_summarize_few() {
let compressor = ContextCompressor::new();
let msg = Message::new(MessageRole::User, "hello");
let summary = compressor.summarize_messages(&[&msg]);
assert!(summary.contains("hello"));
}
}

View File

@@ -0,0 +1,13 @@
//! GyanDoot Agent — Core agent loop, tool execution, context compression.
//!
//! This crate implements the main agent inference loop: receiving messages,
//! invoking LLM providers, executing tool calls, compressing context when
//! needed, and interacting with memory.
pub mod agent_loop;
pub mod tools;
pub mod context;
pub use agent_loop::AgentLoop;
pub use tools::ToolExecutor;
pub use context::ContextCompressor;

View File

@@ -0,0 +1,146 @@
//! Tool execution subsystem.
//!
//! Discovers available tools, executes tool calls, and returns results.
use std::collections::HashMap;
use gyandoot_core::{ToolCall, ToolDefinition, ToolResult};
use tracing::{debug, error, warn};
/// Executes tool calls made by the LLM.
pub struct ToolExecutor {
tools: HashMap<String, Box<dyn Tool + Send + Sync>>,
}
/// Trait that all tools must implement.
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String>;
}
impl ToolExecutor {
/// Create a new executor with no tools loaded.
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
/// Register a tool implementation.
pub fn register(&mut self, tool: Box<dyn Tool + Send + Sync>) {
let name = tool.name().to_string();
debug!("Registering tool: {}", name);
self.tools.insert(name, tool);
}
/// Return all registered tool definitions (for LLM function calling).
pub fn definitions(&self) -> Vec<ToolDefinition> {
self.tools
.values()
.map(|t| ToolDefinition {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
})
.collect()
}
/// Execute a single tool call and return the result.
pub async fn execute(&self, call: &ToolCall) -> anyhow::Result<ToolResult> {
let span = tracing::span!(tracing::Level::INFO, "tool_exec", tool = %call.name);
let _guard = span.enter();
match self.tools.get(&call.name) {
Some(tool) => {
debug!("Executing tool '{}' with args: {}", call.name, call.arguments);
match tool.execute(call.arguments.clone()).await {
Ok(output) => {
debug!("Tool '{}' succeeded", call.name);
Ok(ToolResult {
tool_call_id: call.id.clone(),
success: true,
output,
error: None,
})
}
Err(e) => {
error!("Tool '{}' failed: {}", call.name, e);
Ok(ToolResult {
tool_call_id: call.id.clone(),
success: false,
output: String::new(),
error: Some(e.to_string()),
})
}
}
}
None => {
warn!("Tool '{}' not found", call.name);
Ok(ToolResult {
tool_call_id: call.id.clone(),
success: false,
output: String::new(),
error: Some(format!("Tool '{}' is not available", call.name)),
})
}
}
}
}
impl Default for ToolExecutor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct EchoTool;
#[async_trait::async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echo back the input"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {"type": "string"}
},
"required": ["message"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
Ok(args["message"].as_str().unwrap_or("").to_string())
}
}
#[tokio::test]
async fn test_tool_registration_and_execution() {
let mut executor = ToolExecutor::new();
executor.register(Box::new(EchoTool));
let defs = executor.definitions();
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "echo");
let call = ToolCall {
id: "1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({"message": "hello"}),
};
let result = executor.execute(&call).await.unwrap();
assert!(result.success);
assert_eq!(result.output, "hello");
}
}