2 Commits

89 changed files with 2026 additions and 14280 deletions

798
AGENTS.md
View File

@@ -1,798 +0,0 @@
# AGENTS.md - AI Agent Instructions for Owlen Development
This document provides comprehensive context and guidelines for AI agents (Claude, GPT-4, etc.) working on the Owlen codebase.
## Project Overview
**Owlen** is a local-first, terminal-based AI assistant built in Rust using the Ratatui TUI framework. It implements a Model Context Protocol (MCP) architecture for modular tool execution and supports both local (Ollama) and cloud LLM providers.
**Core Philosophy:**
- **Local-first**: Prioritize local LLMs (Ollama) with cloud as fallback
- **Privacy-focused**: No telemetry, user data stays on device
- **MCP-native**: All operations through MCP servers for modularity
- **Terminal-native**: Vim-style modal interaction in a beautiful TUI
**Current Status:** v1.0 - MCP-only architecture (Phase 10 complete)
## Architecture
### Project Structure
```
owlen/
├── crates/
│ ├── owlen-core/ # Core types, config, provider traits
│ ├── owlen-tui/ # Ratatui-based terminal interface
│ ├── owlen-cli/ # Command-line interface
│ ├── owlen-ollama/ # Ollama provider implementation
│ ├── owlen-mcp-llm-server/ # LLM inference as MCP server
│ ├── owlen-mcp-client/ # MCP client library
│ ├── owlen-mcp-server/ # Base MCP server framework
│ ├── owlen-mcp-code-server/ # Code execution in Docker
│ └── owlen-mcp-prompt-server/ # Prompt management server
├── docs/ # Documentation
├── themes/ # TUI color themes
└── .agents/ # Agent development plans
```
### Key Technologies
- **Language**: Rust 1.83+
- **TUI**: Ratatui with Crossterm backend
- **Async Runtime**: Tokio
- **Config**: TOML (serde)
- **HTTP Client**: reqwest
- **LLM Providers**: Ollama (primary), with extensibility for OpenAI/Anthropic
- **Protocol**: JSON-RPC 2.0 over STDIO/HTTP/WebSocket
## Current Features (v1.0)
### Core Capabilities
1. **MCP Architecture** (Phase 3-10 complete)
- All LLM interactions via MCP servers
- Local and remote MCP client support
- STDIO, HTTP, WebSocket transports
- Automatic failover with health checks
2. **Provider System**
- Ollama (local and cloud)
- Configurable per-provider settings
- API key management with env variable expansion
- Model switching via TUI (`:m` command)
3. **Agentic Loop** (ReAct pattern)
- THOUGHT → ACTION → OBSERVATION cycle
- Tool discovery and execution
- Configurable iteration limits
- Emergency stop (Ctrl+C)
4. **Mode System**
- Chat mode: Limited tool availability
- Code mode: Full tool access
- Tool filtering by mode
- Runtime mode switching
5. **Session Management**
- Auto-save conversations
- Session persistence with encryption
- Description generation
- Session timeout management
6. **Security**
- Docker sandboxing for code execution
- Tool whitelisting
- Permission prompts for dangerous operations
- Network isolation options
### TUI Features
- Vim-style modal editing (Normal, Insert, Visual, Command modes)
- Multi-panel layout (conversation, status, input)
- Syntax highlighting for code blocks
- Theme system (10+ built-in themes)
- Scrollback history (configurable limit)
- Word wrap and visual selection
## Development Guidelines
### Code Style
1. **Rust Best Practices**
- Use `rustfmt` (pre-commit hook enforced)
- Run `cargo clippy` before commits
- Prefer `Result` over `panic!` for errors
- Document public APIs with `///` comments
2. **Error Handling**
- Use `owlen_core::Error` enum for all errors
- Chain errors with context (`.map_err(|e| Error::X(format!(...)))`)
- Never unwrap in library code (tests OK)
3. **Async Patterns**
- All I/O operations must be async
- Use `tokio::spawn` for background tasks
- Prefer `tokio::sync::mpsc` for channels
- Always set timeouts for network operations
4. **Testing**
- Unit tests in same file (`#[cfg(test)] mod tests`)
- Use mock implementations from `test_utils` modules
- Integration tests in `crates/*/tests/`
- All public APIs must have tests
### File Organization
**When editing existing files:**
1. Read the entire file first (use `Read` tool)
2. Preserve existing code style and formatting
3. Update related tests in the same commit
4. Keep changes atomic and focused
**When creating new files:**
1. Check `crates/owlen-core/src/` for similar modules
2. Follow existing module structure
3. Add to `lib.rs` with appropriate visibility
4. Document module purpose with `//!` header
### Configuration
**Config file**: `~/.config/owlen/config.toml`
Example structure:
```toml
[general]
default_provider = "ollama"
default_model = "llama3.2:latest"
enable_streaming = true
[mcp]
# MCP is always enabled in v1.0+
[providers.ollama]
provider_type = "ollama"
base_url = "http://localhost:11434"
[providers.ollama-cloud]
provider_type = "ollama-cloud"
base_url = "https://ollama.com"
api_key = "$OLLAMA_API_KEY"
[ui]
theme = "default_dark"
word_wrap = true
[security]
enable_sandboxing = true
allowed_tools = ["web_search", "code_exec"]
```
### Common Tasks
#### Adding a New Provider
1. Create `crates/owlen-{provider}/` crate
2. Implement `owlen_core::provider::Provider` trait
3. Add to `owlen_core::router::ProviderRouter`
4. Update config schema in `owlen_core::config`
5. Add tests with `MockProvider` pattern
6. Document in `docs/provider-implementation.md`
#### Adding a New MCP Server
1. Create `crates/owlen-mcp-{name}-server/` crate
2. Implement JSON-RPC 2.0 protocol handlers
3. Define tool descriptors with JSON schemas
4. Add sandboxing/security checks
5. Register in `mcp_servers` config array
6. Document tool capabilities
#### Adding a TUI Feature
1. Modify `crates/owlen-tui/src/chat_app.rs`
2. Update keybinding handlers
3. Extend UI rendering in `draw()` method
4. Add to help screen (`?` command)
5. Test with different terminal sizes
6. Ensure theme compatibility
## Feature Parity Roadmap
Based on analysis of OpenAI Codex and Claude Code, here are prioritized features to implement:
### Phase 11: MCP Client Enhancement (HIGHEST PRIORITY)
**Goal**: Full MCP client capabilities to access ecosystem tools
**Features:**
1. **MCP Server Management**
- `owlen mcp add/list/remove` commands
- Three config scopes: local, project (`.mcp.json`), user
- Environment variable expansion in config
- OAuth 2.0 authentication for remote servers
2. **MCP Resource References**
- `@github:issue://123` syntax
- `@postgres:schema://users` syntax
- Auto-completion for resources
3. **MCP Prompts as Slash Commands**
- `/mcp__github__list_prs`
- Dynamic command registration
**Implementation:**
- Extend `owlen-mcp-client` crate
- Add `.mcp.json` parsing to `owlen-core::config`
- Update TUI command parser for `@` and `/mcp__` syntax
- Add OAuth flow to TUI
**Files to modify:**
- `crates/owlen-mcp-client/src/lib.rs`
- `crates/owlen-core/src/config.rs`
- `crates/owlen-tui/src/command_parser.rs`
### Phase 12: Approval & Sandbox System (HIGHEST PRIORITY)
**Goal**: Safe agentic behavior with user control
**Features:**
1. **Three-tier Approval Modes**
- `suggest`: Approve ALL file writes and shell commands (default)
- `auto-edit`: Auto-approve file changes, prompt for shell
- `full-auto`: Auto-approve everything (requires Git repo)
2. **Platform-specific Sandboxing**
- Linux: Docker with network isolation
- macOS: Apple Seatbelt (`sandbox-exec`)
- Windows: AppContainer or Job Objects
3. **Permission Management**
- `/permissions` command in TUI
- Tool allowlist (e.g., `Edit`, `Bash(git commit:*)`)
- Stored in `.owlen/settings.json` (project) or `~/.owlen.json` (user)
**Implementation:**
- New `owlen-core::approval` module
- Extend `owlen-core::sandbox` with platform detection
- Update `owlen-mcp-code-server` to use new sandbox
- Add permission storage to config system
**Files to create:**
- `crates/owlen-core/src/approval.rs`
- `crates/owlen-core/src/sandbox/linux.rs`
- `crates/owlen-core/src/sandbox/macos.rs`
- `crates/owlen-core/src/sandbox/windows.rs`
### Phase 13: Project Documentation System (HIGH PRIORITY)
**Goal**: Massive usability improvement with project context
**Features:**
1. **OWLEN.md System**
- `OWLEN.md` at repo root (checked into git)
- `OWLEN.local.md` (gitignored, personal)
- `~/.config/owlen/OWLEN.md` (global)
- Support nested OWLEN.md in monorepos
2. **Auto-generation**
- `/init` command to generate project-specific OWLEN.md
- Analyze codebase structure
- Detect build system, test framework
- Suggest common commands
3. **Live Updates**
- `#` command to add instructions to OWLEN.md
- Context-aware insertion (relevant section)
**Contents of OWLEN.md:**
- Common bash commands
- Code style guidelines
- Testing instructions
- Core files and utilities
- Known quirks/warnings
**Implementation:**
- New `owlen-core::project_doc` module
- File discovery algorithm (walk up directory tree)
- Markdown parser for sections
- TUI commands: `/init`, `#`
**Files to create:**
- `crates/owlen-core/src/project_doc.rs`
- `crates/owlen-tui/src/commands/init.rs`
### Phase 14: Non-Interactive Mode (HIGH PRIORITY)
**Goal**: Enable CI/CD integration and automation
**Features:**
1. **Headless Execution**
```bash
owlen exec "fix linting errors" --approval-mode auto-edit
owlen --quiet "update CHANGELOG" --json
```
2. **Environment Variables**
- `OWLEN_QUIET_MODE=1`
- `OWLEN_DISABLE_PROJECT_DOC=1`
- `OWLEN_APPROVAL_MODE=full-auto`
3. **JSON Output**
- Structured output for parsing
- Exit codes for success/failure
- Progress events on stderr
**Implementation:**
- New `owlen-cli` subcommand: `exec`
- Extend `owlen-core::session` with non-interactive mode
- Add JSON serialization for results
- Environment variable parsing in config
**Files to modify:**
- `crates/owlen-cli/src/main.rs`
- `crates/owlen-core/src/session.rs`
### Phase 15: Multi-Provider Expansion (HIGH PRIORITY)
**Goal**: Support cloud providers while maintaining local-first
**Providers to add:**
1. OpenAI (GPT-4, o1, o4-mini)
2. Anthropic (Claude 3.5 Sonnet, Opus)
3. Google (Gemini Ultra, Pro)
4. Mistral AI
**Configuration:**
```toml
[providers.openai]
api_key = "${OPENAI_API_KEY}"
model = "o4-mini"
enabled = true
[providers.anthropic]
api_key = "${ANTHROPIC_API_KEY}"
model = "claude-3-5-sonnet"
enabled = true
```
**Runtime Switching:**
```
:model ollama/starcoder
:model openai/o4-mini
:model anthropic/claude-3-5-sonnet
```
**Implementation:**
- Create `owlen-openai`, `owlen-anthropic`, `owlen-google` crates
- Implement `Provider` trait for each
- Add runtime model switching to TUI
- Maintain Ollama as default
**Files to create:**
- `crates/owlen-openai/src/lib.rs`
- `crates/owlen-anthropic/src/lib.rs`
- `crates/owlen-google/src/lib.rs`
### Phase 16: Custom Slash Commands (MEDIUM PRIORITY)
**Goal**: User and team-defined workflows
**Features:**
1. **Command Directories**
- `~/.owlen/commands/` (user, available everywhere)
- `.owlen/commands/` (project, checked into git)
- Support `$ARGUMENTS` keyword
2. **Example Structure**
```markdown
# .owlen/commands/fix-github-issue.md
Please analyze and fix GitHub issue: $ARGUMENTS.
1. Use `gh issue view` to get details
2. Implement changes
3. Write and run tests
4. Create PR
```
3. **TUI Integration**
- Auto-complete for custom commands
- Help text from command files
- Parameter validation
**Implementation:**
- New `owlen-core::commands` module
- Command discovery and parsing
- Template expansion
- TUI command registration
**Files to create:**
- `crates/owlen-core/src/commands.rs`
- `crates/owlen-tui/src/commands/custom.rs`
### Phase 17: Plugin System (MEDIUM PRIORITY)
**Goal**: One-command installation of tool collections
**Features:**
1. **Plugin Structure**
```json
{
"name": "github-workflow",
"version": "1.0.0",
"commands": [
{"name": "pr", "file": "commands/pr.md"}
],
"mcp_servers": [
{
"name": "github",
"command": "${OWLEN_PLUGIN_ROOT}/bin/github-mcp"
}
]
}
```
2. **Installation**
```bash
owlen plugin install github-workflow
owlen plugin list
owlen plugin remove github-workflow
```
3. **Discovery**
- `~/.owlen/plugins/` directory
- Git repository URLs
- Plugin registry (future)
**Implementation:**
- New `owlen-core::plugins` module
- Plugin manifest parser
- Installation/removal logic
- Sandboxing for plugin code
**Files to create:**
- `crates/owlen-core/src/plugins.rs`
- `crates/owlen-cli/src/commands/plugin.rs`
### Phase 18: Extended Thinking Modes (MEDIUM PRIORITY)
**Goal**: Progressive computation budgets for complex tasks
**Modes:**
- `think` - basic extended thinking
- `think hard` - increased computation
- `think harder` - more computation
- `ultrathink` - maximum budget
**Implementation:**
- Extend `owlen-core::types::ChatParameters`
- Add thinking mode to TUI commands
- Configure per-provider max tokens
**Files to modify:**
- `crates/owlen-core/src/types.rs`
- `crates/owlen-tui/src/command_parser.rs`
### Phase 19: Git Workflow Automation (MEDIUM PRIORITY)
**Goal**: Streamline common Git operations
**Features:**
1. Auto-commit message generation
2. PR creation via `gh` CLI
3. Rebase conflict resolution
4. File revert operations
5. Git history analysis
**Implementation:**
- New `owlen-mcp-git-server` crate
- Tools: `commit`, `create_pr`, `rebase`, `revert`, `history`
- Integration with TUI commands
**Files to create:**
- `crates/owlen-mcp-git-server/src/lib.rs`
### Phase 20: Enterprise Features (LOW PRIORITY)
**Goal**: Team and enterprise deployment support
**Features:**
1. **Managed Configuration**
- `/etc/owlen/managed-mcp.json` (Linux)
- Restrict user additions with `useEnterpriseMcpConfigOnly`
2. **Audit Logging**
- Log all file writes and shell commands
- Structured JSON logs
- Tamper-proof storage
3. **Team Collaboration**
- Shared OWLEN.md across team
- Project-scoped MCP servers in `.mcp.json`
- Approval policy enforcement
**Implementation:**
- Extend `owlen-core::config` with managed settings
- New `owlen-core::audit` module
- Enterprise deployment documentation
## Testing Requirements
### Test Coverage Goals
- **Unit tests**: 80%+ coverage for `owlen-core`
- **Integration tests**: All MCP servers, providers
- **TUI tests**: Key workflows (not pixel-perfect)
### Test Organization
```rust
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::test_utils::MockProvider;
use crate::mcp::test_utils::MockMcpClient;
#[test]
fn test_feature() {
// Setup
let provider = MockProvider::new();
// Execute
let result = provider.chat(request).await;
// Assert
assert!(result.is_ok());
}
}
```
### Running Tests
```bash
cargo test --all # All tests
cargo test --lib -p owlen-core # Core library tests
cargo test --test integration # Integration tests
```
## Documentation Standards
### Code Documentation
1. **Module-level** (`//!` at top of file):
```rust
//! Brief module description
//!
//! Detailed explanation of module purpose,
//! key types, and usage examples.
```
2. **Public APIs** (`///` above items):
```rust
/// Brief description
///
/// # Arguments
/// * `arg1` - Description
///
/// # Returns
/// Description of return value
///
/// # Errors
/// When this function returns an error
///
/// # Example
/// ```
/// let result = function(arg);
/// ```
pub fn function(arg: Type) -> Result<Output> {
// implementation
}
```
3. **Private items**: Optional, use for complex logic
### User Documentation
Location: `docs/` directory
Files to maintain:
- `architecture.md` - System design
- `configuration.md` - Config reference
- `migration-guide.md` - Version upgrades
- `troubleshooting.md` - Common issues
- `provider-implementation.md` - Adding providers
- `faq.md` - Frequently asked questions
## Git Workflow
### Branch Strategy
- `main` - stable releases only
- `dev` - active development (default)
- `feature/*` - new features
- `fix/*` - bug fixes
- `docs/*` - documentation only
### Commit Messages
Follow conventional commits:
```
type(scope): brief description
Detailed explanation of changes.
Breaking changes, if any.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
```
Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
### Pre-commit Hooks
Automatically run:
- `cargo fmt` (formatting)
- `cargo check` (compilation)
- `cargo clippy` (linting)
- YAML/TOML validation
- Trailing whitespace removal
## Performance Guidelines
### Optimization Priorities
1. **Startup time**: < 500ms cold start
2. **First token latency**: < 2s for local models
3. **Memory usage**: < 100MB base, < 500MB with conversation
4. **Responsiveness**: TUI redraws < 16ms (60 FPS)
### Profiling
```bash
cargo build --release --features profiling
valgrind --tool=callgrind target/release/owlen
kcachegrind callgrind.out.*
```
### Async Performance
- Avoid blocking in async contexts
- Use `tokio::spawn` for CPU-intensive work
- Set timeouts on all network operations
- Cancel tasks on shutdown
## Security Considerations
### Threat Model
**Trusted:**
- User's local machine
- User-installed Ollama models
- User configuration files
**Untrusted:**
- MCP server responses
- Web search results
- Code execution output
- Cloud LLM responses
### Security Measures
1. **Input Validation**
- Sanitize all MCP tool arguments
- Validate JSON schemas strictly
- Escape shell commands
2. **Sandboxing**
- Docker for code execution
- Network isolation
- Filesystem restrictions
3. **Secrets Management**
- Never log API keys
- Use environment variables
- Encrypt sensitive config fields
4. **Dependency Auditing**
```bash
cargo audit
cargo deny check
```
## Debugging Tips
### Enable Debug Logging
```bash
OWLEN_DEBUG_OLLAMA=1 owlen # Ollama requests
RUST_LOG=debug owlen # All debug logs
RUST_BACKTRACE=1 owlen # Stack traces
```
### Common Issues
1. **Timeout on Ollama**
- Check `ollama ps` for loaded models
- Increase timeout in config
- Restart Ollama service
2. **MCP Server Not Found**
- Verify `mcp_servers` config
- Check server binary exists
- Test server manually with STDIO
3. **TUI Rendering Issues**
- Test in different terminals
- Check terminal size (`tput cols; tput lines`)
- Verify theme compatibility
## Contributing
### Before Submitting PR
1. Run full test suite: `cargo test --all`
2. Check formatting: `cargo fmt -- --check`
3. Run linter: `cargo clippy -- -D warnings`
4. Update documentation if API changed
5. Add tests for new features
6. Update CHANGELOG.md
### PR Description Template
```markdown
## Summary
Brief description of changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
Describe tests performed
## Checklist
- [ ] Tests added/updated
- [ ] Documentation updated
- [ ] CHANGELOG.md updated
- [ ] No clippy warnings
```
## Resources
### External Documentation
- [Ratatui Docs](https://ratatui.rs/)
- [Tokio Tutorial](https://tokio.rs/tokio/tutorial)
- [MCP Specification](https://modelcontextprotocol.io/)
- [Ollama API](https://github.com/ollama/ollama/blob/main/docs/api.md)
### Internal Documentation
- `.agents/new_phases.md` - 10-phase migration plan (completed)
- `docs/phase5-mode-system.md` - Mode system design
- `docs/migration-guide.md` - v0.x → v1.0 migration
### Community
- GitHub Issues: Bug reports and feature requests
- GitHub Discussions: Questions and ideas
- AUR Package: `owlen-git` (Arch Linux)
## Version History
- **v1.0.0** (current) - MCP-only architecture, Phase 10 complete
- **v0.2.0** - Added web search, code execution servers
- **v0.1.0** - Initial release with Ollama support
## License
Owlen is open source software. See LICENSE file for details.
---
**Last Updated**: 2025-10-11
**Maintained By**: Owlen Development Team
**For AI Agents**: Follow these guidelines when modifying Owlen codebase. Prioritize MCP client enhancement (Phase 11) and approval system (Phase 12) for feature parity with Codex/Claude Code while maintaining local-first philosophy.

View File

@@ -11,12 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comprehensive documentation suite including guides for architecture, configuration, testing, and more.
- Rustdoc examples for core components like `Provider` and `SessionController`.
- Module-level documentation for `owlen-tui`.
- Ollama integration can now talk to Ollama Cloud when an API key is configured.
- Ollama provider will also read `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables when no key is stored in the config.
### Changed
- The main `README.md` has been updated to be more concise and link to the new documentation.
- Default configuration now pre-populates both `providers.ollama` and `providers.ollama-cloud` entries so switching between local and cloud backends is a single setting change.
---

View File

@@ -5,11 +5,6 @@ members = [
"crates/owlen-tui",
"crates/owlen-cli",
"crates/owlen-ollama",
"crates/owlen-mcp-server",
"crates/owlen-mcp-llm-server",
"crates/owlen-mcp-client",
"crates/owlen-mcp-code-server",
"crates/owlen-mcp-prompt-server",
]
exclude = []
@@ -39,24 +34,12 @@ tui-textarea = "0.6"
# HTTP client and JSON handling
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
serde_json = "1.0"
# Utilities
uuid = { version = "1.0", features = ["v4", "serde"] }
anyhow = "1.0"
thiserror = "1.0"
nix = "0.29"
which = "6.0"
tempfile = "3.8"
jsonschema = "0.17"
aes-gcm = "0.10"
ring = "0.17"
keyring = "3.0"
chrono = { version = "0.4", features = ["serde"] }
urlencoding = "2.1"
regex = "1.10"
rpassword = "7.3"
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] }
# Configuration
toml = "0.8"
@@ -75,6 +58,7 @@ async-trait = "0.1"
clap = { version = "4.0", features = ["derive"] }
# Dev dependencies
tempfile = "3.8"
tokio-test = "0.4"
# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -10,7 +10,8 @@ description = "Command-line interface for OWLEN LLM client"
[features]
default = ["chat-client"]
chat-client = ["owlen-tui"]
chat-client = []
code-client = []
[[bin]]
name = "owlen"
@@ -18,14 +19,14 @@ path = "src/main.rs"
required-features = ["chat-client"]
[[bin]]
name = "owlen-agent"
path = "src/agent_main.rs"
required-features = ["chat-client"]
name = "owlen-code"
path = "src/code_main.rs"
required-features = ["code-client"]
[dependencies]
owlen-core = { path = "../owlen-core" }
# Optional TUI dependency, enabled by the "chat-client" feature.
owlen-tui = { path = "../owlen-tui", optional = true }
owlen-tui = { path = "../owlen-tui" }
owlen-ollama = { path = "../owlen-ollama" }
# CLI framework
clap = { version = "4.0", features = ["derive"] }
@@ -42,6 +43,3 @@ crossterm = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
regex = "1"
thiserror = "1"
dirs = "5"

View File

@@ -1,61 +0,0 @@
//! Simple entry point for the ReAct agentic executor.
//!
//! Usage: `owlen-agent "<prompt>" [--model <model>] [--max-iter <n>]`
//!
//! This binary demonstrates Phase4 without the full TUI. It creates an
//! OllamaProvider, a RemoteMcpClient, runs the AgentExecutor and prints the
//! final answer.
use std::sync::Arc;
use clap::Parser;
use owlen_cli::agent::{AgentConfig, AgentExecutor};
use owlen_core::mcp::remote_client::RemoteMcpClient;
/// Commandline arguments for the agent binary.
#[derive(Parser, Debug)]
#[command(
name = "owlen-agent",
author,
version,
about = "Run the ReAct agent via MCP"
)]
struct Args {
/// The initial user query.
prompt: String,
/// Model to use (defaults to Ollama default).
#[arg(long)]
model: Option<String>,
/// Maximum ReAct iterations.
#[arg(long, default_value_t = 10)]
max_iter: usize,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
// Initialise the MCP LLM client it implements Provider and talks to the
// MCP LLM server which wraps Ollama. This ensures all communication goes
// through the MCP architecture (Phase 10 requirement).
let provider = Arc::new(RemoteMcpClient::new()?);
// The MCP client also serves as the tool client for resource operations
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
max_iterations: args.max_iter,
model: args.model.unwrap_or_else(|| "llama3.2:latest".to_string()),
..AgentConfig::default()
};
let executor = AgentExecutor::new(provider, mcp_client, config);
match executor.run(args.prompt).await {
Ok(result) => {
println!("\n✓ Agent completed in {} iterations", result.iterations);
println!("\nFinal answer:\n{}", result.answer);
Ok(())
}
Err(e) => Err(anyhow::anyhow!(e)),
}
}

View File

@@ -0,0 +1,103 @@
//! OWLEN Code Mode - TUI client optimized for coding assistance
use anyhow::Result;
use clap::{Arg, Command};
use owlen_core::session::SessionController;
use owlen_ollama::OllamaProvider;
use owlen_tui::{config, ui, AppState, CodeApp, Event, EventHandler, SessionEvent};
use std::io;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
#[tokio::main]
async fn main() -> Result<()> {
let matches = Command::new("owlen-code")
.about("OWLEN Code Mode - TUI optimized for programming assistance")
.version(env!("CARGO_PKG_VERSION"))
.arg(
Arg::new("model")
.short('m')
.long("model")
.value_name("MODEL")
.help("Preferred model to use for this session"),
)
.get_matches();
let mut config = config::try_load_config().unwrap_or_default();
if let Some(model) = matches.get_one::<String>("model") {
config.general.default_model = Some(model.clone());
}
let provider_cfg = config::ensure_ollama_config(&mut config).clone();
let provider = Arc::new(OllamaProvider::from_config(
&provider_cfg,
Some(&config.general),
)?);
let controller = SessionController::new(provider, config.clone());
let (mut app, mut session_rx) = CodeApp::new(controller);
app.inner_mut().initialize_models().await?;
let cancellation_token = CancellationToken::new();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let event_handler = EventHandler::new(event_tx, cancellation_token.clone());
let event_handle = tokio::spawn(async move { event_handler.run().await });
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_app(&mut terminal, &mut app, event_rx, &mut session_rx).await;
cancellation_token.cancel();
event_handle.await?;
config::save_config(app.inner().config())?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = result {
println!("{err:?}");
}
Ok(())
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut CodeApp,
mut event_rx: mpsc::UnboundedReceiver<Event>,
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
) -> Result<()> {
loop {
terminal.draw(|f| ui::render_chat(f, app.inner_mut()))?;
tokio::select! {
Some(event) = event_rx.recv() => {
if let AppState::Quit = app.handle_event(event).await? {
return Ok(());
}
}
Some(session_event) = session_rx.recv() => {
app.handle_session_event(session_event)?;
}
}
}
}

View File

@@ -1,8 +0,0 @@
//! Library portion of the `owlen-cli` crate.
//!
//! It currently only reexports the `agent` module used by the standalone
//! `owlen-agent` binary. Additional shared functionality can be added here in
//! the future.
// Re-export agent module from owlen-core
pub use owlen_core::agent;

View File

@@ -1,12 +1,9 @@
//! OWLEN CLI - Chat TUI client
use anyhow::Result;
use clap::Parser;
use owlen_core::{
mcp::remote_client::RemoteMcpClient, mode::Mode, session::SessionController,
storage::StorageManager, Provider,
};
use owlen_tui::tui_controller::{TuiController, TuiRequest};
use clap::{Arg, Command};
use owlen_core::session::SessionController;
use owlen_ollama::OllamaProvider;
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
use std::io;
use std::sync::Arc;
@@ -18,53 +15,39 @@ use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{prelude::CrosstermBackend, Terminal};
use ratatui::{backend::CrosstermBackend, Terminal};
/// Owlen - Terminal UI for LLM chat
#[derive(Parser, Debug)]
#[command(name = "owlen")]
#[command(about = "Terminal UI for LLM chat via MCP", long_about = None)]
struct Args {
/// Start in code mode (enables all tools)
#[arg(long, short = 'c')]
code: bool,
}
#[tokio::main(flavor = "multi_thread")]
#[tokio::main]
async fn main() -> Result<()> {
// Parse command-line arguments
let args = Args::parse();
let initial_mode = if args.code { Mode::Code } else { Mode::Chat };
let matches = Command::new("owlen")
.about("OWLEN - A chat-focused TUI client for Ollama")
.version(env!("CARGO_PKG_VERSION"))
.arg(
Arg::new("model")
.short('m')
.long("model")
.value_name("MODEL")
.help("Preferred model to use for this session"),
)
.get_matches();
// Set auto-consent for TUI mode to prevent blocking stdin reads
std::env::set_var("OWLEN_AUTO_CONSENT", "1");
let mut config = config::try_load_config().unwrap_or_default();
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
let tui_controller = Arc::new(TuiController::new(tui_tx));
if let Some(model) = matches.get_one::<String>("model") {
config.general.default_model = Some(model.clone());
}
// Load configuration (or fall back to defaults) for the session controller.
let mut cfg = config::try_load_config().unwrap_or_default();
// Disable encryption for CLI to avoid password prompts in this environment.
cfg.privacy.encrypt_local_data = false;
// Prepare provider from configuration
let provider_cfg = config::ensure_ollama_config(&mut config).clone();
let provider = Arc::new(OllamaProvider::from_config(
&provider_cfg,
Some(&config.general),
)?);
// Create MCP LLM client as the provider (replaces direct OllamaProvider usage)
let provider: Arc<dyn Provider> = if let Some(mcp_server) = cfg.mcp_servers.first() {
// Use configured MCP server if available
Arc::new(RemoteMcpClient::new_with_config(mcp_server)?)
} else {
// Fall back to default MCP LLM server discovery
Arc::new(RemoteMcpClient::new()?)
};
let storage = Arc::new(StorageManager::new().await?);
let controller =
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
let controller = SessionController::new(provider, config.clone());
let (mut app, mut session_rx) = ChatApp::new(controller);
app.initialize_models().await?;
// Set the initial mode
app.set_mode(initial_mode).await;
// Event infrastructure
let cancellation_token = CancellationToken::new();
let (event_tx, event_rx) = mpsc::unbounded_channel();
@@ -90,7 +73,7 @@ async fn main() -> Result<()> {
event_handle.await?;
// Persist configuration updates (e.g., selected model)
config::save_config(&app.config())?;
config::save_config(app.config())?;
disable_raw_mode()?;
execute!(
@@ -121,14 +104,7 @@ async fn run_app(
terminal.draw(|f| ui::render_chat(f, app))?;
// Process any pending LLM requests AFTER UI has been drawn
if let Err(e) = app.process_pending_llm_request().await {
eprintln!("Error processing LLM request: {}", e);
}
// Process any pending tool executions AFTER UI has been drawn
if let Err(e) = app.process_pending_tool_execution().await {
eprintln!("Error processing tool execution: {}", e);
}
app.process_pending_llm_request().await?;
tokio::select! {
Some(event) = event_rx.recv() => {

View File

@@ -1,266 +0,0 @@
//! Integration tests for the ReAct agent loop functionality.
//!
//! These tests verify that the agent executor correctly:
//! - Parses ReAct formatted responses
//! - Executes tool calls
//! - Handles multi-step workflows
//! - Recovers from errors
//! - Respects iteration limits
use owlen_cli::agent::{AgentConfig, AgentExecutor, LlmResponse};
use owlen_core::mcp::remote_client::RemoteMcpClient;
use std::sync::Arc;
#[tokio::test]
async fn test_react_parsing_tool_call() {
let executor = create_test_executor();
// Test parsing a tool call with JSON arguments
let text = "THOUGHT: I should search for information\nACTION: web_search\nACTION_INPUT: {\"query\": \"rust async programming\"}\n";
let result = executor.parse_response(text);
match result {
Ok(LlmResponse::ToolCall {
thought,
tool_name,
arguments,
}) => {
assert_eq!(thought, "I should search for information");
assert_eq!(tool_name, "web_search");
assert_eq!(arguments["query"], "rust async programming");
}
other => panic!("Expected ToolCall, got: {:?}", other),
}
}
#[tokio::test]
async fn test_react_parsing_final_answer() {
let executor = create_test_executor();
let text = "THOUGHT: I have enough information now\nACTION: final_answer\nACTION_INPUT: The answer is 42\n";
let result = executor.parse_response(text);
match result {
Ok(LlmResponse::FinalAnswer { thought, answer }) => {
assert_eq!(thought, "I have enough information now");
assert_eq!(answer, "The answer is 42");
}
other => panic!("Expected FinalAnswer, got: {:?}", other),
}
}
#[tokio::test]
async fn test_react_parsing_with_multiline_thought() {
let executor = create_test_executor();
let text = "THOUGHT: This is a complex\nmulti-line thought\nACTION: list_files\nACTION_INPUT: {\"path\": \".\"}\n";
let result = executor.parse_response(text);
// The regex currently only captures until first newline
// This test documents current behavior
match result {
Ok(LlmResponse::ToolCall { thought, .. }) => {
// Regex pattern stops at first \n after THOUGHT:
assert!(thought.contains("This is a complex"));
}
other => panic!("Expected ToolCall, got: {:?}", other),
}
}
#[tokio::test]
#[ignore] // Requires MCP LLM server to be running
async fn test_agent_single_tool_scenario() {
// This test requires a running MCP LLM server (which wraps Ollama)
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
max_iterations: 5,
model: "llama3.2".to_string(),
temperature: Some(0.7),
max_tokens: None,
};
let executor = AgentExecutor::new(provider, mcp_client, config);
// Simple query that should complete in one tool call
let result = executor
.run("List files in the current directory".to_string())
.await;
match result {
Ok(agent_result) => {
assert!(
!agent_result.answer.is_empty(),
"Answer should not be empty"
);
println!("Agent answer: {}", agent_result.answer);
}
Err(e) => {
// It's okay if this fails due to LLM not following format
println!("Agent test skipped: {}", e);
}
}
}
#[tokio::test]
#[ignore] // Requires Ollama to be running
async fn test_agent_multi_step_workflow() {
// Test a query that requires multiple tool calls
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
max_iterations: 10,
model: "llama3.2".to_string(),
temperature: Some(0.5), // Lower temperature for more consistent behavior
max_tokens: None,
};
let executor = AgentExecutor::new(provider, mcp_client, config);
// Query requiring multiple steps: list -> read -> analyze
let result = executor
.run("Find all Rust files and tell me which one contains 'Agent'".to_string())
.await;
match result {
Ok(agent_result) => {
assert!(!agent_result.answer.is_empty());
println!("Multi-step answer: {:?}", agent_result);
}
Err(e) => {
println!("Multi-step test skipped: {}", e);
}
}
}
#[tokio::test]
#[ignore] // Requires Ollama
async fn test_agent_iteration_limit() {
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
max_iterations: 2, // Very low limit to test enforcement
model: "llama3.2".to_string(),
temperature: Some(0.7),
max_tokens: None,
};
let executor = AgentExecutor::new(provider, mcp_client, config);
// Complex query that would require many iterations
let result = executor
.run("Perform an exhaustive analysis of all files".to_string())
.await;
// Should hit the iteration limit (or parse error if LLM doesn't follow format)
match result {
Err(e) => {
let error_str = format!("{}", e);
// Accept either iteration limit error or parse error (LLM didn't follow ReAct format)
assert!(
error_str.contains("Maximum iterations")
|| error_str.contains("2")
|| error_str.contains("parse"),
"Expected iteration limit or parse error, got: {}",
error_str
);
println!("Test passed: agent stopped with error: {}", error_str);
}
Ok(_) => {
// It's possible the LLM completed within 2 iterations
println!("Agent completed within iteration limit");
}
}
}
#[tokio::test]
#[ignore] // Requires Ollama
async fn test_agent_tool_budget_enforcement() {
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
max_iterations: 3, // Very low iteration limit to enforce budget
model: "llama3.2".to_string(),
temperature: Some(0.7),
max_tokens: None,
};
let executor = AgentExecutor::new(provider, mcp_client, config);
// Query that would require many tool calls
let result = executor
.run("Read every file in the project and summarize them all".to_string())
.await;
// Should hit the tool call budget (or parse error if LLM doesn't follow format)
match result {
Err(e) => {
let error_str = format!("{}", e);
// Accept either budget error or parse error (LLM didn't follow ReAct format)
assert!(
error_str.contains("Maximum iterations")
|| error_str.contains("budget")
|| error_str.contains("parse"),
"Expected budget or parse error, got: {}",
error_str
);
println!("Test passed: agent stopped with error: {}", error_str);
}
Ok(_) => {
println!("Agent completed within tool budget");
}
}
}
// Helper function to create a test executor
// For parsing tests, we don't need a real connection
fn create_test_executor() -> AgentExecutor {
// For parsing tests, we can accept the error from RemoteMcpClient::new()
// since we're only testing parse_response which doesn't use the MCP client
let provider = match RemoteMcpClient::new() {
Ok(client) => Arc::new(client),
Err(_) => {
// If MCP server binary doesn't exist, parsing tests can still run
// by using a dummy client that will never be called
// This is a workaround for unit tests that only need parse_response
panic!("MCP server binary not found - build the project first with: cargo build --all");
}
};
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig::default();
AgentExecutor::new(provider, mcp_client, config)
}
#[test]
fn test_agent_config_defaults() {
let config = AgentConfig::default();
assert_eq!(config.max_iterations, 10);
assert_eq!(config.model, "ollama");
assert_eq!(config.temperature, Some(0.7));
// max_tool_calls field removed - agent now tracks iterations instead
}
#[test]
fn test_agent_config_custom() {
let config = AgentConfig {
max_iterations: 15,
model: "custom-model".to_string(),
temperature: Some(0.5),
max_tokens: Some(2000),
};
assert_eq!(config.max_iterations, 15);
assert_eq!(config.model, "custom-model");
assert_eq!(config.temperature, Some(0.5));
assert_eq!(config.max_tokens, Some(2000));
}

View File

@@ -9,42 +9,23 @@ homepage.workspace = true
description = "Core traits and types for OWLEN LLM client"
[dependencies]
anyhow = { workspace = true }
anyhow = "1.0.75"
log = "0.4.20"
regex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.105"
thiserror = "1.0.48"
tokio = { version = "1.32.0", features = ["full"] }
unicode-segmentation = "1.11"
unicode-width = "0.1"
uuid = { workspace = true }
textwrap = { workspace = true }
futures = { workspace = true }
async-trait = { workspace = true }
toml = { workspace = true }
shellexpand = { workspace = true }
uuid = { version = "1.4.1", features = ["v4", "serde"] }
textwrap = "0.16.0"
futures = "0.3.28"
async-trait = "0.1.73"
toml = "0.8.0"
shellexpand = "3.1.0"
dirs = "5.0"
ratatui = { workspace = true }
tempfile = { workspace = true }
jsonschema = { workspace = true }
which = { workspace = true }
nix = { workspace = true }
aes-gcm = { workspace = true }
ring = { workspace = true }
keyring = { workspace = true }
chrono = { workspace = true }
crossterm = { workspace = true }
urlencoding = { workspace = true }
rpassword = { workspace = true }
sqlx = { workspace = true }
duckduckgo = "0.2.0"
reqwest = { workspace = true, features = ["default"] }
reqwest_011 = { version = "0.11", package = "reqwest" }
path-clean = "1.0"
tokio-stream = "0.1"
tokio-tungstenite = "0.21"
tungstenite = "0.21"
[dev-dependencies]
tokio-test = { workspace = true }
tempfile = { workspace = true }

View File

@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
model TEXT NOT NULL,
message_count INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at DESC);

View File

@@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS secure_items (
key TEXT PRIMARY KEY,
nonce BLOB NOT NULL,
ciphertext BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);

View File

@@ -1,421 +0,0 @@
//! Agentic execution loop with ReAct pattern support.
//!
//! This module provides the core agent orchestration logic that allows an LLM
//! to reason about tasks, execute tools, and observe results in an iterative loop.
use crate::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::provider::Provider;
use crate::types::{ChatParameters, ChatRequest, Message};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// Maximum number of agent iterations before stopping
const DEFAULT_MAX_ITERATIONS: usize = 15;
/// Parsed response from the LLM in ReAct format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LlmResponse {
/// LLM wants to execute a tool
ToolCall {
thought: String,
tool_name: String,
arguments: serde_json::Value,
},
/// LLM has reached a final answer
FinalAnswer { thought: String, answer: String },
/// LLM is just reasoning without taking action
Reasoning { thought: String },
}
/// Parse error when LLM response doesn't match expected format
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("No recognizable pattern found in response")]
NoPattern,
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Invalid JSON in ACTION_INPUT: {0}")]
InvalidJson(String),
}
/// Result of an agent execution
#[derive(Debug, Clone)]
pub struct AgentResult {
/// Final answer from the agent
pub answer: String,
/// Number of iterations taken
pub iterations: usize,
/// All messages exchanged during execution
pub messages: Vec<Message>,
/// Whether the agent completed successfully
pub success: bool,
}
/// Configuration for agent execution
#[derive(Debug, Clone)]
pub struct AgentConfig {
/// Maximum number of iterations
pub max_iterations: usize,
/// Model to use for reasoning
pub model: String,
/// Temperature for LLM sampling
pub temperature: Option<f32>,
/// Max tokens per LLM call
pub max_tokens: Option<u32>,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
max_iterations: DEFAULT_MAX_ITERATIONS,
model: "llama3.2:latest".to_string(),
temperature: Some(0.7),
max_tokens: Some(4096),
}
}
}
/// Agent executor that orchestrates the ReAct loop
pub struct AgentExecutor {
/// LLM provider for reasoning
llm_client: Arc<dyn Provider>,
/// MCP client for tool execution
tool_client: Arc<dyn McpClient>,
/// Agent configuration
config: AgentConfig,
}
impl AgentExecutor {
/// Create a new agent executor
pub fn new(
llm_client: Arc<dyn Provider>,
tool_client: Arc<dyn McpClient>,
config: AgentConfig,
) -> Self {
Self {
llm_client,
tool_client,
config,
}
}
/// Run the agent loop with the given query
pub async fn run(&self, query: String) -> Result<AgentResult> {
let mut messages = vec![Message::user(query)];
let tools = self.discover_tools().await?;
for iteration in 0..self.config.max_iterations {
let prompt = self.build_react_prompt(&messages, &tools);
let response = self.generate_llm_response(prompt).await?;
match self.parse_response(&response)? {
LlmResponse::ToolCall {
thought,
tool_name,
arguments,
} => {
// Add assistant's reasoning
messages.push(Message::assistant(format!(
"THOUGHT: {}\nACTION: {}\nACTION_INPUT: {}",
thought,
tool_name,
serde_json::to_string_pretty(&arguments).unwrap_or_default()
)));
// Execute the tool
let result = self.execute_tool(&tool_name, arguments).await?;
// Add observation
messages.push(Message::tool(
tool_name.clone(),
format!(
"OBSERVATION: {}",
serde_json::to_string_pretty(&result.output).unwrap_or_default()
),
));
}
LlmResponse::FinalAnswer { thought, answer } => {
messages.push(Message::assistant(format!(
"THOUGHT: {}\nFINAL_ANSWER: {}",
thought, answer
)));
return Ok(AgentResult {
answer,
iterations: iteration + 1,
messages,
success: true,
});
}
LlmResponse::Reasoning { thought } => {
messages.push(Message::assistant(format!("THOUGHT: {}", thought)));
}
}
}
// Max iterations reached
Ok(AgentResult {
answer: "Maximum iterations reached without finding a final answer".to_string(),
iterations: self.config.max_iterations,
messages,
success: false,
})
}
/// Discover available tools from the MCP client
async fn discover_tools(&self) -> Result<Vec<McpToolDescriptor>> {
self.tool_client.list_tools().await
}
/// Build a ReAct-formatted prompt with available tools
fn build_react_prompt(
&self,
messages: &[Message],
tools: &[McpToolDescriptor],
) -> Vec<Message> {
let mut prompt_messages = Vec::new();
// System prompt with ReAct instructions
let system_prompt = self.build_system_prompt(tools);
prompt_messages.push(Message::system(system_prompt));
// Add conversation history
prompt_messages.extend_from_slice(messages);
prompt_messages
}
/// Build the system prompt with ReAct format and tool descriptions
fn build_system_prompt(&self, tools: &[McpToolDescriptor]) -> String {
let mut prompt = String::from(
"You are an AI assistant that uses the ReAct (Reasoning and Acting) pattern to solve tasks.\n\n\
You have access to the following tools:\n\n"
);
for tool in tools {
prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description));
}
prompt.push_str(
"\nUse the following format:\n\n\
THOUGHT: Your reasoning about what to do next\n\
ACTION: tool_name\n\
ACTION_INPUT: {\"param\": \"value\"}\n\n\
You will receive:\n\
OBSERVATION: The result of the tool execution\n\n\
Continue this process until you have enough information, then provide:\n\
THOUGHT: Final reasoning\n\
FINAL_ANSWER: Your comprehensive answer\n\n\
Important:\n\
- Always start with THOUGHT to explain your reasoning\n\
- ACTION must be one of the available tools\n\
- ACTION_INPUT must be valid JSON\n\
- Use FINAL_ANSWER only when you have sufficient information\n",
);
prompt
}
/// Generate an LLM response
async fn generate_llm_response(&self, messages: Vec<Message>) -> Result<String> {
let request = ChatRequest {
model: self.config.model.clone(),
messages,
parameters: ChatParameters {
temperature: self.config.temperature,
max_tokens: self.config.max_tokens,
stream: false,
..Default::default()
},
tools: None,
};
let response = self.llm_client.chat(request).await?;
Ok(response.message.content)
}
/// Parse LLM response into structured format
pub fn parse_response(&self, text: &str) -> Result<LlmResponse> {
let lines: Vec<&str> = text.lines().collect();
let mut thought = String::new();
let mut action = String::new();
let mut action_input = String::new();
let mut final_answer = String::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if line.starts_with("THOUGHT:") {
thought = line
.strip_prefix("THOUGHT:")
.unwrap_or("")
.trim()
.to_string();
// Collect multi-line thoughts
i += 1;
while i < lines.len()
&& !lines[i].trim().starts_with("ACTION")
&& !lines[i].trim().starts_with("FINAL_ANSWER")
{
if !lines[i].trim().is_empty() {
thought.push(' ');
thought.push_str(lines[i].trim());
}
i += 1;
}
continue;
}
if line.starts_with("ACTION:") {
action = line
.strip_prefix("ACTION:")
.unwrap_or("")
.trim()
.to_string();
i += 1;
continue;
}
if line.starts_with("ACTION_INPUT:") {
action_input = line
.strip_prefix("ACTION_INPUT:")
.unwrap_or("")
.trim()
.to_string();
// Collect multi-line JSON
i += 1;
while i < lines.len()
&& !lines[i].trim().starts_with("THOUGHT")
&& !lines[i].trim().starts_with("ACTION")
{
action_input.push(' ');
action_input.push_str(lines[i].trim());
i += 1;
}
continue;
}
if line.starts_with("FINAL_ANSWER:") {
final_answer = line
.strip_prefix("FINAL_ANSWER:")
.unwrap_or("")
.trim()
.to_string();
// Collect multi-line answer
i += 1;
while i < lines.len() {
if !lines[i].trim().is_empty() {
final_answer.push(' ');
final_answer.push_str(lines[i].trim());
}
i += 1;
}
break;
}
i += 1;
}
// Determine response type
if !final_answer.is_empty() {
return Ok(LlmResponse::FinalAnswer {
thought,
answer: final_answer,
});
}
if !action.is_empty() {
let arguments = if action_input.is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&action_input)
.map_err(|e| Error::Agent(ParseError::InvalidJson(e.to_string()).to_string()))?
};
return Ok(LlmResponse::ToolCall {
thought,
tool_name: action,
arguments,
});
}
if !thought.is_empty() {
return Ok(LlmResponse::Reasoning { thought });
}
Err(Error::Agent(ParseError::NoPattern.to_string()))
}
/// Execute a tool call
async fn execute_tool(
&self,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<McpToolResponse> {
let call = McpToolCall {
name: tool_name.to_string(),
arguments,
};
self.tool_client.call_tool(call).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::test_utils::MockMcpClient;
use crate::provider::test_utils::MockProvider;
#[test]
fn test_parse_tool_call() {
let executor = AgentExecutor {
llm_client: Arc::new(MockProvider),
tool_client: Arc::new(MockMcpClient),
config: AgentConfig::default(),
};
let text = r#"
THOUGHT: I need to search for information about Rust
ACTION: web_search
ACTION_INPUT: {"query": "Rust programming language"}
"#;
let result = executor.parse_response(text).unwrap();
match result {
LlmResponse::ToolCall {
thought,
tool_name,
arguments,
} => {
assert!(thought.contains("search for information"));
assert_eq!(tool_name, "web_search");
assert_eq!(arguments["query"], "Rust programming language");
}
_ => panic!("Expected ToolCall"),
}
}
#[test]
fn test_parse_final_answer() {
let executor = AgentExecutor {
llm_client: Arc::new(MockProvider),
tool_client: Arc::new(MockMcpClient),
config: AgentConfig::default(),
};
let text = r#"
THOUGHT: I now have enough information to answer
FINAL_ANSWER: Rust is a systems programming language focused on safety and performance.
"#;
let result = executor.parse_response(text).unwrap();
match result {
LlmResponse::FinalAnswer { thought, answer } => {
assert!(thought.contains("enough information"));
assert!(answer.contains("Rust is a systems programming language"));
}
_ => panic!("Expected FinalAnswer"),
}
}
}

View File

@@ -1,4 +1,3 @@
use crate::mode::ModeConfig;
use crate::provider::ProviderConfig;
use crate::Result;
use serde::{Deserialize, Serialize};
@@ -15,9 +14,6 @@ pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
pub struct Config {
/// General application settings
pub general: GeneralSettings,
/// MCP (Multi-Client-Provider) settings
#[serde(default)]
pub mcp: McpSettings,
/// Provider specific configuration keyed by provider name
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
@@ -30,72 +26,31 @@ pub struct Config {
/// Input handling preferences
#[serde(default)]
pub input: InputSettings,
/// Privacy controls for tooling and network usage
#[serde(default)]
pub privacy: PrivacySettings,
/// Security controls for sandboxing and resource limits
#[serde(default)]
pub security: SecuritySettings,
/// Per-tool configuration toggles
#[serde(default)]
pub tools: ToolSettings,
/// Mode-specific tool availability configuration
#[serde(default)]
pub modes: ModeConfig,
/// External MCP server definitions
#[serde(default)]
pub mcp_servers: Vec<McpServerConfig>,
}
impl Default for Config {
fn default() -> Self {
let mut providers = HashMap::new();
providers.insert("ollama".to_string(), default_ollama_provider_config());
providers.insert(
"ollama-cloud".to_string(),
default_ollama_cloud_provider_config(),
"ollama".to_string(),
ProviderConfig {
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: None,
extra: HashMap::new(),
},
);
Self {
general: GeneralSettings::default(),
mcp: McpSettings::default(),
providers,
ui: UiSettings::default(),
storage: StorageSettings::default(),
input: InputSettings::default(),
privacy: PrivacySettings::default(),
security: SecuritySettings::default(),
tools: ToolSettings::default(),
modes: ModeConfig::default(),
mcp_servers: Vec::new(),
}
}
}
/// Configuration for an external MCP server process.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpServerConfig {
/// Logical name used to reference the server (e.g., "web_search").
pub name: String,
/// Command to execute (binary or script).
pub command: String,
/// Arguments passed to the command.
#[serde(default)]
pub args: Vec<String>,
/// Transport mechanism, currently only "stdio" is supported.
#[serde(default = "McpServerConfig::default_transport")]
pub transport: String,
/// Optional environment variable map for the process.
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
}
impl McpServerConfig {
fn default_transport() -> String {
"stdio".to_string()
}
}
impl Config {
/// Load configuration from disk, falling back to defaults when missing
pub fn load(path: Option<&Path>) -> Result<Self> {
@@ -165,26 +120,17 @@ impl Config {
self.general.default_provider = "ollama".to_string();
}
ensure_provider_config(self, "ollama");
ensure_provider_config(self, "ollama-cloud");
}
}
fn default_ollama_provider_config() -> ProviderConfig {
ProviderConfig {
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: None,
extra: HashMap::new(),
}
}
fn default_ollama_cloud_provider_config() -> ProviderConfig {
ProviderConfig {
provider_type: "ollama-cloud".to_string(),
base_url: Some("https://ollama.com".to_string()),
api_key: None,
extra: HashMap::new(),
if !self.providers.contains_key("ollama") {
self.providers.insert(
"ollama".to_string(),
ProviderConfig {
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: None,
extra: HashMap::new(),
},
);
}
}
}
@@ -239,167 +185,6 @@ impl Default for GeneralSettings {
}
}
/// MCP (Multi-Client-Provider) settings
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpSettings {
// MCP is now always enabled in v1.0+
// Kept as a struct for future configuration options
}
/// Privacy controls governing network access and storage
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacySettings {
#[serde(default = "PrivacySettings::default_remote_search")]
pub enable_remote_search: bool,
#[serde(default)]
pub cache_web_results: bool,
#[serde(default)]
pub retain_history_days: u32,
#[serde(default = "PrivacySettings::default_require_consent")]
pub require_consent_per_session: bool,
#[serde(default = "PrivacySettings::default_encrypt_local_data")]
pub encrypt_local_data: bool,
}
impl PrivacySettings {
const fn default_remote_search() -> bool {
false
}
const fn default_require_consent() -> bool {
true
}
const fn default_encrypt_local_data() -> bool {
true
}
}
impl Default for PrivacySettings {
fn default() -> Self {
Self {
enable_remote_search: Self::default_remote_search(),
cache_web_results: false,
retain_history_days: 0,
require_consent_per_session: Self::default_require_consent(),
encrypt_local_data: Self::default_encrypt_local_data(),
}
}
}
/// Security settings that constrain tool execution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecuritySettings {
#[serde(default = "SecuritySettings::default_enable_sandboxing")]
pub enable_sandboxing: bool,
#[serde(default = "SecuritySettings::default_timeout")]
pub sandbox_timeout_seconds: u64,
#[serde(default = "SecuritySettings::default_max_memory")]
pub max_memory_mb: u64,
#[serde(default = "SecuritySettings::default_allowed_tools")]
pub allowed_tools: Vec<String>,
}
impl SecuritySettings {
const fn default_enable_sandboxing() -> bool {
true
}
const fn default_timeout() -> u64 {
30
}
const fn default_max_memory() -> u64 {
512
}
fn default_allowed_tools() -> Vec<String> {
vec![
"web_search".to_string(),
"web_scrape".to_string(),
"code_exec".to_string(),
"file_write".to_string(),
"file_delete".to_string(),
]
}
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
enable_sandboxing: Self::default_enable_sandboxing(),
sandbox_timeout_seconds: Self::default_timeout(),
max_memory_mb: Self::default_max_memory(),
allowed_tools: Self::default_allowed_tools(),
}
}
}
/// Per-tool configuration toggles
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolSettings {
#[serde(default)]
pub web_search: WebSearchToolConfig,
#[serde(default)]
pub code_exec: CodeExecToolConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSearchToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub api_key: String,
#[serde(default = "WebSearchToolConfig::default_max_results")]
pub max_results: u32,
}
impl WebSearchToolConfig {
const fn default_max_results() -> u32 {
5
}
}
impl Default for WebSearchToolConfig {
fn default() -> Self {
Self {
enabled: false,
api_key: String::new(),
max_results: Self::default_max_results(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeExecToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "CodeExecToolConfig::default_allowed_languages")]
pub allowed_languages: Vec<String>,
#[serde(default = "CodeExecToolConfig::default_timeout")]
pub timeout_seconds: u64,
}
impl CodeExecToolConfig {
fn default_allowed_languages() -> Vec<String> {
vec!["python".to_string(), "javascript".to_string()]
}
const fn default_timeout() -> u64 {
30
}
}
impl Default for CodeExecToolConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_languages: Self::default_allowed_languages(),
timeout_seconds: Self::default_timeout(),
}
}
}
/// UI preferences that consumers can respect as needed
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiSettings {
@@ -558,32 +343,15 @@ impl Default for InputSettings {
/// Convenience accessor for an Ollama provider entry, creating a default if missing
pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
ensure_provider_config(config, "ollama")
}
/// Ensure a provider configuration exists for the requested provider name
pub fn ensure_provider_config<'a>(
config: &'a mut Config,
provider_name: &str,
) -> &'a ProviderConfig {
use std::collections::hash_map::Entry;
match config.providers.entry(provider_name.to_string()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let default = match provider_name {
"ollama-cloud" => default_ollama_cloud_provider_config(),
"ollama" => default_ollama_provider_config(),
other => ProviderConfig {
provider_type: other.to_string(),
base_url: None,
api_key: None,
extra: HashMap::new(),
},
};
entry.insert(default)
}
}
config
.providers
.entry("ollama".to_string())
.or_insert_with(|| ProviderConfig {
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: None,
extra: HashMap::new(),
})
}
/// Calculate absolute timeout for session data based on configuration
@@ -636,21 +404,4 @@ mod tests {
let path = config.storage.conversation_path();
assert!(path.to_string_lossy().contains("custom/path"));
}
#[test]
fn default_config_contains_local_and_cloud_providers() {
let config = Config::default();
assert!(config.providers.contains_key("ollama"));
assert!(config.providers.contains_key("ollama-cloud"));
}
#[test]
fn ensure_provider_config_backfills_cloud_defaults() {
let mut config = Config::default();
config.providers.remove("ollama-cloud");
let cloud = ensure_provider_config(&mut config, "ollama-cloud");
assert_eq!(cloud.provider_type, "ollama-cloud");
assert_eq!(cloud.base_url.as_deref(), Some("https://ollama.com"));
}
}

View File

@@ -1,295 +0,0 @@
use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::encryption::VaultHandle;
#[derive(Clone, Debug)]
pub struct ConsentRequest {
pub tool_name: String,
}
/// Scope of consent grant
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ConsentScope {
/// Grant only for this single operation
Once,
/// Grant for the duration of the current session
Session,
/// Grant permanently (persisted across sessions)
Permanent,
/// Explicitly denied
Denied,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ConsentRecord {
pub tool_name: String,
pub scope: ConsentScope,
pub timestamp: DateTime<Utc>,
pub data_types: Vec<String>,
pub external_endpoints: Vec<String>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct ConsentManager {
/// Permanent consent records (persisted to vault)
permanent_records: HashMap<String, ConsentRecord>,
/// Session-scoped consent (cleared on manager drop or explicit clear)
#[serde(skip)]
session_records: HashMap<String, ConsentRecord>,
/// Once-scoped consent (used once then cleared)
#[serde(skip)]
once_records: HashMap<String, ConsentRecord>,
/// Pending consent requests (to prevent duplicate prompts)
#[serde(skip)]
pending_requests: HashMap<String, ()>,
}
impl ConsentManager {
pub fn new() -> Self {
Self::default()
}
/// Load consent records from vault storage
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
let guard = vault.lock().expect("Vault mutex poisoned");
if let Some(consent_data) = guard.settings().get("consent_records") {
if let Ok(permanent_records) =
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
{
return Self {
permanent_records,
session_records: HashMap::new(),
once_records: HashMap::new(),
pending_requests: HashMap::new(),
};
}
}
Self::default()
}
/// Persist permanent consent records to vault storage
pub fn persist_to_vault(&self, vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Result<()> {
let mut guard = vault.lock().expect("Vault mutex poisoned");
let consent_json = serde_json::to_value(&self.permanent_records)?;
guard
.settings_mut()
.insert("consent_records".to_string(), consent_json);
guard.persist()?;
Ok(())
}
pub fn request_consent(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Result<ConsentScope> {
// Check if already granted permanently
if let Some(existing) = self.permanent_records.get(tool_name) {
if existing.scope == ConsentScope::Permanent {
return Ok(ConsentScope::Permanent);
}
}
// Check if granted for session
if let Some(existing) = self.session_records.get(tool_name) {
if existing.scope == ConsentScope::Session {
return Ok(ConsentScope::Session);
}
}
// Check if request is already pending (prevent duplicate prompts)
if self.pending_requests.contains_key(tool_name) {
// Wait for the other prompt to complete by returning denied temporarily
// The caller should retry after a short delay
return Ok(ConsentScope::Denied);
}
// Mark as pending
self.pending_requests.insert(tool_name.to_string(), ());
// Show consent dialog and get scope
let scope = self.show_consent_dialog(tool_name, &data_types, &endpoints)?;
// Remove from pending
self.pending_requests.remove(tool_name);
// Create record based on scope
let record = ConsentRecord {
tool_name: tool_name.to_string(),
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
external_endpoints: endpoints,
};
// Store in appropriate location
match scope {
ConsentScope::Permanent => {
self.permanent_records.insert(tool_name.to_string(), record);
}
ConsentScope::Session => {
self.session_records.insert(tool_name.to_string(), record);
}
ConsentScope::Once | ConsentScope::Denied => {
// Don't store, just return the decision
}
}
Ok(scope)
}
/// Grant consent programmatically (for TUI or automated flows)
pub fn grant_consent(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) {
self.grant_consent_with_scope(tool_name, data_types, endpoints, ConsentScope::Permanent);
}
/// Grant consent with specific scope
pub fn grant_consent_with_scope(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
scope: ConsentScope,
) {
let record = ConsentRecord {
tool_name: tool_name.to_string(),
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
external_endpoints: endpoints,
};
match scope {
ConsentScope::Permanent => {
self.permanent_records.insert(tool_name.to_string(), record);
}
ConsentScope::Session => {
self.session_records.insert(tool_name.to_string(), record);
}
ConsentScope::Once => {
self.once_records.insert(tool_name.to_string(), record);
}
ConsentScope::Denied => {} // Denied is not stored
}
}
/// Check if consent is needed (returns None if already granted, Some(info) if needed)
pub fn check_consent_needed(&self, tool_name: &str) -> Option<ConsentRequest> {
if self.has_consent(tool_name) {
None
} else {
Some(ConsentRequest {
tool_name: tool_name.to_string(),
})
}
}
pub fn has_consent(&self, tool_name: &str) -> bool {
// Check permanent first, then session, then once
self.permanent_records
.get(tool_name)
.map(|r| r.scope == ConsentScope::Permanent)
.or_else(|| {
self.session_records
.get(tool_name)
.map(|r| r.scope == ConsentScope::Session)
})
.or_else(|| {
self.once_records
.get(tool_name)
.map(|r| r.scope == ConsentScope::Once)
})
.unwrap_or(false)
}
/// Consume "once" consent for a tool (clears it after first use)
pub fn consume_once_consent(&mut self, tool_name: &str) {
self.once_records.remove(tool_name);
}
pub fn revoke_consent(&mut self, tool_name: &str) {
self.permanent_records.remove(tool_name);
self.session_records.remove(tool_name);
self.once_records.remove(tool_name);
}
pub fn clear_all_consent(&mut self) {
self.permanent_records.clear();
self.session_records.clear();
self.once_records.clear();
}
/// Clear only session-scoped consent (useful when starting new session)
pub fn clear_session_consent(&mut self) {
self.session_records.clear();
self.once_records.clear(); // Also clear once consent on session clear
}
/// Check if consent is needed for a tool (non-blocking)
/// Returns Some with consent details if needed, None if already granted
pub fn check_if_consent_needed(
&self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Option<(String, Vec<String>, Vec<String>)> {
if self.has_consent(tool_name) {
return None;
}
Some((tool_name.to_string(), data_types, endpoints))
}
fn show_consent_dialog(
&self,
tool_name: &str,
data_types: &[String],
endpoints: &[String],
) -> Result<ConsentScope> {
// TEMPORARY: Auto-grant session consent when not in a proper terminal (TUI mode)
// TODO: Integrate consent UI into the TUI event loop
use std::io::IsTerminal;
if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() {
eprintln!("Auto-granting session consent for {} (TUI mode)", tool_name);
return Ok(ConsentScope::Session);
}
println!("\n╔══════════════════════════════════════════════════╗");
println!("║ 🔒 PRIVACY CONSENT REQUIRED 🔒 ║");
println!("╚══════════════════════════════════════════════════╝");
println!();
println!("Tool: {}", tool_name);
println!("Data: {}", data_types.join(", "));
println!("Endpoints: {}", endpoints.join(", "));
println!();
println!("Choose consent scope:");
println!(" [1] Allow once - Grant only for this operation");
println!(" [2] Allow session - Grant for current session");
println!(" [3] Allow always - Grant permanently");
println!(" [4] Deny - Reject this operation");
println!();
print!("Enter choice (1-4) [default: 4]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
match input.trim() {
"1" => Ok(ConsentScope::Once),
"2" => Ok(ConsentScope::Session),
"3" => Ok(ConsentScope::Permanent),
_ => Ok(ConsentScope::Denied),
}
}
}

View File

@@ -3,6 +3,7 @@ use crate::types::{Conversation, Message};
use crate::Result;
use serde_json::{Number, Value};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use uuid::Uuid;
@@ -213,25 +214,6 @@ impl ConversationManager {
Ok(())
}
/// Set tool calls on a streaming message
pub fn set_tool_calls_on_message(
&mut self,
message_id: Uuid,
tool_calls: Vec<crate::types::ToolCall>,
) -> Result<()> {
let index = self
.message_index
.get(&message_id)
.copied()
.ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?;
if let Some(message) = self.active_mut().messages.get_mut(index) {
message.tool_calls = Some(tool_calls);
}
Ok(())
}
/// Update the active model (used when user changes model mid session)
pub fn set_model(&mut self, model: impl Into<String>) {
self.active.model = model.into();
@@ -286,40 +268,36 @@ impl ConversationManager {
}
/// Save the active conversation to disk
pub async fn save_active(
&self,
storage: &StorageManager,
name: Option<String>,
) -> Result<Uuid> {
storage.save_conversation(&self.active, name).await?;
Ok(self.active.id)
pub fn save_active(&self, storage: &StorageManager, name: Option<String>) -> Result<PathBuf> {
storage.save_conversation(&self.active, name)
}
/// Save the active conversation to disk with a description
pub async fn save_active_with_description(
pub fn save_active_with_description(
&self,
storage: &StorageManager,
name: Option<String>,
description: Option<String>,
) -> Result<Uuid> {
storage
.save_conversation_with_description(&self.active, name, description)
.await?;
Ok(self.active.id)
) -> Result<PathBuf> {
storage.save_conversation_with_description(&self.active, name, description)
}
/// Load a conversation from storage and make it active
pub async fn load_saved(&mut self, storage: &StorageManager, id: Uuid) -> Result<()> {
let conversation = storage.load_conversation(id).await?;
/// Load a conversation from disk and make it active
pub fn load_from_disk(
&mut self,
storage: &StorageManager,
path: impl AsRef<Path>,
) -> Result<()> {
let conversation = storage.load_conversation(path)?;
self.load(conversation);
Ok(())
}
/// List all saved sessions
pub async fn list_saved_sessions(
pub fn list_saved_sessions(
storage: &StorageManager,
) -> Result<Vec<crate::storage::SessionMeta>> {
storage.list_sessions().await
storage.list_sessions()
}
}

View File

@@ -1,69 +0,0 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{storage::StorageManager, Error, Result};
#[derive(Serialize, Deserialize, Debug)]
pub struct ApiCredentials {
pub api_key: String,
pub endpoint: String,
}
pub struct CredentialManager {
storage: Arc<StorageManager>,
master_key: Arc<Vec<u8>>,
namespace: String,
}
impl CredentialManager {
pub fn new(storage: Arc<StorageManager>, master_key: Arc<Vec<u8>>) -> Self {
Self {
storage,
master_key,
namespace: "owlen".to_string(),
}
}
fn namespaced_key(&self, tool_name: &str) -> String {
format!("{}_{}", self.namespace, tool_name)
}
pub async fn store_credentials(
&self,
tool_name: &str,
credentials: &ApiCredentials,
) -> Result<()> {
let key = self.namespaced_key(tool_name);
let payload = serde_json::to_vec(credentials).map_err(|e| {
Error::Storage(format!(
"Failed to serialize credentials for secure storage: {e}"
))
})?;
self.storage
.store_secure_item(&key, &payload, &self.master_key)
.await
}
pub async fn get_credentials(&self, tool_name: &str) -> Result<Option<ApiCredentials>> {
let key = self.namespaced_key(tool_name);
match self
.storage
.load_secure_item(&key, &self.master_key)
.await?
{
Some(bytes) => {
let creds = serde_json::from_slice(&bytes).map_err(|e| {
Error::Storage(format!("Failed to deserialize stored credentials: {e}"))
})?;
Ok(Some(creds))
}
None => Ok(None),
}
}
pub async fn delete_credentials(&self, tool_name: &str) -> Result<()> {
let key = self.namespaced_key(tool_name);
self.storage.delete_secure_item(&key).await
}
}

View File

@@ -1,241 +0,0 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use anyhow::{bail, Context, Result};
use ring::digest;
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
pub struct EncryptedStorage {
cipher: Aes256Gcm,
storage_path: PathBuf,
}
#[derive(Serialize, Deserialize)]
struct EncryptedData {
nonce: [u8; 12],
ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultData {
pub master_key: Vec<u8>,
#[serde(default)]
pub settings: HashMap<String, JsonValue>,
}
pub struct VaultHandle {
storage: EncryptedStorage,
pub data: VaultData,
}
impl VaultHandle {
pub fn master_key(&self) -> &[u8] {
&self.data.master_key
}
pub fn settings(&self) -> &HashMap<String, JsonValue> {
&self.data.settings
}
pub fn settings_mut(&mut self) -> &mut HashMap<String, JsonValue> {
&mut self.data.settings
}
pub fn persist(&self) -> Result<()> {
self.storage.store(&self.data)
}
}
impl EncryptedStorage {
pub fn new(storage_path: PathBuf, password: &str) -> Result<Self> {
let digest = digest::digest(&digest::SHA256, password.as_bytes());
let cipher = Aes256Gcm::new_from_slice(digest.as_ref())
.map_err(|_| anyhow::anyhow!("Invalid key length for AES-256"))?;
if let Some(parent) = storage_path.parent() {
fs::create_dir_all(parent).context("Failed to ensure storage directory exists")?;
}
Ok(Self {
cipher,
storage_path,
})
}
pub fn store<T: Serialize>(&self, data: &T) -> Result<()> {
let json = serde_json::to_vec(data).context("Failed to serialize data")?;
let nonce = generate_nonce()?;
let nonce_ref = Nonce::from_slice(&nonce);
let ciphertext = self
.cipher
.encrypt(nonce_ref, json.as_ref())
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let encrypted_data = EncryptedData { nonce, ciphertext };
let encrypted_json = serde_json::to_vec(&encrypted_data)?;
fs::write(&self.storage_path, encrypted_json).context("Failed to write encrypted data")?;
Ok(())
}
pub fn load<T: for<'de> Deserialize<'de>>(&self) -> Result<T> {
let encrypted_json =
fs::read(&self.storage_path).context("Failed to read encrypted data")?;
let encrypted_data: EncryptedData =
serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?;
let nonce_ref = Nonce::from_slice(&encrypted_data.nonce);
let plaintext = self
.cipher
.decrypt(nonce_ref, encrypted_data.ciphertext.as_ref())
.map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))?;
let data: T =
serde_json::from_slice(&plaintext).context("Failed to deserialize decrypted data")?;
Ok(data)
}
pub fn exists(&self) -> bool {
self.storage_path.exists()
}
pub fn delete(&self) -> Result<()> {
if self.exists() {
fs::remove_file(&self.storage_path).context("Failed to delete encrypted storage")?;
}
Ok(())
}
pub fn verify_password(&self) -> Result<()> {
if !self.exists() {
return Ok(());
}
let encrypted_json =
fs::read(&self.storage_path).context("Failed to read encrypted data")?;
if encrypted_json.is_empty() {
return Ok(());
}
let encrypted_data: EncryptedData =
serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?;
let nonce_ref = Nonce::from_slice(&encrypted_data.nonce);
self.cipher
.decrypt(nonce_ref, encrypted_data.ciphertext.as_ref())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))
}
}
pub fn prompt_password(prompt: &str) -> Result<String> {
let password = rpassword::prompt_password(prompt)
.map_err(|e| anyhow::anyhow!("Failed to read password: {e}"))?;
if password.is_empty() {
bail!("Password cannot be empty");
}
Ok(password)
}
pub fn prompt_new_password() -> Result<String> {
loop {
let first = prompt_password("Enter new master password: ")?;
let confirm = prompt_password("Confirm master password: ")?;
if first == confirm {
return Ok(first);
}
println!("Passwords did not match. Please try again.");
}
}
pub fn unlock_with_password(storage_path: PathBuf, password: &str) -> Result<VaultHandle> {
let storage = EncryptedStorage::new(storage_path, password)?;
let data = load_or_initialize_vault(&storage)?;
Ok(VaultHandle { storage, data })
}
pub fn unlock_interactive(storage_path: PathBuf) -> Result<VaultHandle> {
if storage_path.exists() {
for attempt in 0..3 {
let password = prompt_password("Enter master password: ")?;
match unlock_with_password(storage_path.clone(), &password) {
Ok(handle) => return Ok(handle),
Err(err) => {
println!("Failed to unlock vault: {err}");
if attempt == 2 {
return Err(err);
}
}
}
}
bail!("Failed to unlock encrypted storage after multiple attempts");
} else {
println!(
"No encrypted storage found at {}. Initializing a new vault.",
storage_path.display()
);
let password = prompt_new_password()?;
let storage = EncryptedStorage::new(storage_path, &password)?;
let data = VaultData {
master_key: generate_master_key()?,
..Default::default()
};
storage.store(&data)?;
Ok(VaultHandle { storage, data })
}
}
fn load_or_initialize_vault(storage: &EncryptedStorage) -> Result<VaultData> {
match storage.load::<VaultData>() {
Ok(data) => {
if data.master_key.len() != 32 {
bail!(
"Corrupted vault: master key has invalid length ({}). \
Expected 32 bytes for AES-256. Vault cannot be recovered.",
data.master_key.len()
);
}
Ok(data)
}
Err(err) => {
if storage.exists() {
return Err(err);
}
let data = VaultData {
master_key: generate_master_key()?,
..Default::default()
};
storage.store(&data)?;
Ok(data)
}
}
}
fn generate_master_key() -> Result<Vec<u8>> {
let mut key = vec![0u8; 32];
SystemRandom::new()
.fill(&mut key)
.map_err(|_| anyhow::anyhow!("Failed to generate master key"))?;
Ok(key)
}
fn generate_nonce() -> Result<[u8; 12]> {
let mut nonce = [0u8; 12];
let rng = SystemRandom::new();
rng.fill(&mut nonce)
.map_err(|_| anyhow::anyhow!("Failed to generate nonce"))?;
Ok(nonce)
}

View File

@@ -91,11 +91,6 @@ impl MessageFormatter {
Some(thinking)
};
// If the result is empty but we have thinking content, show a placeholder
if result.trim().is_empty() && thinking_result.is_some() {
result.push_str("[Thinking...]");
}
(result, thinking_result)
}
}

View File

@@ -3,52 +3,29 @@
//! This crate provides the foundational abstractions for building
//! LLM providers, routers, and MCP (Model Context Protocol) adapters.
pub mod agent;
pub mod config;
pub mod consent;
pub mod conversation;
pub mod credentials;
pub mod encryption;
pub mod formatting;
pub mod input;
pub mod mcp;
pub mod mode;
pub mod model;
pub mod provider;
pub mod router;
pub mod sandbox;
pub mod session;
pub mod storage;
pub mod theme;
pub mod tools;
pub mod types;
pub mod ui;
pub mod validation;
pub mod wrap_cursor;
pub use agent::*;
pub use config::*;
pub use consent::*;
pub use conversation::*;
pub use credentials::*;
pub use encryption::*;
pub use formatting::*;
pub use input::*;
// Export MCP types but exclude test_utils to avoid ambiguity
pub use mcp::{
client, factory, failover, permission, protocol, remote_client, LocalMcpClient, McpServer,
McpToolCall, McpToolDescriptor, McpToolResponse,
};
pub use mode::*;
pub use model::*;
// Export provider types but exclude test_utils to avoid ambiguity
pub use provider::{ChatStream, Provider, ProviderConfig, ProviderRegistry};
pub use provider::*;
pub use router::*;
pub use sandbox::*;
pub use session::*;
pub use theme::*;
pub use tools::*;
pub use validation::*;
/// Result type used throughout the OWLEN ecosystem
pub type Result<T> = std::result::Result<T, Error>;
@@ -85,13 +62,4 @@ pub enum Error {
#[error("Unknown error: {0}")]
Unknown(String),
#[error("Not implemented: {0}")]
NotImplemented(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Agent execution error: {0}")]
Agent(String),
}

View File

@@ -1,182 +0,0 @@
use crate::mode::Mode;
use crate::tools::registry::ToolRegistry;
use crate::validation::SchemaValidator;
use crate::Result;
use async_trait::async_trait;
pub use client::McpClient;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
pub mod client;
pub mod factory;
pub mod failover;
pub mod permission;
pub mod protocol;
pub mod remote_client;
/// Descriptor for a tool exposed over MCP
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDescriptor {
pub name: String,
pub description: String,
pub input_schema: Value,
pub requires_network: bool,
pub requires_filesystem: Vec<String>,
}
/// Invocation payload for a tool call
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCall {
pub name: String,
pub arguments: Value,
}
/// Result returned by a tool invocation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolResponse {
pub name: String,
pub success: bool,
pub output: Value,
pub metadata: HashMap<String, String>,
pub duration_ms: u128,
}
/// Thin MCP server facade over the tool registry
pub struct McpServer {
registry: Arc<ToolRegistry>,
validator: Arc<SchemaValidator>,
mode: Arc<tokio::sync::RwLock<Mode>>,
}
impl McpServer {
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
Self {
registry,
validator,
mode: Arc::new(tokio::sync::RwLock::new(Mode::default())),
}
}
/// Set the current operating mode
pub async fn set_mode(&self, mode: Mode) {
*self.mode.write().await = mode;
}
/// Get the current operating mode
pub async fn get_mode(&self) -> Mode {
*self.mode.read().await
}
/// Enumerate the registered tools as MCP descriptors
pub async fn list_tools(&self) -> Vec<McpToolDescriptor> {
let mode = self.get_mode().await;
let available_tools = self.registry.available_tools(mode).await;
self.registry
.all()
.into_iter()
.filter(|tool| available_tools.contains(&tool.name().to_string()))
.map(|tool| McpToolDescriptor {
name: tool.name().to_string(),
description: tool.description().to_string(),
input_schema: tool.schema(),
requires_network: tool.requires_network(),
requires_filesystem: tool.requires_filesystem(),
})
.collect()
}
/// Execute a tool call after validating inputs against the registered schema
pub async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
self.validator.validate(&call.name, &call.arguments)?;
let mode = self.get_mode().await;
let result = self
.registry
.execute(&call.name, call.arguments, mode)
.await?;
Ok(McpToolResponse {
name: call.name,
success: result.success,
output: result.output,
metadata: result.metadata,
duration_ms: duration_to_millis(result.duration),
})
}
}
fn duration_to_millis(duration: Duration) -> u128 {
duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis())
}
pub struct LocalMcpClient {
server: McpServer,
}
impl LocalMcpClient {
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
Self {
server: McpServer::new(registry, validator),
}
}
/// Set the current operating mode
pub async fn set_mode(&self, mode: Mode) {
self.server.set_mode(mode).await;
}
/// Get the current operating mode
pub async fn get_mode(&self) -> Mode {
self.server.get_mode().await
}
}
#[async_trait]
impl McpClient for LocalMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
Ok(self.server.list_tools().await)
}
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
self.server.call_tool(call).await
}
}
#[cfg(test)]
pub mod test_utils {
use super::*;
/// Mock MCP client for testing
#[derive(Default)]
pub struct MockMcpClient;
#[async_trait]
impl McpClient for MockMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
Ok(vec![McpToolDescriptor {
name: "mock_tool".to_string(),
description: "A mock tool for testing".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": {"type": "string"}
}
}),
requires_network: false,
requires_filesystem: vec![],
}])
}
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
Ok(McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!({"result": "mock result"}),
metadata: HashMap::new(),
duration_ms: 10,
})
}
}
}

View File

@@ -1,16 +0,0 @@
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::Result;
use async_trait::async_trait;
/// Trait for a client that can interact with an MCP server
#[async_trait]
pub trait McpClient: Send + Sync {
/// List the tools available on the server
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
/// Call a tool on the server
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse>;
}
// Re-export the concrete implementation that supports stdio and HTTP transports.
pub use super::remote_client::RemoteMcpClient;

View File

@@ -1,87 +0,0 @@
/// MCP Client Factory
///
/// Provides a unified interface for creating MCP clients based on configuration.
/// Supports switching between local (in-process) and remote (STDIO) execution modes.
use super::client::McpClient;
use super::{remote_client::RemoteMcpClient, LocalMcpClient};
use crate::config::Config;
use crate::tools::registry::ToolRegistry;
use crate::validation::SchemaValidator;
use crate::Result;
use std::sync::Arc;
/// Factory for creating MCP clients based on configuration
pub struct McpClientFactory {
config: Arc<Config>,
registry: Arc<ToolRegistry>,
validator: Arc<SchemaValidator>,
}
impl McpClientFactory {
pub fn new(
config: Arc<Config>,
registry: Arc<ToolRegistry>,
validator: Arc<SchemaValidator>,
) -> Self {
Self {
config,
registry,
validator,
}
}
/// Create an MCP client based on the current configuration
///
/// In v1.0+, MCP architecture is always enabled. If MCP servers are configured,
/// uses the first server; otherwise falls back to local in-process client.
pub fn create(&self) -> Result<Box<dyn McpClient>> {
// Use the first configured MCP server, if any.
if let Some(server_cfg) = self.config.mcp_servers.first() {
match RemoteMcpClient::new_with_config(server_cfg) {
Ok(client) => Ok(Box::new(client)),
Err(e) => {
eprintln!("Warning: Failed to start remote MCP client '{}': {}. Falling back to local mode.", server_cfg.name, e);
Ok(Box::new(LocalMcpClient::new(
self.registry.clone(),
self.validator.clone(),
)))
}
}
} else {
// No servers configured fall back to local client.
eprintln!("Warning: No MCP servers defined in config. Using local client.");
Ok(Box::new(LocalMcpClient::new(
self.registry.clone(),
self.validator.clone(),
)))
}
}
/// Check if remote MCP mode is available
pub fn is_remote_available() -> bool {
RemoteMcpClient::new().is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_factory_creates_local_client_when_no_servers_configured() {
let config = Config::default();
let ui = Arc::new(crate::ui::NoOpUiController);
let registry = Arc::new(ToolRegistry::new(
Arc::new(tokio::sync::Mutex::new(config.clone())),
ui,
));
let validator = Arc::new(SchemaValidator::new());
let factory = McpClientFactory::new(Arc::new(config), registry, validator);
// Should create without error and fall back to local client
let result = factory.create();
assert!(result.is_ok());
}
}

View File

@@ -1,322 +0,0 @@
//! Failover and redundancy support for MCP clients
//!
//! Provides automatic failover between multiple MCP servers with:
//! - Health checking
//! - Priority-based selection
//! - Automatic retry with exponential backoff
//! - Circuit breaker pattern
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::{Error, Result};
use async_trait::async_trait;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
/// Server health status
#[derive(Debug, Clone, PartialEq)]
pub enum ServerHealth {
/// Server is healthy and available
Healthy,
/// Server is experiencing issues but may recover
Degraded { since: Instant },
/// Server is down
Down { since: Instant },
}
/// Server configuration with priority
#[derive(Clone)]
pub struct ServerEntry {
/// Name for logging
pub name: String,
/// MCP client instance
pub client: Arc<dyn McpClient>,
/// Priority (lower = higher priority)
pub priority: u32,
/// Health status
health: Arc<RwLock<ServerHealth>>,
/// Last health check time
last_check: Arc<RwLock<Option<Instant>>>,
}
impl ServerEntry {
pub fn new(name: String, client: Arc<dyn McpClient>, priority: u32) -> Self {
Self {
name,
client,
priority,
health: Arc::new(RwLock::new(ServerHealth::Healthy)),
last_check: Arc::new(RwLock::new(None)),
}
}
/// Check if server is available
pub async fn is_available(&self) -> bool {
let health = self.health.read().await;
matches!(*health, ServerHealth::Healthy)
}
/// Mark server as healthy
pub async fn mark_healthy(&self) {
let mut health = self.health.write().await;
*health = ServerHealth::Healthy;
let mut last_check = self.last_check.write().await;
*last_check = Some(Instant::now());
}
/// Mark server as down
pub async fn mark_down(&self) {
let mut health = self.health.write().await;
*health = ServerHealth::Down {
since: Instant::now(),
};
}
/// Mark server as degraded
pub async fn mark_degraded(&self) {
let mut health = self.health.write().await;
if matches!(*health, ServerHealth::Healthy) {
*health = ServerHealth::Degraded {
since: Instant::now(),
};
}
}
/// Get current health status
pub async fn get_health(&self) -> ServerHealth {
self.health.read().await.clone()
}
}
/// Failover configuration
#[derive(Debug, Clone)]
pub struct FailoverConfig {
/// Maximum number of retry attempts
pub max_retries: usize,
/// Base retry delay (will be exponentially increased)
pub base_retry_delay: Duration,
/// Health check interval
pub health_check_interval: Duration,
/// Timeout for health checks
pub health_check_timeout: Duration,
/// Circuit breaker threshold (failures before opening circuit)
pub circuit_breaker_threshold: usize,
}
impl Default for FailoverConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_retry_delay: Duration::from_millis(100),
health_check_interval: Duration::from_secs(30),
health_check_timeout: Duration::from_secs(5),
circuit_breaker_threshold: 5,
}
}
}
/// MCP client with failover support
pub struct FailoverMcpClient {
servers: Arc<RwLock<Vec<ServerEntry>>>,
config: FailoverConfig,
consecutive_failures: Arc<RwLock<usize>>,
}
impl FailoverMcpClient {
/// Create a new failover client with multiple servers
pub fn new(servers: Vec<ServerEntry>, config: FailoverConfig) -> Self {
// Sort servers by priority
let mut sorted_servers = servers;
sorted_servers.sort_by_key(|s| s.priority);
Self {
servers: Arc::new(RwLock::new(sorted_servers)),
config,
consecutive_failures: Arc::new(RwLock::new(0)),
}
}
/// Create with default configuration
pub fn with_servers(servers: Vec<ServerEntry>) -> Self {
Self::new(servers, FailoverConfig::default())
}
/// Get the first available server
async fn get_available_server(&self) -> Option<ServerEntry> {
let servers = self.servers.read().await;
for server in servers.iter() {
if server.is_available().await {
return Some(server.clone());
}
}
None
}
/// Execute an operation with automatic failover
async fn with_failover<F, T>(&self, operation: F) -> Result<T>
where
F: Fn(Arc<dyn McpClient>) -> futures::future::BoxFuture<'static, Result<T>>,
T: Send + 'static,
{
let mut attempt = 0;
let mut last_error = None;
while attempt < self.config.max_retries {
// Get available server
let server = match self.get_available_server().await {
Some(s) => s,
None => {
// No healthy servers, try all servers anyway
let servers = self.servers.read().await;
if let Some(first) = servers.first() {
first.clone()
} else {
return Err(Error::Network("No servers configured".to_string()));
}
}
};
// Execute operation
match operation(server.client.clone()).await {
Ok(result) => {
server.mark_healthy().await;
let mut failures = self.consecutive_failures.write().await;
*failures = 0;
return Ok(result);
}
Err(e) => {
log::warn!("Server '{}' failed: {}", server.name, e);
server.mark_degraded().await;
last_error = Some(e);
let mut failures = self.consecutive_failures.write().await;
*failures += 1;
if *failures >= self.config.circuit_breaker_threshold {
server.mark_down().await;
}
}
}
// Exponential backoff
if attempt < self.config.max_retries - 1 {
let delay = self.config.base_retry_delay * 2_u32.pow(attempt as u32);
tokio::time::sleep(delay).await;
}
attempt += 1;
}
Err(last_error.unwrap_or_else(|| Error::Network("All servers failed".to_string())))
}
/// Perform health check on all servers
pub async fn health_check_all(&self) {
let servers = self.servers.read().await;
for server in servers.iter() {
let client = server.client.clone();
let server_clone = server.clone();
tokio::spawn(async move {
match tokio::time::timeout(
Duration::from_secs(5),
// Use a simple list_tools call as health check
async { client.list_tools().await },
)
.await
{
Ok(Ok(_)) => server_clone.mark_healthy().await,
Ok(Err(e)) => {
log::warn!("Health check failed for '{}': {}", server_clone.name, e);
server_clone.mark_down().await;
}
Err(_) => {
log::warn!("Health check timeout for '{}'", server_clone.name);
server_clone.mark_down().await;
}
}
});
}
}
/// Start background health checking
pub fn start_health_checks(&self) -> tokio::task::JoinHandle<()> {
let client = self.clone_ref();
let interval = self.config.health_check_interval;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
loop {
interval_timer.tick().await;
client.health_check_all().await;
}
})
}
/// Clone the client (returns new handle to same underlying data)
fn clone_ref(&self) -> Self {
Self {
servers: self.servers.clone(),
config: self.config.clone(),
consecutive_failures: self.consecutive_failures.clone(),
}
}
/// Get status of all servers
pub async fn get_server_status(&self) -> Vec<(String, ServerHealth)> {
let servers = self.servers.read().await;
let mut status = Vec::new();
for server in servers.iter() {
status.push((server.name.clone(), server.get_health().await));
}
status
}
}
#[async_trait]
impl McpClient for FailoverMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
self.with_failover(|client| Box::pin(async move { client.list_tools().await }))
.await
}
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
self.with_failover(|client| {
let call_clone = call.clone();
Box::pin(async move { client.call_tool(call_clone).await })
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_server_entry_health() {
use crate::mcp::remote_client::RemoteMcpClient;
// This would need a mock client in practice
// Just demonstrating the API
let config = crate::config::McpServerConfig {
name: "test".to_string(),
command: "test".to_string(),
args: vec![],
transport: "http".to_string(),
env: std::collections::HashMap::new(),
};
if let Ok(client) = RemoteMcpClient::new_with_config(&config) {
let entry = ServerEntry::new("test".to_string(), Arc::new(client), 1);
assert!(entry.is_available().await);
entry.mark_down().await;
assert!(!entry.is_available().await);
entry.mark_healthy().await;
assert!(entry.is_available().await);
}
}
}

View File

@@ -1,217 +0,0 @@
/// Permission and Safety Layer for MCP
///
/// This module provides runtime enforcement of security policies for tool execution.
/// It wraps MCP clients to filter/whitelist tool calls, log invocations, and prompt for consent.
use super::client::McpClient;
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::config::Config;
use crate::{Error, Result};
use async_trait::async_trait;
use std::collections::HashSet;
use std::sync::Arc;
/// Callback for requesting user consent for dangerous operations
pub type ConsentCallback = Arc<dyn Fn(&str, &McpToolCall) -> bool + Send + Sync>;
/// Callback for logging tool invocations
pub type LogCallback = Arc<dyn Fn(&str, &McpToolCall, &Result<McpToolResponse>) + Send + Sync>;
/// Permission-enforcing wrapper around an MCP client
pub struct PermissionLayer {
inner: Box<dyn McpClient>,
config: Arc<Config>,
consent_callback: Option<ConsentCallback>,
log_callback: Option<LogCallback>,
allowed_tools: HashSet<String>,
}
impl PermissionLayer {
/// Create a new permission layer wrapping the given client
pub fn new(inner: Box<dyn McpClient>, config: Arc<Config>) -> Self {
let allowed_tools = config.security.allowed_tools.iter().cloned().collect();
Self {
inner,
config,
consent_callback: None,
log_callback: None,
allowed_tools,
}
}
/// Set a callback for requesting user consent
pub fn with_consent_callback(mut self, callback: ConsentCallback) -> Self {
self.consent_callback = Some(callback);
self
}
/// Set a callback for logging tool invocations
pub fn with_log_callback(mut self, callback: LogCallback) -> Self {
self.log_callback = Some(callback);
self
}
/// Check if a tool requires dangerous filesystem operations
fn requires_dangerous_filesystem(&self, tool_name: &str) -> bool {
matches!(
tool_name,
"resources/write" | "resources/delete" | "file_write" | "file_delete"
)
}
/// Check if a tool is allowed by security policy
fn is_tool_allowed(&self, tool_descriptor: &McpToolDescriptor) -> bool {
// Check if tool requires filesystem access
for fs_perm in &tool_descriptor.requires_filesystem {
if !self.allowed_tools.contains(fs_perm) {
return false;
}
}
// Check if tool requires network access
if tool_descriptor.requires_network && !self.allowed_tools.contains("web_search") {
return false;
}
true
}
/// Request user consent for a tool call
fn request_consent(&self, tool_name: &str, call: &McpToolCall) -> bool {
if let Some(ref callback) = self.consent_callback {
callback(tool_name, call)
} else {
// If no callback is set, deny dangerous operations by default
!self.requires_dangerous_filesystem(tool_name)
}
}
/// Log a tool invocation
fn log_invocation(
&self,
tool_name: &str,
call: &McpToolCall,
result: &Result<McpToolResponse>,
) {
if let Some(ref callback) = self.log_callback {
callback(tool_name, call, result);
} else {
// Default logging to stderr
match result {
Ok(resp) => {
eprintln!(
"[MCP] Tool '{}' executed successfully ({}ms)",
tool_name, resp.duration_ms
);
}
Err(e) => {
eprintln!("[MCP] Tool '{}' failed: {}", tool_name, e);
}
}
}
}
}
#[async_trait]
impl McpClient for PermissionLayer {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
let tools = self.inner.list_tools().await?;
// Filter tools based on security policy
Ok(tools
.into_iter()
.filter(|tool| self.is_tool_allowed(tool))
.collect())
}
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
// Check if tool requires consent
if self.requires_dangerous_filesystem(&call.name)
&& self.config.privacy.require_consent_per_session
&& !self.request_consent(&call.name, &call)
{
let result = Err(Error::PermissionDenied(format!(
"User denied consent for tool '{}'",
call.name
)));
self.log_invocation(&call.name, &call, &result);
return result;
}
// Execute the tool call
let result = self.inner.call_tool(call.clone()).await;
// Log the invocation
self.log_invocation(&call.name, &call, &result);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::LocalMcpClient;
use crate::tools::registry::ToolRegistry;
use crate::validation::SchemaValidator;
use std::sync::atomic::{AtomicBool, Ordering};
#[tokio::test]
async fn test_permission_layer_filters_dangerous_tools() {
let config = Arc::new(Config::default());
let ui = Arc::new(crate::ui::NoOpUiController);
let registry = Arc::new(ToolRegistry::new(
Arc::new(tokio::sync::Mutex::new((*config).clone())),
ui,
));
let validator = Arc::new(SchemaValidator::new());
let client = Box::new(LocalMcpClient::new(registry, validator));
let mut config_mut = (*config).clone();
// Disallow file operations
config_mut.security.allowed_tools = vec!["web_search".to_string()];
let permission_layer = PermissionLayer::new(client, Arc::new(config_mut));
let tools = permission_layer.list_tools().await.unwrap();
// Should not include file_write or file_delete tools
assert!(!tools.iter().any(|t| t.name.contains("write")));
assert!(!tools.iter().any(|t| t.name.contains("delete")));
}
#[tokio::test]
async fn test_consent_callback_is_invoked() {
let config = Arc::new(Config::default());
let ui = Arc::new(crate::ui::NoOpUiController);
let registry = Arc::new(ToolRegistry::new(
Arc::new(tokio::sync::Mutex::new((*config).clone())),
ui,
));
let validator = Arc::new(SchemaValidator::new());
let client = Box::new(LocalMcpClient::new(registry, validator));
let consent_called = Arc::new(AtomicBool::new(false));
let consent_called_clone = consent_called.clone();
let consent_callback: ConsentCallback = Arc::new(move |_tool, _call| {
consent_called_clone.store(true, Ordering::SeqCst);
false // Deny
});
let mut config_mut = (*config).clone();
config_mut.privacy.require_consent_per_session = true;
let permission_layer = PermissionLayer::new(client, Arc::new(config_mut))
.with_consent_callback(consent_callback);
let call = McpToolCall {
name: "resources/write".to_string(),
arguments: serde_json::json!({"path": "test.txt", "content": "hello"}),
};
let result = permission_layer.call_tool(call).await;
assert!(consent_called.load(Ordering::SeqCst));
assert!(result.is_err());
}
}

View File

@@ -1,389 +0,0 @@
/// MCP Protocol Definitions
///
/// This module defines the JSON-RPC protocol contracts for the Model Context Protocol (MCP).
/// It includes request/response schemas, error codes, and versioning semantics.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// MCP Protocol version - uses semantic versioning
pub const PROTOCOL_VERSION: &str = "1.0.0";
/// JSON-RPC version constant
pub const JSONRPC_VERSION: &str = "2.0";
// ============================================================================
// Error Codes and Handling
// ============================================================================
/// Standard JSON-RPC error codes following the spec
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorCode(pub i64);
impl ErrorCode {
// Standard JSON-RPC 2.0 errors
pub const PARSE_ERROR: Self = Self(-32700);
pub const INVALID_REQUEST: Self = Self(-32600);
pub const METHOD_NOT_FOUND: Self = Self(-32601);
pub const INVALID_PARAMS: Self = Self(-32602);
pub const INTERNAL_ERROR: Self = Self(-32603);
// MCP-specific errors (range -32000 to -32099)
pub const TOOL_NOT_FOUND: Self = Self(-32000);
pub const TOOL_EXECUTION_FAILED: Self = Self(-32001);
pub const PERMISSION_DENIED: Self = Self(-32002);
pub const RESOURCE_NOT_FOUND: Self = Self(-32003);
pub const TIMEOUT: Self = Self(-32004);
pub const VALIDATION_ERROR: Self = Self(-32005);
pub const PATH_TRAVERSAL: Self = Self(-32006);
pub const RATE_LIMIT_EXCEEDED: Self = Self(-32007);
}
/// Structured error response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl RpcError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code: code.0,
message: message.into(),
data: None,
}
}
pub fn with_data(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
pub fn parse_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::PARSE_ERROR, message)
}
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::new(ErrorCode::INVALID_REQUEST, message)
}
pub fn method_not_found(method: &str) -> Self {
Self::new(
ErrorCode::METHOD_NOT_FOUND,
format!("Method not found: {}", method),
)
}
pub fn invalid_params(message: impl Into<String>) -> Self {
Self::new(ErrorCode::INVALID_PARAMS, message)
}
pub fn internal_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::INTERNAL_ERROR, message)
}
pub fn tool_not_found(tool_name: &str) -> Self {
Self::new(
ErrorCode::TOOL_NOT_FOUND,
format!("Tool not found: {}", tool_name),
)
}
pub fn permission_denied(message: impl Into<String>) -> Self {
Self::new(ErrorCode::PERMISSION_DENIED, message)
}
pub fn path_traversal() -> Self {
Self::new(ErrorCode::PATH_TRAVERSAL, "Path traversal attempt detected")
}
}
// ============================================================================
// Request/Response Structures
// ============================================================================
/// JSON-RPC request structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcRequest {
pub jsonrpc: String,
pub id: RequestId,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
impl RpcRequest {
pub fn new(id: RequestId, method: impl Into<String>, params: Option<Value>) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_string(),
id,
method: method.into(),
params,
}
}
}
/// JSON-RPC response structure (success)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcResponse {
pub jsonrpc: String,
pub id: RequestId,
pub result: Value,
}
impl RpcResponse {
pub fn new(id: RequestId, result: Value) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_string(),
id,
result,
}
}
}
/// JSON-RPC error response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcErrorResponse {
pub jsonrpc: String,
pub id: RequestId,
pub error: RpcError,
}
impl RpcErrorResponse {
pub fn new(id: RequestId, error: RpcError) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_string(),
id,
error,
}
}
}
/// JSONRPC notification (no id). Used for streaming partial results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcNotification {
pub jsonrpc: String,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
impl RpcNotification {
pub fn new(method: impl Into<String>, params: Option<Value>) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_string(),
method: method.into(),
params,
}
}
}
/// Request ID can be string, number, or null
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(untagged)]
pub enum RequestId {
Number(u64),
String(String),
}
impl From<u64> for RequestId {
fn from(n: u64) -> Self {
Self::Number(n)
}
}
impl From<String> for RequestId {
fn from(s: String) -> Self {
Self::String(s)
}
}
// ============================================================================
// MCP Method Names
// ============================================================================
/// Standard MCP methods
pub mod methods {
pub const INITIALIZE: &str = "initialize";
pub const TOOLS_LIST: &str = "tools/list";
pub const TOOLS_CALL: &str = "tools/call";
pub const RESOURCES_LIST: &str = "resources/list";
pub const RESOURCES_GET: &str = "resources/get";
pub const RESOURCES_WRITE: &str = "resources/write";
pub const RESOURCES_DELETE: &str = "resources/delete";
pub const MODELS_LIST: &str = "models/list";
}
// ============================================================================
// Initialization Protocol
// ============================================================================
/// Initialize request parameters
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitializeParams {
pub protocol_version: String,
pub client_info: ClientInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<ClientCapabilities>,
}
impl Default for InitializeParams {
fn default() -> Self {
Self {
protocol_version: PROTOCOL_VERSION.to_string(),
client_info: ClientInfo {
name: "owlen".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: None,
}
}
}
/// Client information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientInfo {
pub name: String,
pub version: String,
}
/// Client capabilities
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub supports_streaming: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supports_cancellation: Option<bool>,
}
/// Initialize response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitializeResult {
pub protocol_version: String,
pub server_info: ServerInfo,
pub capabilities: ServerCapabilities,
}
/// Server information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub name: String,
pub version: String,
}
/// Server capabilities
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServerCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub supports_tools: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supports_resources: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supports_streaming: Option<bool>,
}
// ============================================================================
// Tool Call Protocol
// ============================================================================
/// Parameters for tools/list
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolsListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<String>,
}
/// Parameters for tools/call
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsCallParams {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Value>,
}
/// Result of tools/call
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsCallResult {
pub success: bool,
pub output: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
// ============================================================================
// Resource Protocol
// ============================================================================
/// Parameters for resources/list
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcesListParams {
pub path: String,
}
/// Parameters for resources/get
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcesGetParams {
pub path: String,
}
/// Parameters for resources/write
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcesWriteParams {
pub path: String,
pub content: String,
}
/// Parameters for resources/delete
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcesDeleteParams {
pub path: String,
}
// ============================================================================
// Versioning and Compatibility
// ============================================================================
/// Check if a protocol version is compatible
pub fn is_compatible(client_version: &str, server_version: &str) -> bool {
// For now, simple exact match on major version
let client_major = client_version.split('.').next().unwrap_or("0");
let server_major = server_version.split('.').next().unwrap_or("0");
client_major == server_major
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_codes() {
let err = RpcError::tool_not_found("test_tool");
assert_eq!(err.code, ErrorCode::TOOL_NOT_FOUND.0);
assert!(err.message.contains("test_tool"));
}
#[test]
fn test_version_compatibility() {
assert!(is_compatible("1.0.0", "1.0.0"));
assert!(is_compatible("1.0.0", "1.1.0"));
assert!(is_compatible("1.2.5", "1.0.0"));
assert!(!is_compatible("1.0.0", "2.0.0"));
assert!(!is_compatible("2.0.0", "1.0.0"));
}
#[test]
fn test_request_serialization() {
let req = RpcRequest::new(
RequestId::Number(1),
"tools/call",
Some(serde_json::json!({"name": "test"})),
);
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"method\":\"tools/call\""));
}
}

View File

@@ -1,528 +0,0 @@
use super::protocol::methods;
use super::protocol::{
RequestId, RpcErrorResponse, RpcNotification, RpcRequest, RpcResponse, PROTOCOL_VERSION,
};
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::consent::{ConsentManager, ConsentScope};
use crate::tools::{Tool, WebScrapeTool, WebSearchTool};
use crate::types::ModelInfo;
use crate::{Error, Provider, Result};
use async_trait::async_trait;
use reqwest::Client as HttpClient;
use serde_json::json;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use tungstenite::protocol::Message as WsMessage;
// Provider trait is already imported via the earlier use statement.
use crate::types::{ChatResponse, Message, Role};
use futures::stream;
use futures::StreamExt;
/// Client that talks to the external `owlen-mcp-server` over STDIO, HTTP, or WebSocket.
pub struct RemoteMcpClient {
// Child process handling the server (kept alive for the duration of the client).
#[allow(dead_code)]
// For stdio transport, we keep the child process handles.
child: Option<Arc<Mutex<Child>>>,
stdin: Option<Arc<Mutex<tokio::process::ChildStdin>>>, // async write
stdout: Option<Arc<Mutex<BufReader<tokio::process::ChildStdout>>>>,
// For HTTP transport we keep a reusable client and base URL.
http_client: Option<HttpClient>,
http_endpoint: Option<String>,
// For WebSocket transport we keep a WebSocket stream.
ws_stream: Option<Arc<Mutex<WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>>>>,
#[allow(dead_code)] // Useful for debugging/logging
ws_endpoint: Option<String>,
// Incrementing request identifier.
next_id: AtomicU64,
}
impl RemoteMcpClient {
/// Spawn the MCP server binary and prepare communication channels.
/// Spawn an MCP server based on a configuration entry.
/// The `transport` field must be "stdio" (the only supported mode).
/// Spawn an external MCP server based on a configuration entry.
/// The server must communicate over STDIO (the only supported transport).
pub fn new_with_config(config: &crate::config::McpServerConfig) -> Result<Self> {
let transport = config.transport.to_lowercase();
match transport.as_str() {
"stdio" => {
// Build the command using the provided binary and arguments.
let mut cmd = Command::new(config.command.clone());
if !config.args.is_empty() {
cmd.args(config.args.clone());
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
// Apply environment variables defined in the configuration.
for (k, v) in config.env.iter() {
cmd.env(k, v);
}
let mut child = cmd.spawn().map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to spawn MCP server '{}': {}", config.name, e),
))
})?;
let stdin = child.stdin.take().ok_or_else(|| {
Error::Io(std::io::Error::other(
"Failed to capture stdin of MCP server",
))
})?;
let stdout = child.stdout.take().ok_or_else(|| {
Error::Io(std::io::Error::other(
"Failed to capture stdout of MCP server",
))
})?;
Ok(Self {
child: Some(Arc::new(Mutex::new(child))),
stdin: Some(Arc::new(Mutex::new(stdin))),
stdout: Some(Arc::new(Mutex::new(BufReader::new(stdout)))),
http_client: None,
http_endpoint: None,
ws_stream: None,
ws_endpoint: None,
next_id: AtomicU64::new(1),
})
}
"http" => {
// For HTTP we treat `command` as the base URL.
let client = HttpClient::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| Error::Network(e.to_string()))?;
Ok(Self {
child: None,
stdin: None,
stdout: None,
http_client: Some(client),
http_endpoint: Some(config.command.clone()),
ws_stream: None,
ws_endpoint: None,
next_id: AtomicU64::new(1),
})
}
"websocket" => {
// For WebSocket, the `command` field contains the WebSocket URL.
// We need to use a blocking task to establish the connection.
let ws_url = config.command.clone();
let (ws_stream, _response) = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
connect_async(&ws_url).await.map_err(|e| {
Error::Network(format!("WebSocket connection failed: {}", e))
})
})
})?;
Ok(Self {
child: None,
stdin: None,
stdout: None,
http_client: None,
http_endpoint: None,
ws_stream: Some(Arc::new(Mutex::new(ws_stream))),
ws_endpoint: Some(ws_url),
next_id: AtomicU64::new(1),
})
}
other => Err(Error::NotImplemented(format!(
"Transport '{}' not supported",
other
))),
}
}
/// Legacy constructor kept for compatibility; attempts to locate a binary.
pub fn new() -> Result<Self> {
// Fall back to searching for a binary as before, then delegate to new_with_config.
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.map_err(Error::Io)?;
// Prefer the LLM server binary as it provides both LLM and resource tools.
// The generic file-server is kept as a fallback for testing.
let candidates = [
"target/debug/owlen-mcp-llm-server",
"target/release/owlen-mcp-llm-server",
"target/debug/owlen-mcp-server",
];
let binary_path = candidates
.iter()
.map(|rel| workspace_root.join(rel))
.find(|p| p.exists())
.ok_or_else(|| {
Error::NotImplemented(format!(
"owlen-mcp server binary not found; checked {}, {}, and {}",
candidates[0], candidates[1], candidates[2]
))
})?;
let config = crate::config::McpServerConfig {
name: "default".to_string(),
command: binary_path.to_string_lossy().into_owned(),
args: Vec::new(),
transport: "stdio".to_string(),
env: std::collections::HashMap::new(),
};
Self::new_with_config(&config)
}
async fn send_rpc(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
let id = RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed));
let request = RpcRequest::new(id.clone(), method, Some(params));
let req_str = serde_json::to_string(&request)? + "\n";
// For stdio transport we forward the request to the child process.
if let Some(stdin_arc) = &self.stdin {
let mut stdin = stdin_arc.lock().await;
stdin.write_all(req_str.as_bytes()).await?;
stdin.flush().await?;
}
// Read a single line response
// Handle based on selected transport.
if let Some(client) = &self.http_client {
// HTTP: POST JSON body to endpoint.
let endpoint = self
.http_endpoint
.as_ref()
.ok_or_else(|| Error::Network("Missing HTTP endpoint".into()))?;
let resp = client
.post(endpoint)
.json(&request)
.send()
.await
.map_err(|e| Error::Network(e.to_string()))?;
let text = resp
.text()
.await
.map_err(|e| Error::Network(e.to_string()))?;
// Try to parse as success then error.
if let Ok(r) = serde_json::from_str::<RpcResponse>(&text) {
if r.id == id {
return Ok(r.result);
}
}
let err_resp: RpcErrorResponse =
serde_json::from_str(&text).map_err(Error::Serialization)?;
return Err(Error::Network(format!(
"MCP server error {}: {}",
err_resp.error.code, err_resp.error.message
)));
}
// WebSocket path.
if let Some(ws_arc) = &self.ws_stream {
use futures::SinkExt;
let mut ws = ws_arc.lock().await;
// Send request as text message
let req_json = serde_json::to_string(&request)?;
ws.send(WsMessage::Text(req_json))
.await
.map_err(|e| Error::Network(format!("WebSocket send failed: {}", e)))?;
// Read response
let response_msg = ws
.next()
.await
.ok_or_else(|| Error::Network("WebSocket stream closed".into()))?
.map_err(|e| Error::Network(format!("WebSocket receive failed: {}", e)))?;
let response_text = match response_msg {
WsMessage::Text(text) => text,
WsMessage::Binary(data) => String::from_utf8(data).map_err(|e| {
Error::Network(format!("Invalid UTF-8 in binary message: {}", e))
})?,
WsMessage::Close(_) => {
return Err(Error::Network(
"WebSocket connection closed by server".into(),
));
}
_ => return Err(Error::Network("Unexpected WebSocket message type".into())),
};
// Try to parse as success then error.
if let Ok(r) = serde_json::from_str::<RpcResponse>(&response_text) {
if r.id == id {
return Ok(r.result);
}
}
let err_resp: RpcErrorResponse =
serde_json::from_str(&response_text).map_err(Error::Serialization)?;
return Err(Error::Network(format!(
"MCP server error {}: {}",
err_resp.error.code, err_resp.error.message
)));
}
// STDIO path (default).
// Loop to skip notifications and find the response with matching ID.
loop {
let mut line = String::new();
{
let mut stdout = self
.stdout
.as_ref()
.ok_or_else(|| Error::Network("STDIO stdout not available".into()))?
.lock()
.await;
stdout.read_line(&mut line).await?;
}
// Try to parse as notification first (has no id field)
if let Ok(_notif) = serde_json::from_str::<RpcNotification>(&line) {
// Skip notifications and continue reading
continue;
}
// Try to parse successful response
if let Ok(resp) = serde_json::from_str::<RpcResponse>(&line) {
if resp.id == id {
return Ok(resp.result);
}
// If ID doesn't match, continue (though this shouldn't happen)
continue;
}
// Fallback to error response
if let Ok(err_resp) = serde_json::from_str::<RpcErrorResponse>(&line) {
return Err(Error::Network(format!(
"MCP server error {}: {}",
err_resp.error.code, err_resp.error.message
)));
}
// If we can't parse as any known type, return error
return Err(Error::Network(format!(
"Unable to parse server response: {}",
line.trim()
)));
}
}
}
impl RemoteMcpClient {
/// Convenience wrapper delegating to the `McpClient` trait methods.
pub async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
<Self as McpClient>::list_tools(self).await
}
pub async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
<Self as McpClient>::call_tool(self, call).await
}
}
#[async_trait::async_trait]
impl McpClient for RemoteMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
// Query the remote MCP server for its tool descriptors using the standard
// `tools/list` RPC method. The server returns a JSON array of
// `McpToolDescriptor` objects.
let result = self.send_rpc(methods::TOOLS_LIST, json!(null)).await?;
let descriptors: Vec<McpToolDescriptor> = serde_json::from_value(result)?;
Ok(descriptors)
}
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
// Local handling for simple resource tools to avoid needing the MCP server
// to implement them.
if call.name.starts_with("resources/get") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
let content = std::fs::read_to_string(path).map_err(Error::Io)?;
return Ok(McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!(content),
metadata: std::collections::HashMap::new(),
duration_ms: 0,
});
}
if call.name.starts_with("resources/list") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
let mut names = Vec::new();
for entry in std::fs::read_dir(path).map_err(Error::Io)?.flatten() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
return Ok(McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!(names),
metadata: std::collections::HashMap::new(),
duration_ms: 0,
});
}
// Handle write and delete resources locally as well.
if call.name.starts_with("resources/write") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
// Simple pathtraversal protection: reject any path containing ".." or absolute paths.
if path.contains("..") || Path::new(path).is_absolute() {
return Err(Error::InvalidInput("path traversal".into()));
}
let content = call
.arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidInput("content missing".into()))?;
std::fs::write(path, content).map_err(Error::Io)?;
return Ok(McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!(null),
metadata: std::collections::HashMap::new(),
duration_ms: 0,
});
}
if call.name.starts_with("resources/delete") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
if path.contains("..") || Path::new(path).is_absolute() {
return Err(Error::InvalidInput("path traversal".into()));
}
std::fs::remove_file(path).map_err(Error::Io)?;
return Ok(McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!(null),
metadata: std::collections::HashMap::new(),
duration_ms: 0,
});
}
// Local handling for web tools to avoid needing an external MCP server.
if call.name == "web_search" {
// Autogrant consent for the web_search tool (permanent for this process).
let consent_manager = std::sync::Arc::new(std::sync::Mutex::new(ConsentManager::new()));
{
let mut cm = consent_manager.lock().unwrap();
cm.grant_consent_with_scope(
"web_search",
Vec::new(),
Vec::new(),
ConsentScope::Permanent,
);
}
let tool = WebSearchTool::new(consent_manager.clone(), None, None);
let result = tool
.execute(call.arguments.clone())
.await
.map_err(|e| Error::Provider(e.into()))?;
return Ok(McpToolResponse {
name: call.name,
success: true,
output: result.output,
metadata: std::collections::HashMap::new(),
duration_ms: result.duration.as_millis() as u128,
});
}
if call.name == "web_scrape" {
let tool = WebScrapeTool::new();
let result = tool
.execute(call.arguments.clone())
.await
.map_err(|e| Error::Provider(e.into()))?;
return Ok(McpToolResponse {
name: call.name,
success: true,
output: result.output,
metadata: std::collections::HashMap::new(),
duration_ms: result.duration.as_millis() as u128,
});
}
// MCP server expects a generic "tools/call" method with a payload containing the
// specific tool name and its arguments. Wrap the incoming call accordingly.
let payload = serde_json::to_value(&call)?;
let result = self.send_rpc(methods::TOOLS_CALL, payload).await?;
// The server returns an McpToolResponse; deserialize it.
let response: McpToolResponse = serde_json::from_value(result)?;
Ok(response)
}
}
// ---------------------------------------------------------------------------
// Provider implementation forwards chat requests to the generate_text tool.
// ---------------------------------------------------------------------------
#[async_trait]
impl Provider for RemoteMcpClient {
fn name(&self) -> &str {
"mcp-llm-server"
}
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?;
let models: Vec<ModelInfo> = serde_json::from_value(result)?;
Ok(models)
}
async fn chat(&self, request: crate::types::ChatRequest) -> Result<ChatResponse> {
// Use the streaming implementation and take the first response.
let mut stream = self.chat_stream(request).await?;
match stream.next().await {
Some(Ok(resp)) => Ok(resp),
Some(Err(e)) => Err(e),
None => Err(Error::Provider(anyhow::anyhow!("Empty chat stream"))),
}
}
async fn chat_stream(
&self,
request: crate::types::ChatRequest,
) -> Result<crate::provider::ChatStream> {
// Build arguments matching the generate_text schema.
let args = serde_json::json!({
"messages": request.messages,
"temperature": request.parameters.temperature,
"max_tokens": request.parameters.max_tokens,
"model": request.model,
"stream": request.parameters.stream,
});
let call = McpToolCall {
name: "generate_text".to_string(),
arguments: args,
};
let resp = self.call_tool(call).await?;
// Build a ChatResponse from the tool output (assumed to be a string).
let content = resp.output.as_str().unwrap_or("").to_string();
let message = Message::new(Role::Assistant, content);
let chat_resp = ChatResponse {
message,
usage: None,
is_streaming: false,
is_final: true,
};
let stream = stream::once(async move { Ok(chat_resp) });
Ok(Box::pin(stream))
}
async fn health_check(&self) -> Result<()> {
// Simple ping using initialize method.
let params = serde_json::json!({"protocol_version": PROTOCOL_VERSION});
self.send_rpc("initialize", params).await.map(|_| ())
}
}

View File

@@ -1,182 +0,0 @@
//! Operating modes for Owlen
//!
//! Defines the different modes in which Owlen can operate and their associated
//! tool availability policies.
use serde::{Deserialize, Serialize};
use std::str::FromStr;
/// Operating mode for Owlen
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
/// Chat mode - limited tool access, safe for general conversation
#[default]
Chat,
/// Code mode - full tool access for development tasks
Code,
}
impl Mode {
/// Get the display name for this mode
pub fn display_name(&self) -> &'static str {
match self {
Mode::Chat => "chat",
Mode::Code => "code",
}
}
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.display_name())
}
}
impl FromStr for Mode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"chat" => Ok(Mode::Chat),
"code" => Ok(Mode::Code),
_ => Err(format!(
"Invalid mode: '{}'. Valid modes are 'chat' or 'code'",
s
)),
}
}
}
/// Configuration for tool availability in different modes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModeConfig {
/// Tools allowed in chat mode
#[serde(default = "ModeConfig::default_chat_tools")]
pub chat: ModeToolConfig,
/// Tools allowed in code mode
#[serde(default = "ModeConfig::default_code_tools")]
pub code: ModeToolConfig,
}
impl Default for ModeConfig {
fn default() -> Self {
Self {
chat: Self::default_chat_tools(),
code: Self::default_code_tools(),
}
}
}
impl ModeConfig {
fn default_chat_tools() -> ModeToolConfig {
ModeToolConfig {
allowed_tools: vec!["web_search".to_string()],
}
}
fn default_code_tools() -> ModeToolConfig {
ModeToolConfig {
allowed_tools: vec!["*".to_string()], // All tools allowed
}
}
/// Check if a tool is allowed in the given mode
pub fn is_tool_allowed(&self, mode: Mode, tool_name: &str) -> bool {
let config = match mode {
Mode::Chat => &self.chat,
Mode::Code => &self.code,
};
config.is_tool_allowed(tool_name)
}
}
/// Tool configuration for a specific mode
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModeToolConfig {
/// List of allowed tools. Use "*" to allow all tools.
pub allowed_tools: Vec<String>,
}
impl ModeToolConfig {
/// Check if a tool is allowed in this mode
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
// Check for wildcard
if self.allowed_tools.iter().any(|t| t == "*") {
return true;
}
// Check if tool is explicitly listed
self.allowed_tools.iter().any(|t| t == tool_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mode_display() {
assert_eq!(Mode::Chat.to_string(), "chat");
assert_eq!(Mode::Code.to_string(), "code");
}
#[test]
fn test_mode_from_str() {
assert_eq!("chat".parse::<Mode>(), Ok(Mode::Chat));
assert_eq!("code".parse::<Mode>(), Ok(Mode::Code));
assert_eq!("CHAT".parse::<Mode>(), Ok(Mode::Chat));
assert_eq!("CODE".parse::<Mode>(), Ok(Mode::Code));
assert!("invalid".parse::<Mode>().is_err());
}
#[test]
fn test_default_mode() {
assert_eq!(Mode::default(), Mode::Chat);
}
#[test]
fn test_chat_mode_restrictions() {
let config = ModeConfig::default();
// Web search should be allowed in chat mode
assert!(config.is_tool_allowed(Mode::Chat, "web_search"));
// Code exec should not be allowed in chat mode
assert!(!config.is_tool_allowed(Mode::Chat, "code_exec"));
assert!(!config.is_tool_allowed(Mode::Chat, "file_write"));
}
#[test]
fn test_code_mode_allows_all() {
let config = ModeConfig::default();
// All tools should be allowed in code mode
assert!(config.is_tool_allowed(Mode::Code, "web_search"));
assert!(config.is_tool_allowed(Mode::Code, "code_exec"));
assert!(config.is_tool_allowed(Mode::Code, "file_write"));
assert!(config.is_tool_allowed(Mode::Code, "anything"));
}
#[test]
fn test_wildcard_tool_config() {
let config = ModeToolConfig {
allowed_tools: vec!["*".to_string()],
};
assert!(config.is_tool_allowed("any_tool"));
assert!(config.is_tool_allowed("another_tool"));
}
#[test]
fn test_explicit_tool_list() {
let config = ModeToolConfig {
allowed_tools: vec!["tool1".to_string(), "tool2".to_string()],
};
assert!(config.is_tool_allowed("tool1"));
assert!(config.is_tool_allowed("tool2"));
assert!(!config.is_tool_allowed("tool3"));
}
}

View File

@@ -17,7 +17,7 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
/// use std::sync::Arc;
/// use futures::Stream;
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message, Role, ChatParameters};
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message};
/// use owlen_core::Result;
///
/// // 1. Create a mock provider
@@ -31,23 +31,18 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
///
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
/// Ok(vec![ModelInfo {
/// id: "mock-model".to_string(),
/// provider: "mock".to_string(),
/// name: "mock-model".to_string(),
/// description: None,
/// context_window: None,
/// capabilities: vec![],
/// supports_tools: false,
/// ..Default::default()
/// }])
/// }
///
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
/// Ok(ChatResponse {
/// message: Message::new(Role::Assistant, content),
/// usage: None,
/// is_streaming: false,
/// is_final: true,
/// model: request.model,
/// message: Message { role: "assistant".to_string(), content, ..Default::default() },
/// ..Default::default()
/// })
/// }
///
@@ -72,9 +67,8 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
///
/// let request = ChatRequest {
/// model: "mock-model".to_string(),
/// messages: vec![Message::new(Role::User, "Hello".to_string())],
/// parameters: ChatParameters::default(),
/// tools: None,
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }],
/// ..Default::default()
/// };
///
/// let response = provider.chat(request).await.unwrap();
@@ -174,49 +168,3 @@ impl Default for ProviderRegistry {
Self::new()
}
}
#[cfg(test)]
pub mod test_utils {
use super::*;
use crate::types::{ChatRequest, ChatResponse, Message, ModelInfo, Role};
/// Mock provider for testing
#[derive(Default)]
pub struct MockProvider;
#[async_trait::async_trait]
impl Provider for MockProvider {
fn name(&self) -> &str {
"mock"
}
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
Ok(vec![ModelInfo {
id: "mock-model".to_string(),
provider: "mock".to_string(),
name: "mock-model".to_string(),
description: None,
context_window: None,
capabilities: vec![],
supports_tools: false,
}])
}
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse> {
Ok(ChatResponse {
message: Message::new(Role::Assistant, "Mock response".to_string()),
usage: None,
is_streaming: false,
is_final: true,
})
}
async fn chat_stream(&self, _request: ChatRequest) -> Result<ChatStream> {
unimplemented!("MockProvider does not support streaming")
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
}
}

View File

@@ -1,212 +0,0 @@
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use tempfile::TempDir;
/// Configuration options for sandboxed process execution.
#[derive(Clone, Debug)]
pub struct SandboxConfig {
pub allow_network: bool,
pub allow_paths: Vec<PathBuf>,
pub readonly_paths: Vec<PathBuf>,
pub timeout_seconds: u64,
pub max_memory_mb: u64,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_network: false,
allow_paths: Vec::new(),
readonly_paths: Vec::new(),
timeout_seconds: 30,
max_memory_mb: 512,
}
}
}
/// Wrapper around a bubblewrap sandbox instance.
///
/// Memory limits are enforced via:
/// - bwrap's --rlimit-as (version >= 0.12.0)
/// - prlimit wrapper (fallback for older bwrap versions)
/// - timeout mechanism (always enforced as last resort)
pub struct SandboxedProcess {
temp_dir: TempDir,
config: SandboxConfig,
}
impl SandboxedProcess {
pub fn new(config: SandboxConfig) -> Result<Self> {
let temp_dir = TempDir::new().context("Failed to create temp directory")?;
which::which("bwrap")
.context("bubblewrap not found. Install with: sudo apt install bubblewrap")?;
Ok(Self { temp_dir, config })
}
pub fn execute(&self, command: &str, args: &[&str]) -> Result<SandboxResult> {
let supports_rlimit = self.supports_rlimit_as();
let use_prlimit = !supports_rlimit && which::which("prlimit").is_ok();
let mut cmd = if use_prlimit {
// Use prlimit wrapper for older bwrap versions
let mut prlimit_cmd = Command::new("prlimit");
let memory_limit_bytes = self
.config
.max_memory_mb
.saturating_mul(1024)
.saturating_mul(1024);
prlimit_cmd.arg(format!("--as={}", memory_limit_bytes));
prlimit_cmd.arg("bwrap");
prlimit_cmd
} else {
Command::new("bwrap")
};
cmd.args(["--unshare-all", "--die-with-parent", "--new-session"]);
if self.config.allow_network {
cmd.arg("--share-net");
} else {
cmd.arg("--unshare-net");
}
cmd.args(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]);
// Bind essential system paths readonly for executables and libraries
let system_paths = ["/usr", "/bin", "/lib", "/lib64", "/etc"];
for sys_path in &system_paths {
let path = std::path::Path::new(sys_path);
if path.exists() {
cmd.arg("--ro-bind").arg(sys_path).arg(sys_path);
}
}
// Bind /run for DNS resolution (resolv.conf may be a symlink to /run/systemd/resolve/*)
if std::path::Path::new("/run").exists() {
cmd.arg("--ro-bind").arg("/run").arg("/run");
}
for path in &self.config.allow_paths {
let path_host = path.to_string_lossy().into_owned();
let path_guest = path_host.clone();
cmd.arg("--bind").arg(&path_host).arg(&path_guest);
}
for path in &self.config.readonly_paths {
let path_host = path.to_string_lossy().into_owned();
let path_guest = path_host.clone();
cmd.arg("--ro-bind").arg(&path_host).arg(&path_guest);
}
let work_dir = self.temp_dir.path().to_string_lossy().into_owned();
cmd.arg("--bind").arg(&work_dir).arg("/work");
cmd.arg("--chdir").arg("/work");
// Add memory limits via bwrap's --rlimit-as if supported (version >= 0.12.0)
// If not supported, we use prlimit wrapper (set earlier)
if supports_rlimit && !use_prlimit {
let memory_limit_bytes = self
.config
.max_memory_mb
.saturating_mul(1024)
.saturating_mul(1024);
let memory_soft = memory_limit_bytes.to_string();
let memory_hard = memory_limit_bytes.to_string();
cmd.arg("--rlimit-as").arg(&memory_soft).arg(&memory_hard);
}
cmd.arg(command);
cmd.args(args);
let start = Instant::now();
let timeout = Duration::from_secs(self.config.timeout_seconds);
// Spawn the process instead of waiting immediately
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn sandboxed command")?;
let mut was_timeout = false;
// Wait for the child with timeout
let output = loop {
match child.try_wait() {
Ok(Some(_status)) => {
// Process exited
let output = child
.wait_with_output()
.context("Failed to collect process output")?;
break output;
}
Ok(None) => {
// Process still running, check timeout
if start.elapsed() >= timeout {
// Timeout exceeded, kill the process
was_timeout = true;
child.kill().context("Failed to kill timed-out process")?;
// Wait for the killed process to exit
let output = child
.wait_with_output()
.context("Failed to collect output from killed process")?;
break output;
}
// Sleep briefly before checking again
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
bail!("Failed to check process status: {}", e);
}
}
};
let duration = start.elapsed();
Ok(SandboxResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
duration,
was_timeout,
})
}
/// Check if bubblewrap supports --rlimit-as option (version >= 0.12.0)
fn supports_rlimit_as(&self) -> bool {
// Try to get bwrap version
let output = Command::new("bwrap").arg("--version").output();
if let Ok(output) = output {
let version_str = String::from_utf8_lossy(&output.stdout);
// Parse version like "bubblewrap 0.11.0" or "0.11.0"
if let Some(version_part) = version_str.split_whitespace().last() {
if let Some((major, rest)) = version_part.split_once('.') {
if let Some((minor, _patch)) = rest.split_once('.') {
if let (Ok(maj), Ok(min)) = (major.parse::<u32>(), minor.parse::<u32>()) {
// --rlimit-as was added in 0.12.0
return maj > 0 || (maj == 0 && min >= 12);
}
}
}
}
}
// If we can't determine the version, assume it doesn't support it (safer default)
false
}
}
#[derive(Debug, Clone)]
pub struct SandboxResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub duration: Duration,
pub was_timeout: bool,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,19 @@
//! Session persistence and storage management backed by SQLite
//! Session persistence and storage management
use crate::types::Conversation;
use crate::{Error, Result};
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{Pool, Row, Sqlite};
use std::fs;
use std::io::IsTerminal;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use std::time::SystemTime;
/// Metadata about a saved session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
/// Session file path
pub path: PathBuf,
/// Conversation ID
pub id: Uuid,
pub id: uuid::Uuid,
/// Optional session name
pub name: Option<String>,
/// Optional AI-generated description
@@ -35,525 +28,282 @@ pub struct SessionMeta {
pub updated_at: SystemTime,
}
/// Storage manager for persisting conversations in SQLite
/// Storage manager for persisting conversations
pub struct StorageManager {
pool: Pool<Sqlite>,
database_path: PathBuf,
sessions_dir: PathBuf,
}
impl StorageManager {
/// Create a new storage manager using the default database path
pub async fn new() -> Result<Self> {
let db_path = Self::default_database_path()?;
Self::with_database_path(db_path).await
/// Create a new storage manager with the default sessions directory
pub fn new() -> Result<Self> {
let sessions_dir = Self::default_sessions_dir()?;
Self::with_directory(sessions_dir)
}
/// Create a storage manager using the provided database path
pub async fn with_database_path(database_path: PathBuf) -> Result<Self> {
if let Some(parent) = database_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| {
Error::Storage(format!(
"Failed to create database directory {parent:?}: {e}"
))
})?;
}
/// Create a storage manager with a custom sessions directory
pub fn with_directory(sessions_dir: PathBuf) -> Result<Self> {
// Ensure the directory exists
if !sessions_dir.exists() {
fs::create_dir_all(&sessions_dir).map_err(|e| {
Error::Storage(format!("Failed to create sessions directory: {}", e))
})?;
}
let options = SqliteConnectOptions::from_str(&format!(
"sqlite://{}",
database_path
.to_str()
.ok_or_else(|| Error::Storage("Invalid database path".to_string()))?
))
.map_err(|e| Error::Storage(format!("Invalid database URL: {e}")))?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.map_err(|e| Error::Storage(format!("Failed to connect to database: {e}")))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| Error::Storage(format!("Failed to run database migrations: {e}")))?;
let storage = Self {
pool,
database_path,
};
storage.try_migrate_legacy_sessions().await?;
Ok(storage)
Ok(Self { sessions_dir })
}
/// Save a conversation. Existing entries are updated in-place.
pub async fn save_conversation(
&self,
conversation: &Conversation,
name: Option<String>,
) -> Result<()> {
self.save_conversation_with_description(conversation, name, None)
.await
}
/// Save a conversation with an optional description override
pub async fn save_conversation_with_description(
&self,
conversation: &Conversation,
name: Option<String>,
description: Option<String>,
) -> Result<()> {
let mut serialized = conversation.clone();
if name.is_some() {
serialized.name = name.clone();
}
if description.is_some() {
serialized.description = description.clone();
}
let data = serde_json::to_string(&serialized)
.map_err(|e| Error::Storage(format!("Failed to serialize conversation: {e}")))?;
let created_at = to_epoch_seconds(serialized.created_at);
let updated_at = to_epoch_seconds(serialized.updated_at);
let message_count = serialized.messages.len() as i64;
sqlx::query(
r#"
INSERT INTO conversations (
id,
name,
description,
model,
message_count,
created_at,
updated_at,
data
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
model = excluded.model,
message_count = excluded.message_count,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
data = excluded.data
"#,
)
.bind(serialized.id.to_string())
.bind(name.or(serialized.name.clone()))
.bind(description.or(serialized.description.clone()))
.bind(&serialized.model)
.bind(message_count)
.bind(created_at)
.bind(updated_at)
.bind(data)
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to save conversation: {e}")))?;
Ok(())
}
/// Load a conversation by ID
pub async fn load_conversation(&self, id: Uuid) -> Result<Conversation> {
let record = sqlx::query(r#"SELECT data FROM conversations WHERE id = ?1"#)
.bind(id.to_string())
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to load conversation: {e}")))?;
let row =
record.ok_or_else(|| Error::Storage(format!("No conversation found with id {id}")))?;
let data: String = row
.try_get("data")
.map_err(|e| Error::Storage(format!("Failed to read conversation payload: {e}")))?;
serde_json::from_str(&data)
.map_err(|e| Error::Storage(format!("Failed to deserialize conversation: {e}")))
}
/// List metadata for all saved conversations ordered by most recent update
pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let rows = sqlx::query(
r#"
SELECT id, name, description, model, message_count, created_at, updated_at
FROM conversations
ORDER BY updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to list sessions: {e}")))?;
let mut sessions = Vec::with_capacity(rows.len());
for row in rows {
let id_text: String = row
.try_get("id")
.map_err(|e| Error::Storage(format!("Failed to read id column: {e}")))?;
let id = Uuid::parse_str(&id_text)
.map_err(|e| Error::Storage(format!("Invalid UUID in storage: {e}")))?;
let message_count: i64 = row
.try_get("message_count")
.map_err(|e| Error::Storage(format!("Failed to read message count: {e}")))?;
let created_at: i64 = row
.try_get("created_at")
.map_err(|e| Error::Storage(format!("Failed to read created_at: {e}")))?;
let updated_at: i64 = row
.try_get("updated_at")
.map_err(|e| Error::Storage(format!("Failed to read updated_at: {e}")))?;
sessions.push(SessionMeta {
id,
name: row
.try_get("name")
.map_err(|e| Error::Storage(format!("Failed to read name: {e}")))?,
description: row
.try_get("description")
.map_err(|e| Error::Storage(format!("Failed to read description: {e}")))?,
model: row
.try_get("model")
.map_err(|e| Error::Storage(format!("Failed to read model: {e}")))?,
message_count: message_count as usize,
created_at: from_epoch_seconds(created_at),
updated_at: from_epoch_seconds(updated_at),
});
}
Ok(sessions)
}
/// Delete a conversation by ID
pub async fn delete_session(&self, id: Uuid) -> Result<()> {
sqlx::query("DELETE FROM conversations WHERE id = ?1")
.bind(id.to_string())
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to delete conversation: {e}")))?;
Ok(())
}
pub async fn store_secure_item(
&self,
key: &str,
plaintext: &[u8],
master_key: &[u8],
) -> Result<()> {
let cipher = create_cipher(master_key)?;
let nonce_bytes = generate_nonce()?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| Error::Storage(format!("Failed to encrypt secure item: {e}")))?;
let now = to_epoch_seconds(SystemTime::now());
sqlx::query(
r#"
INSERT INTO secure_items (key, nonce, ciphertext, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(key) DO UPDATE SET
nonce = excluded.nonce,
ciphertext = excluded.ciphertext,
updated_at = excluded.updated_at
"#,
)
.bind(key)
.bind(&nonce_bytes[..])
.bind(&ciphertext[..])
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to store secure item: {e}")))?;
Ok(())
}
pub async fn load_secure_item(&self, key: &str, master_key: &[u8]) -> Result<Option<Vec<u8>>> {
let record = sqlx::query("SELECT nonce, ciphertext FROM secure_items WHERE key = ?1")
.bind(key)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to load secure item: {e}")))?;
let Some(row) = record else {
return Ok(None);
};
let nonce_bytes: Vec<u8> = row
.try_get("nonce")
.map_err(|e| Error::Storage(format!("Failed to read secure item nonce: {e}")))?;
let ciphertext: Vec<u8> = row
.try_get("ciphertext")
.map_err(|e| Error::Storage(format!("Failed to read secure item ciphertext: {e}")))?;
if nonce_bytes.len() != 12 {
return Err(Error::Storage(
"Invalid nonce length for secure item".to_string(),
));
}
let cipher = create_cipher(master_key)?;
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| Error::Storage(format!("Failed to decrypt secure item: {e}")))?;
Ok(Some(plaintext))
}
pub async fn delete_secure_item(&self, key: &str) -> Result<()> {
sqlx::query("DELETE FROM secure_items WHERE key = ?1")
.bind(key)
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to delete secure item: {e}")))?;
Ok(())
}
pub async fn clear_secure_items(&self) -> Result<()> {
sqlx::query("DELETE FROM secure_items")
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to clear secure items: {e}")))?;
Ok(())
}
/// Database location used by this storage manager
pub fn database_path(&self) -> &Path {
&self.database_path
}
/// Determine default database path (platform specific)
pub fn default_database_path() -> Result<PathBuf> {
let data_dir = dirs::data_local_dir()
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
Ok(data_dir.join("owlen").join("owlen.db"))
}
fn legacy_sessions_dir() -> Result<PathBuf> {
/// Get the default sessions directory
/// - Linux: ~/.local/share/owlen/sessions
/// - Windows: %APPDATA%\owlen\sessions
/// - macOS: ~/Library/Application Support/owlen/sessions
pub fn default_sessions_dir() -> Result<PathBuf> {
let data_dir = dirs::data_local_dir()
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
Ok(data_dir.join("owlen").join("sessions"))
}
async fn database_has_records(&self) -> Result<bool> {
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations")
.fetch_one(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to inspect database: {e}")))?;
Ok(count > 0)
/// Save a conversation to disk
pub fn save_conversation(
&self,
conversation: &Conversation,
name: Option<String>,
) -> Result<PathBuf> {
self.save_conversation_with_description(conversation, name, None)
}
async fn try_migrate_legacy_sessions(&self) -> Result<()> {
if self.database_has_records().await? {
return Ok(());
}
let legacy_dir = match Self::legacy_sessions_dir() {
Ok(dir) => dir,
Err(_) => return Ok(()),
/// Save a conversation to disk with an optional description
pub fn save_conversation_with_description(
&self,
conversation: &Conversation,
name: Option<String>,
description: Option<String>,
) -> Result<PathBuf> {
let filename = if let Some(ref session_name) = name {
// Use provided name, sanitized
let sanitized = sanitize_filename(session_name);
format!("{}_{}.json", conversation.id, sanitized)
} else {
// Use conversation ID and timestamp
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}_{}.json", conversation.id, timestamp)
};
if !legacy_dir.exists() {
return Ok(());
let path = self.sessions_dir.join(filename);
// Create a saveable version with the name and description
let mut save_conv = conversation.clone();
if name.is_some() {
save_conv.name = name;
}
if description.is_some() {
save_conv.description = description;
}
let entries = fs::read_dir(&legacy_dir).map_err(|e| {
Error::Storage(format!("Failed to read legacy sessions directory: {e}"))
})?;
let json = serde_json::to_string_pretty(&save_conv)
.map_err(|e| Error::Storage(format!("Failed to serialize conversation: {}", e)))?;
fs::write(&path, json)
.map_err(|e| Error::Storage(format!("Failed to write session file: {}", e)))?;
Ok(path)
}
/// Load a conversation from disk
pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> {
let content = fs::read_to_string(path.as_ref())
.map_err(|e| Error::Storage(format!("Failed to read session file: {}", e)))?;
let conversation: Conversation = serde_json::from_str(&content)
.map_err(|e| Error::Storage(format!("Failed to parse session file: {}", e)))?;
Ok(conversation)
}
/// List all saved sessions with metadata
pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let mut sessions = Vec::new();
let entries = fs::read_dir(&self.sessions_dir)
.map_err(|e| Error::Storage(format!("Failed to read sessions directory: {}", e)))?;
for entry in entries {
let entry = entry
.map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
let mut json_files = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
json_files.push(path);
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
}
if json_files.is_empty() {
return Ok(());
}
if !io::stdin().is_terminal() {
return Ok(());
}
println!(
"Legacy OWLEN session files were found in {}.",
legacy_dir.display()
);
if !prompt_yes_no("Migrate them to the new SQLite storage? (y/N) ")? {
println!("Skipping legacy session migration.");
return Ok(());
}
println!("Migrating legacy sessions...");
let mut migrated = 0usize;
for path in &json_files {
match fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<Conversation>(&content) {
Ok(conversation) => {
if let Err(err) = self
.save_conversation_with_description(
&conversation,
conversation.name.clone(),
conversation.description.clone(),
)
.await
{
println!(" • Failed to migrate {}: {}", path.display(), err);
} else {
migrated += 1;
}
}
Err(err) => {
println!(
" • Failed to parse conversation {}: {}",
path.display(),
err
);
}
},
Err(err) => {
println!(" • Failed to read {}: {}", path.display(), err);
// Try to load the conversation to extract metadata
match self.load_conversation(&path) {
Ok(conv) => {
sessions.push(SessionMeta {
path: path.clone(),
id: conv.id,
name: conv.name.clone(),
description: conv.description.clone(),
message_count: conv.messages.len(),
model: conv.model.clone(),
created_at: conv.created_at,
updated_at: conv.updated_at,
});
}
Err(_) => {
// Skip files that can't be parsed
continue;
}
}
}
if migrated > 0 {
if let Err(err) = archive_legacy_directory(&legacy_dir) {
println!(
"Warning: migrated sessions but failed to archive legacy directory: {}",
err
);
// Sort by updated_at, most recent first
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(sessions)
}
/// Delete a saved session
pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> {
fs::remove_file(path.as_ref())
.map_err(|e| Error::Storage(format!("Failed to delete session file: {}", e)))
}
/// Get the sessions directory path
pub fn sessions_dir(&self) -> &Path {
&self.sessions_dir
}
}
impl Default for StorageManager {
fn default() -> Self {
Self::new().expect("Failed to create default storage manager")
}
}
/// Sanitize a filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else if c.is_whitespace() {
'_'
} else {
'-'
}
}
println!("Migrated {} legacy sessions.", migrated);
Ok(())
}
}
fn to_epoch_seconds(time: SystemTime) -> i64 {
match time.duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_secs() as i64,
Err(_) => 0,
}
}
fn from_epoch_seconds(seconds: i64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
}
fn prompt_yes_no(prompt: &str) -> Result<bool> {
print!("{}", prompt);
io::stdout()
.flush()
.map_err(|e| Error::Storage(format!("Failed to flush stdout: {e}")))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| Error::Storage(format!("Failed to read input: {e}")))?;
let trimmed = input.trim().to_lowercase();
Ok(matches!(trimmed.as_str(), "y" | "yes"))
}
fn archive_legacy_directory(legacy_dir: &Path) -> Result<()> {
let mut backup_dir = legacy_dir.with_file_name("sessions_legacy_backup");
let mut counter = 1;
while backup_dir.exists() {
backup_dir = legacy_dir.with_file_name(format!("sessions_legacy_backup_{}", counter));
counter += 1;
}
fs::rename(legacy_dir, &backup_dir).map_err(|e| {
Error::Storage(format!(
"Failed to archive legacy sessions directory {}: {}",
legacy_dir.display(),
e
))
})?;
println!("Legacy session files archived to {}", backup_dir.display());
Ok(())
}
fn create_cipher(master_key: &[u8]) -> Result<Aes256Gcm> {
if master_key.len() != 32 {
return Err(Error::Storage(
"Master key must be 32 bytes for AES-256-GCM".to_string(),
));
}
Aes256Gcm::new_from_slice(master_key).map_err(|_| {
Error::Storage("Failed to initialize cipher with provided master key".to_string())
})
}
fn generate_nonce() -> Result<[u8; 12]> {
let mut nonce = [0u8; 12];
SystemRandom::new()
.fill(&mut nonce)
.map_err(|_| Error::Storage("Failed to generate nonce".to_string()))?;
Ok(nonce)
})
.collect::<String>()
.chars()
.take(50) // Limit length
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Conversation, Message};
use tempfile::tempdir;
use crate::types::Message;
use tempfile::TempDir;
fn sample_conversation() -> Conversation {
Conversation {
id: Uuid::new_v4(),
name: Some("Test conversation".to_string()),
description: Some("A sample conversation".to_string()),
messages: vec![
Message::user("Hello".to_string()),
Message::assistant("Hi".to_string()),
],
model: "test-model".to_string(),
created_at: SystemTime::now(),
updated_at: SystemTime::now(),
#[test]
fn test_platform_specific_default_path() {
let path = StorageManager::default_sessions_dir().unwrap();
// Verify it contains owlen/sessions
assert!(path.to_string_lossy().contains("owlen"));
assert!(path.to_string_lossy().contains("sessions"));
// Platform-specific checks
#[cfg(target_os = "linux")]
{
// Linux should use ~/.local/share/owlen/sessions
assert!(path.to_string_lossy().contains(".local/share"));
}
#[cfg(target_os = "windows")]
{
// Windows should use AppData
assert!(path.to_string_lossy().contains("AppData"));
}
#[cfg(target_os = "macos")]
{
// macOS should use ~/Library/Application Support
assert!(path
.to_string_lossy()
.contains("Library/Application Support"));
}
println!("Default sessions directory: {}", path.display());
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("Hello World"), "Hello_World");
assert_eq!(sanitize_filename("test/path\\file"), "test-path-file");
assert_eq!(sanitize_filename("file:name?"), "file-name-");
}
#[test]
fn test_save_and_load_conversation() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user("Hello".to_string()));
conv.messages
.push(Message::assistant("Hi there!".to_string()));
// Save conversation
let path = storage
.save_conversation(&conv, Some("test_session".to_string()))
.unwrap();
assert!(path.exists());
// Load conversation
let loaded = storage.load_conversation(&path).unwrap();
assert_eq!(loaded.id, conv.id);
assert_eq!(loaded.model, conv.model);
assert_eq!(loaded.messages.len(), 2);
assert_eq!(loaded.name, Some("test_session".to_string()));
}
#[test]
fn test_list_sessions() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
// Create multiple sessions
for i in 0..3 {
let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user(format!("Message {}", i)));
storage
.save_conversation(&conv, Some(format!("session_{}", i)))
.unwrap();
}
// List sessions
let sessions = storage.list_sessions().unwrap();
assert_eq!(sessions.len(), 3);
// Check that sessions are sorted by updated_at (most recent first)
for i in 0..sessions.len() - 1 {
assert!(sessions[i].updated_at >= sessions[i + 1].updated_at);
}
}
#[tokio::test]
async fn test_storage_lifecycle() {
let temp_dir = tempdir().expect("failed to create temp dir");
let db_path = temp_dir.path().join("owlen.db");
let storage = StorageManager::with_database_path(db_path).await.unwrap();
#[test]
fn test_delete_session() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
let conversation = sample_conversation();
storage
.save_conversation(&conversation, None)
.await
.expect("failed to save conversation");
let conv = Conversation::new("test-model".to_string());
let path = storage.save_conversation(&conv, None).unwrap();
assert!(path.exists());
let sessions = storage.list_sessions().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, conversation.id);
let loaded = storage.load_conversation(conversation.id).await.unwrap();
assert_eq!(loaded.messages.len(), 2);
storage
.delete_session(conversation.id)
.await
.expect("failed to delete conversation");
let sessions = storage.list_sessions().await.unwrap();
assert!(sessions.is_empty());
storage.delete_session(&path).unwrap();
assert!(!path.exists());
}
}

View File

@@ -44,11 +44,6 @@ pub struct Theme {
#[serde(serialize_with = "serialize_color")]
pub assistant_message_role: Color,
/// Color for tool output messages
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub tool_output: Color,
/// Color for thinking panel title
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
@@ -273,7 +268,6 @@ fn default_dark() -> Theme {
unfocused_panel_border: Color::Rgb(95, 20, 135),
user_message_role: Color::LightBlue,
assistant_message_role: Color::Yellow,
tool_output: Color::Gray,
thinking_panel_title: Color::LightMagenta,
command_bar_background: Color::Black,
status_background: Color::Black,
@@ -303,7 +297,6 @@ fn default_light() -> Theme {
unfocused_panel_border: Color::Rgb(221, 221, 221),
user_message_role: Color::Rgb(0, 85, 164),
assistant_message_role: Color::Rgb(142, 68, 173),
tool_output: Color::Gray,
thinking_panel_title: Color::Rgb(142, 68, 173),
command_bar_background: Color::White,
status_background: Color::White,
@@ -333,9 +326,8 @@ fn gruvbox() -> Theme {
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
tool_output: Color::Rgb(146, 131, 116),
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
status_background: Color::Rgb(60, 56, 54),
mode_normal: Color::Rgb(131, 165, 152), // blue
mode_editing: Color::Rgb(184, 187, 38), // green
@@ -363,8 +355,7 @@ fn dracula() -> Theme {
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
tool_output: Color::Rgb(98, 114, 164),
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
command_bar_background: Color::Rgb(68, 71, 90),
status_background: Color::Rgb(68, 71, 90),
mode_normal: Color::Rgb(139, 233, 253),
@@ -393,7 +384,6 @@ fn solarized() -> Theme {
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
tool_output: Color::Rgb(101, 123, 131),
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
command_bar_background: Color::Rgb(7, 54, 66),
status_background: Color::Rgb(7, 54, 66),
@@ -423,7 +413,6 @@ fn midnight_ocean() -> Theme {
unfocused_panel_border: Color::Rgb(48, 54, 61),
user_message_role: Color::Rgb(121, 192, 255),
assistant_message_role: Color::Rgb(137, 221, 255),
tool_output: Color::Rgb(84, 110, 122),
thinking_panel_title: Color::Rgb(158, 206, 106),
command_bar_background: Color::Rgb(22, 27, 34),
status_background: Color::Rgb(22, 27, 34),
@@ -453,8 +442,7 @@ fn rose_pine() -> Theme {
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
tool_output: Color::Rgb(110, 106, 134),
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
command_bar_background: Color::Rgb(38, 35, 58),
status_background: Color::Rgb(38, 35, 58),
mode_normal: Color::Rgb(156, 207, 216),
@@ -483,8 +471,7 @@ fn monokai() -> Theme {
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
tool_output: Color::Rgb(117, 113, 94),
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
command_bar_background: Color::Rgb(39, 40, 34),
status_background: Color::Rgb(39, 40, 34),
mode_normal: Color::Rgb(102, 217, 239),
@@ -513,8 +500,7 @@ fn material_dark() -> Theme {
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
tool_output: Color::Rgb(84, 110, 122),
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
command_bar_background: Color::Rgb(33, 43, 48),
status_background: Color::Rgb(33, 43, 48),
mode_normal: Color::Rgb(130, 170, 255),
@@ -543,7 +529,6 @@ fn material_light() -> Theme {
unfocused_panel_border: Color::Rgb(176, 190, 197),
user_message_role: Color::Rgb(68, 138, 255),
assistant_message_role: Color::Rgb(124, 77, 255),
tool_output: Color::Rgb(144, 164, 174),
thinking_panel_title: Color::Rgb(245, 124, 0),
command_bar_background: Color::Rgb(255, 255, 255),
status_background: Color::Rgb(255, 255, 255),

View File

@@ -1,97 +0,0 @@
//! Tool module aggregating builtin tool implementations.
//!
//! The crate originally declared `pub mod tools;` in `lib.rs` but the source
//! directory only contained individual tool files without a `mod.rs`, causing the
//! compiler to look for `tools.rs` and fail. Adding this module file makes the
//! directory a proper Rust module and reexports the concrete tool types.
pub mod code_exec;
pub mod fs_tools;
pub mod registry;
pub mod web_scrape;
pub mod web_search;
pub mod web_search_detailed;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::Duration;
use crate::Result;
/// Trait representing a tool that can be called via the MCP interface.
#[async_trait]
pub trait Tool: Send + Sync {
/// Unique name of the tool (used in the MCP protocol).
fn name(&self) -> &'static str;
/// Humanreadable description for documentation.
fn description(&self) -> &'static str;
/// JSONSchema describing the expected arguments.
fn schema(&self) -> Value;
/// Execute the tool with the provided arguments.
fn requires_network(&self) -> bool {
false
}
fn requires_filesystem(&self) -> Vec<String> {
Vec::new()
}
async fn execute(&self, args: Value) -> Result<ToolResult>;
}
/// Result returned by a tool execution.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolResult {
/// Indicates whether the tool completed successfully.
pub success: bool,
/// Humanreadable status string retained for compatibility.
pub status: String,
/// Arbitrary JSON payload describing the tool output.
pub output: Value,
/// Execution duration.
#[serde(skip_serializing_if = "Duration::is_zero", default)]
pub duration: Duration,
/// Optional key/value metadata for the tool invocation.
#[serde(default)]
pub metadata: HashMap<String, String>,
}
impl ToolResult {
pub fn success(output: Value) -> Self {
Self {
success: true,
status: "success".into(),
output,
duration: Duration::default(),
metadata: HashMap::new(),
}
}
pub fn error(msg: &str) -> Self {
Self {
success: false,
status: "error".into(),
output: json!({ "error": msg }),
duration: Duration::default(),
metadata: HashMap::new(),
}
}
pub fn cancelled(msg: &str) -> Self {
Self {
success: false,
status: "cancelled".into(),
output: json!({ "error": msg }),
duration: Duration::default(),
metadata: HashMap::new(),
}
}
}
// Reexport the most commonly used types so they can be accessed as
// `owlen_core::tools::CodeExecTool`, etc.
pub use code_exec::CodeExecTool;
pub use fs_tools::{ResourcesDeleteTool, ResourcesGetTool, ResourcesListTool, ResourcesWriteTool};
pub use registry::ToolRegistry;
pub use web_scrape::WebScrapeTool;
pub use web_search::WebSearchTool;
pub use web_search_detailed::WebSearchDetailedTool;

View File

@@ -1,148 +0,0 @@
use std::sync::Arc;
use std::time::Instant;
use crate::Result;
use anyhow::{anyhow, Context};
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Tool, ToolResult};
use crate::sandbox::{SandboxConfig, SandboxedProcess};
pub struct CodeExecTool {
allowed_languages: Arc<Vec<String>>,
}
impl CodeExecTool {
pub fn new(allowed_languages: Vec<String>) -> Self {
Self {
allowed_languages: Arc::new(allowed_languages),
}
}
}
#[async_trait]
impl Tool for CodeExecTool {
fn name(&self) -> &'static str {
"code_exec"
}
fn description(&self) -> &'static str {
"Execute code snippets within a sandboxed environment"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"enum": self.allowed_languages.as_slice(),
"description": "Language of the code block"
},
"code": {
"type": "string",
"minLength": 1,
"maxLength": 10000,
"description": "Code to execute"
},
"timeout": {
"type": "integer",
"minimum": 1,
"maximum": 300,
"default": 30,
"description": "Execution timeout in seconds"
}
},
"required": ["language", "code"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let start = Instant::now();
let language = args
.get("language")
.and_then(Value::as_str)
.context("Missing language parameter")?;
let code = args
.get("code")
.and_then(Value::as_str)
.context("Missing code parameter")?;
let timeout = args.get("timeout").and_then(Value::as_u64).unwrap_or(30);
if !self.allowed_languages.iter().any(|lang| lang == language) {
return Err(anyhow!("Language '{}' not permitted", language).into());
}
let (command, command_args) = match language {
"python" => (
"python3".to_string(),
vec!["-c".to_string(), code.to_string()],
),
"javascript" => ("node".to_string(), vec!["-e".to_string(), code.to_string()]),
"bash" => ("bash".to_string(), vec!["-c".to_string(), code.to_string()]),
"rust" => {
let mut result =
ToolResult::error("Rust execution is not yet supported in the sandbox");
result.duration = start.elapsed();
return Ok(result);
}
other => return Err(anyhow!("Unsupported language: {}", other).into()),
};
let sandbox_config = SandboxConfig {
allow_network: false,
timeout_seconds: timeout,
..Default::default()
};
let sandbox_result = tokio::task::spawn_blocking(move || {
let sandbox = SandboxedProcess::new(sandbox_config)?;
let arg_refs: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect();
sandbox.execute(&command, &arg_refs)
})
.await
.context("Sandbox execution task failed")??;
let mut result = if sandbox_result.exit_code == 0 {
ToolResult::success(json!({
"stdout": sandbox_result.stdout,
"stderr": sandbox_result.stderr,
"exit_code": sandbox_result.exit_code,
"timed_out": sandbox_result.was_timeout,
}))
} else {
let error_msg = if sandbox_result.was_timeout {
format!(
"Execution timed out after {} seconds (exit code {}): {}",
timeout, sandbox_result.exit_code, sandbox_result.stderr
)
} else {
format!(
"Execution failed with status {}: {}",
sandbox_result.exit_code, sandbox_result.stderr
)
};
let mut err_result = ToolResult::error(&error_msg);
err_result.output = json!({
"stdout": sandbox_result.stdout,
"stderr": sandbox_result.stderr,
"exit_code": sandbox_result.exit_code,
"timed_out": sandbox_result.was_timeout,
});
err_result
};
result.duration = start.elapsed();
result
.metadata
.insert("language".to_string(), language.to_string());
result
.metadata
.insert("timeout_seconds".to_string(), timeout.to_string());
Ok(result)
}
}

View File

@@ -1,198 +0,0 @@
use crate::tools::{Tool, ToolResult};
use crate::{Error, Result};
use async_trait::async_trait;
use path_clean::PathClean;
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Deserialize)]
struct FileArgs {
path: String,
}
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf> {
let path = Path::new(path);
let path = if path.is_absolute() {
// Strip leading '/' to treat as relative to the project root.
path.strip_prefix("/")
.map_err(|_| Error::InvalidInput("Invalid path".into()))?
.to_path_buf()
} else {
path.to_path_buf()
};
let full_path = root.join(path).clean();
if !full_path.starts_with(root) {
return Err(Error::PermissionDenied("Path traversal detected".into()));
}
Ok(full_path)
}
pub struct ResourcesListTool;
#[async_trait]
impl Tool for ResourcesListTool {
fn name(&self) -> &'static str {
"resources/list"
}
fn description(&self) -> &'static str {
"Lists directory contents."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the directory to list."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: FileArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
let entries = fs::read_dir(full_path)?;
let mut result = Vec::new();
for entry in entries {
let entry = entry?;
result.push(entry.file_name().to_string_lossy().to_string());
}
Ok(ToolResult::success(serde_json::to_value(result)?))
}
}
pub struct ResourcesGetTool;
#[async_trait]
impl Tool for ResourcesGetTool {
fn name(&self) -> &'static str {
"resources/get"
}
fn description(&self) -> &'static str {
"Reads file content."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to read."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: FileArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
let content = fs::read_to_string(full_path)?;
Ok(ToolResult::success(serde_json::to_value(content)?))
}
}
// ---------------------------------------------------------------------------
// Write tool writes (or overwrites) a file under the project root.
// ---------------------------------------------------------------------------
pub struct ResourcesWriteTool;
#[derive(Deserialize)]
struct WriteArgs {
path: String,
content: String,
}
#[async_trait]
impl Tool for ResourcesWriteTool {
fn name(&self) -> &'static str {
"resources/write"
}
fn description(&self) -> &'static str {
"Writes (or overwrites) a file. Requires explicit consent."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Target file path (relative to project root)" },
"content": { "type": "string", "description": "File content to write" }
},
"required": ["path", "content"]
})
}
fn requires_filesystem(&self) -> Vec<String> {
vec!["file_write".to_string()]
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: WriteArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
// Ensure the parent directory exists
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(full_path, args.content)?;
Ok(ToolResult::success(json!(null)))
}
}
// ---------------------------------------------------------------------------
// Delete tool deletes a file under the project root.
// ---------------------------------------------------------------------------
pub struct ResourcesDeleteTool;
#[derive(Deserialize)]
struct DeleteArgs {
path: String,
}
#[async_trait]
impl Tool for ResourcesDeleteTool {
fn name(&self) -> &'static str {
"resources/delete"
}
fn description(&self) -> &'static str {
"Deletes a file. Requires explicit consent."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "File path to delete" } },
"required": ["path"]
})
}
fn requires_filesystem(&self) -> Vec<String> {
vec!["file_delete".to_string()]
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: DeleteArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
if full_path.is_file() {
fs::remove_file(full_path)?;
Ok(ToolResult::success(json!(null)))
} else {
Err(Error::InvalidInput("Path does not refer to a file".into()))
}
}
}

View File

@@ -1,114 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::Result;
use anyhow::Context;
use serde_json::Value;
use super::{Tool, ToolResult};
use crate::config::Config;
use crate::mode::Mode;
use crate::ui::UiController;
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
config: Arc<tokio::sync::Mutex<Config>>,
ui: Arc<dyn UiController>,
}
impl ToolRegistry {
pub fn new(config: Arc<tokio::sync::Mutex<Config>>, ui: Arc<dyn UiController>) -> Self {
Self {
tools: HashMap::new(),
config,
ui,
}
}
pub fn register<T>(&mut self, tool: T)
where
T: Tool + 'static,
{
let tool: Arc<dyn Tool> = Arc::new(tool);
let name = tool.name().to_string();
self.tools.insert(name, tool);
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn all(&self) -> Vec<Arc<dyn Tool>> {
self.tools.values().cloned().collect()
}
pub async fn execute(&self, name: &str, args: Value, mode: Mode) -> Result<ToolResult> {
let tool = self
.get(name)
.with_context(|| format!("Tool not registered: {}", name))?;
let mut config = self.config.lock().await;
// Check mode-based tool availability first
if !config.modes.is_tool_allowed(mode, name) {
let alternate_mode = match mode {
Mode::Chat => Mode::Code,
Mode::Code => Mode::Chat,
};
if config.modes.is_tool_allowed(alternate_mode, name) {
return Ok(ToolResult::error(&format!(
"Tool '{}' is not available in {} mode. Switch to {} mode to use this tool (use :mode {} command).",
name, mode, alternate_mode, alternate_mode
)));
} else {
return Ok(ToolResult::error(&format!(
"Tool '{}' is not available in any mode. Check your configuration.",
name
)));
}
}
let is_enabled = match name {
"web_search" => config.tools.web_search.enabled,
"code_exec" => config.tools.code_exec.enabled,
_ => true, // All other tools are considered enabled by default
};
if !is_enabled {
let prompt = format!(
"Tool '{}' is disabled. Would you like to enable it for this session?",
name
);
if self.ui.confirm(&prompt).await {
// Enable the tool in the in-memory config for the current session
match name {
"web_search" => config.tools.web_search.enabled = true,
"code_exec" => config.tools.code_exec.enabled = true,
_ => {}
}
} else {
return Ok(ToolResult::cancelled(&format!(
"Tool '{}' execution was cancelled by the user.",
name
)));
}
}
tool.execute(args).await
}
/// Get all tools available in the given mode
pub async fn available_tools(&self, mode: Mode) -> Vec<String> {
let config = self.config.lock().await;
self.tools
.keys()
.filter(|name| config.modes.is_tool_allowed(mode, name))
.cloned()
.collect()
}
pub fn tools(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
}

View File

@@ -1,102 +0,0 @@
use super::{Tool, ToolResult};
use crate::Result;
use anyhow::Context;
use async_trait::async_trait;
use serde_json::{json, Value};
/// Tool that fetches the raw HTML content for a list of URLs.
///
/// Input schema expects:
/// urls: array of strings (max 5 URLs)
/// timeout_secs: optional integer perrequest timeout (default 10)
pub struct WebScrapeTool {
// No special dependencies; uses reqwest_011 for compatibility with existing web_search.
client: reqwest_011::Client,
}
impl Default for WebScrapeTool {
fn default() -> Self {
Self::new()
}
}
impl WebScrapeTool {
pub fn new() -> Self {
let client = reqwest_011::Client::builder()
.user_agent("OwlenWebScrape/0.1")
.build()
.expect("Failed to build reqwest client");
Self { client }
}
}
#[async_trait]
impl Tool for WebScrapeTool {
fn name(&self) -> &'static str {
"web_scrape"
}
fn description(&self) -> &'static str {
"Fetch raw HTML content for a list of URLs"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"urls": {
"type": "array",
"items": { "type": "string", "format": "uri" },
"minItems": 1,
"maxItems": 5,
"description": "List of URLs to scrape"
},
"timeout_secs": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 10,
"description": "Perrequest timeout in seconds"
}
},
"required": ["urls"],
"additionalProperties": false
})
}
fn requires_network(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let urls = args
.get("urls")
.and_then(|v| v.as_array())
.context("Missing 'urls' array")?;
let timeout_secs = args
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(10);
let mut results = Vec::new();
for url_val in urls {
let url = url_val.as_str().unwrap_or("");
let resp = self
.client
.get(url)
.timeout(std::time::Duration::from_secs(timeout_secs))
.send()
.await;
match resp {
Ok(r) => {
let text = r.text().await.unwrap_or_default();
results.push(json!({ "url": url, "content": text }));
}
Err(e) => {
results.push(json!({ "url": url, "error": e.to_string() }));
}
}
}
Ok(ToolResult::success(json!({ "pages": results })))
}
}

View File

@@ -1,154 +0,0 @@
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::Result;
use anyhow::Context;
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Tool, ToolResult};
use crate::consent::ConsentManager;
use crate::credentials::CredentialManager;
use crate::encryption::VaultHandle;
pub struct WebSearchTool {
consent_manager: Arc<Mutex<ConsentManager>>,
_credential_manager: Option<Arc<CredentialManager>>,
browser: duckduckgo::browser::Browser,
}
impl WebSearchTool {
pub fn new(
consent_manager: Arc<Mutex<ConsentManager>>,
credential_manager: Option<Arc<CredentialManager>>,
_vault: Option<Arc<Mutex<VaultHandle>>>,
) -> Self {
// Create a reqwest client compatible with duckduckgo crate (v0.11)
let client = reqwest_011::Client::new();
let browser = duckduckgo::browser::Browser::new(client);
Self {
consent_manager,
_credential_manager: credential_manager,
browser,
}
}
}
#[async_trait]
impl Tool for WebSearchTool {
fn name(&self) -> &'static str {
"web_search"
}
fn description(&self) -> &'static str {
"Search the web for information using DuckDuckGo API"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 500,
"description": "Search query"
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "Maximum number of results"
}
},
"required": ["query"],
"additionalProperties": false
})
}
fn requires_network(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let start = Instant::now();
// Check if consent has been granted (non-blocking check)
// Consent should have been granted via TUI dialog before tool execution
{
let consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
if !consent.has_consent(self.name()) {
return Ok(ToolResult::error(
"Consent not granted for web search. This should have been handled by the TUI.",
));
}
}
let query = args
.get("query")
.and_then(Value::as_str)
.context("Missing query parameter")?;
let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize;
let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0",
);
// Detect if this is a news query - use news endpoint for better snippets
let is_news_query = query.to_lowercase().contains("news")
|| query.to_lowercase().contains("latest")
|| query.to_lowercase().contains("today")
|| query.to_lowercase().contains("recent");
let mut formatted_results = Vec::new();
if is_news_query {
// Use news endpoint which returns excerpts/snippets
let news_results = self
.browser
.news(query, "wt-wt", false, Some(max_results), user_agent)
.await
.context("DuckDuckGo news search failed")?;
for result in news_results {
formatted_results.push(json!({
"title": result.title,
"url": result.url,
"snippet": result.body, // news has body/excerpt
"source": result.source,
"date": result.date
}));
}
} else {
// Use lite search for general queries (fast but no snippets)
let search_results = self
.browser
.lite_search(query, "wt-wt", Some(max_results), user_agent)
.await
.context("DuckDuckGo search failed")?;
for result in search_results {
formatted_results.push(json!({
"title": result.title,
"url": result.url,
"snippet": result.snippet
}));
}
}
let mut result = ToolResult::success(json!({
"query": query,
"results": formatted_results,
"total_found": formatted_results.len()
}));
result.duration = start.elapsed();
Ok(result)
}
}

View File

@@ -1,131 +0,0 @@
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::Result;
use anyhow::Context;
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Tool, ToolResult};
use crate::consent::ConsentManager;
use crate::credentials::CredentialManager;
use crate::encryption::VaultHandle;
pub struct WebSearchDetailedTool {
consent_manager: Arc<Mutex<ConsentManager>>,
_credential_manager: Option<Arc<CredentialManager>>,
browser: duckduckgo::browser::Browser,
}
impl WebSearchDetailedTool {
pub fn new(
consent_manager: Arc<Mutex<ConsentManager>>,
credential_manager: Option<Arc<CredentialManager>>,
_vault: Option<Arc<Mutex<VaultHandle>>>,
) -> Self {
// Create a reqwest client compatible with duckduckgo crate (v0.11)
let client = reqwest_011::Client::new();
let browser = duckduckgo::browser::Browser::new(client);
Self {
consent_manager,
_credential_manager: credential_manager,
browser,
}
}
}
#[async_trait]
impl Tool for WebSearchDetailedTool {
fn name(&self) -> &'static str {
"web_search_detailed"
}
fn description(&self) -> &'static str {
"Search for recent articles and web content with detailed snippets and descriptions. \
Returns results with publication dates, sources, and full text excerpts. \
Best for finding recent information, articles, and detailed context about topics."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 500,
"description": "Search query"
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "Maximum number of results"
}
},
"required": ["query"],
"additionalProperties": false
})
}
fn requires_network(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let start = Instant::now();
// Check if consent has been granted (non-blocking check)
// Consent should have been granted via TUI dialog before tool execution
{
let consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
if !consent.has_consent(self.name()) {
return Ok(ToolResult::error("Consent not granted for detailed web search. This should have been handled by the TUI."));
}
}
let query = args
.get("query")
.and_then(Value::as_str)
.context("Missing query parameter")?;
let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize;
let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0",
);
// Use news endpoint which provides detailed results with full snippets
// Even for non-news queries, this often returns recent articles and content with good descriptions
let news_results = self
.browser
.news(query, "wt-wt", false, Some(max_results), user_agent)
.await
.context("DuckDuckGo detailed search failed")?;
let mut formatted_results = Vec::new();
for result in news_results {
formatted_results.push(json!({
"title": result.title,
"url": result.url,
"snippet": result.body, // news endpoint includes full excerpts
"source": result.source,
"date": result.date
}));
}
let mut result = ToolResult::success(json!({
"query": query,
"results": formatted_results,
"total_found": formatted_results.len()
}));
result.duration = start.elapsed();
Ok(result)
}
}

View File

@@ -18,9 +18,6 @@ pub struct Message {
pub metadata: HashMap<String, serde_json::Value>,
/// Timestamp when the message was created
pub timestamp: std::time::SystemTime,
/// Tool calls requested by the assistant
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
/// Role of a message sender
@@ -33,19 +30,6 @@ pub enum Role {
Assistant,
/// System message (prompts, context, etc.)
System,
/// Tool response message
Tool,
}
/// A tool call requested by the assistant
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
/// Unique identifier for this tool call
pub id: String,
/// Name of the tool to call
pub name: String,
/// Arguments for the tool (JSON object)
pub arguments: serde_json::Value,
}
impl fmt::Display for Role {
@@ -54,7 +38,6 @@ impl fmt::Display for Role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
Role::Tool => "tool",
};
f.write_str(label)
}
@@ -89,9 +72,6 @@ pub struct ChatRequest {
pub messages: Vec<Message>,
/// Optional parameters for the request
pub parameters: ChatParameters,
/// Optional tools available for the model to use
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<crate::mcp::McpToolDescriptor>>,
}
/// Parameters for chat completion
@@ -153,9 +133,6 @@ pub struct ModelInfo {
pub context_window: Option<u32>,
/// Additional capabilities
pub capabilities: Vec<String>,
/// Whether this model supports tool/function calling
#[serde(default)]
pub supports_tools: bool,
}
impl Message {
@@ -167,7 +144,6 @@ impl Message {
content,
metadata: HashMap::new(),
timestamp: std::time::SystemTime::now(),
tool_calls: None,
}
}
@@ -185,24 +161,6 @@ impl Message {
pub fn system(content: String) -> Self {
Self::new(Role::System, content)
}
/// Create a tool response message
pub fn tool(tool_call_id: String, content: String) -> Self {
let mut msg = Self::new(Role::Tool, content);
msg.metadata.insert(
"tool_call_id".to_string(),
serde_json::Value::String(tool_call_id),
);
msg
}
/// Check if this message has tool calls
pub fn has_tool_calls(&self) -> bool {
self.tool_calls
.as_ref()
.map(|tc| !tc.is_empty())
.unwrap_or(false)
}
}
impl Conversation {

View File

@@ -351,52 +351,14 @@ pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
Some(pos)
}
use crate::theme::Theme;
use async_trait::async_trait;
use std::io::stdout;
pub fn show_mouse_cursor() {
let mut stdout = stdout();
crossterm::execute!(stdout, crossterm::cursor::Show).ok();
}
pub fn hide_mouse_cursor() {
let mut stdout = stdout();
crossterm::execute!(stdout, crossterm::cursor::Hide).ok();
}
pub fn apply_theme_to_string(s: &str, _theme: &Theme) -> String {
// This is a placeholder. In a real implementation, you'd parse the string
// and apply colors based on syntax or other rules.
s.to_string()
}
/// A trait for abstracting UI interactions like confirmations.
#[async_trait]
pub trait UiController: Send + Sync {
async fn confirm(&self, prompt: &str) -> bool;
}
/// A no-op UI controller for non-interactive contexts.
pub struct NoOpUiController;
#[async_trait]
impl UiController for NoOpUiController {
async fn confirm(&self, _prompt: &str) -> bool {
false // Always decline in non-interactive mode
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auto_scroll() {
let mut scroll = AutoScroll {
content_len: 100,
..Default::default()
};
let mut scroll = AutoScroll::default();
scroll.content_len = 100;
// Test on_viewport with stick_to_bottom
scroll.on_viewport(10);

View File

@@ -1,108 +0,0 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use jsonschema::{JSONSchema, ValidationError};
use serde_json::{json, Value};
pub struct SchemaValidator {
schemas: HashMap<String, JSONSchema>,
}
impl Default for SchemaValidator {
fn default() -> Self {
Self::new()
}
}
impl SchemaValidator {
pub fn new() -> Self {
Self {
schemas: HashMap::new(),
}
}
pub fn register_schema(&mut self, tool_name: &str, schema: Value) -> Result<()> {
let compiled = JSONSchema::compile(&schema)
.map_err(|e| anyhow::anyhow!("Invalid schema for {}: {}", tool_name, e))?;
self.schemas.insert(tool_name.to_string(), compiled);
Ok(())
}
pub fn validate(&self, tool_name: &str, input: &Value) -> Result<()> {
let schema = self
.schemas
.get(tool_name)
.with_context(|| format!("No schema registered for tool: {}", tool_name))?;
if let Err(errors) = schema.validate(input) {
let error_messages: Vec<String> = errors.map(format_validation_error).collect();
return Err(anyhow::anyhow!(
"Input validation failed for {}: {}",
tool_name,
error_messages.join(", ")
));
}
Ok(())
}
}
fn format_validation_error(error: ValidationError) -> String {
format!("Validation error at {}: {}", error.instance_path, error)
}
pub fn get_builtin_schemas() -> HashMap<String, Value> {
let mut schemas = HashMap::new();
schemas.insert(
"web_search".to_string(),
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5
}
},
"required": ["query"],
"additionalProperties": false
}),
);
schemas.insert(
"code_exec".to_string(),
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"enum": ["python", "javascript", "bash", "rust"]
},
"code": {
"type": "string",
"minLength": 1,
"maxLength": 10000
},
"timeout": {
"type": "integer",
"minimum": 1,
"maximum": 300,
"default": 30
}
},
"required": ["language", "code"],
"additionalProperties": false
}),
);
schemas
}

View File

@@ -1,99 +0,0 @@
use owlen_core::consent::{ConsentManager, ConsentScope};
#[test]
fn test_consent_scopes() {
let mut manager = ConsentManager::new();
// Test session consent
manager.grant_consent_with_scope(
"test_tool",
vec!["data".to_string()],
vec!["https://example.com".to_string()],
ConsentScope::Session,
);
assert!(manager.has_consent("test_tool"));
// Clear session consent and verify it's gone
manager.clear_session_consent();
assert!(!manager.has_consent("test_tool"));
// Test permanent consent survives session clear
manager.grant_consent_with_scope(
"test_tool_permanent",
vec!["data".to_string()],
vec!["https://example.com".to_string()],
ConsentScope::Permanent,
);
assert!(manager.has_consent("test_tool_permanent"));
manager.clear_session_consent();
assert!(manager.has_consent("test_tool_permanent"));
// Verify revoke works for permanent consent
manager.revoke_consent("test_tool_permanent");
assert!(!manager.has_consent("test_tool_permanent"));
}
#[test]
fn test_pending_requests_prevents_duplicates() {
let mut manager = ConsentManager::new();
// Simulate concurrent consent requests by checking pending state
// In real usage, multiple threads would call request_consent simultaneously
// First, verify a tool has no consent
assert!(!manager.has_consent("web_search"));
// The pending_requests map is private, but we can test the behavior
// by checking that consent checks work correctly
assert!(manager.check_consent_needed("web_search").is_some());
// Grant session consent
manager.grant_consent_with_scope(
"web_search",
vec!["search queries".to_string()],
vec!["https://api.search.com".to_string()],
ConsentScope::Session,
);
// Now it should have consent
assert!(manager.has_consent("web_search"));
assert!(manager.check_consent_needed("web_search").is_none());
}
#[test]
fn test_consent_record_separation() {
let mut manager = ConsentManager::new();
// Add permanent consent
manager.grant_consent_with_scope(
"perm_tool",
vec!["data".to_string()],
vec!["https://perm.com".to_string()],
ConsentScope::Permanent,
);
// Add session consent
manager.grant_consent_with_scope(
"session_tool",
vec!["data".to_string()],
vec!["https://session.com".to_string()],
ConsentScope::Session,
);
// Both should have consent
assert!(manager.has_consent("perm_tool"));
assert!(manager.has_consent("session_tool"));
// Clear session consent
manager.clear_session_consent();
// Only permanent should remain
assert!(manager.has_consent("perm_tool"));
assert!(!manager.has_consent("session_tool"));
// Clear all
manager.clear_all_consent();
assert!(!manager.has_consent("perm_tool"));
}

View File

@@ -1,52 +0,0 @@
use owlen_core::mcp::remote_client::RemoteMcpClient;
use owlen_core::McpToolCall;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[tokio::test]
async fn remote_file_server_read_and_list() {
// Create temporary directory with a file
let dir = tempdir().expect("tempdir failed");
let file_path = dir.path().join("hello.txt");
let mut file = File::create(&file_path).expect("create file");
writeln!(file, "world").expect("write file");
// Change current directory for the test process so the server sees the temp dir as its root
std::env::set_current_dir(dir.path()).expect("set cwd");
// Ensure the MCP server binary is built.
// Build the MCP server binary using the workspace manifest.
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("Cargo.toml");
let build_status = std::process::Command::new("cargo")
.args(["build", "-p", "owlen-mcp-server", "--manifest-path"])
.arg(manifest_path)
.status()
.expect("failed to run cargo build for MCP server");
assert!(build_status.success(), "MCP server build failed");
// Spawn remote client after the cwd is set and binary built
let client = RemoteMcpClient::new().expect("remote client init");
// Read file via MCP
let call = McpToolCall {
name: "resources/get".to_string(),
arguments: serde_json::json!({"path": "hello.txt"}),
};
let resp = client.call_tool(call).await.expect("call_tool");
let content: String = serde_json::from_value(resp.output).expect("parse output");
assert!(content.trim().ends_with("world"));
// List directory via MCP
let list_call = McpToolCall {
name: "resources/list".to_string(),
arguments: serde_json::json!({"path": "."}),
};
let list_resp = client.call_tool(list_call).await.expect("list_tool");
let entries: Vec<String> = serde_json::from_value(list_resp.output).expect("parse list");
assert!(entries.contains(&"hello.txt".to_string()));
// Cleanup handled by tempdir
}

View File

@@ -1,67 +0,0 @@
use owlen_core::mcp::remote_client::RemoteMcpClient;
use owlen_core::McpToolCall;
use tempfile::tempdir;
#[tokio::test]
async fn remote_write_and_delete() {
// Build the server binary first
let status = std::process::Command::new("cargo")
.args(["build", "-p", "owlen-mcp-server"])
.status()
.expect("failed to build MCP server");
assert!(status.success());
// Use a temp dir as project root
let dir = tempdir().expect("tempdir");
std::env::set_current_dir(dir.path()).expect("set cwd");
let client = RemoteMcpClient::new().expect("client init");
// Write a file via MCP
let write_call = McpToolCall {
name: "resources/write".to_string(),
arguments: serde_json::json!({ "path": "test.txt", "content": "hello" }),
};
client.call_tool(write_call).await.expect("write tool");
// Verify content via local read (fallback check)
let content = std::fs::read_to_string(dir.path().join("test.txt")).expect("read back");
assert_eq!(content, "hello");
// Delete the file via MCP
let del_call = McpToolCall {
name: "resources/delete".to_string(),
arguments: serde_json::json!({ "path": "test.txt" }),
};
client.call_tool(del_call).await.expect("delete tool");
assert!(!dir.path().join("test.txt").exists());
}
#[tokio::test]
async fn write_outside_root_is_rejected() {
// Build server (already built in previous test, but ensure it exists)
let status = std::process::Command::new("cargo")
.args(["build", "-p", "owlen-mcp-server"])
.status()
.expect("failed to build MCP server");
assert!(status.success());
// Set cwd to a fresh temp dir
let dir = tempdir().expect("tempdir");
std::env::set_current_dir(dir.path()).expect("set cwd");
let client = RemoteMcpClient::new().expect("client init");
// Attempt to write outside the root using "../evil.txt"
let call = McpToolCall {
name: "resources/write".to_string(),
arguments: serde_json::json!({ "path": "../evil.txt", "content": "bad" }),
};
let err = client.call_tool(call).await.unwrap_err();
// The server returns a Network error with path traversal message
let err_str = format!("{err}");
assert!(
err_str.contains("path traversal") || err_str.contains("Path traversal"),
"Expected path traversal error, got: {}",
err_str
);
}

View File

@@ -1,110 +0,0 @@
//! Tests for modebased tool availability filtering.
//!
//! These tests verify that `ToolRegistry::execute` respects the
//! `ModeConfig` settings in `Config`. The default configuration only
//! allows `web_search` in chat mode and all tools in code mode.
//!
//! We create a simple mock tool (`EchoTool`) that just echoes the
//! provided arguments. By customizing the `Config` we can test both the
//! allowedinchat and disallowedinanymode paths.
use std::sync::Arc;
use owlen_core::config::Config;
use owlen_core::mode::{Mode, ModeConfig, ModeToolConfig};
use owlen_core::tools::registry::ToolRegistry;
use owlen_core::tools::{Tool, ToolResult};
use owlen_core::ui::{NoOpUiController, UiController};
use serde_json::json;
use tokio::sync::Mutex;
/// A trivial tool that returns the provided arguments as its output.
#[derive(Debug)]
struct EchoTool;
#[async_trait::async_trait]
impl Tool for EchoTool {
fn name(&self) -> &'static str {
"echo"
}
fn description(&self) -> &'static str {
"Echo the input arguments"
}
fn schema(&self) -> serde_json::Value {
// Accept any object.
json!({ "type": "object" })
}
async fn execute(&self, args: serde_json::Value) -> owlen_core::Result<ToolResult> {
Ok(ToolResult::success(args))
}
}
#[tokio::test]
async fn test_tool_allowed_in_chat_mode() {
// Build a config where the `echo` tool is explicitly allowed in chat.
let cfg = Config {
modes: ModeConfig {
chat: ModeToolConfig {
allowed_tools: vec!["echo".to_string()],
},
code: ModeToolConfig {
allowed_tools: vec!["*".to_string()],
},
},
..Default::default()
};
let cfg = Arc::new(Mutex::new(cfg));
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
let mut reg = ToolRegistry::new(cfg.clone(), ui);
reg.register(EchoTool);
let args = json!({ "msg": "hello" });
let result = reg
.execute("echo", args.clone(), Mode::Chat)
.await
.expect("execution should succeed");
assert!(result.success, "Tool should succeed when allowed");
assert_eq!(result.output, args, "Output should echo the input");
}
#[tokio::test]
async fn test_tool_not_allowed_in_any_mode() {
// Config that does NOT list `echo` in either mode.
let cfg = Config {
modes: ModeConfig {
chat: ModeToolConfig {
allowed_tools: vec!["web_search".to_string()],
},
code: ModeToolConfig {
// Strict denial - only web_search allowed
allowed_tools: vec!["web_search".to_string()],
},
},
..Default::default()
};
let cfg = Arc::new(Mutex::new(cfg));
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
let mut reg = ToolRegistry::new(cfg.clone(), ui);
reg.register(EchoTool);
let args = json!({ "msg": "hello" });
let result = reg
.execute("echo", args, Mode::Chat)
.await
.expect("execution should return a ToolResult");
// Expect an error indicating the tool is unavailable in any mode.
assert!(!result.success, "Tool should be rejected when not allowed");
let err_msg = result
.output
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
err_msg.contains("not available in any mode"),
"Error message should explain unavailability"
);
}

View File

@@ -1,311 +0,0 @@
//! Integration tests for Phase 9: Remoting / Cloud Hybrid Deployment
//!
//! Tests WebSocket transport, failover mechanisms, and health checking.
use owlen_core::mcp::failover::{FailoverConfig, FailoverMcpClient, ServerEntry, ServerHealth};
use owlen_core::mcp::{McpClient, McpToolCall, McpToolDescriptor};
use owlen_core::{Error, Result};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// Mock MCP client for testing failover behavior
struct MockMcpClient {
name: String,
fail_count: AtomicUsize,
max_failures: usize,
}
impl MockMcpClient {
fn new(name: &str, max_failures: usize) -> Self {
Self {
name: name.to_string(),
fail_count: AtomicUsize::new(0),
max_failures,
}
}
fn always_healthy(name: &str) -> Self {
Self::new(name, 0)
}
fn fail_n_times(name: &str, n: usize) -> Self {
Self::new(name, n)
}
}
#[async_trait::async_trait]
impl McpClient for MockMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
let current = self.fail_count.fetch_add(1, Ordering::SeqCst);
if current < self.max_failures {
Err(Error::Network(format!(
"Mock failure {} from '{}'",
current + 1,
self.name
)))
} else {
Ok(vec![McpToolDescriptor {
name: format!("test_tool_{}", self.name),
description: format!("Tool from {}", self.name),
input_schema: serde_json::json!({}),
requires_network: false,
requires_filesystem: vec![],
}])
}
}
async fn call_tool(&self, call: McpToolCall) -> Result<owlen_core::mcp::McpToolResponse> {
let current = self.fail_count.load(Ordering::SeqCst);
if current < self.max_failures {
Err(Error::Network(format!("Mock failure from '{}'", self.name)))
} else {
Ok(owlen_core::mcp::McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!({ "server": self.name }),
metadata: std::collections::HashMap::new(),
duration_ms: 0,
})
}
}
}
#[tokio::test]
async fn test_failover_basic_priority() {
// Create two healthy servers with different priorities
let primary = Arc::new(MockMcpClient::always_healthy("primary"));
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
let servers = vec![
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
];
let client = FailoverMcpClient::with_servers(servers);
// Should use primary (lower priority number)
let tools = client.list_tools().await.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "test_tool_primary");
}
#[tokio::test]
async fn test_failover_with_retry() {
// Primary fails 2 times, then succeeds
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 2));
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
let servers = vec![
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
];
let config = FailoverConfig {
max_retries: 3,
base_retry_delay: Duration::from_millis(10),
health_check_interval: Duration::from_secs(30),
health_check_timeout: Duration::from_secs(5),
circuit_breaker_threshold: 5,
};
let client = FailoverMcpClient::new(servers, config);
// Should eventually succeed after retries
let tools = client.list_tools().await.unwrap();
assert_eq!(tools.len(), 1);
// After 2 failures and 1 success, should get the tool
assert!(tools[0].name.contains("test_tool"));
}
#[tokio::test]
async fn test_failover_to_backup() {
// Primary always fails, backup always succeeds
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999));
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
let servers = vec![
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
];
let config = FailoverConfig {
max_retries: 5,
base_retry_delay: Duration::from_millis(5),
health_check_interval: Duration::from_secs(30),
health_check_timeout: Duration::from_secs(5),
circuit_breaker_threshold: 3,
};
let client = FailoverMcpClient::new(servers, config);
// Should failover to backup after exhausting retries on primary
let tools = client.list_tools().await.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "test_tool_backup");
}
#[tokio::test]
async fn test_server_health_tracking() {
let client = Arc::new(MockMcpClient::always_healthy("test"));
let entry = ServerEntry::new("test".to_string(), client, 1);
// Initial state should be healthy
assert!(entry.is_available().await);
assert_eq!(entry.get_health().await, ServerHealth::Healthy);
// Mark as degraded
entry.mark_degraded().await;
assert!(!entry.is_available().await);
match entry.get_health().await {
ServerHealth::Degraded { .. } => {}
_ => panic!("Expected Degraded state"),
}
// Mark as down
entry.mark_down().await;
assert!(!entry.is_available().await);
match entry.get_health().await {
ServerHealth::Down { .. } => {}
_ => panic!("Expected Down state"),
}
// Recover to healthy
entry.mark_healthy().await;
assert!(entry.is_available().await);
assert_eq!(entry.get_health().await, ServerHealth::Healthy);
}
#[tokio::test]
async fn test_health_check_all() {
let healthy = Arc::new(MockMcpClient::always_healthy("healthy"));
let unhealthy = Arc::new(MockMcpClient::fail_n_times("unhealthy", 999));
let servers = vec![
ServerEntry::new("healthy".to_string(), healthy as Arc<dyn McpClient>, 1),
ServerEntry::new("unhealthy".to_string(), unhealthy as Arc<dyn McpClient>, 2),
];
let client = FailoverMcpClient::with_servers(servers);
// Run health check
client.health_check_all().await;
// Give spawned tasks time to complete
tokio::time::sleep(Duration::from_millis(100)).await;
// Check server status
let status = client.get_server_status().await;
assert_eq!(status.len(), 2);
// Healthy server should be healthy
let healthy_status = status.iter().find(|(name, _)| name == "healthy").unwrap();
assert_eq!(healthy_status.1, ServerHealth::Healthy);
// Unhealthy server should be down
let unhealthy_status = status.iter().find(|(name, _)| name == "unhealthy").unwrap();
match unhealthy_status.1 {
ServerHealth::Down { .. } => {}
_ => panic!("Expected unhealthy server to be Down"),
}
}
#[tokio::test]
async fn test_call_tool_failover() {
// Primary fails, backup succeeds
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999));
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
let servers = vec![
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
];
let config = FailoverConfig {
max_retries: 5,
base_retry_delay: Duration::from_millis(5),
..Default::default()
};
let client = FailoverMcpClient::new(servers, config);
// Call a tool - should failover to backup
let call = McpToolCall {
name: "test_tool".to_string(),
arguments: serde_json::json!({}),
};
let response = client.call_tool(call).await.unwrap();
assert!(response.success);
assert_eq!(response.output["server"], "backup");
}
#[tokio::test]
async fn test_exponential_backoff() {
// Test that retry delays increase exponentially
let client = Arc::new(MockMcpClient::fail_n_times("test", 2));
let entry = ServerEntry::new("test".to_string(), client, 1);
let config = FailoverConfig {
max_retries: 3,
base_retry_delay: Duration::from_millis(10),
..Default::default()
};
let failover = FailoverMcpClient::new(vec![entry], config);
let start = std::time::Instant::now();
let _ = failover.list_tools().await;
let elapsed = start.elapsed();
// With base delay of 10ms and 2 retries:
// Attempt 1: immediate
// Attempt 2: 10ms delay (2^0 * 10)
// Attempt 3: 20ms delay (2^1 * 10)
// Total should be at least 30ms
assert!(
elapsed >= Duration::from_millis(30),
"Expected at least 30ms, got {:?}",
elapsed
);
}
#[tokio::test]
async fn test_no_servers_configured() {
let config = FailoverConfig::default();
let client = FailoverMcpClient::new(vec![], config);
let result = client.list_tools().await;
assert!(result.is_err());
match result {
Err(Error::Network(msg)) => assert!(msg.contains("No servers configured")),
_ => panic!("Expected Network error"),
}
}
#[tokio::test]
async fn test_all_servers_fail() {
// Both servers always fail
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999));
let backup = Arc::new(MockMcpClient::fail_n_times("backup", 999));
let servers = vec![
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
];
let config = FailoverConfig {
max_retries: 2,
base_retry_delay: Duration::from_millis(5),
..Default::default()
};
let client = FailoverMcpClient::new(servers, config);
let result = client.list_tools().await;
assert!(result.is_err());
match result {
Err(Error::Network(_)) => {} // Expected
_ => panic!("Expected Network error"),
}
}

View File

@@ -1,50 +0,0 @@
//! Integration test for the MCP prompt rendering server.
use owlen_core::config::McpServerConfig;
use owlen_core::mcp::client::RemoteMcpClient;
use owlen_core::mcp::{McpToolCall, McpToolResponse};
use owlen_core::Result;
use serde_json::json;
use std::path::PathBuf;
#[tokio::test]
async fn test_render_prompt_via_external_server() -> Result<()> {
// Locate the compiled prompt server binary.
let mut binary = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
binary.pop(); // remove `tests`
binary.pop(); // remove `owlen-core`
binary.push("owlen-mcp-prompt-server");
binary.push("target");
binary.push("debug");
binary.push("owlen-mcp-prompt-server");
assert!(
binary.exists(),
"Prompt server binary not found: {:?}",
binary
);
let config = McpServerConfig {
name: "prompt_server".into(),
command: binary.to_string_lossy().into_owned(),
args: Vec::new(),
transport: "stdio".into(),
env: std::collections::HashMap::new(),
};
let client = RemoteMcpClient::new_with_config(&config)?;
let call = McpToolCall {
name: "render_prompt".into(),
arguments: json!({
"template_name": "example",
"variables": {"name": "Alice", "role": "Tester"}
}),
};
let resp: McpToolResponse = client.call_tool(call).await?;
assert!(resp.success, "Tool reported failure: {:?}", resp);
let output = resp.output.as_str().unwrap_or("");
assert!(output.contains("Alice"), "Output missing name: {}", output);
assert!(output.contains("Tester"), "Output missing role: {}", output);
Ok(())
}

View File

@@ -1,12 +0,0 @@
[package]
name = "owlen-mcp-client"
version = "0.1.0"
edition = "2021"
description = "Dedicated MCP client library for Owlen, exposing remote MCP server communication"
license = "AGPL-3.0"
[dependencies]
owlen-core = { path = "../owlen-core" }
[features]
default = []

View File

@@ -1,19 +0,0 @@
//! Owlen MCP client library.
//!
//! This crate provides a thin façade over the remote MCP client implementation
//! inside `owlen-core`. It reexports the most useful types so downstream
//! crates can depend only on `owlen-mcp-client` without pulling in the entire
//! core crate internals.
pub use owlen_core::mcp::remote_client::RemoteMcpClient;
pub use owlen_core::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
// Reexport the Provider implementation so the client can also be used as an
// LLM provider when the remote MCP server hosts a languagemodel tool (e.g.
// `generate_text`).
// Reexport the core Provider trait so that the MCP client can also be used as an LLM provider.
pub use owlen_core::provider::Provider as McpProvider;
// Note: The `RemoteMcpClient` type provides its own `new` constructor in the core
// crate. Users can call `RemoteMcpClient::new()` directly. No additional wrapper
// is needed here.

View File

@@ -1,22 +0,0 @@
[package]
name = "owlen-mcp-code-server"
version = "0.1.0"
edition = "2021"
description = "MCP server exposing safe code execution tools for Owlen"
license = "AGPL-3.0"
[dependencies]
owlen-core = { path = "../owlen-core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
anyhow = "1.0"
async-trait = "0.1"
bollard = "0.17"
tempfile = "3.0"
uuid = { version = "1.0", features = ["v4"] }
futures = "0.3"
[lib]
name = "owlen_mcp_code_server"
path = "src/lib.rs"

View File

@@ -1,186 +0,0 @@
//! MCP server exposing code execution tools with Docker sandboxing.
//!
//! This server provides:
//! - compile_project: Build projects (Rust, Node.js, Python)
//! - run_tests: Execute test suites
//! - format_code: Run code formatters
//! - lint_code: Run linters
pub mod sandbox;
pub mod tools;
use owlen_core::mcp::protocol::{
methods, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError, RpcErrorResponse,
RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
};
use owlen_core::tools::{Tool, ToolResult};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
use tools::{CompileProjectTool, FormatCodeTool, LintCodeTool, RunTestsTool};
/// Tool registry for the code server
#[allow(dead_code)]
struct ToolRegistry {
tools: HashMap<String, Box<dyn Tool + Send + Sync>>,
}
#[allow(dead_code)]
impl ToolRegistry {
fn new() -> Self {
let mut tools: HashMap<String, Box<dyn Tool + Send + Sync>> = HashMap::new();
tools.insert(
"compile_project".to_string(),
Box::new(CompileProjectTool::new()),
);
tools.insert("run_tests".to_string(), Box::new(RunTestsTool::new()));
tools.insert("format_code".to_string(), Box::new(FormatCodeTool::new()));
tools.insert("lint_code".to_string(), Box::new(LintCodeTool::new()));
Self { tools }
}
fn list_tools(&self) -> Vec<owlen_core::mcp::McpToolDescriptor> {
self.tools
.values()
.map(|tool| owlen_core::mcp::McpToolDescriptor {
name: tool.name().to_string(),
description: tool.description().to_string(),
input_schema: tool.schema(),
requires_network: tool.requires_network(),
requires_filesystem: tool.requires_filesystem(),
})
.collect()
}
async fn execute(&self, name: &str, args: Value) -> Result<ToolResult, String> {
self.tools
.get(name)
.ok_or_else(|| format!("Tool not found: {}", name))?
.execute(args)
.await
.map_err(|e| e.to_string())
}
}
#[allow(dead_code)]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
let registry = Arc::new(ToolRegistry::new());
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let resp = handle_request(req.clone(), registry.clone()).await;
match resp {
Ok(r) => {
let s = serde_json::to_string(&r)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(e) => {
let err = RpcErrorResponse::new(req.id.clone(), e);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
eprintln!("Error reading stdin: {}", e);
break;
}
}
}
Ok(())
}
#[allow(dead_code)]
async fn handle_request(
req: RpcRequest,
registry: Arc<ToolRegistry>,
) -> Result<RpcResponse, RpcError> {
match req.method.as_str() {
methods::INITIALIZE => {
let params: InitializeParams =
serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
if !params.protocol_version.eq(PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
params.protocol_version, PROTOCOL_VERSION
),
));
}
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-code-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(true),
supports_resources: Some(false),
supports_streaming: Some(false),
},
};
Ok(RpcResponse::new(
req.id,
serde_json::to_value(result).unwrap(),
))
}
methods::TOOLS_LIST => {
let tools = registry.list_tools();
Ok(RpcResponse::new(req.id, json!(tools)))
}
methods::TOOLS_CALL => {
let call = serde_json::from_value::<owlen_core::mcp::McpToolCall>(
req.params.unwrap_or_else(|| json!({})),
)
.map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?;
let result: ToolResult = registry
.execute(&call.name, call.arguments)
.await
.map_err(|e| RpcError::internal_error(format!("Tool execution failed: {}", e)))?;
let resp = owlen_core::mcp::McpToolResponse {
name: call.name,
success: result.success,
output: result.output,
metadata: result.metadata,
duration_ms: result.duration.as_millis() as u128,
};
Ok(RpcResponse::new(
req.id,
serde_json::to_value(resp).unwrap(),
))
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}

View File

@@ -1,250 +0,0 @@
//! Docker-based sandboxing for secure code execution
use anyhow::{Context, Result};
use bollard::container::{
Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
WaitContainerOptions,
};
use bollard::models::{HostConfig, Mount, MountTypeEnum};
use bollard::Docker;
use std::collections::HashMap;
use std::path::Path;
/// Result of executing code in a sandbox
#[derive(Debug, Clone)]
pub struct ExecutionResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i64,
pub timed_out: bool,
}
/// Docker-based sandbox executor
pub struct Sandbox {
docker: Docker,
memory_limit: i64,
cpu_quota: i64,
timeout_secs: u64,
}
impl Sandbox {
/// Create a new sandbox with default resource limits
pub fn new() -> Result<Self> {
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
Ok(Self {
docker,
memory_limit: 512 * 1024 * 1024, // 512MB
cpu_quota: 50000, // 50% of one core
timeout_secs: 30,
})
}
/// Execute a command in a sandboxed container
pub async fn execute(
&self,
image: &str,
cmd: &[&str],
workspace: Option<&Path>,
env: HashMap<String, String>,
) -> Result<ExecutionResult> {
let container_name = format!("owlen-sandbox-{}", uuid::Uuid::new_v4());
// Prepare volume mount if workspace provided
let mounts = if let Some(ws) = workspace {
vec![Mount {
target: Some("/workspace".to_string()),
source: Some(ws.to_string_lossy().to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
}]
} else {
vec![]
};
// Create container config
let host_config = HostConfig {
memory: Some(self.memory_limit),
cpu_quota: Some(self.cpu_quota),
network_mode: Some("none".to_string()), // No network access
mounts: Some(mounts),
auto_remove: Some(true),
..Default::default()
};
let config = Config {
image: Some(image.to_string()),
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
working_dir: Some("/workspace".to_string()),
env: Some(env.iter().map(|(k, v)| format!("{}={}", k, v)).collect()),
host_config: Some(host_config),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(false),
..Default::default()
};
// Create container
let container = self
.docker
.create_container(
Some(CreateContainerOptions {
name: container_name.clone(),
..Default::default()
}),
config,
)
.await
.context("Failed to create container")?;
// Start container
self.docker
.start_container(&container.id, None::<StartContainerOptions<String>>)
.await
.context("Failed to start container")?;
// Wait for container with timeout
let wait_result =
tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), async {
let mut wait_stream = self
.docker
.wait_container(&container.id, None::<WaitContainerOptions<String>>);
use futures::StreamExt;
if let Some(result) = wait_stream.next().await {
result
} else {
Err(bollard::errors::Error::IOError {
err: std::io::Error::other("Container wait stream ended unexpectedly"),
})
}
})
.await;
let (exit_code, timed_out) = match wait_result {
Ok(Ok(result)) => (result.status_code, false),
Ok(Err(e)) => {
eprintln!("Container wait error: {}", e);
(1, false)
}
Err(_) => {
// Timeout - kill the container
let _ = self
.docker
.kill_container(
&container.id,
None::<bollard::container::KillContainerOptions<String>>,
)
.await;
(124, true)
}
};
// Get logs
let logs = self.docker.logs(
&container.id,
Some(bollard::container::LogsOptions::<String> {
stdout: true,
stderr: true,
..Default::default()
}),
);
use futures::StreamExt;
let mut stdout = String::new();
let mut stderr = String::new();
let log_result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
let mut logs = logs;
while let Some(log) = logs.next().await {
match log {
Ok(bollard::container::LogOutput::StdOut { message }) => {
stdout.push_str(&String::from_utf8_lossy(&message));
}
Ok(bollard::container::LogOutput::StdErr { message }) => {
stderr.push_str(&String::from_utf8_lossy(&message));
}
_ => {}
}
}
})
.await;
if log_result.is_err() {
eprintln!("Timeout reading container logs");
}
// Remove container (auto_remove should handle this, but be explicit)
let _ = self
.docker
.remove_container(
&container.id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
Ok(ExecutionResult {
stdout,
stderr,
exit_code,
timed_out,
})
}
/// Execute in a Rust environment
pub async fn execute_rust(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
self.execute("rust:1.75-slim", cmd, Some(workspace), HashMap::new())
.await
}
/// Execute in a Python environment
pub async fn execute_python(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
self.execute("python:3.11-slim", cmd, Some(workspace), HashMap::new())
.await
}
/// Execute in a Node.js environment
pub async fn execute_node(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
self.execute("node:20-slim", cmd, Some(workspace), HashMap::new())
.await
}
}
impl Default for Sandbox {
fn default() -> Self {
Self::new().expect("Failed to create default sandbox")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
#[ignore] // Requires Docker daemon
async fn test_sandbox_rust_compile() {
let sandbox = Sandbox::new().unwrap();
let temp_dir = TempDir::new().unwrap();
// Create a simple Rust project
std::fs::write(
temp_dir.path().join("main.rs"),
"fn main() { println!(\"Hello from sandbox!\"); }",
)
.unwrap();
let result = sandbox
.execute_rust(temp_dir.path(), &["rustc", "main.rs"])
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
}
}

View File

@@ -1,417 +0,0 @@
//! Code execution tools using Docker sandboxing
use crate::sandbox::Sandbox;
use async_trait::async_trait;
use owlen_core::tools::{Tool, ToolResult};
use owlen_core::Result;
use serde_json::{json, Value};
use std::path::PathBuf;
/// Tool for compiling projects (Rust, Node.js, Python)
pub struct CompileProjectTool {
sandbox: Sandbox,
}
impl Default for CompileProjectTool {
fn default() -> Self {
Self::new()
}
}
impl CompileProjectTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for CompileProjectTool {
fn name(&self) -> &'static str {
"compile_project"
}
fn description(&self) -> &'static str {
"Compile a project (Rust, Node.js, Python). Detects project type automatically."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"project_type": {
"type": "string",
"enum": ["rust", "node", "python"],
"description": "Project type (auto-detected if not specified)"
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
// Detect project type
let project_type = if let Some(pt) = args.get("project_type").and_then(|v| v.as_str()) {
pt.to_string()
} else if path.join("Cargo.toml").exists() {
"rust".to_string()
} else if path.join("package.json").exists() {
"node".to_string()
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
"python".to_string()
} else {
return Ok(ToolResult::error("Could not detect project type"));
};
// Execute compilation
let result = match project_type.as_str() {
"rust" => self.sandbox.execute_rust(&path, &["cargo", "build"]).await,
"node" => {
self.sandbox
.execute_node(&path, &["npm", "run", "build"])
.await
}
"python" => {
// Python typically doesn't need compilation, but we can check syntax
self.sandbox
.execute_python(&path, &["python", "-m", "compileall", "."])
.await
}
_ => return Ok(ToolResult::error("Unsupported project type")),
};
match result {
Ok(exec_result) => {
if exec_result.timed_out {
Ok(ToolResult::error("Compilation timed out"))
} else if exec_result.exit_code == 0 {
Ok(ToolResult::success(json!({
"success": true,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr,
"project_type": project_type
})))
} else {
Ok(ToolResult::success(json!({
"success": false,
"exit_code": exec_result.exit_code,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr,
"project_type": project_type
})))
}
}
Err(e) => Ok(ToolResult::error(&format!("Compilation failed: {}", e))),
}
}
}
/// Tool for running test suites
pub struct RunTestsTool {
sandbox: Sandbox,
}
impl Default for RunTestsTool {
fn default() -> Self {
Self::new()
}
}
impl RunTestsTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for RunTestsTool {
fn name(&self) -> &'static str {
"run_tests"
}
fn description(&self) -> &'static str {
"Run tests for a project (Rust, Node.js, Python)"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"test_filter": {
"type": "string",
"description": "Optional test filter/pattern"
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
let test_filter = args.get("test_filter").and_then(|v| v.as_str());
// Detect project type and run tests
let result = if path.join("Cargo.toml").exists() {
let cmd = if let Some(filter) = test_filter {
vec!["cargo", "test", filter]
} else {
vec!["cargo", "test"]
};
self.sandbox.execute_rust(&path, &cmd).await
} else if path.join("package.json").exists() {
self.sandbox.execute_node(&path, &["npm", "test"]).await
} else if path.join("pytest.ini").exists()
|| path.join("setup.py").exists()
|| path.join("pyproject.toml").exists()
{
let cmd = if let Some(filter) = test_filter {
vec!["pytest", "-k", filter]
} else {
vec!["pytest"]
};
self.sandbox.execute_python(&path, &cmd).await
} else {
return Ok(ToolResult::error("Could not detect test framework"));
};
match result {
Ok(exec_result) => Ok(ToolResult::success(json!({
"success": exec_result.exit_code == 0 && !exec_result.timed_out,
"exit_code": exec_result.exit_code,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr,
"timed_out": exec_result.timed_out
}))),
Err(e) => Ok(ToolResult::error(&format!("Tests failed to run: {}", e))),
}
}
}
/// Tool for formatting code
pub struct FormatCodeTool {
sandbox: Sandbox,
}
impl Default for FormatCodeTool {
fn default() -> Self {
Self::new()
}
}
impl FormatCodeTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for FormatCodeTool {
fn name(&self) -> &'static str {
"format_code"
}
fn description(&self) -> &'static str {
"Format code using project-appropriate formatter (rustfmt, prettier, black)"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"check_only": {
"type": "boolean",
"description": "Only check formatting without modifying files",
"default": false
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
let check_only = args
.get("check_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Detect project type and run formatter
let result = if path.join("Cargo.toml").exists() {
let cmd = if check_only {
vec!["cargo", "fmt", "--", "--check"]
} else {
vec!["cargo", "fmt"]
};
self.sandbox.execute_rust(&path, &cmd).await
} else if path.join("package.json").exists() {
let cmd = if check_only {
vec!["npx", "prettier", "--check", "."]
} else {
vec!["npx", "prettier", "--write", "."]
};
self.sandbox.execute_node(&path, &cmd).await
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
let cmd = if check_only {
vec!["black", "--check", "."]
} else {
vec!["black", "."]
};
self.sandbox.execute_python(&path, &cmd).await
} else {
return Ok(ToolResult::error("Could not detect project type"));
};
match result {
Ok(exec_result) => Ok(ToolResult::success(json!({
"success": exec_result.exit_code == 0,
"formatted": !check_only && exec_result.exit_code == 0,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr
}))),
Err(e) => Ok(ToolResult::error(&format!("Formatting failed: {}", e))),
}
}
}
/// Tool for linting code
pub struct LintCodeTool {
sandbox: Sandbox,
}
impl Default for LintCodeTool {
fn default() -> Self {
Self::new()
}
}
impl LintCodeTool {
pub fn new() -> Self {
Self {
sandbox: Sandbox::default(),
}
}
}
#[async_trait]
impl Tool for LintCodeTool {
fn name(&self) -> &'static str {
"lint_code"
}
fn description(&self) -> &'static str {
"Lint code using project-appropriate linter (clippy, eslint, pylint)"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the project root"
},
"fix": {
"type": "boolean",
"description": "Automatically fix issues if possible",
"default": false
}
},
"required": ["project_path"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
let path = PathBuf::from(project_path);
if !path.exists() {
return Ok(ToolResult::error("Project path does not exist"));
}
let fix = args.get("fix").and_then(|v| v.as_bool()).unwrap_or(false);
// Detect project type and run linter
let result = if path.join("Cargo.toml").exists() {
let cmd = if fix {
vec!["cargo", "clippy", "--fix", "--allow-dirty"]
} else {
vec!["cargo", "clippy"]
};
self.sandbox.execute_rust(&path, &cmd).await
} else if path.join("package.json").exists() {
let cmd = if fix {
vec!["npx", "eslint", ".", "--fix"]
} else {
vec!["npx", "eslint", "."]
};
self.sandbox.execute_node(&path, &cmd).await
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
// pylint doesn't have auto-fix
self.sandbox.execute_python(&path, &["pylint", "."]).await
} else {
return Ok(ToolResult::error("Could not detect project type"));
};
match result {
Ok(exec_result) => {
let issues_found = exec_result.exit_code != 0;
Ok(ToolResult::success(json!({
"success": true,
"issues_found": issues_found,
"exit_code": exec_result.exit_code,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr
})))
}
Err(e) => Ok(ToolResult::error(&format!("Linting failed: {}", e))),
}
}
}

View File

@@ -1,17 +0,0 @@
[package]
name = "owlen-mcp-llm-server"
version = "0.1.0"
edition = "2021"
[dependencies]
owlen-core = { path = "../owlen-core" }
owlen-ollama = { path = "../owlen-ollama" }
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
tokio-stream = "0.1"
[[bin]]
name = "owlen-mcp-llm-server"
path = "src/main.rs"

View File

@@ -1,504 +0,0 @@
#![allow(
unused_imports,
unused_variables,
dead_code,
clippy::unnecessary_cast,
clippy::manual_flatten,
clippy::empty_line_after_outer_attr
)]
use owlen_core::mcp::protocol::{
methods, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError, RpcErrorResponse,
RpcNotification, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
};
use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
use owlen_core::types::{ChatParameters, ChatRequest, Message};
use owlen_core::Provider;
use owlen_ollama::OllamaProvider;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
use tokio_stream::StreamExt;
// Suppress warnings are handled by the crate-level attribute at the top.
/// Arguments for the generate_text tool
#[derive(Debug, Deserialize)]
struct GenerateTextArgs {
messages: Vec<Message>,
temperature: Option<f32>,
max_tokens: Option<u32>,
model: String,
stream: bool,
}
/// Simple tool descriptor for generate_text
fn generate_text_descriptor() -> McpToolDescriptor {
McpToolDescriptor {
name: "generate_text".to_string(),
description: "Generate text using Ollama LLM. Each message must have 'role' (user/assistant/system) and 'content' (string) fields.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"role": {
"type": "string",
"enum": ["user", "assistant", "system"],
"description": "The role of the message sender"
},
"content": {
"type": "string",
"description": "The message content"
}
},
"required": ["role", "content"]
},
"description": "Array of message objects with role and content"
},
"temperature": {"type": ["number", "null"], "description": "Sampling temperature (0.0-2.0)"},
"max_tokens": {"type": ["integer", "null"], "description": "Maximum tokens to generate"},
"model": {"type": "string", "description": "Model name (e.g., llama3.2:latest)"},
"stream": {"type": "boolean", "description": "Whether to stream the response"}
},
"required": ["messages", "model", "stream"]
}),
requires_network: true,
requires_filesystem: vec![],
}
}
/// Tool descriptor for resources/get (read file)
fn resources_get_descriptor() -> McpToolDescriptor {
McpToolDescriptor {
name: "resources/get".to_string(),
description: "Read and return the TEXT CONTENTS of a single FILE. Use this to read the contents of code files, config files, or text documents. Do NOT use for directories.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the FILE (not directory) to read"}
},
"required": ["path"]
}),
requires_network: false,
requires_filesystem: vec!["read".to_string()],
}
}
/// Tool descriptor for resources/list (list directory)
fn resources_list_descriptor() -> McpToolDescriptor {
McpToolDescriptor {
name: "resources/list".to_string(),
description: "List the NAMES of all files and directories in a directory. Use this to see what files exist in a folder, or to list directory contents. Returns an array of file/directory names.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the DIRECTORY to list (use '.' for current directory)"}
}
}),
requires_network: false,
requires_filesystem: vec!["read".to_string()],
}
}
async fn handle_generate_text(args: GenerateTextArgs) -> Result<String, RpcError> {
// Create provider with Ollama URL from environment or default to localhost
let ollama_url =
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let provider = OllamaProvider::new(&ollama_url)
.map_err(|e| RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e)))?;
let parameters = ChatParameters {
temperature: args.temperature,
max_tokens: args.max_tokens.map(|v| v as u32),
stream: args.stream,
extra: HashMap::new(),
};
let request = ChatRequest {
model: args.model,
messages: args.messages,
parameters,
tools: None,
};
// Use streaming API and collect output
let mut stream = provider
.chat_stream(request)
.await
.map_err(|e| RpcError::internal_error(format!("Chat request failed: {}", e)))?;
let mut content = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(resp) => {
content.push_str(&resp.message.content);
if resp.is_final {
break;
}
}
Err(e) => {
return Err(RpcError::internal_error(format!("Stream error: {}", e)));
}
}
}
Ok(content)
}
async fn handle_request(req: &RpcRequest) -> Result<Value, RpcError> {
match req.method.as_str() {
methods::INITIALIZE => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?;
let init: InitializeParams = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
if !init.protocol_version.eq(PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
init.protocol_version, PROTOCOL_VERSION
),
));
}
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-llm-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(true),
supports_resources: Some(false),
supports_streaming: Some(true),
},
};
Ok(serde_json::to_value(result).unwrap())
}
methods::TOOLS_LIST => {
let tools = vec![
generate_text_descriptor(),
resources_get_descriptor(),
resources_list_descriptor(),
];
Ok(json!(tools))
}
// New method to list available Ollama models via the provider.
methods::MODELS_LIST => {
// Reuse the provider instance for model listing.
let ollama_url =
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let provider = OllamaProvider::new(&ollama_url).map_err(|e| {
RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e))
})?;
let models = provider
.list_models()
.await
.map_err(|e| RpcError::internal_error(format!("Failed to list models: {}", e)))?;
Ok(serde_json::to_value(models).unwrap())
}
methods::TOOLS_CALL => {
// For streaming we will send incremental notifications directly from here.
// The caller (main loop) will handle writing the final response.
Err(RpcError::internal_error(
"TOOLS_CALL should be handled in main loop for streaming",
))
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?; // not used but kept for parity
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let id = req.id.clone();
// Streaming tool calls (generate_text) are handled specially to emit incremental notifications.
if req.method == methods::TOOLS_CALL {
// Parse the tool call
let params = match &req.params {
Some(p) => p,
None => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params("Missing params for tool call"),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let call: McpToolCall = match serde_json::from_value(params.clone()) {
Ok(c) => c,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params(format!("Invalid tool call: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Dispatch based on the requested tool name.
// Handle resources tools manually.
if call.name.starts_with("resources/get") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
match std::fs::read_to_string(path) {
Ok(content) => {
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(content),
metadata: HashMap::new(),
duration_ms: 0,
};
let final_resp = RpcResponse::new(
id.clone(),
serde_json::to_value(response).unwrap(),
);
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Failed to read file: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
}
}
if call.name.starts_with("resources/list") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
match std::fs::read_dir(path) {
Ok(entries) => {
let mut names = Vec::new();
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(names),
metadata: HashMap::new(),
duration_ms: 0,
};
let final_resp = RpcResponse::new(
id.clone(),
serde_json::to_value(response).unwrap(),
);
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Failed to list dir: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
}
}
// Expect generate_text tool for the remaining path.
if call.name != "generate_text" {
let err_resp =
RpcErrorResponse::new(id.clone(), RpcError::tool_not_found(&call.name));
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
let args: GenerateTextArgs =
match serde_json::from_value(call.arguments.clone()) {
Ok(a) => a,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params(format!("Invalid arguments: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Initialize Ollama provider and start streaming
let ollama_url = env::var("OLLAMA_URL")
.unwrap_or_else(|_| "http://localhost:11434".to_string());
let provider = match OllamaProvider::new(&ollama_url) {
Ok(p) => p,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!(
"Failed to init OllamaProvider: {}",
e
)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let parameters = ChatParameters {
temperature: args.temperature,
max_tokens: args.max_tokens.map(|v| v as u32),
stream: true,
extra: HashMap::new(),
};
let request = ChatRequest {
model: args.model,
messages: args.messages,
parameters,
tools: None,
};
let mut stream = match provider.chat_stream(request).await {
Ok(s) => s,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Chat request failed: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Accumulate full content while sending incremental progress notifications
let mut final_content = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(resp) => {
// Append chunk to the final content buffer
final_content.push_str(&resp.message.content);
// Emit a progress notification for the UI
let notif = RpcNotification::new(
"tools/call/progress",
Some(json!({ "content": resp.message.content })),
);
let s = serde_json::to_string(&notif)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
if resp.is_final {
break;
}
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Stream error: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
break;
}
}
}
// After streaming, send the final tool response containing the full content
let final_output = final_content.clone();
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(final_output),
metadata: HashMap::new(),
duration_ms: 0,
};
let final_resp =
RpcResponse::new(id.clone(), serde_json::to_value(response).unwrap());
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
// Nonstreaming requests are handled by the generic handler
match handle_request(&req).await {
Ok(res) => {
let resp = RpcResponse::new(id, res);
let s = serde_json::to_string(&resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(err) => {
let err_resp = RpcErrorResponse::new(id, err);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
eprintln!("Read error: {}", e);
break;
}
}
}
Ok(())
}

View File

@@ -1,21 +0,0 @@
[package]
name = "owlen-mcp-prompt-server"
version = "0.1.0"
edition = "2021"
description = "MCP server that renders prompt templates (YAML) for Owlen"
license = "AGPL-3.0"
[dependencies]
owlen-core = { path = "../owlen-core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
tokio = { version = "1.0", features = ["full"] }
anyhow = "1.0"
handlebars = "6.0"
dirs = "5.0"
futures = "0.3"
[lib]
name = "owlen_mcp_prompt_server"
path = "src/lib.rs"

View File

@@ -1,407 +0,0 @@
//! MCP server for rendering prompt templates with YAML storage and Handlebars rendering.
//!
//! Templates are stored in `~/.config/owlen/prompts/` as YAML files.
//! Provides full Handlebars templating support for dynamic prompt generation.
use anyhow::{Context, Result};
use handlebars::Handlebars;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use owlen_core::mcp::protocol::{
methods, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError, RpcErrorResponse,
RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
};
use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
/// Prompt template definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptTemplate {
/// Template name
pub name: String,
/// Template version
pub version: String,
/// Optional mode restriction
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// Handlebars template content
pub template: String,
/// Template description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// Prompt server managing templates
pub struct PromptServer {
templates: Arc<RwLock<HashMap<String, PromptTemplate>>>,
handlebars: Handlebars<'static>,
templates_dir: PathBuf,
}
impl PromptServer {
/// Create a new prompt server
pub fn new() -> Result<Self> {
let templates_dir = Self::get_templates_dir()?;
// Create templates directory if it doesn't exist
if !templates_dir.exists() {
fs::create_dir_all(&templates_dir)?;
Self::create_default_templates(&templates_dir)?;
}
let mut server = Self {
templates: Arc::new(RwLock::new(HashMap::new())),
handlebars: Handlebars::new(),
templates_dir,
};
// Load all templates
server.load_templates()?;
Ok(server)
}
/// Get the templates directory path
fn get_templates_dir() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("Could not determine config directory")?;
Ok(config_dir.join("owlen").join("prompts"))
}
/// Create default template examples
fn create_default_templates(dir: &Path) -> Result<()> {
let chat_mode_system = PromptTemplate {
name: "chat_mode_system".to_string(),
version: "1.0".to_string(),
mode: Some("chat".to_string()),
description: Some("System prompt for chat mode".to_string()),
template: r#"You are Owlen, a helpful AI assistant. You have access to these tools:
{{#each tools}}
- {{name}}: {{description}}
{{/each}}
Use the ReAct pattern:
THOUGHT: Your reasoning
ACTION: tool_name
ACTION_INPUT: {"param": "value"}
When you have enough information:
FINAL_ANSWER: Your response"#
.to_string(),
};
let code_mode_system = PromptTemplate {
name: "code_mode_system".to_string(),
version: "1.0".to_string(),
mode: Some("code".to_string()),
description: Some("System prompt for code mode".to_string()),
template: r#"You are Owlen in code mode, with full development capabilities. You have access to:
{{#each tools}}
- {{name}}: {{description}}
{{/each}}
Use the ReAct pattern to solve coding tasks:
THOUGHT: Analyze what needs to be done
ACTION: tool_name (compile_project, run_tests, format_code, lint_code, etc.)
ACTION_INPUT: {"param": "value"}
Continue iterating until the task is complete, then provide:
FINAL_ANSWER: Summary of what was done"#
.to_string(),
};
// Save templates
let chat_path = dir.join("chat_mode_system.yaml");
let code_path = dir.join("code_mode_system.yaml");
fs::write(chat_path, serde_yaml::to_string(&chat_mode_system)?)?;
fs::write(code_path, serde_yaml::to_string(&code_mode_system)?)?;
Ok(())
}
/// Load all templates from the templates directory
fn load_templates(&mut self) -> Result<()> {
let entries = fs::read_dir(&self.templates_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("yaml")
|| path.extension().and_then(|s| s.to_str()) == Some("yml")
{
match self.load_template(&path) {
Ok(template) => {
// Register with Handlebars
if let Err(e) = self
.handlebars
.register_template_string(&template.name, &template.template)
{
eprintln!(
"Warning: Failed to register template {}: {}",
template.name, e
);
} else {
let mut templates = futures::executor::block_on(self.templates.write());
templates.insert(template.name.clone(), template);
}
}
Err(e) => {
eprintln!("Warning: Failed to load template {:?}: {}", path, e);
}
}
}
}
Ok(())
}
/// Load a single template from file
fn load_template(&self, path: &Path) -> Result<PromptTemplate> {
let content = fs::read_to_string(path)?;
let template: PromptTemplate = serde_yaml::from_str(&content)?;
Ok(template)
}
/// Get a template by name
pub async fn get_template(&self, name: &str) -> Option<PromptTemplate> {
let templates = self.templates.read().await;
templates.get(name).cloned()
}
/// List all available templates
pub async fn list_templates(&self) -> Vec<String> {
let templates = self.templates.read().await;
templates.keys().cloned().collect()
}
/// Render a template with given variables
pub fn render_template(&self, name: &str, vars: &Value) -> Result<String> {
self.handlebars
.render(name, vars)
.context("Failed to render template")
}
/// Reload all templates from disk
pub async fn reload_templates(&mut self) -> Result<()> {
{
let mut templates = self.templates.write().await;
templates.clear();
}
self.handlebars = Handlebars::new();
self.load_templates()
}
}
#[allow(dead_code)]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
let server = Arc::new(tokio::sync::Mutex::new(PromptServer::new()?));
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let resp = handle_request(req.clone(), server.clone()).await;
match resp {
Ok(r) => {
let s = serde_json::to_string(&r)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(e) => {
let err = RpcErrorResponse::new(req.id.clone(), e);
let s = serde_json::to_string(&err)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
eprintln!("Error reading stdin: {}", e);
break;
}
}
}
Ok(())
}
#[allow(dead_code)]
async fn handle_request(
req: RpcRequest,
server: Arc<tokio::sync::Mutex<PromptServer>>,
) -> Result<RpcResponse, RpcError> {
match req.method.as_str() {
methods::INITIALIZE => {
let params: InitializeParams =
serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
if !params.protocol_version.eq(PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
params.protocol_version, PROTOCOL_VERSION
),
));
}
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-prompt-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(true),
supports_resources: Some(false),
supports_streaming: Some(false),
},
};
Ok(RpcResponse::new(
req.id,
serde_json::to_value(result).unwrap(),
))
}
methods::TOOLS_LIST => {
let tools = vec![
McpToolDescriptor {
name: "get_prompt".to_string(),
description: "Retrieve a prompt template by name".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"name": {"type": "string", "description": "Template name"}
},
"required": ["name"]
}),
requires_network: false,
requires_filesystem: vec![],
},
McpToolDescriptor {
name: "render_prompt".to_string(),
description: "Render a prompt template with Handlebars variables".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"name": {"type": "string", "description": "Template name"},
"vars": {"type": "object", "description": "Variables for Handlebars rendering"}
},
"required": ["name"]
}),
requires_network: false,
requires_filesystem: vec![],
},
McpToolDescriptor {
name: "list_prompts".to_string(),
description: "List all available prompt templates".to_string(),
input_schema: json!({"type": "object", "properties": {}}),
requires_network: false,
requires_filesystem: vec![],
},
McpToolDescriptor {
name: "reload_prompts".to_string(),
description: "Reload all prompts from disk".to_string(),
input_schema: json!({"type": "object", "properties": {}}),
requires_network: false,
requires_filesystem: vec![],
},
];
Ok(RpcResponse::new(req.id, json!(tools)))
}
methods::TOOLS_CALL => {
let call: McpToolCall = serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
.map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?;
let result = match call.name.as_str() {
"get_prompt" => {
let name = call
.arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?;
let srv = server.lock().await;
match srv.get_template(name).await {
Some(template) => {
json!({"success": true, "template": serde_json::to_value(template).unwrap()})
}
None => json!({"success": false, "error": "Template not found"}),
}
}
"render_prompt" => {
let name = call
.arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?;
let default_vars = json!({});
let vars = call.arguments.get("vars").unwrap_or(&default_vars);
let srv = server.lock().await;
match srv.render_template(name, vars) {
Ok(rendered) => json!({"success": true, "rendered": rendered}),
Err(e) => json!({"success": false, "error": e.to_string()}),
}
}
"list_prompts" => {
let srv = server.lock().await;
let templates = srv.list_templates().await;
json!({"success": true, "templates": templates})
}
"reload_prompts" => {
let mut srv = server.lock().await;
match srv.reload_templates().await {
Ok(_) => json!({"success": true, "message": "Prompts reloaded"}),
Err(e) => json!({"success": false, "error": e.to_string()}),
}
}
_ => return Err(RpcError::method_not_found(&call.name)),
};
let resp = McpToolResponse {
name: call.name,
success: result
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false),
output: result,
metadata: HashMap::new(),
duration_ms: 0,
};
Ok(RpcResponse::new(
req.id,
serde_json::to_value(resp).unwrap(),
))
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}

View File

@@ -1,3 +0,0 @@
prompt: |
Hello {{name}}!
Your role is: {{role}}.

View File

@@ -1,12 +0,0 @@
[package]
name = "owlen-mcp-server"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
path-clean = "1.0"
owlen-core = { path = "../owlen-core" }

View File

@@ -1,246 +0,0 @@
use owlen_core::mcp::protocol::{
is_compatible, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError,
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
};
use path_clean::PathClean;
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
#[derive(Deserialize)]
struct FileArgs {
path: String,
}
#[derive(Deserialize)]
struct WriteArgs {
path: String,
content: String,
}
async fn handle_request(req: &RpcRequest, root: &Path) -> Result<serde_json::Value, RpcError> {
match req.method.as_str() {
"initialize" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?;
let init_params: InitializeParams =
serde_json::from_value(params.clone()).map_err(|e| {
RpcError::invalid_params(format!("Invalid initialize params: {}", e))
})?;
// Check protocol version compatibility
if !is_compatible(&init_params.protocol_version, PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
init_params.protocol_version, PROTOCOL_VERSION
),
));
}
// Build initialization result
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(false),
supports_resources: Some(true), // Supports read, write, delete
supports_streaming: Some(false),
},
};
Ok(serde_json::to_value(result).map_err(|e| {
RpcError::internal_error(format!("Failed to serialize result: {}", e))
})?)
}
"resources/list" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: FileArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_list(&args.path, root).await
}
"resources/get" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: FileArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_get(&args.path, root).await
}
"resources/write" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: WriteArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_write(&args.path, &args.content, root).await
}
"resources/delete" => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params"))?;
let args: FileArgs = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid params: {}", e)))?;
resources_delete(&args.path, root).await
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, RpcError> {
let path = Path::new(path);
let path = if path.is_absolute() {
path.strip_prefix("/")
.map_err(|_| RpcError::invalid_params("Invalid path"))?
.to_path_buf()
} else {
path.to_path_buf()
};
let full_path = root.join(path).clean();
if !full_path.starts_with(root) {
return Err(RpcError::path_traversal());
}
Ok(full_path)
}
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
let entries = fs::read_dir(full_path).map_err(|e| {
RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
format!("Failed to read directory: {}", e),
)
})?;
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| {
RpcError::internal_error(format!("Failed to read directory entry: {}", e))
})?;
result.push(entry.file_name().to_string_lossy().to_string());
}
Ok(serde_json::json!(result))
}
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
let content = fs::read_to_string(full_path).map_err(|e| {
RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
format!("Failed to read file: {}", e),
)
})?;
Ok(serde_json::json!(content))
}
async fn resources_write(
path: &str,
content: &str,
root: &Path,
) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
// Ensure parent directory exists
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
RpcError::internal_error(format!("Failed to create parent directories: {}", e))
})?;
}
std::fs::write(full_path, content)
.map_err(|e| RpcError::internal_error(format!("Failed to write file: {}", e)))?;
Ok(serde_json::json!(null))
}
async fn resources_delete(path: &str, root: &Path) -> Result<serde_json::Value, RpcError> {
let full_path = sanitize_path(path, root)?;
if full_path.is_file() {
std::fs::remove_file(full_path)
.map_err(|e| RpcError::internal_error(format!("Failed to delete file: {}", e)))?;
Ok(serde_json::json!(null))
} else {
Err(RpcError::new(
ErrorCode::RESOURCE_NOT_FOUND,
"Path does not refer to a file",
))
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?;
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => {
// EOF
break;
}
Ok(_) => {
let req: RpcRequest = match serde_json::from_str(&line) {
Ok(req) => req,
Err(e) => {
let err_resp = RpcErrorResponse::new(
RequestId::Number(0),
RpcError::parse_error(format!("Parse error: {}", e)),
);
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let request_id = req.id.clone();
match handle_request(&req, &root).await {
Ok(result) => {
let resp = RpcResponse::new(request_id, result);
let resp_str = serde_json::to_string(&resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(error) => {
let err_resp = RpcErrorResponse::new(request_id, error);
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
// Handle read error
eprintln!("Error reading from stdin: {}", e);
break;
}
}
}
Ok(())
}

View File

@@ -2,7 +2,7 @@
This crate provides an implementation of the `owlen-core::Provider` trait for the [Ollama](https://ollama.ai) backend.
It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models. You can also target [Ollama Cloud](https://docs.ollama.com/cloud) by pointing the provider at `https://ollama.com` (or `https://api.ollama.com`) and providing an API key through your Owlen configuration (or the `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables). The client automatically adds the required Bearer authorization header when a key is supplied, accepts either host without rewriting, and expands inline environment references like `$OLLAMA_API_KEY` if you prefer not to check the secret into your config file. The generated configuration now includes both `providers.ollama` and `providers.ollama-cloud` entries—switch between them by updating `general.default_provider`.
It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models.
## Configuration

View File

@@ -5,16 +5,13 @@ use owlen_core::{
config::GeneralSettings,
model::ModelManager,
provider::{ChatStream, Provider, ProviderConfig},
types::{
ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall,
},
types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage},
Result,
};
use reqwest::{header, Client, Url};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::io;
use std::time::Duration;
use tokio::sync::mpsc;
@@ -23,195 +20,26 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
const DEFAULT_TIMEOUT_SECS: u64 = 120;
const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OllamaMode {
Local,
Cloud,
}
impl OllamaMode {
fn from_provider_type(provider_type: &str) -> Self {
if provider_type.eq_ignore_ascii_case("ollama-cloud") {
Self::Cloud
} else {
Self::Local
}
}
fn default_base_url(self) -> &'static str {
match self {
Self::Local => "http://localhost:11434",
Self::Cloud => "https://ollama.com",
}
}
fn default_scheme(self) -> &'static str {
match self {
Self::Local => "http",
Self::Cloud => "https",
}
}
}
fn is_ollama_host(host: &str) -> bool {
host.eq_ignore_ascii_case("ollama.com")
|| host.eq_ignore_ascii_case("www.ollama.com")
|| host.eq_ignore_ascii_case("api.ollama.com")
|| host.ends_with(".ollama.com")
}
fn normalize_base_url(
input: Option<&str>,
mode_hint: OllamaMode,
) -> std::result::Result<String, String> {
let mut candidate = input
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string())
.unwrap_or_else(|| mode_hint.default_base_url().to_string());
if !candidate.contains("://") {
candidate = format!("{}://{}", mode_hint.default_scheme(), candidate);
}
let mut url =
Url::parse(&candidate).map_err(|err| format!("Invalid base_url '{candidate}': {err}"))?;
let mut is_cloud = matches!(mode_hint, OllamaMode::Cloud);
if let Some(host) = url.host_str() {
if is_ollama_host(host) {
is_cloud = true;
}
}
if is_cloud {
if url.scheme() != "https" {
url.set_scheme("https")
.map_err(|_| "Ollama Cloud requires an https URL".to_string())?;
}
match url.host_str() {
Some(host) => {
if host.eq_ignore_ascii_case("www.ollama.com") {
url.set_host(Some("ollama.com"))
.map_err(|_| "Failed to normalize Ollama Cloud host".to_string())?;
}
}
None => {
return Err("Ollama Cloud base_url must include a hostname".to_string());
}
}
}
// Remove trailing slash and discard query/fragment segments
let current_path = url.path().to_string();
let trimmed_path = current_path.trim_end_matches('/');
if trimmed_path.is_empty() {
url.set_path("");
} else {
url.set_path(trimmed_path);
}
url.set_query(None);
url.set_fragment(None);
Ok(url.to_string().trim_end_matches('/').to_string())
}
fn build_api_endpoint(base_url: &str, endpoint: &str) -> String {
let trimmed_base = base_url.trim_end_matches('/');
let trimmed_endpoint = endpoint.trim_start_matches('/');
if trimmed_base.ends_with("/api") {
format!("{trimmed_base}/{trimmed_endpoint}")
} else {
format!("{trimmed_base}/api/{trimmed_endpoint}")
}
}
fn env_var_non_empty(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn resolve_api_key(configured: Option<String>) -> Option<String> {
let raw = configured?.trim().to_string();
if raw.is_empty() {
return None;
}
if let Some(variable) = raw
.strip_prefix("${")
.and_then(|value| value.strip_suffix('}'))
.or_else(|| raw.strip_prefix('$'))
{
let var_name = variable.trim();
if var_name.is_empty() {
return None;
}
return env_var_non_empty(var_name);
}
Some(raw)
}
fn debug_requests_enabled() -> bool {
std::env::var("OWLEN_DEBUG_OLLAMA")
.ok()
.map(|value| {
matches!(
value.trim(),
"1" | "true" | "TRUE" | "True" | "yes" | "YES" | "Yes"
)
})
.unwrap_or(false)
}
fn mask_token(token: &str) -> String {
if token.len() <= 8 {
return "***".to_string();
}
let head = &token[..4];
let tail = &token[token.len() - 4..];
format!("{head}***{tail}")
}
fn mask_authorization(value: &str) -> String {
if let Some(token) = value.strip_prefix("Bearer ") {
format!("Bearer {}", mask_token(token))
} else {
"***".to_string()
}
}
/// Ollama provider implementation with enhanced configuration and caching
#[derive(Debug)]
pub struct OllamaProvider {
client: Client,
base_url: String,
api_key: Option<String>,
model_manager: ModelManager,
}
/// Options for configuring the Ollama provider
pub(crate) struct OllamaOptions {
base_url: String,
request_timeout: Duration,
model_cache_ttl: Duration,
api_key: Option<String>,
pub struct OllamaOptions {
pub base_url: String,
pub request_timeout: Duration,
pub model_cache_ttl: Duration,
}
impl OllamaOptions {
pub(crate) fn new(base_url: impl Into<String>) -> Self {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS),
api_key: None,
}
}
@@ -226,20 +54,6 @@ impl OllamaOptions {
struct OllamaMessage {
role: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<OllamaToolCall>>,
}
/// Ollama tool call format
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaToolCall {
function: OllamaToolCallFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaToolCallFunction {
name: String,
arguments: serde_json::Value,
}
/// Ollama chat request format
@@ -248,27 +62,10 @@ struct OllamaChatRequest {
model: String,
messages: Vec<OllamaMessage>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<OllamaTool>>,
#[serde(flatten)]
options: HashMap<String, Value>,
}
/// Ollama tool definition
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaTool {
#[serde(rename = "type")]
tool_type: String,
function: OllamaToolFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaToolFunction {
name: String,
description: String,
parameters: serde_json::Value,
}
/// Ollama chat response format
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
@@ -310,60 +107,17 @@ struct OllamaModelDetails {
impl OllamaProvider {
/// Create a new Ollama provider with sensible defaults
pub fn new(base_url: impl Into<String>) -> Result<Self> {
let mode = OllamaMode::Local;
let supplied = base_url.into();
let normalized =
normalize_base_url(Some(&supplied), mode).map_err(owlen_core::Error::Config)?;
Self::with_options(OllamaOptions::new(normalized))
}
fn debug_log_request(&self, label: &str, request: &reqwest::Request, body_json: Option<&str>) {
if !debug_requests_enabled() {
return;
}
eprintln!("--- OWLEN Ollama request ({label}) ---");
eprintln!("{} {}", request.method(), request.url());
match request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
{
Some(value) => eprintln!("Authorization: {}", mask_authorization(value)),
None => eprintln!("Authorization: <none>"),
}
if let Some(body) = body_json {
eprintln!("Body:\n{body}");
}
eprintln!("---------------------------------------");
}
/// Convert MCP tool descriptors to Ollama tool format
fn convert_tools_to_ollama(tools: &[owlen_core::mcp::McpToolDescriptor]) -> Vec<OllamaTool> {
tools
.iter()
.map(|tool| OllamaTool {
tool_type: "function".to_string(),
function: OllamaToolFunction {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
},
})
.collect()
Self::with_options(OllamaOptions::new(base_url))
}
/// Create a provider from configuration settings
pub fn from_config(config: &ProviderConfig, general: Option<&GeneralSettings>) -> Result<Self> {
let mode = OllamaMode::from_provider_type(&config.provider_type);
let normalized_base_url = normalize_base_url(config.base_url.as_deref(), mode)
.map_err(owlen_core::Error::Config)?;
let mut options = OllamaOptions::new(normalized_base_url);
let mut options = OllamaOptions::new(
config
.base_url
.clone()
.unwrap_or_else(|| "http://localhost:11434".to_string()),
);
if let Some(timeout) = config
.extra
@@ -381,10 +135,6 @@ impl OllamaProvider {
options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5));
}
options.api_key = resolve_api_key(config.api_key.clone())
.or_else(|| env_var_non_empty("OLLAMA_API_KEY"))
.or_else(|| env_var_non_empty("OLLAMA_CLOUD_API_KEY"));
if let Some(general) = general {
options = options.with_general(general);
}
@@ -393,24 +143,16 @@ impl OllamaProvider {
}
/// Create a provider from explicit options
pub(crate) fn with_options(options: OllamaOptions) -> Result<Self> {
let OllamaOptions {
base_url,
request_timeout,
model_cache_ttl,
api_key,
} = options;
pub fn with_options(options: OllamaOptions) -> Result<Self> {
let client = Client::builder()
.timeout(request_timeout)
.timeout(options.request_timeout)
.build()
.map_err(|e| owlen_core::Error::Config(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
model_manager: ModelManager::new(model_cache_ttl),
base_url: options.base_url.trim_end_matches('/').to_string(),
model_manager: ModelManager::new(options.model_cache_ttl),
})
}
@@ -419,42 +161,14 @@ impl OllamaProvider {
&self.model_manager
}
fn api_url(&self, endpoint: &str) -> String {
build_api_endpoint(&self.base_url, endpoint)
}
fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(api_key) = &self.api_key {
request.bearer_auth(api_key)
} else {
request
}
}
fn convert_message(message: &Message) -> OllamaMessage {
let role = match message.role {
Role::User => "user".to_string(),
Role::Assistant => "assistant".to_string(),
Role::System => "system".to_string(),
Role::Tool => "tool".to_string(),
};
let tool_calls = message.tool_calls.as_ref().map(|calls| {
calls
.iter()
.map(|tc| OllamaToolCall {
function: OllamaToolCallFunction {
name: tc.name.clone(),
arguments: tc.arguments.clone(),
},
})
.collect()
});
OllamaMessage {
role,
role: match message.role {
Role::User => "user".to_string(),
Role::Assistant => "assistant".to_string(),
Role::System => "system".to_string(),
},
content: message.content.clone(),
tool_calls,
}
}
@@ -463,27 +177,10 @@ impl OllamaProvider {
"user" => Role::User,
"assistant" => Role::Assistant,
"system" => Role::System,
"tool" => Role::Tool,
_ => Role::Assistant,
};
let mut msg = Message::new(role, message.content.clone());
// Convert tool calls if present
if let Some(ollama_tool_calls) = &message.tool_calls {
let tool_calls: Vec<ToolCall> = ollama_tool_calls
.iter()
.enumerate()
.map(|(idx, tc)| ToolCall {
id: format!("call_{}", idx),
name: tc.function.name.clone(),
arguments: tc.function.arguments.clone(),
})
.collect();
msg.tool_calls = Some(tool_calls);
}
msg
Message::new(role, message.content.clone())
}
fn build_options(parameters: ChatParameters) -> HashMap<String, Value> {
@@ -505,10 +202,11 @@ impl OllamaProvider {
}
async fn fetch_models(&self) -> Result<Vec<ModelInfo>> {
let url = self.api_url("tags");
let url = format!("{}/api/tags", self.base_url);
let response = self
.apply_auth(self.client.get(&url))
.client
.get(&url)
.send()
.await
.map_err(|e| owlen_core::Error::Network(format!("Failed to fetch models: {e}")))?;
@@ -531,51 +229,21 @@ impl OllamaProvider {
let models = ollama_response
.models
.into_iter()
.map(|model| {
// Check if model supports tool calling based on known models
let supports_tools = Self::check_tool_support(&model.name);
ModelInfo {
id: model.name.clone(),
name: model.name.clone(),
description: model
.details
.as_ref()
.and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))),
provider: "ollama".to_string(),
context_window: None,
capabilities: vec!["chat".to_string()],
supports_tools,
}
.map(|model| ModelInfo {
id: model.name.clone(),
name: model.name.clone(),
description: model
.details
.as_ref()
.and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))),
provider: "ollama".to_string(),
context_window: None,
capabilities: vec!["chat".to_string()],
})
.collect();
Ok(models)
}
/// Check if a model supports tool calling based on its name
fn check_tool_support(model_name: &str) -> bool {
let name_lower = model_name.to_lowercase();
// Known models with tool calling support
let tool_supporting_models = [
"qwen",
"llama3.1",
"llama3.2",
"llama3.3",
"mistral-nemo",
"mistral:7b-instruct",
"command-r",
"firefunction",
"hermes",
"nexusraven",
"granite-code",
];
tool_supporting_models
.iter()
.any(|&supported| name_lower.contains(supported))
}
}
#[async_trait::async_trait]
@@ -595,52 +263,25 @@ impl Provider for OllamaProvider {
model,
messages,
parameters,
tools,
} = request;
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
let options = Self::build_options(parameters);
// Only send the `tools` field if there is at least one tool.
// An empty array makes Ollama validate tool support and can cause a
// 400 Bad Request for models that do not support tools.
// Currently the `tools` field is omitted for compatibility; the variable is retained
// for potential future use.
let _ollama_tools = tools
.as_ref()
.filter(|t| !t.is_empty())
.map(|t| Self::convert_tools_to_ollama(t));
// Ollama currently rejects any presence of the `tools` field for models that
// do not support function calling. To be safe, we omit the field entirely.
let ollama_request = OllamaChatRequest {
model,
messages,
stream: false,
tools: None,
options,
};
let url = self.api_url("chat");
let debug_body = if debug_requests_enabled() {
serde_json::to_string_pretty(&ollama_request).ok()
} else {
None
};
let mut request_builder = self.client.post(&url).json(&ollama_request);
request_builder = self.apply_auth(request_builder);
let request = request_builder.build().map_err(|e| {
owlen_core::Error::Network(format!("Failed to build chat request: {e}"))
})?;
self.debug_log_request("chat", &request, debug_body.as_deref());
let url = format!("{}/api/chat", self.base_url);
let response = self
.client
.execute(request)
.post(&url)
.json(&ollama_request)
.send()
.await
.map_err(|e| owlen_core::Error::Network(format!("Chat request failed: {e}")))?;
@@ -698,51 +339,28 @@ impl Provider for OllamaProvider {
model,
messages,
parameters,
tools,
} = request;
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
let options = Self::build_options(parameters);
// Only include the `tools` field if there is at least one tool.
// Sending an empty tools array causes Ollama to reject the request for
// models without tool support (400 Bad Request).
// Retain tools conversion for possible future extensions, but silence unused warnings.
let _ollama_tools = tools
.as_ref()
.filter(|t| !t.is_empty())
.map(|t| Self::convert_tools_to_ollama(t));
// Omit the `tools` field for compatibility with models lacking tool support.
let ollama_request = OllamaChatRequest {
model,
messages,
stream: true,
tools: None,
options,
};
let url = self.api_url("chat");
let debug_body = if debug_requests_enabled() {
serde_json::to_string_pretty(&ollama_request).ok()
} else {
None
};
let url = format!("{}/api/chat", self.base_url);
let mut request_builder = self.client.post(&url).json(&ollama_request);
request_builder = self.apply_auth(request_builder);
let request = request_builder.build().map_err(|e| {
owlen_core::Error::Network(format!("Failed to build streaming request: {e}"))
})?;
self.debug_log_request("chat_stream", &request, debug_body.as_deref());
let response =
self.client.execute(request).await.map_err(|e| {
owlen_core::Error::Network(format!("Streaming request failed: {e}"))
})?;
let response = self
.client
.post(&url)
.json(&ollama_request)
.send()
.await
.map_err(|e| owlen_core::Error::Network(format!("Streaming request failed: {e}")))?;
if !response.status().is_success() {
let code = response.status();
@@ -844,10 +462,11 @@ impl Provider for OllamaProvider {
}
async fn health_check(&self) -> Result<()> {
let url = self.api_url("version");
let url = format!("{}/api/version", self.base_url);
let response = self
.apply_auth(self.client.get(&url))
.client
.get(&url)
.send()
.await
.map_err(|e| owlen_core::Error::Network(format!("Health check failed: {e}")))?;
@@ -909,86 +528,3 @@ async fn parse_error_body(response: reqwest::Response) -> String {
Err(_) => "unknown error".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_local_base_url_and_infers_scheme() {
let normalized =
normalize_base_url(Some("localhost:11434"), OllamaMode::Local).expect("valid URL");
assert_eq!(normalized, "http://localhost:11434");
}
#[test]
fn normalizes_cloud_base_url_and_host() {
let normalized =
normalize_base_url(Some("https://ollama.com"), OllamaMode::Cloud).expect("valid URL");
assert_eq!(normalized, "https://ollama.com");
}
#[test]
fn infers_scheme_for_cloud_hosts() {
let normalized =
normalize_base_url(Some("ollama.com"), OllamaMode::Cloud).expect("valid URL");
assert_eq!(normalized, "https://ollama.com");
}
#[test]
fn rewrites_www_cloud_host() {
let normalized = normalize_base_url(Some("https://www.ollama.com"), OllamaMode::Cloud)
.expect("valid URL");
assert_eq!(normalized, "https://ollama.com");
}
#[test]
fn retains_explicit_api_suffix() {
let normalized = normalize_base_url(Some("https://api.ollama.com/api"), OllamaMode::Cloud)
.expect("valid URL");
assert_eq!(normalized, "https://api.ollama.com/api");
}
#[test]
fn builds_api_endpoint_without_duplicate_segments() {
let base = "http://localhost:11434";
assert_eq!(
build_api_endpoint(base, "chat"),
"http://localhost:11434/api/chat"
);
let base_with_api = "http://localhost:11434/api";
assert_eq!(
build_api_endpoint(base_with_api, "chat"),
"http://localhost:11434/api/chat"
);
}
#[test]
fn resolve_api_key_prefers_literal_value() {
assert_eq!(
resolve_api_key(Some("direct-key".into())),
Some("direct-key".into())
);
}
#[test]
fn resolve_api_key_expands_braced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY_BRACED", "super-secret");
assert_eq!(
resolve_api_key(Some("${OWLEN_TEST_KEY_BRACED}".into())),
Some("super-secret".into())
);
std::env::remove_var("OWLEN_TEST_KEY_BRACED");
}
#[test]
fn resolve_api_key_expands_unbraced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY_UNBRACED", "another-secret");
assert_eq!(
resolve_api_key(Some("$OWLEN_TEST_KEY_UNBRACED".into())),
Some("another-secret".into())
);
std::env::remove_var("OWLEN_TEST_KEY_UNBRACED");
}
}

View File

@@ -10,7 +10,6 @@ description = "Terminal User Interface for OWLEN LLM client"
[dependencies]
owlen-core = { path = "../owlen-core" }
# Removed owlen-ollama dependency - all providers now accessed via MCP architecture (Phase 10)
# TUI framework
ratatui = { workspace = true }
@@ -18,7 +17,6 @@ crossterm = { workspace = true }
tui-textarea = { workspace = true }
textwrap = { workspace = true }
unicode-width = "0.1"
async-trait = "0.1"
# Async runtime
tokio = { workspace = true }
@@ -28,7 +26,6 @@ futures-util = { workspace = true }
# Utilities
anyhow = { workspace = true }
uuid = { workspace = true }
serde_json.workspace = true
[dev-dependencies]
tokio-test = { workspace = true }

File diff suppressed because it is too large Load Diff

View File

@@ -14,14 +14,12 @@ pub struct CodeApp {
}
impl CodeApp {
pub async fn new(
mut controller: SessionController,
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
pub fn new(mut controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
controller
.conversation_mut()
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
let (inner, rx) = ChatApp::new(controller).await?;
Ok((Self { inner }, rx))
let (inner, rx) = ChatApp::new(controller);
(Self { inner }, rx)
}
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {

View File

@@ -1,6 +1,6 @@
pub use owlen_core::config::{
default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout, Config,
GeneralSettings, InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH,
default_config_path, ensure_ollama_config, session_timeout, Config, GeneralSettings,
InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH,
};
/// Attempt to load configuration from default location

View File

@@ -16,7 +16,6 @@ pub mod chat_app;
pub mod code_app;
pub mod config;
pub mod events;
pub mod tui_controller;
pub mod ui;
pub use chat_app::{ChatApp, SessionEvent};

View File

@@ -1,44 +0,0 @@
use async_trait::async_trait;
use owlen_core::ui::UiController;
use tokio::sync::{mpsc, oneshot};
/// A request sent from the UiController to the TUI event loop.
#[derive(Debug)]
pub enum TuiRequest {
Confirm {
prompt: String,
tx: oneshot::Sender<bool>,
},
}
/// An implementation of the UiController trait for the TUI.
/// It uses channels to communicate with the main ChatApp event loop.
pub struct TuiController {
tx: mpsc::UnboundedSender<TuiRequest>,
}
impl TuiController {
pub fn new(tx: mpsc::UnboundedSender<TuiRequest>) -> Self {
Self { tx }
}
}
#[async_trait]
impl UiController for TuiController {
async fn confirm(&self, prompt: &str) -> bool {
let (tx, rx) = oneshot::channel();
let request = TuiRequest::Confirm {
prompt: prompt.to_string(),
tx,
};
if self.tx.send(request).is_err() {
// Receiver was dropped, so we can't get confirmation.
// Default to false for safety.
return false;
}
// Wait for the response from the TUI.
rx.await.unwrap_or(false)
}
}

View File

@@ -3,17 +3,14 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame;
use serde_json;
use textwrap::{wrap, Options};
use tui_textarea::TextArea;
use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, ModelSelectorItemKind, HELP_TAB_COUNT};
use crate::chat_app::ChatApp;
use owlen_core::types::Role;
use owlen_core::ui::{FocusedPanel, InputMode};
const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1;
pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Update thinking content from last message
app.update_thinking_from_last_message();
@@ -51,15 +48,6 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
0
};
// Calculate agent actions panel height (similar to thinking)
let actions_height = if let Some(actions) = app.agent_actions() {
let content_width = available_width.saturating_sub(4);
let visual_lines = calculate_wrapped_line_count(actions.lines(), content_width);
(visual_lines as u16).min(6) + 2 // +2 for borders, max 6 lines
} else {
0
};
let mut constraints = vec![
Constraint::Length(4), // Header
Constraint::Min(8), // Messages
@@ -68,14 +56,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
if thinking_height > 0 {
constraints.push(Constraint::Length(thinking_height)); // Thinking
}
// Insert agent actions panel after thinking (if any)
if actions_height > 0 {
constraints.push(Constraint::Length(actions_height)); // Agent actions
}
constraints.push(Constraint::Length(input_height)); // Input
constraints.push(Constraint::Length(5)); // System/Status output (3 lines content + 2 borders)
constraints.push(Constraint::Length(3)); // Mode and shortcuts bar
constraints.push(Constraint::Length(3)); // Status
let layout = Layout::default()
.direction(Direction::Vertical)
@@ -93,33 +76,20 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
render_thinking(frame, layout[idx], app);
idx += 1;
}
// Render agent actions panel if present
if actions_height > 0 {
render_agent_actions(frame, layout[idx], app);
idx += 1;
}
render_input(frame, layout[idx], app);
idx += 1;
render_system_output(frame, layout[idx], app);
idx += 1;
render_status(frame, layout[idx], app);
// Render consent dialog with highest priority (always on top)
if app.has_pending_consent() {
render_consent_dialog(frame, app);
} else {
match app.mode() {
InputMode::ProviderSelection => render_provider_selector(frame, app),
InputMode::ModelSelection => render_model_selector(frame, app),
InputMode::Help => render_help(frame, app),
InputMode::SessionBrowser => render_session_browser(frame, app),
InputMode::ThemeBrowser => render_theme_browser(frame, app),
InputMode::Command => render_command_suggestions(frame, app),
_ => {}
}
match app.mode() {
InputMode::ProviderSelection => render_provider_selector(frame, app),
InputMode::ModelSelection => render_model_selector(frame, app),
InputMode::Help => render_help(frame, app),
InputMode::SessionBrowser => render_session_browser(frame, app),
InputMode::ThemeBrowser => render_theme_browser(frame, app),
InputMode::Command => render_command_suggestions(frame, app),
_ => {}
}
}
@@ -630,16 +600,12 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
Role::User => ("👤 ", "You: "),
Role::Assistant => ("🤖 ", "Assistant: "),
Role::System => ("⚙️ ", "System: "),
Role::Tool => ("🔧 ", "Tool: "),
};
// Extract content without thinking tags for assistant messages
let content_to_display = if matches!(role, Role::Assistant) {
let (content_without_think, _) = formatter.extract_thinking(&message.content);
content_without_think
} else if matches!(role, Role::Tool) {
// Format tool results nicely
format_tool_output(&message.content)
} else {
message.content.clone()
};
@@ -692,13 +658,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(format!("{indent}{}", seg), style)];
let mut spans = vec![Span::raw(format!("{indent}{}", seg))];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
@@ -710,13 +670,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let chunks = wrap(&content, content_width as usize);
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(seg.into_owned(), style)];
let mut spans = vec![Span::raw(seg.into_owned())];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
@@ -916,191 +870,6 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
}
}
// Render a panel displaying the latest ReAct agent actions (thought/action/observation).
// Color-coded: THOUGHT (blue), ACTION (yellow), OBSERVATION (green)
fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
if let Some(actions) = app.agent_actions().cloned() {
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4);
// Parse and color-code ReAct components
let mut lines: Vec<Line> = Vec::new();
for line in actions.lines() {
let line_trimmed = line.trim();
// Detect ReAct components and apply color coding
if line_trimmed.starts_with("THOUGHT:") {
// Blue for THOUGHT
let thought_content = line_trimmed.strip_prefix("THOUGHT:").unwrap_or("").trim();
let wrapped = wrap(thought_content, content_width as usize);
// First line with label
if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![
Span::styled(
"THOUGHT: ",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.to_string(), Style::default().fg(Color::Blue)),
]));
}
// Continuation lines
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Blue),
)));
}
} else if line_trimmed.starts_with("ACTION:") {
// Yellow for ACTION
let action_content = line_trimmed.strip_prefix("ACTION:").unwrap_or("").trim();
lines.push(Line::from(vec![
Span::styled(
"ACTION: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
action_content,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
]));
} else if line_trimmed.starts_with("ACTION_INPUT:") {
// Cyan for ACTION_INPUT
let input_content = line_trimmed
.strip_prefix("ACTION_INPUT:")
.unwrap_or("")
.trim();
let wrapped = wrap(input_content, content_width as usize);
if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![
Span::styled(
"ACTION_INPUT: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.to_string(), Style::default().fg(Color::Cyan)),
]));
}
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Cyan),
)));
}
} else if line_trimmed.starts_with("OBSERVATION:") {
// Green for OBSERVATION
let obs_content = line_trimmed
.strip_prefix("OBSERVATION:")
.unwrap_or("")
.trim();
let wrapped = wrap(obs_content, content_width as usize);
if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![
Span::styled(
"OBSERVATION: ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.to_string(), Style::default().fg(Color::Green)),
]));
}
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Green),
)));
}
} else if line_trimmed.starts_with("FINAL_ANSWER:") {
// Magenta for FINAL_ANSWER
let answer_content = line_trimmed
.strip_prefix("FINAL_ANSWER:")
.unwrap_or("")
.trim();
let wrapped = wrap(answer_content, content_width as usize);
if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![
Span::styled(
"FINAL_ANSWER: ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(
first.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
]));
}
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Magenta),
)));
}
} else if !line_trimmed.is_empty() {
// Regular text
let wrapped = wrap(line_trimmed, content_width as usize);
for chunk in wrapped {
lines.push(Line::from(Span::styled(
chunk.into_owned(),
Style::default().fg(theme.text),
)));
}
} else {
// Empty line
lines.push(Line::from(""));
}
}
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) {
// Reuse the same focus logic; could add a dedicated enum variant later.
theme.focused_panel_border
} else {
theme.unfocused_panel_border
};
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Span::styled(
" 🤖 Agent Actions ",
Style::default()
.fg(theme.thinking_panel_title)
.add_modifier(Modifier::ITALIC),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
_ = viewport_height;
}
}
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme();
let title = match app.mode() {
@@ -1180,47 +949,6 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
}
}
fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let theme = app.theme();
let system_status = app.system_status();
// Priority: system_status > error > status > "Ready"
let display_message = if !system_status.is_empty() {
system_status.to_string()
} else if let Some(error) = app.error_message() {
format!("Error: {}", error)
} else {
let status = app.status_message();
if status.is_empty() || status == "Ready" {
"Ready".to_string()
} else {
status.to_string()
}
};
// Create a simple paragraph with wrapping enabled
let line = Line::from(Span::styled(
display_message,
Style::default().fg(theme.info),
));
let paragraph = Paragraph::new(line)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Span::styled(
" System/Status ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
where
I: IntoIterator<Item = &'a str>,
@@ -1269,53 +997,39 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
InputMode::ThemeBrowser => (" THEMES", theme.mode_help),
};
let status_message = if let Some(error) = app.error_message() {
format!("Error: {}", error)
} else {
app.status_message().to_string()
};
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
let mut spans = vec![Span::styled(
format!(" {} ", mode_text),
Style::default()
.fg(theme.background)
.bg(mode_bg_color)
.add_modifier(Modifier::BOLD),
)];
// Add agent status indicator if agent mode is active
if app.is_agent_running() {
spans.push(Span::styled(
" 🤖 AGENT RUNNING ",
let left_spans = vec![
Span::styled(
format!(" {} ", mode_text),
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.fg(theme.background)
.bg(mode_bg_color)
.add_modifier(Modifier::BOLD),
));
} else if app.is_agent_mode() {
spans.push(Span::styled(
" 🤖 AGENT MODE ",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
}
),
Span::styled(
format!(" | {} ", status_message),
Style::default().fg(theme.text),
),
];
// Add operating mode indicator
let operating_mode = app.get_mode();
let (op_mode_text, op_mode_color) = match operating_mode {
owlen_core::mode::Mode::Chat => (" 💬 CHAT", Color::Blue),
owlen_core::mode::Mode::Code => (" 💻 CODE", Color::Magenta),
};
spans.push(Span::styled(
op_mode_text,
Style::default()
.fg(Color::Black)
.bg(op_mode_color)
.add_modifier(Modifier::BOLD),
));
let right_spans = vec![
Span::styled(" Help: ", Style::default().fg(theme.text)),
Span::styled(help_text, Style::default().fg(theme.info)),
];
spans.push(Span::styled(" ", Style::default().fg(theme.text)));
spans.push(Span::styled(help_text, Style::default().fg(theme.info)));
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let paragraph = Paragraph::new(Line::from(spans))
let left_paragraph = Paragraph::new(Line::from(left_spans))
.alignment(Alignment::Left)
.style(Style::default().bg(theme.status_background).fg(theme.text))
.block(
@@ -1325,7 +1039,18 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.style(Style::default().bg(theme.status_background).fg(theme.text)),
);
frame.render_widget(paragraph, area);
let right_paragraph = Paragraph::new(Line::from(right_spans))
.alignment(Alignment::Right)
.style(Style::default().bg(theme.status_background).fg(theme.text))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.status_background).fg(theme.text)),
);
frame.render_widget(left_paragraph, layout[0]);
frame.render_widget(right_paragraph, layout[1]);
}
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
@@ -1377,49 +1102,20 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
frame.render_widget(Clear, area);
let items: Vec<ListItem> = app
.model_selector_items()
.models()
.iter()
.map(|item| match item.kind() {
ModelSelectorItemKind::Header { provider, expanded } => {
let marker = if *expanded { "" } else { "" };
let label = format!("{} {}", marker, provider);
ListItem::new(Span::styled(
label,
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
}
ModelSelectorItemKind::Model {
provider: _,
model_index,
} => {
if let Some(model) = app.model_info_by_index(*model_index) {
let tool_indicator = if model.supports_tools { "🔧 " } else { " " };
let label = if model.name.is_empty() {
format!(" {}{}", tool_indicator, model.id)
} else {
format!(" {}{}{}", tool_indicator, model.id, model.name)
};
ListItem::new(Span::styled(
label,
Style::default()
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
))
} else {
ListItem::new(Span::styled(
" <model unavailable>",
Style::default().fg(theme.error),
))
}
}
ModelSelectorItemKind::Empty { provider } => ListItem::new(Span::styled(
format!(" (no models configured for {provider})"),
.map(|model| {
let label = if model.name.is_empty() {
model.id.clone()
} else {
format!("{}{}", model.id, model.name)
};
ListItem::new(Span::styled(
label,
Style::default()
.fg(theme.unfocused_panel_border)
.add_modifier(Modifier::ITALIC),
)),
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
))
})
.collect();
@@ -1427,7 +1123,7 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
.block(
Block::default()
.title(Span::styled(
"Select Model — 🔧 = Tool Support",
format!("Select Model ({})", app.selected_provider),
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
@@ -1443,232 +1139,10 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
.highlight_symbol("");
let mut state = ListState::default();
state.select(app.selected_model_item());
state.select(app.selected_model_index());
frame.render_stateful_widget(list, area, &mut state);
}
fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
// Get consent dialog state
let consent_state = match app.consent_dialog() {
Some(state) => state,
None => return,
};
// Create centered modal area
let area = centered_rect(70, 50, frame.area());
frame.render_widget(Clear, area);
// Build consent dialog content
let mut lines = vec![
Line::from(vec![
Span::styled("🔒 ", Style::default().fg(theme.focused_panel_border)),
Span::styled(
"Consent Required",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
consent_state.tool_name.clone(),
Style::default().fg(theme.user_message_role),
),
]),
Line::from(""),
];
// Add data types if any
if !consent_state.data_types.is_empty() {
lines.push(Line::from(Span::styled(
"Data Access:",
Style::default().add_modifier(Modifier::BOLD),
)));
for data_type in &consent_state.data_types {
lines.push(Line::from(vec![
Span::raw(""),
Span::styled(data_type, Style::default().fg(theme.text)),
]));
}
lines.push(Line::from(""));
}
// Add endpoints if any
if !consent_state.endpoints.is_empty() {
lines.push(Line::from(Span::styled(
"Endpoints:",
Style::default().add_modifier(Modifier::BOLD),
)));
for endpoint in &consent_state.endpoints {
lines.push(Line::from(vec![
Span::raw(""),
Span::styled(endpoint, Style::default().fg(theme.text)),
]));
}
lines.push(Line::from(""));
}
// Add prompt
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Choose consent scope:",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
"[1] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow once "),
Span::styled(
"- Grant only for this operation",
Style::default().fg(theme.placeholder),
),
]));
lines.push(Line::from(vec![
Span::styled(
"[2] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow session "),
Span::styled(
"- Grant for current session",
Style::default().fg(theme.placeholder),
),
]));
lines.push(Line::from(vec![
Span::styled(
"[3] ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow always "),
Span::styled(
"- Grant permanently",
Style::default().fg(theme.placeholder),
),
]));
lines.push(Line::from(vec![
Span::styled(
"[4] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Deny "),
Span::styled(
"- Reject this operation",
Style::default().fg(theme.placeholder),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
"[Esc] ",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
),
Span::raw("Cancel"),
]));
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.title(Span::styled(
" Consent Dialog ",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.focused_panel_border))
.style(Style::default().bg(theme.background)),
)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let theme = app.theme();
let config = app.config();
let block = Block::default()
.title("Privacy Settings")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
let inner = block.inner(area);
frame.render_widget(block, area);
let remote_search_enabled =
config.privacy.enable_remote_search && config.tools.web_search.enabled;
let code_exec_enabled = config.tools.code_exec.enabled;
let history_days = config.privacy.retain_history_days;
let cache_results = config.privacy.cache_web_results;
let consent_required = config.privacy.require_consent_per_session;
let encryption_enabled = config.privacy.encrypt_local_data;
let status_line = |label: &str, enabled: bool| {
let status_text = if enabled { "Enabled" } else { "Disabled" };
let status_style = if enabled {
Style::default().fg(theme.selection_fg)
} else {
Style::default().fg(theme.error)
};
Line::from(vec![
Span::raw(format!(" {label}: ")),
Span::styled(status_text, status_style),
])
};
let mut lines = Vec::new();
lines.push(Line::from(vec![Span::styled(
"Privacy Configuration",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
)]));
lines.push(Line::raw(""));
lines.push(Line::from("Network Access:"));
lines.push(status_line("Web Search", remote_search_enabled));
lines.push(status_line("Code Execution", code_exec_enabled));
lines.push(Line::raw(""));
lines.push(Line::from("Data Retention:"));
lines.push(Line::from(format!(
" History retention: {} day(s)",
history_days
)));
lines.push(Line::from(format!(
" Cache web results: {}",
if cache_results { "Yes" } else { "No" }
)));
lines.push(Line::raw(""));
lines.push(Line::from("Safeguards:"));
lines.push(status_line("Consent required", consent_required));
lines.push(status_line("Encrypted storage", encryption_enabled));
lines.push(Line::raw(""));
lines.push(Line::from("Commands:"));
lines.push(Line::from(" :privacy-enable <tool> - Enable tool"));
lines.push(Line::from(" :privacy-disable <tool> - Disable tool"));
lines.push(Line::from(" :privacy-clear - Clear all data"));
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: true })
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(paragraph, inner);
}
fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(75, 70, frame.area());
@@ -1682,7 +1156,6 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
"Commands",
"Sessions",
"Browsers",
"Privacy",
];
// Build tab line
@@ -1875,12 +1348,6 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" :w [name] → alias for :save"),
Line::from(" :load, :o, :open → browse and load saved sessions"),
Line::from(" :sessions, :ls → browse saved sessions"),
// New mode and tool commands added in phases 05
Line::from(" :code → switch to code mode (CLI: owlen --code)"),
Line::from(" :mode <chat|code> → change current mode explicitly"),
Line::from(" :tools → list tools available in the current mode"),
Line::from(" :agent status → show agent configuration and iteration info"),
Line::from(" :stop-agent → abort a running ReAct agent loop"),
],
4 => vec![
// Sessions
@@ -1962,7 +1429,6 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" g / Home → jump to top"),
Line::from(" G / End → jump to bottom"),
],
6 => vec![],
_ => vec![],
};
@@ -1988,18 +1454,14 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
frame.render_widget(tabs_para, layout[0]);
// Render content
if tab_index == PRIVACY_TAB_INDEX {
render_privacy_settings(frame, layout[1], app);
} else {
let content_block = Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
let content_para = Paragraph::new(help_text)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(content_block);
frame.render_widget(content_para, layout[1]);
}
let content_block = Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
let content_para = Paragraph::new(help_text)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(content_block);
frame.render_widget(content_para, layout[1]);
// Render navigation hint
let nav_hint = Line::from(vec![
@@ -2012,7 +1474,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
),
Span::raw(":Switch "),
Span::styled(
format!("1-{}", HELP_TAB_COUNT),
"1-6",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
@@ -2383,108 +1845,6 @@ fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
match role {
Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.unfocused_panel_border),
Role::Tool => Style::default().fg(theme.info),
}
}
/// Format tool output JSON into a nice human-readable format
fn format_tool_output(content: &str) -> String {
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
let mut output = String::new();
let mut content_found = false;
// Extract query if present
if let Some(query) = json.get("query").and_then(|v| v.as_str()) {
output.push_str(&format!("Query: \"{}\"\n\n", query));
content_found = true;
}
// Extract results array
if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
content_found = true;
if results.is_empty() {
output.push_str("No results found");
return output;
}
for (i, result) in results.iter().enumerate() {
// Title
if let Some(title) = result.get("title").and_then(|v| v.as_str()) {
// Strip HTML tags from title
let clean_title = title.replace("<b>", "").replace("</b>", "");
output.push_str(&format!("{}. {}\n", i + 1, clean_title));
}
// Source and date (if available)
let mut meta = Vec::new();
if let Some(source) = result.get("source").and_then(|v| v.as_str()) {
meta.push(format!("📰 {}", source));
}
if let Some(date) = result.get("date").and_then(|v| v.as_str()) {
// Simplify date format
if let Some(simple_date) = date.split('T').next() {
meta.push(format!("📅 {}", simple_date));
}
}
if !meta.is_empty() {
output.push_str(&format!(" {}\n", meta.join("")));
}
// Snippet (truncated if too long)
if let Some(snippet) = result.get("snippet").and_then(|v| v.as_str()) {
if !snippet.is_empty() {
// Strip HTML tags
let clean_snippet = snippet
.replace("<b>", "")
.replace("</b>", "")
.replace("&#x27;", "'")
.replace("&quot;", "\"");
// Truncate if too long
let truncated = if clean_snippet.len() > 200 {
format!("{}...", &clean_snippet[..197])
} else {
clean_snippet
};
output.push_str(&format!(" {}\n", truncated));
}
}
// URL (shortened if too long)
if let Some(url) = result.get("url").and_then(|v| v.as_str()) {
let display_url = if url.len() > 80 {
format!("{}...", &url[..77])
} else {
url.to_string()
};
output.push_str(&format!(" 🔗 {}\n", display_url));
}
output.push('\n');
}
// Add total count
if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) {
output.push_str(&format!("Found {} result(s)", total));
}
} else if let Some(result) = json.get("result").and_then(|v| v.as_str()) {
content_found = true;
output.push_str(result);
} else if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
content_found = true;
// Handle error results
output.push_str(&format!("❌ Error: {}", error));
}
if content_found {
output
} else {
content.to_string()
}
} else {
// If not JSON, return as-is
content.to_string()
Role::System => Style::default().fg(theme.info),
}
}

View File

@@ -1,177 +0,0 @@
# Changelog for v1.0.0 - MCP-Only Architecture
## Summary
Version 1.0.0 marks the completion of the MCP-only architecture migration, removing all legacy code paths and fully embracing the Model Context Protocol for all LLM interactions and tool executions.
## Breaking Changes
### 1. Removed Legacy MCP Mode
**What changed:**
- The `[mcp]` section in `config.toml` no longer accepts a `mode` setting
- The `McpMode` enum has been removed from the configuration system
- MCP architecture is now always enabled - no option to disable it
**Migration:**
```diff
# old config.toml
[mcp]
-mode = "legacy" # or "enabled"
# new config.toml
[mcp]
# MCP is always enabled - no mode setting needed
```
**Code changes:**
- `crates/owlen-core/src/config.rs`: Removed `McpMode` enum, simplified `McpSettings`
- `crates/owlen-core/src/mcp/factory.rs`: Removed legacy mode handling from `McpClientFactory`
- All provider calls now go through MCP clients exclusively
### 2. Updated MCP Client Factory
**What changed:**
- `McpClientFactory::create()` no longer checks for legacy mode
- Automatically falls back to `LocalMcpClient` when no external MCP servers are configured
- Improved error messages for server connection failures
**Before:**
```rust
match self.config.mcp.mode {
McpMode::Legacy => { /* use local */ },
McpMode::Enabled => { /* use remote or fallback */ },
}
```
**After:**
```rust
// Always use MCP architecture
if let Some(server_cfg) = self.config.mcp_servers.first() {
// Try remote server, fallback to local on error
} else {
// Use local client
}
```
## New Features
### Test Infrastructure
Added comprehensive mock implementations for testing:
1. **MockProvider** (`crates/owlen-core/src/provider.rs`)
- Located in `provider::test_utils` module
- Provides a simple provider for unit tests
- Implements all required `Provider` trait methods
2. **MockMcpClient** (`crates/owlen-core/src/mcp.rs`)
- Located in `mcp::test_utils` module
- Provides a simple MCP client for unit tests
- Returns mock tool descriptors and responses
### Documentation
1. **Migration Guide** (`docs/migration-guide.md`)
- Comprehensive guide for migrating from v0.x to v1.0
- Step-by-step configuration update instructions
- Common issues and troubleshooting
- Rollback procedures if needed
2. **Updated Configuration Reference**
- Removed references to legacy mode
- Clarified MCP server configuration
- Added examples for local and cloud Ollama usage
## Bug Fixes
- Fixed test compilation errors due to missing mock implementations
- Resolved ambiguous glob re-export warnings (non-critical, test-only)
## Internal Changes
### Configuration System
- `McpSettings` struct now only serves as a placeholder for future MCP-specific settings
- Removed `McpMode` enum entirely
- Default configuration no longer includes mode setting
### MCP Factory
- Simplified factory logic by removing mode branching
- Improved fallback behavior with better error messages
- Test renamed to reflect new behavior: `test_factory_creates_local_client_when_no_servers_configured`
## Performance
No performance regressions expected. The MCP architecture may actually improve performance by:
- Removing unnecessary mode checks
- Streamlining the client creation process
- Better error handling reduces retry overhead
## Compatibility
### Backwards Compatibility
**Breaking:** Configuration files with `mode = "legacy"` will need to be updated:
- The setting is ignored (logs a warning in future versions)
- User config has been automatically updated if using standard path
### Forward Compatibility
The `McpSettings` struct is kept for future expansion:
- Can add MCP-specific timeouts
- Can add connection pooling settings
- Can add server selection strategies
## Testing
All tests passing:
```
test result: ok. 29 passed; 0 failed; 0 ignored
```
Key test areas:
- Agent ReAct pattern parsing
- MCP client factory creation
- Configuration loading and validation
- Mode-based tool filtering
- Permission and consent handling
## Upgrade Instructions
See [Migration Guide](migration-guide.md) for detailed instructions.
**Quick upgrade:**
1. Update your `~/.config/owlen/config.toml`:
```bash
# Remove the 'mode' line from [mcp] section
sed -i '/^mode = /d' ~/.config/owlen/config.toml
```
2. Rebuild Owlen:
```bash
cargo build --release
```
3. Test with a simple query:
```bash
owlen
```
## Known Issues
1. **Warning about ambiguous glob re-exports** - Non-critical, only affects test builds
2. **First inference may be slow** - Ollama loads models on first use (expected behavior)
3. **Cloud model 404 errors** - Ensure model names match Ollama Cloud's naming (remove `-cloud` suffix from model names)
## Contributors
This release completes the Phase 10 migration plan documented in `.agents/new_phases.md`.
## Related Issues
- Closes: Legacy mode removal
- Implements: Phase 10 cleanup and production polish
- References: MCP architecture migration phases 1-10

View File

@@ -31,54 +31,10 @@ A simplified diagram of how components interact:
## Crate Breakdown
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`. Also contains the MCP client implementation.
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`.
- `owlen-tui`: Contains all the logic for the terminal user interface, including event handling and rendering.
- `owlen-cli`: The command-line entry point, responsible for parsing arguments and starting the TUI.
- `owlen-mcp-llm-server`: MCP server that wraps Ollama providers and exposes them via the Model Context Protocol.
- `owlen-mcp-server`: Generic MCP server for file operations and resource management.
- `owlen-ollama`: Direct Ollama provider implementation (legacy, used only by MCP servers).
## MCP Architecture (Phase 10)
As of Phase 10, OWLEN uses a **MCP-only architecture** where all LLM interactions go through the Model Context Protocol:
```
[TUI/CLI] -> [RemoteMcpClient] -> [MCP LLM Server] -> [Ollama Provider] -> [Ollama API]
```
### Benefits of MCP Architecture
1. **Separation of Concerns**: The TUI/CLI never directly instantiates provider implementations.
2. **Process Isolation**: LLM interactions run in a separate process, improving stability.
3. **Extensibility**: New providers can be added by implementing MCP servers.
4. **Multi-Transport**: Supports STDIO, HTTP, and WebSocket transports.
5. **Tool Integration**: MCP servers can expose tools (file operations, web search, etc.) to the LLM.
### MCP Communication Flow
1. **Client Creation**: `RemoteMcpClient::new()` spawns an MCP server binary via STDIO.
2. **Initialization**: Client sends `initialize` request to establish protocol version.
3. **Tool Discovery**: Client calls `tools/list` to discover available LLM operations.
4. **Chat Requests**: Client calls the `generate_text` tool with chat parameters.
5. **Streaming**: Server sends progress notifications during generation, then final response.
6. **Response Handling**: Client skips notifications and returns the final text to the caller.
### Cloud Provider Support
For Ollama Cloud providers, the MCP server accepts an `OLLAMA_URL` environment variable:
```rust
let env_vars = HashMap::from([
("OLLAMA_URL".to_string(), "https://cloud-provider-url".to_string())
]);
let config = McpServerConfig {
command: "path/to/owlen-mcp-llm-server",
env: env_vars,
transport: "stdio",
...
};
let client = RemoteMcpClient::new_with_config(&config)?;
```
- `owlen-ollama` / `owlen-openai` / etc.: Implementations of the `Provider` trait for specific services.
## Session Management

View File

@@ -96,22 +96,17 @@ These settings control the behavior of the text input area.
## Provider Settings (`[providers]`)
This section contains a table for each provider you want to configure. Owlen ships with two entries pre-populated: `ollama` for a local daemon and `ollama-cloud` for the hosted API. You can switch between them by changing `general.default_provider`.
This section contains a table for each provider you want to configure. The key is the provider name (e.g., `ollama`).
```toml
[providers.ollama]
provider_type = "ollama"
base_url = "http://localhost:11434"
# api_key = "..."
[providers.ollama-cloud]
provider_type = "ollama-cloud"
base_url = "https://ollama.com"
# api_key = "${OLLAMA_API_KEY}"
```
- `provider_type` (string, required)
The type of the provider. The built-in options are `"ollama"` (local daemon) and `"ollama-cloud"` (hosted service).
The type of the provider. Currently, only `"ollama"` is built-in.
- `base_url` (string, optional)
The base URL of the provider's API.
@@ -121,16 +116,3 @@ base_url = "https://ollama.com"
- `extra` (table, optional)
Any additional, provider-specific parameters can be added here.
### Using Ollama Cloud
To talk to [Ollama Cloud](https://docs.ollama.com/cloud), point the base URL at the hosted endpoint and supply your API key:
```toml
[providers.ollama-cloud]
provider_type = "ollama-cloud"
base_url = "https://ollama.com"
api_key = "${OLLAMA_API_KEY}"
```
Requests target the same `/api/chat` endpoint documented by Ollama and automatically include the API key using a `Bearer` authorization header. If you prefer not to store the key in the config file, you can leave `api_key` unset and provide it via the `OLLAMA_API_KEY` (or `OLLAMA_CLOUD_API_KEY`) environment variable instead. You can also reference an environment variable inline (for example `api_key = "$OLLAMA_API_KEY"` or `api_key = "${OLLAMA_API_KEY}"`), which Owlen expands when the configuration is loaded. The base URL is normalised automatically—Owlen enforces HTTPS, trims trailing slashes, and accepts both `https://ollama.com` and `https://api.ollama.com` without rewriting the host.

