Compare commits
72 Commits
v0.1.2
...
0da8a3f193
| Author | SHA1 | Date | |
|---|---|---|---|
| 0da8a3f193 | |||
| 15f81d9728 | |||
| b80db89391 | |||
| f413a63c5a | |||
| 33ad3797a1 | |||
| 55e6b0583d | |||
| ae9c3af096 | |||
| 0bd560b408 | |||
| 083b621b7d | |||
| d2a193e5c1 | |||
| acbfe47a4b | |||
| 60c859b3ab | |||
| 82078afd6d | |||
| 7851af14a9 | |||
| c2f5ccea3b | |||
| fab63d224b | |||
| 15e5c1206b | |||
| 38aba1a6bb | |||
| d0d3079df5 | |||
| 56de1170ee | |||
| 952e4819fe | |||
| 5ac0d152cb | |||
| 40c44470e8 | |||
| 5c37df1b22 | |||
| 5e81185df3 | |||
| 7534c9ef8d | |||
| 9545a4b3ad | |||
| e94df2c48a | |||
| cdf95002fc | |||
| 4c066bf2da | |||
| e57844e742 | |||
| 33d11ae223 | |||
| 05e90d3e2b | |||
| fe414d49e6 | |||
| d002d35bde | |||
| c9c3d17db0 | |||
| a909455f97 | |||
| 67381b02db | |||
| 235f84fa19 | |||
| 9c777c8429 | |||
| 0b17a0f4c8 | |||
| 2eabe55fe6 | |||
| 4d7ad2c330 | |||
| 13af046eff | |||
| 5b202fed4f | |||
| 979347bf53 | |||
| 76b55ccff5 | |||
| f0e162d551 | |||
| 6c4571804f | |||
| a0cdcfdf6c | |||
| 96e2482782 | |||
| 6a3f44f911 | |||
| e0e5a2a83d | |||
| 23e86591d1 | |||
| b60a317788 | |||
| 2788e8b7e2 | |||
| 7c186882dc | |||
| bdda669d4d | |||
| 108070db4b | |||
| 08ba04e99f | |||
| e58032deae | |||
| 5c59539120 | |||
| c725bb1ce6 | |||
| c4a6bb1c0f | |||
| dcbfe6ef06 | |||
| e468658d63 | |||
| 2ad801f0c1 | |||
| 1bfc6e5956 | |||
| 6b8774f0aa | |||
| ec6876727f | |||
| e3eb4d7a04 | |||
| 7234021014 |
20
.cargo/config.toml
Normal file
20
.cargo/config.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[target.x86_64-unknown-linux-musl]
|
||||||
|
linker = "x86_64-linux-gnu-gcc"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||||
|
|
||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
|
|
||||||
|
[target.aarch64-unknown-linux-musl]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||||
|
|
||||||
|
[target.armv7-unknown-linux-gnueabihf]
|
||||||
|
linker = "arm-linux-gnueabihf-gcc"
|
||||||
|
|
||||||
|
[target.armv7-unknown-linux-musleabihf]
|
||||||
|
linker = "arm-linux-gnueabihf-gcc"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
linker = "x86_64-w64-mingw32-gcc"
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,6 +4,10 @@
|
|||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
dev/
|
dev/
|
||||||
|
.agents/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
@@ -100,4 +104,3 @@ fabric.properties
|
|||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
# Android studio 3.1+ serialized cache file
|
||||||
.idea/caches/build_file_checksums.ser
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
|||||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Pre-commit hooks configuration
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
|
||||||
|
repos:
|
||||||
|
# General file checks
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: ['--maxkb=1000']
|
||||||
|
- id: mixed-line-ending
|
||||||
|
|
||||||
|
# Rust formatting
|
||||||
|
- repo: https://github.com/doublify/pre-commit-rust
|
||||||
|
rev: v1.0
|
||||||
|
hooks:
|
||||||
|
- id: fmt
|
||||||
|
name: cargo fmt
|
||||||
|
description: Format Rust code with rustfmt
|
||||||
|
- id: cargo-check
|
||||||
|
name: cargo check
|
||||||
|
description: Check Rust code compilation
|
||||||
|
- id: clippy
|
||||||
|
name: cargo clippy
|
||||||
|
description: Lint Rust code with clippy
|
||||||
|
args: ['--all-features', '--', '-D', 'warnings']
|
||||||
|
|
||||||
|
# Optional: run on all files when config changes
|
||||||
|
default_install_hook_types: [pre-commit, pre-push]
|
||||||
@@ -11,63 +11,118 @@ matrix:
|
|||||||
- TARGET: x86_64-unknown-linux-gnu
|
- TARGET: x86_64-unknown-linux-gnu
|
||||||
ARTIFACT: owlen-linux-x86_64-gnu
|
ARTIFACT: owlen-linux-x86_64-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: x86_64-unknown-linux-musl
|
- TARGET: x86_64-unknown-linux-musl
|
||||||
ARTIFACT: owlen-linux-x86_64-musl
|
ARTIFACT: owlen-linux-x86_64-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: aarch64-unknown-linux-gnu
|
- TARGET: aarch64-unknown-linux-gnu
|
||||||
ARTIFACT: owlen-linux-aarch64-gnu
|
ARTIFACT: owlen-linux-aarch64-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: aarch64-unknown-linux-musl
|
- TARGET: aarch64-unknown-linux-musl
|
||||||
ARTIFACT: owlen-linux-aarch64-musl
|
ARTIFACT: owlen-linux-aarch64-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: armv7-unknown-linux-gnueabihf
|
- TARGET: armv7-unknown-linux-gnueabihf
|
||||||
ARTIFACT: owlen-linux-armv7-gnu
|
ARTIFACT: owlen-linux-armv7-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: armv7-unknown-linux-musleabihf
|
- TARGET: armv7-unknown-linux-musleabihf
|
||||||
ARTIFACT: owlen-linux-armv7-musl
|
ARTIFACT: owlen-linux-armv7-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
# Windows
|
||||||
|
- TARGET: x86_64-pc-windows-gnu
|
||||||
|
ARTIFACT: owlen-windows-x86_64
|
||||||
|
PLATFORM: windows
|
||||||
|
EXT: ".exe"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: install-deps
|
- name: tests
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
commands:
|
||||||
- apt-get update
|
- rustup component add llvm-tools-preview
|
||||||
- apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
|
- cargo install cargo-llvm-cov --locked
|
||||||
|
- cargo llvm-cov --workspace --all-features --summary-only
|
||||||
|
- cargo llvm-cov --workspace --all-features --lcov --output-path coverage.lcov --no-run
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
commands:
|
||||||
|
# Install cross-compilation tools
|
||||||
|
- apt-get update
|
||||||
|
- apt-get install -y musl-tools gcc-aarch64-linux-gnu g++-aarch64-linux-gnu gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf mingw-w64 zip
|
||||||
|
|
||||||
|
# Verify cross-compilers are installed
|
||||||
|
- which aarch64-linux-gnu-gcc || echo "aarch64-linux-gnu-gcc not found!"
|
||||||
|
- which arm-linux-gnueabihf-gcc || echo "arm-linux-gnueabihf-gcc not found!"
|
||||||
|
- which x86_64-w64-mingw32-gcc || echo "x86_64-w64-mingw32-gcc not found!"
|
||||||
|
|
||||||
|
# Add rust target
|
||||||
- rustup target add ${TARGET}
|
- rustup target add ${TARGET}
|
||||||
|
|
||||||
|
# Set up cross-compilation environment variables and build
|
||||||
- |
|
- |
|
||||||
case "${TARGET}" in
|
case "${TARGET}" in
|
||||||
aarch64-unknown-linux-gnu)
|
aarch64-unknown-linux-gnu)
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
|
export CC_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
|
export CXX_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-g++
|
||||||
|
export AR_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-ar
|
||||||
;;
|
;;
|
||||||
aarch64-unknown-linux-musl)
|
aarch64-unknown-linux-musl)
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
|
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
export CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc
|
export CC_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
|
export CXX_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-g++
|
||||||
|
export AR_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-ar
|
||||||
;;
|
;;
|
||||||
armv7-unknown-linux-gnueabihf)
|
armv7-unknown-linux-gnueabihf)
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
|
export CC_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
|
export CXX_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-g++
|
||||||
|
export AR_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-ar
|
||||||
;;
|
;;
|
||||||
armv7-unknown-linux-musleabihf)
|
armv7-unknown-linux-musleabihf)
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
export CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc
|
export CC_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
|
export CXX_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-g++
|
||||||
|
export AR_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-ar
|
||||||
|
;;
|
||||||
|
x86_64-pc-windows-gnu)
|
||||||
|
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=/usr/bin/x86_64-w64-mingw32-gcc
|
||||||
|
export CC_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-gcc
|
||||||
|
export CXX_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-g++
|
||||||
|
export AR_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-ar
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
- cargo build --release --all-features --target ${TARGET}
|
|
||||||
|
# Build the project
|
||||||
|
cargo build --release --all-features --target ${TARGET}
|
||||||
|
|
||||||
- name: package
|
- name: package
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
commands:
|
||||||
|
- apt-get update && apt-get install -y zip
|
||||||
- mkdir -p dist
|
- mkdir -p dist
|
||||||
- cp target/${TARGET}/release/owlen dist/owlen
|
- |
|
||||||
- cp target/${TARGET}/release/owlen-code dist/owlen-code
|
if [ "${PLATFORM}" = "windows" ]; then
|
||||||
- cd dist
|
cp target/${TARGET}/release/owlen.exe dist/owlen.exe
|
||||||
- tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe
|
||||||
- cd ..
|
cd dist
|
||||||
- mv dist/${ARTIFACT}.tar.gz .
|
zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe
|
||||||
- sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
cd ..
|
||||||
|
mv dist/${ARTIFACT}.zip .
|
||||||
|
sha256sum ${ARTIFACT}.zip > ${ARTIFACT}.zip.sha256
|
||||||
|
else
|
||||||
|
cp target/${TARGET}/release/owlen dist/owlen
|
||||||
|
cp target/${TARGET}/release/owlen-code dist/owlen-code
|
||||||
|
cd dist
|
||||||
|
tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
||||||
|
cd ..
|
||||||
|
mv dist/${ARTIFACT}.tar.gz .
|
||||||
|
sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
||||||
|
fi
|
||||||
|
|
||||||
- name: release
|
- name: release
|
||||||
image: plugins/gitea-release
|
image: plugins/gitea-release
|
||||||
@@ -78,5 +133,7 @@ steps:
|
|||||||
files:
|
files:
|
||||||
- ${ARTIFACT}.tar.gz
|
- ${ARTIFACT}.tar.gz
|
||||||
- ${ARTIFACT}.tar.gz.sha256
|
- ${ARTIFACT}.tar.gz.sha256
|
||||||
|
- ${ARTIFACT}.zip
|
||||||
|
- ${ARTIFACT}.zip.sha256
|
||||||
title: Release ${CI_COMMIT_TAG}
|
title: Release ${CI_COMMIT_TAG}
|
||||||
note: "Release ${CI_COMMIT_TAG}"
|
note: "Release ${CI_COMMIT_TAG}"
|
||||||
|
|||||||
798
AGENTS.md
Normal file
798
AGENTS.md
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
# 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.
|
||||||
105
CHANGELOG.md
Normal file
105
CHANGELOG.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 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.
|
||||||
|
- `owlen config doctor`, `owlen config path`, and `owlen upgrade` CLI commands to automate migrations and surface manual update steps.
|
||||||
|
- Startup provider health check with actionable hints when Ollama or remote MCP servers are unavailable.
|
||||||
|
- `dev/check-windows.sh` helper script for on-demand Windows cross-checks.
|
||||||
|
- Global F1 keybinding for the in-app help overlay and a clearer status hint on launch.
|
||||||
|
- Automatic fallback to the new `ansi_basic` theme when the active terminal only advertises 16-color support.
|
||||||
|
- Offline provider shim that keeps the TUI usable while primary providers are unreachable and communicates recovery steps inline.
|
||||||
|
- `owlen cloud` subcommands (`setup`, `status`, `models`, `logout`) for managing Ollama Cloud credentials without hand-editing config files.
|
||||||
|
- Tabbed model selector that separates local and cloud providers, including cloud indicators in the UI.
|
||||||
|
- Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys).
|
||||||
|
- Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`.
|
||||||
|
- Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in.
|
||||||
|
- Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching.
|
||||||
|
- Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions.
|
||||||
|
- Chat history honors `ui.scrollback_lines`, trimming older rows to keep the TUI responsive and surfacing a "↓ New messages" badge whenever updates land off-screen.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- `McpMode` support was restored with explicit validation; `remote_only`, `remote_preferred`, and `local_only` now behave predictably.
|
||||||
|
- Configuration loading performs structural validation and fails fast on missing default providers or invalid MCP definitions.
|
||||||
|
- Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures.
|
||||||
|
- `owlen` warns when the active terminal likely lacks 256-color support.
|
||||||
|
- `config.toml` now carries a schema version (`1.2.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures.
|
||||||
|
- Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state.
|
||||||
|
- Header displays the active model together with its provider (e.g., `Model (Provider)`), improving clarity when swapping backends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.10] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
|
||||||
|
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
|
||||||
|
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
|
||||||
|
|
||||||
|
## [0.1.9] - 2025-10-03
|
||||||
|
|
||||||
|
*This version corresponds to the release tagged v0.1.10 in the source repository.*
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
|
||||||
|
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
|
||||||
|
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
|
||||||
|
|
||||||
|
## [0.1.8] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Command Autocompletion**: Implemented intelligent command suggestions and Tab completion in command mode.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Build & CI**: Fixed cross-compilation for ARM64, ARMv7, and Windows.
|
||||||
|
|
||||||
|
## [0.1.7] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Tabbed Help System**: The help menu is now organized into five tabs for easier navigation.
|
||||||
|
- **Command Aliases**: Added `:o` as a short alias for `:load` / `:open`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Session Management**: Improved AI-generated session descriptions.
|
||||||
|
|
||||||
|
## [0.1.6] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Platform-Specific Storage**: Sessions are now saved to platform-appropriate directories (e.g., `~/.local/share/owlen` on Linux).
|
||||||
|
- **AI-Generated Session Descriptions**: Conversations can be automatically summarized on save.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Migration**: Users on older versions can manually move their sessions from `~/.config/owlen/sessions` to the new platform-specific directory.
|
||||||
|
|
||||||
|
## [0.1.4] - 2025-10-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-Platform Builds**: Pre-built binaries are now provided for Linux (x86_64, aarch64, armv7) and Windows (x86_64).
|
||||||
|
- **AUR Package**: Owlen is now available on the Arch User Repository.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Build System**: Switched from OpenSSL to rustls for better cross-platform compatibility.
|
||||||
121
CODE_OF_CONDUCT.md
Normal file
121
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that are welcoming, open, and respectful.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
[security@owlibou.com](mailto:security@owlibou.com). All complaints will be
|
||||||
|
reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interaction in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
122
CONTRIBUTING.md
Normal file
122
CONTRIBUTING.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Contributing to Owlen
|
||||||
|
|
||||||
|
First off, thank you for considering contributing to Owlen! It's people like you that make Owlen such a great tool.
|
||||||
|
|
||||||
|
Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project and everyone participating in it is governed by the [Owlen Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior.
|
||||||
|
|
||||||
|
## How Can I Contribute?
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
This is one of the most helpful ways you can contribute. Before creating a bug report, please check a few things:
|
||||||
|
|
||||||
|
1. **Check the [troubleshooting guide](docs/troubleshooting.md).** Your issue might be a common one with a known solution.
|
||||||
|
2. **Search the existing issues.** It's possible someone has already reported the same bug. If so, add a comment to the existing issue instead of creating a new one.
|
||||||
|
|
||||||
|
When you are creating a bug report, please include as many details as possible. Fill out the required template, the information it asks for helps us resolve issues faster.
|
||||||
|
|
||||||
|
### Suggesting Enhancements
|
||||||
|
|
||||||
|
If you have an idea for a new feature or an improvement to an existing one, we'd love to hear about it. Please provide as much context as you can about what you're trying to achieve.
|
||||||
|
|
||||||
|
### Your First Code Contribution
|
||||||
|
|
||||||
|
Unsure where to begin contributing to Owlen? You can start by looking through `good first issue` and `help wanted` issues.
|
||||||
|
|
||||||
|
### Pull Requests
|
||||||
|
|
||||||
|
The process for submitting a pull request is as follows:
|
||||||
|
|
||||||
|
1. **Fork the repository** and create your branch from `main`.
|
||||||
|
2. **Set up pre-commit hooks** (see [Development Setup](#development-setup) above). This will automatically format and lint your code.
|
||||||
|
3. **Make your changes.**
|
||||||
|
4. **Run the tests.**
|
||||||
|
- `cargo test --all`
|
||||||
|
5. **Commit your changes.** The pre-commit hooks will automatically run `cargo fmt`, `cargo check`, and `cargo clippy`. If you need to bypass the hooks (not recommended), use `git commit --no-verify`.
|
||||||
|
6. **Add a clear, concise commit message.** We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
||||||
|
7. **Push to your fork** and submit a pull request to Owlen's `main` branch.
|
||||||
|
8. **Include a clear description** of the problem and solution. Include the relevant issue number if applicable.
|
||||||
|
9. **Declare AI assistance.** If any part of the patch was generated with an AI tool (e.g., ChatGPT, Claude Code), call that out in the PR description. A human maintainer must review and approve AI-assisted changes before merge.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
To get started with the codebase, you'll need to have Rust installed. Then, you can clone the repository and build the project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/Owlibou/owlen.git
|
||||||
|
cd owlen
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
|
||||||
|
We use [pre-commit](https://pre-commit.com/) to automatically run formatting and linting checks before each commit. This helps maintain code quality and consistency.
|
||||||
|
|
||||||
|
**Install pre-commit:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S pre-commit
|
||||||
|
|
||||||
|
# Other Linux/macOS
|
||||||
|
pip install pre-commit
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
pre-commit --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup the hooks:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd owlen
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
Once installed, the hooks will automatically run on every commit. You can also run them manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Run on all files
|
||||||
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
# Run on staged files only
|
||||||
|
pre-commit run
|
||||||
|
```
|
||||||
|
|
||||||
|
The pre-commit hooks will check:
|
||||||
|
- Code formatting (`cargo fmt`)
|
||||||
|
- Compilation (`cargo check`)
|
||||||
|
- Linting (`cargo clippy --all-features`)
|
||||||
|
- General file hygiene (trailing whitespace, EOF newlines, etc.)
|
||||||
|
|
||||||
|
## Coding Style
|
||||||
|
|
||||||
|
- We use `cargo fmt` for automated code formatting. Please run it before committing your changes.
|
||||||
|
- We use `cargo clippy` for linting. Your code should be free of any clippy warnings.
|
||||||
|
|
||||||
|
## Commit Message Conventions
|
||||||
|
|
||||||
|
We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for our commit messages. This allows for automated changelog generation and makes the project history easier to read.
|
||||||
|
|
||||||
|
The basic format is:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(provider): add support for Gemini Pro
|
||||||
|
```
|
||||||
|
|
||||||
|
Thank you for your contribution!
|
||||||
33
Cargo.toml
33
Cargo.toml
@@ -4,13 +4,17 @@ members = [
|
|||||||
"crates/owlen-core",
|
"crates/owlen-core",
|
||||||
"crates/owlen-tui",
|
"crates/owlen-tui",
|
||||||
"crates/owlen-cli",
|
"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 = []
|
exclude = []
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.9"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
authors = ["Owlibou"]
|
authors = ["Owlibou"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
repository = "https://somegit.dev/Owlibou/owlen"
|
repository = "https://somegit.dev/Owlibou/owlen"
|
||||||
@@ -32,14 +36,30 @@ crossterm = "0.28"
|
|||||||
tui-textarea = "0.6"
|
tui-textarea = "0.6"
|
||||||
|
|
||||||
# HTTP client and JSON handling
|
# HTTP client and JSON handling
|
||||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = { version = "1.0" }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "1.0"
|
thiserror = "2.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"] }
|
||||||
|
log = "0.4"
|
||||||
|
dirs = "5.0"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
handlebars = "6.0"
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
@@ -58,7 +78,6 @@ async-trait = "0.1"
|
|||||||
clap = { version = "4.0", features = ["derive"] }
|
clap = { version = "4.0", features = ["derive"] }
|
||||||
|
|
||||||
# Dev dependencies
|
# Dev dependencies
|
||||||
tempfile = "3.8"
|
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|
||||||
# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html
|
# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
1
LICENSE
1
LICENSE
@@ -659,4 +659,3 @@ specific requirements.
|
|||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|||||||
42
PKGBUILD
42
PKGBUILD
@@ -1,45 +1,49 @@
|
|||||||
# Maintainer: Owlibou
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlen
|
pkgname=owlen
|
||||||
pkgver=0.1.0
|
pkgver=0.1.9
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64')
|
||||||
url="https://somegit.dev/Owlibou/owlen"
|
url="https://somegit.dev/Owlibou/owlen"
|
||||||
license=('AGPL-3.0-only')
|
license=('AGPL-3.0-or-later')
|
||||||
depends=('gcc-libs')
|
depends=('gcc-libs')
|
||||||
makedepends=('cargo' 'git')
|
makedepends=('cargo' 'git')
|
||||||
source=("${pkgname}-${pkgver}.tar.gz::https://somegit.dev/Owlibou/owlen/archive/v${pkgver}.tar.gz")
|
options=(!lto) # avoid LTO-linked ring symbol drop with lld
|
||||||
sha256sums=('SKIP') # Update this after first release
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('cabb1cfdfc247b5d008c6c5f94e13548bcefeba874aae9a9d45aa95ae1c085ec')
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
cargo fetch --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
export CARGO_TARGET_DIR=target
|
export CARGO_TARGET_DIR=target
|
||||||
cargo build --frozen --release --all-features
|
cargo build --frozen --release --all-features
|
||||||
}
|
}
|
||||||
|
|
||||||
check() {
|
check() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||||
cargo test --frozen --all-features
|
cargo test --frozen --all-features
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
|
|
||||||
# Install binaries
|
# Install binaries
|
||||||
install -Dm755 "target/release/owlen" "$pkgdir/usr/bin/owlen"
|
install -Dm755 target/release/owlen "$pkgdir/usr/bin/owlen"
|
||||||
install -Dm755 "target/release/owlen-code" "$pkgdir/usr/bin/owlen-code"
|
install -Dm755 target/release/owlen-code "$pkgdir/usr/bin/owlen-code"
|
||||||
|
|
||||||
# Install license
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
|
||||||
|
|
||||||
# Install documentation
|
# Install documentation
|
||||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
# Install built-in themes for reference
|
||||||
|
install -Dm644 themes/README.md "$pkgdir/usr/share/$pkgname/themes/README.md"
|
||||||
|
for theme in themes/*.toml; do
|
||||||
|
install -Dm644 "$theme" "$pkgdir/usr/share/$pkgname/themes/$(basename $theme)"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|||||||
285
README.md
285
README.md
@@ -3,17 +3,10 @@
|
|||||||
> Terminal-native assistant for running local language models with a comfortable TUI.
|
> Terminal-native assistant for running local language models with a comfortable TUI.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Alpha Status
|
|
||||||
|
|
||||||
- This project is currently in **alpha** (v0.1.0) and under active development.
|
|
||||||
- Core features are functional but expect occasional bugs and missing polish.
|
|
||||||
- Breaking changes may occur between releases as we refine the API.
|
|
||||||
- Feedback, bug reports, and contributions are very welcome!
|
|
||||||
|
|
||||||
## What Is OWLEN?
|
## What Is OWLEN?
|
||||||
|
|
||||||
OWLEN is a Rust-powered, terminal-first interface for interacting with local large
|
OWLEN is a Rust-powered, terminal-first interface for interacting with local large
|
||||||
@@ -21,259 +14,137 @@ language models. It provides a responsive chat workflow that runs against
|
|||||||
[Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation,
|
[Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation,
|
||||||
and seamless session management—all without leaving your terminal.
|
and seamless session management—all without leaving your terminal.
|
||||||
|
|
||||||
|
## Alpha Status
|
||||||
|
|
||||||
|
This project is currently in **alpha** and under active development. Core features are functional, but expect occasional bugs and breaking changes. Feedback, bug reports, and contributions are very welcome!
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### Initial Layout
|
|
||||||

|

|
||||||
|
|
||||||
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory including:
|
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory.
|
||||||
- Full chat conversations (`chat_view.png`)
|
|
||||||
- Help menu (`help.png`)
|
|
||||||
- Model selection (`model_select.png`)
|
|
||||||
- Visual selection mode (`select_mode.png`)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Chat Client (`owlen`)
|
- **Vim-style Navigation**: Normal, editing, visual, and command modes.
|
||||||
- **Vim-style Navigation** - Normal, editing, visual, and command modes
|
- **Streaming Responses**: Real-time token streaming from Ollama.
|
||||||
- **Streaming Responses** - Real-time token streaming from Ollama
|
- **Advanced Text Editing**: Multi-line input, history, and clipboard support.
|
||||||
- **Multi-Panel Interface** - Separate panels for chat, thinking content, and input
|
- **Session Management**: Save, load, and manage conversations.
|
||||||
- **Advanced Text Editing** - Multi-line input with `tui-textarea`, history navigation
|
- **Code Side Panel**: Switch to code mode (`:mode code`) and open files inline with `:open <path>` for LLM-assisted coding.
|
||||||
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
- **Theming System**: 10 built-in themes and support for custom themes.
|
||||||
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
- **Modular Architecture**: Extensible provider system (Ollama today, additional providers on the roadmap).
|
||||||
- **Model Management** - Interactive model and provider selection (press `m`)
|
- **Guided Setup**: `owlen config doctor` upgrades legacy configs and verifies your environment in seconds.
|
||||||
- **Session Management** - Start new conversations, clear history
|
|
||||||
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
|
||||||
- **Bracketed Paste** - Safe paste handling for multi-line content
|
|
||||||
|
|
||||||
### Code Client (`owlen-code`) [Experimental]
|
## Security & Privacy
|
||||||
- All chat client features
|
|
||||||
- Optimized system prompt for programming assistance
|
|
||||||
- Foundation for future code-specific features
|
|
||||||
|
|
||||||
### Core Infrastructure
|
Owlen is designed to keep data local by default while still allowing controlled access to remote tooling.
|
||||||
- **Modular Architecture** - Separated core logic, TUI components, and providers
|
|
||||||
- **Provider System** - Extensible provider trait (currently: Ollama)
|
- **Local-first execution**: All LLM calls flow through the bundled MCP LLM server which talks to a local Ollama instance. If the server is unreachable, Owlen stays usable in “offline mode” and surfaces clear recovery instructions.
|
||||||
- **Session Controller** - Unified conversation and state management
|
- **Sandboxed tooling**: Code execution runs in Docker according to the MCP Code Server settings, and future releases will extend this to other OS-level sandboxes (`sandbox-exec` on macOS, Windows job objects).
|
||||||
- **Configuration Management** - TOML-based config with sensible defaults
|
- **Session storage**: Conversations are stored under the platform data directory and can be encrypted at rest. Set `privacy.encrypt_local_data = true` in `config.toml` to enable AES-GCM storage protected by a user-supplied passphrase.
|
||||||
- **Message Formatting** - Markdown rendering, thinking content extraction
|
- **Network access**: No telemetry is sent. The only outbound requests occur when you explicitly enable remote tooling (e.g., web search) or configure a cloud LLM provider. Each tool is opt-in via `privacy` and `tools` configuration sections.
|
||||||
- **Async Runtime** - Built on Tokio for efficient streaming
|
- **Config migrations**: Every saved `config.toml` carries a schema version and is upgraded automatically; deprecated keys trigger warnings so security-related settings are not silently ignored.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Rust 1.75+ and Cargo (`rustup` recommended)
|
- Rust 1.75+ and Cargo.
|
||||||
- A running Ollama instance with at least one model pulled
|
- A running Ollama instance.
|
||||||
(defaults to `http://localhost:11434`)
|
- A terminal that supports 256 colors.
|
||||||
- A terminal that supports 256 colors
|
|
||||||
|
|
||||||
### Clone and Build
|
### Installation
|
||||||
|
|
||||||
|
#### Linux & macOS
|
||||||
|
The recommended way to install on Linux and macOS is to clone the repository and install using `cargo`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://somegit.dev/Owlibou/owlen.git
|
git clone https://github.com/Owlibou/owlen.git
|
||||||
cd owlen
|
cd owlen
|
||||||
cargo build --release
|
cargo install --path crates/owlen-cli
|
||||||
```
|
```
|
||||||
|
**Note for macOS**: While this method works, official binary releases for macOS are planned for the future.
|
||||||
|
|
||||||
### Run the Chat Client
|
#### Windows
|
||||||
|
The Windows build has not been thoroughly tested yet. Installation is possible via the same `cargo install` method, but it is considered experimental at this time.
|
||||||
|
|
||||||
Make sure Ollama is running, then launch:
|
From Unix hosts you can run `scripts/check-windows.sh` to ensure the code base still compiles for Windows (`rustup` will install the required target automatically).
|
||||||
|
|
||||||
|
### Running OWLEN
|
||||||
|
|
||||||
|
Make sure Ollama is running, then launch the application:
|
||||||
|
```bash
|
||||||
|
owlen
|
||||||
|
```
|
||||||
|
If you built from source without installing, you can run it with:
|
||||||
```bash
|
```bash
|
||||||
./target/release/owlen
|
./target/release/owlen
|
||||||
# or during development:
|
|
||||||
cargo run --bin owlen
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### (Optional) Try the Code Client
|
### Updating
|
||||||
|
|
||||||
The coding-focused TUI is experimental:
|
Owlen does not auto-update. Run `owlen upgrade` at any time to print the recommended manual steps (pull the repository and reinstall with `cargo install --path crates/owlen-cli --force`). Arch Linux users can update via the `owlen-git` AUR package.
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release --bin owlen-code --features code-client
|
|
||||||
./target/release/owlen-code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using the TUI
|
## Using the TUI
|
||||||
|
|
||||||
### Mode System (Vim-inspired)
|
OWLEN uses a modal, vim-inspired interface. Press `F1` (available from any mode) or `?` in Normal mode to view the help screen with all keybindings.
|
||||||
|
|
||||||
**Normal Mode** (default):
|
- **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`.
|
||||||
- `i` / `Enter` - Enter editing mode
|
- **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`.
|
||||||
- `a` - Append (move right and enter editing mode)
|
- **Command Mode**: Enter with `:`. Access commands like `:quit`, `:save`, `:theme`.
|
||||||
- `A` - Append at end of line
|
- **Tutorial Command**: Type `:tutorial` any time for a quick summary of the most important keybindings.
|
||||||
- `I` - Insert at start of line
|
|
||||||
- `o` - Insert new line below
|
|
||||||
- `O` - Insert new line above
|
|
||||||
- `v` - Enter visual mode (text selection)
|
|
||||||
- `:` - Enter command mode
|
|
||||||
- `h/j/k/l` - Navigate left/down/up/right
|
|
||||||
- `w/b/e` - Word navigation
|
|
||||||
- `0/$` - Jump to line start/end
|
|
||||||
- `gg` - Jump to top
|
|
||||||
- `G` - Jump to bottom
|
|
||||||
- `Ctrl-d/u` - Half-page scroll
|
|
||||||
- `Ctrl-f/b` - Full-page scroll
|
|
||||||
- `Tab` - Cycle focus between panels
|
|
||||||
- `p` - Paste from clipboard
|
|
||||||
- `dd` - Clear input buffer
|
|
||||||
- `q` - Quit
|
|
||||||
|
|
||||||
**Editing Mode**:
|
## Documentation
|
||||||
- `Esc` - Return to normal mode
|
|
||||||
- `Enter` - Send message and return to normal mode
|
|
||||||
- `Ctrl-J` / `Shift-Enter` - Insert newline
|
|
||||||
- `Ctrl-↑/↓` - Navigate input history
|
|
||||||
- Paste events handled automatically
|
|
||||||
|
|
||||||
**Visual Mode**:
|
For more detailed information, please refer to the following documents:
|
||||||
- `j/k/h/l` - Extend selection
|
|
||||||
- `w/b/e` - Word-based selection
|
|
||||||
- `y` - Yank (copy) selection
|
|
||||||
- `d` - Cut selection (Input panel only)
|
|
||||||
- `Esc` - Cancel selection
|
|
||||||
|
|
||||||
**Command Mode**:
|
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: Guidelines for contributing to the project.
|
||||||
- `:q` / `:quit` - Quit application
|
- **[CHANGELOG.md](CHANGELOG.md)**: A log of changes for each version.
|
||||||
- `:c` / `:clear` - Clear conversation
|
- **[docs/architecture.md](docs/architecture.md)**: An overview of the project's architecture.
|
||||||
- `:m` / `:model` - Open model selector
|
- **[docs/troubleshooting.md](docs/troubleshooting.md)**: Help with common issues.
|
||||||
- `:n` / `:new` - Start new conversation
|
- **[docs/provider-implementation.md](docs/provider-implementation.md)**: A guide for adding new providers.
|
||||||
- `:h` / `:help` - Show help
|
- **[docs/platform-support.md](docs/platform-support.md)**: Current OS support matrix and cross-check instructions.
|
||||||
|
|
||||||
### Panel Management
|
|
||||||
- Three panels: Chat, Thinking, and Input
|
|
||||||
- `Tab` / `Shift-Tab` - Cycle focus forward/backward
|
|
||||||
- Focused panel receives scroll and navigation commands
|
|
||||||
- Thinking panel appears when extended reasoning is available
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
OWLEN stores configuration in `~/.config/owlen/config.toml`. The file is created
|
OWLEN stores its configuration in the standard platform-specific config directory:
|
||||||
on first run and can be edited to customize behavior:
|
|
||||||
|
|
||||||
```toml
|
| Platform | Location |
|
||||||
[general]
|
|----------|----------|
|
||||||
default_model = "llama3.2:latest"
|
| Linux | `~/.config/owlen/config.toml` |
|
||||||
default_provider = "ollama"
|
| macOS | `~/Library/Application Support/owlen/config.toml` |
|
||||||
enable_streaming = true
|
| Windows | `%APPDATA%\owlen\config.toml` |
|
||||||
project_context_file = "OWLEN.md"
|
|
||||||
|
|
||||||
[providers.ollama]
|
Use `owlen config path` to print the exact location on your machine and `owlen config doctor` to migrate a legacy config automatically.
|
||||||
provider_type = "ollama"
|
You can also add custom themes alongside the config directory (e.g., `~/.config/owlen/themes/`).
|
||||||
base_url = "http://localhost:11434"
|
|
||||||
timeout = 300
|
|
||||||
```
|
|
||||||
|
|
||||||
Configuration is automatically saved when you change models or providers.
|
See the [themes/README.md](themes/README.md) for more details on theming.
|
||||||
|
|
||||||
## Repository Layout
|
## Testing
|
||||||
|
|
||||||
```
|
Owlen uses standard Rust tooling for verification. Run the full test suite with:
|
||||||
owlen/
|
|
||||||
├── crates/
|
|
||||||
│ ├── owlen-core/ # Core types, session management, shared UI components
|
|
||||||
│ ├── owlen-ollama/ # Ollama provider implementation
|
|
||||||
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
|
|
||||||
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
|
|
||||||
├── LICENSE # AGPL-3.0 License
|
|
||||||
├── Cargo.toml # Workspace configuration
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Architecture Highlights
|
|
||||||
- **owlen-core**: Provider-agnostic core with session controller, UI primitives (AutoScroll, InputMode, FocusedPanel), and shared utilities
|
|
||||||
- **owlen-tui**: Ratatui-based UI implementation with vim-style modal editing
|
|
||||||
- **Separation of Concerns**: Clean boundaries between business logic, presentation, and provider implementations
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Building
|
|
||||||
```bash
|
```bash
|
||||||
# Debug build
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Release build
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# Build with all features
|
|
||||||
cargo build --all-features
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
# Check code
|
|
||||||
cargo clippy
|
|
||||||
cargo fmt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Notes
|
Unit tests cover the command palette state machine, agent response parsing, and key MCP abstractions. Formatting and lint checks can be run with `cargo fmt --all` and `cargo clippy` respectively.
|
||||||
- Standard Rust workflows apply (`cargo fmt`, `cargo clippy`, `cargo test`)
|
|
||||||
- Codebase uses async Rust (`tokio`) for event handling and streaming
|
|
||||||
- Configuration is cached in `~/.config/owlen` (wipe to reset)
|
|
||||||
- UI components are extensively tested in `owlen-core/src/ui.rs`
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Completed ✓
|
Upcoming milestones focus on feature parity with modern code assistants while keeping Owlen local-first:
|
||||||
- [x] Streaming responses with real-time display
|
|
||||||
- [x] Autoscroll and viewport management
|
|
||||||
- [x] Push user message before loading LLM response
|
|
||||||
- [x] Thinking mode support with dedicated panel
|
|
||||||
- [x] Vim-style modal editing (Normal, Visual, Command modes)
|
|
||||||
- [x] Multi-panel focus management
|
|
||||||
- [x] Text selection and clipboard functionality
|
|
||||||
- [x] Comprehensive keyboard navigation
|
|
||||||
- [x] Bracketed paste support
|
|
||||||
|
|
||||||
### In Progress
|
1. **Phase 11 – MCP client enhancements**: `owlen mcp add/list/remove`, resource references (`@github:issue://123`), and MCP prompt slash commands.
|
||||||
- [ ] Theming options and color customization
|
2. **Phase 12 – Approval & sandboxing**: Three-tier approval modes plus platform-specific sandboxes (Docker, `sandbox-exec`, Windows job objects).
|
||||||
- [ ] Enhanced configuration UX (in-app settings)
|
3. **Phase 13 – Project documentation system**: Automatic `OWLEN.md` generation, contextual updates, and nested project support.
|
||||||
- [ ] Chat history management (save/load/export)
|
4. **Phase 15 – Provider expansion**: OpenAI, Anthropic, and other cloud providers layered onto the existing Ollama-first architecture.
|
||||||
|
|
||||||
### Planned
|
See `AGENTS.md` for the long-form roadmap and design notes.
|
||||||
- [ ] Code Client Enhancement
|
|
||||||
- [ ] In-project code navigation
|
|
||||||
- [ ] Syntax highlighting for code blocks
|
|
||||||
- [ ] File tree browser integration
|
|
||||||
- [ ] Project-aware context management
|
|
||||||
- [ ] Code snippets and templates
|
|
||||||
- [ ] Additional LLM Providers
|
|
||||||
- [ ] OpenAI API support
|
|
||||||
- [ ] Anthropic Claude support
|
|
||||||
- [ ] Local model providers (llama.cpp, etc.)
|
|
||||||
- [ ] Advanced Features
|
|
||||||
- [ ] Conversation search and filtering
|
|
||||||
- [ ] Multi-session management
|
|
||||||
- [ ] Export conversations (Markdown, JSON)
|
|
||||||
- [ ] Custom keybindings
|
|
||||||
- [ ] Plugin system
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Here's how to get started:
|
Contributions are highly welcome! Please see our **[Contributing Guide](CONTRIBUTING.md)** for details on how to get started, including our code style, commit conventions, and pull request process.
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Make your changes and add tests
|
|
||||||
4. Run `cargo fmt` and `cargo clippy`
|
|
||||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
||||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
7. Open a Pull Request
|
|
||||||
|
|
||||||
Please open an issue first for significant changes to discuss the approach.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the GNU Affero General Public License v3.0. See the [LICENSE](LICENSE) file for details.
|
||||||
|
For commercial or proprietary integrations that cannot adopt AGPL, please reach out to the maintainers to discuss alternative licensing arrangements.
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
Built with:
|
|
||||||
- [ratatui](https://ratatui.rs/) - Terminal UI framework
|
|
||||||
- [crossterm](https://github.com/crossterm-rs/crossterm) - Cross-platform terminal manipulation
|
|
||||||
- [tokio](https://tokio.rs/) - Async runtime
|
|
||||||
- [Ollama](https://ollama.com/) - Local LLM runtime
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: Alpha v0.1.0 | **License**: AGPL-3.0 | **Made with Rust** 🦀
|
|
||||||
|
|||||||
40
SECURITY.md
Normal file
40
SECURITY.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
We are currently in a pre-release phase, so only the latest version is actively supported. As we move towards a 1.0 release, this policy will be updated with specific version support.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| < 1.0 | :white_check_mark: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
The Owlen team and community take all security vulnerabilities seriously. Thank you for improving the security of our project. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
To report a security vulnerability, please email the project lead at [security@owlibou.com](mailto:security@owlibou.com) with a detailed description of the issue, the steps to reproduce it, and any affected versions.
|
||||||
|
|
||||||
|
You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible, depending on the complexity of the issue.
|
||||||
|
|
||||||
|
Please do not report security vulnerabilities through public GitHub issues.
|
||||||
|
|
||||||
|
## Design Overview
|
||||||
|
|
||||||
|
Owlen ships with a local-first architecture:
|
||||||
|
|
||||||
|
- **Process isolation** – The TUI speaks to language models through a separate MCP LLM server. Tool execution (code, web, filesystem) occurs in dedicated MCP processes so a crash or hang cannot take down the UI.
|
||||||
|
- **Sandboxing** – The MCP Code Server executes snippets in Docker containers. Upcoming releases will extend this to platform sandboxes (`sandbox-exec` on macOS, Windows job objects) as described in our roadmap.
|
||||||
|
- **Network posture** – No telemetry is emitted. The application only reaches the network when a user explicitly enables remote tools (web search, remote MCP servers) or configures cloud providers. All tools require allow-listing in `config.toml`.
|
||||||
|
|
||||||
|
## Data Handling
|
||||||
|
|
||||||
|
- **Sessions** – Conversations are stored in the user’s data directory (`~/.local/share/owlen` on Linux, equivalent paths on macOS/Windows). Enable `privacy.encrypt_local_data = true` to wrap the session store in AES-GCM encryption protected by a passphrase (`OWLEN_MASTER_PASSWORD` or an interactive prompt).
|
||||||
|
- **Credentials** – API tokens are resolved from the config file or environment variables at runtime and are never written to logs.
|
||||||
|
- **Remote calls** – When remote search or cloud LLM tooling is on, only the minimum payload (prompt, tool arguments) is sent. All outbound requests go through the MCP servers so they can be audited or disabled centrally.
|
||||||
|
|
||||||
|
## Supply-Chain Safeguards
|
||||||
|
|
||||||
|
- The repository includes a git `pre-commit` configuration that runs `cargo fmt`, `cargo check`, and `cargo clippy -- -D warnings` on every commit.
|
||||||
|
- Pull requests generated with the assistance of AI tooling must receive manual maintainer review before merging. Contributors are asked to declare AI involvement in their PR description so maintainers can double-check the changes.
|
||||||
|
|
||||||
|
Additional recommendations for operators (e.g., running Owlen on shared systems) are maintained in `docs/security.md` (planned) and the issue tracker.
|
||||||
5
crates/owlen-anthropic/README.md
Normal file
5
crates/owlen-anthropic/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Owlen Anthropic
|
||||||
|
|
||||||
|
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Anthropic (Claude) API.
|
||||||
|
|
||||||
|
This provider is not yet implemented. Contributions are welcome!
|
||||||
@@ -10,8 +10,7 @@ description = "Command-line interface for OWLEN LLM client"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["chat-client"]
|
default = ["chat-client"]
|
||||||
chat-client = []
|
chat-client = ["owlen-tui"]
|
||||||
code-client = []
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "owlen"
|
name = "owlen"
|
||||||
@@ -19,17 +18,20 @@ path = "src/main.rs"
|
|||||||
required-features = ["chat-client"]
|
required-features = ["chat-client"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "owlen-code"
|
name = "owlen-agent"
|
||||||
path = "src/code_main.rs"
|
path = "src/agent_main.rs"
|
||||||
required-features = ["code-client"]
|
required-features = ["chat-client"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
owlen-core = { path = "../owlen-core" }
|
owlen-core = { path = "../owlen-core" }
|
||||||
owlen-tui = { path = "../owlen-tui" }
|
# Optional TUI dependency, enabled by the "chat-client" feature.
|
||||||
owlen-ollama = { path = "../owlen-ollama" }
|
owlen-tui = { path = "../owlen-tui", optional = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|
||||||
# CLI framework
|
# CLI framework
|
||||||
clap = { version = "4.0", features = ["derive"] }
|
clap = { workspace = true, features = ["derive"] }
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
@@ -43,3 +45,10 @@ crossterm = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
regex = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tokio-test = { workspace = true }
|
||||||
|
|||||||
15
crates/owlen-cli/README.md
Normal file
15
crates/owlen-cli/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Owlen CLI
|
||||||
|
|
||||||
|
This crate is the command-line entry point for the Owlen application.
|
||||||
|
|
||||||
|
It is responsible for:
|
||||||
|
|
||||||
|
- Parsing command-line arguments.
|
||||||
|
- Loading the configuration.
|
||||||
|
- Initializing the providers.
|
||||||
|
- Starting the `owlen-tui` application.
|
||||||
|
|
||||||
|
There are two binaries:
|
||||||
|
|
||||||
|
- `owlen`: The main chat application.
|
||||||
|
- `owlen-code`: A specialized version for code-related tasks.
|
||||||
31
crates/owlen-cli/build.rs
Normal file
31
crates/owlen-cli/build.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
const MIN_VERSION: (u32, u32, u32) = (1, 75, 0);
|
||||||
|
|
||||||
|
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
|
||||||
|
let output = Command::new(&rustc)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.expect("failed to invoke rustc");
|
||||||
|
|
||||||
|
let version_line = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let version_str = version_line.split_whitespace().nth(1).unwrap_or("0.0.0");
|
||||||
|
let sanitized = version_str.split('-').next().unwrap_or(version_str);
|
||||||
|
|
||||||
|
let mut parts = sanitized
|
||||||
|
.split('.')
|
||||||
|
.map(|part| part.parse::<u32>().unwrap_or(0));
|
||||||
|
let current = (
|
||||||
|
parts.next().unwrap_or(0),
|
||||||
|
parts.next().unwrap_or(0),
|
||||||
|
parts.next().unwrap_or(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if current < MIN_VERSION {
|
||||||
|
panic!(
|
||||||
|
"owlen requires rustc {}.{}.{} or newer (found {version_line})",
|
||||||
|
MIN_VERSION.0, MIN_VERSION.1, MIN_VERSION.2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
crates/owlen-cli/src/agent_main.rs
Normal file
61
crates/owlen-cli/src/agent_main.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Simple entry point for the ReAct agentic executor.
|
||||||
|
//!
|
||||||
|
//! Usage: `owlen-agent "<prompt>" [--model <model>] [--max-iter <n>]`
|
||||||
|
//!
|
||||||
|
//! This binary demonstrates Phase 4 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;
|
||||||
|
|
||||||
|
/// Command‑line 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
414
crates/owlen-cli/src/cloud.rs
Normal file
414
crates/owlen-cli/src/cloud.rs
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use clap::Subcommand;
|
||||||
|
use owlen_core::LlmProvider;
|
||||||
|
use owlen_core::ProviderConfig;
|
||||||
|
use owlen_core::config as core_config;
|
||||||
|
use owlen_core::config::Config;
|
||||||
|
use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID};
|
||||||
|
use owlen_core::encryption;
|
||||||
|
use owlen_core::providers::OllamaProvider;
|
||||||
|
use owlen_core::storage::StorageManager;
|
||||||
|
|
||||||
|
const DEFAULT_CLOUD_ENDPOINT: &str = "https://ollama.com";
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum CloudCommand {
|
||||||
|
/// Configure Ollama Cloud credentials
|
||||||
|
Setup {
|
||||||
|
/// API key passed directly on the command line (prompted when omitted)
|
||||||
|
#[arg(long)]
|
||||||
|
api_key: Option<String>,
|
||||||
|
/// Override the cloud endpoint (default: https://ollama.com)
|
||||||
|
#[arg(long)]
|
||||||
|
endpoint: Option<String>,
|
||||||
|
/// Provider name to configure (default: ollama)
|
||||||
|
#[arg(long, default_value = "ollama")]
|
||||||
|
provider: String,
|
||||||
|
},
|
||||||
|
/// Check connectivity to Ollama Cloud
|
||||||
|
Status {
|
||||||
|
/// Provider name to check (default: ollama)
|
||||||
|
#[arg(long, default_value = "ollama")]
|
||||||
|
provider: String,
|
||||||
|
},
|
||||||
|
/// List available cloud-hosted models
|
||||||
|
Models {
|
||||||
|
/// Provider name to query (default: ollama)
|
||||||
|
#[arg(long, default_value = "ollama")]
|
||||||
|
provider: String,
|
||||||
|
},
|
||||||
|
/// Remove stored Ollama Cloud credentials
|
||||||
|
Logout {
|
||||||
|
/// Provider name to clear (default: ollama)
|
||||||
|
#[arg(long, default_value = "ollama")]
|
||||||
|
provider: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_cloud_command(command: CloudCommand) -> Result<()> {
|
||||||
|
match command {
|
||||||
|
CloudCommand::Setup {
|
||||||
|
api_key,
|
||||||
|
endpoint,
|
||||||
|
provider,
|
||||||
|
} => setup(provider, api_key, endpoint).await,
|
||||||
|
CloudCommand::Status { provider } => status(provider).await,
|
||||||
|
CloudCommand::Models { provider } => models(provider).await,
|
||||||
|
CloudCommand::Logout { provider } => logout(provider).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup(provider: String, api_key: Option<String>, endpoint: Option<String>) -> Result<()> {
|
||||||
|
let provider = canonical_provider_name(&provider);
|
||||||
|
let mut config = crate::config::try_load_config().unwrap_or_default();
|
||||||
|
let endpoint = endpoint.unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string());
|
||||||
|
|
||||||
|
ensure_provider_entry(&mut config, &provider, &endpoint);
|
||||||
|
|
||||||
|
let key = match api_key {
|
||||||
|
Some(value) if !value.trim().is_empty() => value,
|
||||||
|
_ => {
|
||||||
|
let prompt = format!("Enter API key for {provider}: ");
|
||||||
|
encryption::prompt_password(&prompt)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if config.privacy.encrypt_local_data {
|
||||||
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
|
let manager = unlock_credential_manager(&config, storage.clone())?;
|
||||||
|
let credentials = ApiCredentials {
|
||||||
|
api_key: key.clone(),
|
||||||
|
endpoint: endpoint.clone(),
|
||||||
|
};
|
||||||
|
manager
|
||||||
|
.store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials)
|
||||||
|
.await?;
|
||||||
|
// Ensure plaintext key is not persisted to disk.
|
||||||
|
if let Some(entry) = config.providers.get_mut(&provider) {
|
||||||
|
entry.api_key = None;
|
||||||
|
}
|
||||||
|
} else if let Some(entry) = config.providers.get_mut(&provider) {
|
||||||
|
entry.api_key = Some(key.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(entry) = config.providers.get_mut(&provider) {
|
||||||
|
entry.base_url = Some(endpoint.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::config::save_config(&config)?;
|
||||||
|
println!("Saved Ollama configuration for provider '{provider}'.");
|
||||||
|
if config.privacy.encrypt_local_data {
|
||||||
|
println!("API key stored securely in the encrypted credential vault.");
|
||||||
|
} else {
|
||||||
|
println!("API key stored in plaintext configuration (encryption disabled).");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status(provider: String) -> Result<()> {
|
||||||
|
let provider = canonical_provider_name(&provider);
|
||||||
|
let mut config = crate::config::try_load_config().unwrap_or_default();
|
||||||
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
|
let manager = if config.privacy.encrypt_local_data {
|
||||||
|
Some(unlock_credential_manager(&config, storage.clone())?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let api_key = hydrate_api_key(&mut config, manager.as_ref()).await?;
|
||||||
|
ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT);
|
||||||
|
|
||||||
|
let provider_cfg = config
|
||||||
|
.provider(&provider)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?;
|
||||||
|
|
||||||
|
let ollama = OllamaProvider::from_config(&provider_cfg, Some(&config.general))
|
||||||
|
.with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?;
|
||||||
|
|
||||||
|
match ollama.health_check().await {
|
||||||
|
Ok(_) => {
|
||||||
|
println!(
|
||||||
|
"✓ Connected to {provider} ({})",
|
||||||
|
provider_cfg
|
||||||
|
.base_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(DEFAULT_CLOUD_ENDPOINT)
|
||||||
|
);
|
||||||
|
if api_key.is_none() && config.privacy.encrypt_local_data {
|
||||||
|
println!(
|
||||||
|
"Warning: No API key stored; connection succeeded via environment variables."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("✗ Failed to reach {provider}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn models(provider: String) -> Result<()> {
|
||||||
|
let provider = canonical_provider_name(&provider);
|
||||||
|
let mut config = crate::config::try_load_config().unwrap_or_default();
|
||||||
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
|
let manager = if config.privacy.encrypt_local_data {
|
||||||
|
Some(unlock_credential_manager(&config, storage.clone())?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
hydrate_api_key(&mut config, manager.as_ref()).await?;
|
||||||
|
|
||||||
|
ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT);
|
||||||
|
let provider_cfg = config
|
||||||
|
.provider(&provider)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?;
|
||||||
|
|
||||||
|
let ollama = OllamaProvider::from_config(&provider_cfg, Some(&config.general))
|
||||||
|
.with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?;
|
||||||
|
|
||||||
|
match ollama.list_models().await {
|
||||||
|
Ok(models) => {
|
||||||
|
if models.is_empty() {
|
||||||
|
println!("No cloud models reported by '{}'.", provider);
|
||||||
|
} else {
|
||||||
|
println!("Models available via '{}':", provider);
|
||||||
|
for model in models {
|
||||||
|
if let Some(description) = &model.description {
|
||||||
|
println!(" - {} ({})", model.id, description);
|
||||||
|
} else {
|
||||||
|
println!(" - {}", model.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
bail!("Failed to list models: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(provider: String) -> Result<()> {
|
||||||
|
let provider = canonical_provider_name(&provider);
|
||||||
|
let mut config = crate::config::try_load_config().unwrap_or_default();
|
||||||
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
|
|
||||||
|
if config.privacy.encrypt_local_data {
|
||||||
|
let manager = unlock_credential_manager(&config, storage.clone())?;
|
||||||
|
manager
|
||||||
|
.delete_credentials(OLLAMA_CLOUD_CREDENTIAL_ID)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(entry) = provider_entry_mut(&mut config) {
|
||||||
|
entry.api_key = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::config::save_config(&config)?;
|
||||||
|
println!("Cleared credentials for provider '{provider}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) {
|
||||||
|
if provider == "ollama"
|
||||||
|
&& config.providers.contains_key("ollama-cloud")
|
||||||
|
&& !config.providers.contains_key("ollama")
|
||||||
|
&& let Some(mut legacy) = config.providers.remove("ollama-cloud")
|
||||||
|
{
|
||||||
|
legacy.provider_type = "ollama".to_string();
|
||||||
|
config.providers.insert("ollama".to_string(), legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
core_config::ensure_provider_config(config, provider);
|
||||||
|
|
||||||
|
if let Some(cfg) = config.providers.get_mut(provider) {
|
||||||
|
if cfg.provider_type != "ollama" {
|
||||||
|
cfg.provider_type = "ollama".to_string();
|
||||||
|
}
|
||||||
|
if cfg.base_url.is_none() {
|
||||||
|
cfg.base_url = Some(endpoint.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_provider_name(provider: &str) -> String {
|
||||||
|
let normalized = provider.trim().replace('_', "-").to_ascii_lowercase();
|
||||||
|
match normalized.as_str() {
|
||||||
|
"" => "ollama".to_string(),
|
||||||
|
"ollama-cloud" => "ollama".to_string(),
|
||||||
|
value => value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_env_var<K, V>(key: K, value: V)
|
||||||
|
where
|
||||||
|
K: AsRef<OsStr>,
|
||||||
|
V: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
// Safety: the CLI updates process-wide environment variables during startup while no
|
||||||
|
// other threads are mutating the environment.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_env_if_missing(var: &str, value: &str) {
|
||||||
|
if std::env::var(var)
|
||||||
|
.map(|v| v.trim().is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
set_env_var(var, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_entry_mut(config: &mut Config) -> Option<&mut ProviderConfig> {
|
||||||
|
if config.providers.contains_key("ollama") {
|
||||||
|
config.providers.get_mut("ollama")
|
||||||
|
} else {
|
||||||
|
config.providers.get_mut("ollama-cloud")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_entry(config: &Config) -> Option<&ProviderConfig> {
|
||||||
|
if let Some(entry) = config.providers.get("ollama") {
|
||||||
|
return Some(entry);
|
||||||
|
}
|
||||||
|
config.providers.get("ollama-cloud")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock_credential_manager(
|
||||||
|
config: &Config,
|
||||||
|
storage: Arc<StorageManager>,
|
||||||
|
) -> Result<Arc<CredentialManager>> {
|
||||||
|
if !config.privacy.encrypt_local_data {
|
||||||
|
bail!("Credential manager requested but encryption is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let secure_path = vault_path(&storage)?;
|
||||||
|
let handle = unlock_vault(&secure_path)?;
|
||||||
|
let master_key = Arc::new(handle.data.master_key.clone());
|
||||||
|
Ok(Arc::new(CredentialManager::new(
|
||||||
|
storage,
|
||||||
|
master_key.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vault_path(storage: &StorageManager) -> Result<PathBuf> {
|
||||||
|
let base_dir = storage
|
||||||
|
.database_path()
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.or_else(dirs::data_local_dir)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
Ok(base_dir.join("encrypted_data.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock_vault(path: &Path) -> Result<encryption::VaultHandle> {
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
if let Ok(password) = env::var("OWLEN_MASTER_PASSWORD")
|
||||||
|
&& !password.trim().is_empty()
|
||||||
|
{
|
||||||
|
return encryption::unlock_with_password(path.to_path_buf(), &password)
|
||||||
|
.context("Failed to unlock vault with OWLEN_MASTER_PASSWORD");
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in 0..3 {
|
||||||
|
let password = encryption::prompt_password("Enter master password: ")?;
|
||||||
|
match encryption::unlock_with_password(path.to_path_buf(), &password) {
|
||||||
|
Ok(handle) => {
|
||||||
|
set_env_var("OWLEN_MASTER_PASSWORD", password);
|
||||||
|
return Ok(handle);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to unlock vault: {err}");
|
||||||
|
if attempt == 2 {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("Unable to unlock encrypted credential vault");
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = encryption::unlock_interactive(path.to_path_buf())?;
|
||||||
|
if env::var("OWLEN_MASTER_PASSWORD")
|
||||||
|
.map(|v| v.trim().is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
let password = encryption::prompt_password("Cache master password for this session: ")?;
|
||||||
|
set_env_var("OWLEN_MASTER_PASSWORD", password);
|
||||||
|
}
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hydrate_api_key(
|
||||||
|
config: &mut Config,
|
||||||
|
manager: Option<&Arc<CredentialManager>>,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
if let Some(manager) = manager
|
||||||
|
&& let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await?
|
||||||
|
{
|
||||||
|
let key = credentials.api_key.trim().to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
set_env_if_missing("OLLAMA_API_KEY", &key);
|
||||||
|
set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cfg) = provider_entry_mut(config)
|
||||||
|
&& cfg.base_url.is_none()
|
||||||
|
&& !credentials.endpoint.trim().is_empty()
|
||||||
|
{
|
||||||
|
cfg.base_url = Some(credentials.endpoint);
|
||||||
|
}
|
||||||
|
return Ok(Some(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cfg) = provider_entry(config)
|
||||||
|
&& let Some(key) = cfg
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
set_env_if_missing("OLLAMA_API_KEY", key);
|
||||||
|
set_env_if_missing("OLLAMA_CLOUD_API_KEY", key);
|
||||||
|
return Ok(Some(key.to_string()));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_runtime_credentials(
|
||||||
|
config: &mut Config,
|
||||||
|
storage: Arc<StorageManager>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if config.privacy.encrypt_local_data {
|
||||||
|
let manager = unlock_credential_manager(config, storage.clone())?;
|
||||||
|
hydrate_api_key(config, Some(&manager)).await?;
|
||||||
|
} else {
|
||||||
|
hydrate_api_key(config, None).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalises_provider_names() {
|
||||||
|
assert_eq!(canonical_provider_name("OLLAMA_CLOUD"), "ollama");
|
||||||
|
assert_eq!(canonical_provider_name(" ollama-cloud"), "ollama");
|
||||||
|
assert_eq!(canonical_provider_name(""), "ollama");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
//! 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)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
crates/owlen-cli/src/lib.rs
Normal file
8
crates/owlen-cli/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Library portion of the `owlen-cli` crate.
|
||||||
|
//!
|
||||||
|
//! It currently only re‑exports 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;
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
//! OWLEN CLI - Chat TUI client
|
//! OWLEN CLI - Chat TUI client
|
||||||
|
|
||||||
use anyhow::Result;
|
mod cloud;
|
||||||
use clap::{Arg, Command};
|
|
||||||
use owlen_core::session::SessionController;
|
use anyhow::{Result, anyhow};
|
||||||
use owlen_ollama::OllamaProvider;
|
use async_trait::async_trait;
|
||||||
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
use clap::{Parser, Subcommand};
|
||||||
|
use cloud::{CloudCommand, load_runtime_credentials, set_env_var};
|
||||||
|
use owlen_core::config as core_config;
|
||||||
|
use owlen_core::{
|
||||||
|
ChatStream, Error, Provider,
|
||||||
|
config::{Config, McpMode},
|
||||||
|
mcp::remote_client::RemoteMcpClient,
|
||||||
|
mode::Mode,
|
||||||
|
providers::OllamaProvider,
|
||||||
|
session::SessionController,
|
||||||
|
storage::StorageManager,
|
||||||
|
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||||
|
};
|
||||||
|
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
||||||
|
use owlen_tui::{AppState, ChatApp, Event, EventHandler, SessionEvent, config, ui};
|
||||||
|
use std::any::Any;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -13,40 +29,405 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
use futures::stream;
|
||||||
|
use ratatui::{Terminal, prelude::CrosstermBackend};
|
||||||
|
|
||||||
#[tokio::main]
|
/// Owlen - Terminal UI for LLM chat
|
||||||
async fn main() -> Result<()> {
|
#[derive(Parser, Debug)]
|
||||||
let matches = Command::new("owlen")
|
#[command(name = "owlen")]
|
||||||
.about("OWLEN - A chat-focused TUI client for Ollama")
|
#[command(about = "Terminal UI for LLM chat via MCP", long_about = None)]
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
struct Args {
|
||||||
.arg(
|
/// Start in code mode (enables all tools)
|
||||||
Arg::new("model")
|
#[arg(long, short = 'c')]
|
||||||
.short('m')
|
code: bool,
|
||||||
.long("model")
|
#[command(subcommand)]
|
||||||
.value_name("MODEL")
|
command: Option<OwlenCommand>,
|
||||||
.help("Preferred model to use for this session"),
|
}
|
||||||
)
|
|
||||||
.get_matches();
|
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum OwlenCommand {
|
||||||
|
/// Inspect or upgrade configuration files
|
||||||
|
#[command(subcommand)]
|
||||||
|
Config(ConfigCommand),
|
||||||
|
/// Manage Ollama Cloud credentials
|
||||||
|
#[command(subcommand)]
|
||||||
|
Cloud(CloudCommand),
|
||||||
|
/// Show manual steps for updating Owlen to the latest revision
|
||||||
|
Upgrade,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum ConfigCommand {
|
||||||
|
/// Automatically upgrade legacy configuration values and ensure validity
|
||||||
|
Doctor,
|
||||||
|
/// Print the resolved configuration file path
|
||||||
|
Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
|
||||||
|
match cfg.mcp.mode {
|
||||||
|
McpMode::RemotePreferred => {
|
||||||
|
let remote_result = if let Some(mcp_server) = cfg.mcp_servers.first() {
|
||||||
|
RemoteMcpClient::new_with_config(mcp_server)
|
||||||
|
} else {
|
||||||
|
RemoteMcpClient::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
match remote_result {
|
||||||
|
Ok(client) => {
|
||||||
|
let provider: Arc<dyn Provider> = Arc::new(client);
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
Err(err) if cfg.mcp.allow_fallback => {
|
||||||
|
log::warn!(
|
||||||
|
"Remote MCP client unavailable ({}); falling back to local provider.",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
build_local_provider(cfg)
|
||||||
|
}
|
||||||
|
Err(err) => Err(anyhow::Error::from(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McpMode::RemoteOnly => {
|
||||||
|
let mcp_server = cfg.mcp_servers.first().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"[[mcp_servers]] must be configured when [mcp].mode = \"remote_only\""
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let client = RemoteMcpClient::new_with_config(mcp_server)?;
|
||||||
|
let provider: Arc<dyn Provider> = Arc::new(client);
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
McpMode::LocalOnly | McpMode::Legacy => build_local_provider(cfg),
|
||||||
|
McpMode::Disabled => Err(anyhow::anyhow!(
|
||||||
|
"MCP mode 'disabled' is not supported by the owlen TUI"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_local_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
|
||||||
|
let provider_name = cfg.general.default_provider.clone();
|
||||||
|
let provider_cfg = cfg.provider(&provider_name).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(format!(
|
||||||
|
"No provider configuration found for '{provider_name}' in [providers]"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match provider_cfg.provider_type.as_str() {
|
||||||
|
"ollama" | "ollama-cloud" => {
|
||||||
|
let provider = OllamaProvider::from_config(provider_cfg, Some(&cfg.general))?;
|
||||||
|
Ok(Arc::new(provider) as Arc<dyn Provider>)
|
||||||
|
}
|
||||||
|
other => Err(anyhow::anyhow!(format!(
|
||||||
|
"Provider type '{other}' is not supported in legacy/local MCP mode"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(command: OwlenCommand) -> Result<()> {
|
||||||
|
match command {
|
||||||
|
OwlenCommand::Config(config_cmd) => run_config_command(config_cmd),
|
||||||
|
OwlenCommand::Cloud(cloud_cmd) => cloud::run_cloud_command(cloud_cmd).await,
|
||||||
|
OwlenCommand::Upgrade => {
|
||||||
|
println!(
|
||||||
|
"To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"If you installed from the AUR, use your package manager (e.g., yay -S owlen-git)."
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_config_command(command: ConfigCommand) -> Result<()> {
|
||||||
|
match command {
|
||||||
|
ConfigCommand::Doctor => run_config_doctor(),
|
||||||
|
ConfigCommand::Path => {
|
||||||
|
let path = core_config::default_config_path();
|
||||||
|
println!("{}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_config_doctor() -> Result<()> {
|
||||||
|
let config_path = core_config::default_config_path();
|
||||||
|
let existed = config_path.exists();
|
||||||
let mut config = config::try_load_config().unwrap_or_default();
|
let mut config = config::try_load_config().unwrap_or_default();
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
|
||||||
if let Some(model) = matches.get_one::<String>("model") {
|
if !existed {
|
||||||
config.general.default_model = Some(model.clone());
|
changes.push("created configuration file from defaults".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare provider from configuration
|
if !config
|
||||||
let provider_cfg = config::ensure_ollama_config(&mut config).clone();
|
.providers
|
||||||
let provider = Arc::new(OllamaProvider::from_config(
|
.contains_key(&config.general.default_provider)
|
||||||
&provider_cfg,
|
{
|
||||||
Some(&config.general),
|
config.general.default_provider = "ollama".to_string();
|
||||||
)?);
|
changes.push("default provider missing; reset to 'ollama'".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let controller = SessionController::new(provider, config.clone());
|
if let Some(mut legacy) = config.providers.remove("ollama-cloud") {
|
||||||
let (mut app, mut session_rx) = ChatApp::new(controller);
|
legacy.provider_type = "ollama".to_string();
|
||||||
|
use std::collections::hash_map::Entry;
|
||||||
|
match config.providers.entry("ollama".to_string()) {
|
||||||
|
Entry::Occupied(mut existing) => {
|
||||||
|
let entry = existing.get_mut();
|
||||||
|
if entry.api_key.is_none() {
|
||||||
|
entry.api_key = legacy.api_key.take();
|
||||||
|
}
|
||||||
|
if entry.base_url.is_none() && legacy.base_url.is_some() {
|
||||||
|
entry.base_url = legacy.base_url.take();
|
||||||
|
}
|
||||||
|
entry.extra.extend(legacy.extra);
|
||||||
|
}
|
||||||
|
Entry::Vacant(slot) => {
|
||||||
|
slot.insert(legacy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes.push(
|
||||||
|
"migrated legacy 'ollama-cloud' provider into unified 'ollama' entry".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.providers.contains_key("ollama") {
|
||||||
|
core_config::ensure_provider_config(&mut config, "ollama");
|
||||||
|
changes.push("added default ollama provider configuration".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match config.mcp.mode {
|
||||||
|
McpMode::Legacy => {
|
||||||
|
config.mcp.mode = McpMode::LocalOnly;
|
||||||
|
config.mcp.warn_on_legacy = true;
|
||||||
|
changes.push("converted [mcp].mode = 'legacy' to 'local_only'".to_string());
|
||||||
|
}
|
||||||
|
McpMode::RemoteOnly if config.mcp_servers.is_empty() => {
|
||||||
|
config.mcp.mode = McpMode::RemotePreferred;
|
||||||
|
config.mcp.allow_fallback = true;
|
||||||
|
changes.push(
|
||||||
|
"downgraded remote-only configuration to remote_preferred because no servers are defined"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
McpMode::RemotePreferred if !config.mcp.allow_fallback && config.mcp_servers.is_empty() => {
|
||||||
|
config.mcp.allow_fallback = true;
|
||||||
|
changes.push(
|
||||||
|
"enabled [mcp].allow_fallback because no remote servers are configured".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.validate()?;
|
||||||
|
config::save_config(&config)?;
|
||||||
|
|
||||||
|
if changes.is_empty() {
|
||||||
|
println!(
|
||||||
|
"Configuration already up to date: {}",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Updated {}:", config_path.display());
|
||||||
|
for change in changes {
|
||||||
|
println!(" - {change}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASIC_THEME_NAME: &str = "ansi_basic";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum TerminalColorSupport {
|
||||||
|
Full,
|
||||||
|
Limited { term: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_terminal_color_support() -> TerminalColorSupport {
|
||||||
|
let term = std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string());
|
||||||
|
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
||||||
|
let term_lower = term.to_lowercase();
|
||||||
|
let color_lower = colorterm.to_lowercase();
|
||||||
|
|
||||||
|
let supports_extended = term_lower.contains("256color")
|
||||||
|
|| color_lower.contains("truecolor")
|
||||||
|
|| color_lower.contains("24bit")
|
||||||
|
|| color_lower.contains("fullcolor");
|
||||||
|
|
||||||
|
if supports_extended {
|
||||||
|
TerminalColorSupport::Full
|
||||||
|
} else {
|
||||||
|
TerminalColorSupport::Limited { term }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_terminal_theme(cfg: &mut Config, support: &TerminalColorSupport) -> Option<String> {
|
||||||
|
match support {
|
||||||
|
TerminalColorSupport::Full => None,
|
||||||
|
TerminalColorSupport::Limited { .. } => {
|
||||||
|
if cfg.ui.theme != BASIC_THEME_NAME {
|
||||||
|
let previous = std::mem::replace(&mut cfg.ui.theme, BASIC_THEME_NAME.to_string());
|
||||||
|
Some(previous)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OfflineProvider {
|
||||||
|
reason: String,
|
||||||
|
placeholder_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfflineProvider {
|
||||||
|
fn new(reason: String, placeholder_model: String) -> Self {
|
||||||
|
Self {
|
||||||
|
reason,
|
||||||
|
placeholder_model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn friendly_response(&self, requested_model: &str) -> ChatResponse {
|
||||||
|
let mut message = String::new();
|
||||||
|
message.push_str("⚠️ Owlen is running in offline mode.\n\n");
|
||||||
|
message.push_str(&self.reason);
|
||||||
|
if !requested_model.is_empty() && requested_model != self.placeholder_model {
|
||||||
|
message.push_str(&format!(
|
||||||
|
"\n\nYou requested model '{}', but no providers are reachable.",
|
||||||
|
requested_model
|
||||||
|
));
|
||||||
|
}
|
||||||
|
message.push_str(
|
||||||
|
"\n\nStart your preferred provider (e.g. `ollama serve`) or switch providers with `:provider` once connectivity is restored.",
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatResponse {
|
||||||
|
message: Message::assistant(message),
|
||||||
|
usage: None,
|
||||||
|
is_streaming: false,
|
||||||
|
is_final: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for OfflineProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelInfo>, Error> {
|
||||||
|
Ok(vec![ModelInfo {
|
||||||
|
id: self.placeholder_model.clone(),
|
||||||
|
provider: "offline".to_string(),
|
||||||
|
name: format!("Offline (fallback: {})", self.placeholder_model),
|
||||||
|
description: Some("Placeholder model used while no providers are reachable".into()),
|
||||||
|
context_window: None,
|
||||||
|
capabilities: vec![],
|
||||||
|
supports_tools: false,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_prompt(&self, request: ChatRequest) -> Result<ChatResponse, Error> {
|
||||||
|
Ok(self.friendly_response(&request.model))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_prompt(&self, request: ChatRequest) -> Result<ChatStream, Error> {
|
||||||
|
let response = self.friendly_response(&request.model);
|
||||||
|
Ok(Box::pin(stream::iter(vec![Ok(response)])))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check(&self) -> Result<(), Error> {
|
||||||
|
Err(Error::Provider(anyhow!(
|
||||||
|
"offline provider cannot reach any backing models"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// Parse command-line arguments
|
||||||
|
let Args { code, command } = Args::parse();
|
||||||
|
if let Some(command) = command {
|
||||||
|
return run_command(command).await;
|
||||||
|
}
|
||||||
|
let initial_mode = if code { Mode::Code } else { Mode::Chat };
|
||||||
|
|
||||||
|
// Set auto-consent for TUI mode to prevent blocking stdin reads
|
||||||
|
set_env_var("OWLEN_AUTO_CONSENT", "1");
|
||||||
|
|
||||||
|
let color_support = detect_terminal_color_support();
|
||||||
|
// Load configuration (or fall back to defaults) for the session controller.
|
||||||
|
let mut cfg = config::try_load_config().unwrap_or_default();
|
||||||
|
if let Some(previous_theme) = apply_terminal_theme(&mut cfg, &color_support) {
|
||||||
|
let term_label = match &color_support {
|
||||||
|
TerminalColorSupport::Limited { term } => Cow::from(term.as_str()),
|
||||||
|
TerminalColorSupport::Full => Cow::from("current terminal"),
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"Terminal '{}' lacks full 256-color support. Using '{}' theme instead of '{}'.",
|
||||||
|
term_label, BASIC_THEME_NAME, previous_theme
|
||||||
|
);
|
||||||
|
} else if let TerminalColorSupport::Limited { term } = &color_support {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: terminal '{}' may not fully support 256-color themes.",
|
||||||
|
term
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cfg.validate()?;
|
||||||
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
|
load_runtime_credentials(&mut cfg, storage.clone()).await?;
|
||||||
|
|
||||||
|
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
|
||||||
|
let tui_controller = Arc::new(TuiController::new(tui_tx));
|
||||||
|
|
||||||
|
// Create provider according to MCP configuration (supports legacy/local fallback)
|
||||||
|
let provider = build_provider(&cfg)?;
|
||||||
|
let mut offline_notice: Option<String> = None;
|
||||||
|
let provider = match provider.health_check().await {
|
||||||
|
Ok(_) => provider,
|
||||||
|
Err(err) => {
|
||||||
|
let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly)
|
||||||
|
&& !cfg.mcp_servers.is_empty()
|
||||||
|
{
|
||||||
|
"Ensure the configured MCP server is running and reachable."
|
||||||
|
} else {
|
||||||
|
"Ensure Ollama is running (`ollama serve`) and reachable at the configured base_url."
|
||||||
|
};
|
||||||
|
let notice =
|
||||||
|
format!("Provider health check failed: {err}. {hint} Continuing in offline mode.");
|
||||||
|
eprintln!("{notice}");
|
||||||
|
offline_notice = Some(notice.clone());
|
||||||
|
let fallback_model = cfg
|
||||||
|
.general
|
||||||
|
.default_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "offline".to_string());
|
||||||
|
Arc::new(OfflineProvider::new(notice, fallback_model)) as Arc<dyn Provider>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller =
|
||||||
|
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
||||||
|
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
||||||
app.initialize_models().await?;
|
app.initialize_models().await?;
|
||||||
|
if let Some(notice) = offline_notice {
|
||||||
|
app.set_status_message(¬ice);
|
||||||
|
app.set_system_status(notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the initial mode
|
||||||
|
app.set_mode(initial_mode).await;
|
||||||
|
|
||||||
// Event infrastructure
|
// Event infrastructure
|
||||||
let cancellation_token = CancellationToken::new();
|
let cancellation_token = CancellationToken::new();
|
||||||
@@ -73,7 +454,7 @@ async fn main() -> Result<()> {
|
|||||||
event_handle.await?;
|
event_handle.await?;
|
||||||
|
|
||||||
// Persist configuration updates (e.g., selected model)
|
// Persist configuration updates (e.g., selected model)
|
||||||
config::save_config(app.config())?;
|
config::save_config(&app.config())?;
|
||||||
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
@@ -97,14 +478,43 @@ async fn run_app(
|
|||||||
mut event_rx: mpsc::UnboundedReceiver<Event>,
|
mut event_rx: mpsc::UnboundedReceiver<Event>,
|
||||||
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let stream_draw_interval = tokio::time::Duration::from_millis(50);
|
||||||
|
let idle_tick = tokio::time::Duration::from_millis(100);
|
||||||
|
let mut last_draw = tokio::time::Instant::now() - stream_draw_interval;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Advance loading animation frame
|
// Advance loading animation frame
|
||||||
app.advance_loading_animation();
|
app.advance_loading_animation();
|
||||||
|
|
||||||
terminal.draw(|f| ui::render_chat(f, app))?;
|
let streaming_active = app.streaming_count() > 0;
|
||||||
|
let draw_due = if streaming_active {
|
||||||
|
last_draw.elapsed() >= stream_draw_interval
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
if draw_due {
|
||||||
|
terminal.draw(|f| ui::render_chat(f, app))?;
|
||||||
|
last_draw = tokio::time::Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
// Process any pending LLM requests AFTER UI has been drawn
|
// Process any pending LLM requests AFTER UI has been drawn
|
||||||
app.process_pending_llm_request().await?;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sleep_duration = if streaming_active {
|
||||||
|
stream_draw_interval
|
||||||
|
.checked_sub(last_draw.elapsed())
|
||||||
|
.unwrap_or_else(|| tokio::time::Duration::from_millis(0))
|
||||||
|
} else {
|
||||||
|
idle_tick
|
||||||
|
};
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
Some(event) = event_rx.recv() => {
|
Some(event) = event_rx.recv() => {
|
||||||
@@ -115,10 +525,7 @@ async fn run_app(
|
|||||||
Some(session_event) = session_rx.recv() => {
|
Some(session_event) = session_rx.recv() => {
|
||||||
app.handle_session_event(session_event)?;
|
app.handle_session_event(session_event)?;
|
||||||
}
|
}
|
||||||
// Add a timeout to keep the animation going even when there are no events
|
_ = tokio::time::sleep(sleep_duration) => {}
|
||||||
_ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
|
|
||||||
// This will cause the loop to continue and advance the animation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
266
crates/owlen-cli/tests/agent_tests.rs
Normal file
266
crates/owlen-cli/tests/agent_tests.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
//! 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\nFINAL_ANSWER: 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, 15);
|
||||||
|
assert_eq!(config.model, "llama3.2:latest");
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -9,20 +9,44 @@ homepage.workspace = true
|
|||||||
description = "Core traits and types for OWLEN LLM client"
|
description = "Core traits and types for OWLEN LLM client"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.75"
|
anyhow = { workspace = true }
|
||||||
log = "0.4.20"
|
log = { workspace = true }
|
||||||
serde = { version = "1.0.188", features = ["derive"] }
|
regex = { workspace = true }
|
||||||
serde_json = "1.0.105"
|
serde = { workspace = true }
|
||||||
thiserror = "1.0.48"
|
serde_json = { workspace = true }
|
||||||
tokio = { version = "1.32.0", features = ["full"] }
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
unicode-segmentation = "1.11"
|
unicode-segmentation = "1.11"
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
uuid = { version = "1.4.1", features = ["v4", "serde"] }
|
uuid = { workspace = true }
|
||||||
textwrap = "0.16.0"
|
textwrap = { workspace = true }
|
||||||
futures = "0.3.28"
|
futures = { workspace = true }
|
||||||
async-trait = "0.1.73"
|
futures-util = { workspace = true }
|
||||||
toml = "0.8.0"
|
async-trait = { workspace = true }
|
||||||
shellexpand = "3.1.0"
|
toml = { workspace = true }
|
||||||
|
shellexpand = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
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 = { workspace = true }
|
||||||
|
tokio-tungstenite = "0.21"
|
||||||
|
tungstenite = "0.21"
|
||||||
|
ollama-rs = { version = "0.3", features = ["stream", "headers"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
|
|||||||
12
crates/owlen-core/README.md
Normal file
12
crates/owlen-core/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Owlen Core
|
||||||
|
|
||||||
|
This crate provides the core abstractions and data structures for the Owlen ecosystem.
|
||||||
|
|
||||||
|
It defines the essential traits and types that enable communication with various LLM providers, manage sessions, and handle configuration.
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
- **`Provider` trait**: The fundamental abstraction for all LLM providers. Implement this trait to add support for a new provider.
|
||||||
|
- **`Session`**: Represents a single conversation, managing message history and context.
|
||||||
|
- **`Model`**: Defines the structure for LLM models, including their names and properties.
|
||||||
|
- **Configuration**: Handles loading and parsing of the application's configuration.
|
||||||
12
crates/owlen-core/migrations/0001_create_conversations.sql
Normal file
12
crates/owlen-core/migrations/0001_create_conversations.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
421
crates/owlen-core/src/agent.rs
Normal file
421
crates/owlen-core/src/agent.rs
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
//! 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::Provider;
|
||||||
|
use crate::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
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.send_prompt(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::llm::test_utils::MockProvider;
|
||||||
|
use crate::mcp::test_utils::MockMcpClient;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tool_call() {
|
||||||
|
let executor = AgentExecutor {
|
||||||
|
llm_client: Arc::new(MockProvider::default()),
|
||||||
|
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::default()),
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
294
crates/owlen-core/src/consent.rs
Normal file
294
crates/owlen-core/src/consent.rs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
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")
|
||||||
|
&& 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)
|
||||||
|
&& existing.scope == ConsentScope::Permanent
|
||||||
|
{
|
||||||
|
return Ok(ConsentScope::Permanent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if granted for session
|
||||||
|
if let Some(existing) = self.session_records.get(tool_name)
|
||||||
|
&& 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::types::{Conversation, Message};
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
use crate::storage::StorageManager;
|
||||||
|
use crate::types::{Conversation, Message};
|
||||||
use serde_json::{Number, Value};
|
use serde_json::{Number, Value};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -47,8 +48,8 @@ impl ConversationManager {
|
|||||||
&self.active
|
&self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutable access to the active conversation (auto refreshing indexes afterwards)
|
/// Public mutable access to the active conversation
|
||||||
fn active_mut(&mut self) -> &mut Conversation {
|
pub fn active_mut(&mut self) -> &mut Conversation {
|
||||||
&mut self.active
|
&mut self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +213,53 @@ impl ConversationManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cancel_stream(&mut self, message_id: Uuid, notice: impl Into<String>) -> 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.content = notice.into();
|
||||||
|
message.timestamp = std::time::SystemTime::now();
|
||||||
|
message
|
||||||
|
.metadata
|
||||||
|
.insert(STREAMING_FLAG.to_string(), Value::Bool(false));
|
||||||
|
message.metadata.remove(PLACEHOLDER_FLAG);
|
||||||
|
let millis = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
message.metadata.insert(
|
||||||
|
LAST_CHUNK_TS.to_string(),
|
||||||
|
Value::Number(Number::from(millis)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.streaming.remove(&message_id);
|
||||||
|
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)
|
/// Update the active model (used when user changes model mid session)
|
||||||
pub fn set_model(&mut self, model: impl Into<String>) {
|
pub fn set_model(&mut self, model: impl Into<String>) {
|
||||||
self.active.model = model.into();
|
self.active.model = model.into();
|
||||||
@@ -264,6 +312,43 @@ impl ConversationManager {
|
|||||||
fn stream_reset(&mut self) {
|
fn stream_reset(&mut self) {
|
||||||
self.streaming.clear();
|
self.streaming.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the active conversation to disk with a description
|
||||||
|
pub async 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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?;
|
||||||
|
self.load(conversation);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all saved sessions
|
||||||
|
pub async fn list_saved_sessions(
|
||||||
|
storage: &StorageManager,
|
||||||
|
) -> Result<Vec<crate::storage::SessionMeta>> {
|
||||||
|
storage.list_sessions().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamingMetadata {
|
impl StreamingMetadata {
|
||||||
|
|||||||
71
crates/owlen-core/src/credentials.rs
Normal file
71
crates/owlen-core/src/credentials.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{Error, Result, storage::StorageManager};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct ApiCredentials {
|
||||||
|
pub api_key: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const OLLAMA_CLOUD_CREDENTIAL_ID: &str = "provider_ollama_cloud";
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
241
crates/owlen-core/src/encryption.rs
Normal file
241
crates/owlen-core/src/encryption.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use aes_gcm::{
|
||||||
|
Aes256Gcm, Nonce,
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
use crate::types::Message;
|
use crate::types::Message;
|
||||||
|
use crate::ui::RoleLabelDisplay;
|
||||||
|
|
||||||
/// Formats messages for display across different clients.
|
/// Formats messages for display across different clients.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MessageFormatter {
|
pub struct MessageFormatter {
|
||||||
wrap_width: usize,
|
wrap_width: usize,
|
||||||
show_role_labels: bool,
|
role_label_mode: RoleLabelDisplay,
|
||||||
preserve_empty_lines: bool,
|
preserve_empty_lines: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageFormatter {
|
impl MessageFormatter {
|
||||||
/// Create a new formatter
|
/// Create a new formatter
|
||||||
pub fn new(wrap_width: usize, show_role_labels: bool) -> Self {
|
pub fn new(wrap_width: usize, role_label_mode: RoleLabelDisplay) -> Self {
|
||||||
Self {
|
Self {
|
||||||
wrap_width: wrap_width.max(20),
|
wrap_width: wrap_width.max(20),
|
||||||
show_role_labels,
|
role_label_mode,
|
||||||
preserve_empty_lines: false,
|
preserve_empty_lines: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,9 +30,19 @@ impl MessageFormatter {
|
|||||||
self.wrap_width = width.max(20);
|
self.wrap_width = width.max(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether role labels should be shown alongside messages
|
/// The configured role label layout preference.
|
||||||
|
pub fn role_label_mode(&self) -> RoleLabelDisplay {
|
||||||
|
self.role_label_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any role label should be shown alongside messages.
|
||||||
pub fn show_role_labels(&self) -> bool {
|
pub fn show_role_labels(&self) -> bool {
|
||||||
self.show_role_labels
|
!matches!(self.role_label_mode, RoleLabelDisplay::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the role label layout preference.
|
||||||
|
pub fn set_role_label_mode(&mut self, mode: RoleLabelDisplay) {
|
||||||
|
self.role_label_mode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn format_message(&self, message: &Message) -> Vec<String> {
|
pub fn format_message(&self, message: &Message) -> Vec<String> {
|
||||||
@@ -91,6 +102,11 @@ impl MessageFormatter {
|
|||||||
Some(thinking)
|
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)
|
(result, thinking_result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,12 @@ impl InputBuffer {
|
|||||||
self.history.pop_back();
|
self.history.pop_back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear saved input history entries.
|
||||||
|
pub fn clear_history(&mut self) {
|
||||||
|
self.history.clear();
|
||||||
|
self.history_index = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prev_char_boundary(buffer: &str, cursor: usize) -> usize {
|
fn prev_char_boundary(buffer: &str, cursor: usize) -> usize {
|
||||||
|
|||||||
@@ -3,26 +3,57 @@
|
|||||||
//! This crate provides the foundational abstractions for building
|
//! This crate provides the foundational abstractions for building
|
||||||
//! LLM providers, routers, and MCP (Model Context Protocol) adapters.
|
//! LLM providers, routers, and MCP (Model Context Protocol) adapters.
|
||||||
|
|
||||||
|
pub mod agent;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod consent;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
|
pub mod credentials;
|
||||||
|
pub mod encryption;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod llm;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod mode;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod provider;
|
pub mod providers;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod state;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod tools;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod validation;
|
||||||
pub mod wrap_cursor;
|
pub mod wrap_cursor;
|
||||||
|
|
||||||
|
pub use agent::*;
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
|
pub use consent::*;
|
||||||
pub use conversation::*;
|
pub use conversation::*;
|
||||||
|
pub use credentials::*;
|
||||||
|
pub use encryption::*;
|
||||||
pub use formatting::*;
|
pub use formatting::*;
|
||||||
pub use input::*;
|
pub use input::*;
|
||||||
|
// Export MCP types but exclude test_utils to avoid ambiguity
|
||||||
|
pub use llm::{
|
||||||
|
ChatStream, LlmProvider, Provider, ProviderConfig, ProviderRegistry, send_via_stream,
|
||||||
|
};
|
||||||
|
pub use mcp::{
|
||||||
|
LocalMcpClient, McpServer, McpToolCall, McpToolDescriptor, McpToolResponse, client, factory,
|
||||||
|
failover, permission, protocol, remote_client,
|
||||||
|
};
|
||||||
|
pub use mode::*;
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
pub use provider::*;
|
pub use providers::*;
|
||||||
pub use router::*;
|
pub use router::*;
|
||||||
|
pub use sandbox::*;
|
||||||
pub use session::*;
|
pub use session::*;
|
||||||
|
pub use state::*;
|
||||||
|
pub use theme::*;
|
||||||
|
pub use tools::*;
|
||||||
|
pub use validation::*;
|
||||||
|
|
||||||
/// Result type used throughout the OWLEN ecosystem
|
/// Result type used throughout the OWLEN ecosystem
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
@@ -54,6 +85,18 @@ pub enum Error {
|
|||||||
#[error("Serialization error: {0}")]
|
#[error("Serialization error: {0}")]
|
||||||
Serialization(#[from] serde_json::Error),
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Storage error: {0}")]
|
||||||
|
Storage(String),
|
||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
#[error("Unknown error: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
|
|
||||||
|
#[error("Not implemented: {0}")]
|
||||||
|
NotImplemented(String),
|
||||||
|
|
||||||
|
#[error("Permission denied: {0}")]
|
||||||
|
PermissionDenied(String),
|
||||||
|
|
||||||
|
#[error("Agent execution error: {0}")]
|
||||||
|
Agent(String),
|
||||||
}
|
}
|
||||||
|
|||||||
297
crates/owlen-core/src/llm/mod.rs
Normal file
297
crates/owlen-core/src/llm/mod.rs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
//! LLM provider abstractions and registry.
|
||||||
|
//!
|
||||||
|
//! This module defines the provider trait hierarchy along with helpers that
|
||||||
|
//! make it easy to register concrete LLM backends and access them through
|
||||||
|
//! dynamic dispatch when wiring the application together.
|
||||||
|
|
||||||
|
use crate::{Error, Result, types::*};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use futures::{Stream, StreamExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::any::Any;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// A boxed stream of chat responses produced by a provider.
|
||||||
|
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
||||||
|
|
||||||
|
/// Trait implemented by every LLM backend Owlen can speak to.
|
||||||
|
///
|
||||||
|
/// Providers expose both one-shot and streaming prompt APIs. Concrete
|
||||||
|
/// implementations typically live in `crate::providers`.
|
||||||
|
pub trait LlmProvider: Send + Sync + 'static + Any + Sized {
|
||||||
|
/// Stream type returned by [`Self::stream_prompt`].
|
||||||
|
type Stream: Stream<Item = Result<ChatResponse>> + Send + 'static;
|
||||||
|
|
||||||
|
type ListModelsFuture<'a>: Future<Output = Result<Vec<ModelInfo>>> + Send
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type SendPromptFuture<'a>: Future<Output = Result<ChatResponse>> + Send
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type StreamPromptFuture<'a>: Future<Output = Result<Self::Stream>> + Send
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type HealthCheckFuture<'a>: Future<Output = Result<()>> + Send
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
/// Human-readable provider identifier.
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Return metadata on all models exposed by this provider.
|
||||||
|
fn list_models(&self) -> Self::ListModelsFuture<'_>;
|
||||||
|
|
||||||
|
/// Issue a prompt and wait for the provider to return the full response.
|
||||||
|
fn send_prompt(&self, request: ChatRequest) -> Self::SendPromptFuture<'_>;
|
||||||
|
|
||||||
|
/// Issue a prompt and receive responses incrementally as a stream.
|
||||||
|
fn stream_prompt(&self, request: ChatRequest) -> Self::StreamPromptFuture<'_>;
|
||||||
|
|
||||||
|
/// Perform a lightweight health check.
|
||||||
|
fn health_check(&self) -> Self::HealthCheckFuture<'_>;
|
||||||
|
|
||||||
|
/// Provider-specific configuration schema (optional).
|
||||||
|
fn config_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the provider as an `Any` for downcasting.
|
||||||
|
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper that requests a streamed generation and yields the first chunk as a
|
||||||
|
/// regular response. This is handy for providers that only implement the
|
||||||
|
/// streaming API.
|
||||||
|
pub async fn send_via_stream<'a, P>(provider: &'a P, request: ChatRequest) -> Result<ChatResponse>
|
||||||
|
where
|
||||||
|
P: LlmProvider + 'a,
|
||||||
|
{
|
||||||
|
let stream = provider.stream_prompt(request).await?;
|
||||||
|
let mut boxed: ChatStream = Box::pin(stream);
|
||||||
|
match boxed.next().await {
|
||||||
|
Some(Ok(response)) => Ok(response),
|
||||||
|
Some(Err(err)) => Err(err),
|
||||||
|
None => Err(Error::Provider(anyhow!(
|
||||||
|
"Empty chat stream from provider {}",
|
||||||
|
provider.name()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Object-safe wrapper around [`LlmProvider`] for dynamic dispatch scenarios.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait Provider: Send + Sync {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelInfo>>;
|
||||||
|
|
||||||
|
async fn send_prompt(&self, request: ChatRequest) -> Result<ChatResponse>;
|
||||||
|
|
||||||
|
async fn stream_prompt(&self, request: ChatRequest) -> Result<ChatStream>;
|
||||||
|
|
||||||
|
async fn health_check(&self) -> Result<()>;
|
||||||
|
|
||||||
|
fn config_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &(dyn Any + Send + Sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<T> Provider for T
|
||||||
|
where
|
||||||
|
T: LlmProvider,
|
||||||
|
{
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
LlmProvider::name(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||||
|
LlmProvider::list_models(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_prompt(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||||
|
LlmProvider::send_prompt(self, request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_prompt(&self, request: ChatRequest) -> Result<ChatStream> {
|
||||||
|
let stream = LlmProvider::stream_prompt(self, request).await?;
|
||||||
|
Ok(Box::pin(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check(&self) -> Result<()> {
|
||||||
|
LlmProvider::health_check(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_schema(&self) -> serde_json::Value {
|
||||||
|
LlmProvider::config_schema(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||||
|
LlmProvider::as_any(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime configuration for a provider instance.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
/// Provider type identifier.
|
||||||
|
pub provider_type: String,
|
||||||
|
/// Base URL for API calls.
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
/// API key or token material.
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// Additional provider-specific configuration.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static registry of providers available to the application.
|
||||||
|
pub struct ProviderRegistry {
|
||||||
|
providers: HashMap<String, Arc<dyn Provider>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
providers: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register<P: LlmProvider + 'static>(&mut self, provider: P) {
|
||||||
|
self.register_arc(Arc::new(provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_arc(&mut self, provider: Arc<dyn Provider>) {
|
||||||
|
let name = provider.name().to_string();
|
||||||
|
self.providers.insert(name, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, name: &str) -> Option<Arc<dyn Provider>> {
|
||||||
|
self.providers.get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_providers(&self) -> Vec<String> {
|
||||||
|
self.providers.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_all_models(&self) -> Result<Vec<ModelInfo>> {
|
||||||
|
let mut all_models = Vec::new();
|
||||||
|
|
||||||
|
for provider in self.providers.values() {
|
||||||
|
match provider.list_models().await {
|
||||||
|
Ok(mut models) => all_models.append(&mut models),
|
||||||
|
Err(_) => {
|
||||||
|
// Ignore failing providers and continue.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProviderRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test utilities for constructing mock providers.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test_utils {
|
||||||
|
use super::*;
|
||||||
|
use futures::stream;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// Simple provider stub that always returns the same response.
|
||||||
|
pub struct MockProvider {
|
||||||
|
name: String,
|
||||||
|
response: ChatResponse,
|
||||||
|
call_count: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockProvider {
|
||||||
|
pub fn new(name: impl Into<String>, response: ChatResponse) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
response,
|
||||||
|
call_count: AtomicUsize::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call_count(&self) -> usize {
|
||||||
|
self.call_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MockProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(
|
||||||
|
"mock-provider",
|
||||||
|
ChatResponse {
|
||||||
|
message: Message::assistant("mock response".to_string()),
|
||||||
|
usage: None,
|
||||||
|
is_streaming: false,
|
||||||
|
is_final: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmProvider for MockProvider {
|
||||||
|
type Stream = stream::Iter<std::vec::IntoIter<Result<ChatResponse>>>;
|
||||||
|
|
||||||
|
type ListModelsFuture<'a>
|
||||||
|
= futures::future::Ready<Result<Vec<ModelInfo>>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type SendPromptFuture<'a>
|
||||||
|
= futures::future::Ready<Result<ChatResponse>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type StreamPromptFuture<'a>
|
||||||
|
= futures::future::Ready<Result<Self::Stream>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type HealthCheckFuture<'a>
|
||||||
|
= futures::future::Ready<Result<()>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_models(&self) -> Self::ListModelsFuture<'_> {
|
||||||
|
futures::future::ready(Ok(vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_prompt(&self, _request: ChatRequest) -> Self::SendPromptFuture<'_> {
|
||||||
|
self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
futures::future::ready(Ok(self.response.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_prompt(&self, _request: ChatRequest) -> Self::StreamPromptFuture<'_> {
|
||||||
|
self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let response = self.response.clone();
|
||||||
|
futures::future::ready(Ok(stream::iter(vec![Ok(response)])))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health_check(&self) -> Self::HealthCheckFuture<'_> {
|
||||||
|
futures::future::ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
crates/owlen-core/src/mcp.rs
Normal file
187
crates/owlen-core/src/mcp.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
use crate::Result;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
use crate::tools::registry::ToolRegistry;
|
||||||
|
use crate::validation::SchemaValidator;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_mode(&self, mode: Mode) -> Result<()> {
|
||||||
|
self.server.set_mode(mode).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/owlen-core/src/mcp/client.rs
Normal file
21
crates/owlen-core/src/mcp/client.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
use crate::{Result, mode::Mode};
|
||||||
|
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>;
|
||||||
|
|
||||||
|
/// Update the server with the active operating mode.
|
||||||
|
async fn set_mode(&self, _mode: Mode) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the concrete implementation that supports stdio and HTTP transports.
|
||||||
|
pub use super::remote_client::RemoteMcpClient;
|
||||||
177
crates/owlen-core/src/mcp/factory.rs
Normal file
177
crates/owlen-core/src/mcp/factory.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/// 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::{LocalMcpClient, remote_client::RemoteMcpClient};
|
||||||
|
use crate::config::{Config, McpMode};
|
||||||
|
use crate::tools::registry::ToolRegistry;
|
||||||
|
use crate::validation::SchemaValidator;
|
||||||
|
use crate::{Error, Result};
|
||||||
|
use log::{info, warn};
|
||||||
|
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.
|
||||||
|
pub fn create(&self) -> Result<Box<dyn McpClient>> {
|
||||||
|
match self.config.mcp.mode {
|
||||||
|
McpMode::Disabled => Err(Error::Config(
|
||||||
|
"MCP mode is set to 'disabled'; tooling cannot function in this configuration."
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
McpMode::LocalOnly | McpMode::Legacy => {
|
||||||
|
if matches!(self.config.mcp.mode, McpMode::Legacy) {
|
||||||
|
warn!("Using deprecated MCP legacy mode; consider switching to 'local_only'.");
|
||||||
|
}
|
||||||
|
Ok(Box::new(LocalMcpClient::new(
|
||||||
|
self.registry.clone(),
|
||||||
|
self.validator.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
McpMode::RemoteOnly => {
|
||||||
|
let server_cfg = self.config.mcp_servers.first().ok_or_else(|| {
|
||||||
|
Error::Config(
|
||||||
|
"MCP mode 'remote_only' requires at least one entry in [[mcp_servers]]"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
RemoteMcpClient::new_with_config(server_cfg)
|
||||||
|
.map(|client| Box::new(client) as Box<dyn McpClient>)
|
||||||
|
.map_err(|e| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"Failed to start remote MCP client '{}': {e}",
|
||||||
|
server_cfg.name
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
McpMode::RemotePreferred => {
|
||||||
|
if let Some(server_cfg) = self.config.mcp_servers.first() {
|
||||||
|
match RemoteMcpClient::new_with_config(server_cfg) {
|
||||||
|
Ok(client) => {
|
||||||
|
info!(
|
||||||
|
"Connected to remote MCP server '{}' via {} transport.",
|
||||||
|
server_cfg.name, server_cfg.transport
|
||||||
|
);
|
||||||
|
Ok(Box::new(client) as Box<dyn McpClient>)
|
||||||
|
}
|
||||||
|
Err(e) if self.config.mcp.allow_fallback => {
|
||||||
|
warn!(
|
||||||
|
"Failed to start remote MCP client '{}': {}. Falling back to local tooling.",
|
||||||
|
server_cfg.name, e
|
||||||
|
);
|
||||||
|
Ok(Box::new(LocalMcpClient::new(
|
||||||
|
self.registry.clone(),
|
||||||
|
self.validator.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(Error::Config(format!(
|
||||||
|
"Failed to start remote MCP client '{}': {e}. To allow fallback, set [mcp].allow_fallback = true.",
|
||||||
|
server_cfg.name
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("No MCP servers configured; using local MCP tooling.");
|
||||||
|
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::*;
|
||||||
|
use crate::Error;
|
||||||
|
use crate::config::McpServerConfig;
|
||||||
|
|
||||||
|
fn build_factory(config: Config) -> McpClientFactory {
|
||||||
|
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());
|
||||||
|
|
||||||
|
McpClientFactory::new(Arc::new(config), registry, validator)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_creates_local_client_when_no_servers_configured() {
|
||||||
|
let config = Config::default();
|
||||||
|
|
||||||
|
let factory = build_factory(config);
|
||||||
|
|
||||||
|
// Should create without error and fall back to local client
|
||||||
|
let result = factory.create();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_only_without_servers_errors() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.mcp.mode = McpMode::RemoteOnly;
|
||||||
|
config.mcp_servers.clear();
|
||||||
|
|
||||||
|
let factory = build_factory(config);
|
||||||
|
let result = factory.create();
|
||||||
|
assert!(matches!(result, Err(Error::Config(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_preferred_without_fallback_propagates_remote_error() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.mcp.mode = McpMode::RemotePreferred;
|
||||||
|
config.mcp.allow_fallback = false;
|
||||||
|
config.mcp_servers = vec![McpServerConfig {
|
||||||
|
name: "invalid".to_string(),
|
||||||
|
command: "nonexistent-mcp-server-binary".to_string(),
|
||||||
|
args: Vec::new(),
|
||||||
|
transport: "stdio".to_string(),
|
||||||
|
env: std::collections::HashMap::new(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let factory = build_factory(config);
|
||||||
|
let result = factory.create();
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(Error::Config(message)) if message.contains("Failed to start remote MCP client"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_mode_uses_local_client() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.mcp.mode = McpMode::Legacy;
|
||||||
|
|
||||||
|
let factory = build_factory(config);
|
||||||
|
let result = factory.create();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
322
crates/owlen-core/src/mcp/failover.rs
Normal file
322
crates/owlen-core/src/mcp/failover.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
crates/owlen-core/src/mcp/permission.rs
Normal file
221
crates/owlen-core/src/mcp/permission.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/// 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::{Error, Result};
|
||||||
|
use crate::{config::Config, mode::Mode};
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_mode(&self, mode: Mode) -> Result<()> {
|
||||||
|
self.inner.set_mode(mode).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
389
crates/owlen-core/src/mcp/protocol.rs
Normal file
389
crates/owlen-core/src/mcp/protocol.rs
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON‑RPC 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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
538
crates/owlen-core/src/mcp/remote_client.rs
Normal file
538
crates/owlen-core/src/mcp/remote_client.rs
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
use super::protocol::methods;
|
||||||
|
use super::protocol::{
|
||||||
|
PROTOCOL_VERSION, RequestId, RpcErrorResponse, RpcNotification, RpcRequest, RpcResponse,
|
||||||
|
};
|
||||||
|
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
use crate::consent::{ConsentManager, ConsentScope};
|
||||||
|
use crate::tools::{Tool, WebScrapeTool, WebSearchTool};
|
||||||
|
use crate::types::ModelInfo;
|
||||||
|
use crate::types::{ChatResponse, Message, Role};
|
||||||
|
use crate::{Error, LlmProvider, Result, mode::Mode, send_via_stream};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use futures::{StreamExt, future::BoxFuture, stream};
|
||||||
|
use reqwest::Client as HttpClient;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||||
|
use tungstenite::protocol::Message as WsMessage;
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
&& 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)
|
||||||
|
&& 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 path‑traversal 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" {
|
||||||
|
// Auto‑grant 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()
|
||||||
|
.map_err(|_| Error::Provider(anyhow!("Consent manager mutex poisoned")))?;
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_mode(&self, _mode: Mode) -> Result<()> {
|
||||||
|
// Remote servers manage their own mode settings; treat as best-effort no-op.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider implementation – forwards chat requests to the generate_text tool.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl LlmProvider for RemoteMcpClient {
|
||||||
|
type Stream = stream::Iter<std::vec::IntoIter<Result<ChatResponse>>>;
|
||||||
|
type ListModelsFuture<'a> = BoxFuture<'a, Result<Vec<ModelInfo>>>;
|
||||||
|
type SendPromptFuture<'a> = BoxFuture<'a, Result<ChatResponse>>;
|
||||||
|
type StreamPromptFuture<'a> = BoxFuture<'a, Result<Self::Stream>>;
|
||||||
|
type HealthCheckFuture<'a> = BoxFuture<'a, Result<()>>;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"mcp-llm-server"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_models(&self) -> Self::ListModelsFuture<'_> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?;
|
||||||
|
let models: Vec<ModelInfo> = serde_json::from_value(result)?;
|
||||||
|
Ok(models)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_prompt(&self, request: crate::types::ChatRequest) -> Self::SendPromptFuture<'_> {
|
||||||
|
Box::pin(send_via_stream(self, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_prompt(&self, request: crate::types::ChatRequest) -> Self::StreamPromptFuture<'_> {
|
||||||
|
Box::pin(async move {
|
||||||
|
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?;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
Ok(stream::iter(vec![Ok(chat_resp)]))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health_check(&self) -> Self::HealthCheckFuture<'_> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"protocol_version": PROTOCOL_VERSION,
|
||||||
|
"client_info": {
|
||||||
|
"name": "owlen",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
},
|
||||||
|
"capabilities": {}
|
||||||
|
});
|
||||||
|
self.send_rpc(methods::INITIALIZE, params).await.map(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
182
crates/owlen-core/src/mode.rs
Normal file
182
crates/owlen-core/src/mode.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
//! 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
use crate::types::ModelInfo;
|
pub mod details;
|
||||||
|
|
||||||
|
pub use details::{DetailedModelInfo, ModelInfoRetrievalError};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
use crate::types::ModelInfo;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -37,10 +42,8 @@ impl ModelManager {
|
|||||||
F: FnOnce() -> Fut,
|
F: FnOnce() -> Fut,
|
||||||
Fut: Future<Output = Result<Vec<ModelInfo>>>,
|
Fut: Future<Output = Result<Vec<ModelInfo>>>,
|
||||||
{
|
{
|
||||||
if !force_refresh {
|
if !force_refresh && let Some(models) = self.cached_if_fresh().await {
|
||||||
if let Some(models) = self.cached_if_fresh().await {
|
return Ok(models);
|
||||||
return Ok(models);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let models = fetcher().await?;
|
let models = fetcher().await?;
|
||||||
@@ -82,3 +85,125 @@ impl ModelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
struct ModelDetailsCacheInner {
|
||||||
|
by_key: HashMap<String, DetailedModelInfo>,
|
||||||
|
name_to_key: HashMap<String, String>,
|
||||||
|
fetched_at: HashMap<String, Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache for rich model details, indexed by digest when available.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ModelDetailsCache {
|
||||||
|
inner: Arc<RwLock<ModelDetailsCacheInner>>,
|
||||||
|
ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelDetailsCache {
|
||||||
|
/// Create a new details cache with the provided TTL.
|
||||||
|
pub fn new(ttl: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(ModelDetailsCacheInner::default())),
|
||||||
|
ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to read cached details for the provided model name.
|
||||||
|
pub async fn get(&self, name: &str) -> Option<DetailedModelInfo> {
|
||||||
|
let mut inner = self.inner.write().await;
|
||||||
|
let key = inner.name_to_key.get(name).cloned()?;
|
||||||
|
let stale = inner
|
||||||
|
.fetched_at
|
||||||
|
.get(&key)
|
||||||
|
.is_some_and(|ts| ts.elapsed() >= self.ttl);
|
||||||
|
if stale {
|
||||||
|
inner.by_key.remove(&key);
|
||||||
|
inner.name_to_key.remove(name);
|
||||||
|
inner.fetched_at.remove(&key);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
inner.by_key.get(&key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache the provided details, overwriting existing entries.
|
||||||
|
pub async fn insert(&self, info: DetailedModelInfo) {
|
||||||
|
let key = info.digest.clone().unwrap_or_else(|| info.name.clone());
|
||||||
|
let mut inner = self.inner.write().await;
|
||||||
|
|
||||||
|
// Remove prior mappings for this model name (possibly different digest).
|
||||||
|
if let Some(previous_key) = inner.name_to_key.get(&info.name).cloned()
|
||||||
|
&& previous_key != key
|
||||||
|
{
|
||||||
|
inner.by_key.remove(&previous_key);
|
||||||
|
inner.fetched_at.remove(&previous_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.fetched_at.insert(key.clone(), Instant::now());
|
||||||
|
inner.name_to_key.insert(info.name.clone(), key.clone());
|
||||||
|
inner.by_key.insert(key, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a specific model from the cache.
|
||||||
|
pub async fn invalidate(&self, name: &str) {
|
||||||
|
let mut inner = self.inner.write().await;
|
||||||
|
if let Some(key) = inner.name_to_key.remove(name) {
|
||||||
|
inner.by_key.remove(&key);
|
||||||
|
inner.fetched_at.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the entire cache.
|
||||||
|
pub async fn invalidate_all(&self) {
|
||||||
|
let mut inner = self.inner.write().await;
|
||||||
|
inner.by_key.clear();
|
||||||
|
inner.name_to_key.clear();
|
||||||
|
inner.fetched_at.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all cached values regardless of freshness.
|
||||||
|
pub async fn cached(&self) -> Vec<DetailedModelInfo> {
|
||||||
|
let inner = self.inner.read().await;
|
||||||
|
inner.by_key.values().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
fn sample_details(name: &str) -> DetailedModelInfo {
|
||||||
|
DetailedModelInfo {
|
||||||
|
name: name.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn model_details_cache_returns_cached_entry() {
|
||||||
|
let cache = ModelDetailsCache::new(Duration::from_millis(50));
|
||||||
|
let info = sample_details("llama");
|
||||||
|
cache.insert(info.clone()).await;
|
||||||
|
let cached = cache.get("llama").await;
|
||||||
|
assert!(cached.is_some());
|
||||||
|
assert_eq!(cached.unwrap().name, "llama");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn model_details_cache_expires_based_on_ttl() {
|
||||||
|
let cache = ModelDetailsCache::new(Duration::from_millis(10));
|
||||||
|
cache.insert(sample_details("phi")).await;
|
||||||
|
sleep(Duration::from_millis(30)).await;
|
||||||
|
assert!(cache.get("phi").await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn model_details_cache_invalidate_removes_entry() {
|
||||||
|
let cache = ModelDetailsCache::new(Duration::from_secs(1));
|
||||||
|
cache.insert(sample_details("mistral")).await;
|
||||||
|
cache.invalidate("mistral").await;
|
||||||
|
assert!(cache.get("mistral").await.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
105
crates/owlen-core/src/model/details.rs
Normal file
105
crates/owlen-core/src/model/details.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! Detailed model metadata for provider inspection features.
|
||||||
|
//!
|
||||||
|
//! These types capture richer information about locally available models
|
||||||
|
//! than the lightweight [`crate::types::ModelInfo`] listing and back the
|
||||||
|
//! higher-level inspection UI exposed in the Owlen TUI.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Rich metadata about an Ollama model.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct DetailedModelInfo {
|
||||||
|
/// Canonical model name (including tag).
|
||||||
|
pub name: String,
|
||||||
|
/// Reported architecture or model format.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub architecture: Option<String>,
|
||||||
|
/// Human-readable parameter / quantisation summary.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parameters: Option<String>,
|
||||||
|
/// Context window length, if provided.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_length: Option<u64>,
|
||||||
|
/// Embedding vector length for embedding-capable models.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub embedding_length: Option<u64>,
|
||||||
|
/// Quantisation level (e.g., Q4_0, Q5_K_M).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub quantization: Option<String>,
|
||||||
|
/// Primary family identifier (e.g., llama3).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub family: Option<String>,
|
||||||
|
/// Additional family tags reported by Ollama.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub families: Vec<String>,
|
||||||
|
/// Verbose parameter size description (e.g., 70B parameters).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parameter_size: Option<String>,
|
||||||
|
/// Default prompt template packaged with the model.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub template: Option<String>,
|
||||||
|
/// Default system prompt packaged with the model.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub system: Option<String>,
|
||||||
|
/// License string provided by the model.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub license: Option<String>,
|
||||||
|
/// Raw modelfile contents (if available).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub modelfile: Option<String>,
|
||||||
|
/// Modification timestamp (ISO-8601) if reported.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub modified_at: Option<String>,
|
||||||
|
/// Approximate model size in bytes.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
/// Digest / checksum used by Ollama (sha256).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub digest: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetailedModelInfo {
|
||||||
|
/// Convenience helper that normalises empty strings to `None`.
|
||||||
|
pub fn with_normalised_strings(mut self) -> Self {
|
||||||
|
if self.architecture.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.architecture = None;
|
||||||
|
}
|
||||||
|
if self.parameters.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.parameters = None;
|
||||||
|
}
|
||||||
|
if self.quantization.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.quantization = None;
|
||||||
|
}
|
||||||
|
if self.family.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.family = None;
|
||||||
|
}
|
||||||
|
if self.parameter_size.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.parameter_size = None;
|
||||||
|
}
|
||||||
|
if self.template.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.template = None;
|
||||||
|
}
|
||||||
|
if self.system.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.system = None;
|
||||||
|
}
|
||||||
|
if self.license.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.license = None;
|
||||||
|
}
|
||||||
|
if self.modelfile.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.modelfile = None;
|
||||||
|
}
|
||||||
|
if self.digest.as_ref().is_some_and(String::is_empty) {
|
||||||
|
self.digest = None;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error payload returned when model inspection fails for a specific model.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelInfoRetrievalError {
|
||||||
|
/// Model that failed to resolve.
|
||||||
|
pub model_name: String,
|
||||||
|
/// Human-readable description of the failure.
|
||||||
|
pub error_message: String,
|
||||||
|
}
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
//! Provider trait and related types
|
|
||||||
|
|
||||||
use crate::{types::*, Result};
|
|
||||||
use futures::Stream;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// A stream of chat responses
|
|
||||||
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
|
||||||
|
|
||||||
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.)
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
pub trait Provider: Send + Sync {
|
|
||||||
/// Get the name of this provider
|
|
||||||
fn name(&self) -> &str;
|
|
||||||
|
|
||||||
/// List available models from this provider
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>>;
|
|
||||||
|
|
||||||
/// Send a chat completion request
|
|
||||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse>;
|
|
||||||
|
|
||||||
/// Send a streaming chat completion request
|
|
||||||
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream>;
|
|
||||||
|
|
||||||
/// Check if the provider is available/healthy
|
|
||||||
async fn health_check(&self) -> Result<()>;
|
|
||||||
|
|
||||||
/// Get provider-specific configuration schema
|
|
||||||
fn config_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for a provider
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct ProviderConfig {
|
|
||||||
/// Provider type identifier
|
|
||||||
pub provider_type: String,
|
|
||||||
/// Base URL for API calls
|
|
||||||
pub base_url: Option<String>,
|
|
||||||
/// API key or token
|
|
||||||
pub api_key: Option<String>,
|
|
||||||
/// Additional provider-specific configuration
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: std::collections::HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A registry of providers
|
|
||||||
pub struct ProviderRegistry {
|
|
||||||
providers: std::collections::HashMap<String, Arc<dyn Provider>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderRegistry {
|
|
||||||
/// Create a new provider registry
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
providers: std::collections::HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a provider
|
|
||||||
pub fn register<P: Provider + 'static>(&mut self, provider: P) {
|
|
||||||
self.register_arc(Arc::new(provider));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register an already wrapped provider
|
|
||||||
pub fn register_arc(&mut self, provider: Arc<dyn Provider>) {
|
|
||||||
let name = provider.name().to_string();
|
|
||||||
self.providers.insert(name, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a provider by name
|
|
||||||
pub fn get(&self, name: &str) -> Option<Arc<dyn Provider>> {
|
|
||||||
self.providers.get(name).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all registered provider names
|
|
||||||
pub fn list_providers(&self) -> Vec<String> {
|
|
||||||
self.providers.keys().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all models from all providers
|
|
||||||
pub async fn list_all_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
let mut all_models = Vec::new();
|
|
||||||
|
|
||||||
for provider in self.providers.values() {
|
|
||||||
match provider.list_models().await {
|
|
||||||
Ok(mut models) => all_models.append(&mut models),
|
|
||||||
Err(e) => {
|
|
||||||
// Log error but continue with other providers
|
|
||||||
eprintln!("Failed to get models from {}: {}", provider.name(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(all_models)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ProviderRegistry {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
crates/owlen-core/src/providers/mod.rs
Normal file
8
crates/owlen-core/src/providers/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Built-in LLM provider implementations.
|
||||||
|
//!
|
||||||
|
//! Each provider integration lives in its own module so that maintenance
|
||||||
|
//! stays focused and configuration remains clear.
|
||||||
|
|
||||||
|
pub mod ollama;
|
||||||
|
|
||||||
|
pub use ollama::OllamaProvider;
|
||||||
1095
crates/owlen-core/src/providers/ollama.rs
Normal file
1095
crates/owlen-core/src/providers/ollama.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
//! Router for managing multiple providers and routing requests
|
//! Router for managing multiple providers and routing requests
|
||||||
|
|
||||||
use crate::{provider::*, types::*, Result};
|
use crate::{Result, llm::*, types::*};
|
||||||
|
use anyhow::anyhow;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// A router that can distribute requests across multiple providers
|
/// A router that can distribute requests across multiple providers
|
||||||
@@ -32,7 +33,7 @@ impl Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register a provider with the router
|
/// Register a provider with the router
|
||||||
pub fn register_provider<P: Provider + 'static>(&mut self, provider: P) {
|
pub fn register_provider<P: LlmProvider + 'static>(&mut self, provider: P) {
|
||||||
self.registry.register(provider);
|
self.registry.register(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,13 +53,13 @@ impl Router {
|
|||||||
/// Route a request to the appropriate provider
|
/// Route a request to the appropriate provider
|
||||||
pub async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
pub async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||||
let provider = self.find_provider_for_model(&request.model)?;
|
let provider = self.find_provider_for_model(&request.model)?;
|
||||||
provider.chat(request).await
|
provider.send_prompt(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route a streaming request to the appropriate provider
|
/// Route a streaming request to the appropriate provider
|
||||||
pub async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
pub async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
||||||
let provider = self.find_provider_for_model(&request.model)?;
|
let provider = self.find_provider_for_model(&request.model)?;
|
||||||
provider.chat_stream(request).await
|
provider.stream_prompt(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all available models from all providers
|
/// List all available models from all providers
|
||||||
@@ -70,18 +71,18 @@ impl Router {
|
|||||||
fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> {
|
fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> {
|
||||||
// Check routing rules first
|
// Check routing rules first
|
||||||
for rule in &self.routing_rules {
|
for rule in &self.routing_rules {
|
||||||
if self.matches_pattern(&rule.model_pattern, model) {
|
if self.matches_pattern(&rule.model_pattern, model)
|
||||||
if let Some(provider) = self.registry.get(&rule.provider) {
|
&& let Some(provider) = self.registry.get(&rule.provider)
|
||||||
return Ok(provider);
|
{
|
||||||
}
|
return Ok(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to default provider
|
// Fall back to default provider
|
||||||
if let Some(default) = &self.default_provider {
|
if let Some(default) = &self.default_provider
|
||||||
if let Some(provider) = self.registry.get(default) {
|
&& let Some(provider) = self.registry.get(default)
|
||||||
return Ok(provider);
|
{
|
||||||
}
|
return Ok(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no default, try to find any provider that has this model
|
// If no default, try to find any provider that has this model
|
||||||
@@ -92,7 +93,7 @@ impl Router {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(crate::Error::Provider(anyhow::anyhow!(
|
Err(crate::Error::Provider(anyhow!(
|
||||||
"No provider found for model: {}",
|
"No provider found for model: {}",
|
||||||
model
|
model
|
||||||
)))
|
)))
|
||||||
|
|||||||
210
crates/owlen-core/src/sandbox.rs
Normal file
210
crates/owlen-core/src/sandbox.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
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()
|
||||||
|
&& let Some((major, rest)) = version_part.split_once('.')
|
||||||
|
&& let Some((minor, _patch)) = rest.split_once('.')
|
||||||
|
&& 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
199
crates/owlen-core/src/state/mod.rs
Normal file
199
crates/owlen-core/src/state/mod.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
//! Shared application state types used across TUI frontends.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// High-level application state reported by the UI loop.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AppState {
|
||||||
|
Running,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vim-style input modes supported by the TUI.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InputMode {
|
||||||
|
Normal,
|
||||||
|
Editing,
|
||||||
|
ProviderSelection,
|
||||||
|
ModelSelection,
|
||||||
|
Help,
|
||||||
|
Visual,
|
||||||
|
Command,
|
||||||
|
SessionBrowser,
|
||||||
|
ThemeBrowser,
|
||||||
|
RepoSearch,
|
||||||
|
SymbolSearch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InputMode {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let label = match self {
|
||||||
|
InputMode::Normal => "Normal",
|
||||||
|
InputMode::Editing => "Editing",
|
||||||
|
InputMode::ModelSelection => "Model",
|
||||||
|
InputMode::ProviderSelection => "Provider",
|
||||||
|
InputMode::Help => "Help",
|
||||||
|
InputMode::Visual => "Visual",
|
||||||
|
InputMode::Command => "Command",
|
||||||
|
InputMode::SessionBrowser => "Sessions",
|
||||||
|
InputMode::ThemeBrowser => "Themes",
|
||||||
|
InputMode::RepoSearch => "Search",
|
||||||
|
InputMode::SymbolSearch => "Symbols",
|
||||||
|
};
|
||||||
|
f.write_str(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents which panel is currently focused in the TUI layout.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FocusedPanel {
|
||||||
|
Files,
|
||||||
|
Chat,
|
||||||
|
Thinking,
|
||||||
|
Input,
|
||||||
|
Code,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-scroll state manager for scrollable panels.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AutoScroll {
|
||||||
|
pub scroll: usize,
|
||||||
|
pub content_len: usize,
|
||||||
|
pub stick_to_bottom: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AutoScroll {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
scroll: 0,
|
||||||
|
content_len: 0,
|
||||||
|
stick_to_bottom: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutoScroll {
|
||||||
|
/// Update scroll position based on viewport height.
|
||||||
|
pub fn on_viewport(&mut self, viewport_h: usize) {
|
||||||
|
let max = self.content_len.saturating_sub(viewport_h);
|
||||||
|
if self.stick_to_bottom {
|
||||||
|
self.scroll = max;
|
||||||
|
} else {
|
||||||
|
self.scroll = self.scroll.min(max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle user scroll input.
|
||||||
|
pub fn on_user_scroll(&mut self, delta: isize, viewport_h: usize) {
|
||||||
|
let max = self.content_len.saturating_sub(viewport_h) as isize;
|
||||||
|
let s = (self.scroll as isize + delta).clamp(0, max) as usize;
|
||||||
|
self.scroll = s;
|
||||||
|
self.stick_to_bottom = s as isize == max;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_half_page_down(&mut self, viewport_h: usize) {
|
||||||
|
let delta = (viewport_h / 2) as isize;
|
||||||
|
self.on_user_scroll(delta, viewport_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_half_page_up(&mut self, viewport_h: usize) {
|
||||||
|
let delta = -((viewport_h / 2) as isize);
|
||||||
|
self.on_user_scroll(delta, viewport_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_full_page_down(&mut self, viewport_h: usize) {
|
||||||
|
let delta = viewport_h as isize;
|
||||||
|
self.on_user_scroll(delta, viewport_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_full_page_up(&mut self, viewport_h: usize) {
|
||||||
|
let delta = -(viewport_h as isize);
|
||||||
|
self.on_user_scroll(delta, viewport_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jump_to_top(&mut self) {
|
||||||
|
self.scroll = 0;
|
||||||
|
self.stick_to_bottom = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jump_to_bottom(&mut self, viewport_h: usize) {
|
||||||
|
self.stick_to_bottom = true;
|
||||||
|
self.on_viewport(viewport_h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual selection state for text selection.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct VisualSelection {
|
||||||
|
pub start: Option<(usize, usize)>,
|
||||||
|
pub end: Option<(usize, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisualSelection {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_at(&mut self, pos: (usize, usize)) {
|
||||||
|
self.start = Some(pos);
|
||||||
|
self.end = Some(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extend_to(&mut self, pos: (usize, usize)) {
|
||||||
|
self.end = Some(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.start = None;
|
||||||
|
self.end = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.start.is_some() && self.end.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_normalized(&self) -> Option<((usize, usize), (usize, usize))> {
|
||||||
|
if let (Some(s), Some(e)) = (self.start, self.end) {
|
||||||
|
if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) {
|
||||||
|
Some((s, e))
|
||||||
|
} else {
|
||||||
|
Some((e, s))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cursor position helper for navigating scrollable content.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct CursorPosition {
|
||||||
|
pub row: usize,
|
||||||
|
pub col: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorPosition {
|
||||||
|
pub fn new(row: usize, col: usize) -> Self {
|
||||||
|
Self { row, col }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_up(&mut self, amount: usize) {
|
||||||
|
self.row = self.row.saturating_sub(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_down(&mut self, amount: usize, max: usize) {
|
||||||
|
self.row = (self.row + amount).min(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_left(&mut self, amount: usize) {
|
||||||
|
self.col = self.col.saturating_sub(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_right(&mut self, amount: usize, max: usize) {
|
||||||
|
self.col = (self.col + amount).min(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_tuple(&self) -> (usize, usize) {
|
||||||
|
(self.row, self.col)
|
||||||
|
}
|
||||||
|
}
|
||||||
559
crates/owlen-core/src/storage.rs
Normal file
559
crates/owlen-core/src/storage.rs
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
//! Session persistence and storage management backed by SQLite
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Metadata about a saved session
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionMeta {
|
||||||
|
/// Conversation ID
|
||||||
|
pub id: Uuid,
|
||||||
|
/// Optional session name
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Optional AI-generated description
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Number of messages in the conversation
|
||||||
|
pub message_count: usize,
|
||||||
|
/// Model used
|
||||||
|
pub model: String,
|
||||||
|
/// When the session was created
|
||||||
|
pub created_at: SystemTime,
|
||||||
|
/// When the session was last updated
|
||||||
|
pub updated_at: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage manager for persisting conversations in SQLite
|
||||||
|
pub struct StorageManager {
|
||||||
|
pool: Pool<Sqlite>,
|
||||||
|
database_path: 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 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()
|
||||||
|
&& !parent.exists()
|
||||||
|
{
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
Error::Storage(format!(
|
||||||
|
"Failed to create database directory {parent:?}: {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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !legacy_dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = fs::read_dir(&legacy_dir).map_err(|e| {
|
||||||
|
Error::Storage(format!("Failed to read legacy sessions directory: {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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if migrated > 0
|
||||||
|
&& let Err(err) = archive_legacy_directory(&legacy_dir)
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
"Warning: migrated sessions but failed to archive legacy directory: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::{Conversation, 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|
||||||
|
let conversation = sample_conversation();
|
||||||
|
storage
|
||||||
|
.save_conversation(&conversation, None)
|
||||||
|
.await
|
||||||
|
.expect("failed to save conversation");
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
1229
crates/owlen-core/src/theme.rs
Normal file
1229
crates/owlen-core/src/theme.rs
Normal file
File diff suppressed because it is too large
Load Diff
97
crates/owlen-core/src/tools.rs
Normal file
97
crates/owlen-core/src/tools.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//! Tool module aggregating built‑in 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 re‑exports 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::{Value, json};
|
||||||
|
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;
|
||||||
|
/// Human‑readable description for documentation.
|
||||||
|
fn description(&self) -> &'static str;
|
||||||
|
/// JSON‑Schema 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,
|
||||||
|
/// Human‑readable 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re‑export 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;
|
||||||
148
crates/owlen-core/src/tools/code_exec.rs
Normal file
148
crates/owlen-core/src/tools/code_exec.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use anyhow::{Context, anyhow};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
198
crates/owlen-core/src/tools/fs_tools.rs
Normal file
198
crates/owlen-core/src/tools/fs_tools.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
crates/owlen-core/src/tools/registry.rs
Normal file
114
crates/owlen-core/src/tools/registry.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
102
crates/owlen-core/src/tools/web_scrape.rs
Normal file
102
crates/owlen-core/src/tools/web_scrape.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use super::{Tool, ToolResult};
|
||||||
|
use crate::Result;
|
||||||
|
use anyhow::Context;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// 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 per‑request 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": "Per‑request 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 })))
|
||||||
|
}
|
||||||
|
}
|
||||||
154
crates/owlen-core/src/tools/web_search.rs
Normal file
154
crates/owlen-core/src/tools/web_search.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use anyhow::Context;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/owlen-core/src/tools/web_search_detailed.rs
Normal file
133
crates/owlen-core/src/tools/web_search_detailed.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use anyhow::Context;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ pub struct Message {
|
|||||||
pub metadata: HashMap<String, serde_json::Value>,
|
pub metadata: HashMap<String, serde_json::Value>,
|
||||||
/// Timestamp when the message was created
|
/// Timestamp when the message was created
|
||||||
pub timestamp: std::time::SystemTime,
|
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
|
/// Role of a message sender
|
||||||
@@ -30,6 +33,19 @@ pub enum Role {
|
|||||||
Assistant,
|
Assistant,
|
||||||
/// System message (prompts, context, etc.)
|
/// System message (prompts, context, etc.)
|
||||||
System,
|
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 {
|
impl fmt::Display for Role {
|
||||||
@@ -38,6 +54,7 @@ impl fmt::Display for Role {
|
|||||||
Role::User => "user",
|
Role::User => "user",
|
||||||
Role::Assistant => "assistant",
|
Role::Assistant => "assistant",
|
||||||
Role::System => "system",
|
Role::System => "system",
|
||||||
|
Role::Tool => "tool",
|
||||||
};
|
};
|
||||||
f.write_str(label)
|
f.write_str(label)
|
||||||
}
|
}
|
||||||
@@ -50,6 +67,9 @@ pub struct Conversation {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
/// Optional name/title for the conversation
|
/// Optional name/title for the conversation
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
/// Optional AI-generated description of the conversation
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
/// Messages in chronological order
|
/// Messages in chronological order
|
||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
/// Model used for this conversation
|
/// Model used for this conversation
|
||||||
@@ -69,6 +89,9 @@ pub struct ChatRequest {
|
|||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
/// Optional parameters for the request
|
/// Optional parameters for the request
|
||||||
pub parameters: ChatParameters,
|
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
|
/// Parameters for chat completion
|
||||||
@@ -130,6 +153,9 @@ pub struct ModelInfo {
|
|||||||
pub context_window: Option<u32>,
|
pub context_window: Option<u32>,
|
||||||
/// Additional capabilities
|
/// Additional capabilities
|
||||||
pub capabilities: Vec<String>,
|
pub capabilities: Vec<String>,
|
||||||
|
/// Whether this model supports tool/function calling
|
||||||
|
#[serde(default)]
|
||||||
|
pub supports_tools: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
impl Message {
|
||||||
@@ -141,6 +167,7 @@ impl Message {
|
|||||||
content,
|
content,
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
timestamp: std::time::SystemTime::now(),
|
timestamp: std::time::SystemTime::now(),
|
||||||
|
tool_calls: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +185,24 @@ impl Message {
|
|||||||
pub fn system(content: String) -> Self {
|
pub fn system(content: String) -> Self {
|
||||||
Self::new(Role::System, content)
|
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 {
|
impl Conversation {
|
||||||
@@ -167,6 +212,7 @@ impl Conversation {
|
|||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: None,
|
name: None,
|
||||||
|
description: None,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
model,
|
model,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|||||||
@@ -3,166 +3,30 @@
|
|||||||
//! This module contains reusable UI components that can be shared between
|
//! This module contains reusable UI components that can be shared between
|
||||||
//! different TUI applications (chat, code, etc.)
|
//! different TUI applications (chat, code, etc.)
|
||||||
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Application state
|
/// Application state
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub use crate::state::AppState;
|
||||||
pub enum AppState {
|
|
||||||
Running,
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Input modes for TUI applications
|
/// Input modes for TUI applications
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub use crate::state::InputMode;
|
||||||
pub enum InputMode {
|
|
||||||
Normal,
|
|
||||||
Editing,
|
|
||||||
ProviderSelection,
|
|
||||||
ModelSelection,
|
|
||||||
Help,
|
|
||||||
Visual,
|
|
||||||
Command,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for InputMode {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let label = match self {
|
|
||||||
InputMode::Normal => "Normal",
|
|
||||||
InputMode::Editing => "Editing",
|
|
||||||
InputMode::ModelSelection => "Model",
|
|
||||||
InputMode::ProviderSelection => "Provider",
|
|
||||||
InputMode::Help => "Help",
|
|
||||||
InputMode::Visual => "Visual",
|
|
||||||
InputMode::Command => "Command",
|
|
||||||
};
|
|
||||||
f.write_str(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents which panel is currently focused
|
/// Represents which panel is currently focused
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub use crate::state::FocusedPanel;
|
||||||
pub enum FocusedPanel {
|
|
||||||
Chat,
|
|
||||||
Thinking,
|
|
||||||
Input,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-scroll state manager for scrollable panels
|
/// Auto-scroll state manager for scrollable panels
|
||||||
#[derive(Debug, Clone)]
|
pub use crate::state::AutoScroll;
|
||||||
pub struct AutoScroll {
|
|
||||||
pub scroll: usize,
|
|
||||||
pub content_len: usize,
|
|
||||||
pub stick_to_bottom: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AutoScroll {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
scroll: 0,
|
|
||||||
content_len: 0,
|
|
||||||
stick_to_bottom: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoScroll {
|
|
||||||
/// Update scroll position based on viewport height
|
|
||||||
pub fn on_viewport(&mut self, viewport_h: usize) {
|
|
||||||
let max = self.content_len.saturating_sub(viewport_h);
|
|
||||||
if self.stick_to_bottom {
|
|
||||||
self.scroll = max;
|
|
||||||
} else {
|
|
||||||
self.scroll = self.scroll.min(max);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle user scroll input
|
|
||||||
pub fn on_user_scroll(&mut self, delta: isize, viewport_h: usize) {
|
|
||||||
let max = self.content_len.saturating_sub(viewport_h) as isize;
|
|
||||||
let s = (self.scroll as isize + delta).clamp(0, max) as usize;
|
|
||||||
self.scroll = s;
|
|
||||||
self.stick_to_bottom = s as isize == max;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll down half page
|
|
||||||
pub fn scroll_half_page_down(&mut self, viewport_h: usize) {
|
|
||||||
let delta = (viewport_h / 2) as isize;
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll up half page
|
|
||||||
pub fn scroll_half_page_up(&mut self, viewport_h: usize) {
|
|
||||||
let delta = -((viewport_h / 2) as isize);
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll down full page
|
|
||||||
pub fn scroll_full_page_down(&mut self, viewport_h: usize) {
|
|
||||||
let delta = viewport_h as isize;
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll up full page
|
|
||||||
pub fn scroll_full_page_up(&mut self, viewport_h: usize) {
|
|
||||||
let delta = -(viewport_h as isize);
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump to top
|
|
||||||
pub fn jump_to_top(&mut self) {
|
|
||||||
self.scroll = 0;
|
|
||||||
self.stick_to_bottom = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump to bottom
|
|
||||||
pub fn jump_to_bottom(&mut self, viewport_h: usize) {
|
|
||||||
self.stick_to_bottom = true;
|
|
||||||
self.on_viewport(viewport_h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Visual selection state for text selection
|
/// Visual selection state for text selection
|
||||||
#[derive(Debug, Clone, Default)]
|
pub use crate::state::VisualSelection;
|
||||||
pub struct VisualSelection {
|
|
||||||
pub start: Option<(usize, usize)>, // (row, col)
|
|
||||||
pub end: Option<(usize, usize)>, // (row, col)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VisualSelection {
|
use serde::{Deserialize, Serialize};
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_at(&mut self, pos: (usize, usize)) {
|
/// How role labels should be rendered alongside chat messages.
|
||||||
self.start = Some(pos);
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
self.end = Some(pos);
|
#[serde(rename_all = "lowercase")]
|
||||||
}
|
pub enum RoleLabelDisplay {
|
||||||
|
Inline,
|
||||||
pub fn extend_to(&mut self, pos: (usize, usize)) {
|
Above,
|
||||||
self.end = Some(pos);
|
None,
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.start = None;
|
|
||||||
self.end = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_active(&self) -> bool {
|
|
||||||
self.start.is_some() && self.end.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_normalized(&self) -> Option<((usize, usize), (usize, usize))> {
|
|
||||||
if let (Some(s), Some(e)) = (self.start, self.end) {
|
|
||||||
// Normalize selection so start is always before end
|
|
||||||
if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) {
|
|
||||||
Some((s, e))
|
|
||||||
} else {
|
|
||||||
Some((e, s))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract text from a selection range in a list of lines
|
/// Extract text from a selection range in a list of lines
|
||||||
@@ -231,37 +95,7 @@ pub fn extract_text_from_selection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cursor position for navigating scrollable content
|
/// Cursor position for navigating scrollable content
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
pub use crate::state::CursorPosition;
|
||||||
pub struct CursorPosition {
|
|
||||||
pub row: usize,
|
|
||||||
pub col: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorPosition {
|
|
||||||
pub fn new(row: usize, col: usize) -> Self {
|
|
||||||
Self { row, col }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_up(&mut self, amount: usize) {
|
|
||||||
self.row = self.row.saturating_sub(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_down(&mut self, amount: usize, max: usize) {
|
|
||||||
self.row = (self.row + amount).min(max);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_left(&mut self, amount: usize) {
|
|
||||||
self.col = self.col.saturating_sub(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_right(&mut self, amount: usize, max: usize) {
|
|
||||||
self.col = (self.col + amount).min(max);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_tuple(&self) -> (usize, usize) {
|
|
||||||
(self.row, self.col)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Word boundary detection for navigation
|
/// Word boundary detection for navigation
|
||||||
pub fn find_next_word_boundary(line: &str, col: usize) -> Option<usize> {
|
pub fn find_next_word_boundary(line: &str, col: usize) -> Option<usize> {
|
||||||
@@ -347,14 +181,52 @@ pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
|
|||||||
Some(pos)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_auto_scroll() {
|
fn test_auto_scroll() {
|
||||||
let mut scroll = AutoScroll::default();
|
let mut scroll = AutoScroll {
|
||||||
scroll.content_len = 100;
|
content_len: 100,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
// Test on_viewport with stick_to_bottom
|
// Test on_viewport with stick_to_bottom
|
||||||
scroll.on_viewport(10);
|
scroll.on_viewport(10);
|
||||||
|
|||||||
108
crates/owlen-core/src/validation.rs
Normal file
108
crates/owlen-core/src/validation.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use jsonschema::{JSONSchema, ValidationError};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
99
crates/owlen-core/tests/consent_scope.rs
Normal file
99
crates/owlen-core/tests/consent_scope.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
52
crates/owlen-core/tests/file_server.rs
Normal file
52
crates/owlen-core/tests/file_server.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use owlen_core::McpToolCall;
|
||||||
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
|
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
|
||||||
|
}
|
||||||
67
crates/owlen-core/tests/file_write.rs
Normal file
67
crates/owlen-core/tests/file_write.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use owlen_core::McpToolCall;
|
||||||
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
110
crates/owlen-core/tests/mode_tool_filter.rs
Normal file
110
crates/owlen-core/tests/mode_tool_filter.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
//! Tests for mode‑based 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
|
||||||
|
//! allowed‑in‑chat and disallowed‑in‑any‑mode 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
311
crates/owlen-core/tests/phase9_remoting.rs
Normal file
311
crates/owlen-core/tests/phase9_remoting.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
//! 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::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
74
crates/owlen-core/tests/prompt_server.rs
Normal file
74
crates/owlen-core/tests/prompt_server.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//! Integration test for the MCP prompt rendering server.
|
||||||
|
|
||||||
|
use owlen_core::Result;
|
||||||
|
use owlen_core::config::McpServerConfig;
|
||||||
|
use owlen_core::mcp::client::RemoteMcpClient;
|
||||||
|
use owlen_core::mcp::{McpToolCall, McpToolResponse};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_render_prompt_via_external_server() -> Result<()> {
|
||||||
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let workspace_root = manifest_dir
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.expect("workspace root");
|
||||||
|
|
||||||
|
let candidates = [
|
||||||
|
workspace_root
|
||||||
|
.join("target")
|
||||||
|
.join("debug")
|
||||||
|
.join("owlen-mcp-prompt-server"),
|
||||||
|
workspace_root
|
||||||
|
.join("owlen-mcp-prompt-server")
|
||||||
|
.join("target")
|
||||||
|
.join("debug")
|
||||||
|
.join("owlen-mcp-prompt-server"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let binary = if let Some(path) = candidates.iter().find(|path| path.exists()) {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping prompt server integration test: binary not found. \
|
||||||
|
Build it with `cargo build -p owlen-mcp-prompt-server`. Tried {:?}",
|
||||||
|
candidates
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = match RemoteMcpClient::new_with_config(&config) {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping prompt server integration test: failed to launch {} ({err})",
|
||||||
|
config.command
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
use owlen_core::wrap_cursor::{build_cursor_map, ScreenPos};
|
use owlen_core::wrap_cursor::{ScreenPos, build_cursor_map};
|
||||||
|
|
||||||
fn assert_cursor_pos(map: &[ScreenPos], byte_idx: usize, expected: ScreenPos) {
|
fn assert_cursor_pos(map: &[ScreenPos], byte_idx: usize, expected: ScreenPos) {
|
||||||
assert_eq!(map[byte_idx], expected, "Mismatch at byte {}", byte_idx);
|
assert_eq!(map[byte_idx], expected, "Mismatch at byte {}", byte_idx);
|
||||||
|
|||||||
5
crates/owlen-gemini/README.md
Normal file
5
crates/owlen-gemini/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Owlen Gemini
|
||||||
|
|
||||||
|
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Google Gemini API.
|
||||||
|
|
||||||
|
This provider is not yet implemented. Contributions are welcome!
|
||||||
12
crates/owlen-mcp-client/Cargo.toml
Normal file
12
crates/owlen-mcp-client/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "owlen-mcp-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
description = "Dedicated MCP client library for Owlen, exposing remote MCP server communication"
|
||||||
|
license = "AGPL-3.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
16
crates/owlen-mcp-client/src/lib.rs
Normal file
16
crates/owlen-mcp-client/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//! Owlen MCP client library.
|
||||||
|
//!
|
||||||
|
//! This crate provides a thin façade over the remote MCP client implementation
|
||||||
|
//! inside `owlen-core`. It re‑exports 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};
|
||||||
|
|
||||||
|
// Re‑export the core Provider trait so that the MCP client can also be used as an LLM provider.
|
||||||
|
pub use owlen_core::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.
|
||||||
22
crates/owlen-mcp-code-server/Cargo.toml
Normal file
22
crates/owlen-mcp-code-server/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "owlen-mcp-code-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
description = "MCP server exposing safe code execution tools for Owlen"
|
||||||
|
license = "AGPL-3.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
bollard = "0.17"
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "owlen_mcp_code_server"
|
||||||
|
path = "src/lib.rs"
|
||||||
186
crates/owlen-mcp-code-server/src/lib.rs
Normal file
186
crates/owlen-mcp-code-server/src/lib.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
//! 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::{
|
||||||
|
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
|
||||||
|
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, methods,
|
||||||
|
};
|
||||||
|
use owlen_core::tools::{Tool, ToolResult};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_value(result).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to serialize initialize result: {}", e))
|
||||||
|
})?;
|
||||||
|
Ok(RpcResponse::new(req.id, payload))
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_value(resp).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to serialize tool response: {}", e))
|
||||||
|
})?;
|
||||||
|
Ok(RpcResponse::new(req.id, payload))
|
||||||
|
}
|
||||||
|
_ => Err(RpcError::method_not_found(&req.method)),
|
||||||
|
}
|
||||||
|
}
|
||||||
250
crates/owlen-mcp-code-server/src/sandbox.rs
Normal file
250
crates/owlen-mcp-code-server/src/sandbox.rs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
//! Docker-based sandboxing for secure code execution
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use bollard::Docker;
|
||||||
|
use bollard::container::{
|
||||||
|
Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
||||||
|
WaitContainerOptions,
|
||||||
|
};
|
||||||
|
use bollard::models::{HostConfig, Mount, MountTypeEnum};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
417
crates/owlen-mcp-code-server/src/tools.rs
Normal file
417
crates/owlen-mcp-code-server/src/tools.rs
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
//! Code execution tools using Docker sandboxing
|
||||||
|
|
||||||
|
use crate::sandbox::Sandbox;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use owlen_core::Result;
|
||||||
|
use owlen_core::tools::{Tool, ToolResult};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
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))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/owlen-mcp-llm-server/Cargo.toml
Normal file
16
crates/owlen-mcp-llm-server/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "owlen-mcp-llm-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tokio-stream = { workspace = true }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "owlen-mcp-llm-server"
|
||||||
|
path = "src/main.rs"
|
||||||
595
crates/owlen-mcp-llm-server/src/main.rs
Normal file
595
crates/owlen-mcp-llm-server/src/main.rs
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
#![allow(
|
||||||
|
unused_imports,
|
||||||
|
unused_variables,
|
||||||
|
dead_code,
|
||||||
|
clippy::unnecessary_cast,
|
||||||
|
clippy::manual_flatten,
|
||||||
|
clippy::empty_line_after_outer_attr
|
||||||
|
)]
|
||||||
|
|
||||||
|
use owlen_core::Provider;
|
||||||
|
use owlen_core::ProviderConfig;
|
||||||
|
use owlen_core::config::{Config as OwlenConfig, ensure_provider_config};
|
||||||
|
use owlen_core::mcp::protocol::{
|
||||||
|
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
|
||||||
|
RpcErrorResponse, RpcNotification, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo,
|
||||||
|
methods,
|
||||||
|
};
|
||||||
|
use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
use owlen_core::providers::OllamaProvider;
|
||||||
|
use owlen_core::types::{ChatParameters, ChatRequest, Message};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
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()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_from_config() -> Result<Arc<dyn Provider>, RpcError> {
|
||||||
|
let mut config = OwlenConfig::load(None).unwrap_or_default();
|
||||||
|
let requested_name =
|
||||||
|
env::var("OWLEN_PROVIDER").unwrap_or_else(|_| config.general.default_provider.clone());
|
||||||
|
let provider_key = canonical_provider_name(&requested_name);
|
||||||
|
if config.provider(&provider_key).is_none() {
|
||||||
|
ensure_provider_config(&mut config, &provider_key);
|
||||||
|
}
|
||||||
|
let provider_cfg: ProviderConfig =
|
||||||
|
config.provider(&provider_key).cloned().ok_or_else(|| {
|
||||||
|
RpcError::internal_error(format!(
|
||||||
|
"Provider '{provider_key}' not found in configuration"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match provider_cfg.provider_type.as_str() {
|
||||||
|
"ollama" | "ollama-cloud" => {
|
||||||
|
let provider = OllamaProvider::from_config(&provider_cfg, Some(&config.general))
|
||||||
|
.map_err(|e| {
|
||||||
|
RpcError::internal_error(format!(
|
||||||
|
"Failed to init Ollama provider from config: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Arc::new(provider) as Arc<dyn Provider>)
|
||||||
|
}
|
||||||
|
other => Err(RpcError::internal_error(format!(
|
||||||
|
"Unsupported provider type '{other}' for MCP LLM server"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_provider() -> Result<Arc<dyn Provider>, RpcError> {
|
||||||
|
if let Ok(url) = env::var("OLLAMA_URL") {
|
||||||
|
let provider = OllamaProvider::new(&url).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to init Ollama provider: {e}"))
|
||||||
|
})?;
|
||||||
|
return Ok(Arc::new(provider) as Arc<dyn Provider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
provider_from_config()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_provider_name(name: &str) -> String {
|
||||||
|
if name.eq_ignore_ascii_case("ollama-cloud") {
|
||||||
|
"ollama".to_string()
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_generate_text(args: GenerateTextArgs) -> Result<String, RpcError> {
|
||||||
|
let provider = create_provider()?;
|
||||||
|
|
||||||
|
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
|
||||||
|
.stream_prompt(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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
serde_json::to_value(result).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to serialize init result: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 => {
|
||||||
|
let provider = create_provider()?;
|
||||||
|
let models = provider
|
||||||
|
.list_models()
|
||||||
|
.await
|
||||||
|
.map_err(|e| RpcError::internal_error(format!("Failed to list models: {}", e)))?;
|
||||||
|
serde_json::to_value(models).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to serialize model list: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 payload = match serde_json::to_value(&response) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
let err_resp = RpcErrorResponse::new(
|
||||||
|
id.clone(),
|
||||||
|
RpcError::internal_error(format!(
|
||||||
|
"Failed to serialize resource response: {}",
|
||||||
|
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 final_resp = RpcResponse::new(id.clone(), payload);
|
||||||
|
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 payload = match serde_json::to_value(&response) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
let err_resp = RpcErrorResponse::new(
|
||||||
|
id.clone(),
|
||||||
|
RpcError::internal_error(format!(
|
||||||
|
"Failed to serialize directory listing: {}",
|
||||||
|
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 final_resp = RpcResponse::new(id.clone(), payload);
|
||||||
|
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 provider and start streaming
|
||||||
|
let provider = match create_provider() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
let err_resp = RpcErrorResponse::new(
|
||||||
|
id.clone(),
|
||||||
|
RpcError::internal_error(format!(
|
||||||
|
"Failed to initialize provider: {:?}",
|
||||||
|
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.stream_prompt(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(¬if)?;
|
||||||
|
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 payload = match serde_json::to_value(&response) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
let err_resp = RpcErrorResponse::new(
|
||||||
|
id.clone(),
|
||||||
|
RpcError::internal_error(format!(
|
||||||
|
"Failed to serialize final streaming response: {}",
|
||||||
|
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 final_resp = RpcResponse::new(id.clone(), payload);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Non‑streaming 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(())
|
||||||
|
}
|
||||||
21
crates/owlen-mcp-prompt-server/Cargo.toml
Normal file
21
crates/owlen-mcp-prompt-server/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "owlen-mcp-prompt-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
description = "MCP server that renders prompt templates (YAML) for Owlen"
|
||||||
|
license = "AGPL-3.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
serde_yaml = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
handlebars = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "owlen_mcp_prompt_server"
|
||||||
|
path = "src/lib.rs"
|
||||||
415
crates/owlen-mcp-prompt-server/src/lib.rs
Normal file
415
crates/owlen-mcp-prompt-server/src/lib.rs
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
//! 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::{Value, json};
|
||||||
|
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::{
|
||||||
|
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
|
||||||
|
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, methods,
|
||||||
|
};
|
||||||
|
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 = self.templates.blocking_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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_value(result).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to serialize initialize result: {}", e))
|
||||||
|
})?;
|
||||||
|
Ok(RpcResponse::new(req.id, payload))
|
||||||
|
}
|
||||||
|
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) => match serde_json::to_value(template) {
|
||||||
|
Ok(serialized) => {
|
||||||
|
json!({"success": true, "template": serialized})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(RpcError::internal_error(format!(
|
||||||
|
"Failed to serialize template '{}': {}",
|
||||||
|
name, e
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::to_value(resp).map_err(|e| {
|
||||||
|
RpcError::internal_error(format!("Failed to serialize tool response: {}", e))
|
||||||
|
})?;
|
||||||
|
Ok(RpcResponse::new(req.id, payload))
|
||||||
|
}
|
||||||
|
_ => Err(RpcError::method_not_found(&req.method)),
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/owlen-mcp-prompt-server/templates/example.yaml
Normal file
3
crates/owlen-mcp-prompt-server/templates/example.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
prompt: |
|
||||||
|
Hello {{name}}!
|
||||||
|
Your role is: {{role}}.
|
||||||
12
crates/owlen-mcp-server/Cargo.toml
Normal file
12
crates/owlen-mcp-server/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "owlen-mcp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
path-clean = "1.0"
|
||||||
|
owlen-core = { path = "../owlen-core" }
|
||||||
246
crates/owlen-mcp-server/src/main.rs
Normal file
246
crates/owlen-mcp-server/src/main.rs
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
use owlen_core::mcp::protocol::{
|
||||||
|
ErrorCode, InitializeParams, InitializeResult, PROTOCOL_VERSION, RequestId, RpcError,
|
||||||
|
RpcErrorResponse, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, is_compatible,
|
||||||
|
};
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "owlen-ollama"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
description = "Ollama provider for OWLEN LLM client"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
owlen-core = { path = "../owlen-core" }
|
|
||||||
|
|
||||||
# HTTP client
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
|
|
||||||
# Async runtime
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tokio-stream = { workspace = true }
|
|
||||||
futures = { workspace = true }
|
|
||||||
futures-util = { workspace = true }
|
|
||||||
|
|
||||||
# Serialization
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio-test = { workspace = true }
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
//! Ollama provider for OWLEN LLM client
|
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use owlen_core::{
|
|
||||||
config::GeneralSettings,
|
|
||||||
model::ModelManager,
|
|
||||||
provider::{ChatStream, Provider, ProviderConfig},
|
|
||||||
types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage},
|
|
||||||
Result,
|
|
||||||
};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
|
||||||
const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60;
|
|
||||||
|
|
||||||
/// Ollama provider implementation with enhanced configuration and caching
|
|
||||||
pub struct OllamaProvider {
|
|
||||||
client: Client,
|
|
||||||
base_url: String,
|
|
||||||
model_manager: ModelManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Options for configuring the Ollama provider
|
|
||||||
pub struct OllamaOptions {
|
|
||||||
pub base_url: String,
|
|
||||||
pub request_timeout: Duration,
|
|
||||||
pub model_cache_ttl: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OllamaOptions {
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_general(mut self, general: &GeneralSettings) -> Self {
|
|
||||||
self.model_cache_ttl = general.model_cache_ttl();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama-specific message format
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct OllamaMessage {
|
|
||||||
role: String,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama chat request format
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct OllamaChatRequest {
|
|
||||||
model: String,
|
|
||||||
messages: Vec<OllamaMessage>,
|
|
||||||
stream: bool,
|
|
||||||
#[serde(flatten)]
|
|
||||||
options: HashMap<String, Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama chat response format
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaChatResponse {
|
|
||||||
message: Option<OllamaMessage>,
|
|
||||||
done: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
prompt_eval_count: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
eval_count: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaErrorResponse {
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama models list response
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaModelsResponse {
|
|
||||||
models: Vec<OllamaModelInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama model information
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaModelInfo {
|
|
||||||
name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
details: Option<OllamaModelDetails>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaModelDetails {
|
|
||||||
#[serde(default)]
|
|
||||||
family: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OllamaProvider {
|
|
||||||
/// Create a new Ollama provider with sensible defaults
|
|
||||||
pub fn new(base_url: impl Into<String>) -> Result<Self> {
|
|
||||||
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 mut options = OllamaOptions::new(
|
|
||||||
config
|
|
||||||
.base_url
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "http://localhost:11434".to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(timeout) = config
|
|
||||||
.extra
|
|
||||||
.get("timeout_secs")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
{
|
|
||||||
options.request_timeout = Duration::from_secs(timeout.max(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cache_ttl) = config
|
|
||||||
.extra
|
|
||||||
.get("model_cache_ttl_secs")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
{
|
|
||||||
options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(general) = general {
|
|
||||||
options = options.with_general(general);
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::with_options(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a provider from explicit options
|
|
||||||
pub fn with_options(options: OllamaOptions) -> Result<Self> {
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(options.request_timeout)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| owlen_core::Error::Config(format!("Failed to build HTTP client: {e}")))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
client,
|
|
||||||
base_url: options.base_url.trim_end_matches('/').to_string(),
|
|
||||||
model_manager: ModelManager::new(options.model_cache_ttl),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accessor for the underlying model manager
|
|
||||||
pub fn model_manager(&self) -> &ModelManager {
|
|
||||||
&self.model_manager
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_message(message: &Message) -> OllamaMessage {
|
|
||||||
OllamaMessage {
|
|
||||||
role: match message.role {
|
|
||||||
Role::User => "user".to_string(),
|
|
||||||
Role::Assistant => "assistant".to_string(),
|
|
||||||
Role::System => "system".to_string(),
|
|
||||||
},
|
|
||||||
content: message.content.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_ollama_message(message: &OllamaMessage) -> Message {
|
|
||||||
let role = match message.role.as_str() {
|
|
||||||
"user" => Role::User,
|
|
||||||
"assistant" => Role::Assistant,
|
|
||||||
"system" => Role::System,
|
|
||||||
_ => Role::Assistant,
|
|
||||||
};
|
|
||||||
|
|
||||||
Message::new(role, message.content.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_options(parameters: ChatParameters) -> HashMap<String, Value> {
|
|
||||||
let mut options = parameters.extra;
|
|
||||||
|
|
||||||
if let Some(temperature) = parameters.temperature {
|
|
||||||
options
|
|
||||||
.entry("temperature".to_string())
|
|
||||||
.or_insert(json!(temperature as f64));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(max_tokens) = parameters.max_tokens {
|
|
||||||
options
|
|
||||||
.entry("num_predict".to_string())
|
|
||||||
.or_insert(json!(max_tokens));
|
|
||||||
}
|
|
||||||
|
|
||||||
options
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
let url = format!("{}/api/tags", self.base_url);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| owlen_core::Error::Network(format!("Failed to fetch models: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let code = response.status();
|
|
||||||
let error = parse_error_body(response).await;
|
|
||||||
return Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama model listing failed ({code}): {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = response.text().await.map_err(|e| {
|
|
||||||
owlen_core::Error::Network(format!("Failed to read models response: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let ollama_response: OllamaModelsResponse =
|
|
||||||
serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?;
|
|
||||||
|
|
||||||
let models = ollama_response
|
|
||||||
.models
|
|
||||||
.into_iter()
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Provider for OllamaProvider {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"ollama"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
self.model_manager
|
|
||||||
.get_or_refresh(false, || async { self.fetch_models().await })
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
|
||||||
let ChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
parameters,
|
|
||||||
} = request;
|
|
||||||
|
|
||||||
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
|
||||||
|
|
||||||
let options = Self::build_options(parameters);
|
|
||||||
|
|
||||||
let ollama_request = OllamaChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
stream: false,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}/api/chat", self.base_url);
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.json(&ollama_request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| owlen_core::Error::Network(format!("Chat request failed: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let code = response.status();
|
|
||||||
let error = parse_error_body(response).await;
|
|
||||||
return Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama chat failed ({code}): {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = response.text().await.map_err(|e| {
|
|
||||||
owlen_core::Error::Network(format!("Failed to read chat response: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut ollama_response: OllamaChatResponse =
|
|
||||||
serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?;
|
|
||||||
|
|
||||||
if let Some(error) = ollama_response.error.take() {
|
|
||||||
return Err(owlen_core::Error::Provider(anyhow::anyhow!(error)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = match ollama_response.message {
|
|
||||||
Some(ref msg) => Self::convert_ollama_message(msg),
|
|
||||||
None => {
|
|
||||||
return Err(owlen_core::Error::Provider(anyhow::anyhow!(
|
|
||||||
"Ollama response missing message"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let usage = if let (Some(prompt_tokens), Some(completion_tokens)) = (
|
|
||||||
ollama_response.prompt_eval_count,
|
|
||||||
ollama_response.eval_count,
|
|
||||||
) {
|
|
||||||
Some(TokenUsage {
|
|
||||||
prompt_tokens,
|
|
||||||
completion_tokens,
|
|
||||||
total_tokens: prompt_tokens + completion_tokens,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ChatResponse {
|
|
||||||
message,
|
|
||||||
usage,
|
|
||||||
is_streaming: false,
|
|
||||||
is_final: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
|
||||||
let ChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
parameters,
|
|
||||||
} = request;
|
|
||||||
|
|
||||||
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
|
||||||
|
|
||||||
let options = Self::build_options(parameters);
|
|
||||||
|
|
||||||
let ollama_request = OllamaChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
stream: true,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}/api/chat", self.base_url);
|
|
||||||
|
|
||||||
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();
|
|
||||||
let error = parse_error_body(response).await;
|
|
||||||
return Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama streaming chat failed ({code}): {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut buffer = String::new();
|
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
match chunk {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if let Ok(text) = String::from_utf8(bytes.to_vec()) {
|
|
||||||
buffer.push_str(&text);
|
|
||||||
|
|
||||||
while let Some(pos) = buffer.find('\n') {
|
|
||||||
let mut line = buffer[..pos].trim().to_string();
|
|
||||||
buffer.drain(..=pos);
|
|
||||||
|
|
||||||
if line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if line.ends_with('\r') {
|
|
||||||
line.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
match serde_json::from_str::<OllamaChatResponse>(&line) {
|
|
||||||
Ok(mut ollama_response) => {
|
|
||||||
if let Some(error) = ollama_response.error.take() {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Provider(
|
|
||||||
anyhow::anyhow!(error),
|
|
||||||
)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(message) = ollama_response.message {
|
|
||||||
let mut chat_response = ChatResponse {
|
|
||||||
message: Self::convert_ollama_message(&message),
|
|
||||||
usage: None,
|
|
||||||
is_streaming: true,
|
|
||||||
is_final: ollama_response.done,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let (Some(prompt_tokens), Some(completion_tokens)) = (
|
|
||||||
ollama_response.prompt_eval_count,
|
|
||||||
ollama_response.eval_count,
|
|
||||||
) {
|
|
||||||
chat_response.usage = Some(TokenUsage {
|
|
||||||
prompt_tokens,
|
|
||||||
completion_tokens,
|
|
||||||
total_tokens: prompt_tokens + completion_tokens,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if tx.send(Ok(chat_response)).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ollama_response.done {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Serialization(e)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Serialization(
|
|
||||||
serde_json::Error::io(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"Non UTF-8 chunk from Ollama",
|
|
||||||
)),
|
|
||||||
)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Network(format!(
|
|
||||||
"Stream error: {e}"
|
|
||||||
))));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let stream = UnboundedReceiverStream::new(rx);
|
|
||||||
Ok(Box::pin(stream))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<()> {
|
|
||||||
let url = format!("{}/api/version", self.base_url);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| owlen_core::Error::Network(format!("Health check failed: {e}")))?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama health check failed: HTTP {}",
|
|
||||||
response.status()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"base_url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Base URL for Ollama API",
|
|
||||||
"default": "http://localhost:11434"
|
|
||||||
},
|
|
||||||
"timeout_secs": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "HTTP request timeout in seconds",
|
|
||||||
"minimum": 5,
|
|
||||||
"default": DEFAULT_TIMEOUT_SECS
|
|
||||||
},
|
|
||||||
"model_cache_ttl_secs": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Seconds to cache model listings",
|
|
||||||
"minimum": 5,
|
|
||||||
"default": DEFAULT_MODEL_CACHE_TTL_SECS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse_error_body(response: reqwest::Response) -> String {
|
|
||||||
match response.bytes().await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return "unknown error".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(err) = serde_json::from_slice::<OllamaErrorResponse>(&bytes) {
|
|
||||||
if let Some(error) = err.error {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match String::from_utf8(bytes.to_vec()) {
|
|
||||||
Ok(text) if !text.trim().is_empty() => text,
|
|
||||||
_ => "unknown error".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => "unknown error".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
crates/owlen-openai/README.md
Normal file
5
crates/owlen-openai/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Owlen OpenAI
|
||||||
|
|
||||||
|
This crate is a placeholder for a future `owlen-core::Provider` implementation for the OpenAI API.
|
||||||
|
|
||||||
|
This provider is not yet implemented. Contributions are welcome!
|
||||||
@@ -10,6 +10,7 @@ description = "Terminal User Interface for OWLEN LLM client"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
owlen-core = { path = "../owlen-core" }
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
# Removed owlen-ollama dependency - all providers now accessed via MCP architecture (Phase 10)
|
||||||
|
|
||||||
# TUI framework
|
# TUI framework
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
@@ -17,6 +18,15 @@ crossterm = { workspace = true }
|
|||||||
tui-textarea = { workspace = true }
|
tui-textarea = { workspace = true }
|
||||||
textwrap = { workspace = true }
|
textwrap = { workspace = true }
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
|
unicode-segmentation = "1.11"
|
||||||
|
async-trait = "0.1"
|
||||||
|
globset = "0.4"
|
||||||
|
ignore = "0.4"
|
||||||
|
pathdiff = "0.2"
|
||||||
|
tree-sitter = "0.20"
|
||||||
|
tree-sitter-rust = "0.20"
|
||||||
|
dirs = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
@@ -26,6 +36,9 @@ futures-util = { workspace = true }
|
|||||||
# Utilities
|
# Utilities
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
serde_json.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
|
|||||||
12
crates/owlen-tui/README.md
Normal file
12
crates/owlen-tui/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Owlen TUI
|
||||||
|
|
||||||
|
This crate contains all the logic for the terminal user interface (TUI) of Owlen.
|
||||||
|
|
||||||
|
It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for rendering the chat interface, handling user input, and managing the application state.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Chat View**: A scrollable view of the conversation history.
|
||||||
|
- **Input Box**: A text input area for composing messages.
|
||||||
|
- **Model Selection**: An interface for switching between different models.
|
||||||
|
- **Event Handling**: A system for managing keyboard events and asynchronous operations.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,14 @@ pub struct CodeApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CodeApp {
|
impl CodeApp {
|
||||||
pub fn new(mut controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
|
pub async fn new(
|
||||||
|
mut controller: SessionController,
|
||||||
|
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
||||||
controller
|
controller
|
||||||
.conversation_mut()
|
.conversation_mut()
|
||||||
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
|
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
|
||||||
let (inner, rx) = ChatApp::new(controller);
|
let (inner, rx) = ChatApp::new(controller).await?;
|
||||||
(Self { inner }, rx)
|
Ok((Self { inner }, rx))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
|
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
|
||||||
|
|||||||
282
crates/owlen-tui/src/commands/mod.rs
Normal file
282
crates/owlen-tui/src/commands/mod.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
//! Command catalog and lookup utilities for the command palette.
|
||||||
|
|
||||||
|
/// Metadata describing a single command keyword.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CommandSpec {
|
||||||
|
pub keyword: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDS: &[CommandSpec] = &[
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "quit",
|
||||||
|
description: "Exit the application",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "q",
|
||||||
|
description: "Alias for quit",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "clear",
|
||||||
|
description: "Clear the conversation",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "c",
|
||||||
|
description: "Alias for clear",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "w",
|
||||||
|
description: "Alias for write",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "save",
|
||||||
|
description: "Alias for write",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "load",
|
||||||
|
description: "Load a saved conversation",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "o",
|
||||||
|
description: "Alias for load",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "open",
|
||||||
|
description: "Open a file in the code view",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "close",
|
||||||
|
description: "Close the active code view",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "mode",
|
||||||
|
description: "Switch operating mode (chat/code)",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "code",
|
||||||
|
description: "Switch to code mode",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "chat",
|
||||||
|
description: "Switch to chat mode",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "tools",
|
||||||
|
description: "List available tools in current mode",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "sessions",
|
||||||
|
description: "List saved sessions",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "help",
|
||||||
|
description: "Show help documentation",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "h",
|
||||||
|
description: "Alias for help",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "model",
|
||||||
|
description: "Select a model",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "provider",
|
||||||
|
description: "Switch active provider",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "model info",
|
||||||
|
description: "Show detailed information for a model",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "model refresh",
|
||||||
|
description: "Refresh cached model information",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "model details",
|
||||||
|
description: "Show details for the active model",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "m",
|
||||||
|
description: "Alias for model",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "models info",
|
||||||
|
description: "Prefetch detailed information for all models",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "new",
|
||||||
|
description: "Start a new conversation",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "n",
|
||||||
|
description: "Alias for new",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "theme",
|
||||||
|
description: "Switch theme",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "themes",
|
||||||
|
description: "List available themes",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "tutorial",
|
||||||
|
description: "Show keybinding tutorial",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "reload",
|
||||||
|
description: "Reload configuration and themes",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "e",
|
||||||
|
description: "Edit a file",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "edit",
|
||||||
|
description: "Alias for edit",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "ls",
|
||||||
|
description: "List directory contents",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "privacy-enable",
|
||||||
|
description: "Enable a privacy-sensitive tool",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "privacy-disable",
|
||||||
|
description: "Disable a privacy-sensitive tool",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "privacy-clear",
|
||||||
|
description: "Clear stored secure data",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "agent",
|
||||||
|
description: "Enable agent mode for autonomous task execution",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "stop-agent",
|
||||||
|
description: "Stop the running agent",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "agent status",
|
||||||
|
description: "Show current agent status",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "agent start",
|
||||||
|
description: "Arm the agent for the next request",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "agent stop",
|
||||||
|
description: "Stop the running agent",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "layout save",
|
||||||
|
description: "Persist the current pane layout",
|
||||||
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "layout load",
|
||||||
|
description: "Restore the last saved pane layout",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Return the static catalog of commands.
|
||||||
|
pub fn all() -> &'static [CommandSpec] {
|
||||||
|
COMMANDS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the default suggestion list (all command keywords).
|
||||||
|
pub fn default_suggestions() -> Vec<CommandSpec> {
|
||||||
|
COMMANDS.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate keyword suggestions for the given input.
|
||||||
|
pub fn suggestions(input: &str) -> Vec<CommandSpec> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return default_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches: Vec<(usize, usize, CommandSpec)> = COMMANDS
|
||||||
|
.iter()
|
||||||
|
.filter_map(|spec| {
|
||||||
|
match_score(spec.keyword, trimmed).map(|score| (score.0, score.1, *spec))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if matches.is_empty() {
|
||||||
|
return default_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort_by(|a, b| {
|
||||||
|
a.0.cmp(&b.0)
|
||||||
|
.then(a.1.cmp(&b.1))
|
||||||
|
.then(a.2.keyword.cmp(b.2.keyword))
|
||||||
|
});
|
||||||
|
|
||||||
|
matches.into_iter().map(|(_, _, spec)| spec).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> {
|
||||||
|
let query = query.trim();
|
||||||
|
if query.is_empty() {
|
||||||
|
return Some((usize::MAX, candidate.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate_normalized = candidate.trim().to_lowercase();
|
||||||
|
if candidate_normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_normalized = query.to_lowercase();
|
||||||
|
|
||||||
|
if candidate_normalized == query_normalized {
|
||||||
|
Some((0, candidate.len()))
|
||||||
|
} else if candidate_normalized.starts_with(&query_normalized) {
|
||||||
|
Some((1, candidate.len()))
|
||||||
|
} else if let Some(pos) = candidate_normalized.find(&query_normalized) {
|
||||||
|
Some((2, pos))
|
||||||
|
} else if is_subsequence(&candidate_normalized, &query_normalized) {
|
||||||
|
Some((3, candidate.len()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggestions_prioritize_agent_start() {
|
||||||
|
let results = suggestions("agent st");
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
assert_eq!(results[0].keyword, "agent start");
|
||||||
|
assert!(results.iter().any(|spec| spec.keyword == "agent stop"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_subsequence(text: &str, pattern: &str) -> bool {
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pattern_chars = pattern.chars();
|
||||||
|
let mut current = match pattern_chars.next() {
|
||||||
|
Some(ch) => ch,
|
||||||
|
None => return true,
|
||||||
|
};
|
||||||
|
|
||||||
|
for ch in text.chars() {
|
||||||
|
if ch == current {
|
||||||
|
match pattern_chars.next() {
|
||||||
|
Some(next_ch) => current = next_ch,
|
||||||
|
None => return true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pub use owlen_core::config::{
|
pub use owlen_core::config::{
|
||||||
default_config_path, ensure_ollama_config, session_timeout, Config, GeneralSettings,
|
Config, DEFAULT_CONFIG_PATH, GeneralSettings, IconMode, InputSettings, StorageSettings,
|
||||||
InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH,
|
UiSettings, default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Attempt to load configuration from default location
|
/// Attempt to load configuration from default location
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
|
//! # Owlen TUI
|
||||||
|
//!
|
||||||
|
//! This crate contains all the logic for the terminal user interface (TUI) of Owlen.
|
||||||
|
//!
|
||||||
|
//! It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for
|
||||||
|
//! rendering the chat interface, handling user input, and managing the application state.
|
||||||
|
//!
|
||||||
|
//! ## Modules
|
||||||
|
//! - `chat_app`: The main application logic for the chat client.
|
||||||
|
//! - `code_app`: The main application logic for the experimental code client.
|
||||||
|
//! - `config`: TUI-specific configuration.
|
||||||
|
//! - `events`: Event handling for user input and other asynchronous actions.
|
||||||
|
//! - `ui`: The rendering logic for all TUI components.
|
||||||
|
|
||||||
pub mod chat_app;
|
pub mod chat_app;
|
||||||
pub mod code_app;
|
pub mod code_app;
|
||||||
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod model_info_panel;
|
||||||
|
pub mod state;
|
||||||
|
pub mod tui_controller;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
pub use chat_app::{ChatApp, SessionEvent};
|
pub use chat_app::{ChatApp, SessionEvent};
|
||||||
|
|||||||
226
crates/owlen-tui/src/model_info_panel.rs
Normal file
226
crates/owlen-tui/src/model_info_panel.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
use owlen_core::model::DetailedModelInfo;
|
||||||
|
use owlen_core::theme::Theme;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Dedicated panel for presenting detailed model information.
|
||||||
|
pub struct ModelInfoPanel {
|
||||||
|
info: Option<DetailedModelInfo>,
|
||||||
|
scroll_offset: usize,
|
||||||
|
total_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelInfoPanel {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
info: None,
|
||||||
|
scroll_offset: 0,
|
||||||
|
total_lines: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_model_info(&mut self, info: DetailedModelInfo) {
|
||||||
|
self.info = Some(info);
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
self.total_lines = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.info = None;
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
self.total_lines = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&mut self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
|
||||||
|
let block = Block::default()
|
||||||
|
.title("Model Information")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().bg(theme.background).fg(theme.text))
|
||||||
|
.border_style(Style::default().fg(theme.focused_panel_border));
|
||||||
|
|
||||||
|
if let Some(info) = &self.info {
|
||||||
|
let body = self.format_info(info);
|
||||||
|
self.total_lines = body.lines().count();
|
||||||
|
let paragraph = Paragraph::new(body)
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().fg(theme.text))
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.scroll((self.scroll_offset as u16, 0));
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
} else {
|
||||||
|
self.total_lines = 0;
|
||||||
|
let paragraph = Paragraph::new("Select a model to inspect its details.")
|
||||||
|
.block(block)
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.placeholder)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_up(&mut self) {
|
||||||
|
if self.scroll_offset > 0 {
|
||||||
|
self.scroll_offset -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_down(&mut self, viewport_height: usize) {
|
||||||
|
if viewport_height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let max_offset = self.total_lines.saturating_sub(viewport_height);
|
||||||
|
if self.scroll_offset < max_offset {
|
||||||
|
self.scroll_offset += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_scroll(&mut self) {
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_offset(&self) -> usize {
|
||||||
|
self.scroll_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_lines(&self) -> usize {
|
||||||
|
self.total_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_model_name(&self) -> Option<&str> {
|
||||||
|
self.info.as_ref().map(|info| info.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_info(&self, info: &DetailedModelInfo) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(format!("Name: {}", info.name));
|
||||||
|
lines.push(format!(
|
||||||
|
"Architecture: {}",
|
||||||
|
display_option(info.architecture.as_deref())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"Parameters: {}",
|
||||||
|
display_option(info.parameters.as_deref())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"Context Length: {}",
|
||||||
|
display_u64(info.context_length)
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"Embedding Length: {}",
|
||||||
|
display_u64(info.embedding_length)
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"Quantization: {}",
|
||||||
|
display_option(info.quantization.as_deref())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"Family: {}",
|
||||||
|
display_option(info.family.as_deref())
|
||||||
|
));
|
||||||
|
if !info.families.is_empty() {
|
||||||
|
lines.push(format!("Families: {}", info.families.join(", ")));
|
||||||
|
}
|
||||||
|
lines.push(format!(
|
||||||
|
"Parameter Size: {}",
|
||||||
|
display_option(info.parameter_size.as_deref())
|
||||||
|
));
|
||||||
|
lines.push(format!("Size: {}", format_size(info.size)));
|
||||||
|
lines.push(format!(
|
||||||
|
"Modified: {}",
|
||||||
|
display_option(info.modified_at.as_deref())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"License: {}",
|
||||||
|
display_option(info.license.as_deref())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"Digest: {}",
|
||||||
|
display_option(info.digest.as_deref())
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(template) = info.template.as_deref() {
|
||||||
|
lines.push(format!("Template: {}", snippet(template)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(system) = info.system.as_deref() {
|
||||||
|
lines.push(format!("System Prompt: {}", snippet(system)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(modelfile) = info.modelfile.as_deref() {
|
||||||
|
lines.push("Modelfile:".to_string());
|
||||||
|
lines.push(snippet_multiline(modelfile, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelInfoPanel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_option(value: Option<&str>) -> String {
|
||||||
|
value
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "N/A".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_u64(value: Option<u64>) -> String {
|
||||||
|
value
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_else(|| "N/A".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_size(value: Option<u64>) -> String {
|
||||||
|
if let Some(bytes) = value {
|
||||||
|
if bytes >= 1_000_000_000 {
|
||||||
|
let human = bytes as f64 / 1_000_000_000_f64;
|
||||||
|
format!("{human:.2} GB ({} bytes)", bytes)
|
||||||
|
} else if bytes >= 1_000_000 {
|
||||||
|
let human = bytes as f64 / 1_000_000_f64;
|
||||||
|
format!("{human:.2} MB ({} bytes)", bytes)
|
||||||
|
} else if bytes >= 1_000 {
|
||||||
|
let human = bytes as f64 / 1_000_f64;
|
||||||
|
format!("{human:.2} KB ({} bytes)", bytes)
|
||||||
|
} else {
|
||||||
|
format!("{bytes} bytes")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"N/A".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snippet(text: &str) -> String {
|
||||||
|
const MAX_LEN: usize = 160;
|
||||||
|
if text.len() > MAX_LEN {
|
||||||
|
format!("{}…", text.chars().take(MAX_LEN).collect::<String>())
|
||||||
|
} else {
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snippet_multiline(text: &str, max_lines: usize) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for (idx, line) in text.lines().enumerate() {
|
||||||
|
if idx >= max_lines {
|
||||||
|
lines.push("…".to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lines.push(snippet(line));
|
||||||
|
}
|
||||||
|
if lines.is_empty() {
|
||||||
|
"N/A".to_string()
|
||||||
|
} else {
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
439
crates/owlen-tui/src/state/command_palette.rs
Normal file
439
crates/owlen-tui/src/state/command_palette.rs
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
use crate::commands::{self, CommandSpec};
|
||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
|
||||||
|
const MAX_RESULTS: usize = 12;
|
||||||
|
const MAX_HISTORY_RESULTS: usize = 4;
|
||||||
|
const HISTORY_CAPACITY: usize = 20;
|
||||||
|
|
||||||
|
/// Encapsulates the command-line style palette used in command mode.
|
||||||
|
///
|
||||||
|
/// The palette keeps track of the raw buffer, matching suggestions, and the
|
||||||
|
/// currently highlighted suggestion index. It contains no terminal-specific
|
||||||
|
/// logic which makes it straightforward to unit test.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PaletteGroup {
|
||||||
|
History,
|
||||||
|
Command,
|
||||||
|
Model,
|
||||||
|
Provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PaletteSuggestion {
|
||||||
|
pub value: String,
|
||||||
|
pub label: String,
|
||||||
|
pub detail: Option<String>,
|
||||||
|
pub group: PaletteGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModelPaletteEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub provider: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelPaletteEntry {
|
||||||
|
fn display_name(&self) -> &str {
|
||||||
|
if self.name.is_empty() {
|
||||||
|
&self.id
|
||||||
|
} else {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CommandPalette {
|
||||||
|
buffer: String,
|
||||||
|
suggestions: Vec<PaletteSuggestion>,
|
||||||
|
selected: usize,
|
||||||
|
models: Vec<ModelPaletteEntry>,
|
||||||
|
providers: Vec<String>,
|
||||||
|
history: VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandPalette {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer(&self) -> &str {
|
||||||
|
&self.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn suggestions(&self) -> &[PaletteSuggestion] {
|
||||||
|
&self.suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_index(&self) -> usize {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.buffer.clear();
|
||||||
|
self.suggestions.clear();
|
||||||
|
self.selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remember(&mut self, value: impl AsRef<str>) {
|
||||||
|
let trimmed = value.as_ref().trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid duplicate consecutive entries by removing any existing matching value.
|
||||||
|
if let Some(pos) = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.eq_ignore_ascii_case(trimmed))
|
||||||
|
{
|
||||||
|
self.history.remove(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.history.push_back(trimmed.to_string());
|
||||||
|
|
||||||
|
while self.history.len() > HISTORY_CAPACITY {
|
||||||
|
self.history.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_buffer(&mut self, value: impl Into<String>) {
|
||||||
|
self.buffer = value.into();
|
||||||
|
self.refresh_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_char(&mut self, ch: char) {
|
||||||
|
self.buffer.push(ch);
|
||||||
|
self.refresh_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pop_char(&mut self) {
|
||||||
|
self.buffer.pop();
|
||||||
|
self.refresh_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_dynamic_sources(
|
||||||
|
&mut self,
|
||||||
|
models: Vec<ModelPaletteEntry>,
|
||||||
|
providers: Vec<String>,
|
||||||
|
) {
|
||||||
|
self.models = models;
|
||||||
|
self.providers = providers;
|
||||||
|
self.refresh_suggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_previous(&mut self) {
|
||||||
|
if !self.suggestions.is_empty() {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_next(&mut self) {
|
||||||
|
if !self.suggestions.is_empty() {
|
||||||
|
let max_index = self.suggestions.len().saturating_sub(1);
|
||||||
|
self.selected = (self.selected + 1).min(max_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_selected(&mut self) -> Option<String> {
|
||||||
|
let selected = self
|
||||||
|
.suggestions
|
||||||
|
.get(self.selected)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| self.suggestions.first().cloned());
|
||||||
|
if let Some(entry) = selected.clone() {
|
||||||
|
self.buffer = entry.value.clone();
|
||||||
|
self.refresh_suggestions();
|
||||||
|
}
|
||||||
|
selected.map(|entry| entry.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_suggestions(&mut self) {
|
||||||
|
let trimmed = self.buffer.trim();
|
||||||
|
self.suggestions = self.dynamic_suggestions(trimmed);
|
||||||
|
if self.selected >= self.suggestions.len() {
|
||||||
|
self.selected = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_suggestions(&mut self) {
|
||||||
|
if self.suggestions.is_empty() {
|
||||||
|
self.refresh_suggestions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<PaletteSuggestion> {
|
||||||
|
let lowered = trimmed.to_ascii_lowercase();
|
||||||
|
let mut results: Vec<PaletteSuggestion> = Vec::new();
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
fn push_entries(
|
||||||
|
results: &mut Vec<PaletteSuggestion>,
|
||||||
|
seen: &mut HashSet<String>,
|
||||||
|
entries: Vec<PaletteSuggestion>,
|
||||||
|
) {
|
||||||
|
for entry in entries {
|
||||||
|
if seen.insert(entry.value.to_ascii_lowercase()) {
|
||||||
|
results.push(entry);
|
||||||
|
}
|
||||||
|
if results.len() >= MAX_RESULTS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let history = self.history_suggestions(trimmed);
|
||||||
|
push_entries(&mut results, &mut seen, history);
|
||||||
|
if results.len() >= MAX_RESULTS {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lowered.starts_with("model ") {
|
||||||
|
let rest = trimmed[5..].trim();
|
||||||
|
push_entries(
|
||||||
|
&mut results,
|
||||||
|
&mut seen,
|
||||||
|
self.model_suggestions("model", rest),
|
||||||
|
);
|
||||||
|
if results.len() < MAX_RESULTS {
|
||||||
|
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lowered.starts_with("m ") {
|
||||||
|
let rest = trimmed[2..].trim();
|
||||||
|
push_entries(&mut results, &mut seen, self.model_suggestions("m", rest));
|
||||||
|
if results.len() < MAX_RESULTS {
|
||||||
|
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lowered == "model" {
|
||||||
|
push_entries(&mut results, &mut seen, self.model_suggestions("model", ""));
|
||||||
|
if results.len() < MAX_RESULTS {
|
||||||
|
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lowered.starts_with("provider ") {
|
||||||
|
let rest = trimmed[9..].trim();
|
||||||
|
push_entries(
|
||||||
|
&mut results,
|
||||||
|
&mut seen,
|
||||||
|
self.provider_suggestions("provider", rest),
|
||||||
|
);
|
||||||
|
if results.len() < MAX_RESULTS {
|
||||||
|
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lowered == "provider" {
|
||||||
|
push_entries(
|
||||||
|
&mut results,
|
||||||
|
&mut seen,
|
||||||
|
self.provider_suggestions("provider", ""),
|
||||||
|
);
|
||||||
|
if results.len() < MAX_RESULTS {
|
||||||
|
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// General query – combine commands, models, and providers using fuzzy order.
|
||||||
|
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||||
|
if results.len() < MAX_RESULTS {
|
||||||
|
push_entries(
|
||||||
|
&mut results,
|
||||||
|
&mut seen,
|
||||||
|
self.model_suggestions("model", trimmed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if results.len() < MAX_RESULTS {
|
||||||
|
push_entries(
|
||||||
|
&mut results,
|
||||||
|
&mut seen,
|
||||||
|
self.provider_suggestions("provider", trimmed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_suggestions(&self, query: &str) -> Vec<PaletteSuggestion> {
|
||||||
|
if self.history.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.trim().is_empty() {
|
||||||
|
return self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(MAX_HISTORY_RESULTS)
|
||||||
|
.map(|value| PaletteSuggestion {
|
||||||
|
value: value.to_string(),
|
||||||
|
label: value.to_string(),
|
||||||
|
detail: Some("Recent command".to_string()),
|
||||||
|
group: PaletteGroup::History,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches: Vec<(usize, usize, usize, &String)> = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(recency, value)| {
|
||||||
|
commands::match_score(value, query)
|
||||||
|
.map(|(primary, secondary)| (primary, secondary, recency, value))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
|
||||||
|
|
||||||
|
matches
|
||||||
|
.into_iter()
|
||||||
|
.take(MAX_HISTORY_RESULTS)
|
||||||
|
.map(|(_, _, _, value)| PaletteSuggestion {
|
||||||
|
value: value.to_string(),
|
||||||
|
label: value.to_string(),
|
||||||
|
detail: Some("Recent command".to_string()),
|
||||||
|
group: PaletteGroup::History,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_entries(&self, query: &str) -> Vec<PaletteSuggestion> {
|
||||||
|
let specs: Vec<CommandSpec> = commands::suggestions(query);
|
||||||
|
specs
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| PaletteSuggestion {
|
||||||
|
value: spec.keyword.to_string(),
|
||||||
|
label: spec.keyword.to_string(),
|
||||||
|
detail: Some(spec.description.to_string()),
|
||||||
|
group: PaletteGroup::Command,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_suggestions(&self, keyword: &str, query: &str) -> Vec<PaletteSuggestion> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return self
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.take(15)
|
||||||
|
.map(|entry| PaletteSuggestion {
|
||||||
|
value: format!("{keyword} {}", entry.id),
|
||||||
|
label: entry.display_name().to_string(),
|
||||||
|
detail: Some(format!("Model · {}", entry.provider)),
|
||||||
|
group: PaletteGroup::Model,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches: Vec<(usize, usize, &ModelPaletteEntry)> = self
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
commands::match_score(entry.id.as_str(), query)
|
||||||
|
.or_else(|| commands::match_score(entry.name.as_str(), query))
|
||||||
|
.or_else(|| {
|
||||||
|
let composite = format!("{} {}", entry.provider, entry.display_name());
|
||||||
|
commands::match_score(composite.as_str(), query)
|
||||||
|
})
|
||||||
|
.map(|score| (score.0, score.1, entry))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.id.cmp(&b.2.id)));
|
||||||
|
matches
|
||||||
|
.into_iter()
|
||||||
|
.take(15)
|
||||||
|
.map(|(_, _, entry)| PaletteSuggestion {
|
||||||
|
value: format!("{keyword} {}", entry.id),
|
||||||
|
label: entry.display_name().to_string(),
|
||||||
|
detail: Some(format!("Model · {}", entry.provider)),
|
||||||
|
group: PaletteGroup::Model,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<PaletteSuggestion> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return self
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.take(15)
|
||||||
|
.map(|provider| PaletteSuggestion {
|
||||||
|
value: format!("{keyword} {}", provider),
|
||||||
|
label: provider.to_string(),
|
||||||
|
detail: Some("Provider".to_string()),
|
||||||
|
group: PaletteGroup::Provider,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches: Vec<(usize, usize, &String)> = self
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|provider| {
|
||||||
|
commands::match_score(provider.as_str(), query)
|
||||||
|
.map(|score| (score.0, score.1, provider))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
matches.sort();
|
||||||
|
matches
|
||||||
|
.into_iter()
|
||||||
|
.take(15)
|
||||||
|
.map(|(_, _, provider)| PaletteSuggestion {
|
||||||
|
value: format!("{keyword} {}", provider),
|
||||||
|
label: provider.to_string(),
|
||||||
|
detail: Some("Provider".to_string()),
|
||||||
|
group: PaletteGroup::Provider,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_entries_are_prioritized() {
|
||||||
|
let mut palette = CommandPalette::new();
|
||||||
|
palette.remember("open foo.rs");
|
||||||
|
palette.remember("model llama");
|
||||||
|
palette.ensure_suggestions();
|
||||||
|
|
||||||
|
let suggestions = palette.suggestions();
|
||||||
|
assert!(!suggestions.is_empty());
|
||||||
|
assert_eq!(suggestions[0].value, "model llama");
|
||||||
|
assert!(matches!(suggestions[0].group, PaletteGroup::History));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_deduplicates_case_insensitively() {
|
||||||
|
let mut palette = CommandPalette::new();
|
||||||
|
palette.remember("open foo.rs");
|
||||||
|
palette.remember("OPEN FOO.RS");
|
||||||
|
palette.ensure_suggestions();
|
||||||
|
|
||||||
|
let history_entries: Vec<_> = palette
|
||||||
|
.suggestions()
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| matches!(entry.group, PaletteGroup::History))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(history_entries.len(), 1);
|
||||||
|
assert_eq!(history_entries[0].value, "OPEN FOO.RS");
|
||||||
|
}
|
||||||
|
}
|
||||||
320
crates/owlen-tui/src/state/file_icons.rs
Normal file
320
crates/owlen-tui/src/state/file_icons.rs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use owlen_core::config::IconMode;
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
use super::FileNode;
|
||||||
|
|
||||||
|
const ENV_ICON_OVERRIDE: &str = "OWLEN_TUI_ICONS";
|
||||||
|
|
||||||
|
/// Concrete icon sets that can be rendered in the terminal.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FileIconSet {
|
||||||
|
Nerd,
|
||||||
|
Ascii,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileIconSet {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FileIconSet::Nerd => "Nerd",
|
||||||
|
FileIconSet::Ascii => "ASCII",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the icon mode was decided.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum IconDetection {
|
||||||
|
/// Explicit configuration (config file or CLI flag) forced the mode.
|
||||||
|
Configured,
|
||||||
|
/// The runtime environment variable override selected the mode.
|
||||||
|
Environment,
|
||||||
|
/// Automatic heuristics guessed the appropriate mode.
|
||||||
|
Heuristic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves per-file icons with configurable fallbacks.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileIconResolver {
|
||||||
|
set: FileIconSet,
|
||||||
|
detection: IconDetection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileIconResolver {
|
||||||
|
/// Construct a resolver from the configured icon preference.
|
||||||
|
pub fn from_mode(pref: IconMode) -> Self {
|
||||||
|
let (set, detection) = match pref {
|
||||||
|
IconMode::Ascii => (FileIconSet::Ascii, IconDetection::Configured),
|
||||||
|
IconMode::Nerd => (FileIconSet::Nerd, IconDetection::Configured),
|
||||||
|
IconMode::Auto => detect_icon_set(),
|
||||||
|
};
|
||||||
|
Self { set, detection }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective icon set that will be rendered.
|
||||||
|
pub fn set(&self) -> FileIconSet {
|
||||||
|
self.set
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the icon set was chosen.
|
||||||
|
pub fn detection(&self) -> IconDetection {
|
||||||
|
self.detection
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human readable label for status lines.
|
||||||
|
pub fn status_label(&self) -> &'static str {
|
||||||
|
self.set.label()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short label indicating where the decision originated.
|
||||||
|
pub fn detection_label(&self) -> &'static str {
|
||||||
|
match self.detection {
|
||||||
|
IconDetection::Configured => "config",
|
||||||
|
IconDetection::Environment => "env",
|
||||||
|
IconDetection::Heuristic => "auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the glyph to render for the given node.
|
||||||
|
pub fn icon_for(&self, node: &FileNode) -> &'static str {
|
||||||
|
match self.set {
|
||||||
|
FileIconSet::Nerd => nerd_icon_for(node),
|
||||||
|
FileIconSet::Ascii => ascii_icon_for(node),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_icon_set() -> (FileIconSet, IconDetection) {
|
||||||
|
if let Some(set) = env_icon_override() {
|
||||||
|
return (set, IconDetection::Environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !locale_supports_unicode() || is_basic_terminal() {
|
||||||
|
return (FileIconSet::Ascii, IconDetection::Heuristic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if nerd_glyph_has_compact_width() {
|
||||||
|
(FileIconSet::Nerd, IconDetection::Heuristic)
|
||||||
|
} else {
|
||||||
|
(FileIconSet::Ascii, IconDetection::Heuristic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_icon_override() -> Option<FileIconSet> {
|
||||||
|
let value = env::var(ENV_ICON_OVERRIDE).ok()?;
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"nerd" | "nerdfont" | "nf" | "fancy" => Some(FileIconSet::Nerd),
|
||||||
|
"ascii" | "plain" | "simple" => Some(FileIconSet::Ascii),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locale_supports_unicode() -> bool {
|
||||||
|
let vars = ["LC_ALL", "LC_CTYPE", "LANG"];
|
||||||
|
vars.iter()
|
||||||
|
.filter_map(|name| env::var(name).ok())
|
||||||
|
.map(|value| value.to_ascii_lowercase())
|
||||||
|
.any(|value| value.contains("utf-8") || value.contains("utf8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_basic_terminal() -> bool {
|
||||||
|
matches!(env::var("TERM").ok().as_deref(), Some("linux" | "vt100"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nerd_glyph_has_compact_width() -> bool {
|
||||||
|
// Sample glyphs chosen from the Nerd Font private use area.
|
||||||
|
const SAMPLE_ICONS: [&str; 3] = ["", "", ""];
|
||||||
|
SAMPLE_ICONS.iter().all(|icon| {
|
||||||
|
icon.chars()
|
||||||
|
.all(|ch| UnicodeWidthChar::width(ch).unwrap_or(1) == 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nerd_icon_for(node: &FileNode) -> &'static str {
|
||||||
|
if node.depth == 0 {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if node.is_dir {
|
||||||
|
return if node.is_expanded { "" } else { "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = node.name.as_str();
|
||||||
|
if let Some(icon) = nerd_icon_by_special_name(name) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
match ext.as_str() {
|
||||||
|
"rs" => "",
|
||||||
|
"toml" => "",
|
||||||
|
"lock" => "",
|
||||||
|
"json" => "",
|
||||||
|
"yaml" | "yml" => "",
|
||||||
|
"md" | "markdown" => "",
|
||||||
|
"py" => "",
|
||||||
|
"rb" => "",
|
||||||
|
"go" => "",
|
||||||
|
"sh" | "bash" => "",
|
||||||
|
"zsh" => "",
|
||||||
|
"fish" => "",
|
||||||
|
"ts" => "",
|
||||||
|
"tsx" => "",
|
||||||
|
"js" => "",
|
||||||
|
"jsx" => "",
|
||||||
|
"mjs" | "cjs" => "",
|
||||||
|
"html" | "htm" => "",
|
||||||
|
"css" => "",
|
||||||
|
"scss" | "sass" => "",
|
||||||
|
"less" => "",
|
||||||
|
"vue" => "",
|
||||||
|
"svelte" => "",
|
||||||
|
"java" => "",
|
||||||
|
"kt" => "",
|
||||||
|
"swift" => "",
|
||||||
|
"c" => "",
|
||||||
|
"h" => "",
|
||||||
|
"cpp" | "cxx" | "cc" => "",
|
||||||
|
"hpp" | "hh" | "hxx" => "",
|
||||||
|
"cs" => "",
|
||||||
|
"php" => "",
|
||||||
|
"zig" => "",
|
||||||
|
"lua" => "",
|
||||||
|
"sql" => "",
|
||||||
|
"erl" | "hrl" => "",
|
||||||
|
"ex" | "exs" => "",
|
||||||
|
"hs" => "",
|
||||||
|
"scala" => "",
|
||||||
|
"dart" => "",
|
||||||
|
"gradle" => "",
|
||||||
|
"groovy" => "",
|
||||||
|
"xml" => "",
|
||||||
|
"ini" | "cfg" => "",
|
||||||
|
"env" => "",
|
||||||
|
"log" => "",
|
||||||
|
"txt" => "",
|
||||||
|
"pdf" => "",
|
||||||
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "",
|
||||||
|
"svg" => "",
|
||||||
|
"ico" => "",
|
||||||
|
"lockb" => "",
|
||||||
|
"wasm" => "",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nerd_icon_by_special_name(name: &str) -> Option<&'static str> {
|
||||||
|
match name {
|
||||||
|
"Cargo.toml" => Some(""),
|
||||||
|
"Cargo.lock" => Some(""),
|
||||||
|
"Makefile" | "makefile" => Some(""),
|
||||||
|
"Dockerfile" => Some(""),
|
||||||
|
".gitignore" => Some(""),
|
||||||
|
".gitmodules" => Some(""),
|
||||||
|
"README.md" | "readme.md" => Some(""),
|
||||||
|
"LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some(""),
|
||||||
|
"package.json" => Some(""),
|
||||||
|
"package-lock.json" => Some(""),
|
||||||
|
"yarn.lock" => Some(""),
|
||||||
|
"pnpm-lock.yaml" | "pnpm-lock.yml" => Some(""),
|
||||||
|
"tsconfig.json" => Some(""),
|
||||||
|
"config.toml" => Some(""),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ascii_icon_for(node: &FileNode) -> &'static str {
|
||||||
|
if node.depth == 0 {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
if node.is_dir {
|
||||||
|
return if node.is_expanded { "[]" } else { "<>" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = node.name.as_str();
|
||||||
|
if let Some(icon) = ascii_icon_by_special_name(name) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
match ext.as_str() {
|
||||||
|
"rs" => "RS",
|
||||||
|
"toml" => "TL",
|
||||||
|
"lock" => "LK",
|
||||||
|
"json" => "JS",
|
||||||
|
"yaml" | "yml" => "YM",
|
||||||
|
"md" | "markdown" => "MD",
|
||||||
|
"py" => "PY",
|
||||||
|
"rb" => "RB",
|
||||||
|
"go" => "GO",
|
||||||
|
"sh" | "bash" | "zsh" | "fish" => "SH",
|
||||||
|
"ts" => "TS",
|
||||||
|
"tsx" => "TX",
|
||||||
|
"js" | "jsx" | "mjs" | "cjs" => "JS",
|
||||||
|
"html" | "htm" => "HT",
|
||||||
|
"css" => "CS",
|
||||||
|
"scss" | "sass" => "SC",
|
||||||
|
"vue" => "VU",
|
||||||
|
"svelte" => "SV",
|
||||||
|
"java" => "JV",
|
||||||
|
"kt" => "KT",
|
||||||
|
"swift" => "SW",
|
||||||
|
"c" => "C",
|
||||||
|
"h" => "H",
|
||||||
|
"cpp" | "cxx" | "cc" => "C+",
|
||||||
|
"hpp" | "hh" | "hxx" => "H+",
|
||||||
|
"cs" => "CS",
|
||||||
|
"php" => "PH",
|
||||||
|
"zig" => "ZG",
|
||||||
|
"lua" => "LU",
|
||||||
|
"sql" => "SQ",
|
||||||
|
"erl" | "hrl" => "ER",
|
||||||
|
"ex" | "exs" => "EX",
|
||||||
|
"hs" => "HS",
|
||||||
|
"scala" => "SC",
|
||||||
|
"dart" => "DT",
|
||||||
|
"gradle" => "GR",
|
||||||
|
"groovy" => "GR",
|
||||||
|
"xml" => "XM",
|
||||||
|
"ini" | "cfg" => "CF",
|
||||||
|
"env" => "EV",
|
||||||
|
"log" => "LG",
|
||||||
|
"txt" => "--",
|
||||||
|
"pdf" => "PD",
|
||||||
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "IM",
|
||||||
|
"svg" => "SG",
|
||||||
|
"wasm" => "WM",
|
||||||
|
_ => "--",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ascii_icon_by_special_name(name: &str) -> Option<&'static str> {
|
||||||
|
match name {
|
||||||
|
"Cargo.toml" => Some("TL"),
|
||||||
|
"Cargo.lock" => Some("LK"),
|
||||||
|
"Makefile" | "makefile" => Some("MK"),
|
||||||
|
"Dockerfile" => Some("DK"),
|
||||||
|
".gitignore" => Some("GI"),
|
||||||
|
".gitmodules" => Some("GI"),
|
||||||
|
"README.md" | "readme.md" => Some("MD"),
|
||||||
|
"LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some("LC"),
|
||||||
|
"package.json" => Some("PJ"),
|
||||||
|
"package-lock.json" => Some("PL"),
|
||||||
|
"yarn.lock" => Some("YL"),
|
||||||
|
"pnpm-lock.yaml" | "pnpm-lock.yml" => Some("PL"),
|
||||||
|
"tsconfig.json" => Some("TC"),
|
||||||
|
"config.toml" => Some("CF"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user