View File

@@ -6,183 +6,29 @@ As Owlen is currently in its alpha phase (pre-v1.0), breaking changes may occur
---
## Migrating from v0.x to v1.0 (MCP-Only Architecture)
## Migrating from v0.1.x to v0.2.x (Example)
**Version 1.0** marks a major milestone: Owlen has completed its transition to a **MCP-only architecture** (Model Context Protocol). This brings significant improvements in modularity, extensibility, and performance, but requires configuration updates.
*This is a template for a future migration. No breaking changes have occurred yet.*
### Breaking Changes
Version 0.2.0 introduces a new configuration structure for providers.
#### 1. MCP Mode is Now Always Enabled
### Configuration File Changes
The `[mcp]` section in `config.toml` previously had a `mode` setting that could be set to `"legacy"` or `"enabled"`. In v1.0+, MCP architecture is **always enabled** and the `mode` setting has been removed.
**Old configuration (v0.x):**
```toml
[mcp]
mode = "legacy" # or "enabled"
```
**New configuration (v1.0+):**
```toml
[mcp]
# MCP is now always enabled - no mode setting needed
# This section is kept for future MCP-specific configuration options
```
#### 2. Direct Provider Access Removed
In v0.x, Owlen could make direct HTTP calls to Ollama and other providers when in "legacy" mode. In v1.0+, **all LLM interactions go through MCP servers**.
### What Changed Under the Hood
The v1.0 architecture implements the full 10-phase migration plan:
- **Phase 1-2**: File operations via MCP servers
- **Phase 3**: LLM inference via MCP servers (Ollama wrapped)
- **Phase 4**: Agent loop with ReAct pattern
- **Phase 5**: Mode system (chat/code) with tool availability
- **Phase 6**: Web search integration
- **Phase 7**: Code execution with Docker sandboxing
- **Phase 8**: Prompt server for versioned prompts
- **Phase 9**: Remote MCP server support (HTTP/WebSocket)
- **Phase 10**: Legacy mode removal and production polish
### Migration Steps
#### Step 1: Update Your Configuration
Edit `~/.config/owlen/config.toml`:
**Remove the `mode` line:**
```diff
[mcp]
-mode = "legacy"
```
The `[mcp]` section can now be empty or contain future MCP-specific settings.
#### Step 2: Verify Provider Configuration
Ensure your provider configuration is correct. For Ollama:
Previously, your `config.toml` might have looked like this:
```toml
[general]
default_provider = "ollama"
default_model = "llama3.2:latest" # or your preferred model
# old config.toml (pre-v0.2.0)
ollama_base_url = "http://localhost:11434"
```
In v0.2.0, all provider settings are now nested under a `[providers]` table. You will need to update your `config.toml` to the new format:
```toml
# new config.toml (v0.2.0+)
[providers.ollama]
provider_type = "ollama"
base_url = "http://localhost:11434"
[providers.ollama-cloud]
provider_type = "ollama-cloud"
base_url = "https://ollama.com"
api_key = "$OLLAMA_API_KEY" # Optional: for Ollama Cloud
```
#### Step 3: Understanding MCP Server Configuration
### Action Required
While not required for basic usage (Owlen will use the built-in local MCP client), you can optionally configure external MCP servers:
```toml
[[mcp_servers]]
name = "llm"
command = "owlen-mcp-llm-server"
transport = "stdio"
[[mcp_servers]]
name = "filesystem"
command = "/path/to/filesystem-server"
transport = "stdio"
```
**Note**: If no `mcp_servers` are configured, Owlen automatically falls back to its built-in local MCP client, which provides the same functionality.
#### Step 4: Verify Installation
After updating your config:
1. **Check Ollama is running**:
```bash
curl http://localhost:11434/api/version
```
2. **List available models**:
```bash
ollama list
```
3. **Test Owlen**:
```bash
owlen
```
### Common Issues After Migration
#### Issue: "Warning: No MCP servers defined in config. Using local client."
**This is normal!** In v1.0+, if you don't configure external MCP servers, Owlen uses its built-in local MCP client. This provides the same functionality without needing separate server processes.
**No action required** unless you specifically want to use external MCP servers.
#### Issue: Timeouts on First Message
**Cause**: Ollama loads models into memory on first use, which can take 10-60 seconds for large models.
**Solution**:
- Be patient on first inference after model selection
- Use smaller models for faster loading (e.g., `llama3.2:latest` instead of `qwen3-coder:latest`)
- Pre-load models with: `ollama run <model-name>`
#### Issue: Cloud Models Return 404 Errors
**Cause**: Ollama Cloud model names may differ from local model names.
**Solution**:
- Verify model availability on https://ollama.com/models
- Remove the `-cloud` suffix from model names when using cloud provider
- Ensure `api_key` is set in `[providers.ollama-cloud]` config
### Rollback to v0.x
If you encounter issues and need to rollback:
1. **Reinstall v0.x**:
```bash
# Using AUR (if applicable)
yay -S owlen-git
# Or from source
git checkout <v0.x-tag>
cargo install --path crates/owlen-tui
```
2. **Restore configuration**:
```toml
[mcp]
mode = "legacy"
```
3. **Report issues**: https://github.com/Owlibou/owlen/issues
### Benefits of v1.0 MCP Architecture
- **Modularity**: LLM, file operations, and tools are isolated in MCP servers
- **Extensibility**: Easy to add new tools and capabilities via MCP protocol
- **Multi-Provider**: Support for multiple LLM providers through standard interface
- **Remote Execution**: Can connect to remote MCP servers over HTTP/WebSocket
- **Better Error Handling**: Structured error responses from MCP servers
- **Agentic Capabilities**: ReAct pattern for autonomous task completion
### Getting Help
- **Documentation**: See `docs/` directory for detailed guides
- **Issues**: https://github.com/Owlibou/owlen/issues
- **Configuration Reference**: `docs/configuration.md`
- **Troubleshooting**: `docs/troubleshooting.md`
---
## Future Migrations
We will continue to document breaking changes here as Owlen evolves. Always check this guide when upgrading to a new major version.
Update your `~/.config/owlen/config.toml` to match the new structure. If you do not, Owlen will fall back to its default provider configuration.

View File

@@ -1,213 +0,0 @@
# Phase 5: Mode Consolidation & Tool Availability System
## Implementation Status: ✅ COMPLETE
Phase 5 has been fully implemented according to the specification in `.agents/new_phases.md`.
## What Was Implemented
### 1. Mode System (✅ Complete)
**File**: `crates/owlen-core/src/mode.rs`
- `Mode` enum with `Chat` and `Code` variants
- `ModeConfig` for configuring tool availability per mode
- `ModeToolConfig` with wildcard (`*`) support for allowing all tools
- Default configuration:
- Chat mode: only `web_search` allowed
- Code mode: all tools allowed (`*`)
### 2. Configuration Integration (✅ Complete)
**File**: `crates/owlen-core/src/config.rs`
- Added `modes: ModeConfig` field to `Config` struct
- Mode configuration loaded from TOML with sensible defaults
- Example configuration:
```toml
[modes.chat]
allowed_tools = ["web_search"]
[modes.code]
allowed_tools = ["*"] # All tools allowed
```
### 3. Tool Registry Filtering (✅ Complete)
**Files**:
- `crates/owlen-core/src/tools/registry.rs`
- `crates/owlen-core/src/mcp.rs`
Changes:
- `ToolRegistry::execute()` now takes a `Mode` parameter
- Mode-based filtering before tool execution
- Helpful error messages suggesting mode switch if tool unavailable
- `ToolRegistry::available_tools(mode)` method for listing tools per mode
- `McpServer` tracks current mode and filters tool lists accordingly
- `LocalMcpClient` exposes `set_mode()` and `get_mode()` methods
### 4. CLI Argument (✅ Complete)
**File**: `crates/owlen-cli/src/main.rs`
- Added `--code` / `-c` CLI argument using clap
- Sets initial operating mode on startup
- Example: `owlen --code` starts in code mode
### 5. TUI Commands (✅ Complete)
**File**: `crates/owlen-tui/src/chat_app.rs`
New commands added:
- `:mode <chat|code>` - Switch operating mode explicitly
- `:code` - Shortcut to switch to code mode
- `:chat` - Shortcut to switch to chat mode
- `:tools` - List available tools in current mode
Implementation details:
- Commands update `operating_mode` field in `ChatApp`
- Status message confirms mode switch
- Error messages for invalid mode names
### 6. Status Line Indicator (✅ Complete)
**File**: `crates/owlen-tui/src/ui.rs`
- Operating mode badge displayed in status line
- `💬 CHAT` badge (blue background) in chat mode
- `💻 CODE` badge (magenta background) in code mode
- Positioned after agent status indicators
### 7. Documentation (✅ Complete)
**File**: `crates/owlen-tui/src/ui.rs` (help system)
Help documentation already included:
- `:code` command with CLI usage hint
- `:mode <chat|code>` command
- `:tools` command
## Architecture
```
User Input → CLI Args → ChatApp.operating_mode
TUI Commands (:mode, :code, :chat)
ChatApp.set_mode(mode)
Status Line Updates
Tool Execution → ToolRegistry.execute(name, args, mode)
Mode Check → Config.modes.is_tool_allowed(mode, tool)
Execute or Error
```
## Testing Checklist
- [x] Mode enum defaults to Chat
- [x] Config loads mode settings from TOML
- [x] `:mode` command shows current mode
- [x] `:mode chat` switches to chat mode
- [x] `:mode code` switches to code mode
- [x] `:code` shortcut works
- [x] `:chat` shortcut works
- [x] `:tools` lists available tools
- [x] `owlen --code` starts in code mode
- [x] Status line shows current mode
- [ ] Tool execution respects mode filtering (requires runtime test)
- [ ] Mode-restricted tool gives helpful error message (requires runtime test)
## Configuration Example
Create or edit `~/.config/owlen/config.toml`:
```toml
[general]
default_provider = "ollama"
default_model = "llama3.2:latest"
[modes.chat]
# In chat mode, only web search is allowed
allowed_tools = ["web_search"]
[modes.code]
# In code mode, all tools are allowed
allowed_tools = ["*"]
# You can also specify explicit tool lists:
# allowed_tools = ["web_search", "code_exec", "file_write", "file_delete"]
```
## Usage
### Starting in Code Mode
```bash
owlen --code
# or
owlen -c
```
### Switching Modes at Runtime
```
:mode code # Switch to code mode
:code # Shortcut for :mode code
:chat # Shortcut for :mode chat
:mode chat # Switch to chat mode
:mode # Show current mode
:tools # List available tools in current mode
```
### Tool Filtering Behavior
**In Chat Mode:**
-`web_search` - Allowed
-`code_exec` - Blocked (suggests switching to code mode)
-`file_write` - Blocked
-`file_delete` - Blocked
**In Code Mode:**
- ✅ All tools allowed (wildcard `*` configuration)
## Next Steps
To fully complete Phase 5 integration:
1. **Runtime Testing**: Build and run the application to verify:
- Tool filtering works correctly
- Error messages are helpful
- Mode switching updates MCP client when implemented
2. **MCP Integration**: When MCP is fully implemented, update `ChatApp::set_mode()` to propagate mode changes to the MCP client.
3. **Additional Tools**: As new tools are added, update the `:tools` command to discover tools dynamically from the registry instead of hardcoding the list.
## Files Modified
- `crates/owlen-core/src/mode.rs` (NEW)
- `crates/owlen-core/src/lib.rs`
- `crates/owlen-core/src/config.rs`
- `crates/owlen-core/src/tools/registry.rs`
- `crates/owlen-core/src/mcp.rs`
- `crates/owlen-cli/src/main.rs`
- `crates/owlen-tui/src/chat_app.rs`
- `crates/owlen-tui/src/ui.rs`
- `Cargo.toml` (removed invalid bin sections)
## Spec Compliance
All requirements from `.agents/new_phases.md` Phase 5 have been implemented:
- ✅ 5.1. Remove Legacy Code - MCP is primary integration
- ✅ 5.2. Implement Mode Switching in TUI - Commands and CLI args added
- ✅ 5.3. Define Tool Availability System - Mode enum and ModeConfig created
- ✅ 5.4. Configuration in TOML - modes section added to config
- ✅ 5.5. Integrate Mode Filtering with Agent Loop - ToolRegistry updated
- ✅ 5.6. Config Loader in Rust - Uses existing TOML infrastructure
- ✅ 5.7. TUI Command Extensions - All commands implemented
- ✅ 5.8. Testing & Validation - Unit tests added, runtime tests pending

30
examples/basic_chat.rs Normal file
View File

@@ -0,0 +1,30 @@
// This example demonstrates a basic chat interaction without the TUI.
use owlen_core::model::Model;
use owlen_core::provider::Provider;
use owlen_core::session::Session;
use owlen_ollama::OllamaProvider; // Assuming you have an Ollama provider
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// This example requires a running Ollama instance.
// Make sure you have a model available, e.g., `ollama pull llama2`
let provider = OllamaProvider;
let model = Model::new("llama2"); // Change to a model you have
let mut session = Session::new("basic-chat-session");
println!("Starting basic chat with model: {}", model.name);
let user_message = "What is the capital of France?";
session.add_message("user", user_message);
println!("User: {}", user_message);
// Send the chat to the provider
let response = provider.chat(&session, &model).await?;
session.add_message("bot", &response);
println!("Bot: {}", response);
Ok(())
}

View File

@@ -0,0 +1,45 @@
// This example demonstrates how to implement a custom provider.
use async_trait::async_trait;
use owlen_core::model::Model;
use owlen_core::provider::Provider;
use owlen_core::session::Session;
// Define a struct for your custom provider.
pub struct MyCustomProvider;
// Implement the `Provider` trait for your struct.
#[async_trait]
impl Provider for MyCustomProvider {
fn name(&self) -> &str {
"custom-provider"
}
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
println!(
"Custom provider received chat request for model: {}",
model.name
);
// In a real implementation, you would send the session data to an API.
let message_count = session.get_messages().len();
Ok(format!(
"This is a custom response. You have {} messages in your session.",
message_count
))
}
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let provider = MyCustomProvider;
let model = Model::new("custom-model");
let mut session = Session::new("custom-session");
session.add_message("user", "Hello, custom provider!");
let response = provider.chat(&session, &model).await?;
println!("Provider response: {}", response);
Ok(())
}

View File

@@ -1,71 +0,0 @@
//! Example demonstrating MCP-based chat interaction.
//!
//! This example shows the recommended way to interact with LLMs via the MCP architecture.
//! It uses `RemoteMcpClient` which communicates with the MCP LLM server.
//!
//! Prerequisites:
//! - Build the MCP LLM server: `cargo build --release -p owlen-mcp-llm-server`
//! - Ensure Ollama is running with a model available
use owlen_core::{
mcp::remote_client::RemoteMcpClient,
types::{ChatParameters, ChatRequest, Message, Role},
Provider,
};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
println!("🦉 Owlen MCP Chat Example\n");
// Create MCP client - this will spawn/connect to the MCP LLM server
println!("Connecting to MCP LLM server...");
let client = Arc::new(RemoteMcpClient::new()?);
println!("✓ Connected\n");
// List available models
println!("Fetching available models...");
let models = client.list_models().await?;
println!("Available models:");
for model in &models {
println!(" - {} ({})", model.name, model.provider);
}
println!();
// Select first available model or default
let model_name = models
.first()
.map(|m| m.id.clone())
.unwrap_or_else(|| "llama3.2:latest".to_string());
println!("Using model: {}\n", model_name);
// Create a simple chat request
let user_message = "What is the capital of France? Please be concise.";
println!("User: {}", user_message);
let request = ChatRequest {
model: model_name,
messages: vec![Message::new(Role::User, user_message.to_string())],
parameters: ChatParameters {
temperature: Some(0.7),
max_tokens: Some(100),
stream: false,
extra: std::collections::HashMap::new(),
},
tools: None,
};
// Send request and get response
println!("\nAssistant: ");
let response = client.chat(request).await?;
println!("{}", response.message.content);
if let Some(usage) = response.usage {
println!(
"\n📊 Tokens: {} prompt + {} completion = {} total",
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
);
}
Ok(())
}

View File

@@ -5,7 +5,6 @@ focused_panel_border = "lightmagenta"
unfocused_panel_border = "#5f1487"
user_message_role = "lightblue"
assistant_message_role = "yellow"
tool_output = "gray"
thinking_panel_title = "lightmagenta"
command_bar_background = "black"
status_background = "black"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#4a90e2"
unfocused_panel_border = "#dddddd"
user_message_role = "#0055a4"
assistant_message_role = "#8e44ad"
tool_output = "gray"
thinking_panel_title = "#8e44ad"
command_bar_background = "white"
status_background = "white"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#ff79c6"
unfocused_panel_border = "#44475a"
user_message_role = "#8be9fd"
assistant_message_role = "#ff79c6"
tool_output = "#6272a4"
thinking_panel_title = "#bd93f9"
command_bar_background = "#44475a"
status_background = "#44475a"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#fe8019"
unfocused_panel_border = "#7c6f64"
user_message_role = "#b8bb26"
assistant_message_role = "#83a598"
tool_output = "#928374"
thinking_panel_title = "#d3869b"
command_bar_background = "#3c3836"
status_background = "#3c3836"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#80cbc4"
unfocused_panel_border = "#546e7a"
user_message_role = "#82aaff"
assistant_message_role = "#c792ea"
tool_output = "#546e7a"
thinking_panel_title = "#ffcb6b"
command_bar_background = "#212b30"
status_background = "#212b30"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#009688"
unfocused_panel_border = "#b0bec5"
user_message_role = "#448aff"
assistant_message_role = "#7c4dff"
tool_output = "#90a4ae"
thinking_panel_title = "#f57c00"
command_bar_background = "#ffffff"
status_background = "#ffffff"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#58a6ff"
unfocused_panel_border = "#30363d"
user_message_role = "#79c0ff"
assistant_message_role = "#89ddff"
tool_output = "#546e7a"
thinking_panel_title = "#9ece6a"
command_bar_background = "#161b22"
status_background = "#161b22"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#f92672"
unfocused_panel_border = "#75715e"
user_message_role = "#66d9ef"
assistant_message_role = "#ae81ff"
tool_output = "#75715e"
thinking_panel_title = "#e6db74"
command_bar_background = "#272822"
status_background = "#272822"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#eb6f92"
unfocused_panel_border = "#26233a"
user_message_role = "#31748f"
assistant_message_role = "#9ccfd8"
tool_output = "#6e6a86"
thinking_panel_title = "#c4a7e7"
command_bar_background = "#26233a"
status_background = "#26233a"

View File

@@ -5,7 +5,6 @@ focused_panel_border = "#268bd2"
unfocused_panel_border = "#073642"
user_message_role = "#2aa198"
assistant_message_role = "#cb4b16"
tool_output = "#657b83"
thinking_panel_title = "#6c71c4"
command_bar_background = "#073642"
status_background = "#073642"