mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
413ecf8697 |
+2
-14
@@ -57,17 +57,6 @@ NEARAI_AUTH_URL=https://private.near.ai
|
||||
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||
# LLM_API_KEY=fw_...
|
||||
|
||||
# === Anthropic Direct ===
|
||||
# LLM_BACKEND=anthropic
|
||||
# ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com # default
|
||||
# Prompt cache retention — controls Anthropic server-side prompt caching:
|
||||
# none = disabled (no cache_control injected)
|
||||
# short = 5-minute TTL, 1.25× (125%) write surcharge (default)
|
||||
# long = 1-hour TTL, 2.0× (200%) write surcharge
|
||||
# ANTHROPIC_CACHE_RETENTION=short
|
||||
|
||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||
|
||||
# Channel Configuration
|
||||
@@ -119,9 +108,8 @@ HEARTBEAT_NOTIFY_USER=default
|
||||
# Memory hygiene settings (automatic cleanup of stale workspace documents)
|
||||
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
|
||||
# MEMORY_HYGIENE_ENABLED=true
|
||||
# MEMORY_HYGIENE_DAILY_RETENTION_DAYS=30 # delete daily/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
|
||||
# MEMORY_HYGIENE_RETENTION_DAYS=30 # delete daily/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
|
||||
|
||||
# Safety settings
|
||||
SAFETY_MAX_OUTPUT_LENGTH=100000
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../scripts/commit-msg-regression.sh
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Pre-commit hook: run version bump checks when WIT or extension sources change.
|
||||
# Install: git config core.hooksPath .githooks
|
||||
|
||||
# Only run the check if relevant files are staged
|
||||
STAGED=$(git diff --cached --name-only)
|
||||
|
||||
NEEDS_CHECK=false
|
||||
if echo "$STAGED" | grep -qE '^wit/|^channels-src/|^tools-src/'; then
|
||||
NEEDS_CHECK=true
|
||||
fi
|
||||
|
||||
if $NEEDS_CHECK; then
|
||||
echo "pre-commit: checking version bumps..."
|
||||
if ! ./scripts/check-version-bumps.sh; then
|
||||
echo ""
|
||||
echo "Commit blocked: version bump check failed."
|
||||
echo "Bump versions in the relevant registry JSON and/or WIT package declaration."
|
||||
echo "To bypass: git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -12,6 +12,7 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: rustfmt
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -35,6 +36,7 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -42,41 +44,15 @@ jobs:
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
clippy-windows:
|
||||
name: Clippy Windows (${{ matrix.name }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: clippy-windows-${{ matrix.name }}
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
# Roll-up job for branch protection
|
||||
code-style:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [format, clippy, clippy-windows]
|
||||
needs: [format, clippy]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--features postgres,libsql,html-to-markdown"
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
targets: wasm32-wasip2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -44,34 +45,12 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run Telegram Channel Tests
|
||||
run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
|
||||
|
||||
windows-build:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: windows-${{ matrix.name }}
|
||||
- name: Check compilation
|
||||
run: cargo check --all --benches --tests --examples ${{ matrix.flags }}
|
||||
|
||||
wasm-wit-compat:
|
||||
name: WASM WIT Compatibility
|
||||
runs-on: ubuntu-latest
|
||||
@@ -81,6 +60,7 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
targets: wasm32-wasip2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -120,10 +100,10 @@ jobs:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check]
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, version-check]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" || "${{ needs.windows-build.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.16.1](https://github.com/nearai/ironclaw/compare/v0.16.0...v0.16.1) - 2026-03-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- revert WASM artifact SHA256 checksums to null ([#627](https://github.com/nearai/ironclaw/pull/627))
|
||||
|
||||
## [0.16.0](https://github.com/nearai/ironclaw/compare/v0.15.0...v0.16.0) - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -43,53 +43,34 @@ cargo test test_name
|
||||
|
||||
# Run with logging
|
||||
RUST_LOG=ironclaw=debug cargo run
|
||||
|
||||
# Run integration tests (may require running services/DB)
|
||||
cargo test --test workspace_integration
|
||||
cargo test --test ws_gateway_integration
|
||||
cargo test --test heartbeat_integration
|
||||
|
||||
# Run E2E tests (Python/Playwright — requires a running ironclaw instance)
|
||||
# See tests/e2e/CLAUDE.md for full setup instructions
|
||||
cd tests/e2e
|
||||
python -m venv .venv && source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
pip install -e .
|
||||
playwright install chromium
|
||||
pytest scenarios/ # all scenarios
|
||||
pytest scenarios/test_chat.py # specific scenario
|
||||
```
|
||||
|
||||
### Test Tiers
|
||||
|
||||
| Tier | Command | What runs | External deps |
|
||||
|------|---------|-----------|---------------|
|
||||
| Unit | `cargo test` | All `mod tests` + self-contained integration tests | None |
|
||||
| Integration | `cargo test --features integration` | + PostgreSQL-dependent tests | Running PostgreSQL |
|
||||
| Live | `cargo test --features integration -- --ignored` | + LLM-dependent tests | PostgreSQL + LLM API keys |
|
||||
|
||||
Run `bash scripts/check-boundaries.sh` to verify test tier gating and other architecture rules.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib.rs # Library root, module declarations
|
||||
├── main.rs # Entry point, CLI args, startup
|
||||
├── app.rs # App startup orchestration (channel wiring, DB init)
|
||||
├── bootstrap.rs # Base directory resolution (~/.ironclaw), early .env loading
|
||||
├── settings.rs # User settings persistence (~/.ironclaw/settings.json)
|
||||
├── service.rs # OS service management (launchd/systemd daemon install)
|
||||
├── tracing_fmt.rs # Custom tracing formatter
|
||||
├── util.rs # Shared utilities
|
||||
├── config/ # Configuration from env vars (split by subsystem)
|
||||
│ ├── mod.rs # Re-exports all config types; top-level Config struct
|
||||
│ ├── agent.rs, llm.rs, channels.rs, database.rs, sandbox.rs, skills.rs
|
||||
│ ├── heartbeat.rs, routines.rs, safety.rs, embeddings.rs, wasm.rs
|
||||
│ ├── tunnel.rs # Tunnel provider config (TUNNEL_PROVIDER, TUNNEL_URL, etc.)
|
||||
│ └── secrets.rs, hygiene.rs, builder.rs, helpers.rs
|
||||
├── config.rs # Configuration from env vars
|
||||
├── error.rs # Error types (thiserror)
|
||||
│
|
||||
├── agent/ # Core agent loop, dispatcher, scheduler, sessions — see src/agent/CLAUDE.md
|
||||
├── agent/ # Core agent logic
|
||||
│ ├── agent_loop.rs # Main Agent struct, message handling loop
|
||||
│ ├── router.rs # MessageIntent classification
|
||||
│ ├── scheduler.rs # Parallel job scheduling
|
||||
│ ├── worker.rs # Per-job execution with LLM reasoning
|
||||
│ ├── self_repair.rs # Stuck job detection and recovery
|
||||
│ ├── heartbeat.rs # Proactive periodic execution
|
||||
│ ├── session.rs # Session/thread/turn model with state machine
|
||||
│ ├── session_manager.rs # Thread/session lifecycle management
|
||||
│ ├── compaction.rs # Context window management with turn summarization
|
||||
│ ├── context_monitor.rs # Memory pressure detection
|
||||
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
||||
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
||||
│ ├── dispatcher.rs # Skill-aware job dispatching
|
||||
│ ├── task.rs # Sub-task execution framework
|
||||
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
|
||||
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
|
||||
│
|
||||
├── channels/ # Multi-channel input
|
||||
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
||||
@@ -102,60 +83,21 @@ src/
|
||||
│ │ ├── overlay.rs # Approval overlays
|
||||
│ │ └── composer.rs # Message composition
|
||||
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
||||
│ ├── webhook_server.rs # Unified HTTP server composing all webhook routes
|
||||
│ ├── repl.rs # Simple REPL (for testing)
|
||||
│ ├── web/ # Web gateway (browser UI) — see src/channels/web/CLAUDE.md
|
||||
│ ├── web/ # Web gateway (browser UI)
|
||||
│ │ ├── mod.rs # Gateway builder, startup
|
||||
│ │ ├── server.rs # Axum router, 40+ API endpoints
|
||||
│ │ ├── sse.rs # SSE broadcast manager
|
||||
│ │ ├── ws.rs # WebSocket gateway + connection tracking
|
||||
│ │ ├── types.rs # Request/response types, SseEvent enum
|
||||
│ │ ├── auth.rs # Bearer token auth middleware
|
||||
│ │ ├── log_layer.rs # Tracing layer for log streaming
|
||||
│ │ └── static/ # HTML, CSS, JS (single-page app)
|
||||
│ └── wasm/ # WASM channel runtime
|
||||
│ ├── mod.rs
|
||||
│ ├── bundled.rs # Bundled channel discovery
|
||||
│ ├── capabilities.rs # Channel-specific capabilities (HTTP endpoint, emit rate)
|
||||
│ ├── error.rs # WASM channel error types
|
||||
│ ├── runtime.rs # WASM channel execution runtime
|
||||
│ └── wrapper.rs # Channel trait wrapper for WASM modules
|
||||
│
|
||||
├── cli/ # CLI subcommands (clap)
|
||||
│ ├── mod.rs # Cli struct, Command enum (run/onboard/config/tool/registry/mcp/memory/pairing/service/doctor/status/completion)
|
||||
│ ├── config.rs # config list/get/set subcommands
|
||||
│ ├── tool.rs # tool install/list/remove subcommands
|
||||
│ ├── registry.rs # registry list/install subcommands
|
||||
│ ├── mcp.rs # mcp add/auth/list/test subcommands
|
||||
│ ├── memory.rs # memory search/read/write subcommands
|
||||
│ ├── pairing.rs # pairing list/approve subcommands
|
||||
│ ├── service.rs # service install/start/stop subcommands
|
||||
│ ├── doctor.rs # Active health diagnostics
|
||||
│ ├── status.rs # System health/status display
|
||||
│ ├── completion.rs # Shell completion script generation
|
||||
│ └── oauth_defaults.rs # Default OAuth redirect URIs
|
||||
│
|
||||
├── registry/ # Extension registry catalog
|
||||
│ ├── mod.rs # Public API; re-exports RegistryCatalog, RegistryInstaller, manifest types
|
||||
│ ├── manifest.rs # ExtensionManifest, ArtifactSpec, BundleDefinition types
|
||||
│ ├── catalog.rs # RegistryCatalog: load from filesystem and embedded JSON
|
||||
│ ├── installer.rs # RegistryInstaller: download, verify, install WASM artifacts
|
||||
│ ├── artifacts.rs # Artifact download and caching
|
||||
│ └── embedded.rs # Catalog compiled into binary at build time (via build.rs)
|
||||
│
|
||||
├── hooks/ # Lifecycle hooks for intercepting agent operations
|
||||
│ ├── mod.rs # 6 HookPoints: BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse
|
||||
│ ├── hook.rs # Hook trait, HookContext, HookEvent, HookOutcome, HookFailureMode
|
||||
│ ├── registry.rs # HookRegistry: register, prioritize, execute hooks
|
||||
│ └── bundled.rs # Built-in hooks: rule-based filters, webhook forwarders, HookBundleConfig
|
||||
│
|
||||
├── tunnel/ # Tunnel abstraction for public internet exposure
|
||||
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel() factory
|
||||
│ ├── cloudflare.rs # CloudflareTunnel (cloudflared binary)
|
||||
│ ├── ngrok.rs # NgrokTunnel
|
||||
│ ├── tailscale.rs # TailscaleTunnel (serve/funnel modes)
|
||||
│ ├── custom.rs # CustomTunnel (arbitrary command with {host}/{port})
|
||||
│ └── none.rs # NoneTunnel (local-only, no exposure)
|
||||
│
|
||||
├── observability/ # Pluggable event/metric recording
|
||||
│ ├── mod.rs # create_observer() factory, ObservabilityConfig
|
||||
│ ├── traits.rs # Observer trait, ObserverEvent, ObserverMetric
|
||||
│ ├── noop.rs # NoopObserver (zero overhead, default)
|
||||
│ ├── log.rs # LogObserver (tracing-based)
|
||||
│ └── multi.rs # MultiObserver (fan-out to multiple backends)
|
||||
│
|
||||
├── orchestrator/ # Internal HTTP API for sandbox containers
|
||||
│ ├── mod.rs
|
||||
│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts)
|
||||
@@ -173,30 +115,34 @@ src/
|
||||
│ ├── sanitizer.rs # Pattern detection, content escaping
|
||||
│ ├── validator.rs # Input validation (length, encoding, patterns)
|
||||
│ ├── policy.rs # PolicyRule system with severity/actions
|
||||
│ ├── leak_detector.rs # Secret detection (API keys, tokens, etc.)
|
||||
│ └── credential_detect.rs # HTTP request credential detection (headers, URL params)
|
||||
│ └── leak_detector.rs # Secret detection (API keys, tokens, etc.)
|
||||
│
|
||||
├── llm/ # Multi-provider LLM integration — see src/llm/CLAUDE.md
|
||||
├── llm/ # LLM integration (multi-provider)
|
||||
│ ├── mod.rs # Provider factory, LlmBackend enum
|
||||
│ ├── provider.rs # LlmProvider trait, message types
|
||||
│ ├── nearai_chat.rs # NEAR AI Chat Completions provider (session token + API key auth)
|
||||
│ ├── reasoning.rs # Planning, tool selection, evaluation
|
||||
│ ├── session.rs # Session token management with auto-renewal
|
||||
│ ├── circuit_breaker.rs # Circuit breaker for provider failures
|
||||
│ ├── retry.rs # Retry with exponential backoff
|
||||
│ ├── failover.rs # Multi-provider failover chain
|
||||
│ ├── response_cache.rs # LLM response caching
|
||||
│ ├── costs.rs # Token cost tracking
|
||||
│ └── rig_adapter.rs # Rig framework adapter
|
||||
│
|
||||
├── tools/ # Extensible tool system
|
||||
│ ├── tool.rs # Tool trait, ToolOutput, ToolError
|
||||
│ ├── registry.rs # ToolRegistry for discovery
|
||||
│ ├── sandbox.rs # Process-based sandbox (stub, superseded by wasm/)
|
||||
│ ├── rate_limiter.rs # Shared sliding-window rate limiter for built-in and WASM tools
|
||||
│ ├── builtin/ # Built-in tools
|
||||
│ │ ├── echo.rs, time.rs, json.rs, http.rs
|
||||
│ │ ├── web_fetch.rs # GET URL → clean Markdown (readability + html-to-md conversion)
|
||||
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
||||
│ │ ├── shell.rs # Shell command execution
|
||||
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
|
||||
│ │ ├── message.rs # MessageTool: agent proactively messages users on any channel
|
||||
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
|
||||
│ │ ├── routine.rs # routine_create/list/update/delete/history
|
||||
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
|
||||
│ │ ├── skill_tools.rs # skill_list/search/install/remove tools
|
||||
│ │ ├── secrets_tools.rs # secret_list/secret_delete (zero-exposure: no values exposed)
|
||||
│ │ ├── html_converter.rs # HTML→Markdown via readability + html-to-markdown-rs
|
||||
│ │ ├── path_utils.rs # Shared path validation/canonicalization helpers
|
||||
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
||||
│ ├── builder/ # Dynamic tool building
|
||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||
@@ -205,8 +151,7 @@ src/
|
||||
│ │ └── validation.rs # WASM validation
|
||||
│ ├── mcp/ # Model Context Protocol
|
||||
│ │ ├── client.rs # MCP client over HTTP
|
||||
│ │ ├── protocol.rs # JSON-RPC types
|
||||
│ │ └── session.rs # MCP session management (Mcp-Session-Id header, per-server state)
|
||||
│ │ └── protocol.rs # JSON-RPC types
|
||||
│ └── wasm/ # Full WASM sandbox (wasmtime)
|
||||
│ ├── runtime.rs # Module compilation and caching
|
||||
│ ├── wrapper.rs # Tool trait wrapper for WASM modules
|
||||
@@ -216,10 +161,13 @@ src/
|
||||
│ ├── credential_injector.rs # Safe credential injection
|
||||
│ ├── loader.rs # WASM tool discovery from filesystem
|
||||
│ ├── rate_limiter.rs # Per-tool rate limiting
|
||||
│ ├── error.rs # WASM-specific error types
|
||||
│ └── storage.rs # Linear memory persistence
|
||||
│
|
||||
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md
|
||||
├── db/ # Database abstraction layer
|
||||
│ ├── mod.rs # Database trait (~60 async methods)
|
||||
│ ├── postgres.rs # PostgreSQL backend (delegates to Store + Repository)
|
||||
│ ├── libsql_backend.rs # libSQL/Turso backend (embedded SQLite)
|
||||
│ └── libsql_migrations.rs # SQLite-dialect schema (idempotent)
|
||||
│
|
||||
├── workspace/ # Persistent memory system (OpenClaw-inspired)
|
||||
│ ├── mod.rs # Workspace struct, memory operations
|
||||
@@ -257,11 +205,9 @@ src/
|
||||
│ └── allowlist.rs # DomainAllowlist validation
|
||||
│
|
||||
├── secrets/ # Secrets management
|
||||
│ ├── mod.rs # SecretsStore trait, public API
|
||||
│ ├── types.rs # Core types (Secret, SecretRef, SecretMetadata)
|
||||
│ ├── crypto.rs # AES-256-GCM encryption
|
||||
│ ├── keychain.rs # OS keychain integration (macOS Keychain, GNOME Keyring) for master key
|
||||
│ └── store.rs # Encrypted secret storage
|
||||
│ ├── store.rs # Secret storage
|
||||
│ └── types.rs # Credential types
|
||||
│
|
||||
├── setup/ # Onboarding wizard (spec: src/setup/README.md)
|
||||
│ ├── mod.rs # Entry point, check_onboard_needed()
|
||||
@@ -281,11 +227,6 @@ src/
|
||||
└── history/ # Persistence
|
||||
├── store.rs # PostgreSQL repositories
|
||||
└── analytics.rs # Aggregation queries (JobStats, ToolStats)
|
||||
|
||||
tests/
|
||||
├── *.rs # Integration tests (workspace, heartbeat, WS gateway, pairing, etc.)
|
||||
├── test-pages/ # HTML→Markdown conversion fixtures (CNN, Medium, Yahoo)
|
||||
└── e2e/ # Python/Playwright E2E scenarios (see tests/e2e/CLAUDE.md)
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
@@ -306,16 +247,13 @@ When designing new features or systems, always prefer generic/extensible archite
|
||||
- Use `RwLock` for concurrent read/write access
|
||||
|
||||
### Traits for Extensibility
|
||||
- `Database` - Add new database backends (must implement all ~78 methods)
|
||||
- `Database` - Add new database backends (must implement all ~60 methods)
|
||||
- `Channel` - Add new input sources
|
||||
- `Tool` - Add new capabilities
|
||||
- `LlmProvider` - Add new LLM backends
|
||||
- `SuccessEvaluator` - Custom evaluation logic
|
||||
- `EmbeddingProvider` - Add embedding backends (workspace search)
|
||||
- `NetworkPolicyDecider` - Custom network access policies for sandbox containers
|
||||
- `Hook` - Lifecycle hook at 6 interception points (BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
|
||||
- `Observer` - Observability backend (noop/log/multi; future: OpenTelemetry, Prometheus)
|
||||
- `Tunnel` - Tunnel provider for public internet exposure
|
||||
|
||||
### Tool Implementation
|
||||
```rust
|
||||
@@ -468,38 +406,99 @@ SKILLS_AUTO_DISCOVER=true # Scan skill directories on startup
|
||||
# Tinfoil private inference
|
||||
TINFOIL_API_KEY=... # Required when LLM_BACKEND=tinfoil
|
||||
TINFOIL_MODEL=kimi-k2-5 # Default model
|
||||
|
||||
# Tunnel (public internet exposure for webhooks)
|
||||
TUNNEL_URL=https://abc123.ngrok.io # Static public URL (manual tunnel)
|
||||
# Or use a managed tunnel provider:
|
||||
TUNNEL_PROVIDER=none # none (default), cloudflare, tailscale, ngrok, custom
|
||||
TUNNEL_CF_TOKEN=... # Required for TUNNEL_PROVIDER=cloudflare
|
||||
TUNNEL_NGROK_TOKEN=... # Required for TUNNEL_PROVIDER=ngrok
|
||||
# TUNNEL_NGROK_DOMAIN=... # Custom domain (paid ngrok plan)
|
||||
# TUNNEL_TS_FUNNEL=true # Use tailscale funnel (public) vs serve (tailnet)
|
||||
TUNNEL_CUSTOM_COMMAND=... # Command with {host}/{port} for custom providers
|
||||
|
||||
# Observability backend
|
||||
OBSERVABILITY_BACKEND=none # none/noop (default) or log
|
||||
```
|
||||
|
||||
### LLM Providers
|
||||
|
||||
Backends: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, `tinfoil` — set via `LLM_BACKEND`. See [src/llm/CLAUDE.md](src/llm/CLAUDE.md) for per-provider auth and configuration details.
|
||||
IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`.
|
||||
|
||||
**NEAR AI** -- Uses the Chat Completions API with dual auth support. Session token auth (default): authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google), base URL defaults to `https://private.near.ai`. API key auth: set `NEARAI_API_KEY` (from `cloud.near.ai`), base URL defaults to `https://cloud-api.near.ai`. Both modes use the same Chat Completions endpoint. Tool messages are flattened to plain text for compatibility. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment.
|
||||
|
||||
**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`).
|
||||
|
||||
**OpenAI-compatible** -- Any endpoint that speaks the OpenAI API (vLLM, LiteLLM, OpenRouter, etc.). Configure with `LLM_BASE_URL`, `LLM_API_KEY` (optional), `LLM_MODEL`. Set `LLM_EXTRA_HEADERS` to inject custom HTTP headers into every request (format: `Key:Value,Key2:Value2`), useful for OpenRouter attribution headers like `HTTP-Referer` and `X-Title`.
|
||||
|
||||
**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API. Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`).
|
||||
|
||||
## Database
|
||||
|
||||
Dual-backend persistence (PostgreSQL + libSQL/Turso). **All new persistence features must support both backends** — see [src/db/CLAUDE.md](src/db/CLAUDE.md) for schema, SQL dialect differences, adding operations, and libSQL limitations.
|
||||
IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable.
|
||||
|
||||
**IMPORTANT: All new features that touch persistence MUST support both backends.** Implement the operation as a method on the `Database` trait in `src/db/mod.rs`, then add the implementation in both `src/db/postgres.rs` (delegate to Store/Repository) and `src/db/libsql_backend.rs` (native SQL).
|
||||
|
||||
### Backends
|
||||
|
||||
| Backend | Feature Flag | Default | Use Case |
|
||||
|---------|-------------|---------|----------|
|
||||
| PostgreSQL | `postgres` (default) | Yes | Production, existing deployments |
|
||||
| libSQL/Turso | `libsql` | No | Zero-dependency local mode, edge, Turso cloud |
|
||||
|
||||
Implement every new operation in both `src/db/postgres.rs` and `src/db/libsql/mod.rs`. Test in isolation:
|
||||
```bash
|
||||
cargo check # postgres (default)
|
||||
cargo check --no-default-features --features libsql # libsql only
|
||||
cargo check --all-features # both
|
||||
# Build with PostgreSQL only (default)
|
||||
cargo build
|
||||
|
||||
# Build with libSQL only
|
||||
cargo build --no-default-features --features libsql
|
||||
|
||||
# Build with both backends available
|
||||
cargo build --features "postgres,libsql"
|
||||
```
|
||||
|
||||
### Database Trait
|
||||
|
||||
The `Database` trait (`src/db/mod.rs`) defines ~60 async methods covering all persistence:
|
||||
- Conversations, messages, metadata
|
||||
- Jobs, actions, LLM calls, estimation snapshots
|
||||
- Sandbox jobs, job events
|
||||
- Routines, routine runs
|
||||
- Tool failures, settings
|
||||
- Workspace: documents, chunks, hybrid search
|
||||
|
||||
Both backends implement this trait. PostgreSQL delegates to the existing `Store` + `Repository`. libSQL implements native SQLite-dialect SQL.
|
||||
|
||||
### Schema
|
||||
|
||||
**PostgreSQL:** `migrations/V1__initial.sql` (351 lines). Uses pgvector for embeddings, tsvector for FTS, PL/pgSQL functions. Managed by `refinery`.
|
||||
|
||||
**libSQL:** `src/db/libsql_migrations.rs` (consolidated schema, ~480 lines). Translates PG types:
|
||||
- `UUID` -> `TEXT`, `TIMESTAMPTZ` -> `TEXT` (ISO-8601), `JSONB` -> `TEXT`
|
||||
- `VECTOR(1536)` -> `F32_BLOB(1536)` with `libsql_vector_idx`
|
||||
- `tsvector`/`ts_rank_cd` -> FTS5 virtual table with sync triggers
|
||||
- PL/pgSQL functions -> SQLite triggers
|
||||
|
||||
**Tables (both backends):**
|
||||
|
||||
**Core:**
|
||||
- `conversations` - Multi-channel conversation tracking
|
||||
- `agent_jobs` - Job metadata and status
|
||||
- `job_actions` - Event-sourced tool executions
|
||||
- `dynamic_tools` - Agent-built tools
|
||||
- `llm_calls` - Cost tracking
|
||||
- `estimation_snapshots` - Learning data
|
||||
|
||||
**Workspace/Memory:**
|
||||
- `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
|
||||
- `memory_chunks` - Chunked content with FTS and vector indexes
|
||||
- `heartbeat_state` - Periodic execution tracking
|
||||
|
||||
**Other:**
|
||||
- `routines`, `routine_runs` - Scheduled/reactive execution
|
||||
- `settings` - Per-user key-value settings
|
||||
- `tool_failures` - Self-repair tracking
|
||||
- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
|
||||
|
||||
Database configuration: see Configuration section above.
|
||||
|
||||
### Current Limitations (libSQL backend)
|
||||
|
||||
- **Workspace/memory system** not yet wired through Database trait (requires Store migration)
|
||||
- **Secrets store** not yet available (still requires PostgresSecretsStore)
|
||||
- **Hybrid search** uses FTS5 only (vector search via libsql_vector_idx not yet implemented)
|
||||
- **Settings reload from DB** skipped (Config::from_db requires Store)
|
||||
- No incremental migration versioning (schema is CREATE IF NOT EXISTS, no ALTER TABLE support yet)
|
||||
- **No encryption at rest** -- The local SQLite database file stores conversation content, job data, workspace memory, and other application data in plaintext. Only secrets (API tokens, credentials) are encrypted via AES-256-GCM before storage. Users handling sensitive data should use full-disk encryption (FileVault, LUKS, BitLocker) or consider the PostgreSQL backend with TDE/encrypted storage.
|
||||
- **JSON merge patch vs path-targeted update** -- The libSQL backend uses RFC 7396 JSON Merge Patch (`json_patch`) for metadata updates, while PostgreSQL uses path-targeted `jsonb_set`. Merge patch replaces top-level keys entirely, which may drop nested keys not present in the patch. Callers should avoid relying on partial nested object updates in metadata fields.
|
||||
|
||||
## Safety Layer
|
||||
|
||||
All external tool output passes through `SafetyLayer`:
|
||||
@@ -629,8 +628,8 @@ Key test patterns:
|
||||
4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
||||
5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
||||
6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
||||
7. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
|
||||
8. **Observability backends** - Only `log` and `noop` implemented; OpenTelemetry/Prometheus not yet supported
|
||||
7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway
|
||||
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
|
||||
|
||||
## Tool Architecture
|
||||
|
||||
@@ -644,8 +643,8 @@ See `src/tools/README.md` for full tool architecture, adding new tools (built-in
|
||||
|
||||
1. Create `src/channels/my_channel.rs`
|
||||
2. Implement the `Channel` trait
|
||||
3. Add config in `src/config/channels.rs`
|
||||
4. Wire up in `src/app.rs` channel setup section
|
||||
3. Add config in `src/config.rs`
|
||||
4. Wire up in `main.rs` channel setup section
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -677,11 +676,6 @@ for that module's behavior. When modifying code in a module that has a spec:
|
||||
| `src/setup/` | `src/setup/README.md` |
|
||||
| `src/workspace/` | `src/workspace/README.md` |
|
||||
| `src/tools/` | `src/tools/README.md` |
|
||||
| `src/agent/` | `src/agent/CLAUDE.md` |
|
||||
| `src/channels/web/` | `src/channels/web/CLAUDE.md` |
|
||||
| `src/db/` | `src/db/CLAUDE.md` |
|
||||
| `src/llm/` | `src/llm/CLAUDE.md` |
|
||||
| `tests/e2e/` | `tests/e2e/CLAUDE.md` |
|
||||
|
||||
## Workspace & Memory System
|
||||
|
||||
|
||||
@@ -1,862 +0,0 @@
|
||||
# IronClaw Coverage Plan: 63.3% to 95%
|
||||
|
||||
> Generated 2025-03-06 from [Codecov](https://app.codecov.io/gh/nearai/ironclaw/tree/main/src)
|
||||
|
||||
## Current State
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Current coverage** | 48,571 / 76,694 lines = **63.33%** |
|
||||
| **Target** | 72,859 / 76,694 lines = **95.0%** |
|
||||
| **Gap** | **24,288 lines** need coverage |
|
||||
| **Files >= 95%** | 43 / 239 |
|
||||
| **Files < 95%** | 196 (27,872 total misses) |
|
||||
|
||||
## Module Summary
|
||||
|
||||
Sorted by uncovered lines (descending):
|
||||
|
||||
| Module | Lines | Hits | Miss | Coverage | Priority |
|
||||
|--------|------:|-----:|-----:|---------:|----------|
|
||||
| `channels/` | 14,079 | 8,677 | 5,402 | 61.6% | P0 |
|
||||
| `tools/` | 13,445 | 9,407 | 4,038 | 70.0% | P1 |
|
||||
| `agent/` | 9,152 | 6,096 | 3,056 | 66.6% | P0 |
|
||||
| `setup/` | 3,005 | 462 | 2,543 | 15.4% | P1 |
|
||||
| `extensions/` | 3,540 | 1,298 | 2,242 | 36.7% | P0 |
|
||||
| `cli/` | 2,834 | 697 | 2,137 | 24.6% | P1 |
|
||||
| `history/` | 1,626 | 0 | 1,626 | 0.0% | P0 |
|
||||
| `llm/` | 7,029 | 5,776 | 1,253 | 82.2% | P2 |
|
||||
| `(root)` | 4,122 | 3,121 | 1,001 | 75.7% | P2 |
|
||||
| `worker/` | 1,274 | 480 | 794 | 37.7% | P1 |
|
||||
| `sandbox/` | 1,615 | 897 | 718 | 55.5% | P2 |
|
||||
| `registry/` | 1,588 | 1,107 | 481 | 69.7% | P2 |
|
||||
| `db/` | 921 | 441 | 480 | 47.9% | P1 |
|
||||
| `workspace/` | 2,006 | 1,584 | 422 | 79.0% | P2 |
|
||||
| `orchestrator/` | 1,199 | 795 | 404 | 66.3% | P2 |
|
||||
| `config/` | 1,464 | 1,095 | 369 | 74.8% | P2 |
|
||||
| `hooks/` | 1,379 | 1,081 | 298 | 78.4% | P2 |
|
||||
| `secrets/` | 687 | 407 | 280 | 59.2% | P2 |
|
||||
| `skills/` | 1,714 | 1,585 | 129 | 92.5% | P3 |
|
||||
| `context/` | 693 | 586 | 107 | 84.6% | P3 |
|
||||
| `estimation/` | 467 | 369 | 98 | 79.0% | P3 |
|
||||
| `safety/` | 1,424 | 1,337 | 87 | 93.9% | P3 |
|
||||
| `evaluation/` | 226 | 152 | 74 | 67.3% | P3 |
|
||||
| `pairing/` | 498 | 446 | 52 | 89.6% | P3 |
|
||||
| `tunnel/` | 391 | 368 | 23 | 94.1% | P3 |
|
||||
| `observability/` | 316 | 307 | 9 | 97.2% | Done |
|
||||
|
||||
## Top 40 Files by Uncovered Lines
|
||||
|
||||
These files account for the vast majority of the coverage gap:
|
||||
|
||||
| File | Lines | Miss | Coverage | Lines to 95% |
|
||||
|------|------:|-----:|---------:|--------------:|
|
||||
| `src/extensions/manager.rs` | 2,404 | 2,083 | 13.3% | 1,962 |
|
||||
| `src/setup/wizard.rs` | 2,150 | 1,789 | 16.8% | 1,681 |
|
||||
| `src/history/store.rs` | 1,486 | 1,486 | 0.0% | 1,411 |
|
||||
| `src/channels/web/server.rs` | 1,985 | 993 | 50.0% | 893 |
|
||||
| `src/channels/wasm/wrapper.rs` | 2,237 | 934 | 58.2% | 822 |
|
||||
| `src/agent/thread_ops.rs` | 1,044 | 763 | 26.9% | 710 |
|
||||
| `src/cli/tool.rs` | 757 | 735 | 2.9% | 697 |
|
||||
| `src/setup/channels.rs` | 645 | 596 | 7.6% | 563 |
|
||||
| `src/agent/commands.rs` | 587 | 587 | 0.0% | 557 |
|
||||
| `src/main.rs` | 740 | 522 | 29.4% | 485 |
|
||||
| `src/channels/web/handlers/jobs.rs` | 513 | 456 | 11.1% | 430 |
|
||||
| `src/tools/builder/core.rs` | 524 | 456 | 13.0% | 429 |
|
||||
| `src/agent/worker.rs` | 1,078 | 467 | 56.7% | 413 |
|
||||
| `src/channels/web/handlers/chat.rs` | 564 | 417 | 26.1% | 388 |
|
||||
| `src/tools/wasm/wrapper.rs` | 1,005 | 436 | 56.6% | 385 |
|
||||
| `src/channels/signal.rs` | 1,814 | 472 | 74.0% | 381 |
|
||||
| `src/tools/mcp/auth.rs` | 472 | 378 | 19.9% | 354 |
|
||||
| `src/worker/runtime.rs` | 350 | 330 | 5.7% | 312 |
|
||||
| `src/tools/builtin/job.rs` | 1,014 | 359 | 64.6% | 308 |
|
||||
| `src/cli/mcp.rs` | 322 | 319 | 0.9% | 302 |
|
||||
| `src/cli/oauth_defaults.rs` | 730 | 335 | 54.1% | 298 |
|
||||
| `src/llm/nearai_chat.rs` | 854 | 340 | 60.2% | 297 |
|
||||
| `src/sandbox/container.rs` | 407 | 317 | 22.1% | 296 |
|
||||
| `src/tools/mcp/client.rs` | 341 | 291 | 14.7% | 273 |
|
||||
| `src/registry/installer.rs` | 765 | 311 | 59.3% | 272 |
|
||||
| `src/orchestrator/job_manager.rs` | 405 | 270 | 33.3% | 249 |
|
||||
| `src/channels/web/handlers/routines.rs` | 249 | 249 | 0.0% | 236 |
|
||||
| `src/agent/scheduler.rs` | 559 | 263 | 53.0% | 235 |
|
||||
| `src/tools/wasm/storage.rs` | 296 | 243 | 17.9% | 228 |
|
||||
| `src/channels/repl.rs` | 233 | 233 | 0.0% | 221 |
|
||||
| `src/llm/session.rs` | 413 | 242 | 41.4% | 221 |
|
||||
| `src/worker/claude_bridge.rs` | 629 | 247 | 60.7% | 215 |
|
||||
| `src/agent/agent_loop.rs` | 523 | 234 | 55.2% | 207 |
|
||||
| `src/worker/api.rs` | 258 | 207 | 19.8% | 194 |
|
||||
| `src/sandbox/proxy/http.rs` | 307 | 192 | 37.5% | 176 |
|
||||
| `src/channels/wasm/storage.rs` | 182 | 182 | 0.0% | 172 |
|
||||
| `src/cli/registry.rs` | 177 | 177 | 0.0% | 168 |
|
||||
| `src/llm/reasoning.rs` | 1,163 | 219 | 81.2% | 160 |
|
||||
| `src/tools/builder/testing.rs` | 308 | 174 | 43.5% | 158 |
|
||||
| `src/db/postgres.rs` | 166 | 166 | 0.0% | 157 |
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 -- High-Impact Unit Tests (~8,500 lines)
|
||||
|
||||
Pure logic, serialization, and database queries testable in isolation without real
|
||||
infrastructure. Highest coverage gain per unit of effort.
|
||||
|
||||
### `src/history/store.rs` -- 0% -> 95% (+1,411 lines)
|
||||
|
||||
PostgreSQL repository layer (conversations, jobs, actions, LLM calls, estimation
|
||||
snapshots). Test query construction and result mapping. Can use the libSQL backend
|
||||
as a real in-memory database or test doubles for the `Database` trait.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_store_conversation_crud` -- create, read, update, delete conversations
|
||||
- `test_store_job_lifecycle` -- insert job, update status through state machine
|
||||
- `test_store_action_recording` -- record and query job actions
|
||||
- `test_store_llm_call_tracking` -- insert and aggregate LLM call records
|
||||
- `test_store_estimation_snapshots` -- save and retrieve estimation data
|
||||
|
||||
### `src/history/analytics.rs` -- 0% -> 95% (+133 lines)
|
||||
|
||||
Aggregation queries (JobStats, ToolStats). Test the query builders and result
|
||||
deserialization.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_job_stats_aggregation` -- verify counts, durations, success rates
|
||||
- `test_tool_stats_ranking` -- verify tool usage frequency sorting
|
||||
- `test_analytics_empty_db` -- graceful handling of no data
|
||||
|
||||
### `src/extensions/manager.rs` -- 13.3% -> 95% (+1,962 lines)
|
||||
|
||||
Largest single file gap. Extension lifecycle orchestration (install, auth,
|
||||
activate, remove), config parsing, and state transitions.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_extension_install_from_manifest` -- parse manifest, create extension record
|
||||
- `test_extension_auth_flow` -- OAuth token setup, credential storage
|
||||
- `test_extension_activate_deactivate` -- state transitions, tool registration
|
||||
- `test_extension_remove_cleanup` -- remove extension, clean up artifacts
|
||||
- `test_extension_config_validation` -- reject invalid configs, handle defaults
|
||||
- `test_extension_list_filtering` -- filter by status, type, search query
|
||||
- `test_extension_capability_check` -- verify required capabilities before activation
|
||||
|
||||
### `src/extensions/discovery.rs` -- 27.8% -> 95% (+125 lines)
|
||||
|
||||
Extension discovery from filesystem and registry.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_discover_local_extensions` -- scan directory, parse manifests
|
||||
- `test_discover_skip_invalid` -- gracefully skip malformed extension dirs
|
||||
- `test_discover_dedup` -- handle duplicate extensions across paths
|
||||
|
||||
### `src/tools/builder/core.rs` -- 13% -> 95% (+429 lines)
|
||||
|
||||
`BuildRequirement`, `SoftwareType`, `Language` types and project scaffolding.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_build_requirement_parsing` -- deserialize from JSON
|
||||
- `test_scaffold_project_structure` -- verify generated file tree
|
||||
- `test_language_detection` -- detect language from file extensions
|
||||
- `test_software_type_constraints` -- validate type-specific requirements
|
||||
|
||||
### `src/tools/builder/testing.rs` -- 43.5% -> 95% (+158 lines)
|
||||
|
||||
Test harness integration for built tools.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_harness_setup_teardown` -- lifecycle of test environment
|
||||
- `test_harness_run_tests` -- execute tests and capture results
|
||||
- `test_harness_failure_reporting` -- verify error details on test failure
|
||||
|
||||
### `src/tools/mcp/auth.rs` -- 19.9% -> 95% (+354 lines)
|
||||
|
||||
OAuth token management for MCP servers.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_token_refresh_on_expiry` -- auto-refresh when token expires
|
||||
- `test_token_header_injection` -- correct Authorization header format
|
||||
- `test_token_persistence` -- save/load tokens across restarts
|
||||
- `test_oauth_pkce_flow` -- code verifier/challenge generation
|
||||
- `test_auth_config_parsing` -- parse various auth config formats
|
||||
|
||||
### `src/tools/mcp/client.rs` -- 14.7% -> 95% (+273 lines)
|
||||
|
||||
JSON-RPC client for MCP protocol.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_jsonrpc_request_serialization` -- correct JSON-RPC 2.0 format
|
||||
- `test_jsonrpc_response_parsing` -- handle success, error, and batch responses
|
||||
- `test_jsonrpc_error_codes` -- map MCP error codes to ToolError
|
||||
- `test_tool_list_discovery` -- parse tools/list response
|
||||
- `test_tool_call_roundtrip` -- serialize call, parse result
|
||||
|
||||
### `src/tools/wasm/storage.rs` -- 17.9% -> 95% (+228 lines)
|
||||
|
||||
WASM tool persistence (store, load, delete, list).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wasm_tool_store_roundtrip` -- store and retrieve tool binary + metadata
|
||||
- `test_wasm_tool_delete` -- remove tool and verify gone
|
||||
- `test_wasm_tool_list_filtering` -- filter by name, capability
|
||||
- `test_wasm_tool_update_metadata` -- update without re-uploading binary
|
||||
|
||||
### `src/tools/wasm/wrapper.rs` -- 56.6% -> 95% (+385 lines)
|
||||
|
||||
Tool trait wrapper for WASM modules.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wasm_param_marshalling` -- JSON params to WASM component model types
|
||||
- `test_wasm_output_conversion` -- WASM return values to ToolOutput
|
||||
- `test_wasm_error_propagation` -- WASM traps to ToolError
|
||||
- `test_wasm_fuel_exhaustion` -- verify fuel limit enforcement
|
||||
- `test_wasm_memory_limit` -- verify memory ceiling
|
||||
|
||||
### `src/tools/wasm/loader.rs` -- 62.4% -> 95% (+156 lines)
|
||||
|
||||
WASM tool discovery from filesystem.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_loader_scan_directory` -- find .wasm files with capabilities.json
|
||||
- `test_loader_skip_invalid` -- skip files without valid WIT exports
|
||||
- `test_loader_cache_invalidation` -- reload when file changes
|
||||
|
||||
### `src/tools/builtin/job.rs` -- 64.6% -> 95% (+308 lines)
|
||||
|
||||
Job management tools (CreateJob, ListJobs, JobStatus, CancelJob).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_create_job_params` -- validate required/optional parameters
|
||||
- `test_list_jobs_formatting` -- verify output structure
|
||||
- `test_job_status_transitions` -- query status at each state
|
||||
- `test_cancel_job_running` -- cancel an in-progress job
|
||||
- `test_cancel_job_completed` -- error on already-completed job
|
||||
|
||||
### `src/secrets/store.rs` -- 48.1% -> 95% (+145 lines)
|
||||
|
||||
Encrypted secret storage.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_secret_store_roundtrip` -- store encrypted, retrieve decrypted
|
||||
- `test_secret_update` -- overwrite existing secret
|
||||
- `test_secret_delete` -- remove and verify inaccessible
|
||||
- `test_secret_list_redacted` -- list shows names but not values
|
||||
|
||||
### `src/llm/session.rs` -- 41.4% -> 95% (+221 lines)
|
||||
|
||||
Session token management with auto-renewal.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_session_token_parsing` -- parse `sess_xxx` format
|
||||
- `test_session_expiry_detection` -- detect expired tokens
|
||||
- `test_session_auto_renewal` -- trigger renewal before expiry
|
||||
- `test_session_concurrent_renewal` -- only one renewal in flight
|
||||
|
||||
### `src/llm/nearai_chat.rs` -- 60.2% -> 95% (+297 lines)
|
||||
|
||||
NEAR AI Chat Completions provider.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_nearai_request_building` -- correct endpoint, headers, body
|
||||
- `test_nearai_response_parsing` -- parse streaming and non-streaming responses
|
||||
- `test_nearai_tool_message_flattening` -- tool messages flattened to text
|
||||
- `test_nearai_auth_modes` -- session token vs API key auth
|
||||
- `test_nearai_error_handling` -- rate limits, auth failures, server errors
|
||||
|
||||
### `src/llm/mod.rs` -- 53.7% -> 95% (+112 lines)
|
||||
|
||||
Provider factory and backend selection.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_provider_factory_nearai` -- select NEAR AI from config
|
||||
- `test_provider_factory_openai` -- select OpenAI from config
|
||||
- `test_provider_factory_ollama` -- select Ollama from config
|
||||
- `test_provider_factory_invalid` -- error on unknown backend
|
||||
|
||||
### `src/llm/reasoning.rs` -- 81.2% -> 95% (+160 lines)
|
||||
|
||||
Planning, tool selection, evaluation logic.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_reasoning_step_parsing` -- parse planning steps from LLM output
|
||||
- `test_tool_selection_scoring` -- rank tools by relevance
|
||||
- `test_evaluation_rubric` -- score completions against criteria
|
||||
- `test_reasoning_with_no_tools` -- handle tool-less responses
|
||||
|
||||
### `src/db/postgres.rs` -- 0% -> 95% (+157 lines)
|
||||
|
||||
PostgreSQL backend delegation to Store + Repository.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_postgres_backend_delegates` -- verify delegation pattern (trait-level)
|
||||
- `test_postgres_connection_config` -- TLS, pool size, timeout parsing
|
||||
|
||||
### `src/workspace/mod.rs` -- 75.9% -> 95% (+109 lines)
|
||||
|
||||
Memory operations (write, read, search, tree).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_workspace_write_read` -- write document, read it back
|
||||
- `test_workspace_search_hybrid` -- FTS + vector search via RRF
|
||||
- `test_workspace_tree` -- directory listing of memory filesystem
|
||||
- `test_workspace_overwrite` -- update existing document
|
||||
|
||||
### `src/workspace/embeddings.rs` -- 35.1% -> 95% (~100 lines)
|
||||
|
||||
Embedding provider abstraction.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_embedding_dimension_handling` -- verify dimension config
|
||||
- `test_embedding_batch_processing` -- batch multiple chunks
|
||||
- `test_embedding_provider_fallback` -- graceful degradation when unavailable
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 -- Trace Tests (~7,000 lines)
|
||||
|
||||
End-to-end tests that exercise the agent loop, worker, scheduler, and dispatcher
|
||||
by replaying LLM traces through `TestRig` (see `tests/support/test_rig.rs`). Each
|
||||
trace test covers multiple modules simultaneously, making them high-leverage.
|
||||
|
||||
Each trace test needs:
|
||||
1. A JSON fixture in `tests/fixtures/llm_traces/`
|
||||
2. A test file in `tests/` using `TestRigBuilder`
|
||||
|
||||
### Trace: Thread Operations
|
||||
|
||||
**Covers:** `agent/thread_ops.rs` (+710 lines)
|
||||
|
||||
Test thread creation, listing, switching, and deletion via trace replay.
|
||||
|
||||
**Fixture:** `thread_operations.json`
|
||||
**Tests:**
|
||||
- `test_thread_create_and_switch` -- create thread, switch to it, verify context
|
||||
- `test_thread_list` -- list all threads, verify metadata
|
||||
- `test_thread_delete` -- delete thread, verify removal
|
||||
- `test_thread_switch_nonexistent` -- error handling for missing thread
|
||||
|
||||
### Trace: Agent Commands
|
||||
|
||||
**Covers:** `agent/commands.rs` (+557 lines)
|
||||
|
||||
Test slash commands through the agent loop.
|
||||
|
||||
**Fixture:** `agent_commands.json`
|
||||
**Tests:**
|
||||
- `test_command_help` -- /help returns command list
|
||||
- `test_command_clear` -- /clear resets conversation
|
||||
- `test_command_compact` -- /compact triggers summarization
|
||||
- `test_command_undo_redo` -- /undo then /redo restores state
|
||||
- `test_command_status` -- /status shows agent state
|
||||
|
||||
### Trace: Worker Multi-Turn Execution
|
||||
|
||||
**Covers:** `agent/worker.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines)
|
||||
|
||||
Test multi-turn tool calling, error recovery, and completion flows.
|
||||
|
||||
**Fixture:** `worker_multi_turn.json`
|
||||
**Tests:**
|
||||
- `test_worker_sequential_tools` -- call tool A, then tool B based on A's result
|
||||
- `test_worker_tool_error_recovery` -- tool fails, agent retries or adapts
|
||||
- `test_worker_max_turns` -- verify turn limit enforcement
|
||||
|
||||
### Trace: Scheduler Parallel Jobs
|
||||
|
||||
**Covers:** `agent/scheduler.rs` (+235 lines)
|
||||
|
||||
Test parallel job dispatch and completion tracking.
|
||||
|
||||
**Fixture:** `scheduler_parallel.json`
|
||||
**Tests:**
|
||||
- `test_scheduler_parallel_dispatch` -- dispatch 3 jobs, all complete
|
||||
- `test_scheduler_job_dependency` -- job B waits for job A
|
||||
- `test_scheduler_stuck_detection` -- detect and recover stuck job
|
||||
|
||||
### Trace: Dispatcher Skill Selection
|
||||
|
||||
**Covers:** `agent/dispatcher.rs` (+153 lines)
|
||||
|
||||
Test skill-aware routing and tool attenuation.
|
||||
|
||||
**Fixture:** `dispatcher_skills.json`
|
||||
**Tests:**
|
||||
- `test_dispatcher_skill_match` -- match message to skill, inject prompt
|
||||
- `test_dispatcher_tool_attenuation` -- installed skill loses dangerous tools
|
||||
- `test_dispatcher_no_skill` -- fallback when no skill matches
|
||||
|
||||
### Trace: Routine Execution
|
||||
|
||||
**Covers:** `agent/routine_engine.rs` (~80 lines), `agent/routine.rs` (~40 lines)
|
||||
|
||||
Test cron tick and event-triggered routine execution.
|
||||
|
||||
**Fixture:** `routine_execution.json`
|
||||
**Tests:**
|
||||
- `test_routine_cron_trigger` -- routine fires on schedule
|
||||
- `test_routine_event_trigger` -- routine fires on matching event
|
||||
- `test_routine_guardrails` -- routine respects policy constraints
|
||||
|
||||
### Trace: Compaction and Context Pressure
|
||||
|
||||
**Covers:** `agent/compaction.rs` (~50 lines), `agent/context_monitor.rs` (~30 lines)
|
||||
|
||||
Test turn summarization and memory pressure detection.
|
||||
|
||||
**Fixture:** `compaction_flow.json`
|
||||
**Tests:**
|
||||
- `test_compaction_triggers_at_threshold` -- summarize when context exceeds limit
|
||||
- `test_compaction_preserves_recent` -- keep recent turns intact
|
||||
- `test_context_pressure_warning` -- emit warning at high usage
|
||||
|
||||
### Trace: Job Tool Coverage
|
||||
|
||||
**Covers:** `tools/builtin/job.rs` (+308 lines), `tools/builtin/skill_tools.rs` (+110 lines)
|
||||
|
||||
Test job and skill management tools through agent execution.
|
||||
|
||||
**Fixture:** `job_and_skill_tools.json`
|
||||
**Tests:**
|
||||
- `test_create_and_list_jobs` -- create job, list shows it
|
||||
- `test_job_status_query` -- query status of running job
|
||||
- `test_skill_list_and_search` -- list local skills, search registry
|
||||
|
||||
### Trace: Memory Tools
|
||||
|
||||
**Covers:** `tools/builtin/memory.rs` (~20 lines), `workspace/` (+109 lines)
|
||||
|
||||
Test memory operations through agent tool calls.
|
||||
|
||||
**Fixture:** `memory_tools.json`
|
||||
**Tests:**
|
||||
- `test_memory_write_and_search` -- write doc, search finds it
|
||||
- `test_memory_read_by_path` -- read specific document
|
||||
- `test_memory_tree` -- list memory filesystem structure
|
||||
|
||||
### Trace: Extension Management
|
||||
|
||||
**Covers:** `tools/builtin/extension_tools.rs` (~40 lines)
|
||||
|
||||
Test extension lifecycle via agent tool calls.
|
||||
|
||||
**Fixture:** `extension_management.json`
|
||||
**Tests:**
|
||||
- `test_extension_install_via_tool` -- agent installs an extension
|
||||
- `test_extension_auth_via_tool` -- agent configures auth
|
||||
- `test_extension_activate_via_tool` -- agent activates extension
|
||||
|
||||
### Trace: Self-Repair
|
||||
|
||||
**Covers:** `agent/self_repair.rs` (~40 lines)
|
||||
|
||||
Test stuck job detection and recovery.
|
||||
|
||||
**Fixture:** `self_repair.json`
|
||||
**Tests:**
|
||||
- `test_stuck_job_detected` -- job stuck for > threshold triggers repair
|
||||
- `test_stuck_job_recovered` -- recovery restarts job successfully
|
||||
- `test_stuck_job_fails_permanently` -- recovery fails, job marked failed
|
||||
|
||||
### Trace: Heartbeat
|
||||
|
||||
**Covers:** `agent/heartbeat.rs` (+80 lines)
|
||||
|
||||
Test periodic proactive execution.
|
||||
|
||||
**Fixture:** `heartbeat.json`
|
||||
**Tests:**
|
||||
- `test_heartbeat_periodic_fire` -- heartbeat triggers at interval
|
||||
- `test_heartbeat_reads_checklist` -- reads HEARTBEAT.md, processes items
|
||||
- `test_heartbeat_notification` -- sends notification on findings
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 -- Web/Channel Handler Tests (~4,500 lines)
|
||||
|
||||
Test HTTP handlers and SSE/WS endpoints using `axum_test` or
|
||||
`tower::ServiceExt::oneshot` with a real router and in-memory database.
|
||||
|
||||
### `src/channels/web/server.rs` -- 50% -> 95% (+893 lines)
|
||||
|
||||
The single biggest web gap. 40+ API endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_api_health` -- GET /health returns 200
|
||||
- `test_api_chat_submit` -- POST /api/chat sends message
|
||||
- `test_api_jobs_list` -- GET /api/jobs returns job list
|
||||
- `test_api_jobs_create` -- POST /api/jobs creates job
|
||||
- `test_api_routines_crud` -- full CRUD cycle for routines
|
||||
- `test_api_settings_get_set` -- GET/PUT settings
|
||||
- `test_api_memory_search` -- POST /api/memory/search
|
||||
- `test_api_extensions_list` -- GET /api/extensions
|
||||
- `test_api_skills_list` -- GET /api/skills
|
||||
- `test_api_sse_connect` -- SSE stream connects and receives events
|
||||
- `test_api_auth_required` -- endpoints reject missing/bad tokens
|
||||
- `test_api_cors_headers` -- verify CORS configuration
|
||||
|
||||
### `src/channels/web/handlers/chat.rs` -- 26.1% -> 95% (+388 lines)
|
||||
|
||||
Chat message submission and SSE streaming.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_chat_submit_message` -- submit message, receive response
|
||||
- `test_chat_sse_stream` -- verify SSE event format
|
||||
- `test_chat_thread_context` -- messages scoped to thread
|
||||
- `test_chat_invalid_payload` -- reject malformed requests
|
||||
|
||||
### `src/channels/web/handlers/jobs.rs` -- 11.1% -> 95% (+430 lines)
|
||||
|
||||
Job CRUD endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_jobs_list_empty` -- empty list returns []
|
||||
- `test_jobs_create_and_get` -- create, then GET by ID
|
||||
- `test_jobs_cancel` -- cancel running job
|
||||
- `test_jobs_filter_by_status` -- filter by pending/running/completed
|
||||
- `test_jobs_pagination` -- limit/offset parameters
|
||||
|
||||
### `src/channels/web/handlers/routines.rs` -- 0% -> 95% (+236 lines)
|
||||
|
||||
Routine CRUD endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_routines_create` -- POST creates routine
|
||||
- `test_routines_list` -- GET lists all routines
|
||||
- `test_routines_update` -- PUT updates routine config
|
||||
- `test_routines_delete` -- DELETE removes routine
|
||||
- `test_routines_history` -- GET history for a routine
|
||||
|
||||
### `src/channels/web/handlers/extensions.rs` -- 0% -> 95% (+129 lines)
|
||||
|
||||
Extension management endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_extensions_list` -- list installed extensions
|
||||
- `test_extensions_install` -- install from manifest URL
|
||||
- `test_extensions_activate` -- activate/deactivate toggle
|
||||
- `test_extensions_remove` -- remove installed extension
|
||||
|
||||
### `src/channels/web/handlers/memory.rs` -- 0% -> 95% (+110 lines)
|
||||
|
||||
Memory/workspace endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_memory_search` -- search returns ranked results
|
||||
- `test_memory_write` -- write a document
|
||||
- `test_memory_read` -- read by path
|
||||
- `test_memory_tree` -- tree returns filesystem structure
|
||||
|
||||
### `src/channels/web/handlers/settings.rs` -- 0% -> 95% (+103 lines)
|
||||
|
||||
Settings endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_settings_get` -- retrieve current settings
|
||||
- `test_settings_update` -- update individual setting
|
||||
- `test_settings_validation` -- reject invalid setting values
|
||||
|
||||
### `src/channels/web/handlers/static_files.rs` -- 0% -> 95% (+97 lines)
|
||||
|
||||
Static file serving.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_static_index_html` -- GET / serves index.html
|
||||
- `test_static_css_js` -- serve CSS/JS with correct content types
|
||||
- `test_static_404` -- missing file returns 404
|
||||
|
||||
### `src/channels/wasm/wrapper.rs` -- 58.2% -> 95% (+822 lines)
|
||||
|
||||
WASM channel wrapper (message routing, lifecycle).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wasm_channel_start` -- initialize WASM channel module
|
||||
- `test_wasm_channel_message_routing` -- route incoming message to WASM
|
||||
- `test_wasm_channel_response` -- return WASM response to caller
|
||||
- `test_wasm_channel_error_handling` -- handle WASM trap gracefully
|
||||
- `test_wasm_channel_lifecycle` -- start, process, shutdown
|
||||
|
||||
### `src/channels/wasm/loader.rs` -- 38.1% -> 95% (+141 lines)
|
||||
|
||||
WASM channel discovery.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_channel_loader_scan` -- find channel WASM modules
|
||||
- `test_channel_loader_validation` -- reject invalid modules
|
||||
- `test_channel_loader_manifest` -- parse channel capabilities
|
||||
|
||||
### `src/channels/wasm/storage.rs` -- 0% -> 95% (+172 lines)
|
||||
|
||||
WASM channel state persistence.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_channel_storage_save_load` -- persist and restore channel state
|
||||
- `test_channel_storage_isolation` -- per-channel state isolation
|
||||
- `test_channel_storage_cleanup` -- remove state on channel uninstall
|
||||
|
||||
### `src/channels/signal.rs` -- 74% -> 95% (+381 lines)
|
||||
|
||||
Signal protocol channel.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_signal_message_send` -- send encrypted message
|
||||
- `test_signal_message_receive` -- decrypt incoming message
|
||||
- `test_signal_attachment_handling` -- handle media attachments
|
||||
- `test_signal_group_message` -- group chat routing
|
||||
- `test_signal_error_handling` -- handle connection failures
|
||||
|
||||
### `src/channels/repl.rs` -- 0% -> 95% (+221 lines)
|
||||
|
||||
Simple REPL channel.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_repl_input_parsing` -- parse user input lines
|
||||
- `test_repl_output_formatting` -- format agent responses
|
||||
- `test_repl_multiline` -- handle multi-line input
|
||||
- `test_repl_special_commands` -- handle /quit, /help
|
||||
|
||||
---
|
||||
|
||||
## Tier 4 -- CLI Tests (~2,100 lines)
|
||||
|
||||
CLI subcommands can be tested by invoking clap-parsed command structs directly
|
||||
or by calling the handler functions with constructed arguments.
|
||||
|
||||
### `src/cli/tool.rs` -- 2.9% -> 95% (+697 lines)
|
||||
|
||||
Tool CLI (install, list, remove, build).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_tool_list` -- list installed tools
|
||||
- `test_cli_tool_install_local` -- install from local .wasm file
|
||||
- `test_cli_tool_install_registry` -- install from registry
|
||||
- `test_cli_tool_remove` -- remove installed tool
|
||||
- `test_cli_tool_build` -- scaffold and build tool project
|
||||
- `test_cli_tool_info` -- display tool details
|
||||
|
||||
### `src/cli/mcp.rs` -- 0.9% -> 95% (+302 lines)
|
||||
|
||||
MCP server management CLI.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_mcp_list` -- list configured MCP servers
|
||||
- `test_cli_mcp_add` -- add MCP server config
|
||||
- `test_cli_mcp_remove` -- remove MCP server config
|
||||
- `test_cli_mcp_tools` -- list tools from MCP server
|
||||
- `test_cli_mcp_test_connection` -- verify MCP server reachable
|
||||
|
||||
### `src/cli/oauth_defaults.rs` -- 54.1% -> 95% (+298 lines)
|
||||
|
||||
OAuth default configurations.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_oauth_defaults_loading` -- load default OAuth configs
|
||||
- `test_oauth_url_construction` -- build auth/token URLs
|
||||
- `test_oauth_scope_merging` -- merge requested scopes with defaults
|
||||
- `test_oauth_provider_lookup` -- lookup by provider name
|
||||
|
||||
### `src/cli/registry.rs` -- 0% -> 95% (+168 lines)
|
||||
|
||||
Registry CLI commands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_registry_search` -- search for packages
|
||||
- `test_cli_registry_install` -- install package from registry
|
||||
- `test_cli_registry_info` -- display package details
|
||||
|
||||
### `src/cli/status.rs` -- 0% -> 95% (+142 lines)
|
||||
|
||||
Status display commands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_status_gathering` -- collect system status info
|
||||
- `test_cli_status_formatting` -- render status output
|
||||
- `test_cli_status_components` -- check individual components
|
||||
|
||||
### `src/cli/memory.rs` -- 15.5% -> 95% (+138 lines)
|
||||
|
||||
Memory CLI subcommands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_memory_search` -- search workspace from CLI
|
||||
- `test_cli_memory_write` -- write document from CLI
|
||||
- `test_cli_memory_read` -- read document from CLI
|
||||
- `test_cli_memory_tree` -- display memory tree
|
||||
|
||||
### `src/cli/doctor.rs` -- 28.7% -> 95% (+115 lines)
|
||||
|
||||
Diagnostic checks.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_doctor_check_database` -- verify DB connectivity check
|
||||
- `test_doctor_check_llm` -- verify LLM provider check
|
||||
- `test_doctor_check_tools` -- verify tool availability check
|
||||
- `test_doctor_report_format` -- verify output format
|
||||
|
||||
### `src/cli/config.rs` -- 36.5% -> 95% (~100 lines)
|
||||
|
||||
Config CLI subcommands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_config_get` -- read config value
|
||||
- `test_cli_config_set` -- write config value
|
||||
- `test_cli_config_list` -- list all config keys
|
||||
- `test_cli_config_reset` -- reset to defaults
|
||||
|
||||
---
|
||||
|
||||
## Tier 5 -- Setup/Infra Tests (~2,400 lines)
|
||||
|
||||
Hardest to test: interactive wizards, Docker, process spawning. Strategy: extract
|
||||
pure logic into testable functions, test the interactive parts by injecting mock
|
||||
input.
|
||||
|
||||
### `src/setup/wizard.rs` -- 16.8% -> 95% (+1,681 lines)
|
||||
|
||||
7-step interactive onboarding wizard. Refactor to extract validation functions,
|
||||
step logic, and config generation into testable units.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wizard_step_validation` -- each step validates input correctly
|
||||
- `test_wizard_config_generation` -- generate config from wizard answers
|
||||
- `test_wizard_default_values` -- verify sensible defaults
|
||||
- `test_wizard_skip_completed` -- skip already-configured steps
|
||||
- `test_wizard_llm_backend_selection` -- provider-specific config paths
|
||||
- `test_wizard_channel_setup` -- channel configuration logic
|
||||
|
||||
### `src/setup/channels.rs` -- 7.6% -> 95% (+563 lines)
|
||||
|
||||
Channel setup helpers.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_channel_setup_defaults` -- default channel configuration
|
||||
- `test_channel_setup_validation` -- reject invalid channel configs
|
||||
- `test_channel_setup_telegram` -- Telegram-specific setup logic
|
||||
- `test_channel_setup_signal` -- Signal-specific setup logic
|
||||
- `test_channel_setup_webhook` -- webhook URL validation
|
||||
|
||||
### `src/setup/prompts.rs` -- 24.8% -> 95% (+147 lines)
|
||||
|
||||
Terminal prompt utilities.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_prompt_select` -- selection from list
|
||||
- `test_prompt_confirm` -- yes/no confirmation
|
||||
- `test_prompt_secret` -- masked input
|
||||
- `test_prompt_validation` -- input validation rules
|
||||
|
||||
### `src/sandbox/container.rs` -- 22.1% -> 95% (+296 lines)
|
||||
|
||||
Docker container lifecycle. Test command construction without actual Docker.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_container_config_to_docker_args` -- generate correct docker run args
|
||||
- `test_container_volume_mounts` -- workspace mount configuration
|
||||
- `test_container_env_scrubbing` -- sensitive env vars removed
|
||||
- `test_container_resource_limits` -- CPU/memory limit args
|
||||
- `test_container_network_config` -- proxy network setup
|
||||
|
||||
### `src/sandbox/manager.rs` -- 59% -> 95% (+114 lines)
|
||||
|
||||
Sandbox orchestration.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_sandbox_policy_enforcement` -- policy to container config mapping
|
||||
- `test_sandbox_cleanup` -- cleanup on job completion
|
||||
- `test_sandbox_concurrent_limit` -- enforce max concurrent containers
|
||||
|
||||
### `src/sandbox/proxy/http.rs` -- 37.5% -> 95% (+176 lines)
|
||||
|
||||
HTTP proxy for container network access.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_proxy_allowlist_enforcement` -- block disallowed domains
|
||||
- `test_proxy_credential_injection` -- inject auth headers
|
||||
- `test_proxy_connect_tunnel` -- HTTPS CONNECT method handling
|
||||
- `test_proxy_logging` -- request/response logging
|
||||
|
||||
### `src/worker/runtime.rs` -- 5.7% -> 95% (+312 lines)
|
||||
|
||||
Worker execution loop (runs inside containers).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_worker_tool_dispatch` -- dispatch tool call, return result
|
||||
- `test_worker_llm_interaction` -- send prompt, receive response
|
||||
- `test_worker_turn_limit` -- enforce max turns
|
||||
- `test_worker_error_propagation` -- tool error surfaces to agent
|
||||
|
||||
### `src/worker/claude_bridge.rs` -- 60.7% -> 95% (+215 lines)
|
||||
|
||||
Claude CLI bridge.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_claude_command_construction` -- build claude CLI command
|
||||
- `test_claude_output_parsing` -- parse claude CLI JSON output
|
||||
- `test_claude_error_handling` -- handle CLI crashes gracefully
|
||||
- `test_claude_config_injection` -- inject config dir and model
|
||||
|
||||
### `src/worker/api.rs` -- 19.8% -> 95% (+194 lines)
|
||||
|
||||
Worker HTTP client to orchestrator.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_worker_api_request_building` -- correct endpoint URLs and headers
|
||||
- `test_worker_api_response_parsing` -- parse orchestrator responses
|
||||
- `test_worker_api_auth_token` -- bearer token injection
|
||||
- `test_worker_api_retry` -- retry on transient failures
|
||||
|
||||
### `src/main.rs` -- 29.4% -> 95% (+485 lines)
|
||||
|
||||
Entry point and startup. Extract startup logic into testable functions.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_arg_parsing` -- verify clap argument parsing
|
||||
- `test_startup_config_loading` -- config from env + file
|
||||
- `test_startup_channel_selection` -- select channels from config
|
||||
- `test_startup_feature_flags` -- feature-gated code paths
|
||||
|
||||
---
|
||||
|
||||
## Tier 6 -- Remaining Files to 95% (~2,000 lines)
|
||||
|
||||
Smaller files that each need a handful of additional tests.
|
||||
|
||||
| File | Lines Needed | Test Focus |
|
||||
|------|-------------:|------------|
|
||||
| `src/tools/builtin/skill_tools.rs` | 110 | skill_list, skill_search, skill_install, skill_remove |
|
||||
| `src/hooks/bundled.rs` | 115 | bundled hook execution, hook discovery |
|
||||
| `src/registry/installer.rs` | 272 | package download, verification, installation |
|
||||
| `src/registry/artifacts.rs` | 72 | artifact packaging, checksums |
|
||||
| `src/orchestrator/job_manager.rs` | 249 | container lifecycle, job routing |
|
||||
| `src/orchestrator/api.rs` | 125 | LLM proxy, event dispatch endpoints |
|
||||
| `src/app.rs` | 137 | AppBuilder configuration, startup sequence |
|
||||
| `src/service.rs` | 120 | service lifecycle, signal handling |
|
||||
| `src/config/channels.rs` | 55 | channel config parsing |
|
||||
| `src/config/sandbox.rs` | 61 | sandbox config parsing |
|
||||
| `src/config/tunnel.rs` | 43 | tunnel config parsing |
|
||||
| `src/config/mod.rs` | 63 | config merging, env override |
|
||||
| `src/config/database.rs` | 38 | database URL parsing |
|
||||
| `src/evaluation/success.rs` | 34 | success evaluator logic |
|
||||
| `src/evaluation/metrics.rs` | 40 | metrics collection |
|
||||
| `src/context/manager.rs` | 57 | concurrent job context isolation |
|
||||
| `src/context/memory.rs` | 36 | action recording, conversation memory |
|
||||
|
||||
---
|
||||
|
||||
## Execution Priority
|
||||
|
||||
Maximize coverage gain per unit of effort:
|
||||
|
||||
| Order | Category | Lines Gained | Effort |
|
||||
|------:|----------|-------------:|--------|
|
||||
| 1 | Trace tests (Tier 2) | ~7,000 | Medium (high leverage, each test covers many modules) |
|
||||
| 2 | Unit tests for 0% files (Tier 1 subset) | ~3,500 | Low (pure logic, no infrastructure) |
|
||||
| 3 | Web handler tests (Tier 3) | ~4,500 | Medium (axum_test + in-memory DB) |
|
||||
| 4 | Extension/MCP/WASM unit tests (Tier 1 remainder) | ~3,500 | Medium |
|
||||
| 5 | CLI subcommand tests (Tier 4) | ~2,100 | Low-Medium |
|
||||
| 6 | Setup wizard extraction + tests (Tier 5) | ~2,400 | High (requires refactoring) |
|
||||
| 7 | LLM provider tests (Tier 1 subset) | ~800 | Medium |
|
||||
| 8 | Remaining small files (Tier 6) | ~2,000 | Low |
|
||||
|
||||
## Notes
|
||||
|
||||
- All trace tests require `--features libsql` and use `TestRigBuilder` from `tests/support/`
|
||||
- Web handler tests can use `axum::test` helpers or build the router directly
|
||||
- CLI tests should call handler functions directly, not shell out to the binary
|
||||
- Setup wizard tests require extracting pure logic from interactive prompts first
|
||||
- Sandbox/container tests should verify command construction, not run Docker
|
||||
- Worker tests can use `TraceLlm` for the LLM provider, same as trace tests
|
||||
Generated
+1
-130
@@ -17,15 +17,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "adobe-cmap-parser"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3"
|
||||
dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
@@ -185,9 +176,6 @@ name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
@@ -1534,17 +1522,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1833,15 +1810,6 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.20.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -2860,7 +2828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.16.1"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -2895,7 +2863,6 @@ dependencies = [
|
||||
"lru",
|
||||
"mime_guess",
|
||||
"open",
|
||||
"pdf-extract",
|
||||
"pgvector",
|
||||
"postgres-types",
|
||||
"pretty_assertions",
|
||||
@@ -2943,7 +2910,6 @@ dependencies = [
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"zbus",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3285,24 +3251,6 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"nom",
|
||||
"rangemap",
|
||||
"time",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.3"
|
||||
@@ -3846,21 +3794,6 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-extract"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575"
|
||||
dependencies = [
|
||||
"adobe-cmap-parser",
|
||||
"encoding_rs",
|
||||
"euclid",
|
||||
"lopdf",
|
||||
"postscript",
|
||||
"type1-encoding-parser",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peeking_take_while"
|
||||
version = "0.1.2"
|
||||
@@ -4060,12 +3993,6 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pom"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6"
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -4111,12 +4038,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postscript"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -4394,12 +4315,6 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
@@ -6376,15 +6291,6 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "type1-encoding-parser"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b"
|
||||
dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -7136,12 +7042,6 @@ dependencies = [
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
@@ -7949,41 +7849,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
+2
-6
@@ -18,7 +18,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.16.1"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
@@ -40,7 +40,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -147,10 +147,6 @@ bollard = "0.18"
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
# Document text extraction
|
||||
pdf-extract = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# HTTP proxy for sandboxed network access
|
||||
hyper = { version = "1.5", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] }
|
||||
|
||||
@@ -28,7 +28,6 @@ COPY migrations/ migrations/
|
||||
COPY registry/ registry/
|
||||
COPY channels-src/ channels-src/
|
||||
COPY wit/ wit/
|
||||
COPY providers.json providers.json
|
||||
|
||||
RUN cargo build --release --bin ironclaw
|
||||
|
||||
|
||||
+3
-16
@@ -119,7 +119,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||
| Per-channel media limits | ✅ | ✅ | Attachment type in WIT; max 10 per msg, 20MB total, MIME allowlist |
|
||||
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
|
||||
| Typing indicators | ✅ | 🚧 | TUI + Telegram typing/actionable status prompts; richer parity pending |
|
||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
|
||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||
@@ -248,32 +248,19 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| WIT inbound-attachment type | N/A | ✅ | P1 | `inbound-attachment` record in channel-host (id, mime_type, filename, size_bytes, source_url, storage_key, extracted_text) |
|
||||
| WIT outbound attachment type | N/A | ✅ | P1 | `attachment` record in channel (filename, mime_type, data) on `agent-response` |
|
||||
| WIT on-broadcast export | N/A | ✅ | P1 | Proactive message sending without prior incoming message |
|
||||
| IncomingMessage attachments | N/A | ✅ | P1 | `IncomingAttachment` struct on `IncomingMessage`, populated from WASM channels |
|
||||
| OutgoingResponse attachments | N/A | ✅ | P1 | File paths on `OutgoingResponse`, read from disk and sent as WIT attachments |
|
||||
| Attachment security (size/MIME) | N/A | ✅ | P1 | Inbound: max 10, 20MB total, MIME allowlist. Outbound: 50MB total |
|
||||
| Telegram media parsing | ✅ | ✅ | P1 | Photo, document, audio, video, voice, sticker parsed and emitted as attachments |
|
||||
| Telegram media sending | ✅ | ✅ | P1 | sendPhoto/sendDocument multipart upload, auto photo→document fallback >10MB |
|
||||
| Slack file parsing | ✅ | ✅ | P1 | `files` array from Events API parsed into attachments |
|
||||
| WhatsApp media parsing | ✅ | ✅ | P1 | Image, audio, video, document parsed with caption as extracted_text |
|
||||
| Discord attachment parsing | ✅ | ❌ | P2 | Discord interaction payloads don't include file attachments (needs message events) |
|
||||
| HTTP tool save_to | N/A | ✅ | P1 | Download binary files to /tmp/ for attachment sending (50MB limit, path traversal protection) |
|
||||
| Credential env var fallback | N/A | ✅ | P2 | Channels can use env vars (e.g., TELEGRAM_BOT_TOKEN) when secrets store not configured |
|
||||
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
|
||||
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
|
||||
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
|
||||
| Audio transcription | ✅ | ❌ | P2 | |
|
||||
| Video support | ✅ | ❌ | P3 | |
|
||||
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||
| MIME detection | ✅ | ✅ | P2 | MIME allowlist in host validates attachment types |
|
||||
| MIME detection | ✅ | ❌ | P2 | |
|
||||
| Media caching | ✅ | ❌ | P3 | |
|
||||
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
|
||||
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
|
||||
| TTS (OpenAI) | ✅ | ❌ | P3 | |
|
||||
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
|
||||
| Sticker-to-image | ✅ | ✅ | P3 | Telegram stickers emitted as image/webp attachments |
|
||||
| Sticker-to-image | ✅ | ❌ | P3 | Telegram stickers |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "discord-channel"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Discord channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
|
||||
@@ -312,10 +312,6 @@ impl Guest for DiscordChannel {
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
||||
Err("broadcast not yet implemented for Discord channel".to_string())
|
||||
}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -418,7 +414,6 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
|
||||
content,
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
attachments: vec![],
|
||||
});
|
||||
true
|
||||
}
|
||||
@@ -472,7 +467,6 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
|
||||
content: format!("[Button clicked] {}", message.content),
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
attachments: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -689,34 +683,4 @@ mod tests {
|
||||
assert_eq!(parsed.channel_id, "123");
|
||||
assert_eq!(parsed.interaction_id, "456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_slash_command_interaction() {
|
||||
// Verify that a slash command interaction deserializes correctly.
|
||||
let json = r#"{
|
||||
"type": 2,
|
||||
"id": "int_1",
|
||||
"application_id": "app_1",
|
||||
"channel_id": "ch_1",
|
||||
"member": {
|
||||
"user": {
|
||||
"id": "user_1",
|
||||
"username": "testuser",
|
||||
"global_name": "Test User"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"id": "cmd_1",
|
||||
"name": "ask",
|
||||
"options": [
|
||||
{"name": "question", "value": "What is rust?"}
|
||||
]
|
||||
},
|
||||
"token": "token_abc"
|
||||
}"#;
|
||||
|
||||
let interaction: DiscordInteraction = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(interaction.interaction_type, 2);
|
||||
assert!(interaction.data.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "slack-channel"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Slack Events API channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "slack",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
|
||||
@@ -29,7 +29,7 @@ use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
/// Slack event wrapper.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -78,25 +78,6 @@ struct SlackEvent {
|
||||
|
||||
/// Subtype (bot_message, etc.)
|
||||
subtype: Option<String>,
|
||||
|
||||
/// File attachments shared in the message.
|
||||
#[serde(default)]
|
||||
files: Option<Vec<SlackFile>>,
|
||||
}
|
||||
|
||||
/// Slack file attachment.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackFile {
|
||||
/// File ID.
|
||||
id: String,
|
||||
/// MIME type.
|
||||
mimetype: Option<String>,
|
||||
/// Original filename.
|
||||
name: Option<String>,
|
||||
/// File size in bytes.
|
||||
size: Option<u64>,
|
||||
/// URL to download the file (requires auth).
|
||||
url_private: Option<String>,
|
||||
}
|
||||
|
||||
/// Metadata stored with emitted messages for response routing.
|
||||
@@ -325,42 +306,13 @@ impl Guest for SlackChannel {
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
||||
Err("broadcast not yet implemented for Slack channel".to_string())
|
||||
}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract attachments from Slack file objects.
|
||||
fn extract_slack_attachments(files: &Option<Vec<SlackFile>>) -> Vec<InboundAttachment> {
|
||||
let Some(files) = files else {
|
||||
return Vec::new();
|
||||
};
|
||||
files
|
||||
.iter()
|
||||
.map(|f| InboundAttachment {
|
||||
id: f.id.clone(),
|
||||
mime_type: f
|
||||
.mimetype
|
||||
.clone()
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
filename: f.name.clone(),
|
||||
size_bytes: f.size,
|
||||
source_url: f.url_private.clone(),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Handle a Slack event and emit message if applicable.
|
||||
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
||||
let attachments = extract_slack_attachments(&event.files);
|
||||
|
||||
match event.event_type.as_str() {
|
||||
// Direct mention of the bot (always in a channel, not a DM)
|
||||
"app_mention" => {
|
||||
@@ -374,14 +326,7 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
||||
if !check_sender_permission(&user, &channel, false) {
|
||||
return;
|
||||
}
|
||||
emit_message(
|
||||
user,
|
||||
text,
|
||||
channel,
|
||||
event.thread_ts.or(Some(ts)),
|
||||
team_id,
|
||||
attachments,
|
||||
);
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,14 +348,7 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
||||
if !check_sender_permission(&user, &channel, true) {
|
||||
return;
|
||||
}
|
||||
emit_message(
|
||||
user,
|
||||
text,
|
||||
channel,
|
||||
event.thread_ts.or(Some(ts)),
|
||||
team_id,
|
||||
attachments,
|
||||
);
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,7 +369,6 @@ fn emit_message(
|
||||
channel: String,
|
||||
thread_ts: Option<String>,
|
||||
team_id: Option<String>,
|
||||
attachments: Vec<InboundAttachment>,
|
||||
) {
|
||||
let message_ts = thread_ts.clone().unwrap_or_default();
|
||||
|
||||
@@ -459,7 +396,6 @@ fn emit_message(
|
||||
content: cleaned_text,
|
||||
thread_id: thread_ts,
|
||||
metadata_json,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -615,111 +551,3 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse
|
||||
|
||||
// Export the component
|
||||
export!(SlackChannel);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_with_files() {
|
||||
let files = Some(vec![
|
||||
SlackFile {
|
||||
id: "F123".to_string(),
|
||||
mimetype: Some("image/png".to_string()),
|
||||
name: Some("screenshot.png".to_string()),
|
||||
size: Some(50000),
|
||||
url_private: Some("https://files.slack.com/F123".to_string()),
|
||||
},
|
||||
SlackFile {
|
||||
id: "F456".to_string(),
|
||||
mimetype: Some("application/pdf".to_string()),
|
||||
name: Some("doc.pdf".to_string()),
|
||||
size: Some(120000),
|
||||
url_private: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let attachments = extract_slack_attachments(&files);
|
||||
assert_eq!(attachments.len(), 2);
|
||||
|
||||
assert_eq!(attachments[0].id, "F123");
|
||||
assert_eq!(attachments[0].mime_type, "image/png");
|
||||
assert_eq!(attachments[0].filename, Some("screenshot.png".to_string()));
|
||||
assert_eq!(attachments[0].size_bytes, Some(50000));
|
||||
assert_eq!(
|
||||
attachments[0].source_url,
|
||||
Some("https://files.slack.com/F123".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(attachments[1].id, "F456");
|
||||
assert_eq!(attachments[1].mime_type, "application/pdf");
|
||||
assert!(attachments[1].source_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_none() {
|
||||
let attachments = extract_slack_attachments(&None);
|
||||
assert!(attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_empty() {
|
||||
let attachments = extract_slack_attachments(&Some(vec![]));
|
||||
assert!(attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_missing_mime() {
|
||||
let files = Some(vec![SlackFile {
|
||||
id: "F789".to_string(),
|
||||
mimetype: None,
|
||||
name: Some("unknown".to_string()),
|
||||
size: None,
|
||||
url_private: None,
|
||||
}]);
|
||||
|
||||
let attachments = extract_slack_attachments(&files);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].mime_type, "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_slack_event_with_files() {
|
||||
let json = r#"{
|
||||
"type": "message",
|
||||
"user": "U123",
|
||||
"channel": "D456",
|
||||
"text": "Check this file",
|
||||
"ts": "1234567890.000001",
|
||||
"files": [
|
||||
{
|
||||
"id": "F001",
|
||||
"mimetype": "image/jpeg",
|
||||
"name": "photo.jpg",
|
||||
"size": 30000,
|
||||
"url_private": "https://files.slack.com/F001"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let event: SlackEvent = serde_json::from_str(json).unwrap();
|
||||
assert!(event.files.is_some());
|
||||
let files = event.files.unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].id, "F001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_slack_event_without_files() {
|
||||
let json = r#"{
|
||||
"type": "message",
|
||||
"user": "U123",
|
||||
"channel": "D456",
|
||||
"text": "Just text",
|
||||
"ts": "1234567890.000001"
|
||||
}"#;
|
||||
|
||||
let event: SlackEvent = serde_json::from_str(json).unwrap();
|
||||
assert!(event.files.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -212,7 +212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telegram-channel"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telegram-channel"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Telegram Bot API channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "telegram",
|
||||
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
|
||||
@@ -17,8 +17,7 @@
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "api.telegram.org", "path_prefix": "/bot" },
|
||||
{ "host": "api.telegram.org", "path_prefix": "/file/bot" }
|
||||
{ "host": "api.telegram.org", "path_prefix": "/bot" }
|
||||
],
|
||||
"credentials": {
|
||||
"telegram_bot": {
|
||||
@@ -27,7 +26,6 @@
|
||||
"host_patterns": ["api.telegram.org"]
|
||||
}
|
||||
},
|
||||
"max_response_bytes": 52428800,
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 30,
|
||||
"requests_per_hour": 1000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whatsapp-channel"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WhatsApp Cloud API channel for IronClaw"
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
// ============================================================================
|
||||
// WhatsApp Cloud API Types
|
||||
@@ -137,46 +137,10 @@ struct WhatsAppMessage {
|
||||
/// Text content (if type is "text")
|
||||
text: Option<TextContent>,
|
||||
|
||||
/// Image content
|
||||
image: Option<WhatsAppMedia>,
|
||||
|
||||
/// Audio content
|
||||
audio: Option<WhatsAppMedia>,
|
||||
|
||||
/// Video content
|
||||
video: Option<WhatsAppMedia>,
|
||||
|
||||
/// Document content
|
||||
document: Option<WhatsAppDocument>,
|
||||
|
||||
/// Context for replies
|
||||
context: Option<MessageContext>,
|
||||
}
|
||||
|
||||
/// WhatsApp media attachment (image, audio, video).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhatsAppMedia {
|
||||
/// Media ID (use to download via Graph API)
|
||||
id: String,
|
||||
/// MIME type
|
||||
mime_type: Option<String>,
|
||||
/// Caption text
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
/// WhatsApp document attachment.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhatsAppDocument {
|
||||
/// Media ID
|
||||
id: String,
|
||||
/// MIME type
|
||||
mime_type: Option<String>,
|
||||
/// Filename
|
||||
filename: Option<String>,
|
||||
/// Caption text
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
/// Text message content.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TextContent {
|
||||
@@ -512,10 +476,6 @@ impl Guest for WhatsAppChannel {
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
||||
Err("broadcast not yet implemented for WhatsApp channel".to_string())
|
||||
}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -658,102 +618,26 @@ fn handle_incoming_message(req: &IncomingHttpRequest) -> OutgoingHttpResponse {
|
||||
json_response(200, serde_json::json!({"status": "ok"}))
|
||||
}
|
||||
|
||||
/// Extract attachments from a WhatsApp message.
|
||||
fn extract_whatsapp_attachments(message: &WhatsAppMessage) -> Vec<InboundAttachment> {
|
||||
let mut attachments = Vec::new();
|
||||
|
||||
if let Some(ref img) = message.image {
|
||||
attachments.push(InboundAttachment {
|
||||
id: img.id.clone(),
|
||||
mime_type: img
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image/jpeg".to_string()),
|
||||
filename: None,
|
||||
size_bytes: None,
|
||||
source_url: None, // WhatsApp requires Graph API call with media ID to get URL
|
||||
storage_key: None,
|
||||
extracted_text: img.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref audio) = message.audio {
|
||||
attachments.push(InboundAttachment {
|
||||
id: audio.id.clone(),
|
||||
mime_type: audio
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "audio/ogg".to_string()),
|
||||
filename: None,
|
||||
size_bytes: None,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: audio.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref video) = message.video {
|
||||
attachments.push(InboundAttachment {
|
||||
id: video.id.clone(),
|
||||
mime_type: video
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "video/mp4".to_string()),
|
||||
filename: None,
|
||||
size_bytes: None,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: video.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref doc) = message.document {
|
||||
attachments.push(InboundAttachment {
|
||||
id: doc.id.clone(),
|
||||
mime_type: doc
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
filename: doc.filename.clone(),
|
||||
size_bytes: None,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: doc.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
attachments
|
||||
}
|
||||
|
||||
/// Process a single WhatsApp message.
|
||||
fn handle_message(
|
||||
message: &WhatsAppMessage,
|
||||
phone_number_id: &str,
|
||||
contact_names: &std::collections::HashMap<String, String>,
|
||||
) {
|
||||
let attachments = extract_whatsapp_attachments(message);
|
||||
// Only handle text messages for now
|
||||
// TODO: Add support for image, audio, video, document, etc.
|
||||
if message.message_type != "text" {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!("Skipping non-text message type: {}", message.message_type),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract text content (from text body or media captions)
|
||||
// Extract text content
|
||||
let text = match &message.text {
|
||||
Some(t) if !t.body.is_empty() => t.body.clone(),
|
||||
_ => {
|
||||
// Try to use caption from media messages as content
|
||||
let caption = message
|
||||
.image
|
||||
.as_ref()
|
||||
.and_then(|m| m.caption.clone())
|
||||
.or_else(|| message.video.as_ref().and_then(|m| m.caption.clone()))
|
||||
.or_else(|| message.document.as_ref().and_then(|m| m.caption.clone()));
|
||||
match caption {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ if !attachments.is_empty() => String::new(),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Look up sender's name from contacts
|
||||
@@ -786,7 +670,6 @@ fn handle_message(
|
||||
content: text,
|
||||
thread_id: None, // WhatsApp doesn't have threads like Slack/Discord
|
||||
metadata_json,
|
||||
attachments,
|
||||
});
|
||||
|
||||
channel_host::log(
|
||||
@@ -1064,138 +947,4 @@ mod tests {
|
||||
assert_eq!(parsed.phone_number_id, "123456");
|
||||
assert_eq!(parsed.sender_phone, "15551234567");
|
||||
}
|
||||
|
||||
// === Attachment extraction fixture tests ===
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_image_attachment() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg1".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "image".to_string(),
|
||||
text: None,
|
||||
image: Some(WhatsAppMedia {
|
||||
id: "media_img_1".to_string(),
|
||||
mime_type: Some("image/jpeg".to_string()),
|
||||
caption: Some("Look at this".to_string()),
|
||||
}),
|
||||
audio: None,
|
||||
video: None,
|
||||
document: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].id, "media_img_1");
|
||||
assert_eq!(attachments[0].mime_type, "image/jpeg");
|
||||
assert_eq!(
|
||||
attachments[0].extracted_text,
|
||||
Some("Look at this".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_document_attachment() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg2".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "document".to_string(),
|
||||
text: None,
|
||||
image: None,
|
||||
audio: None,
|
||||
video: None,
|
||||
document: Some(WhatsAppDocument {
|
||||
id: "media_doc_1".to_string(),
|
||||
mime_type: Some("application/pdf".to_string()),
|
||||
filename: Some("report.pdf".to_string()),
|
||||
caption: None,
|
||||
}),
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].id, "media_doc_1");
|
||||
assert_eq!(attachments[0].mime_type, "application/pdf");
|
||||
assert_eq!(
|
||||
attachments[0].filename,
|
||||
Some("report.pdf".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_audio_video_attachments() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg3".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "audio".to_string(),
|
||||
text: None,
|
||||
image: None,
|
||||
audio: Some(WhatsAppMedia {
|
||||
id: "media_audio_1".to_string(),
|
||||
mime_type: Some("audio/ogg".to_string()),
|
||||
caption: None,
|
||||
}),
|
||||
video: Some(WhatsAppMedia {
|
||||
id: "media_video_1".to_string(),
|
||||
mime_type: Some("video/mp4".to_string()),
|
||||
caption: None,
|
||||
}),
|
||||
document: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 2);
|
||||
assert_eq!(attachments[0].id, "media_audio_1");
|
||||
assert_eq!(attachments[1].id, "media_video_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_text_only_no_attachments() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg4".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "text".to_string(),
|
||||
text: Some(TextContent {
|
||||
body: "Hello".to_string(),
|
||||
}),
|
||||
image: None,
|
||||
audio: None,
|
||||
video: None,
|
||||
document: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert!(attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_whatsapp_image_message() {
|
||||
let json = r#"{
|
||||
"id": "wamid.123",
|
||||
"from": "15551234567",
|
||||
"timestamp": "1234567890",
|
||||
"type": "image",
|
||||
"image": {
|
||||
"id": "media_img_abc",
|
||||
"mime_type": "image/jpeg",
|
||||
"caption": "Check this"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let msg: WhatsAppMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.message_type, "image");
|
||||
assert!(msg.image.is_some());
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].id, "media_img_abc");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "whatsapp",
|
||||
"description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages",
|
||||
|
||||
-253
@@ -1,253 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "openai",
|
||||
"aliases": ["open_ai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"api_key_required": true,
|
||||
"base_url_env": "OPENAI_BASE_URL",
|
||||
"model_env": "OPENAI_MODEL",
|
||||
"default_model": "gpt-4o",
|
||||
"description": "OpenAI GPT models (direct API)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openai_api_key",
|
||||
"key_url": "https://platform.openai.com/api-keys",
|
||||
"display_name": "OpenAI",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"aliases": ["claude"],
|
||||
"protocol": "anthropic",
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"api_key_required": true,
|
||||
"base_url_env": "ANTHROPIC_BASE_URL",
|
||||
"model_env": "ANTHROPIC_MODEL",
|
||||
"default_model": "claude-sonnet-4-20250514",
|
||||
"description": "Anthropic Claude models (direct API)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_anthropic_api_key",
|
||||
"key_url": "https://console.anthropic.com/settings/keys",
|
||||
"display_name": "Anthropic",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"aliases": [],
|
||||
"protocol": "ollama",
|
||||
"default_base_url": "http://localhost:11434",
|
||||
"base_url_env": "OLLAMA_BASE_URL",
|
||||
"model_env": "OLLAMA_MODEL",
|
||||
"default_model": "llama3",
|
||||
"description": "Local Ollama instance (no API key needed)",
|
||||
"setup": {
|
||||
"kind": "ollama",
|
||||
"display_name": "Ollama",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openai_compatible",
|
||||
"aliases": ["openai-compatible", "compatible"],
|
||||
"protocol": "open_ai_completions",
|
||||
"base_url_env": "LLM_BASE_URL",
|
||||
"base_url_required": true,
|
||||
"api_key_env": "LLM_API_KEY",
|
||||
"api_key_required": false,
|
||||
"model_env": "LLM_MODEL",
|
||||
"default_model": "default",
|
||||
"extra_headers_env": "LLM_EXTRA_HEADERS",
|
||||
"description": "Custom OpenAI-compatible endpoint (vLLM, LiteLLM, etc.)",
|
||||
"setup": {
|
||||
"kind": "open_ai_compatible",
|
||||
"secret_name": "llm_compatible_api_key",
|
||||
"display_name": "OpenAI-compatible",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tinfoil",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://inference.tinfoil.sh/v1",
|
||||
"api_key_env": "TINFOIL_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "TINFOIL_MODEL",
|
||||
"default_model": "kimi-k2-5",
|
||||
"description": "Tinfoil private inference (hardware-attested TEE)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_tinfoil_api_key",
|
||||
"key_url": "https://tinfoil.sh",
|
||||
"display_name": "Tinfoil",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openrouter",
|
||||
"aliases": ["open_router"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "OPENROUTER_MODEL",
|
||||
"default_model": "openai/gpt-4o",
|
||||
"description": "OpenRouter multi-provider gateway (200+ models)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openrouter_api_key",
|
||||
"key_url": "https://openrouter.ai/settings/keys",
|
||||
"display_name": "OpenRouter",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "groq",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.groq.com/openai/v1",
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "GROQ_MODEL",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"description": "Groq LPU inference (ultra-fast)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_groq_api_key",
|
||||
"key_url": "https://console.groq.com/keys",
|
||||
"display_name": "Groq",
|
||||
"can_list_models": true,
|
||||
"models_filter": "chat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nvidia",
|
||||
"aliases": ["nvidia_nim", "nim"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key_env": "NVIDIA_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "NVIDIA_MODEL",
|
||||
"default_model": "meta/llama-3.3-70b-instruct",
|
||||
"description": "NVIDIA NIM API (high-performance inference)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_nvidia_api_key",
|
||||
"key_url": "https://build.nvidia.com",
|
||||
"display_name": "NVIDIA NIM",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "venice",
|
||||
"aliases": ["venice_ai", "veniceai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.venice.ai/api/v1",
|
||||
"api_key_env": "VENICE_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "VENICE_MODEL",
|
||||
"default_model": "llama-3.3-70b",
|
||||
"description": "Venice.ai privacy-focused inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_venice_api_key",
|
||||
"key_url": "https://venice.ai/settings/api",
|
||||
"display_name": "Venice.ai",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "together",
|
||||
"aliases": ["together_ai", "togetherai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.together.xyz/v1",
|
||||
"api_key_env": "TOGETHER_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "TOGETHER_MODEL",
|
||||
"default_model": "meta-llama/Llama-3-70b-chat-hf",
|
||||
"description": "Together AI inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_together_api_key",
|
||||
"key_url": "https://api.together.ai/settings/api-keys",
|
||||
"display_name": "Together AI",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fireworks",
|
||||
"aliases": ["fireworks_ai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.fireworks.ai/inference/v1",
|
||||
"api_key_env": "FIREWORKS_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "FIREWORKS_MODEL",
|
||||
"default_model": "accounts/fireworks/models/llama-v3p1-70b-instruct",
|
||||
"description": "Fireworks AI inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_fireworks_api_key",
|
||||
"key_url": "https://fireworks.ai/api-keys",
|
||||
"display_name": "Fireworks AI",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek",
|
||||
"aliases": ["deep_seek"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.deepseek.com/v1",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "DEEPSEEK_MODEL",
|
||||
"default_model": "deepseek-chat",
|
||||
"description": "DeepSeek inference API",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_deepseek_api_key",
|
||||
"key_url": "https://platform.deepseek.com/api_keys",
|
||||
"display_name": "DeepSeek",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cerebras",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.cerebras.ai/v1",
|
||||
"api_key_env": "CEREBRAS_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "CEREBRAS_MODEL",
|
||||
"default_model": "llama-3.3-70b",
|
||||
"description": "Cerebras wafer-scale inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_cerebras_api_key",
|
||||
"key_url": "https://cloud.cerebras.ai",
|
||||
"display_name": "Cerebras",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sambanova",
|
||||
"aliases": ["samba_nova"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.sambanova.ai/v1",
|
||||
"api_key_env": "SAMBANOVA_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "SAMBANOVA_MODEL",
|
||||
"default_model": "Meta-Llama-3.1-70B-Instruct",
|
||||
"description": "SambaNova Cloud inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_sambanova_api_key",
|
||||
"key_url": "https://cloud.sambanova.ai/apis",
|
||||
"display_name": "SambaNova",
|
||||
"can_list_models": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "discord",
|
||||
"display_name": "Discord Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent in Discord",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "56b8d92e3e0f32d9cfaf0dc1ac8aa8c42b98cda4d92cd42a74e0af01eeee60a1"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "slack",
|
||||
"display_name": "Slack Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent in Slack",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "536b52179094d228e18d51b203286c80f800e0132b42fa08ce292c76a13a70e8"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent through a Telegram bot",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "9bcc39d717c2b7e4e3327d31fc0032fbe4bf666852622c69b88dbaee236f77ee"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "whatsapp",
|
||||
"display_name": "WhatsApp Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent through WhatsApp",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "54dc4a6c2b4e07bce6de54cab415920be518c94a096df45f1104f0fa5c8f6382"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "github",
|
||||
"display_name": "GitHub",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "GitHub integration for issues, PRs, repos, and code search",
|
||||
"keywords": [
|
||||
"git",
|
||||
@@ -20,7 +20,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "a068208d454be34585e8809816991c1853c255cd6f04c6ca9277e21219b745bc"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "gmail",
|
||||
"display_name": "Gmail",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Read, send, and manage Gmail messages and threads",
|
||||
"keywords": [
|
||||
"email",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "557e3d485de337340e059948b3099dcacae1c2e937e2bf66bad52b105c5be9bc"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "google-calendar",
|
||||
"display_name": "Google Calendar",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Create, read, update, and delete Google Calendar events",
|
||||
"keywords": [
|
||||
"calendar",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "787d57cb55cf492af013b69bfed8472a1cdd349fbb137f6084c0adcb51a467a6"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "google-docs",
|
||||
"display_name": "Google Docs",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Create and edit Google Docs documents",
|
||||
"keywords": [
|
||||
"documents",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "64407e650b6b8fcf9892255ef237a7683469fd465e3ba84e709fd71cd77fcbab"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "google-drive",
|
||||
"display_name": "Google Drive",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Upload, download, search, and manage Google Drive files and folders",
|
||||
"keywords": [
|
||||
"storage",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "3618689278d9e8546e37490c7ba05ee4dffb0acfaa89f0865161616326c41ee8"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "google-sheets",
|
||||
"display_name": "Google Sheets",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Read and write Google Sheets spreadsheet data",
|
||||
"keywords": [
|
||||
"spreadsheets",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "e486d228891a5b431b9993cebe0820c5331130916555ac3ba33b5b23bfd4ad75"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "google-slides",
|
||||
"display_name": "Google Slides",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Create and edit Google Slides presentations",
|
||||
"keywords": [
|
||||
"presentations",
|
||||
@@ -18,7 +18,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "9f2ba32c6d43cf87f50e2ae6720a1e9d89c00666aab2047080aadb503968c66a"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "slack-tool",
|
||||
"display_name": "Slack Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Your agent uses Slack to post and read messages in your workspace",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -18,7 +18,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "536b52179094d228e18d51b203286c80f800e0132b42fa08ce292c76a13a70e8"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "telegram-mtproto",
|
||||
"display_name": "Telegram Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Your agent uses your Telegram account to read and send messages",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "9bcc39d717c2b7e4e3327d31fc0032fbe4bf666852622c69b88dbaee236f77ee"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "web-search",
|
||||
"display_name": "Web Search",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Search the web using Brave Search API",
|
||||
"keywords": [
|
||||
"search",
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "003b0667878035ba0e6029ec322bf5e6e7bab06c3022d5fe897ce288d2d156e0"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Architecture boundary checks for IronClaw.
|
||||
# Run as: bash scripts/check-boundaries.sh
|
||||
# Returns non-zero if hard violations are found.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
violations=0
|
||||
|
||||
echo "=== Architecture Boundary Checks ==="
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Check 1: Direct database driver usage outside the db layer
|
||||
# --------------------------------------------------------------------------
|
||||
# tokio_postgres:: and libsql:: types should only appear in:
|
||||
# - src/db/ (the database abstraction layer)
|
||||
# - src/workspace/repository.rs (workspace's own DB layer)
|
||||
# - src/error.rs (needs From impls for driver error types)
|
||||
# - src/app.rs (bootstraps/initialises the database)
|
||||
# - src/testing.rs (test infrastructure)
|
||||
# - src/cli/ (CLI commands that bootstrap DB connections)
|
||||
# - src/setup/ (onboarding wizard bootstraps DB)
|
||||
# - src/main.rs (entry point)
|
||||
#
|
||||
# Everything else is a boundary violation -- those modules should go through
|
||||
# the Database trait, not touch driver types directly.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
echo "--- Check 1: Direct database driver usage outside db layer ---"
|
||||
|
||||
results=$(grep -rn 'tokio_postgres::\|libsql::' src/ \
|
||||
--include='*.rs' \
|
||||
| grep -v 'src/db/' \
|
||||
| grep -v 'src/workspace/repository.rs' \
|
||||
| grep -v 'src/error.rs' \
|
||||
| grep -v 'src/app.rs' \
|
||||
| grep -v 'src/testing.rs' \
|
||||
| grep -v 'src/cli/' \
|
||||
| grep -v 'src/setup/' \
|
||||
| grep -v 'src/main.rs' \
|
||||
| grep -v '^\s*//' \
|
||||
| grep -v '//.*tokio_postgres\|//.*libsql' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$results" ]; then
|
||||
echo "VIOLATION: Direct database driver usage found outside db layer:"
|
||||
echo "$results"
|
||||
echo
|
||||
count=$(echo "$results" | wc -l | tr -d ' ')
|
||||
echo "($count occurrence(s) -- these modules should use the Database trait)"
|
||||
violations=$((violations + 1))
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Check 2: .unwrap() / .expect() in production code (heuristic)
|
||||
# --------------------------------------------------------------------------
|
||||
# We cannot perfectly distinguish test vs production code with grep alone
|
||||
# (test modules span many lines). Instead we:
|
||||
# 1. Exclude files that are entirely test infrastructure
|
||||
# 2. Exclude lines that are clearly in test code (assert, #[test], etc.)
|
||||
# 3. Report a per-file summary so reviewers can focus on the worst files
|
||||
#
|
||||
# This is a WARNING, not a hard violation.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
echo "--- Check 2: .unwrap() / .expect() in production code ---"
|
||||
|
||||
# Collect raw matches excluding obvious test-only files and lines
|
||||
raw_results=$(grep -rn '\.unwrap()\|\.expect(' src/ \
|
||||
--include='*.rs' \
|
||||
| grep -v 'src/main.rs' \
|
||||
| grep -v 'src/testing.rs' \
|
||||
| grep -v 'src/setup/' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$raw_results" ]; then
|
||||
total=$(echo "$raw_results" | wc -l | tr -d ' ')
|
||||
echo "WARNING: ~$total .unwrap()/.expect() calls found in src/ (excluding main/testing/setup)."
|
||||
echo "Many are in test modules; a per-file breakdown helps triage:"
|
||||
echo
|
||||
# Show per-file counts, sorted by count descending, top 15
|
||||
file_counts=$(echo "$raw_results" | cut -d: -f1 | sort | uniq -c | sort -rn)
|
||||
echo "$file_counts" | head -15
|
||||
fc_total=$(echo "$file_counts" | wc -l | tr -d ' ')
|
||||
if [ "$fc_total" -gt 15 ]; then
|
||||
echo " ... and $((fc_total - 15)) more files"
|
||||
fi
|
||||
echo
|
||||
echo "(This is a warning for gradual cleanup, not a blocking violation.)"
|
||||
echo "(Many of these are inside #[cfg(test)] modules which is acceptable.)"
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Check 3: std::env::var reads outside config/bootstrap layers
|
||||
# --------------------------------------------------------------------------
|
||||
# Sensitive values should come through Config or the secrets module.
|
||||
# Direct std::env::var / env::var() reads are allowed in:
|
||||
# - src/config/ (the config layer itself)
|
||||
# - src/main.rs (entry point)
|
||||
# - src/setup/ (onboarding wizard)
|
||||
# - src/testing.rs (test infrastructure)
|
||||
# - src/cli/ (CLI commands that read env for bootstrap)
|
||||
# - src/bootstrap.rs (bootstrap logic)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
echo "--- Check 3: Direct env var reads outside config layer ---"
|
||||
|
||||
results=$(grep -rn 'std::env::var\|env::var(' src/ \
|
||||
--include='*.rs' \
|
||||
| grep -v 'src/config/' \
|
||||
| grep -v 'src/main.rs' \
|
||||
| grep -v 'src/setup/' \
|
||||
| grep -v 'src/testing.rs' \
|
||||
| grep -v 'src/cli/' \
|
||||
| grep -v 'src/bootstrap.rs' \
|
||||
| grep -v '#\[cfg(test)\]' \
|
||||
| grep -v '#\[test\]' \
|
||||
| grep -v 'mod tests' \
|
||||
| grep -v 'fn test_' \
|
||||
| grep -v '//.*env::var' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$results" ]; then
|
||||
count=$(echo "$results" | wc -l | tr -d ' ')
|
||||
echo "WARNING: Direct env var reads found outside config layer ($count occurrences):"
|
||||
echo "$results"
|
||||
echo
|
||||
echo "(Review these -- secrets/config should come through Config or the secrets module)"
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Check 4: Test tier gating — integration tests must use feature flags
|
||||
# --------------------------------------------------------------------------
|
||||
# Files in tests/ that connect to PostgreSQL or use DATABASE_URL must be
|
||||
# gated behind #![cfg(all(feature = "postgres", feature = "integration"))].
|
||||
# This ensures `cargo test` (no flags) never requires external services.
|
||||
#
|
||||
# Heuristic: any test file referencing DATABASE_URL, connect(), PgPool,
|
||||
# or tokio_postgres should have the cfg gate on the first few lines.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
echo "--- Check 4: Test tier gating for integration tests ---"
|
||||
|
||||
tier_violations=()
|
||||
for test_file in tests/*.rs; do
|
||||
[ -f "$test_file" ] || continue
|
||||
|
||||
# Check if the file actually connects to a database (imports DB types
|
||||
# or calls pool/connect). Mere string references like "DATABASE_URL"
|
||||
# in config tests don't count.
|
||||
needs_gate=false
|
||||
if grep -q 'PgPool\|tokio_postgres::\|create_pool\|\.connect(' "$test_file" 2>/dev/null; then
|
||||
needs_gate=true
|
||||
fi
|
||||
|
||||
if [ "$needs_gate" = true ]; then
|
||||
# Check first 5 lines for the cfg gate
|
||||
if ! head -5 "$test_file" | grep -q 'cfg.*feature.*integration' 2>/dev/null; then
|
||||
tier_violations+=(" $test_file: needs '#![cfg(all(feature = \"postgres\", feature = \"integration\"))]'")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#tier_violations[@]} -gt 0 ]; then
|
||||
echo "VIOLATION: Integration tests missing feature gate:"
|
||||
printf '%s\n' "${tier_violations[@]}"
|
||||
echo
|
||||
echo "(Tests requiring external services must be gated behind the 'integration' feature)"
|
||||
violations=$((violations + 1))
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Check 5: No silent test-skip patterns (try_connect, is_available, etc.)
|
||||
# --------------------------------------------------------------------------
|
||||
# Tests must fail loudly when prerequisites are missing, not silently skip.
|
||||
# The correct approach is feature-flag gating (#![cfg(feature = "integration")]).
|
||||
# Patterns like try_connect().is_none() { return; } hide broken tests.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
echo "--- Check 5: No silent test-skip patterns ---"
|
||||
|
||||
skip_results=$(grep -rn 'try_connect\|is_available.*return\|is_none.*return\|is_err.*return.*//.*skip' tests/ \
|
||||
--include='*.rs' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$skip_results" ]; then
|
||||
echo "VIOLATION: Silent test-skip patterns found (use feature gates instead):"
|
||||
echo "$skip_results"
|
||||
echo
|
||||
violations=$((violations + 1))
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
echo
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Summary
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
echo "=== Summary ==="
|
||||
if [ "$violations" -gt 0 ]; then
|
||||
echo "FAILED: $violations hard violation(s) found"
|
||||
exit 1
|
||||
else
|
||||
echo "PASSED: No hard violations found (review warnings above)"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,171 +0,0 @@
|
||||
# Agent Module
|
||||
|
||||
Core agent logic. This is the most complex subsystem — read this before working in `src/agent/`.
|
||||
|
||||
## Module Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `agent_loop.rs` | `Agent` struct, `AgentDeps`, main `run()` event loop. Delegates to siblings. |
|
||||
| `dispatcher.rs` | Agentic loop for conversational turns: LLM call → tool execution → repeat. Injects skill context. Returns `Response` or `NeedApproval`. |
|
||||
| `thread_ops.rs` | Thread/session operations: `process_user_input`, undo/redo, approval, auth-mode interception, DB hydration, compaction. |
|
||||
| `commands.rs` | System command handlers (`/help`, `/model`, `/status`, `/skills`, etc.) and job intent handlers. |
|
||||
| `session.rs` | Data model: `Session` → `Thread` → `Turn`. State machines for threads and turns. |
|
||||
| `session_manager.rs` | Lifecycle: create/lookup sessions, map external thread IDs to internal UUIDs, prune stale sessions, manage undo managers. |
|
||||
| `router.rs` | Routes explicit `/commands` to `MessageIntent`. Natural language bypasses the router entirely. |
|
||||
| `scheduler.rs` | Parallel job scheduling. Maintains `jobs` map (full LLM-driven) and `subtasks` map (tool-exec/background). |
|
||||
| `worker.rs` | Per-job execution for background scheduler jobs: calls LLM, runs tools, handles the reasoning loop. Distinct from `dispatcher.rs`. |
|
||||
| `compaction.rs` | Context window management: summarize old turns, write to workspace daily log, trim context. Three strategies. |
|
||||
| `context_monitor.rs` | Detects memory pressure. Suggests `CompactionStrategy` based on usage level. |
|
||||
| `self_repair.rs` | Detects stuck jobs and broken tools, attempts recovery. |
|
||||
| `heartbeat.rs` | Proactive periodic execution. Reads `HEARTBEAT.md`, notifies via channel if findings. |
|
||||
| `submission.rs` | Parses all user submissions into typed variants before routing. |
|
||||
| `undo.rs` | Turn-based undo/redo with checkpoints. Checkpoints store message lists (max 20 by default). |
|
||||
| `routine.rs` | `Routine` types: `Trigger` (cron/event/webhook/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. |
|
||||
| `routine_engine.rs` | Cron ticker and event matcher. Fires routines when triggers match. Lightweight runs inline; full_job dispatches to `Scheduler`. |
|
||||
| `task.rs` | Task types for the scheduler: `Job`, `ToolExec`, `Background`. Used by `spawn_subtask` and `spawn_batch`. |
|
||||
| `cost_guard.rs` | LLM spend and action-rate enforcement. Tracks daily budget (cents) and hourly call rate. Lives in `AgentDeps`. |
|
||||
| `job_monitor.rs` | Subscribes to SSE broadcast and injects Claude Code (container) output back into the agent loop as `IncomingMessage`. |
|
||||
|
||||
## Session / Thread / Turn Model
|
||||
|
||||
```
|
||||
Session (per user)
|
||||
└── Thread (per conversation — can have many)
|
||||
└── Turn (per request/response pair)
|
||||
├── user_input: String
|
||||
├── response: Option<String>
|
||||
├── tool_calls: Vec<ToolCall>
|
||||
└── state: TurnState (Pending | Running | Complete | Failed)
|
||||
```
|
||||
|
||||
- A session has one **active thread** at a time; threads can be switched.
|
||||
- Turns are append-only. Undo rolls back by restoring a prior checkpoint (message list, not a full thread snapshot).
|
||||
- `UndoManager` is per-thread, stored in `SessionManager`, not on `Session` itself. Max 20 checkpoints (oldest dropped when exceeded).
|
||||
- Group chat detection: if `metadata.chat_type` is `group`/`channel`/`supergroup`, `MEMORY.md` is excluded from the system prompt to prevent leaking personal context.
|
||||
- **Auth mode**: if a thread has `pending_auth` set (e.g. from `tool_auth` returning `awaiting_token`), the next user message is intercepted before any turn creation, logging, or safety validation and sent directly to the credential store. Any control submission (undo, interrupt, etc.) cancels auth mode.
|
||||
- `ThreadState` values: `Idle`, `Processing`, `AwaitingApproval`, `Completed`, `Interrupted`.
|
||||
- `SessionManager` maps `(user_id, channel, external_thread_id)` → internal UUID. Prunes idle sessions every 10 minutes (warns at 1000 sessions).
|
||||
|
||||
## Agentic Loop (dispatcher.rs)
|
||||
|
||||
The `dispatcher.rs` module handles **direct conversational turns** (user messages processed inline by the main agent). Background scheduler jobs use `worker.rs` instead — these are two separate execution paths.
|
||||
|
||||
```
|
||||
run_agentic_loop() [dispatcher.rs — conversational turns]
|
||||
1. Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
|
||||
2. Detect group chat from metadata; exclude MEMORY.md if group chat
|
||||
3. Select active skills (keyword/pattern scoring against message content)
|
||||
4. Build skill context block (injected before user message)
|
||||
5. LLM call → text response OR tool calls
|
||||
6. If tool calls:
|
||||
a. Check tool approval (session auto-approvals, pending approval queue)
|
||||
b. Execute tools (parallel via JoinSet)
|
||||
c. Sanitize results through SafetyLayer
|
||||
d. Feed results back → goto 5
|
||||
7. Return AgenticLoopResult::Response or NeedApproval
|
||||
```
|
||||
|
||||
**Tool approval:** Tools flagged `requires_approval` pause the loop and return `NeedApproval`. The web gateway stores the `PendingApproval` in session state and sends an `approval_needed` SSE event. The user's approval/deny resumes the loop.
|
||||
|
||||
**worker.rs vs dispatcher.rs:** `dispatcher.rs` runs the agentic loop for user-initiated conversational turns (holds session lock, tracks turns). `worker.rs` is spawned by the `Scheduler` for background jobs created via `CreateJob` / `/job` — it runs independently of the session and has its own LLM reasoning loop with planning support (`use_planning` flag).
|
||||
|
||||
## Command Routing (router.rs)
|
||||
|
||||
The `Router` handles explicit `/commands` (prefix `/`). It parses them into `MessageIntent` variants: `CreateJob`, `CheckJobStatus`, `CancelJob`, `ListJobs`, `HelpJob`, `Command`. Natural language messages bypass the router entirely — they go directly to `dispatcher.rs` via `process_user_input`. Note: most user-facing commands (undo, compact, etc.) are handled by `SubmissionParser` before the router runs, so `Router` only sees unrecognized `/xxx` patterns that haven't already been claimed by `submission.rs`.
|
||||
|
||||
## Compaction
|
||||
|
||||
Triggered by `ContextMonitor` when token usage approaches the model's context limit.
|
||||
|
||||
**Token estimation**: Word-count × 1.3 + 4 overhead per message. Default context limit: 100,000 tokens. Compaction threshold: 80% (configurable).
|
||||
|
||||
Three strategies, chosen by `ContextMonitor.suggest_compaction()` based on usage ratio:
|
||||
- **MoveToWorkspace** — Writes full turn transcript to workspace daily log, keeps 10 recent turns. Used when usage is 80–85% (moderate). Falls back to `Truncate(5)` if no workspace.
|
||||
- **Summarize** (`keep_recent: N`) — LLM generates a summary of old turns, writes it to workspace daily log (`daily/YYYY-MM-DD.md`), removes old turns. Used when usage is 85–95%.
|
||||
- **Truncate** (`keep_recent: N`) — Removes oldest turns without summarization (fast path). Used when usage >95% (critical).
|
||||
|
||||
If the LLM call for summarization fails, the error propagates — turns are **not** truncated on failure.
|
||||
|
||||
Manual trigger: user sends `/compact` (parsed by `submission.rs`).
|
||||
|
||||
## Scheduler
|
||||
|
||||
`Scheduler` maintains two maps under `Arc<RwLock<HashMap>>`:
|
||||
- `jobs` — full LLM-driven jobs, each with a `Worker` and an `mpsc` channel for `WorkerMessage` (`Start`, `Stop`, `Ping`, `UserMessage`).
|
||||
- `subtasks` — lightweight `ToolExec` or `Background` tasks spawned via `spawn_subtask()` / `spawn_batch()`.
|
||||
|
||||
**Preferred entry point**: `dispatch_job()` — creates context, optionally sets metadata, persists to DB (so FK references from `job_actions`/`llm_calls` are valid immediately), then calls `schedule()`. Don't call `schedule()` directly unless you've already persisted.
|
||||
|
||||
Check-insert is done under a single write lock to prevent TOCTOU races. A cleanup task polls every second for job completion and removes the entry from the map.
|
||||
|
||||
`spawn_subtask()` returns a `oneshot::Receiver` — callers must await it to get the result. `spawn_batch()` runs all tasks concurrently and returns results in input order.
|
||||
|
||||
## Self-Repair
|
||||
|
||||
`DefaultSelfRepair` runs on `repair_check_interval` (from `AgentConfig`). It:
|
||||
1. Calls `ContextManager::find_stuck_jobs()` to find jobs in `JobState::Stuck`.
|
||||
2. Attempts `ctx.attempt_recovery()` (transitions back to `InProgress`).
|
||||
3. Returns `ManualRequired` if `repair_attempts >= max_repair_attempts`.
|
||||
4. Detects broken tools via `store.get_broken_tools(5)` (threshold: 5 failures). Requires `with_store()` to be called; returns empty without a store.
|
||||
5. Attempts to rebuild broken tools via `SoftwareBuilder`. Requires `with_builder()` to be called; returns `ManualRequired` without a builder.
|
||||
|
||||
Note: the `stuck_threshold` duration is stored but currently unused (marked `#[allow(dead_code)]`). Stuck detection relies on `JobState::Stuck` being set by the state machine, not wall-clock time comparison.
|
||||
|
||||
Repair results: `Success`, `Retry`, `Failed`, `ManualRequired`. `Retry` does NOT notify the user (to avoid spam).
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- Never call `.unwrap()` or `.expect()` — use `?` with proper error mapping.
|
||||
- All state mutations on `Session`/`Thread` happen under `Arc<Mutex<Session>>` lock.
|
||||
- The agent loop is single-threaded per thread; parallel execution happens at the job/scheduler level.
|
||||
- Skills are selected **deterministically** (no LLM call) — see `skills/selector.rs`.
|
||||
- Tool results pass through `SafetyLayer` before returning to LLM (sanitizer → validator → policy → leak detector).
|
||||
- `SessionManager` uses double-checked locking for session creation. Read lock first (fast path), then write lock with re-check to prevent duplicate sessions.
|
||||
- `Scheduler.schedule()` holds the write lock for the entire check-insert sequence — don't hold any other locks when calling it.
|
||||
- `cheap_llm` in `AgentDeps` is used for heartbeat and other lightweight tasks. Falls back to main `llm` if `None`. Use `agent.cheap_llm()` accessor, not `deps.cheap_llm` directly.
|
||||
- `CostGuard.check_allowed()` must be called **before** LLM calls; `record_llm_call()` must be called **after**. Both calls are separate — the guard does not auto-record.
|
||||
- `BeforeInbound` and `BeforeOutbound` hooks run for every user message and agent response respectively. Hooks can modify content or reject. Hook errors are logged but **fail-open** (processing continues).
|
||||
|
||||
## Complete Submission Command Reference
|
||||
|
||||
All commands parsed by `SubmissionParser::parse()`:
|
||||
|
||||
| Input | Variant | Notes |
|
||||
|-------|---------|-------|
|
||||
| `/undo` | `Undo` | |
|
||||
| `/redo` | `Redo` | |
|
||||
| `/interrupt`, `/stop` | `Interrupt` | |
|
||||
| `/compact` | `Compact` | |
|
||||
| `/clear` | `Clear` | |
|
||||
| `/heartbeat` | `Heartbeat` | |
|
||||
| `/summarize`, `/summary` | `Summarize` | |
|
||||
| `/suggest` | `Suggest` | |
|
||||
| `/new`, `/thread new` | `NewThread` | |
|
||||
| `/thread <uuid>` | `SwitchThread` | Must be valid UUID |
|
||||
| `/resume <uuid>` | `Resume` | Must be valid UUID |
|
||||
| `/status [id]`, `/progress [id]`, `/list` | `JobStatus` | `/list` = all jobs |
|
||||
| `/cancel <id>` | `JobCancel` | |
|
||||
| `/quit`, `/exit`, `/shutdown` | `Quit` | |
|
||||
| `yes/y/approve/ok` and aliases | `ApprovalResponse { approved: true, always: false }` | |
|
||||
| `always/a` and aliases | `ApprovalResponse { approved: true, always: true }` | |
|
||||
| `no/n/deny/reject/cancel` and aliases | `ApprovalResponse { approved: false }` | |
|
||||
| JSON `ExecApproval{...}` | `ExecApproval` | From web gateway approval endpoint |
|
||||
| `/help`, `/?` | `SystemCommand { "help" }` | Bypasses thread-state checks |
|
||||
| `/version` | `SystemCommand { "version" }` | |
|
||||
| `/tools` | `SystemCommand { "tools" }` | |
|
||||
| `/skills [search <q>]` | `SystemCommand { "skills" }` | |
|
||||
| `/ping` | `SystemCommand { "ping" }` | |
|
||||
| `/debug` | `SystemCommand { "debug" }` | |
|
||||
| `/model [name]` | `SystemCommand { "model" }` | |
|
||||
| Everything else | `UserInput` | Starts a new agentic turn |
|
||||
|
||||
**`SystemCommand` vs control**: `SystemCommand` variants bypass thread-state checks entirely (no session lock, no turn creation). `Quit` returns `Ok(None)` from `handle_message` which breaks the main loop.
|
||||
|
||||
## Adding a New Submission Command
|
||||
|
||||
Submissions are special messages parsed in `submission.rs` before the agentic loop runs. To add a new one:
|
||||
1. Add a variant to `Submission` enum in `submission.rs`
|
||||
2. Add parsing in `SubmissionParser::parse()`
|
||||
3. Handle in `agent_loop.rs` where `SubmissionResult` is matched (the `match submission { ... }` block in `handle_message`)
|
||||
4. Implement the handler method (usually in `thread_ops.rs` for session operations, or `commands.rs` for system commands)
|
||||
@@ -77,10 +77,6 @@ pub struct AgentDeps {
|
||||
pub sse_tx: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||
/// HTTP interceptor for trace recording/replay.
|
||||
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
||||
/// Audio transcription middleware for voice messages.
|
||||
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
|
||||
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
|
||||
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
|
||||
}
|
||||
|
||||
/// The main agent that coordinates all components.
|
||||
@@ -131,9 +127,6 @@ impl Agent {
|
||||
if let Some(ref tx) = deps.sse_tx {
|
||||
scheduler.set_sse_sender(tx.clone());
|
||||
}
|
||||
if let Some(ref interceptor) = deps.http_interceptor {
|
||||
scheduler.set_http_interceptor(Arc::clone(interceptor));
|
||||
}
|
||||
let scheduler = Arc::new(scheduler);
|
||||
|
||||
Self {
|
||||
@@ -528,20 +521,6 @@ impl Agent {
|
||||
}
|
||||
};
|
||||
|
||||
// Apply transcription middleware to audio attachments
|
||||
let mut message = message;
|
||||
if let Some(ref transcription) = self.deps.transcription {
|
||||
transcription.process(&mut message).await;
|
||||
}
|
||||
|
||||
// Apply document extraction middleware to document attachments
|
||||
if let Some(ref doc_extraction) = self.deps.document_extraction {
|
||||
doc_extraction.process(&mut message).await;
|
||||
}
|
||||
|
||||
// Store successfully extracted document text in workspace for indexing
|
||||
self.store_extracted_documents(&message).await;
|
||||
|
||||
match self.handle_message(&message).await {
|
||||
Ok(Some(response)) if !response.is_empty() => {
|
||||
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
|
||||
@@ -640,73 +619,6 @@ impl Agent {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store extracted document text in workspace memory for future search/recall.
|
||||
async fn store_extracted_documents(&self, message: &IncomingMessage) {
|
||||
let workspace = match self.workspace() {
|
||||
Some(ws) => ws,
|
||||
None => return,
|
||||
};
|
||||
|
||||
for attachment in &message.attachments {
|
||||
if attachment.kind != crate::channels::AttachmentKind::Document {
|
||||
continue;
|
||||
}
|
||||
let text = match &attachment.extracted_text {
|
||||
Some(t) if !t.starts_with('[') => t, // skip error messages like "[Failed to..."
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Sanitize filename: strip path separators to prevent directory traversal
|
||||
let raw_name = attachment.filename.as_deref().unwrap_or("unnamed_document");
|
||||
let filename: String = raw_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c == '/' || c == '\\' || c == '\0' {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let filename = filename.trim_start_matches('.');
|
||||
let filename = if filename.is_empty() {
|
||||
"unnamed_document"
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
let date = chrono::Utc::now().format("%Y-%m-%d");
|
||||
let path = format!("documents/{date}/{filename}");
|
||||
|
||||
let header = format!(
|
||||
"# {filename}\n\n\
|
||||
> Uploaded by **{}** via **{}** on {date}\n\
|
||||
> MIME: {} | Size: {} bytes\n\n---\n\n",
|
||||
message.user_id,
|
||||
message.channel,
|
||||
attachment.mime_type,
|
||||
attachment.size_bytes.unwrap_or(0),
|
||||
);
|
||||
let content = format!("{header}{text}");
|
||||
|
||||
match workspace.write(&path, &content).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
path = %path,
|
||||
text_len = text.len(),
|
||||
"Stored extracted document in workspace memory"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %path,
|
||||
error = %e,
|
||||
"Failed to store extracted document in workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
|
||||
// Set message tool context for this turn (current channel and target)
|
||||
// For Signal, use signal_target from metadata (group:ID or phone number),
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
//! Augment user message content with structured attachment context.
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
use crate::channels::{AttachmentKind, IncomingAttachment};
|
||||
use crate::llm::{ContentPart, ImageUrl};
|
||||
|
||||
/// Result of processing attachments for the LLM pipeline.
|
||||
pub struct AugmentResult {
|
||||
/// Augmented text content with attachment metadata appended.
|
||||
pub text: String,
|
||||
/// Image content parts to include as multimodal input.
|
||||
pub image_parts: Vec<ContentPart>,
|
||||
}
|
||||
|
||||
/// Process attachments into augmented text and multimodal image parts.
|
||||
///
|
||||
/// Returns `None` if `attachments` is empty (caller should use original content).
|
||||
/// Returns `Some(AugmentResult)` with:
|
||||
/// - `text`: original content + `<attachments>` block (metadata, transcripts, etc.)
|
||||
/// - `image_parts`: `ContentPart::ImageUrl` entries for images with data
|
||||
pub fn augment_with_attachments(
|
||||
content: &str,
|
||||
attachments: &[IncomingAttachment],
|
||||
) -> Option<AugmentResult> {
|
||||
if attachments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut text = content.to_string();
|
||||
text.push_str("\n\n<attachments>");
|
||||
|
||||
let mut image_parts = Vec::new();
|
||||
|
||||
for (i, att) in attachments.iter().enumerate() {
|
||||
text.push('\n');
|
||||
text.push_str(&format_attachment(i + 1, att));
|
||||
|
||||
// Build multimodal image part when image data is available
|
||||
if att.kind == AttachmentKind::Image && !att.data.is_empty() {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&att.data);
|
||||
let data_url = format!("data:{};base64,{}", att.mime_type, b64);
|
||||
image_parts.push(ContentPart::ImageUrl {
|
||||
image_url: ImageUrl {
|
||||
url: data_url,
|
||||
detail: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
text.push_str("\n</attachments>");
|
||||
Some(AugmentResult { text, image_parts })
|
||||
}
|
||||
|
||||
/// Escape a string for use as an XML attribute value.
|
||||
fn escape_xml_attr(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Escape a string for use as XML text content.
|
||||
fn escape_xml_text(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn format_attachment(index: usize, att: &IncomingAttachment) -> String {
|
||||
let filename = escape_xml_attr(att.filename.as_deref().unwrap_or("unknown"));
|
||||
let mime = escape_xml_attr(&att.mime_type);
|
||||
|
||||
match &att.kind {
|
||||
AttachmentKind::Audio => {
|
||||
let duration_attr = att
|
||||
.duration_secs
|
||||
.map(|d| format!(" duration=\"{d}s\""))
|
||||
.unwrap_or_default();
|
||||
|
||||
let body = match &att.extracted_text {
|
||||
Some(text) => format!("Transcript: {}", escape_xml_text(text)),
|
||||
None => "Audio transcript unavailable.".to_string(),
|
||||
};
|
||||
|
||||
format!(
|
||||
"<attachment index=\"{index}\" type=\"audio\" filename=\"{filename}\"{duration_attr}>\n\
|
||||
{body}\n\
|
||||
</attachment>"
|
||||
)
|
||||
}
|
||||
AttachmentKind::Image => {
|
||||
let size_attr = att
|
||||
.size_bytes
|
||||
.map(|s| format!(" size=\"{}\"", format_size(s)))
|
||||
.unwrap_or_default();
|
||||
|
||||
let body = if att.data.is_empty() {
|
||||
"[Image attached — visual content not available in this conversation]"
|
||||
} else {
|
||||
"[Image attached — sent as visual content]"
|
||||
};
|
||||
|
||||
format!(
|
||||
"<attachment index=\"{index}\" type=\"image\" filename=\"{filename}\" mime=\"{mime}\"{size_attr}>\n\
|
||||
{body}\n\
|
||||
</attachment>"
|
||||
)
|
||||
}
|
||||
AttachmentKind::Document => {
|
||||
let body: String = match &att.extracted_text {
|
||||
Some(text) => escape_xml_text(text),
|
||||
None => {
|
||||
let size_info = att
|
||||
.size_bytes
|
||||
.map(|s| format!(" size=\"{}\"", format_size(s)))
|
||||
.unwrap_or_default();
|
||||
return format!(
|
||||
"<attachment index=\"{index}\" type=\"document\" filename=\"{filename}\" mime=\"{mime}\"{size_info}>\n\
|
||||
[Document attached — text extraction unavailable]\n\
|
||||
</attachment>"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let size_attr = att
|
||||
.size_bytes
|
||||
.map(|s| format!(" size=\"{}\"", format_size(s)))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
"<attachment index=\"{index}\" type=\"document\" filename=\"{filename}\" mime=\"{mime}\"{size_attr}>\n\
|
||||
{body}\n\
|
||||
</attachment>"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes}B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{}KB", bytes / 1024)
|
||||
} else {
|
||||
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_attachment(kind: AttachmentKind) -> IncomingAttachment {
|
||||
IncomingAttachment {
|
||||
id: "test-id".to_string(),
|
||||
kind,
|
||||
mime_type: "application/octet-stream".to_string(),
|
||||
filename: None,
|
||||
size_bytes: None,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
data: vec![],
|
||||
duration_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_attachments_returns_none() {
|
||||
assert!(augment_with_attachments("hello", &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_with_transcript() {
|
||||
let mut att = make_attachment(AttachmentKind::Audio);
|
||||
att.filename = Some("voice.ogg".to_string());
|
||||
att.extracted_text = Some("Hello, can you help me?".to_string());
|
||||
att.duration_secs = Some(5);
|
||||
|
||||
let result = augment_with_attachments("hi", &[att]).unwrap();
|
||||
assert!(result.text.starts_with("hi\n\n<attachments>"));
|
||||
assert!(result.text.contains("type=\"audio\""));
|
||||
assert!(result.text.contains("filename=\"voice.ogg\""));
|
||||
assert!(result.text.contains("duration=\"5s\""));
|
||||
assert!(result.text.contains("Transcript: Hello, can you help me?"));
|
||||
assert!(result.text.ends_with("</attachments>"));
|
||||
assert!(result.image_parts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_without_transcript() {
|
||||
let mut att = make_attachment(AttachmentKind::Audio);
|
||||
att.filename = Some("voice.ogg".to_string());
|
||||
att.duration_secs = Some(10);
|
||||
|
||||
let result = augment_with_attachments("hi", &[att]).unwrap();
|
||||
assert!(result.text.contains("Audio transcript unavailable."));
|
||||
assert!(result.text.contains("duration=\"10s\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_without_data_no_visual() {
|
||||
let mut att = make_attachment(AttachmentKind::Image);
|
||||
att.filename = Some("screenshot.png".to_string());
|
||||
att.mime_type = "image/png".to_string();
|
||||
att.size_bytes = Some(245_000);
|
||||
|
||||
let result = augment_with_attachments("check this", &[att]).unwrap();
|
||||
assert!(result.text.contains("type=\"image\""));
|
||||
assert!(result.text.contains("filename=\"screenshot.png\""));
|
||||
assert!(result.text.contains("mime=\"image/png\""));
|
||||
assert!(result.text.contains("size=\"239KB\""));
|
||||
assert!(
|
||||
result
|
||||
.text
|
||||
.contains("[Image attached — visual content not available in this conversation]")
|
||||
);
|
||||
assert!(result.image_parts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_with_data_produces_content_part() {
|
||||
let mut att = make_attachment(AttachmentKind::Image);
|
||||
att.filename = Some("photo.jpg".to_string());
|
||||
att.mime_type = "image/jpeg".to_string();
|
||||
att.data = vec![0xFF, 0xD8, 0xFF]; // fake JPEG header
|
||||
|
||||
let result = augment_with_attachments("look", &[att]).unwrap();
|
||||
assert!(
|
||||
result
|
||||
.text
|
||||
.contains("[Image attached — sent as visual content]")
|
||||
);
|
||||
assert_eq!(result.image_parts.len(), 1);
|
||||
match &result.image_parts[0] {
|
||||
ContentPart::ImageUrl { image_url } => {
|
||||
assert!(image_url.url.starts_with("data:image/jpeg;base64,"));
|
||||
}
|
||||
other => panic!("Expected ImageUrl, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_with_extracted_text() {
|
||||
let mut att = make_attachment(AttachmentKind::Document);
|
||||
att.filename = Some("report.pdf".to_string());
|
||||
att.extracted_text = Some("Executive summary: Q3 results".to_string());
|
||||
|
||||
let result = augment_with_attachments("review", &[att]).unwrap();
|
||||
assert!(result.text.contains("type=\"document\""));
|
||||
assert!(result.text.contains("filename=\"report.pdf\""));
|
||||
assert!(result.text.contains("Executive summary: Q3 results"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_without_extracted_text() {
|
||||
let mut att = make_attachment(AttachmentKind::Document);
|
||||
att.filename = Some("data.csv".to_string());
|
||||
att.mime_type = "text/csv".to_string();
|
||||
att.size_bytes = Some(1024);
|
||||
|
||||
let result = augment_with_attachments("analyze", &[att]).unwrap();
|
||||
assert!(result.text.contains("type=\"document\""));
|
||||
assert!(result.text.contains("mime=\"text/csv\""));
|
||||
assert!(
|
||||
result
|
||||
.text
|
||||
.contains("[Document attached — text extraction unavailable]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_attachments_with_mixed_images() {
|
||||
let mut audio = make_attachment(AttachmentKind::Audio);
|
||||
audio.filename = Some("voice.ogg".to_string());
|
||||
audio.extracted_text = Some("Hello".to_string());
|
||||
|
||||
let mut image_with_data = make_attachment(AttachmentKind::Image);
|
||||
image_with_data.filename = Some("photo.jpg".to_string());
|
||||
image_with_data.mime_type = "image/jpeg".to_string();
|
||||
image_with_data.data = vec![0xFF, 0xD8];
|
||||
|
||||
let mut image_no_data = make_attachment(AttachmentKind::Image);
|
||||
image_no_data.filename = Some("remote.png".to_string());
|
||||
image_no_data.mime_type = "image/png".to_string();
|
||||
|
||||
let result =
|
||||
augment_with_attachments("msg", &[audio, image_with_data, image_no_data]).unwrap();
|
||||
assert!(result.text.contains("index=\"1\""));
|
||||
assert!(result.text.contains("index=\"2\""));
|
||||
assert!(result.text.contains("index=\"3\""));
|
||||
// Only the image with data produces a content part
|
||||
assert_eq!(result.image_parts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_content_preserved() {
|
||||
let original = "Please help me with this task";
|
||||
let mut att = make_attachment(AttachmentKind::Audio);
|
||||
att.extracted_text = Some("transcript".to_string());
|
||||
|
||||
let result = augment_with_attachments(original, &[att]).unwrap();
|
||||
assert!(result.text.starts_with(original));
|
||||
}
|
||||
}
|
||||
+11
-230
@@ -151,46 +151,21 @@ impl CostGuard {
|
||||
/// Record a completed LLM action: its token costs and the action timestamp.
|
||||
///
|
||||
/// Call this AFTER an LLM call completes so that costs are tracked.
|
||||
/// - `cache_read_input_tokens`: tokens served from cache.
|
||||
/// - `cache_creation_input_tokens`: tokens written to cache.
|
||||
/// - `cache_read_discount`: divisor for cache-read cost (e.g. 10 for Anthropic 90% off, 2 for OpenAI 50% off).
|
||||
/// - `cache_write_multiplier`: cost multiplier for cache writes (1.25 for 5m, 2.0 for 1h).
|
||||
///
|
||||
/// When `cost_per_token` is `Some`, those rates are used directly (provider-
|
||||
/// sourced pricing). When `None`, falls back to the static `costs::model_cost`
|
||||
/// lookup table, then `costs::default_cost`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn record_llm_call(
|
||||
&self,
|
||||
model: &str,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
cache_read_input_tokens: u32,
|
||||
cache_creation_input_tokens: u32,
|
||||
cache_read_discount: Decimal,
|
||||
cache_write_multiplier: Decimal,
|
||||
cost_per_token: Option<(Decimal, Decimal)>,
|
||||
) -> Decimal {
|
||||
let (input_rate, output_rate) = cost_per_token
|
||||
.unwrap_or_else(|| costs::model_cost(model).unwrap_or_else(costs::default_cost));
|
||||
// Cached read tokens cost input_rate / cache_read_discount (provider-specific).
|
||||
// Cached write tokens cost write_multiplier × input_rate (e.g. 1.25× for 5m, 2× for 1h).
|
||||
// Uncached tokens = total input - cache reads - cache writes.
|
||||
let cached_total = cache_read_input_tokens.saturating_add(cache_creation_input_tokens);
|
||||
let uncached_input = input_tokens.saturating_sub(cached_total);
|
||||
let effective_discount = if cache_read_discount.is_zero() {
|
||||
Decimal::ONE
|
||||
} else {
|
||||
cache_read_discount
|
||||
};
|
||||
let cache_read_cost =
|
||||
input_rate * Decimal::from(cache_read_input_tokens) / effective_discount;
|
||||
let cache_write_cost =
|
||||
input_rate * Decimal::from(cache_creation_input_tokens) * cache_write_multiplier;
|
||||
let cost = input_rate * Decimal::from(uncached_input)
|
||||
+ cache_read_cost
|
||||
+ cache_write_cost
|
||||
+ output_rate * Decimal::from(output_tokens);
|
||||
let cost =
|
||||
input_rate * Decimal::from(input_tokens) + output_rate * Decimal::from(output_tokens);
|
||||
|
||||
// Update daily cost (reset if new day)
|
||||
{
|
||||
@@ -292,16 +267,7 @@ mod tests {
|
||||
|
||||
// Record a big call, still allowed
|
||||
guard
|
||||
.record_llm_call(
|
||||
"gpt-4o",
|
||||
100_000,
|
||||
100_000,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.record_llm_call("gpt-4o", 100_000, 100_000, None)
|
||||
.await;
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
}
|
||||
@@ -319,18 +285,7 @@ mod tests {
|
||||
// Record a call that costs more than $0.01
|
||||
// gpt-4o: input=$0.0000025/tok, output=$0.00001/tok
|
||||
// 10000 input + 10000 output = $0.025 + $0.10 = $0.125
|
||||
guard
|
||||
.record_llm_call(
|
||||
"gpt-4o",
|
||||
10_000,
|
||||
10_000,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
guard.record_llm_call("gpt-4o", 10_000, 10_000, None).await;
|
||||
|
||||
// Now should be blocked
|
||||
let result = guard.check_allowed().await;
|
||||
@@ -353,9 +308,7 @@ mod tests {
|
||||
// First 3 actions allowed
|
||||
for _ in 0..3 {
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
guard
|
||||
.record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||||
.await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
}
|
||||
|
||||
// 4th should be blocked
|
||||
@@ -376,9 +329,7 @@ mod tests {
|
||||
|
||||
assert_eq!(guard.daily_spend().await, Decimal::ZERO);
|
||||
|
||||
let cost = guard
|
||||
.record_llm_call("gpt-4o", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||||
.await;
|
||||
let cost = guard.record_llm_call("gpt-4o", 1000, 500, None).await;
|
||||
assert!(cost > Decimal::ZERO);
|
||||
assert_eq!(guard.daily_spend().await, cost);
|
||||
}
|
||||
@@ -389,12 +340,8 @@ mod tests {
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 0);
|
||||
|
||||
guard
|
||||
.record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||||
.await;
|
||||
guard
|
||||
.record_llm_call("gpt-4o", 10, 10, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||||
.await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 2);
|
||||
}
|
||||
@@ -431,23 +378,10 @@ mod tests {
|
||||
assert!(guard.model_usage().await.is_empty());
|
||||
|
||||
// Record calls for two different models
|
||||
guard.record_llm_call("gpt-4o", 1000, 500, None).await;
|
||||
guard.record_llm_call("gpt-4o", 2000, 1000, None).await;
|
||||
guard
|
||||
.record_llm_call("gpt-4o", 1000, 500, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||||
.await;
|
||||
guard
|
||||
.record_llm_call("gpt-4o", 2000, 1000, 0, 0, Decimal::ONE, Decimal::ONE, None)
|
||||
.await;
|
||||
guard
|
||||
.record_llm_call(
|
||||
"claude-3-5-sonnet-20241022",
|
||||
500,
|
||||
200,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.record_llm_call("claude-3-5-sonnet-20241022", 500, 200, None)
|
||||
.await;
|
||||
|
||||
let usage = guard.model_usage().await;
|
||||
@@ -468,157 +402,4 @@ mod tests {
|
||||
// Costs should differ since models have different pricing
|
||||
assert_ne!(gpt.cost, claude.cost);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_discount_reduces_cost() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// Full price: 1000 input + 500 output, no cache
|
||||
let full_cost = guard
|
||||
.record_llm_call(
|
||||
"claude-opus-4-6",
|
||||
1000,
|
||||
500,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let guard2 = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// Same tokens but all input cached (90% discount on input)
|
||||
let cached_cost = guard2
|
||||
.record_llm_call(
|
||||
"claude-opus-4-6",
|
||||
1000,
|
||||
500,
|
||||
1000,
|
||||
0,
|
||||
dec!(10),
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Cached cost must be strictly less than full cost
|
||||
assert!(
|
||||
cached_cost < full_cost,
|
||||
"cached_cost ({}) should be less than full_cost ({})",
|
||||
cached_cost,
|
||||
full_cost
|
||||
);
|
||||
|
||||
// The difference should be exactly 90% of the input cost
|
||||
let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap();
|
||||
let expected_savings = input_rate * Decimal::from(1000u32) * dec!(9) / dec!(10);
|
||||
let actual_savings = full_cost - cached_cost;
|
||||
assert_eq!(
|
||||
actual_savings, expected_savings,
|
||||
"savings should be 90% of input cost for fully-cached request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_write_surcharge_increases_cost() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// Full price: 1000 input + 500 output, no cache activity
|
||||
let full_cost = guard
|
||||
.record_llm_call(
|
||||
"claude-opus-4-6",
|
||||
1000,
|
||||
500,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let guard2 = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// Same tokens, but all input tokens are cache writes (1.25x surcharge for 5m TTL)
|
||||
let short_multiplier = Decimal::new(125, 2); // 1.25
|
||||
let write_cost = guard2
|
||||
.record_llm_call(
|
||||
"claude-opus-4-6",
|
||||
1000,
|
||||
500,
|
||||
0,
|
||||
1000,
|
||||
Decimal::ONE,
|
||||
short_multiplier,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Write cost must be strictly greater than full cost
|
||||
assert!(
|
||||
write_cost > full_cost,
|
||||
"write_cost ({}) should be greater than full_cost ({})",
|
||||
write_cost,
|
||||
full_cost
|
||||
);
|
||||
|
||||
// The difference should be exactly 25% of the input cost
|
||||
let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap();
|
||||
let expected_surcharge = input_rate * Decimal::from(1000u32) * dec!(0.25);
|
||||
let actual_surcharge = write_cost - full_cost;
|
||||
assert_eq!(
|
||||
actual_surcharge, expected_surcharge,
|
||||
"surcharge should be 25% of input cost for 5m cache writes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_write_surcharge_long_ttl() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// Full price: 1000 input + 500 output
|
||||
let full_cost = guard
|
||||
.record_llm_call(
|
||||
"claude-opus-4-6",
|
||||
1000,
|
||||
500,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let guard2 = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// All input tokens are cache writes with 2.0x multiplier (1h TTL)
|
||||
let long_multiplier = Decimal::TWO;
|
||||
let write_cost = guard2
|
||||
.record_llm_call(
|
||||
"claude-opus-4-6",
|
||||
1000,
|
||||
500,
|
||||
0,
|
||||
1000,
|
||||
Decimal::ONE,
|
||||
long_multiplier,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Write cost > full cost
|
||||
assert!(write_cost > full_cost);
|
||||
|
||||
// Surcharge should be 100% of input cost (2.0x - 1.0x = 1.0x)
|
||||
let (input_rate, _) = costs::model_cost("claude-opus-4-6").unwrap();
|
||||
let expected_surcharge = input_rate * Decimal::from(1000u32);
|
||||
let actual_surcharge = write_cost - full_cost;
|
||||
assert_eq!(
|
||||
actual_surcharge, expected_surcharge,
|
||||
"surcharge should be 100% of input cost for 1h cache writes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-91
@@ -131,17 +131,6 @@ impl Agent {
|
||||
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
|
||||
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
||||
|
||||
// Build system prompts once for this turn. Two variants: with tools
|
||||
// (normal iterations) and without (force_text final iteration).
|
||||
let initial_tool_defs = self.tools().tool_definitions().await;
|
||||
let initial_tool_defs = if !active_skills.is_empty() {
|
||||
crate::skills::attenuate_tools(&initial_tool_defs, &active_skills).tools
|
||||
} else {
|
||||
initial_tool_defs
|
||||
};
|
||||
let cached_prompt = reasoning.build_system_prompt_with_tools(&initial_tool_defs);
|
||||
let cached_prompt_no_tools = reasoning.build_system_prompt_with_tools(&[]);
|
||||
|
||||
let max_tool_iterations = self.config.max_tool_iterations;
|
||||
// Force a text-only response on the last iteration to guarantee termination
|
||||
// instead of hard-erroring. The penultimate iteration also gets a nudge
|
||||
@@ -149,8 +138,6 @@ impl Agent {
|
||||
let force_text_at = max_tool_iterations;
|
||||
let nudge_at = max_tool_iterations.saturating_sub(1);
|
||||
let mut iteration = 0;
|
||||
const MAX_TOOL_INTENT_NUDGES: u32 = 2;
|
||||
let mut consecutive_tool_intent_nudges: u32 = 0;
|
||||
loop {
|
||||
iteration += 1;
|
||||
// Hard ceiling one past the forced-text iteration (should never be reached
|
||||
@@ -219,16 +206,10 @@ impl Agent {
|
||||
};
|
||||
|
||||
// Call LLM with current context; force_text drops tools to guarantee a
|
||||
// text response on the final iteration. The pre-built system prompt
|
||||
// avoids rebuilding the same ~1,500-token string each iteration.
|
||||
// text response on the final iteration.
|
||||
let mut context = ReasoningContext::new()
|
||||
.with_messages(context_messages.clone())
|
||||
.with_tools(tool_defs)
|
||||
.with_system_prompt(if force_text {
|
||||
cached_prompt_no_tools.clone()
|
||||
} else {
|
||||
cached_prompt.clone()
|
||||
})
|
||||
.with_metadata({
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("thread_id".to_string(), thread_id.to_string());
|
||||
@@ -265,7 +246,7 @@ impl Agent {
|
||||
// Compact: keep system messages + last user message + current turn
|
||||
context_messages = compact_messages_for_retry(&context_messages);
|
||||
|
||||
// Rebuild context with compacted messages, reusing cached prompt
|
||||
// Rebuild context with compacted messages
|
||||
let mut retry_context = ReasoningContext::new()
|
||||
.with_messages(context_messages.clone())
|
||||
.with_tools(if force_text {
|
||||
@@ -275,7 +256,6 @@ impl Agent {
|
||||
})
|
||||
.with_metadata(context.metadata.clone());
|
||||
retry_context.force_text = force_text;
|
||||
retry_context.system_prompt = context.system_prompt.clone();
|
||||
|
||||
reasoning
|
||||
.respond_with_tools(&retry_context)
|
||||
@@ -296,18 +276,12 @@ impl Agent {
|
||||
|
||||
// Record cost and track token usage
|
||||
let model_name = self.llm().active_model_name();
|
||||
let read_discount = self.llm().cache_read_discount();
|
||||
let write_multiplier = self.llm().cache_write_multiplier();
|
||||
let call_cost = self
|
||||
.cost_guard()
|
||||
.record_llm_call(
|
||||
&model_name,
|
||||
output.usage.input_tokens,
|
||||
output.usage.output_tokens,
|
||||
output.usage.cache_read_input_tokens,
|
||||
output.usage.cache_creation_input_tokens,
|
||||
read_discount,
|
||||
write_multiplier,
|
||||
Some(self.llm().cost_per_token()),
|
||||
)
|
||||
.await;
|
||||
@@ -320,24 +294,6 @@ impl Agent {
|
||||
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
// Nudge the LLM if it expressed tool intent without calling tools.
|
||||
// This is common with non-Anthropic models (e.g. GLM-5 via NEAR AI)
|
||||
// that output "Let me search…" but don't issue tool_calls.
|
||||
if !force_text
|
||||
&& !context.available_tools.is_empty()
|
||||
&& consecutive_tool_intent_nudges < MAX_TOOL_INTENT_NUDGES
|
||||
&& crate::llm::llm_signals_tool_intent(&text)
|
||||
{
|
||||
consecutive_tool_intent_nudges += 1;
|
||||
tracing::info!(
|
||||
iteration,
|
||||
"LLM expressed tool intent without calling a tool, nudging"
|
||||
);
|
||||
context_messages.push(ChatMessage::assistant(&text));
|
||||
context_messages.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip internal "[Called tool ...]" text that can leak when
|
||||
// provider flattening (e.g. NEAR AI) converts tool_calls to
|
||||
// plain text and the LLM echoes it back.
|
||||
@@ -348,7 +304,6 @@ impl Agent {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
// Add the assistant message with tool_calls to context.
|
||||
// OpenAI protocol requires this before tool-result messages.
|
||||
context_messages.push(ChatMessage::assistant_with_tool_calls(
|
||||
@@ -753,7 +708,7 @@ impl Agent {
|
||||
sanitized.was_modified,
|
||||
)
|
||||
}
|
||||
Err(e) => format!("Tool '{}' failed: {}", tc.name, e),
|
||||
Err(e) => format!("Error: {}", e),
|
||||
};
|
||||
|
||||
context_messages.push(ChatMessage::tool_result(
|
||||
@@ -1086,8 +1041,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1101,8 +1054,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1127,8 +1078,6 @@ mod tests {
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
@@ -1665,8 +1614,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1682,8 +1629,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
});
|
||||
}
|
||||
// Tools available: always call one.
|
||||
@@ -1697,8 +1642,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::ToolUse,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1823,8 +1766,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 2,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1839,8 +1780,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 2,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
});
|
||||
}
|
||||
// Always call a tool that does not exist in the registry.
|
||||
@@ -1854,8 +1793,6 @@ mod tests {
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::ToolUse,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1881,8 +1818,6 @@ mod tests {
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
@@ -1996,8 +1931,6 @@ mod tests {
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
@@ -2095,25 +2028,4 @@ mod tests {
|
||||
let result = super::strip_internal_tool_call_text(input);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_error_format_includes_tool_name() {
|
||||
// Regression test for issue #487: tool errors sent to the LLM should
|
||||
// include the tool name so the model can reason about which tool failed
|
||||
// and try alternatives.
|
||||
let tool_name = "http";
|
||||
let err = crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: "connection refused".to_string(),
|
||||
};
|
||||
let formatted = format!("Tool '{}' failed: {}", tool_name, err);
|
||||
assert!(
|
||||
formatted.contains("Tool 'http' failed:"),
|
||||
"Error should identify the tool by name, got: {formatted}"
|
||||
);
|
||||
assert!(
|
||||
formatted.contains("connection refused"),
|
||||
"Error should include the underlying reason, got: {formatted}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,6 @@ impl HeartbeatRunner {
|
||||
if report.had_work() {
|
||||
tracing::info!(
|
||||
daily_logs_deleted = report.daily_logs_deleted,
|
||||
conversation_docs_deleted = report.conversation_docs_deleted,
|
||||
"heartbeat: memory hygiene deleted stale documents"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
//! - Context compaction for long conversations
|
||||
|
||||
mod agent_loop;
|
||||
mod attachments;
|
||||
mod commands;
|
||||
pub mod compaction;
|
||||
pub mod context_monitor;
|
||||
|
||||
+2
-25
@@ -175,11 +175,6 @@ pub enum RoutineAction {
|
||||
/// Max reasoning iterations (default: 10).
|
||||
#[serde(default = "default_max_iterations")]
|
||||
max_iterations: u32,
|
||||
/// Tool names pre-authorized for `Always`-approval tools (e.g. destructive
|
||||
/// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are
|
||||
/// automatically permitted in routine jobs without listing them here.
|
||||
#[serde(default)]
|
||||
tool_permissions: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -191,19 +186,6 @@ fn default_max_iterations() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
||||
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
||||
value
|
||||
.get("tool_permissions")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl RoutineAction {
|
||||
/// The string tag stored in the DB action_type column.
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
@@ -266,12 +248,10 @@ impl RoutineAction {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_iterations() as u64)
|
||||
as u32;
|
||||
let tool_permissions = parse_tool_permissions(&config);
|
||||
Ok(RoutineAction::FullJob {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
})
|
||||
}
|
||||
other => Err(RoutineError::UnknownActionType {
|
||||
@@ -296,12 +276,10 @@ impl RoutineAction {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
} => serde_json::json!({
|
||||
"title": title,
|
||||
"description": description,
|
||||
"max_iterations": max_iterations,
|
||||
"tool_permissions": tool_permissions,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -472,13 +450,12 @@ mod tests {
|
||||
title: "Deploy review".to_string(),
|
||||
description: "Review and deploy pending changes".to_string(),
|
||||
max_iterations: 5,
|
||||
tool_permissions: vec!["shell".to_string()],
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, .. }
|
||||
if title == "Deploy review" && max_iterations == 5 && tool_permissions == vec!["shell".to_string()])
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
|
||||
if title == "Deploy review" && max_iterations == 5)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ use crate::config::RoutineConfig;
|
||||
use crate::db::Database;
|
||||
use crate::error::RoutineError;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||
use crate::tools::ApprovalContext;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// The routine execution engine.
|
||||
@@ -181,9 +180,6 @@ impl RoutineEngine {
|
||||
}
|
||||
|
||||
/// Fire a routine manually (from tool call or CLI).
|
||||
///
|
||||
/// Bypasses cooldown checks (those only apply to cron/event triggers).
|
||||
/// Still enforces enabled check and concurrent run limit.
|
||||
pub async fn fire_manual(&self, routine_id: Uuid) -> Result<Uuid, RoutineError> {
|
||||
let routine = self
|
||||
.store
|
||||
@@ -331,19 +327,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
} => {
|
||||
execute_full_job(
|
||||
&ctx,
|
||||
&routine,
|
||||
&run,
|
||||
title,
|
||||
description,
|
||||
*max_iterations,
|
||||
tool_permissions,
|
||||
)
|
||||
.await
|
||||
}
|
||||
} => execute_full_job(&ctx, &routine, &run, title, description, *max_iterations).await,
|
||||
};
|
||||
|
||||
// Decrement running count
|
||||
@@ -434,7 +418,6 @@ async fn execute_full_job(
|
||||
title: &str,
|
||||
description: &str,
|
||||
max_iterations: u32,
|
||||
tool_permissions: &[String],
|
||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||
let scheduler = ctx
|
||||
.scheduler
|
||||
@@ -443,31 +426,10 @@ async fn execute_full_job(
|
||||
reason: "scheduler not available".to_string(),
|
||||
})?;
|
||||
|
||||
// Set the message tool's default channel/target from the routine's notify config
|
||||
// so the LLM can send results without triggering cross-channel approval.
|
||||
// TODO: This mutates shared global state and can race with concurrent jobs.
|
||||
// Move notify config into JobContext metadata and apply per-job instead.
|
||||
if let Some(channel) = &routine.notify.channel {
|
||||
scheduler
|
||||
.tools()
|
||||
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
|
||||
.await;
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({ "max_iterations": max_iterations });
|
||||
|
||||
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
|
||||
// Always tools require explicit listing in tool_permissions.
|
||||
let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());
|
||||
|
||||
let job_id = scheduler
|
||||
.dispatch_job_with_context(
|
||||
&routine.user_id,
|
||||
title,
|
||||
description,
|
||||
Some(metadata),
|
||||
approval_context,
|
||||
)
|
||||
.dispatch_job(&routine.user_id, title, description, Some(metadata))
|
||||
.await
|
||||
.map_err(|e| RoutineError::JobDispatchFailed {
|
||||
reason: format!("failed to dispatch job: {e}"),
|
||||
|
||||
+3
-270
@@ -18,7 +18,7 @@ use crate::error::{Error, JobError};
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{ApprovalContext, ToolRegistry};
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Message to send to a worker.
|
||||
#[derive(Debug)]
|
||||
@@ -56,8 +56,6 @@ pub struct Scheduler {
|
||||
hooks: Arc<HookRegistry>,
|
||||
/// SSE broadcast sender for live job event streaming.
|
||||
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
|
||||
/// HTTP interceptor for trace recording/replay (propagated to workers).
|
||||
http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
||||
/// Running jobs (main LLM-driven jobs).
|
||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||
/// Running sub-tasks (tool executions, background tasks).
|
||||
@@ -84,7 +82,6 @@ impl Scheduler {
|
||||
store,
|
||||
hooks,
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
@@ -95,14 +92,6 @@ impl Scheduler {
|
||||
self.sse_tx = Some(tx);
|
||||
}
|
||||
|
||||
/// Set the HTTP interceptor for trace recording/replay.
|
||||
pub fn set_http_interceptor(
|
||||
&mut self,
|
||||
interceptor: Arc<dyn crate::llm::recording::HttpInterceptor>,
|
||||
) {
|
||||
self.http_interceptor = Some(interceptor);
|
||||
}
|
||||
|
||||
/// Create, persist, and schedule a job in one shot.
|
||||
///
|
||||
/// This is the preferred entry point for dispatching new jobs. It:
|
||||
@@ -119,41 +108,6 @@ impl Scheduler {
|
||||
title: &str,
|
||||
description: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
self.dispatch_job_inner(user_id, title, description, metadata, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dispatch a job with an explicit approval context for autonomous execution.
|
||||
///
|
||||
/// Same as `dispatch_job`, but the worker will use the given `ApprovalContext`
|
||||
/// to determine which tools are pre-approved (instead of blocking all non-`Never` tools).
|
||||
pub async fn dispatch_job_with_context(
|
||||
&self,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
approval_context: ApprovalContext,
|
||||
) -> Result<Uuid, JobError> {
|
||||
self.dispatch_job_inner(
|
||||
user_id,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
Some(approval_context),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Shared implementation for `dispatch_job` and `dispatch_job_with_context`.
|
||||
async fn dispatch_job_inner(
|
||||
&self,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
approval_context: Option<ApprovalContext>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
let job_id = self
|
||||
.context_manager
|
||||
@@ -178,21 +132,12 @@ impl Scheduler {
|
||||
})?;
|
||||
}
|
||||
|
||||
self.schedule_with_context(job_id, approval_context).await?;
|
||||
self.schedule(job_id).await?;
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
/// Schedule a job for execution.
|
||||
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
||||
self.schedule_with_context(job_id, None).await
|
||||
}
|
||||
|
||||
/// Schedule a job with an optional approval context.
|
||||
async fn schedule_with_context(
|
||||
&self,
|
||||
job_id: Uuid,
|
||||
approval_context: Option<ApprovalContext>,
|
||||
) -> Result<(), JobError> {
|
||||
// Hold write lock for the entire check-insert sequence to prevent
|
||||
// TOCTOU races where two concurrent calls both pass the checks.
|
||||
{
|
||||
@@ -236,8 +181,6 @@ impl Scheduler {
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
sse_tx: self.sse_tx.clone(),
|
||||
approval_context,
|
||||
http_interceptor: self.http_interceptor.clone(),
|
||||
};
|
||||
let worker = Worker::new(job_id, deps);
|
||||
|
||||
@@ -314,14 +257,11 @@ impl Scheduler {
|
||||
let context_manager = self.context_manager.clone();
|
||||
let safety = self.safety.clone();
|
||||
|
||||
// TODO: propagate parent job's ApprovalContext here when subtasks
|
||||
// are used in autonomous/routine paths (currently only used in tests).
|
||||
tokio::spawn(async move {
|
||||
let result = Self::execute_tool_task(
|
||||
tools,
|
||||
context_manager,
|
||||
safety,
|
||||
None,
|
||||
tool_parent_id,
|
||||
&tool_name,
|
||||
params,
|
||||
@@ -450,7 +390,6 @@ impl Scheduler {
|
||||
tools: Arc<ToolRegistry>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
approval_context: Option<ApprovalContext>,
|
||||
job_id: Uuid,
|
||||
tool_name: &str,
|
||||
params: serde_json::Value,
|
||||
@@ -474,10 +413,7 @@ impl Scheduler {
|
||||
.into());
|
||||
}
|
||||
|
||||
let requirement = tool.requires_approval(¶ms);
|
||||
let blocked =
|
||||
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
|
||||
if blocked {
|
||||
if tool.requires_approval(¶ms).is_required() {
|
||||
return Err(crate::error::ToolError::AuthRequired {
|
||||
name: tool_name.to_string(),
|
||||
}
|
||||
@@ -681,11 +617,6 @@ impl Scheduler {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::SafetyConfig;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_creation() {
|
||||
// Would need to mock dependencies for proper testing
|
||||
@@ -696,202 +627,4 @@ mod tests {
|
||||
// This test would need mock dependencies.
|
||||
// For now just verify the empty case doesn't panic.
|
||||
}
|
||||
|
||||
/// A tool that returns `UnlessAutoApproved`.
|
||||
struct SoftApprovalTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for SoftApprovalTool {
|
||||
fn name(&self) -> &str {
|
||||
"soft_gate"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"needs soft approval"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object", "properties": {}})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::text(
|
||||
"soft_ok",
|
||||
std::time::Instant::now().elapsed(),
|
||||
))
|
||||
}
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
}
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool that returns `Always`.
|
||||
struct HardApprovalTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for HardApprovalTool {
|
||||
fn name(&self) -> &str {
|
||||
"hard_gate"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"needs hard approval"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object", "properties": {}})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::text(
|
||||
"hard_ok",
|
||||
std::time::Instant::now().elapsed(),
|
||||
))
|
||||
}
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Always
|
||||
}
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_tools_and_job() -> (
|
||||
Arc<ToolRegistry>,
|
||||
Arc<ContextManager>,
|
||||
Arc<SafetyLayer>,
|
||||
Uuid,
|
||||
) {
|
||||
let registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(SoftApprovalTool)).await;
|
||||
registry.register(Arc::new(HardApprovalTool)).await;
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = cm.create_job("test", "approval test").await.unwrap();
|
||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
}));
|
||||
|
||||
(Arc::new(registry), cm, safety, job_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_tool_task_blocks_without_context() {
|
||||
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
||||
|
||||
// Without approval context, UnlessAutoApproved is blocked
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools.clone(),
|
||||
cm.clone(),
|
||||
safety.clone(),
|
||||
None,
|
||||
job_id,
|
||||
"soft_gate",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"soft_gate should be blocked without context"
|
||||
);
|
||||
|
||||
// Always is also blocked
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools,
|
||||
cm,
|
||||
safety,
|
||||
None,
|
||||
job_id,
|
||||
"hard_gate",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"hard_gate should be blocked without context"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_tool_task_autonomous_unblocks_soft() {
|
||||
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
||||
|
||||
// Autonomous context auto-approves UnlessAutoApproved
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools.clone(),
|
||||
cm.clone(),
|
||||
safety.clone(),
|
||||
Some(ApprovalContext::autonomous()),
|
||||
job_id,
|
||||
"soft_gate",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"soft_gate should pass with autonomous context"
|
||||
);
|
||||
|
||||
// But still blocks Always
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools,
|
||||
cm,
|
||||
safety,
|
||||
Some(ApprovalContext::autonomous()),
|
||||
job_id,
|
||||
"hard_gate",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"hard_gate should still be blocked without explicit permission"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_tool_task_autonomous_with_permissions() {
|
||||
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
||||
|
||||
// Autonomous context with explicit permission for hard_gate
|
||||
let ctx = ApprovalContext::autonomous_with_tools(["hard_gate".to_string()]);
|
||||
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools.clone(),
|
||||
cm.clone(),
|
||||
safety.clone(),
|
||||
Some(ctx.clone()),
|
||||
job_id,
|
||||
"soft_gate",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "soft_gate should pass");
|
||||
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools,
|
||||
cm,
|
||||
safety,
|
||||
Some(ctx),
|
||||
job_id,
|
||||
"hard_gate",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"hard_gate should pass with explicit permission"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-18
@@ -320,14 +320,7 @@ impl Thread {
|
||||
pub fn messages(&self) -> Vec<ChatMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for turn in &self.turns {
|
||||
if turn.image_content_parts.is_empty() {
|
||||
messages.push(ChatMessage::user(&turn.user_input));
|
||||
} else {
|
||||
messages.push(ChatMessage::user_with_parts(
|
||||
&turn.user_input,
|
||||
turn.image_content_parts.clone(),
|
||||
));
|
||||
}
|
||||
messages.push(ChatMessage::user(&turn.user_input));
|
||||
if let Some(ref response) = turn.response {
|
||||
messages.push(ChatMessage::assistant(response));
|
||||
}
|
||||
@@ -414,11 +407,6 @@ pub struct Turn {
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// Error message (if failed).
|
||||
pub error: Option<String>,
|
||||
/// Transient image content parts for multimodal LLM input.
|
||||
/// Not serialized — images are only needed for the current LLM call.
|
||||
/// The text description in `user_input` persists for compaction/context.
|
||||
#[serde(skip)]
|
||||
pub image_content_parts: Vec<crate::llm::ContentPart>,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
@@ -433,7 +421,6 @@ impl Turn {
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
error: None,
|
||||
image_content_parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,8 +429,6 @@ impl Turn {
|
||||
self.response = Some(response.into());
|
||||
self.state = TurnState::Completed;
|
||||
self.completed_at = Some(Utc::now());
|
||||
// Free image data — only needed for the initial LLM call, not subsequent turns
|
||||
self.image_content_parts.clear();
|
||||
}
|
||||
|
||||
/// Fail this turn.
|
||||
@@ -451,14 +436,12 @@ impl Turn {
|
||||
self.error = Some(error.into());
|
||||
self.state = TurnState::Failed;
|
||||
self.completed_at = Some(Utc::now());
|
||||
self.image_content_parts.clear();
|
||||
}
|
||||
|
||||
/// Interrupt this turn.
|
||||
pub fn interrupt(&mut self) {
|
||||
self.state = TurnState::Interrupted;
|
||||
self.completed_at = Some(Utc::now());
|
||||
self.image_content_parts.clear();
|
||||
}
|
||||
|
||||
/// Record a tool call.
|
||||
|
||||
+2
-11
@@ -257,14 +257,6 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
|
||||
// Augment content with attachment context (transcripts, metadata, images)
|
||||
let augmented =
|
||||
crate::agent::attachments::augment_with_attachments(content, &message.attachments);
|
||||
let (effective_content, image_parts) = match &augmented {
|
||||
Some(result) => (result.text.as_str(), result.image_parts.clone()),
|
||||
None => (content, Vec::new()),
|
||||
};
|
||||
|
||||
// Start the turn and get messages
|
||||
let turn_messages = {
|
||||
let mut sess = session.lock().await;
|
||||
@@ -272,13 +264,12 @@ impl Agent {
|
||||
.threads
|
||||
.get_mut(&thread_id)
|
||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||
let turn = thread.start_turn(effective_content);
|
||||
turn.image_content_parts = image_parts;
|
||||
thread.start_turn(content);
|
||||
thread.messages()
|
||||
};
|
||||
|
||||
// Persist user message to DB immediately so it survives crashes
|
||||
self.persist_user_message(thread_id, &message.user_id, effective_content)
|
||||
self.persist_user_message(thread_id, &message.user_id, content)
|
||||
.await;
|
||||
|
||||
// Send thinking status
|
||||
|
||||
+15
-252
@@ -19,7 +19,7 @@ use crate::llm::{
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::rate_limiter::RateLimitResult;
|
||||
use crate::tools::{ApprovalContext, ToolRegistry, redact_params};
|
||||
use crate::tools::{ToolRegistry, redact_params};
|
||||
|
||||
/// Shared dependencies for worker execution.
|
||||
///
|
||||
@@ -37,12 +37,6 @@ pub struct WorkerDeps {
|
||||
pub use_planning: bool,
|
||||
/// SSE broadcast sender for live job event streaming to the web gateway.
|
||||
pub sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
|
||||
/// Approval context for tool execution. When `None`, all non-`Never` tools are
|
||||
/// blocked (legacy behavior). When `Some`, the context determines which tools
|
||||
/// are pre-approved for autonomous execution.
|
||||
pub approval_context: Option<ApprovalContext>,
|
||||
/// HTTP interceptor for trace recording/replay (propagated to JobContext).
|
||||
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
||||
}
|
||||
|
||||
/// Worker that executes a single job.
|
||||
@@ -252,9 +246,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
// Already in a terminal state (e.g. execution_loop
|
||||
// called mark_completed itself).
|
||||
}
|
||||
Ok(JobState::Completed) => {
|
||||
// execution_loop already called mark_completed.
|
||||
}
|
||||
Ok(JobState::Stuck) => {
|
||||
// execution_loop marked this as stuck (e.g. "plan
|
||||
// completed but work remains"); leave for self-repair.
|
||||
@@ -305,8 +296,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
let mut iteration = 0;
|
||||
const MAX_CONSECUTIVE_RATE_LIMITS: usize = 10;
|
||||
let mut consecutive_rate_limits = 0usize;
|
||||
const MAX_TOOL_INTENT_NUDGES: u32 = 2;
|
||||
let mut consecutive_tool_intent_nudges: u32 = 0;
|
||||
|
||||
// Initial tool definitions for planning (will be refreshed in loop)
|
||||
reason_ctx.available_tools = self.tools().tool_definitions().await;
|
||||
@@ -364,13 +353,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
if let Some(ref plan) = plan {
|
||||
self.execute_plan(rx, reasoning, reason_ctx, plan).await?;
|
||||
|
||||
// If the plan marked the job completed, terminal, or stuck, we're
|
||||
// done. Only fall through to the direct selection loop if the
|
||||
// plan was interrupted or explicitly left the job in-progress.
|
||||
// If the plan marked the job terminal, we're done. Only fall
|
||||
// through to the direct selection loop if the plan was
|
||||
// interrupted or explicitly left the job in-progress.
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
|
||||
&& (ctx.state.is_terminal()
|
||||
|| ctx.state == JobState::Stuck
|
||||
|| ctx.state == JobState::Completed)
|
||||
&& (ctx.state.is_terminal() || ctx.state == JobState::Stuck)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -504,34 +491,17 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}),
|
||||
);
|
||||
|
||||
// Nudge the LLM if it expressed tool intent without calling tools
|
||||
let signals_intent = !reason_ctx.available_tools.is_empty()
|
||||
&& crate::llm::llm_signals_tool_intent(&response);
|
||||
if signals_intent && consecutive_tool_intent_nudges < MAX_TOOL_INTENT_NUDGES
|
||||
{
|
||||
consecutive_tool_intent_nudges += 1;
|
||||
tracing::info!(
|
||||
job_id = %self.job_id,
|
||||
"LLM expressed tool intent without calling a tool, nudging"
|
||||
);
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
|
||||
} else if !signals_intent {
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
if iteration > 3 && iteration % 5 == 0 {
|
||||
// Generic fallback nudge
|
||||
reason_ctx.messages.push(ChatMessage::user(
|
||||
"Are you stuck? Do you need help completing this job?",
|
||||
));
|
||||
}
|
||||
// Give it one more chance to select a tool
|
||||
if iteration > 3 && iteration % 5 == 0 {
|
||||
reason_ctx.messages.push(ChatMessage::user(
|
||||
"Are you stuck? Do you need help completing this job?",
|
||||
));
|
||||
}
|
||||
}
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
// Model returned tool calls - execute them
|
||||
tracing::debug!(
|
||||
"Job {} respond_with_tools returned {} tool calls",
|
||||
@@ -577,7 +547,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
}
|
||||
} else if selections.len() == 1 {
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
// Single tool: execute directly
|
||||
let selection = &selections[0];
|
||||
tracing::debug!(
|
||||
@@ -702,11 +671,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
name: tool_name.to_string(),
|
||||
})?;
|
||||
|
||||
// Check approval: use context-aware check if available, else block all non-Never tools
|
||||
let requirement = tool.requires_approval(params);
|
||||
let blocked =
|
||||
ApprovalContext::is_blocked_or_default(&deps.approval_context, tool_name, requirement);
|
||||
if blocked {
|
||||
// Tools requiring approval are blocked in autonomous jobs
|
||||
if tool.requires_approval(params).is_required() {
|
||||
return Err(crate::error::ToolError::AuthRequired {
|
||||
name: tool_name.to_string(),
|
||||
}
|
||||
@@ -714,11 +680,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
// Fetch job context early so we have the real user_id for hooks and rate limiting
|
||||
let mut job_ctx = deps.context_manager.get_context(job_id).await?;
|
||||
// Propagate http_interceptor for trace recording/replay
|
||||
if job_ctx.http_interceptor.is_none() {
|
||||
job_ctx.http_interceptor = deps.http_interceptor.clone();
|
||||
}
|
||||
let job_ctx = deps.context_manager.get_context(job_id).await?;
|
||||
|
||||
// Check per-tool rate limit before running hooks or executing (cheaper check first)
|
||||
if let Some(config) = tool.rate_limit_config()
|
||||
@@ -1336,8 +1298,6 @@ mod tests {
|
||||
timeout: Duration::from_secs(30),
|
||||
use_planning: false,
|
||||
sse_tx: None,
|
||||
approval_context: None,
|
||||
http_interceptor: None,
|
||||
};
|
||||
|
||||
Worker::new(job_id, deps)
|
||||
@@ -1454,11 +1414,9 @@ mod tests {
|
||||
assert!(r.result.is_ok(), "Tool should succeed");
|
||||
}
|
||||
// Parallel should complete well under the sequential 600ms threshold.
|
||||
// Use a generous bound (800ms) to avoid flaky failures on slow CI runners,
|
||||
// while still proving parallelism (sequential would be >= 600ms on any machine).
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(800),
|
||||
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
|
||||
elapsed < Duration::from_millis(500),
|
||||
"Parallel execution took {:?}, expected < 500ms",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
@@ -1536,199 +1494,4 @@ mod tests {
|
||||
"Missing tool should produce an error, not a panic"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that calling mark_completed on an already-Completed job returns
|
||||
/// an error (Completed → Completed is an invalid state transition).
|
||||
#[tokio::test]
|
||||
async fn test_mark_completed_twice_returns_error() {
|
||||
let worker = make_worker(vec![]).await;
|
||||
|
||||
// Transition to InProgress first (required by state machine)
|
||||
worker
|
||||
.context_manager()
|
||||
.update_context(worker.job_id, |ctx| {
|
||||
ctx.transition_to(JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// First mark_completed should succeed
|
||||
worker.mark_completed().await.unwrap();
|
||||
|
||||
// Verify state is Completed
|
||||
let ctx = worker
|
||||
.context_manager()
|
||||
.get_context(worker.job_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Completed);
|
||||
|
||||
// Second mark_completed should fail (Completed → Completed is invalid)
|
||||
let result = worker.mark_completed().await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Completed → Completed transition should be rejected by state machine"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a Worker with the given approval context.
|
||||
async fn make_worker_with_approval(
|
||||
tools: Vec<Arc<dyn Tool>>,
|
||||
approval_context: Option<crate::tools::ApprovalContext>,
|
||||
) -> Worker {
|
||||
let registry = ToolRegistry::new();
|
||||
for t in tools {
|
||||
registry.register(t).await;
|
||||
}
|
||||
|
||||
let cm = Arc::new(crate::context::ContextManager::new(5));
|
||||
let job_id = cm.create_job("test", "test job").await.unwrap();
|
||||
|
||||
let deps = WorkerDeps {
|
||||
context_manager: cm,
|
||||
llm: Arc::new(StubLlm),
|
||||
safety: Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
})),
|
||||
tools: Arc::new(registry),
|
||||
store: None,
|
||||
hooks: Arc::new(crate::hooks::HookRegistry::new()),
|
||||
timeout: Duration::from_secs(30),
|
||||
use_planning: false,
|
||||
sse_tx: None,
|
||||
approval_context,
|
||||
http_interceptor: None,
|
||||
};
|
||||
|
||||
Worker::new(job_id, deps)
|
||||
}
|
||||
|
||||
/// A tool that requires approval (UnlessAutoApproved).
|
||||
struct ApprovalTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for ApprovalTool {
|
||||
fn name(&self) -> &str {
|
||||
"needs_approval"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Tool requiring approval"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object", "properties": {}})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &crate::context::JobContext,
|
||||
) -> Result<ToolOutput, crate::tools::ToolError> {
|
||||
Ok(ToolOutput::text(
|
||||
"approved",
|
||||
std::time::Instant::now().elapsed(),
|
||||
))
|
||||
}
|
||||
fn requires_approval(
|
||||
&self,
|
||||
_params: &serde_json::Value,
|
||||
) -> crate::tools::ApprovalRequirement {
|
||||
crate::tools::ApprovalRequirement::UnlessAutoApproved
|
||||
}
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool that always requires approval.
|
||||
struct AlwaysApprovalTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for AlwaysApprovalTool {
|
||||
fn name(&self) -> &str {
|
||||
"always_approval"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Tool always requiring approval"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object", "properties": {}})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &crate::context::JobContext,
|
||||
) -> Result<ToolOutput, crate::tools::ToolError> {
|
||||
Ok(ToolOutput::text(
|
||||
"always",
|
||||
std::time::Instant::now().elapsed(),
|
||||
))
|
||||
}
|
||||
fn requires_approval(
|
||||
&self,
|
||||
_params: &serde_json::Value,
|
||||
) -> crate::tools::ApprovalRequirement {
|
||||
crate::tools::ApprovalRequirement::Always
|
||||
}
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_approval_context_unblocks_unless_auto_approved() {
|
||||
// Without approval context, UnlessAutoApproved is blocked
|
||||
let worker_blocked = make_worker_with_approval(vec![Arc::new(ApprovalTool)], None).await;
|
||||
let result = worker_blocked
|
||||
.execute_tool("needs_approval", &serde_json::json!({}))
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should be blocked without approval context"
|
||||
);
|
||||
|
||||
// With autonomous approval context, UnlessAutoApproved is allowed
|
||||
let worker_allowed = make_worker_with_approval(
|
||||
vec![Arc::new(ApprovalTool)],
|
||||
Some(crate::tools::ApprovalContext::autonomous()),
|
||||
)
|
||||
.await;
|
||||
let result = worker_allowed
|
||||
.execute_tool("needs_approval", &serde_json::json!({}))
|
||||
.await;
|
||||
assert!(result.is_ok(), "Should be allowed with autonomous context");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_approval_context_blocks_always_unless_permitted() {
|
||||
// Autonomous context without tool_permissions blocks Always tools
|
||||
let worker_blocked = make_worker_with_approval(
|
||||
vec![Arc::new(AlwaysApprovalTool)],
|
||||
Some(crate::tools::ApprovalContext::autonomous()),
|
||||
)
|
||||
.await;
|
||||
let result = worker_blocked
|
||||
.execute_tool("always_approval", &serde_json::json!({}))
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Always tool should be blocked without permission"
|
||||
);
|
||||
|
||||
// Autonomous context with tool_permissions allows Always tools
|
||||
let worker_allowed = make_worker_with_approval(
|
||||
vec![Arc::new(AlwaysApprovalTool)],
|
||||
Some(crate::tools::ApprovalContext::autonomous_with_tools([
|
||||
"always_approval".to_string(),
|
||||
])),
|
||||
)
|
||||
.await;
|
||||
let result = worker_allowed
|
||||
.execute_tool("always_approval", &serde_json::json!({}))
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Always tool should be allowed with permission"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -368,6 +368,21 @@ impl AppBuilder {
|
||||
.embeddings
|
||||
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
|
||||
|
||||
// Warn if libSQL backend is used with non-1536 embedding dimension.
|
||||
if self.config.database.backend == crate::config::DatabaseBackend::LibSql
|
||||
&& self.config.embeddings.enabled
|
||||
&& self.config.embeddings.dimension != 1536
|
||||
{
|
||||
tracing::warn!(
|
||||
configured_dimension = self.config.embeddings.dimension,
|
||||
"Embedding dimension {} is not 1536. The libSQL schema uses \
|
||||
F32_BLOB(1536) which requires exactly 1536 dimensions. \
|
||||
Embedding storage will fail. Use PostgreSQL or set \
|
||||
EMBEDDING_DIMENSION=1536.",
|
||||
self.config.embeddings.dimension
|
||||
);
|
||||
}
|
||||
|
||||
// Register memory tools if database is available
|
||||
let workspace = if let Some(ref db) = self.db {
|
||||
let mut ws = Workspace::new_with_db("default", db.clone());
|
||||
|
||||
@@ -10,56 +10,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Kind of attachment carried on an incoming message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AttachmentKind {
|
||||
/// Audio content (voice notes, audio files).
|
||||
Audio,
|
||||
/// Image content (photos, screenshots).
|
||||
Image,
|
||||
/// Document content (PDFs, files).
|
||||
Document,
|
||||
}
|
||||
|
||||
impl AttachmentKind {
|
||||
/// Infer attachment kind from MIME type.
|
||||
pub fn from_mime_type(mime: &str) -> Self {
|
||||
let base = mime.split(';').next().unwrap_or(mime).trim();
|
||||
if base.starts_with("audio/") {
|
||||
Self::Audio
|
||||
} else if base.starts_with("image/") {
|
||||
Self::Image
|
||||
} else {
|
||||
Self::Document
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file or media attachment on an incoming message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingAttachment {
|
||||
/// Unique identifier within the channel (e.g., Telegram file_id).
|
||||
pub id: String,
|
||||
/// What kind of content this is.
|
||||
pub kind: AttachmentKind,
|
||||
/// MIME type (e.g., "image/jpeg", "audio/ogg", "application/pdf").
|
||||
pub mime_type: String,
|
||||
/// Original filename, if known.
|
||||
pub filename: Option<String>,
|
||||
/// File size in bytes, if known.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// URL to download the file from the channel's API.
|
||||
pub source_url: Option<String>,
|
||||
/// Opaque key for host-side storage (e.g., after download/caching).
|
||||
pub storage_key: Option<String>,
|
||||
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
|
||||
pub extracted_text: Option<String>,
|
||||
/// Raw file bytes (for small files downloaded by the channel).
|
||||
pub data: Vec<u8>,
|
||||
/// Duration in seconds (for audio/video).
|
||||
pub duration_secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// A message received from an external channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingMessage {
|
||||
@@ -79,8 +29,6 @@ pub struct IncomingMessage {
|
||||
pub received_at: DateTime<Utc>,
|
||||
/// Channel-specific metadata.
|
||||
pub metadata: serde_json::Value,
|
||||
/// File or media attachments on this message.
|
||||
pub attachments: Vec<IncomingAttachment>,
|
||||
}
|
||||
|
||||
impl IncomingMessage {
|
||||
@@ -99,7 +47,6 @@ impl IncomingMessage {
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,12 +67,6 @@ impl IncomingMessage {
|
||||
self.user_name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set attachments.
|
||||
pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self {
|
||||
self.attachments = attachments;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream of incoming messages.
|
||||
|
||||
@@ -235,106 +235,3 @@ impl Default for ChannelManager {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::testing::StubChannel;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_and_start_all() {
|
||||
let manager = ChannelManager::new();
|
||||
let (stub, sender) = StubChannel::new("test");
|
||||
|
||||
manager.add(Box::new(stub)).await;
|
||||
|
||||
let mut stream = manager.start_all().await.expect("start_all failed");
|
||||
|
||||
// Inject a message through the stub
|
||||
sender
|
||||
.send(IncomingMessage::new("test", "user1", "hello"))
|
||||
.await
|
||||
.expect("send failed");
|
||||
|
||||
// Should appear in the merged stream
|
||||
let msg = stream.next().await.expect("stream ended");
|
||||
assert_eq!(msg.content, "hello");
|
||||
assert_eq!(msg.channel, "test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_routes_to_correct_channel() {
|
||||
let manager = ChannelManager::new();
|
||||
let (stub, _sender) = StubChannel::new("alpha");
|
||||
|
||||
// Keep a reference for response inspection
|
||||
let responses = stub.captured_responses_handle();
|
||||
manager.add(Box::new(stub)).await;
|
||||
|
||||
let msg = IncomingMessage::new("alpha", "user1", "request");
|
||||
manager
|
||||
.respond(&msg, OutgoingResponse::text("reply"))
|
||||
.await
|
||||
.expect("respond failed");
|
||||
|
||||
// Verify the stub captured the response
|
||||
let captured = responses.lock().expect("poisoned");
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].1.content, "reply");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_respond_unknown_channel_errors() {
|
||||
let manager = ChannelManager::new();
|
||||
let msg = IncomingMessage::new("nonexistent", "user1", "test");
|
||||
let result = manager.respond(&msg, OutgoingResponse::text("hi")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_all() {
|
||||
let manager = ChannelManager::new();
|
||||
let (stub1, _) = StubChannel::new("healthy");
|
||||
let (stub2, _) = StubChannel::new("sick");
|
||||
stub2.set_healthy(false);
|
||||
|
||||
manager.add(Box::new(stub1)).await;
|
||||
manager.add(Box::new(stub2)).await;
|
||||
|
||||
let results = manager.health_check_all().await;
|
||||
assert!(results["healthy"].is_ok());
|
||||
assert!(results["sick"].is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_start_all_no_channels_errors() {
|
||||
let manager = ChannelManager::new();
|
||||
let result = manager.start_all().await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_injection_channel_merges() {
|
||||
let manager = ChannelManager::new();
|
||||
let (stub, _sender) = StubChannel::new("real");
|
||||
manager.add(Box::new(stub)).await;
|
||||
|
||||
let mut stream = manager.start_all().await.expect("start_all failed");
|
||||
|
||||
// Use the injection channel (simulating background task)
|
||||
let inject_tx = manager.inject_sender();
|
||||
inject_tx
|
||||
.send(IncomingMessage::new(
|
||||
"injected",
|
||||
"system",
|
||||
"background alert",
|
||||
))
|
||||
.await
|
||||
.expect("inject failed");
|
||||
|
||||
let msg = stream.next().await.expect("stream ended");
|
||||
assert_eq!(msg.content, "background alert");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -36,10 +36,7 @@ pub mod wasm;
|
||||
pub mod web;
|
||||
mod webhook_server;
|
||||
|
||||
pub use channel::{
|
||||
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse,
|
||||
StatusUpdate,
|
||||
};
|
||||
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
pub use http::HttpChannel;
|
||||
pub use manager::ChannelManager;
|
||||
pub use repl::ReplChannel;
|
||||
|
||||
+1
-329
@@ -5,7 +5,6 @@
|
||||
//! - Workspace write access (scoped to channel namespace)
|
||||
//! - Rate limiting for message emission
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::channels::wasm::capabilities::{ChannelCapabilities, EmitRateLimitConfig};
|
||||
@@ -18,52 +17,6 @@ const MAX_EMITS_PER_EXECUTION: usize = 100;
|
||||
/// Maximum message content size (64 KB).
|
||||
const MAX_MESSAGE_CONTENT_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// A file or media attachment on an incoming message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Attachment {
|
||||
/// Unique identifier within the channel (e.g., Telegram file_id).
|
||||
pub id: String,
|
||||
/// MIME type (e.g., "image/jpeg", "audio/ogg", "application/pdf").
|
||||
pub mime_type: String,
|
||||
/// Original filename, if known.
|
||||
pub filename: Option<String>,
|
||||
/// File size in bytes, if known.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// URL to download the file from the channel's API.
|
||||
pub source_url: Option<String>,
|
||||
/// Opaque key for host-side storage (e.g., after download/caching).
|
||||
pub storage_key: Option<String>,
|
||||
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
|
||||
pub extracted_text: Option<String>,
|
||||
/// Raw file bytes (for small files downloaded by the channel).
|
||||
pub data: Vec<u8>,
|
||||
/// Duration in seconds (for audio/video).
|
||||
pub duration_secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// Maximum total attachment size per message (20 MB).
|
||||
const MAX_ATTACHMENT_TOTAL_SIZE: u64 = 20 * 1024 * 1024;
|
||||
|
||||
/// Maximum number of attachments per message.
|
||||
const MAX_ATTACHMENTS_PER_MESSAGE: usize = 10;
|
||||
|
||||
/// Allowed MIME type prefixes for attachments.
|
||||
const ALLOWED_MIME_PREFIXES: &[&str] = &[
|
||||
"image/",
|
||||
"audio/",
|
||||
"video/",
|
||||
"application/pdf",
|
||||
"application/vnd.",
|
||||
"application/msword",
|
||||
"application/rtf",
|
||||
"text/",
|
||||
"application/json",
|
||||
"application/zip",
|
||||
"application/gzip",
|
||||
"application/x-tar",
|
||||
"application/octet-stream",
|
||||
];
|
||||
|
||||
/// A message emitted by a WASM channel to be sent to the agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmittedMessage {
|
||||
@@ -82,9 +35,6 @@ pub struct EmittedMessage {
|
||||
/// Channel-specific metadata as JSON string.
|
||||
pub metadata_json: String,
|
||||
|
||||
/// File or media attachments on this message.
|
||||
pub attachments: Vec<Attachment>,
|
||||
|
||||
/// Timestamp when the message was emitted.
|
||||
pub emitted_at_millis: u64,
|
||||
}
|
||||
@@ -98,7 +48,6 @@ impl EmittedMessage {
|
||||
content: content.into(),
|
||||
thread_id: None,
|
||||
metadata_json: "{}".to_string(),
|
||||
attachments: Vec::new(),
|
||||
emitted_at_millis: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
@@ -123,12 +72,6 @@ impl EmittedMessage {
|
||||
self.metadata_json = metadata_json.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set attachments.
|
||||
pub fn with_attachments(mut self, attachments: Vec<Attachment>) -> Self {
|
||||
self.attachments = attachments;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending workspace write operation.
|
||||
@@ -169,13 +112,6 @@ pub struct ChannelHostState {
|
||||
|
||||
/// Count of emits dropped due to rate limiting.
|
||||
emits_dropped: usize,
|
||||
|
||||
/// Binary data stored for attachments via `store-attachment-data`.
|
||||
/// Keyed by attachment ID, cleared after callback completes.
|
||||
attachment_data: HashMap<String, Vec<u8>>,
|
||||
|
||||
/// Total bytes stored in attachment_data (for enforcing limits).
|
||||
attachment_data_total: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChannelHostState {
|
||||
@@ -205,8 +141,6 @@ impl ChannelHostState {
|
||||
emit_count: 0,
|
||||
emit_enabled: true,
|
||||
emits_dropped: 0,
|
||||
attachment_data: HashMap::new(),
|
||||
attachment_data_total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +168,6 @@ impl ChannelHostState {
|
||||
///
|
||||
/// Messages are queued and delivered after callback execution completes.
|
||||
/// Rate limiting is enforced per-execution and globally.
|
||||
/// Attachments are validated for count, total size, and MIME type.
|
||||
pub fn emit_message(&mut self, msg: EmittedMessage) -> Result<(), WasmChannelError> {
|
||||
// Check per-execution limit
|
||||
if !self.emit_enabled {
|
||||
@@ -253,9 +186,6 @@ impl ChannelHostState {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate attachments
|
||||
let msg = self.validate_attachments(msg);
|
||||
|
||||
// Validate message content size
|
||||
if msg.content.len() > MAX_MESSAGE_CONTENT_SIZE {
|
||||
tracing::warn!(
|
||||
@@ -279,71 +209,6 @@ impl ChannelHostState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate and sanitize attachments on an emitted message.
|
||||
///
|
||||
/// Enforces count limits, total size limits, and MIME type allowlist.
|
||||
/// Invalid attachments are dropped with a warning.
|
||||
fn validate_attachments(&self, mut msg: EmittedMessage) -> EmittedMessage {
|
||||
if msg.attachments.is_empty() {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Enforce attachment count limit
|
||||
if msg.attachments.len() > MAX_ATTACHMENTS_PER_MESSAGE {
|
||||
tracing::warn!(
|
||||
channel = %self.channel_name,
|
||||
count = msg.attachments.len(),
|
||||
max = MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
"Too many attachments, truncating"
|
||||
);
|
||||
msg.attachments.truncate(MAX_ATTACHMENTS_PER_MESSAGE);
|
||||
}
|
||||
|
||||
// Filter by MIME type and enforce total size limit
|
||||
let mut total_size: u64 = 0;
|
||||
msg.attachments.retain(|att| {
|
||||
let mime_ok = ALLOWED_MIME_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| att.mime_type.starts_with(prefix));
|
||||
if !mime_ok {
|
||||
tracing::warn!(
|
||||
channel = %self.channel_name,
|
||||
mime_type = %att.mime_type,
|
||||
"Attachment MIME type not allowed, dropping"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the larger of reported size_bytes and actual stored data size
|
||||
// to prevent WASM channels from under-reporting to bypass limits.
|
||||
let stored_size = self
|
||||
.attachment_data
|
||||
.get(&att.id)
|
||||
.map(|d| d.len() as u64)
|
||||
.unwrap_or(att.data.len() as u64);
|
||||
let size = att
|
||||
.size_bytes
|
||||
.map(|reported| reported.max(stored_size))
|
||||
.unwrap_or(stored_size);
|
||||
if size > 0 {
|
||||
total_size = total_size.saturating_add(size);
|
||||
if total_size > MAX_ATTACHMENT_TOTAL_SIZE {
|
||||
tracing::warn!(
|
||||
channel = %self.channel_name,
|
||||
total_size,
|
||||
max = MAX_ATTACHMENT_TOTAL_SIZE,
|
||||
"Attachment total size exceeded, dropping"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
});
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
/// Take all emitted messages (clears the queue).
|
||||
pub fn take_emitted_messages(&mut self) -> Vec<EmittedMessage> {
|
||||
std::mem::take(&mut self.emitted_messages)
|
||||
@@ -359,69 +224,6 @@ impl ChannelHostState {
|
||||
self.emits_dropped
|
||||
}
|
||||
|
||||
/// Store binary data for an attachment.
|
||||
///
|
||||
/// Called by WASM channels to associate downloaded bytes with an attachment ID.
|
||||
/// The data is retrieved after callback completion and merged into `Attachment::data`.
|
||||
pub fn store_attachment_data(
|
||||
&mut self,
|
||||
attachment_id: &str,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), WasmChannelError> {
|
||||
const MAX_PER_ATTACHMENT: u64 = 20 * 1024 * 1024; // 20 MB
|
||||
const MAX_TOTAL: u64 = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
let size = data.len() as u64;
|
||||
if size > MAX_PER_ATTACHMENT {
|
||||
return Err(WasmChannelError::CallbackFailed {
|
||||
name: self.channel_name.clone(),
|
||||
reason: format!(
|
||||
"Attachment data too large: {} bytes (max {})",
|
||||
size, MAX_PER_ATTACHMENT
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Subtract the old entry size (if overwriting) before adding new size
|
||||
let old_size = self
|
||||
.attachment_data
|
||||
.get(attachment_id)
|
||||
.map(|d| d.len() as u64)
|
||||
.unwrap_or(0);
|
||||
let adjusted_total = self.attachment_data_total.saturating_sub(old_size);
|
||||
let new_total = adjusted_total.saturating_add(size);
|
||||
if new_total > MAX_TOTAL {
|
||||
return Err(WasmChannelError::CallbackFailed {
|
||||
name: self.channel_name.clone(),
|
||||
reason: format!(
|
||||
"Total attachment data too large: {} bytes (max {})",
|
||||
new_total, MAX_TOTAL
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
self.attachment_data_total = new_total;
|
||||
self.attachment_data.insert(attachment_id.to_string(), data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove stored binary data for a specific attachment ID.
|
||||
pub fn remove_attachment_data(&mut self, id: &str) -> Option<Vec<u8>> {
|
||||
if let Some(data) = self.attachment_data.remove(id) {
|
||||
self.attachment_data_total =
|
||||
self.attachment_data_total.saturating_sub(data.len() as u64);
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Take all stored attachment data (clears the store).
|
||||
pub fn take_attachment_data(&mut self) -> HashMap<String, Vec<u8>> {
|
||||
self.attachment_data_total = 0;
|
||||
std::mem::take(&mut self.attachment_data)
|
||||
}
|
||||
|
||||
/// Write to workspace (scoped to channel namespace).
|
||||
///
|
||||
/// Writes are queued and committed after callback execution completes.
|
||||
@@ -629,8 +431,7 @@ impl ChannelEmitRateLimiter {
|
||||
mod tests {
|
||||
use crate::channels::wasm::capabilities::{ChannelCapabilities, EmitRateLimitConfig};
|
||||
use crate::channels::wasm::host::{
|
||||
Attachment, ChannelEmitRateLimiter, ChannelHostState, EmittedMessage,
|
||||
MAX_ATTACHMENT_TOTAL_SIZE, MAX_ATTACHMENTS_PER_MESSAGE, MAX_EMITS_PER_EXECUTION,
|
||||
ChannelEmitRateLimiter, ChannelHostState, EmittedMessage, MAX_EMITS_PER_EXECUTION,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -959,133 +760,4 @@ mod tests {
|
||||
Some("200".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// === Attachment validation tests ===
|
||||
|
||||
fn make_attachment(id: &str, mime: &str, size: Option<u64>) -> Attachment {
|
||||
Attachment {
|
||||
id: id.to_string(),
|
||||
mime_type: mime.to_string(),
|
||||
filename: None,
|
||||
size_bytes: size,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
data: Vec::new(),
|
||||
duration_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_message_with_attachments() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
let msg = EmittedMessage::new("user1", "Check this image")
|
||||
.with_attachments(vec![make_attachment("file1", "image/jpeg", Some(1024))]);
|
||||
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].attachments.len(), 1);
|
||||
assert_eq!(messages[0].attachments[0].id, "file1");
|
||||
assert_eq!(messages[0].attachments[0].mime_type, "image/jpeg");
|
||||
assert_eq!(messages[0].attachments[0].size_bytes, Some(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_message_no_attachments_backward_compat() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
let msg = EmittedMessage::new("user1", "Just text");
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(messages[0].attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attachment_count_limit() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
let attachments: Vec<Attachment> = (0..MAX_ATTACHMENTS_PER_MESSAGE + 5)
|
||||
.map(|i| make_attachment(&format!("file{}", i), "image/png", Some(100)))
|
||||
.collect();
|
||||
|
||||
let msg = EmittedMessage::new("user1", "Many files").with_attachments(attachments);
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
assert_eq!(messages[0].attachments.len(), MAX_ATTACHMENTS_PER_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attachment_total_size_limit() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
// Each file is 1/3 of the limit, so 3 fit but 4th does not
|
||||
let chunk_size = MAX_ATTACHMENT_TOTAL_SIZE / 3;
|
||||
let attachments = vec![
|
||||
make_attachment("file1", "image/png", Some(chunk_size)),
|
||||
make_attachment("file2", "image/png", Some(chunk_size)),
|
||||
make_attachment("file3", "image/png", Some(chunk_size)),
|
||||
make_attachment("file4", "image/png", Some(chunk_size)),
|
||||
];
|
||||
|
||||
let msg = EmittedMessage::new("user1", "Big files").with_attachments(attachments);
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
// Only first 3 fit within the total size limit
|
||||
assert_eq!(messages[0].attachments.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attachment_mime_type_filtering() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
let attachments = vec![
|
||||
make_attachment("ok1", "image/jpeg", Some(100)),
|
||||
make_attachment("bad1", "application/x-executable", Some(100)),
|
||||
make_attachment("ok2", "application/pdf", Some(100)),
|
||||
make_attachment("bad2", "application/x-msdos-program", Some(100)),
|
||||
make_attachment("ok3", "text/plain", Some(100)),
|
||||
make_attachment("ok4", "audio/mpeg", Some(100)),
|
||||
make_attachment("ok5", "video/mp4", Some(100)),
|
||||
];
|
||||
|
||||
let msg = EmittedMessage::new("user1", "Mixed files").with_attachments(attachments);
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
let ids: Vec<&str> = messages[0]
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|a| a.id.as_str())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["ok1", "ok2", "ok3", "ok4", "ok5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attachment_unknown_size_allowed() {
|
||||
let caps = ChannelCapabilities::for_channel("test");
|
||||
let mut state = ChannelHostState::new("test", caps);
|
||||
|
||||
let attachments = vec![
|
||||
make_attachment("file1", "image/jpeg", None),
|
||||
make_attachment("file2", "image/png", None),
|
||||
];
|
||||
|
||||
let msg = EmittedMessage::new("user1", "No sizes").with_attachments(attachments);
|
||||
state.emit_message(msg).unwrap();
|
||||
|
||||
let messages = state.take_emitted_messages();
|
||||
assert_eq!(messages[0].attachments.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,16 +153,7 @@ impl WasmChannelRuntime {
|
||||
// Enable persistent compilation cache. Wasmtime serializes compiled native
|
||||
// code to disk (~/.cache/wasmtime by default), so subsequent startups
|
||||
// deserialize instead of recompiling — typically 10-50x faster.
|
||||
//
|
||||
// On Windows, each Engine gets its own cache subdirectory to avoid
|
||||
// OS error 33 (ERROR_LOCK_VIOLATION) when multiple engines share the
|
||||
// default cache and Windows holds exclusive locks on memory-mapped
|
||||
// files. See #448.
|
||||
if let Err(e) = crate::tools::wasm::enable_compilation_cache(
|
||||
&mut wasmtime_config,
|
||||
"channels",
|
||||
config.cache_dir.as_deref(),
|
||||
) {
|
||||
if let Err(e) = wasmtime_config.cache_config_load_default() {
|
||||
tracing::warn!("Failed to enable wasmtime compilation cache: {}", e);
|
||||
}
|
||||
|
||||
|
||||
+15
-441
@@ -532,45 +532,9 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
user_id = %msg.user_id,
|
||||
user_name = ?msg.user_name,
|
||||
content_len = msg.content.len(),
|
||||
attachment_count = msg.attachments.len(),
|
||||
"WASM emit_message called"
|
||||
);
|
||||
|
||||
let attachments: Vec<crate::channels::wasm::host::Attachment> = msg
|
||||
.attachments
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
// Parse extras-json for well-known fields
|
||||
let extras: serde_json::Value = if a.extras_json.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::from_str(&a.extras_json).unwrap_or(serde_json::Value::Null)
|
||||
};
|
||||
let duration_secs = extras
|
||||
.get("duration_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
|
||||
// Merge stored binary data (from store-attachment-data host call)
|
||||
let data = self
|
||||
.host_state
|
||||
.remove_attachment_data(&a.id)
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::channels::wasm::host::Attachment {
|
||||
id: a.id,
|
||||
mime_type: a.mime_type,
|
||||
filename: a.filename,
|
||||
size_bytes: a.size_bytes,
|
||||
source_url: a.source_url,
|
||||
storage_key: a.storage_key,
|
||||
extracted_text: a.extracted_text,
|
||||
data,
|
||||
duration_secs,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut emitted = EmittedMessage::new(msg.user_id.clone(), msg.content.clone());
|
||||
if let Some(name) = msg.user_name {
|
||||
emitted = emitted.with_user_name(name);
|
||||
@@ -579,7 +543,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
emitted = emitted.with_thread_id(tid);
|
||||
}
|
||||
emitted = emitted.with_metadata(msg.metadata_json);
|
||||
emitted = emitted.with_attachments(attachments);
|
||||
|
||||
match self.host_state.emit_message(emitted) {
|
||||
Ok(()) => {
|
||||
@@ -591,21 +554,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
}
|
||||
}
|
||||
|
||||
fn store_attachment_data(
|
||||
&mut self,
|
||||
attachment_id: String,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), String> {
|
||||
tracing::debug!(
|
||||
attachment_id = %attachment_id,
|
||||
size = data.len(),
|
||||
"WASM store_attachment_data called"
|
||||
);
|
||||
self.host_state
|
||||
.store_attachment_data(&attachment_id, data)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn pairing_upsert_request(
|
||||
&mut self,
|
||||
channel: String,
|
||||
@@ -1379,14 +1327,12 @@ impl WasmChannel {
|
||||
content: &str,
|
||||
thread_id: Option<&str>,
|
||||
metadata_json: &str,
|
||||
attachments: &[String],
|
||||
) -> Result<(), WasmChannelError> {
|
||||
tracing::info!(
|
||||
channel = %self.name,
|
||||
message_id = %message_id,
|
||||
content_len = content.len(),
|
||||
thread_id = ?thread_id,
|
||||
attachment_count = attachments.len(),
|
||||
"call_on_respond invoked"
|
||||
);
|
||||
|
||||
@@ -1424,21 +1370,12 @@ impl WasmChannel {
|
||||
let content = content.to_string();
|
||||
let thread_id = thread_id.map(|s| s.to_string());
|
||||
let metadata_json = metadata_json.to_string();
|
||||
let attachments = attachments.to_vec();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
tracing::info!(channel = %channel_name, "Starting on_respond WASM execution");
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Read attachment files from disk before entering WASM
|
||||
let wit_attachments = read_attachments(&attachments).map_err(|e| {
|
||||
WasmChannelError::CallbackFailed {
|
||||
name: prepared.name.clone(),
|
||||
reason: e,
|
||||
}
|
||||
})?;
|
||||
|
||||
tracing::info!("Creating WASM store for on_respond");
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
@@ -1458,7 +1395,6 @@ impl WasmChannel {
|
||||
content: content.clone(),
|
||||
thread_id,
|
||||
metadata_json,
|
||||
attachments: wit_attachments,
|
||||
};
|
||||
|
||||
// Truncate at char boundary for logging (avoid panic on multi-byte UTF-8)
|
||||
@@ -1522,124 +1458,6 @@ impl WasmChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the on_broadcast callback.
|
||||
///
|
||||
/// Called to send a proactive message to a user without a prior incoming message.
|
||||
pub async fn call_on_broadcast(
|
||||
&self,
|
||||
user_id: &str,
|
||||
content: &str,
|
||||
thread_id: Option<&str>,
|
||||
attachments: &[String],
|
||||
) -> Result<(), WasmChannelError> {
|
||||
tracing::info!(
|
||||
channel = %self.name,
|
||||
user_id = %user_id,
|
||||
content_len = content.len(),
|
||||
attachment_count = attachments.len(),
|
||||
"call_on_broadcast invoked"
|
||||
);
|
||||
|
||||
// If no WASM bytes, do nothing (for testing)
|
||||
if self.prepared.component().is_none() {
|
||||
tracing::debug!(
|
||||
channel = %self.name,
|
||||
"WASM channel on_broadcast called (no WASM module)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
let prepared = Arc::clone(&self.prepared);
|
||||
let capabilities = self.capabilities.clone();
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let user_id = user_id.to_string();
|
||||
let content = content.to_string();
|
||||
let thread_id = thread_id.map(|s| s.to_string());
|
||||
let attachments = attachments.to_vec();
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Read attachment files from disk
|
||||
let wit_attachments = read_attachments(&attachments).map_err(|e| {
|
||||
WasmChannelError::CallbackFailed {
|
||||
name: prepared.name.clone(),
|
||||
reason: e,
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
let wit_response = wit_channel::AgentResponse {
|
||||
message_id: String::new(),
|
||||
content: content.clone(),
|
||||
thread_id,
|
||||
metadata_json: String::new(),
|
||||
attachments: wit_attachments,
|
||||
};
|
||||
|
||||
let channel_iface = instance.near_agent_channel();
|
||||
let wasm_result = channel_iface
|
||||
.call_on_broadcast(&mut store, &user_id, &wit_response)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "WASM on_broadcast call failed");
|
||||
Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel)
|
||||
})?;
|
||||
|
||||
if let Err(ref err_msg) = wasm_result {
|
||||
tracing::error!(error = %err_msg, "WASM on_broadcast returned error");
|
||||
return Err(WasmChannelError::CallbackFailed {
|
||||
name: prepared.name.clone(),
|
||||
reason: err_msg.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let host_state =
|
||||
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
||||
tracing::info!("on_broadcast WASM execution completed successfully");
|
||||
Ok(((), host_state))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
||||
name: channel_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
})
|
||||
.await;
|
||||
|
||||
let channel_name = self.name.clone();
|
||||
match result {
|
||||
Ok(Ok(((), _host_state))) => {
|
||||
tracing::debug!(
|
||||
channel = %channel_name,
|
||||
"WASM channel on_broadcast completed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(WasmChannelError::Timeout {
|
||||
name: channel_name,
|
||||
callback: "on_broadcast".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the on_status callback.
|
||||
///
|
||||
/// Called to notify the WASM channel of agent status changes (e.g., typing).
|
||||
@@ -1927,7 +1745,7 @@ impl WasmChannel {
|
||||
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||
if let Err(e) = self
|
||||
.call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json, &[])
|
||||
.call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
@@ -2029,27 +1847,6 @@ impl WasmChannel {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
// Convert attachments
|
||||
if !emitted.attachments.is_empty() {
|
||||
let incoming_attachments = emitted
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|a| crate::channels::IncomingAttachment {
|
||||
id: a.id.clone(),
|
||||
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
|
||||
mime_type: a.mime_type.clone(),
|
||||
filename: a.filename.clone(),
|
||||
size_bytes: a.size_bytes,
|
||||
source_url: a.source_url.clone(),
|
||||
storage_key: a.storage_key.clone(),
|
||||
extracted_text: a.extracted_text.clone(),
|
||||
data: a.data.clone(),
|
||||
duration_secs: a.duration_secs,
|
||||
})
|
||||
.collect();
|
||||
msg = msg.with_attachments(incoming_attachments);
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
@@ -2062,7 +1859,6 @@ impl WasmChannel {
|
||||
channel = %self.name,
|
||||
user_id = %emitted.user_id,
|
||||
content_len = emitted.content.len(),
|
||||
attachment_count = msg.attachments.len(),
|
||||
"Sending emitted message to agent"
|
||||
);
|
||||
|
||||
@@ -2316,27 +2112,6 @@ impl WasmChannel {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
// Convert attachments
|
||||
if !emitted.attachments.is_empty() {
|
||||
let incoming_attachments = emitted
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|a| crate::channels::IncomingAttachment {
|
||||
id: a.id.clone(),
|
||||
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
|
||||
mime_type: a.mime_type.clone(),
|
||||
filename: a.filename.clone(),
|
||||
size_bytes: a.size_bytes,
|
||||
source_url: a.source_url.clone(),
|
||||
storage_key: a.storage_key.clone(),
|
||||
extracted_text: a.extracted_text.clone(),
|
||||
data: a.data.clone(),
|
||||
duration_secs: a.duration_secs,
|
||||
})
|
||||
.collect();
|
||||
msg = msg.with_attachments(incoming_attachments);
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
@@ -2355,7 +2130,6 @@ impl WasmChannel {
|
||||
channel = %channel_name,
|
||||
user_id = %emitted.user_id,
|
||||
content_len = emitted.content.len(),
|
||||
attachment_count = msg.attachments.len(),
|
||||
"Sending polled message to agent"
|
||||
);
|
||||
|
||||
@@ -2483,7 +2257,6 @@ impl Channel for WasmChannel {
|
||||
&response.content,
|
||||
response.thread_id.as_deref(),
|
||||
&metadata_json,
|
||||
&response.attachments,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ChannelError::SendFailed {
|
||||
@@ -2496,15 +2269,24 @@ impl Channel for WasmChannel {
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
user_id: &str,
|
||||
_user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.cancel_typing_task().await;
|
||||
self.call_on_broadcast(
|
||||
user_id,
|
||||
let metadata_json = self
|
||||
.last_broadcast_metadata
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::SendFailed {
|
||||
name: self.name.clone(),
|
||||
reason: "No messages received yet — no chat_id available for broadcast".into(),
|
||||
})?;
|
||||
|
||||
self.call_on_respond(
|
||||
uuid::Uuid::new_v4(),
|
||||
&response.content,
|
||||
response.thread_id.as_deref(),
|
||||
&response.attachments,
|
||||
&metadata_json,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ChannelError::SendFailed {
|
||||
@@ -2967,79 +2749,6 @@ async fn resolve_channel_host_credentials(
|
||||
resolved
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Attachment Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Maximum total attachment size (50 MB).
|
||||
const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Detect MIME type from file extension using the `mime_guess` crate.
|
||||
fn mime_from_extension(path: &str) -> String {
|
||||
mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Read attachment files from disk and build WIT attachment records.
|
||||
///
|
||||
/// Validates total size against `MAX_TOTAL_ATTACHMENT_BYTES`.
|
||||
fn read_attachments(paths: &[String]) -> Result<Vec<wit_channel::Attachment>, String> {
|
||||
if paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut attachments = Vec::with_capacity(paths.len());
|
||||
let mut total_bytes: u64 = 0;
|
||||
let tmp_base = std::path::Path::new("/tmp");
|
||||
let home_base = dirs::home_dir()
|
||||
.map(|h| h.join(".ironclaw"))
|
||||
.unwrap_or_default();
|
||||
|
||||
for path in paths {
|
||||
// Validate paths are under /tmp/ or ~/.ironclaw/ to prevent arbitrary file reads
|
||||
let validated = crate::tools::builtin::path_utils::validate_path(path, Some(tmp_base))
|
||||
.or_else(|_| crate::tools::builtin::path_utils::validate_path(path, Some(&home_base)));
|
||||
let validated = validated.map_err(|e| {
|
||||
format!(
|
||||
"Invalid attachment path '{}': must be under /tmp/ or ~/.ironclaw/: {}",
|
||||
path, e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Pre-check file size before reading into memory to avoid OOM
|
||||
let file_size = std::fs::metadata(&validated)
|
||||
.map_err(|e| format!("Failed to stat attachment '{}': {}", validated.display(), e))?
|
||||
.len();
|
||||
total_bytes += file_size;
|
||||
if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
|
||||
return Err(format!(
|
||||
"Total attachment size exceeds {} MB limit",
|
||||
MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
|
||||
let data = std::fs::read(&validated)
|
||||
.map_err(|e| format!("Failed to read attachment '{}': {}", validated.display(), e))?;
|
||||
|
||||
let filename = validated
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
|
||||
let mime_type = mime_from_extension(path);
|
||||
|
||||
attachments.push(wit_channel::Attachment {
|
||||
filename,
|
||||
mime_type,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(attachments)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -4162,139 +3871,4 @@ mod tests {
|
||||
// 404 because "000" is not a valid bot token
|
||||
assert_eq!(result, 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_emitted_messages_preserves_attachments() {
|
||||
use crate::channels::wasm::host::{Attachment, EmittedMessage};
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||
),
|
||||
));
|
||||
|
||||
let attachments = vec![
|
||||
Attachment {
|
||||
id: "photo123".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
filename: Some("cat.jpg".to_string()),
|
||||
size_bytes: Some(50_000),
|
||||
source_url: Some("https://api.telegram.org/file/photo123".to_string()),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
data: Vec::new(),
|
||||
duration_secs: None,
|
||||
},
|
||||
Attachment {
|
||||
id: "doc456".to_string(),
|
||||
mime_type: "application/pdf".to_string(),
|
||||
filename: Some("report.pdf".to_string()),
|
||||
size_bytes: Some(120_000),
|
||||
source_url: None,
|
||||
storage_key: Some("store/doc456".to_string()),
|
||||
extracted_text: Some("Report contents...".to_string()),
|
||||
data: Vec::new(),
|
||||
duration_secs: None,
|
||||
},
|
||||
];
|
||||
|
||||
let messages =
|
||||
vec![EmittedMessage::new("user1", "Check these files").with_attachments(attachments)];
|
||||
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let msg = rx.try_recv().expect("Should receive message");
|
||||
assert_eq!(msg.content, "Check these files");
|
||||
assert_eq!(msg.attachments.len(), 2);
|
||||
|
||||
// Verify first attachment
|
||||
assert_eq!(msg.attachments[0].id, "photo123");
|
||||
assert_eq!(msg.attachments[0].mime_type, "image/jpeg");
|
||||
assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string()));
|
||||
assert_eq!(msg.attachments[0].size_bytes, Some(50_000));
|
||||
assert_eq!(
|
||||
msg.attachments[0].source_url,
|
||||
Some("https://api.telegram.org/file/photo123".to_string())
|
||||
);
|
||||
|
||||
// Verify second attachment
|
||||
assert_eq!(msg.attachments[1].id, "doc456");
|
||||
assert_eq!(msg.attachments[1].mime_type, "application/pdf");
|
||||
assert_eq!(
|
||||
msg.attachments[1].extracted_text,
|
||||
Some("Report contents...".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
msg.attachments[1].storage_key,
|
||||
Some("store/doc456".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_emitted_messages_no_attachments_backward_compat() {
|
||||
use crate::channels::wasm::host::EmittedMessage;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||
),
|
||||
));
|
||||
|
||||
let messages = vec![EmittedMessage::new("user1", "Just text, no attachments")];
|
||||
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let msg = rx.try_recv().expect("Should receive message");
|
||||
assert_eq!(msg.content, "Just text, no attachments");
|
||||
assert!(msg.attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mime_from_extension() {
|
||||
use super::mime_from_extension;
|
||||
assert_eq!(mime_from_extension("screenshot.png"), "image/png");
|
||||
assert_eq!(mime_from_extension("photo.JPG"), "image/jpeg");
|
||||
assert_eq!(mime_from_extension("photo.jpeg"), "image/jpeg");
|
||||
assert_eq!(mime_from_extension("animation.gif"), "image/gif");
|
||||
assert_eq!(mime_from_extension("doc.pdf"), "application/pdf");
|
||||
assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
|
||||
assert_eq!(mime_from_extension("data.csv"), "text/csv");
|
||||
assert_eq!(
|
||||
mime_from_extension("unknown.qqqzzz"),
|
||||
"application/octet-stream"
|
||||
);
|
||||
assert_eq!(mime_from_extension("noext"), "application/octet-stream");
|
||||
assert_eq!(
|
||||
mime_from_extension("/home/user/.ironclaw/screenshot.png"),
|
||||
"image/png"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Web Gateway Module
|
||||
|
||||
Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, single-user with bearer token auth.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `mod.rs` | Gateway builder, startup, `WebChannel` implementation, `with_*` builder methods |
|
||||
| `server.rs` | `GatewayState`, `start_server()`, all Axum route registrations, inline handlers |
|
||||
| `types.rs` | Request/response DTOs and `SseEvent` enum (source of truth for SSE contract) |
|
||||
| `sse.rs` | `SseManager` — broadcast channel that fans out `SseEvent` to all connected SSE clients |
|
||||
| `ws.rs` | WebSocket handler (`handle_ws_connection`) + `WsConnectionTracker` |
|
||||
| `auth.rs` | Bearer token middleware (`Authorization: Bearer <GATEWAY_AUTH_TOKEN>`) |
|
||||
| `log_layer.rs` | Tracing layer that tees log lines to the `/api/logs/events` SSE stream |
|
||||
| `handlers/` | Handler functions split by domain: `chat`, `extensions`, `jobs`, `memory`, `routines`, `settings`, `skills`, `static_files` |
|
||||
| `openai_compat.rs` | OpenAI-compatible proxy (`/v1/chat/completions`, `/v1/models`) |
|
||||
| `util.rs` | Shared helpers (`build_turns_from_db_messages`, `truncate_preview`) |
|
||||
| `static/` | Single-page app (HTML/CSS/JS) — embedded at compile time via `include_str!`/`include_bytes!` |
|
||||
|
||||
## API Routes
|
||||
|
||||
### Public (no auth)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check |
|
||||
| GET | `/oauth/callback` | OAuth callback for extension auth |
|
||||
|
||||
### Chat
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/chat/send` | Send message → queues to agent loop |
|
||||
| GET | `/api/chat/events` | SSE stream of agent events |
|
||||
| GET | `/api/chat/ws` | WebSocket alternative to SSE |
|
||||
| GET | `/api/chat/history` | Paginated turn history for a thread |
|
||||
| GET | `/api/chat/threads` | List threads (returns `assistant_thread` + regular threads) |
|
||||
| POST | `/api/chat/thread/new` | Create new thread |
|
||||
| POST | `/api/chat/approval` | Approve/deny/always a pending tool call |
|
||||
| POST | `/api/chat/auth-token` | Submit auth token for an extension |
|
||||
| POST | `/api/chat/auth-cancel` | Cancel pending auth flow |
|
||||
|
||||
### Memory
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/memory/tree` | Workspace directory tree |
|
||||
| GET | `/api/memory/list` | List files at a path |
|
||||
| GET | `/api/memory/read` | Read a workspace file |
|
||||
| POST | `/api/memory/write` | Write a workspace file |
|
||||
| POST | `/api/memory/search` | Hybrid FTS + vector search |
|
||||
|
||||
### Jobs (sandbox)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/jobs` | List sandbox jobs |
|
||||
| GET | `/api/jobs/summary` | Aggregated stats |
|
||||
| GET | `/api/jobs/{id}` | Job detail |
|
||||
| POST | `/api/jobs/{id}/cancel` | Cancel a running job |
|
||||
| POST | `/api/jobs/{id}/restart` | Restart a failed job |
|
||||
| POST | `/api/jobs/{id}/prompt` | Send follow-up prompt to Claude Code bridge |
|
||||
| GET | `/api/jobs/{id}/events` | SSE stream for a specific job |
|
||||
| GET | `/api/jobs/{id}/files/list` | List files in job workspace |
|
||||
| GET | `/api/jobs/{id}/files/read` | Read a file from job workspace |
|
||||
|
||||
### Skills
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/skills` | List installed skills |
|
||||
| POST | `/api/skills/search` | Search ClawHub registry + local skills |
|
||||
| POST | `/api/skills/install` | Install a skill from ClawHub or by URL/content |
|
||||
| DELETE | `/api/skills/{name}` | Remove an installed skill |
|
||||
|
||||
### Extensions
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/extensions` | Installed extensions |
|
||||
| GET | `/api/extensions/tools` | All registered tools (from tool registry) |
|
||||
| POST | `/api/extensions/install` | Install extension |
|
||||
| GET | `/api/extensions/registry` | Available extensions from registry manifests |
|
||||
| POST | `/api/extensions/{name}/activate` | Activate installed extension |
|
||||
| POST | `/api/extensions/{name}/remove` | Remove extension |
|
||||
| GET/POST | `/api/extensions/{name}/setup` | Extension setup wizard |
|
||||
|
||||
### Routines
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/routines` | List routines |
|
||||
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/failing/runs_today) |
|
||||
| GET | `/api/routines/{id}` | Routine detail with recent run history |
|
||||
| POST | `/api/routines/{id}/trigger` | Manually trigger a routine |
|
||||
| POST | `/api/routines/{id}/toggle` | Enable/disable a routine |
|
||||
| DELETE | `/api/routines/{id}` | Delete a routine |
|
||||
| GET | `/api/routines/{id}/runs` | List runs for a specific routine |
|
||||
|
||||
### Settings
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/settings` | List all settings |
|
||||
| GET | `/api/settings/export` | Export all settings as a map |
|
||||
| POST | `/api/settings/import` | Bulk-import settings from a map |
|
||||
| GET | `/api/settings/{key}` | Get a single setting |
|
||||
| PUT | `/api/settings/{key}` | Set a single setting |
|
||||
| DELETE | `/api/settings/{key}` | Delete a setting |
|
||||
|
||||
### Other
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/logs/events` | Live log stream (SSE) |
|
||||
| GET/PUT | `/api/logs/level` | Get/set log level at runtime |
|
||||
| GET | `/api/pairing/{channel}` | List pending pairing requests |
|
||||
| POST | `/api/pairing/{channel}/approve` | Approve a pairing request |
|
||||
| GET | `/api/gateway/status` | Server uptime, connected clients, config |
|
||||
| POST | `/v1/chat/completions` | OpenAI-compatible LLM proxy |
|
||||
| GET | `/v1/models` | OpenAI-compatible model list |
|
||||
|
||||
### Static / Project files
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | Single-page app HTML |
|
||||
| GET | `/style.css` | App stylesheet |
|
||||
| GET | `/app.js` | App JavaScript |
|
||||
| GET | `/favicon.ico` | Favicon (cached 1 day) |
|
||||
| GET | `/projects/{project_id}/` | Job workspace browser (redirects) |
|
||||
| GET | `/projects/{project_id}/{*path}` | Serve file from job workspace (auth required) |
|
||||
|
||||
## SSE Event Types (`SseEvent` in `types.rs`)
|
||||
|
||||
The SSE contract — every field is `#[serde(tag = "type")]`:
|
||||
|
||||
| Type | When emitted |
|
||||
|------|-------------|
|
||||
| `response` | Final text response from agent |
|
||||
| `stream_chunk` | Streaming token (partial response) |
|
||||
| `thinking` | Agent status update during reasoning |
|
||||
| `tool_started` | Tool call began |
|
||||
| `tool_completed` | Tool call finished (includes success/error) |
|
||||
| `tool_result` | Tool output preview |
|
||||
| `status` | Generic status message |
|
||||
| `job_started` | Sandbox job created |
|
||||
| `job_message` | Message from sandbox worker |
|
||||
| `job_tool_use` | Tool invoked inside sandbox |
|
||||
| `job_tool_result` | Tool result from sandbox |
|
||||
| `job_status` | Sandbox job status update |
|
||||
| `job_result` | Sandbox job final result |
|
||||
| `approval_needed` | Tool requires user approval (pauses agent) |
|
||||
| `auth_required` | Extension needs auth credentials |
|
||||
| `auth_completed` | Extension auth flow finished |
|
||||
| `extension_status` | WASM channel activation status changed |
|
||||
| `error` | Error from agent or gateway |
|
||||
| `heartbeat` | SSE keepalive (empty payload) |
|
||||
|
||||
**SSE serialization:** Events use `#[serde(tag = "type")]` — the wire format is `{"type":"<variant>", ...fields}`. The SSE frame's `event:` field is set to the same string as `type` for easy `addEventListener` use in the browser.
|
||||
|
||||
**WebSocket envelope:** Over WebSocket, SSE events are wrapped as `{"type":"event","event_type":"<variant>","data":{...}}`. Ping/pong uses `{"type":"ping"}` / `{"type":"pong"}`. Client-to-server messages (`message`, `approval`, `auth_token`, `auth_cancel`) are defined in `WsClientMessage` in `types.rs`.
|
||||
|
||||
**To add a new SSE event:** Use the `add-sse-event` skill (`/add-sse-event`). It scaffolds the Rust variant, serialization, broadcast call, and frontend handler. Also add a matching arm to `WsServerMessage::from_sse_event()` in `types.rs`.
|
||||
|
||||
## Auth
|
||||
|
||||
All protected routes require `Authorization: Bearer <GATEWAY_AUTH_TOKEN>`. The token is set via `GATEWAY_AUTH_TOKEN` env var. Missing/wrong token → 401. The `Bearer` prefix is compared case-insensitively (RFC 6750).
|
||||
|
||||
**Query-string token auth (`?token=xxx`):** Because `EventSource` and WebSocket upgrades cannot set custom headers from the browser, three endpoints also accept the token as a URL query parameter: `/api/chat/events`, `/api/logs/events`, and `/api/chat/ws`. All other endpoints reject query-string tokens. If you add a new SSE or WebSocket endpoint, register its path in `allows_query_token_auth()` in `auth.rs`.
|
||||
|
||||
**If no `GATEWAY_AUTH_TOKEN` is configured**, a random 32-character alphanumeric token is generated at startup and printed to the console.
|
||||
|
||||
Rate limiting: chat send endpoints are capped at **30 messages per 60 seconds** (sliding window, not per-IP).
|
||||
|
||||
## GatewayState
|
||||
|
||||
The shared state struct (`server.rs`) holds refs to all subsystems. Fields are `Option<Arc<T>>` so the gateway can start even when optional subsystems (workspace, sandbox, skills) are disabled. Always null-check before use in handlers.
|
||||
|
||||
Key fields:
|
||||
- `msg_tx` — `RwLock<Option<mpsc::Sender<IncomingMessage>>>` — sends messages to the agent loop; set when `start()` is called on the `Channel`.
|
||||
- `sse` — `SseManager` — broadcast hub; call `state.sse.broadcast(event)` from any handler.
|
||||
- `ws_tracker` — `Option<Arc<WsConnectionTracker>>` — tracks WS connection count separately from SSE.
|
||||
- `chat_rate_limiter` — `RateLimiter` — 30 req/60 s sliding window shared across all chat send callers.
|
||||
- `scheduler` — `Option<SchedulerSlot>` — used to inject follow-up messages into running agent jobs.
|
||||
- `cost_guard` — `Option<Arc<CostGuard>>` — exposes token usage / cost totals in the status endpoint.
|
||||
- `startup_time` — `Instant` — used to compute uptime in the gateway status response.
|
||||
- `registry_entries` — `Vec<RegistryEntry>` — loaded once at startup from registry manifests; used by the available extensions API without hitting the network.
|
||||
|
||||
Subsystems are wired via `with_*` builder methods on `GatewayChannel` (`mod.rs`). Each call rebuilds `Arc<GatewayState>` — safe to call before `start()`, not after.
|
||||
|
||||
## SSE / WebSocket Connection Limits
|
||||
|
||||
Both SSE and WebSocket share the same `SseManager` broadcast channel. Key characteristics:
|
||||
|
||||
- **Broadcast buffer:** 256 events. A slow client that falls behind will miss events — the `BroadcastStream` silently drops lagged events. SSE clients are expected to reconnect and re-fetch history.
|
||||
- **Max connections:** 100 total (SSE + WebSocket combined). Connections beyond the limit receive a 503 / are immediately dropped.
|
||||
- **SSE keepalive:** Axum's `KeepAlive` sends an empty event every **30 seconds** to prevent proxy timeouts.
|
||||
- **WebSocket:** Two tasks per connection — a sender task (broadcast → WS frames) and a receiver loop (WS frames → agent). When the client disconnects, the sender is aborted and both the SSE connection counter and WS tracker counter are decremented.
|
||||
|
||||
## CORS and Security Headers
|
||||
|
||||
CORS is restricted to the gateway's own origin (same IP+port and `localhost`+port). Allowed methods: GET, POST, PUT, DELETE. Allowed headers: `Content-Type`, `Authorization`. Credentials are allowed.
|
||||
|
||||
All responses include:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
|
||||
**Request body limit:** 1 MB (`DefaultBodyLimit::max(1024 * 1024)`). Larger payloads return 413.
|
||||
|
||||
## Pending Approvals
|
||||
|
||||
Tool approval state is **in-memory only** (not persisted to DB). Server restart clears all pending approvals. The `pending_approval` field in `HistoryResponse` is re-populated on thread switch from in-memory state.
|
||||
|
||||
## Adding a New API Endpoint
|
||||
|
||||
1. Define request/response types in `types.rs`.
|
||||
2. Implement the handler in the appropriate `handlers/*.rs` file (or inline in `server.rs` for simple handlers).
|
||||
3. Register the route in `start_server()` in `server.rs` under the correct router (`public`, `protected`, or `statics`).
|
||||
4. If it is an SSE or WebSocket endpoint, add its path to `allows_query_token_auth()` in `auth.rs`.
|
||||
5. If it requires a new `GatewayState` field, add it to the struct and to both the `GatewayChannel::new()` initializer and `rebuild_state()` in `mod.rs`, then add a `with_*` builder method.
|
||||
@@ -62,7 +62,6 @@ pub async fn extensions_list_handler(
|
||||
has_auth: ext.has_auth,
|
||||
activation_status,
|
||||
activation_error: ext.activation_error,
|
||||
version: ext.version,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -24,13 +24,6 @@ pub mod types;
|
||||
pub(crate) mod util;
|
||||
pub mod ws;
|
||||
|
||||
/// Test helpers for gateway integration tests.
|
||||
///
|
||||
/// Always compiled (not behind `#[cfg(test)]`) so that integration tests in
|
||||
/// `tests/` -- which import this crate as a regular dependency -- can use
|
||||
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
|
||||
pub mod test_helpers;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -244,7 +244,6 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result<Vec<ChatMessage>,
|
||||
_ => Ok(ChatMessage {
|
||||
role,
|
||||
content: m.content.as_deref().unwrap_or("").to_string(),
|
||||
content_parts: Vec::new(),
|
||||
tool_call_id: None,
|
||||
name: m.name.clone(),
|
||||
tool_calls: None,
|
||||
|
||||
@@ -1438,7 +1438,6 @@ async fn extensions_list_handler(
|
||||
has_auth: ext.has_auth,
|
||||
activation_status,
|
||||
activation_error: ext.activation_error,
|
||||
version: ext.version,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -1732,7 +1731,6 @@ async fn extensions_registry_handler(
|
||||
kind: kind_str,
|
||||
description: e.description.clone(),
|
||||
keywords: e.keywords.clone(),
|
||||
version: e.version.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -2321,7 +2319,6 @@ async fn gateway_status_handler(
|
||||
.unwrap_or(false);
|
||||
|
||||
Json(GatewayStatusResponse {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
sse_connections,
|
||||
ws_connections,
|
||||
total_connections: sse_connections + ws_connections,
|
||||
@@ -2343,7 +2340,6 @@ struct ModelUsageEntry {
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct GatewayStatusResponse {
|
||||
version: String,
|
||||
sse_connections: u64,
|
||||
ws_connections: u64,
|
||||
total_connections: u64,
|
||||
|
||||
@@ -1889,13 +1889,6 @@ function renderAvailableExtensionCard(entry) {
|
||||
kind.textContent = kindLabels[entry.kind] || entry.kind;
|
||||
header.appendChild(kind);
|
||||
|
||||
if (entry.version) {
|
||||
const ver = document.createElement('span');
|
||||
ver.className = 'ext-version';
|
||||
ver.textContent = 'v' + entry.version;
|
||||
header.appendChild(ver);
|
||||
}
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
const desc = document.createElement('div');
|
||||
@@ -2056,13 +2049,6 @@ function renderExtensionCard(ext) {
|
||||
kind.textContent = kindLabels[ext.kind] || ext.kind;
|
||||
header.appendChild(kind);
|
||||
|
||||
if (ext.version) {
|
||||
const ver = document.createElement('span');
|
||||
ver.className = 'ext-version';
|
||||
ver.textContent = 'v' + ext.version;
|
||||
header.appendChild(ver);
|
||||
}
|
||||
|
||||
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
|
||||
if (ext.kind !== 'wasm_channel') {
|
||||
const authDot = document.createElement('span');
|
||||
@@ -3308,12 +3294,6 @@ function fetchGatewayStatus() {
|
||||
var popover = document.getElementById('gateway-popover');
|
||||
var html = '';
|
||||
|
||||
// Version
|
||||
if (data.version) {
|
||||
html += '<div class="gw-section-label">IronClaw v' + escapeHtml(data.version) + '</div>';
|
||||
html += '<div class="gw-divider"></div>';
|
||||
}
|
||||
|
||||
// Connection info
|
||||
html += '<div class="gw-section-label">Connections</div>';
|
||||
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
||||
|
||||
@@ -2438,12 +2438,6 @@ body {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.ext-version {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.ext-auth-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
//! Shared test utilities for gateway integration tests.
|
||||
//!
|
||||
//! This module is always compiled (not `#[cfg(test)]`) because integration tests
|
||||
//! in `tests/` import the crate as a regular dependency and `cfg(test)` is only
|
||||
//! set when compiling *this* crate's unit tests.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::server::{GatewayState, RateLimiter, start_server};
|
||||
use crate::channels::web::sse::SseManager;
|
||||
use crate::channels::web::ws::WsConnectionTracker;
|
||||
|
||||
/// Builder for constructing a [`GatewayState`] with sensible test defaults.
|
||||
///
|
||||
/// Every optional field defaults to `None` and can be overridden via builder
|
||||
/// methods. Call [`build`](Self::build) to get the `Arc<GatewayState>`, or
|
||||
/// [`start`](Self::start) to also bind an Axum server on a random port.
|
||||
pub struct TestGatewayBuilder {
|
||||
msg_tx: Option<mpsc::Sender<IncomingMessage>>,
|
||||
llm_provider: Option<Arc<dyn crate::llm::LlmProvider>>,
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
impl Default for TestGatewayBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
msg_tx: None,
|
||||
llm_provider: None,
|
||||
user_id: "test-user".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TestGatewayBuilder {
|
||||
/// Create a new builder with all defaults.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the agent message sender (the channel the gateway forwards
|
||||
/// incoming chat messages to).
|
||||
pub fn msg_tx(mut self, tx: mpsc::Sender<IncomingMessage>) -> Self {
|
||||
self.msg_tx = Some(tx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the LLM provider (needed for OpenAI-compatible API tests).
|
||||
pub fn llm_provider(mut self, provider: Arc<dyn crate::llm::LlmProvider>) -> Self {
|
||||
self.llm_provider = Some(provider);
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the user ID (default: `"test-user"`).
|
||||
pub fn user_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.user_id = id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the `Arc<GatewayState>` without starting a server.
|
||||
pub fn build(self) -> Arc<GatewayState> {
|
||||
Arc::new(GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(self.msg_tx),
|
||||
sse: SseManager::new(),
|
||||
workspace: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
log_level_handle: None,
|
||||
extension_manager: None,
|
||||
tool_registry: None,
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
user_id: self.user_id,
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
llm_provider: self.llm_provider,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
scheduler: None,
|
||||
chat_rate_limiter: RateLimiter::new(30, 60),
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
startup_time: std::time::Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the state and start a gateway server on `127.0.0.1:0` (random
|
||||
/// port). Returns the bound address and the shared state.
|
||||
pub async fn start(
|
||||
self,
|
||||
auth_token: &str,
|
||||
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
|
||||
let state = self.build();
|
||||
let addr: SocketAddr = "127.0.0.1:0"
|
||||
.parse()
|
||||
.expect("hard-coded address must parse");
|
||||
let bound = start_server(addr, state.clone(), auth_token.to_string()).await?;
|
||||
Ok((bound, state))
|
||||
}
|
||||
}
|
||||
@@ -401,9 +401,6 @@ pub struct ExtensionInfo {
|
||||
/// Human-readable error when activation_status is "failed".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activation_error: Option<String>,
|
||||
/// Extension version (semver).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -506,8 +503,6 @@ pub struct RegistryEntryInfo {
|
||||
pub description: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub installed: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
+2
-6
@@ -86,7 +86,7 @@ pub enum Command {
|
||||
/// Interactive onboarding wizard
|
||||
#[command(
|
||||
about = "Run interactive setup wizard",
|
||||
long_about = "Guides through initial configuration.\nExamples:\n ironclaw onboard --skip-auth # Skip auth step\n ironclaw onboard --channels-only # Reconfigure channels\n ironclaw onboard --provider-only # Change LLM provider and model"
|
||||
long_about = "Guides through initial configuration.\nExamples:\n ironclaw onboard --skip-auth # Skip auth step\n ironclaw onboard --channels-only # Reconfigure channels"
|
||||
)]
|
||||
Onboard {
|
||||
/// Skip authentication (use existing session)
|
||||
@@ -94,12 +94,8 @@ pub enum Command {
|
||||
skip_auth: bool,
|
||||
|
||||
/// Reconfigure channels only
|
||||
#[arg(long, conflicts_with = "provider_only")]
|
||||
#[arg(long)]
|
||||
channels_only: bool,
|
||||
|
||||
/// Reconfigure LLM provider and model only
|
||||
#[arg(long, conflicts_with = "channels_only")]
|
||||
provider_only: bool,
|
||||
},
|
||||
|
||||
/// Manage configuration settings
|
||||
|
||||
@@ -204,162 +204,3 @@ impl ChannelsConfig {
|
||||
fn default_channels_dir() -> PathBuf {
|
||||
ironclaw_base_dir().join("channels")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::channels::*;
|
||||
|
||||
#[test]
|
||||
fn cli_config_fields() {
|
||||
let cfg = CliConfig { enabled: true };
|
||||
assert!(cfg.enabled);
|
||||
|
||||
let disabled = CliConfig { enabled: false };
|
||||
assert!(!disabled.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_config_fields() {
|
||||
let cfg = HttpConfig {
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 8080,
|
||||
webhook_secret: None,
|
||||
user_id: "http".to_string(),
|
||||
};
|
||||
assert_eq!(cfg.host, "0.0.0.0");
|
||||
assert_eq!(cfg.port, 8080);
|
||||
assert!(cfg.webhook_secret.is_none());
|
||||
assert_eq!(cfg.user_id, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_config_with_secret() {
|
||||
let cfg = HttpConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 9090,
|
||||
webhook_secret: Some(secrecy::SecretString::from("s3cret".to_string())),
|
||||
user_id: "webhook-bot".to_string(),
|
||||
};
|
||||
assert!(cfg.webhook_secret.is_some());
|
||||
assert_eq!(cfg.port, 9090);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_fields() {
|
||||
let cfg = GatewayConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 3000,
|
||||
auth_token: Some("tok-abc".to_string()),
|
||||
user_id: "default".to_string(),
|
||||
};
|
||||
assert_eq!(cfg.host, "127.0.0.1");
|
||||
assert_eq!(cfg.port, 3000);
|
||||
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
|
||||
assert_eq!(cfg.user_id, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_no_auth_token() {
|
||||
let cfg = GatewayConfig {
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 3001,
|
||||
auth_token: None,
|
||||
user_id: "anon".to_string(),
|
||||
};
|
||||
assert!(cfg.auth_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_config_fields_and_defaults() {
|
||||
let cfg = SignalConfig {
|
||||
http_url: "http://127.0.0.1:8080".to_string(),
|
||||
account: "+1234567890".to_string(),
|
||||
allow_from: vec!["+1234567890".to_string()],
|
||||
allow_from_groups: vec![],
|
||||
dm_policy: "pairing".to_string(),
|
||||
group_policy: "allowlist".to_string(),
|
||||
group_allow_from: vec![],
|
||||
ignore_attachments: false,
|
||||
ignore_stories: true,
|
||||
};
|
||||
assert_eq!(cfg.http_url, "http://127.0.0.1:8080");
|
||||
assert_eq!(cfg.account, "+1234567890");
|
||||
assert_eq!(cfg.allow_from, vec!["+1234567890"]);
|
||||
assert!(cfg.allow_from_groups.is_empty());
|
||||
assert_eq!(cfg.dm_policy, "pairing");
|
||||
assert_eq!(cfg.group_policy, "allowlist");
|
||||
assert!(cfg.group_allow_from.is_empty());
|
||||
assert!(!cfg.ignore_attachments);
|
||||
assert!(cfg.ignore_stories);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_config_open_policies() {
|
||||
let cfg = SignalConfig {
|
||||
http_url: "http://localhost:7583".to_string(),
|
||||
account: "+0000000000".to_string(),
|
||||
allow_from: vec!["*".to_string()],
|
||||
allow_from_groups: vec!["*".to_string()],
|
||||
dm_policy: "open".to_string(),
|
||||
group_policy: "open".to_string(),
|
||||
group_allow_from: vec![],
|
||||
ignore_attachments: true,
|
||||
ignore_stories: false,
|
||||
};
|
||||
assert_eq!(cfg.allow_from, vec!["*"]);
|
||||
assert_eq!(cfg.allow_from_groups, vec!["*"]);
|
||||
assert_eq!(cfg.dm_policy, "open");
|
||||
assert_eq!(cfg.group_policy, "open");
|
||||
assert!(cfg.ignore_attachments);
|
||||
assert!(!cfg.ignore_stories);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_config_fields() {
|
||||
let cfg = ChannelsConfig {
|
||||
cli: CliConfig { enabled: true },
|
||||
http: None,
|
||||
gateway: None,
|
||||
signal: None,
|
||||
wasm_channels_dir: PathBuf::from("/tmp/channels"),
|
||||
wasm_channels_enabled: true,
|
||||
wasm_channel_owner_ids: HashMap::new(),
|
||||
};
|
||||
assert!(cfg.cli.enabled);
|
||||
assert!(cfg.http.is_none());
|
||||
assert!(cfg.gateway.is_none());
|
||||
assert!(cfg.signal.is_none());
|
||||
assert_eq!(cfg.wasm_channels_dir, PathBuf::from("/tmp/channels"));
|
||||
assert!(cfg.wasm_channels_enabled);
|
||||
assert!(cfg.wasm_channel_owner_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_config_with_owner_ids() {
|
||||
let mut ids = HashMap::new();
|
||||
ids.insert("telegram".to_string(), 12345_i64);
|
||||
ids.insert("slack".to_string(), 67890_i64);
|
||||
|
||||
let cfg = ChannelsConfig {
|
||||
cli: CliConfig { enabled: false },
|
||||
http: None,
|
||||
gateway: None,
|
||||
signal: None,
|
||||
wasm_channels_dir: PathBuf::from("/opt/channels"),
|
||||
wasm_channels_enabled: false,
|
||||
wasm_channel_owner_ids: ids,
|
||||
};
|
||||
assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345));
|
||||
assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890));
|
||||
assert!(!cfg.wasm_channels_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_channels_dir_ends_with_channels() {
|
||||
let dir = default_channels_dir();
|
||||
assert!(
|
||||
dir.ends_with("channels"),
|
||||
"expected path ending in 'channels', got: {dir:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-13
@@ -10,10 +10,8 @@ use crate::error::ConfigError;
|
||||
pub struct HygieneConfig {
|
||||
/// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true).
|
||||
pub enabled: bool,
|
||||
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_DAILY_RETENTION_DAYS` (default: 30).
|
||||
pub daily_retention_days: u32,
|
||||
/// Days before `conversations/` documents are deleted. Env: `MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS` (default: 7).
|
||||
pub conversation_retention_days: u32,
|
||||
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_RETENTION_DAYS` (default: 30).
|
||||
pub retention_days: u32,
|
||||
/// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12).
|
||||
pub cadence_hours: u32,
|
||||
}
|
||||
@@ -22,8 +20,7 @@ impl Default for HygieneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
daily_retention_days: 30,
|
||||
conversation_retention_days: 7,
|
||||
retention_days: 30,
|
||||
cadence_hours: 12,
|
||||
}
|
||||
}
|
||||
@@ -33,11 +30,7 @@ impl HygieneConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: parse_bool_env("MEMORY_HYGIENE_ENABLED", true)?,
|
||||
daily_retention_days: parse_optional_env("MEMORY_HYGIENE_DAILY_RETENTION_DAYS", 30)?,
|
||||
conversation_retention_days: parse_optional_env(
|
||||
"MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS",
|
||||
7,
|
||||
)?,
|
||||
retention_days: parse_optional_env("MEMORY_HYGIENE_RETENTION_DAYS", 30)?,
|
||||
cadence_hours: parse_optional_env("MEMORY_HYGIENE_CADENCE_HOURS", 12)?,
|
||||
})
|
||||
}
|
||||
@@ -47,8 +40,7 @@ impl HygieneConfig {
|
||||
pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig {
|
||||
crate::workspace::hygiene::HygieneConfig {
|
||||
enabled: self.enabled,
|
||||
daily_retention_days: self.daily_retention_days,
|
||||
conversation_retention_days: self.conversation_retention_days,
|
||||
retention_days: self.retention_days,
|
||||
cadence_hours: self.cadence_hours,
|
||||
state_dir: ironclaw_base_dir(),
|
||||
}
|
||||
|
||||
+273
-514
@@ -5,93 +5,141 @@ use secrecy::SecretString;
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||
use crate::llm::session::SessionConfig;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Prompt cache retention policy for Anthropic.
|
||||
/// Which LLM backend to use.
|
||||
///
|
||||
/// Controls Anthropic's automatic prompt caching via a top-level
|
||||
/// `cache_control` field injected through rig-core's `additional_params`.
|
||||
/// - `None` — caching disabled, no `cache_control` injected.
|
||||
/// - `Short` — 5-minute TTL (default), `{"type": "ephemeral"}`, 1.25× write surcharge.
|
||||
/// - `Long` — 1-hour TTL, `{"type": "ephemeral", "ttl": "1h"}`, 2× write surcharge.
|
||||
/// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem.
|
||||
/// Users can override with `LLM_BACKEND` env var to use their own API keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CacheRetention {
|
||||
/// No prompt caching.
|
||||
None,
|
||||
/// 5-minute TTL (default). Write cost: 1.25× base input.
|
||||
pub enum LlmBackend {
|
||||
/// NEAR AI proxy (default) -- session or API key auth
|
||||
#[default]
|
||||
Short,
|
||||
/// 1-hour TTL. Write cost: 2× base input.
|
||||
Long,
|
||||
NearAi,
|
||||
/// Direct OpenAI API
|
||||
OpenAi,
|
||||
/// Direct Anthropic API
|
||||
Anthropic,
|
||||
/// Local Ollama instance
|
||||
Ollama,
|
||||
/// Any OpenAI-compatible endpoint (e.g. vLLM, LiteLLM, Together)
|
||||
OpenAiCompatible,
|
||||
/// Tinfoil private inference
|
||||
Tinfoil,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for CacheRetention {
|
||||
impl std::str::FromStr for LlmBackend {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"none" | "off" | "disabled" => Ok(Self::None),
|
||||
"short" | "5m" | "ephemeral" => Ok(Self::Short),
|
||||
"long" | "1h" => Ok(Self::Long),
|
||||
"nearai" | "near_ai" | "near" => Ok(Self::NearAi),
|
||||
"openai" | "open_ai" => Ok(Self::OpenAi),
|
||||
"anthropic" | "claude" => Ok(Self::Anthropic),
|
||||
"ollama" => Ok(Self::Ollama),
|
||||
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
|
||||
"tinfoil" => Ok(Self::Tinfoil),
|
||||
_ => Err(format!(
|
||||
"invalid cache retention '{}', expected one of: none, short, long",
|
||||
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CacheRetention {
|
||||
impl std::fmt::Display for LlmBackend {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::None => write!(f, "none"),
|
||||
Self::Short => write!(f, "short"),
|
||||
Self::Long => write!(f, "long"),
|
||||
Self::NearAi => write!(f, "nearai"),
|
||||
Self::OpenAi => write!(f, "openai"),
|
||||
Self::Anthropic => write!(f, "anthropic"),
|
||||
Self::Ollama => write!(f, "ollama"),
|
||||
Self::OpenAiCompatible => write!(f, "openai_compatible"),
|
||||
Self::Tinfoil => write!(f, "tinfoil"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved configuration for a registry-based provider.
|
||||
///
|
||||
/// This single struct replaces what used to be five separate config types
|
||||
/// (`OpenAiDirectConfig`, `AnthropicDirectConfig`, `OllamaConfig`,
|
||||
/// `OpenAiCompatibleConfig`, `TinfoilConfig`). The `protocol` field
|
||||
/// determines which rig-core client constructor to use.
|
||||
impl LlmBackend {
|
||||
/// The environment variable that configures the model name for this backend.
|
||||
///
|
||||
/// Used by both `LlmConfig::resolve()` (reads the var) and the setup wizard
|
||||
/// (writes the var to `.env`). Centralised here so the two stay in sync.
|
||||
pub fn model_env_var(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NearAi => "NEARAI_MODEL",
|
||||
Self::OpenAi => "OPENAI_MODEL",
|
||||
Self::Anthropic => "ANTHROPIC_MODEL",
|
||||
Self::Ollama => "OLLAMA_MODEL",
|
||||
Self::OpenAiCompatible => "LLM_MODEL",
|
||||
Self::Tinfoil => "TINFOIL_MODEL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for direct OpenAI API access.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryProviderConfig {
|
||||
/// Which API protocol to use (determines the rig-core client).
|
||||
pub protocol: ProviderProtocol,
|
||||
/// Provider identifier (e.g., "groq", "openai", "tinfoil").
|
||||
pub provider_id: String,
|
||||
/// API key (optional for some providers like Ollama).
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Base URL for the API endpoint.
|
||||
pub base_url: String,
|
||||
/// Model identifier.
|
||||
pub struct OpenAiDirectConfig {
|
||||
pub api_key: SecretString,
|
||||
pub model: String,
|
||||
/// Extra HTTP headers injected into every request.
|
||||
/// Optional base URL override (e.g. for proxies like VibeProxy).
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for direct Anthropic API access.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnthropicDirectConfig {
|
||||
pub api_key: SecretString,
|
||||
pub model: String,
|
||||
/// Optional base URL override (e.g. for proxies like VibeProxy).
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for local Ollama.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OllamaConfig {
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Configuration for any OpenAI-compatible endpoint.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAiCompatibleConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: Option<SecretString>,
|
||||
pub model: String,
|
||||
/// Extra HTTP headers injected into every LLM request.
|
||||
/// Parsed from `LLM_EXTRA_HEADERS` env var (format: `Key:Value,Key2:Value2`).
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Configuration for Tinfoil private inference.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TinfoilConfig {
|
||||
pub api_key: SecretString,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
///
|
||||
/// NearAI remains the default backend with its own config struct (session auth).
|
||||
/// All other providers are resolved through the provider registry, producing
|
||||
/// a generic `RegistryProviderConfig`.
|
||||
/// NEAR AI remains the default backend. Users can switch to other providers
|
||||
/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmConfig {
|
||||
/// Backend identifier (e.g., "nearai", "openai", "groq", "tinfoil").
|
||||
pub backend: String,
|
||||
/// Session manager configuration (auth URL, token persistence path).
|
||||
/// Used by the NearAI provider for OAuth/session-token auth.
|
||||
pub session: SessionConfig,
|
||||
/// NEAR AI config (always populated, also used for embeddings).
|
||||
/// Which backend to use (default: NearAi)
|
||||
pub backend: LlmBackend,
|
||||
/// NEAR AI config (always populated for NEAR AI embeddings, etc.)
|
||||
pub nearai: NearAiConfig,
|
||||
/// Resolved provider config for registry-based providers.
|
||||
/// `None` when backend is "nearai".
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
/// Direct OpenAI config (populated when backend=openai)
|
||||
pub openai: Option<OpenAiDirectConfig>,
|
||||
/// Direct Anthropic config (populated when backend=anthropic)
|
||||
pub anthropic: Option<AnthropicDirectConfig>,
|
||||
/// Ollama config (populated when backend=ollama)
|
||||
pub ollama: Option<OllamaConfig>,
|
||||
/// OpenAI-compatible config (populated when backend=openai_compatible)
|
||||
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
||||
/// Tinfoil config (populated when backend=tinfoil)
|
||||
pub tinfoil: Option<TinfoilConfig>,
|
||||
}
|
||||
|
||||
/// NEAR AI configuration.
|
||||
@@ -100,47 +148,67 @@ pub struct NearAiConfig {
|
||||
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
|
||||
pub model: String,
|
||||
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
|
||||
/// Falls back to the main model if not set.
|
||||
pub cheap_model: Option<String>,
|
||||
/// Base URL for the NEAR AI API.
|
||||
/// Default: `https://private.near.ai` (session token) or `https://cloud-api.near.ai` (API key)
|
||||
pub base_url: String,
|
||||
/// API key for NEAR AI Cloud.
|
||||
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
|
||||
pub auth_base_url: String,
|
||||
/// Path to session file (default: ~/.ironclaw/session.json)
|
||||
pub session_path: PathBuf,
|
||||
/// API key for NEAR AI Cloud. When set, uses API key auth; otherwise uses session token auth.
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Optional fallback model for failover.
|
||||
/// Optional fallback model for failover (default: None).
|
||||
/// When set, a secondary provider is created with this model and wrapped
|
||||
/// in a `FailoverProvider` so transient errors on the primary model
|
||||
/// automatically fall through to the fallback.
|
||||
pub fallback_model: Option<String>,
|
||||
/// Maximum number of retries for transient errors (default: 3).
|
||||
/// With the default of 3, the provider makes up to 4 total attempts
|
||||
/// (1 initial + 3 retries) before giving up.
|
||||
pub max_retries: u32,
|
||||
/// Consecutive failures before circuit breaker opens. None = disabled.
|
||||
/// Consecutive transient failures before the circuit breaker opens.
|
||||
/// None = disabled (default). E.g. 5 means after 5 consecutive failures
|
||||
/// all requests are rejected until recovery timeout elapses.
|
||||
pub circuit_breaker_threshold: Option<u32>,
|
||||
/// Seconds the circuit stays open before probing (default: 30).
|
||||
/// How long (seconds) the circuit stays open before allowing a probe (default: 30).
|
||||
pub circuit_breaker_recovery_secs: u64,
|
||||
/// Enable in-memory response caching. Default: false.
|
||||
/// Enable in-memory response caching for `complete()` calls.
|
||||
/// Saves tokens on repeated prompts within a session. Default: false.
|
||||
pub response_cache_enabled: bool,
|
||||
/// TTL in seconds for cached responses (default: 3600).
|
||||
/// TTL in seconds for cached responses (default: 3600 = 1 hour).
|
||||
pub response_cache_ttl_secs: u64,
|
||||
/// Max cached responses before LRU eviction (default: 1000).
|
||||
pub response_cache_max_entries: usize,
|
||||
/// Cooldown duration in seconds for failover (default: 300).
|
||||
/// Cooldown duration in seconds for the failover provider (default: 300).
|
||||
/// When a provider accumulates enough consecutive failures it is skipped
|
||||
/// for this many seconds.
|
||||
pub failover_cooldown_secs: u64,
|
||||
/// Consecutive failures before failover cooldown (default: 3).
|
||||
/// Number of consecutive retryable failures before a provider enters
|
||||
/// cooldown (default: 3).
|
||||
pub failover_cooldown_threshold: u32,
|
||||
/// Enable cascade mode for smart routing. Default: true.
|
||||
/// Enable cascade mode for smart routing: when a moderate-complexity task
|
||||
/// gets an uncertain response from the cheap model, re-send to primary.
|
||||
/// Default: true.
|
||||
pub smart_routing_cascade: bool,
|
||||
}
|
||||
|
||||
impl LlmConfig {
|
||||
/// Create a test-friendly config without reading env vars.
|
||||
///
|
||||
/// Uses NearAi backend with dummy values. The LLM provider is replaced
|
||||
/// by `TraceLlm` via `AppBuilder::with_llm()`, so these values are unused.
|
||||
#[cfg(feature = "libsql")]
|
||||
pub fn for_testing() -> Self {
|
||||
Self {
|
||||
backend: "nearai".to_string(),
|
||||
session: SessionConfig {
|
||||
auth_base_url: "http://localhost:0".to_string(),
|
||||
session_path: std::env::temp_dir().join("ironclaw-test-session.json"),
|
||||
},
|
||||
backend: LlmBackend::NearAi,
|
||||
nearai: NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
cheap_model: None,
|
||||
base_url: "http://localhost:0".to_string(),
|
||||
auth_base_url: "http://localhost:0".to_string(),
|
||||
session_path: PathBuf::from("/tmp/ironclaw-test-session.json"),
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 0,
|
||||
@@ -153,11 +221,15 @@ impl LlmConfig {
|
||||
failover_cooldown_threshold: 3,
|
||||
smart_routing_cascade: false,
|
||||
},
|
||||
provider: None,
|
||||
openai: None,
|
||||
anthropic: None,
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
tinfoil: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a model name from env var -> settings.selected_model -> hardcoded default.
|
||||
/// Resolve a model name from env var → settings.selected_model → hardcoded default.
|
||||
fn resolve_model(
|
||||
env_var: &str,
|
||||
settings: &Settings,
|
||||
@@ -169,40 +241,31 @@ impl LlmConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let registry = ProviderRegistry::load();
|
||||
|
||||
// Determine backend: env var > settings > default ("nearai")
|
||||
let backend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||
b
|
||||
// Determine backend: env var > settings > default (NearAi)
|
||||
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||
b.parse().map_err(|e| ConfigError::InvalidValue {
|
||||
key: "LLM_BACKEND".to_string(),
|
||||
message: e,
|
||||
})?
|
||||
} else if let Some(ref b) = settings.llm_backend {
|
||||
b.clone()
|
||||
match b.parse() {
|
||||
Ok(backend) => backend,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid llm_backend '{}' in settings: {}. Using default NearAi.",
|
||||
b,
|
||||
e
|
||||
);
|
||||
LlmBackend::NearAi
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"nearai".to_string()
|
||||
LlmBackend::NearAi
|
||||
};
|
||||
|
||||
// Validate the backend is known
|
||||
let backend_lower = backend.to_lowercase();
|
||||
let is_nearai =
|
||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||
|
||||
if !is_nearai && registry.find(&backend_lower).is_none() {
|
||||
tracing::warn!(
|
||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||
backend
|
||||
);
|
||||
}
|
||||
|
||||
// Session config (used by NearAI provider for OAuth/session-token auth)
|
||||
let session = SessionConfig {
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_session_path),
|
||||
};
|
||||
|
||||
// Always resolve NEAR AI config (used for embeddings even when not the primary backend)
|
||||
// Resolve NEAR AI config only when backend is NearAi (or when explicitly configured)
|
||||
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||
|
||||
let nearai = NearAiConfig {
|
||||
model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
@@ -213,6 +276,11 @@ impl LlmConfig {
|
||||
"https://private.near.ai".to_string()
|
||||
}
|
||||
}),
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_session_path),
|
||||
api_key: nearai_api_key,
|
||||
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
|
||||
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
|
||||
@@ -232,155 +300,107 @@ impl LlmConfig {
|
||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||
};
|
||||
|
||||
// Resolve registry provider config (for non-NearAI backends)
|
||||
let provider = if is_nearai {
|
||||
None
|
||||
// Resolve provider-specific configs based on backend
|
||||
let openai = if backend == LlmBackend::OpenAi {
|
||||
let api_key = optional_env("OPENAI_API_KEY")?
|
||||
.map(SecretString::from)
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "OPENAI_API_KEY".to_string(),
|
||||
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
|
||||
})?;
|
||||
let model = Self::resolve_model("OPENAI_MODEL", settings, "gpt-4o")?;
|
||||
let base_url = optional_env("OPENAI_BASE_URL")?;
|
||||
Some(OpenAiDirectConfig {
|
||||
api_key,
|
||||
model,
|
||||
base_url,
|
||||
})
|
||||
} else {
|
||||
Some(Self::resolve_registry_provider(
|
||||
&backend_lower,
|
||||
®istry,
|
||||
settings,
|
||||
)?)
|
||||
None
|
||||
};
|
||||
|
||||
let anthropic = if backend == LlmBackend::Anthropic {
|
||||
let api_key = optional_env("ANTHROPIC_API_KEY")?
|
||||
.map(SecretString::from)
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(),
|
||||
})?;
|
||||
let model =
|
||||
Self::resolve_model("ANTHROPIC_MODEL", settings, "claude-sonnet-4-20250514")?;
|
||||
let base_url = optional_env("ANTHROPIC_BASE_URL")?;
|
||||
Some(AnthropicDirectConfig {
|
||||
api_key,
|
||||
model,
|
||||
base_url,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let ollama = if backend == LlmBackend::Ollama {
|
||||
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||
.or_else(|| settings.ollama_base_url.clone())
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
let model = Self::resolve_model("OLLAMA_MODEL", settings, "llama3")?;
|
||||
Some(OllamaConfig { base_url, model })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
||||
let base_url = optional_env("LLM_BASE_URL")?
|
||||
.or_else(|| settings.openai_compatible_base_url.clone())
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "LLM_BASE_URL".to_string(),
|
||||
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||
})?;
|
||||
let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
|
||||
let model = Self::resolve_model("LLM_MODEL", settings, "default")?;
|
||||
let extra_headers = optional_env("LLM_EXTRA_HEADERS")?
|
||||
.map(|val| parse_extra_headers(&val))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
Some(OpenAiCompatibleConfig {
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
extra_headers,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tinfoil = if backend == LlmBackend::Tinfoil {
|
||||
let api_key = optional_env("TINFOIL_API_KEY")?
|
||||
.map(SecretString::from)
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "TINFOIL_API_KEY".to_string(),
|
||||
hint: "Set TINFOIL_API_KEY when LLM_BACKEND=tinfoil".to_string(),
|
||||
})?;
|
||||
let model = Self::resolve_model("TINFOIL_MODEL", settings, "kimi-k2-5")?;
|
||||
Some(TinfoilConfig { api_key, model })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
backend: if is_nearai {
|
||||
"nearai".to_string()
|
||||
} else if let Some(ref p) = provider {
|
||||
p.provider_id.clone()
|
||||
} else {
|
||||
backend_lower
|
||||
},
|
||||
session,
|
||||
backend,
|
||||
nearai,
|
||||
provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a `RegistryProviderConfig` from the registry and env vars.
|
||||
fn resolve_registry_provider(
|
||||
backend: &str,
|
||||
registry: &ProviderRegistry,
|
||||
settings: &Settings,
|
||||
) -> Result<RegistryProviderConfig, ConfigError> {
|
||||
// Look up provider definition. Fall back to openai_compatible if unknown.
|
||||
let def = registry
|
||||
.find(backend)
|
||||
.or_else(|| registry.find("openai_compatible"));
|
||||
|
||||
let (
|
||||
canonical_id,
|
||||
protocol,
|
||||
api_key_env,
|
||||
base_url_env,
|
||||
model_env,
|
||||
default_model,
|
||||
default_base_url,
|
||||
extra_headers_env,
|
||||
api_key_required,
|
||||
base_url_required,
|
||||
) = if let Some(def) = def {
|
||||
(
|
||||
def.id.as_str(),
|
||||
def.protocol,
|
||||
def.api_key_env.as_deref(),
|
||||
def.base_url_env.as_deref(),
|
||||
def.model_env.as_str(),
|
||||
def.default_model.as_str(),
|
||||
def.default_base_url.as_deref(),
|
||||
def.extra_headers_env.as_deref(),
|
||||
def.api_key_required,
|
||||
def.base_url_required,
|
||||
)
|
||||
} else {
|
||||
// Absolute fallback: treat as generic openai_completions
|
||||
(
|
||||
backend,
|
||||
ProviderProtocol::OpenAiCompletions,
|
||||
Some("LLM_API_KEY"),
|
||||
Some("LLM_BASE_URL"),
|
||||
"LLM_MODEL",
|
||||
"default",
|
||||
None,
|
||||
Some("LLM_EXTRA_HEADERS"),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
// Resolve API key from env
|
||||
let api_key = if let Some(env_var) = api_key_env {
|
||||
optional_env(env_var)?.map(SecretString::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if api_key_required && api_key.is_none() {
|
||||
// Don't hard-fail here. The key might be injected later from the secrets store
|
||||
// via inject_llm_keys_from_secrets(). Log a warning instead.
|
||||
if let Some(env_var) = api_key_env {
|
||||
tracing::debug!(
|
||||
"API key not found in {env_var} for backend '{backend}'. \
|
||||
Will be injected from secrets store if available."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve base URL: env var > settings (backward compat) > registry default
|
||||
let base_url = if let Some(env_var) = base_url_env {
|
||||
optional_env(env_var)?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
// Backward compat: check legacy settings fields
|
||||
match backend {
|
||||
"ollama" => settings.ollama_base_url.clone(),
|
||||
"openai_compatible" | "openrouter" => settings.openai_compatible_base_url.clone(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.or_else(|| default_base_url.map(String::from))
|
||||
.unwrap_or_default();
|
||||
|
||||
if base_url_required
|
||||
&& base_url.is_empty()
|
||||
&& let Some(env_var) = base_url_env
|
||||
{
|
||||
return Err(ConfigError::MissingRequired {
|
||||
key: env_var.to_string(),
|
||||
hint: format!("Set {env_var} when LLM_BACKEND={backend}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve model
|
||||
let model = Self::resolve_model(model_env, settings, default_model)?;
|
||||
|
||||
// Resolve extra headers
|
||||
let extra_headers = if let Some(env_var) = extra_headers_env {
|
||||
optional_env(env_var)?
|
||||
.map(|val| parse_extra_headers(&val))
|
||||
.transpose()?
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(RegistryProviderConfig {
|
||||
protocol,
|
||||
provider_id: canonical_id.to_string(),
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
extra_headers,
|
||||
openai,
|
||||
anthropic,
|
||||
ollama,
|
||||
openai_compatible,
|
||||
tinfoil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `LLM_EXTRA_HEADERS` value into a list of (key, value) pairs.
|
||||
///
|
||||
/// Format: `Key1:Value1,Key2:Value2` (colon-separated, not `=`, because
|
||||
/// header values often contain `=`).
|
||||
/// Format: `Key1:Value1,Key2:Value2` — colon-separated key:value, comma-separated pairs.
|
||||
/// Colon is used as the separator (not `=`) because header values often contain `=`
|
||||
/// (e.g., base64 tokens).
|
||||
fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError> {
|
||||
if val.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -444,9 +464,11 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
let compat = cfg
|
||||
.openai_compatible
|
||||
.expect("openai-compatible config should be present");
|
||||
|
||||
assert_eq!(provider.model, "openai/gpt-5.1-codex");
|
||||
assert_eq!(compat.model, "openai/gpt-5.1-codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -466,9 +488,11 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
let compat = cfg
|
||||
.openai_compatible
|
||||
.expect("openai-compatible config should be present");
|
||||
|
||||
assert_eq!(provider.model, "openai/gpt-5-codex");
|
||||
assert_eq!(compat.model, "openai/gpt-5-codex");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
@@ -514,6 +538,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extra_headers_value_with_colons() {
|
||||
// Values can contain colons (e.g., URLs)
|
||||
let result = parse_extra_headers("Authorization:Bearer abc:def").unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
@@ -562,9 +587,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
||||
|
||||
assert_eq!(provider.model, "llama3.2");
|
||||
assert_eq!(ollama.model, "llama3.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -583,9 +608,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
||||
|
||||
assert_eq!(provider.model, "mistral:latest");
|
||||
assert_eq!(ollama.model, "mistral:latest");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
@@ -606,279 +631,13 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
let compat = cfg
|
||||
.openai_compatible
|
||||
.expect("openai-compatible config should be present");
|
||||
|
||||
assert_eq!(
|
||||
provider.model, "llama3.2",
|
||||
compat.model, "llama3.2",
|
||||
"model name with dot must not be truncated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_provider_resolves_groq() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
std::env::remove_var("GROQ_MODEL");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("groq".to_string()),
|
||||
selected_model: Some("llama-3.3-70b-versatile".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "groq");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.provider_id, "groq");
|
||||
assert_eq!(provider.model, "llama-3.3-70b-versatile");
|
||||
assert_eq!(provider.base_url, "https://api.groq.com/openai/v1");
|
||||
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_provider_resolves_tinfoil() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("TINFOIL_API_KEY");
|
||||
std::env::remove_var("TINFOIL_MODEL");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("tinfoil".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "tinfoil");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
||||
assert_eq!(provider.model, "kimi-k2-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearai_backend_has_no_registry_provider() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "nearai");
|
||||
assert!(cfg.provider.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_alias_normalized_to_canonical_id() {
|
||||
// When the user sets LLM_BACKEND to an alias (e.g., "open_ai"),
|
||||
// LlmConfig.backend should resolve to the canonical ID ("openai").
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_compatible_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "open_ai");
|
||||
std::env::set_var("OPENAI_API_KEY", "test-key");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(
|
||||
cfg.backend, "openai",
|
||||
"alias 'open_ai' should be normalized to canonical 'openai'"
|
||||
);
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(provider.provider_id, "openai");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_backend_falls_back_to_openai_compatible() {
|
||||
// An unrecognized LLM_BACKEND should fall back to the openai_compatible
|
||||
// provider definition instead of erroring.
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_compatible_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "some_custom_provider");
|
||||
std::env::set_var("LLM_BASE_URL", "http://localhost:8080/v1");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
// Falls back to openai_compatible since "some_custom_provider" is unknown
|
||||
assert_eq!(cfg.backend, "openai_compatible");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(provider.provider_id, "openai_compatible");
|
||||
assert_eq!(provider.base_url, "http://localhost:8080/v1");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("LLM_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearai_aliases_all_resolve_to_nearai() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
|
||||
for alias in &["nearai", "near_ai", "near"] {
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", alias);
|
||||
}
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(
|
||||
cfg.backend, "nearai",
|
||||
"alias '{alias}' should resolve to 'nearai'"
|
||||
);
|
||||
assert!(
|
||||
cfg.provider.is_none(),
|
||||
"nearai should not have a registry provider"
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_resolution_priority() {
|
||||
// Env var > settings > registry default
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_compatible_env();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "openai_compatible");
|
||||
std::env::set_var("LLM_BASE_URL", "http://env-url/v1");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_compatible".to_string()),
|
||||
openai_compatible_base_url: Some("http://settings-url/v1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(
|
||||
provider.base_url, "http://env-url/v1",
|
||||
"env var should take priority over settings"
|
||||
);
|
||||
|
||||
// Now without env var, settings should win over registry default
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BASE_URL");
|
||||
}
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(
|
||||
provider.base_url, "http://settings-url/v1",
|
||||
"settings should take priority over registry default"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_retention_from_str_primary_values() {
|
||||
assert_eq!(
|
||||
"none".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::None
|
||||
);
|
||||
assert_eq!(
|
||||
"short".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Short
|
||||
);
|
||||
assert_eq!(
|
||||
"long".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Long
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_retention_from_str_aliases() {
|
||||
assert_eq!(
|
||||
"off".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::None
|
||||
);
|
||||
assert_eq!(
|
||||
"disabled".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::None
|
||||
);
|
||||
assert_eq!(
|
||||
"5m".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Short
|
||||
);
|
||||
assert_eq!(
|
||||
"ephemeral".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Short
|
||||
);
|
||||
assert_eq!(
|
||||
"1h".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Long
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_retention_from_str_case_insensitive() {
|
||||
assert_eq!(
|
||||
"NONE".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::None
|
||||
);
|
||||
assert_eq!(
|
||||
"Short".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Short
|
||||
);
|
||||
assert_eq!(
|
||||
"LONG".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Long
|
||||
);
|
||||
assert_eq!(
|
||||
"Ephemeral".parse::<CacheRetention>().unwrap(),
|
||||
CacheRetention::Short
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_retention_from_str_invalid() {
|
||||
let err = "bogus".parse::<CacheRetention>().unwrap_err();
|
||||
assert!(
|
||||
err.contains("bogus"),
|
||||
"error should mention the invalid value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_retention_display_round_trip() {
|
||||
for variant in [
|
||||
CacheRetention::None,
|
||||
CacheRetention::Short,
|
||||
CacheRetention::Long,
|
||||
] {
|
||||
let s = variant.to_string();
|
||||
let parsed: CacheRetention = s.parse().unwrap();
|
||||
assert_eq!(parsed, variant, "round-trip failed for {s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-31
@@ -19,7 +19,6 @@ mod safety;
|
||||
mod sandbox;
|
||||
mod secrets;
|
||||
mod skills;
|
||||
mod transcription;
|
||||
mod tunnel;
|
||||
mod wasm;
|
||||
|
||||
@@ -37,16 +36,17 @@ pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsq
|
||||
pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
pub use self::llm::{CacheRetention, LlmConfig, NearAiConfig, RegistryProviderConfig};
|
||||
pub use self::llm::{
|
||||
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
|
||||
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
|
||||
};
|
||||
pub use self::routines::RoutineConfig;
|
||||
pub use self::safety::SafetyConfig;
|
||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||
pub use self::secrets::SecretsConfig;
|
||||
pub use self::skills::SkillsConfig;
|
||||
pub use self::transcription::TranscriptionConfig;
|
||||
pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
pub use crate::llm::session::SessionConfig;
|
||||
|
||||
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
||||
///
|
||||
@@ -74,7 +74,6 @@ pub struct Config {
|
||||
pub sandbox: SandboxModeConfig,
|
||||
pub claude_code: ClaudeCodeConfig,
|
||||
pub skills: SkillsConfig,
|
||||
pub transcription: TranscriptionConfig,
|
||||
pub observability: crate::observability::ObservabilityConfig,
|
||||
}
|
||||
|
||||
@@ -111,7 +110,7 @@ impl Config {
|
||||
http: None,
|
||||
gateway: None,
|
||||
signal: None,
|
||||
wasm_channels_dir: std::env::temp_dir().join("ironclaw-test-channels"),
|
||||
wasm_channels_dir: std::path::PathBuf::from("/tmp/ironclaw-test-channels"),
|
||||
wasm_channels_enabled: false,
|
||||
wasm_channel_owner_ids: HashMap::new(),
|
||||
},
|
||||
@@ -146,7 +145,6 @@ impl Config {
|
||||
installed_dir: installed_skills_dir,
|
||||
..SkillsConfig::default()
|
||||
},
|
||||
transcription: TranscriptionConfig::default(),
|
||||
observability: crate::observability::ObservabilityConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -271,7 +269,6 @@ impl Config {
|
||||
sandbox: SandboxModeConfig::resolve()?,
|
||||
claude_code: ClaudeCodeConfig::resolve()?,
|
||||
skills: SkillsConfig::resolve()?,
|
||||
transcription: TranscriptionConfig::resolve(settings)?,
|
||||
observability: crate::observability::ObservabilityConfig {
|
||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||
},
|
||||
@@ -289,29 +286,12 @@ pub async fn inject_llm_keys_from_secrets(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
user_id: &str,
|
||||
) {
|
||||
// Static mappings for well-known providers.
|
||||
// The registry's setup hints define secret_name -> env_var mappings,
|
||||
// so new providers added to providers.json get injection automatically.
|
||||
let mut mappings: Vec<(&str, &str)> = vec![("llm_nearai_api_key", "NEARAI_API_KEY")];
|
||||
|
||||
// Dynamically discover secret->env mappings from the provider registry.
|
||||
// Uses selectable() which deduplicates user overrides correctly.
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
let dynamic_mappings: Vec<(String, String)> = registry
|
||||
.selectable()
|
||||
.iter()
|
||||
.filter_map(|def| {
|
||||
def.api_key_env.as_ref().and_then(|env_var| {
|
||||
def.setup
|
||||
.as_ref()
|
||||
.and_then(|s| s.secret_name())
|
||||
.map(|secret_name| (secret_name.to_string(), env_var.clone()))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (secret, env_var) in &dynamic_mappings {
|
||||
mappings.push((secret, env_var));
|
||||
}
|
||||
let mappings = [
|
||||
("llm_openai_api_key", "OPENAI_API_KEY"),
|
||||
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
||||
("llm_compatible_api_key", "LLM_API_KEY"),
|
||||
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
||||
];
|
||||
|
||||
let mut injected = HashMap::new();
|
||||
|
||||
|
||||
@@ -237,204 +237,3 @@ fn parse_oauth_access_token(json: &str) -> Option<String> {
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::sandbox::*;
|
||||
|
||||
// ── SandboxModeConfig defaults ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_config_default_values() {
|
||||
let cfg = SandboxModeConfig::default();
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.policy, "readonly");
|
||||
assert_eq!(cfg.timeout_secs, 120);
|
||||
assert_eq!(cfg.memory_limit_mb, 2048);
|
||||
assert_eq!(cfg.cpu_shares, 1024);
|
||||
assert_eq!(cfg.image, "ironclaw-worker:latest");
|
||||
assert!(cfg.auto_pull_image);
|
||||
assert!(cfg.extra_allowed_domains.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_config_custom_values() {
|
||||
let cfg = SandboxModeConfig {
|
||||
enabled: false,
|
||||
policy: "full_access".to_string(),
|
||||
timeout_secs: 600,
|
||||
memory_limit_mb: 4096,
|
||||
cpu_shares: 512,
|
||||
image: "custom-worker:v2".to_string(),
|
||||
auto_pull_image: false,
|
||||
extra_allowed_domains: vec!["example.com".to_string()],
|
||||
};
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.policy, "full_access");
|
||||
assert_eq!(cfg.timeout_secs, 600);
|
||||
assert_eq!(cfg.memory_limit_mb, 4096);
|
||||
assert_eq!(cfg.cpu_shares, 512);
|
||||
assert_eq!(cfg.image, "custom-worker:v2");
|
||||
assert!(!cfg.auto_pull_image);
|
||||
assert_eq!(cfg.extra_allowed_domains, vec!["example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_to_sandbox_config_propagates_fields() {
|
||||
let mode = SandboxModeConfig {
|
||||
enabled: true,
|
||||
policy: "workspace_write".to_string(),
|
||||
timeout_secs: 300,
|
||||
memory_limit_mb: 1024,
|
||||
cpu_shares: 2048,
|
||||
image: "test:latest".to_string(),
|
||||
auto_pull_image: false,
|
||||
extra_allowed_domains: vec!["custom.example.com".to_string()],
|
||||
};
|
||||
let sc = mode.to_sandbox_config();
|
||||
assert!(sc.enabled);
|
||||
assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::WorkspaceWrite);
|
||||
assert_eq!(sc.timeout, std::time::Duration::from_secs(300));
|
||||
assert_eq!(sc.memory_limit_mb, 1024);
|
||||
assert_eq!(sc.cpu_shares, 2048);
|
||||
assert_eq!(sc.image, "test:latest");
|
||||
assert!(!sc.auto_pull_image);
|
||||
// extra domain should be in the allowlist
|
||||
assert!(
|
||||
sc.network_allowlist
|
||||
.contains(&"custom.example.com".to_string()),
|
||||
"expected custom domain in allowlist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_to_sandbox_config_invalid_policy_falls_back_to_readonly() {
|
||||
let mode = SandboxModeConfig {
|
||||
policy: "garbage_value".to_string(),
|
||||
..SandboxModeConfig::default()
|
||||
};
|
||||
let sc = mode.to_sandbox_config();
|
||||
assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_to_sandbox_config_includes_default_allowlist() {
|
||||
let mode = SandboxModeConfig::default();
|
||||
let sc = mode.to_sandbox_config();
|
||||
// The default allowlist from sandbox module should be non-empty
|
||||
assert!(
|
||||
!sc.network_allowlist.is_empty(),
|
||||
"default allowlist should not be empty"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ClaudeCodeConfig defaults ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn claude_code_config_default_values() {
|
||||
let cfg = ClaudeCodeConfig::default();
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.model, "sonnet");
|
||||
assert_eq!(cfg.max_turns, 50);
|
||||
assert_eq!(cfg.memory_limit_mb, 4096);
|
||||
assert!(cfg.config_dir.ends_with(".claude"));
|
||||
// Should have all the standard tools
|
||||
assert!(!cfg.allowed_tools.is_empty());
|
||||
assert!(cfg.allowed_tools.contains(&"Bash(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Read(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Edit(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Write(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Grep(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"WebFetch(*)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_config_custom_values() {
|
||||
let cfg = ClaudeCodeConfig {
|
||||
enabled: true,
|
||||
config_dir: std::path::PathBuf::from("/opt/claude"),
|
||||
model: "opus".to_string(),
|
||||
max_turns: 100,
|
||||
memory_limit_mb: 8192,
|
||||
allowed_tools: vec!["Read(*)".to_string(), "Bash(*)".to_string()],
|
||||
};
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.config_dir, std::path::PathBuf::from("/opt/claude"));
|
||||
assert_eq!(cfg.model, "opus");
|
||||
assert_eq!(cfg.max_turns, 100);
|
||||
assert_eq!(cfg.memory_limit_mb, 8192);
|
||||
assert_eq!(cfg.allowed_tools.len(), 2);
|
||||
}
|
||||
|
||||
// ── parse_oauth_access_token ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_valid() {
|
||||
let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-fake"}}"#;
|
||||
let token = parse_oauth_access_token(json);
|
||||
assert_eq!(token, Some("sk-ant-oat01-fake".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_missing_access_token() {
|
||||
let json = r#"{"claudeAiOauth": {}}"#;
|
||||
assert_eq!(parse_oauth_access_token(json), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_missing_oauth_key() {
|
||||
let json = r#"{"someOtherKey": {"accessToken": "tok"}}"#;
|
||||
assert_eq!(parse_oauth_access_token(json), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_invalid_json() {
|
||||
assert_eq!(parse_oauth_access_token("not json at all"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_empty_string() {
|
||||
assert_eq!(parse_oauth_access_token(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_nested_extra_fields() {
|
||||
let json = r#"{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-real-token",
|
||||
"refreshToken": "rt-abc",
|
||||
"expiresAt": 1700000000
|
||||
}
|
||||
}"#;
|
||||
assert_eq!(
|
||||
parse_oauth_access_token(json),
|
||||
Some("sk-ant-real-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_access_token_is_not_string() {
|
||||
let json = r#"{"claudeAiOauth": {"accessToken": 12345}}"#;
|
||||
assert_eq!(parse_oauth_access_token(json), None);
|
||||
}
|
||||
|
||||
// ── default_claude_code_allowed_tools ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_allowed_tools_has_expected_count() {
|
||||
let tools = default_claude_code_allowed_tools();
|
||||
// 10 tools: Read, Write, Edit, Glob, Grep, NotebookEdit, Bash, Task, WebFetch, WebSearch
|
||||
assert_eq!(tools.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_allowed_tools_all_have_glob_pattern() {
|
||||
let tools = default_claude_code_allowed_tools();
|
||||
for tool in &tools {
|
||||
assert!(
|
||||
tool.ends_with("(*)"),
|
||||
"tool '{tool}' should end with '(*)' glob pattern"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_bool_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Transcription pipeline configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptionConfig {
|
||||
/// Whether audio transcription is enabled.
|
||||
pub enabled: bool,
|
||||
/// Provider: "openai" (default).
|
||||
pub provider: String,
|
||||
/// OpenAI API key (reuses OPENAI_API_KEY).
|
||||
pub openai_api_key: Option<SecretString>,
|
||||
/// Model to use (default: "whisper-1").
|
||||
pub model: String,
|
||||
/// Base URL override for the transcription API.
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TranscriptionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
provider: "openai".to_string(),
|
||||
openai_api_key: None,
|
||||
model: "whisper-1".to_string(),
|
||||
base_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptionConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let enabled = parse_bool_env(
|
||||
"TRANSCRIPTION_ENABLED",
|
||||
settings.transcription.as_ref().is_some_and(|t| t.enabled),
|
||||
)?;
|
||||
|
||||
let provider =
|
||||
optional_env("TRANSCRIPTION_PROVIDER")?.unwrap_or_else(|| "openai".to_string());
|
||||
|
||||
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
|
||||
|
||||
let model = optional_env("TRANSCRIPTION_MODEL")?.unwrap_or_else(|| "whisper-1".to_string());
|
||||
|
||||
let base_url = optional_env("TRANSCRIPTION_BASE_URL")?;
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
provider,
|
||||
openai_api_key,
|
||||
model,
|
||||
base_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create the transcription provider if enabled and configured.
|
||||
pub fn create_provider(&self) -> Option<Box<dyn crate::transcription::TranscriptionProvider>> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Currently only OpenAI Whisper is supported; more providers can be
|
||||
// added here with a match on self.provider.
|
||||
let api_key = self.openai_api_key.as_ref()?;
|
||||
tracing::info!(model = %self.model, "Audio transcription enabled via OpenAI Whisper");
|
||||
|
||||
let mut provider = crate::transcription::OpenAiWhisperProvider::new(api_key.clone())
|
||||
.with_model(&self.model);
|
||||
|
||||
if let Some(ref base_url) = self.base_url {
|
||||
provider = provider.with_base_url(base_url);
|
||||
}
|
||||
|
||||
Some(Box::new(provider))
|
||||
}
|
||||
}
|
||||
@@ -104,215 +104,3 @@ impl TunnelConfig {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::tunnel::TunnelConfig;
|
||||
use crate::tunnel::{
|
||||
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
|
||||
TunnelProviderConfig,
|
||||
};
|
||||
|
||||
// ── Default ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_is_disabled() {
|
||||
let cfg = TunnelConfig::default();
|
||||
assert!(cfg.public_url.is_none());
|
||||
assert!(cfg.provider.is_none());
|
||||
assert!(!cfg.is_enabled());
|
||||
}
|
||||
|
||||
// ── is_enabled ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_enabled_with_static_url() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://tunnel.example.com".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert!(cfg.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_enabled_with_provider() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: None,
|
||||
provider: Some(TunnelProviderConfig {
|
||||
provider: "cloudflare".to_string(),
|
||||
cloudflare: Some(CloudflareTunnelConfig {
|
||||
token: "cf-tok".to_string(),
|
||||
}),
|
||||
tailscale: None,
|
||||
ngrok: None,
|
||||
custom: None,
|
||||
}),
|
||||
};
|
||||
assert!(cfg.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_enabled_with_both() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://example.com".to_string()),
|
||||
provider: Some(TunnelProviderConfig {
|
||||
provider: "ngrok".to_string(),
|
||||
cloudflare: None,
|
||||
tailscale: None,
|
||||
ngrok: Some(NgrokTunnelConfig {
|
||||
auth_token: "ngrok-tok".to_string(),
|
||||
domain: None,
|
||||
}),
|
||||
custom: None,
|
||||
}),
|
||||
};
|
||||
assert!(cfg.is_enabled());
|
||||
}
|
||||
|
||||
// ── webhook_url ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn webhook_url_none_when_no_public_url() {
|
||||
let cfg = TunnelConfig::default();
|
||||
assert!(cfg.webhook_url("/hook").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_basic() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.webhook_url("/webhook/telegram"),
|
||||
Some("https://abc.ngrok.io/webhook/telegram".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_trims_trailing_slash_on_base() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io/".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.webhook_url("/hook"),
|
||||
Some("https://abc.ngrok.io/hook".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_trims_leading_slash_on_path() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
// Path without leading slash should also work
|
||||
assert_eq!(
|
||||
cfg.webhook_url("hook"),
|
||||
Some("https://abc.ngrok.io/hook".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_double_slash_normalization() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io/".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
// Both base trailing and path leading slashes trimmed
|
||||
assert_eq!(
|
||||
cfg.webhook_url("/api/webhook"),
|
||||
Some("https://abc.ngrok.io/api/webhook".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_empty_path() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.webhook_url(""),
|
||||
Some("https://abc.ngrok.io/".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ── TunnelProviderConfig field coverage ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn provider_config_cloudflare() {
|
||||
let p = TunnelProviderConfig {
|
||||
provider: "cloudflare".to_string(),
|
||||
cloudflare: Some(CloudflareTunnelConfig {
|
||||
token: "cf-secret".to_string(),
|
||||
}),
|
||||
tailscale: None,
|
||||
ngrok: None,
|
||||
custom: None,
|
||||
};
|
||||
assert_eq!(p.provider, "cloudflare");
|
||||
assert_eq!(p.cloudflare.as_ref().unwrap().token, "cf-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_tailscale() {
|
||||
let ts = TailscaleTunnelConfig {
|
||||
funnel: true,
|
||||
hostname: Some("my-host".to_string()),
|
||||
};
|
||||
assert!(ts.funnel);
|
||||
assert_eq!(ts.hostname.as_deref(), Some("my-host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_tailscale_defaults() {
|
||||
let ts = TailscaleTunnelConfig::default();
|
||||
assert!(!ts.funnel);
|
||||
assert!(ts.hostname.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_ngrok() {
|
||||
let ng = NgrokTunnelConfig {
|
||||
auth_token: "ng-tok".to_string(),
|
||||
domain: Some("custom.ngrok.dev".to_string()),
|
||||
};
|
||||
assert_eq!(ng.auth_token, "ng-tok");
|
||||
assert_eq!(ng.domain.as_deref(), Some("custom.ngrok.dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_ngrok_defaults() {
|
||||
let ng = NgrokTunnelConfig::default();
|
||||
assert!(ng.auth_token.is_empty());
|
||||
assert!(ng.domain.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_custom() {
|
||||
let c = CustomTunnelConfig {
|
||||
start_command: "bore local {port}".to_string(),
|
||||
health_url: Some("http://localhost:8080/health".to_string()),
|
||||
url_pattern: Some("https://bore.pub".to_string()),
|
||||
};
|
||||
assert_eq!(c.start_command, "bore local {port}");
|
||||
assert!(c.health_url.is_some());
|
||||
assert!(c.url_pattern.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_custom_defaults() {
|
||||
let c = CustomTunnelConfig::default();
|
||||
assert!(c.start_command.is_empty());
|
||||
assert!(c.health_url.is_none());
|
||||
assert!(c.url_pattern.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloudflare_config_defaults() {
|
||||
let cf = CloudflareTunnelConfig::default();
|
||||
assert!(cf.token.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,391 +490,4 @@ mod tests {
|
||||
assert_eq!(ctx.state, crate::context::JobState::InProgress);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_context_not_found() {
|
||||
let manager = ContextManager::new(5);
|
||||
let bogus_id = Uuid::new_v4();
|
||||
let result = manager.get_context(bogus_id).await;
|
||||
assert!(matches!(result, Err(JobError::NotFound { id }) if id == bogus_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_context_not_found() {
|
||||
let manager = ContextManager::new(5);
|
||||
let bogus_id = Uuid::new_v4();
|
||||
let result = manager.update_context(bogus_id, |_ctx| {}).await;
|
||||
assert!(matches!(result, Err(JobError::NotFound { id }) if id == bogus_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_job_returns_context_and_memory() {
|
||||
let manager = ContextManager::new(5);
|
||||
let job_id = manager.create_job("Removable", "bye bye").await.unwrap();
|
||||
|
||||
let (ctx, mem) = manager.remove_job(job_id).await.unwrap();
|
||||
assert_eq!(ctx.title, "Removable");
|
||||
assert_eq!(mem.job_id, job_id);
|
||||
|
||||
// After removal, get should fail
|
||||
assert!(matches!(
|
||||
manager.get_context(job_id).await,
|
||||
Err(JobError::NotFound { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
manager.get_memory(job_id).await,
|
||||
Err(JobError::NotFound { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_job_not_found() {
|
||||
let manager = ContextManager::new(5);
|
||||
let result = manager.remove_job(Uuid::new_v4()).await;
|
||||
assert!(matches!(result, Err(JobError::NotFound { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_memory_and_update_memory() {
|
||||
let manager = ContextManager::new(5);
|
||||
let job_id = manager.create_job("Mem test", "desc").await.unwrap();
|
||||
|
||||
// Fresh memory should be empty
|
||||
let mem = manager.get_memory(job_id).await.unwrap();
|
||||
assert_eq!(mem.job_id, job_id);
|
||||
assert!(mem.actions.is_empty());
|
||||
assert!(mem.conversation.is_empty());
|
||||
|
||||
// Update memory by adding a message
|
||||
manager
|
||||
.update_memory(job_id, |m| {
|
||||
m.add_message(crate::llm::ChatMessage::user("hello from test"));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mem = manager.get_memory(job_id).await.unwrap();
|
||||
assert_eq!(mem.conversation.len(), 1);
|
||||
assert_eq!(mem.conversation.messages()[0].content, "hello from test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_memory_not_found() {
|
||||
let manager = ContextManager::new(5);
|
||||
let result = manager.update_memory(Uuid::new_v4(), |_| {}).await;
|
||||
assert!(matches!(result, Err(JobError::NotFound { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_memory_not_found() {
|
||||
let manager = ContextManager::new(5);
|
||||
let result = manager.get_memory(Uuid::new_v4()).await;
|
||||
assert!(matches!(result, Err(JobError::NotFound { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_stuck_jobs_returns_only_stuck() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
let id1 = manager.create_job("Job 1", "desc").await.unwrap();
|
||||
let id2 = manager.create_job("Job 2", "desc").await.unwrap();
|
||||
let id3 = manager.create_job("Job 3", "desc").await.unwrap();
|
||||
|
||||
// Transition id1 and id2 to InProgress, then mark id2 as stuck
|
||||
for id in [id1, id2, id3] {
|
||||
manager
|
||||
.update_context(id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
manager
|
||||
.update_context(id2, |ctx| ctx.mark_stuck("timed out"))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let stuck = manager.find_stuck_jobs().await;
|
||||
assert_eq!(stuck.len(), 1);
|
||||
assert_eq!(stuck[0], id2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_count_tracks_non_terminal_jobs() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
let id1 = manager.create_job("J1", "d").await.unwrap();
|
||||
let id2 = manager.create_job("J2", "d").await.unwrap();
|
||||
|
||||
// Both pending (active)
|
||||
assert_eq!(manager.active_count().await, 2);
|
||||
|
||||
// Transition id1 through to Failed (terminal)
|
||||
manager
|
||||
.update_context(id1, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
manager
|
||||
.update_context(id1, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::Failed, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// id1 is terminal, id2 still pending
|
||||
assert_eq!(manager.active_count().await, 1);
|
||||
|
||||
// Transition id2 to cancelled
|
||||
manager
|
||||
.update_context(id2, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::Cancelled, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(manager.active_count().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_jobs_for_filters_by_user() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
manager
|
||||
.create_job_for_user("alice", "A1", "d")
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.create_job_for_user("alice", "A2", "d")
|
||||
.await
|
||||
.unwrap();
|
||||
let bob_id = manager.create_job_for_user("bob", "B1", "d").await.unwrap();
|
||||
|
||||
assert_eq!(manager.active_jobs_for("alice").await.len(), 2);
|
||||
assert_eq!(manager.active_jobs_for("bob").await.len(), 1);
|
||||
assert_eq!(manager.active_jobs_for("nobody").await.len(), 0);
|
||||
|
||||
// Make bob's job terminal
|
||||
manager
|
||||
.update_context(bob_id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
manager
|
||||
.update_context(bob_id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::Failed, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(manager.active_jobs_for("bob").await.len(), 0);
|
||||
// But all_jobs_for still shows it
|
||||
assert_eq!(manager.all_jobs_for("bob").await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summary_counts_states_correctly() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
let id1 = manager.create_job("J1", "d").await.unwrap();
|
||||
let id2 = manager.create_job("J2", "d").await.unwrap();
|
||||
let id3 = manager.create_job("J3", "d").await.unwrap();
|
||||
|
||||
// id1: Pending -> InProgress -> Completed
|
||||
manager
|
||||
.update_context(id1, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
manager
|
||||
.update_context(id1, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::Completed, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// id2: Pending -> InProgress -> Failed
|
||||
manager
|
||||
.update_context(id2, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
manager
|
||||
.update_context(id2, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::Failed, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// id3: stays Pending
|
||||
|
||||
let s = manager.summary().await;
|
||||
assert_eq!(s.total, 3);
|
||||
assert_eq!(s.pending, 1);
|
||||
assert_eq!(s.completed, 1);
|
||||
assert_eq!(s.failed, 1);
|
||||
assert_eq!(s.in_progress, 0);
|
||||
assert_eq!(s.stuck, 0);
|
||||
assert_eq!(s.cancelled, 0);
|
||||
assert_eq!(s.submitted, 0);
|
||||
assert_eq!(s.accepted, 0);
|
||||
|
||||
// Suppress unused field warning
|
||||
let _ = id3;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summary_for_scopes_to_user() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
manager
|
||||
.create_job_for_user("alice", "A1", "d")
|
||||
.await
|
||||
.unwrap();
|
||||
let bob_id = manager.create_job_for_user("bob", "B1", "d").await.unwrap();
|
||||
|
||||
// Transition bob's job to InProgress
|
||||
manager
|
||||
.update_context(bob_id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let alice_summary = manager.summary_for("alice").await;
|
||||
assert_eq!(alice_summary.total, 1);
|
||||
assert_eq!(alice_summary.pending, 1);
|
||||
assert_eq!(alice_summary.in_progress, 0);
|
||||
|
||||
let bob_summary = manager.summary_for("bob").await;
|
||||
assert_eq!(bob_summary.total, 1);
|
||||
assert_eq!(bob_summary.pending, 0);
|
||||
assert_eq!(bob_summary.in_progress, 1);
|
||||
|
||||
let nobody_summary = manager.summary_for("nobody").await;
|
||||
assert_eq!(nobody_summary.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_context_manager_has_max_10() {
|
||||
let manager = ContextManager::default();
|
||||
// Create 10 jobs and make them active
|
||||
for i in 0..10 {
|
||||
let id = manager
|
||||
.create_job(format!("Job {i}"), "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.update_context(id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
// 11th should fail
|
||||
let result = manager.create_job("overflow", "d").await;
|
||||
assert!(matches!(result, Err(JobError::MaxJobsExceeded { max: 10 })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_jobs_returns_all_regardless_of_state() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
let id1 = manager.create_job("J1", "d").await.unwrap();
|
||||
manager.create_job("J2", "d").await.unwrap();
|
||||
|
||||
// Make id1 terminal
|
||||
manager
|
||||
.update_context(id1, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
manager
|
||||
.update_context(id1, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::Failed, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// all_jobs includes terminal, active_jobs does not
|
||||
assert_eq!(manager.all_jobs().await.len(), 2);
|
||||
assert_eq!(manager.active_jobs().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_job_uses_default_user() {
|
||||
let manager = ContextManager::new(5);
|
||||
let job_id = manager.create_job("Test", "desc").await.unwrap();
|
||||
let ctx = manager.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.user_id, "default");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_remove_and_read() {
|
||||
let manager = std::sync::Arc::new(ContextManager::new(100));
|
||||
|
||||
// Create 20 jobs
|
||||
let mut job_ids = Vec::new();
|
||||
for i in 0..20 {
|
||||
let id = manager
|
||||
.create_job(format!("Job {i}"), "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
job_ids.push(id);
|
||||
}
|
||||
|
||||
// Concurrently remove the first 10 while reading the last 10
|
||||
let remove_handles: Vec<_> = job_ids[..10]
|
||||
.iter()
|
||||
.map(|&id| {
|
||||
let mgr = std::sync::Arc::clone(&manager);
|
||||
tokio::spawn(async move { mgr.remove_job(id).await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let read_handles: Vec<_> = job_ids[10..]
|
||||
.iter()
|
||||
.map(|&id| {
|
||||
let mgr = std::sync::Arc::clone(&manager);
|
||||
tokio::spawn(async move { mgr.get_context(id).await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in remove_handles {
|
||||
handle
|
||||
.await
|
||||
.expect("remove task should not panic")
|
||||
.expect("remove should succeed");
|
||||
}
|
||||
|
||||
for handle in read_handles {
|
||||
let ctx = handle
|
||||
.await
|
||||
.expect("read task should not panic")
|
||||
.expect("read should succeed");
|
||||
assert!(job_ids[10..].contains(&ctx.job_id));
|
||||
}
|
||||
|
||||
assert_eq!(manager.all_jobs().await.len(), 10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,276 +290,4 @@ mod tests {
|
||||
assert_eq!(memory.total_duration(), Duration::from_secs(3));
|
||||
assert_eq!(memory.successful_actions(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_record_fail() {
|
||||
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
|
||||
let action = action.fail("something went wrong", Duration::from_millis(50));
|
||||
|
||||
assert!(!action.success);
|
||||
assert_eq!(action.error.as_deref(), Some("something went wrong"));
|
||||
assert_eq!(action.duration, Duration::from_millis(50));
|
||||
assert!(action.output_raw.is_none());
|
||||
assert!(action.output_sanitized.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_record_with_warnings() {
|
||||
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
|
||||
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
|
||||
|
||||
assert_eq!(action.sanitization_warnings.len(), 2);
|
||||
assert_eq!(action.sanitization_warnings[0], "suspicious pattern");
|
||||
assert_eq!(action.sanitization_warnings[1], "possible xss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_record_with_cost() {
|
||||
let action = ActionRecord::new(0, "expensive_tool", serde_json::json!({}));
|
||||
let cost = Decimal::new(42, 2); // 0.42
|
||||
let action = action.with_cost(cost);
|
||||
|
||||
assert_eq!(action.cost, Some(Decimal::new(42, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_record_new_defaults() {
|
||||
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
|
||||
|
||||
assert_eq!(action.sequence, 5);
|
||||
assert_eq!(action.tool_name, "my_tool");
|
||||
assert_eq!(action.input, serde_json::json!({"key": "val"}));
|
||||
assert!(!action.success);
|
||||
assert!(action.output_raw.is_none());
|
||||
assert!(action.output_sanitized.is_none());
|
||||
assert!(action.sanitization_warnings.is_empty());
|
||||
assert!(action.cost.is_none());
|
||||
assert_eq!(action.duration, Duration::ZERO);
|
||||
assert!(action.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_record_succeed_sets_fields() {
|
||||
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
|
||||
let action = action.succeed(
|
||||
Some("raw output here".into()),
|
||||
serde_json::json!({"clean": true}),
|
||||
Duration::from_secs(7),
|
||||
);
|
||||
|
||||
assert!(action.success);
|
||||
assert_eq!(action.output_raw.as_deref(), Some("raw output here"));
|
||||
assert_eq!(
|
||||
action.output_sanitized,
|
||||
Some(serde_json::json!({"clean": true}))
|
||||
);
|
||||
assert_eq!(action.duration, Duration::from_secs(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_memory_clear() {
|
||||
let mut mem = ConversationMemory::new(10);
|
||||
mem.add(ChatMessage::user("hello"));
|
||||
mem.add(ChatMessage::assistant("hi"));
|
||||
assert_eq!(mem.len(), 2);
|
||||
assert!(!mem.is_empty());
|
||||
|
||||
mem.clear();
|
||||
assert_eq!(mem.len(), 0);
|
||||
assert!(mem.is_empty());
|
||||
assert!(mem.messages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_memory_last_n() {
|
||||
let mut mem = ConversationMemory::new(10);
|
||||
mem.add(ChatMessage::user("one"));
|
||||
mem.add(ChatMessage::assistant("two"));
|
||||
mem.add(ChatMessage::user("three"));
|
||||
mem.add(ChatMessage::assistant("four"));
|
||||
|
||||
let last_2 = mem.last_n(2);
|
||||
assert_eq!(last_2.len(), 2);
|
||||
assert_eq!(last_2[0].content, "three");
|
||||
assert_eq!(last_2[1].content, "four");
|
||||
|
||||
// Requesting more than available returns all
|
||||
let last_100 = mem.last_n(100);
|
||||
assert_eq!(last_100.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_memory_last_n_empty() {
|
||||
let mem = ConversationMemory::new(10);
|
||||
let result = mem.last_n(5);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_memory_preserves_system_message_on_trim() {
|
||||
let mut mem = ConversationMemory::new(3);
|
||||
mem.add(ChatMessage::system("You are helpful"));
|
||||
mem.add(ChatMessage::user("msg1"));
|
||||
mem.add(ChatMessage::user("msg2"));
|
||||
|
||||
// At capacity (3). Adding one more should trim, but keep system.
|
||||
mem.add(ChatMessage::user("msg3"));
|
||||
|
||||
assert_eq!(mem.len(), 3);
|
||||
// System message must survive
|
||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
|
||||
assert_eq!(mem.messages()[0].content, "You are helpful");
|
||||
// Oldest non-system message (msg1) should be gone
|
||||
assert_eq!(mem.messages()[1].content, "msg2");
|
||||
assert_eq!(mem.messages()[2].content, "msg3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_memory_trims_non_system_first() {
|
||||
let mut mem = ConversationMemory::new(2);
|
||||
mem.add(ChatMessage::system("sys"));
|
||||
mem.add(ChatMessage::user("a"));
|
||||
// Now at capacity. Add another.
|
||||
mem.add(ChatMessage::user("b"));
|
||||
|
||||
assert_eq!(mem.len(), 2);
|
||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
|
||||
assert_eq!(mem.messages()[1].content, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_memory_max_one_with_system_does_not_loop() {
|
||||
// Edge case: max_messages = 1 and only a system message.
|
||||
// Adding another message would try to trim but should not
|
||||
// remove the system message and get stuck.
|
||||
let mut mem = ConversationMemory::new(1);
|
||||
mem.add(ChatMessage::system("sys"));
|
||||
// The system message is already at capacity. Adding another
|
||||
// cannot trim the system message, so we end up with 2 (graceful).
|
||||
// The important thing is we don't infinite-loop.
|
||||
mem.add(ChatMessage::user("hello"));
|
||||
// Should have broken out rather than looping forever.
|
||||
// The system message is protected, so len may exceed max.
|
||||
assert!(mem.len() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_failed_actions() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
|
||||
let ok = memory.create_action("good", serde_json::json!({})).succeed(
|
||||
None,
|
||||
serde_json::json!({}),
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
memory.record_action(ok);
|
||||
|
||||
let err = memory
|
||||
.create_action("bad", serde_json::json!({}))
|
||||
.fail("oops", Duration::from_millis(2));
|
||||
memory.record_action(err);
|
||||
|
||||
assert_eq!(memory.successful_actions(), 1);
|
||||
assert_eq!(memory.failed_actions(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_last_action() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
assert!(memory.last_action().is_none());
|
||||
|
||||
let a1 = memory
|
||||
.create_action("first", serde_json::json!({}))
|
||||
.succeed(None, serde_json::json!({}), Duration::ZERO);
|
||||
memory.record_action(a1);
|
||||
|
||||
let a2 = memory
|
||||
.create_action("second", serde_json::json!({}))
|
||||
.fail("nope", Duration::ZERO);
|
||||
memory.record_action(a2);
|
||||
|
||||
let last = memory.last_action().unwrap();
|
||||
assert_eq!(last.tool_name, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_actions_by_tool() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
|
||||
for _ in 0..3 {
|
||||
let a = memory
|
||||
.create_action("shell", serde_json::json!({}))
|
||||
.succeed(None, serde_json::json!({}), Duration::ZERO);
|
||||
memory.record_action(a);
|
||||
}
|
||||
let a = memory.create_action("http", serde_json::json!({})).succeed(
|
||||
None,
|
||||
serde_json::json!({}),
|
||||
Duration::ZERO,
|
||||
);
|
||||
memory.record_action(a);
|
||||
|
||||
assert_eq!(memory.actions_by_tool("shell").len(), 3);
|
||||
assert_eq!(memory.actions_by_tool("http").len(), 1);
|
||||
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_create_action_increments_sequence() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
|
||||
let a0 = memory.create_action("t", serde_json::json!({}));
|
||||
assert_eq!(a0.sequence, 0);
|
||||
|
||||
let a1 = memory.create_action("t", serde_json::json!({}));
|
||||
assert_eq!(a1.sequence, 1);
|
||||
|
||||
let a2 = memory.create_action("t", serde_json::json!({}));
|
||||
assert_eq!(a2.sequence, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_add_message_delegates_to_conversation() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
assert!(memory.conversation.is_empty());
|
||||
|
||||
memory.add_message(ChatMessage::user("hello"));
|
||||
memory.add_message(ChatMessage::assistant("hi"));
|
||||
|
||||
assert_eq!(memory.conversation.len(), 2);
|
||||
assert_eq!(memory.conversation.messages()[0].content, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_total_cost_with_no_cost_actions() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
|
||||
// Actions without cost should contribute zero
|
||||
let a = memory
|
||||
.create_action("free_tool", serde_json::json!({}))
|
||||
.succeed(None, serde_json::json!({}), Duration::ZERO);
|
||||
memory.record_action(a);
|
||||
|
||||
assert_eq!(memory.total_cost(), Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_total_duration_mixed() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
|
||||
let a1 = memory.create_action("t1", serde_json::json!({})).succeed(
|
||||
None,
|
||||
serde_json::json!({}),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
memory.record_action(a1);
|
||||
|
||||
let a2 = memory
|
||||
.create_action("t2", serde_json::json!({}))
|
||||
.fail("err", Duration::from_millis(200));
|
||||
memory.record_action(a2);
|
||||
|
||||
// Both successful and failed actions contribute to total duration
|
||||
assert_eq!(memory.total_duration(), Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
# Database Module
|
||||
|
||||
Dual-backend persistence layer. **All new persistence features must support both backends.**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Default build (PostgreSQL)
|
||||
cargo build
|
||||
|
||||
# libSQL/Turso build
|
||||
cargo build --no-default-features --features libsql
|
||||
|
||||
# Both backends
|
||||
cargo build --features "postgres,libsql"
|
||||
|
||||
# Test each backend in isolation
|
||||
cargo check # postgres (default)
|
||||
cargo check --no-default-features --features libsql # libsql only
|
||||
cargo check --all-features # both
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `mod.rs` | `Database` supertrait + 7 sub-traits (~78 async methods total) — add new ops here first |
|
||||
| `postgres.rs` | PostgreSQL backend — delegates to `Store` + `Repository` in `history/` |
|
||||
| `libsql/mod.rs` | libSQL/Turso backend struct, connection helpers, row parsing utilities |
|
||||
| `libsql/conversations.rs` | `ConversationStore` impl |
|
||||
| `libsql/jobs.rs` | `JobStore` impl |
|
||||
| `libsql/sandbox.rs` | `SandboxStore` impl |
|
||||
| `libsql/routines.rs` | `RoutineStore` impl |
|
||||
| `libsql/settings.rs` | `SettingsStore` impl |
|
||||
| `libsql/tool_failures.rs` | `ToolFailureStore` impl |
|
||||
| `libsql/workspace.rs` | `WorkspaceStore` impl (FTS5 + vector search) |
|
||||
| `libsql_migrations.rs` | Consolidated libSQL schema (CREATE IF NOT EXISTS, no ALTER TABLE) |
|
||||
| `tls.rs` | TLS connector factory for PostgreSQL (`rustls` + system root certs) |
|
||||
|
||||
PostgreSQL schema: `migrations/V1__initial.sql` through `V9__flexible_embedding_dimension.sql` (managed by `refinery`). V1 is the base schema; later migrations add tables, columns, and rename `claude_code_events` → `job_events`.
|
||||
|
||||
## Trait Structure
|
||||
|
||||
The `Database` supertrait is composed of seven sub-traits. Leaf consumers can depend on the narrowest sub-trait they need rather than the full `Database`:
|
||||
|
||||
| Sub-trait | Methods | Covers |
|
||||
|-----------|---------|--------|
|
||||
| `ConversationStore` | 12 | Conversations, messages |
|
||||
| `JobStore` | 13 | Agent jobs, actions, LLM calls, estimation |
|
||||
| `SandboxStore` | 13 | Sandbox jobs, job events |
|
||||
| `RoutineStore` | 15 | Routines, routine runs |
|
||||
| `ToolFailureStore` | 4 | Self-repair tracking |
|
||||
| `SettingsStore` | 8 | Per-user key-value settings |
|
||||
| `WorkspaceStore` | 13 | Memory documents, chunks, hybrid search |
|
||||
|
||||
`Database` adds `run_migrations()` and combines all sub-traits.
|
||||
|
||||
## Adding a New Persistence Operation
|
||||
|
||||
1. Decide which sub-trait the method belongs to, or create a new sub-trait
|
||||
2. Add the async method signature to that sub-trait in `mod.rs`
|
||||
3. Implement in `postgres.rs` (delegate to `Store` or `Repository`)
|
||||
4. Implement in `libsql/<module>.rs` (SQLite-dialect SQL, use `self.connect().await?` per operation)
|
||||
5. Add migration if needed:
|
||||
- PostgreSQL: new `migrations/VN__description.sql`
|
||||
- libSQL: add `CREATE TABLE IF NOT EXISTS` to `libsql_migrations.rs`
|
||||
|
||||
## SQL Dialect Differences
|
||||
|
||||
| Feature | PostgreSQL | libSQL |
|
||||
|---------|-----------|--------|
|
||||
| UUIDs | `UUID` type | `TEXT` |
|
||||
| Timestamps | `TIMESTAMPTZ` | `TEXT` (ISO-8601 RFC 3339 with ms precision) |
|
||||
| JSON | `JSONB` | `TEXT` |
|
||||
| Numeric/Decimal | `NUMERIC` | `TEXT` (preserves `rust_decimal` precision) |
|
||||
| Arrays | `TEXT[]` | `TEXT` (JSON-encoded array) |
|
||||
| Booleans | `BOOLEAN` | `INTEGER` (0/1) |
|
||||
| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(1536)` via `libsql_vector_idx` |
|
||||
| Full-text search | `tsvector` + `ts_rank_cd` | FTS5 virtual table + sync triggers |
|
||||
| JSON path update | `jsonb_set(col, '{key}', val)` | `json_patch(col, '{"key": val}')` |
|
||||
| PL/pgSQL | Functions | Triggers (no stored procs in SQLite) |
|
||||
| Connection model | `deadpool-postgres` connection pool | New connection per operation (`self.connect()`) |
|
||||
| Concurrency | Pool-based, fully concurrent | WAL mode + 5 s busy timeout; write serialized |
|
||||
| Auto-timestamp | `DEFAULT NOW()` | `DEFAULT (datetime('now'))` |
|
||||
| Timestamp parsing | Native type | Multi-format fallback in `parse_timestamp()` |
|
||||
|
||||
**JSON merge patch gotcha:** libSQL uses RFC 7396 JSON Merge Patch (`json_patch`) for metadata updates. This replaces top-level keys entirely — it **cannot** do partial nested updates. PostgreSQL uses `jsonb_set` which is path-targeted. Don't rely on partial nested metadata updates if you need libSQL compat.
|
||||
|
||||
**Boolean storage:** libSQL stores booleans as integers. When reading, use `get_i64(row, idx) != 0`; when writing, pass `1i64`/`0i64`. Never pass a Rust `bool` directly.
|
||||
|
||||
**Timestamp write format:** Always write timestamps with `fmt_ts(dt)` (RFC 3339, millisecond precision). Read with `get_ts()` / `get_opt_ts()` which handle legacy naive formats too.
|
||||
|
||||
**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL still uses `F32_BLOB(1536)` — if you use a different-dimension embedding model, the libSQL schema needs updating too.
|
||||
|
||||
**Connection per operation:** `LibSqlBackend::connect()` creates a fresh connection for every operation, sets `PRAGMA busy_timeout = 5000`, and closes it when the `Connection` is dropped. This is intentional — the libSQL SDK does not offer a pool. Avoid holding connections open across `await` points.
|
||||
|
||||
## Schema: Key Tables
|
||||
|
||||
**Core:**
|
||||
- `conversations` — multi-channel conversation tracking
|
||||
- `conversation_messages` — individual messages within a conversation
|
||||
- `agent_jobs` — job metadata and status
|
||||
- `job_actions` — event-sourced tool executions
|
||||
- `job_events` — sandbox job streaming events (renamed from `claude_code_events` in V7)
|
||||
- `dynamic_tools` — agent-built tools
|
||||
- `llm_calls` — cost/token tracking
|
||||
- `estimation_snapshots` — learning data
|
||||
- `repair_attempts` — self-repair action log (not exposed via Database trait yet)
|
||||
|
||||
**Workspace/Memory:**
|
||||
- `memory_documents` — flexible path-based files
|
||||
- `memory_chunks` — chunked content with FTS + vector indexes
|
||||
- `memory_chunks_fts` — FTS5 virtual table (libSQL) / `tsvector` column (PostgreSQL)
|
||||
- `heartbeat_state` — periodic execution tracking
|
||||
|
||||
**Security/Extensions:**
|
||||
- `secrets` — AES-256-GCM encrypted credentials
|
||||
- `wasm_tools` — installed WASM tool binaries
|
||||
- `tool_capabilities` — per-tool HTTP allowlist, secret access, rate limits
|
||||
- `leak_detection_patterns` — secret regex patterns (seed data in both backends)
|
||||
- `leak_detection_events` — audit log of detected leaks
|
||||
- `secret_usage_log` — per-request credential injection audit trail
|
||||
- `tool_rate_limit_state` — sliding window rate limit counters
|
||||
|
||||
**Other:**
|
||||
- `routines`, `routine_runs` — scheduled/reactive execution
|
||||
- `settings` — per-user key-value
|
||||
- `tool_failures` — broken tool tracking for self-repair
|
||||
- `_migrations` — libSQL-only internal migration version tracking
|
||||
|
||||
## libSQL Current Limitations
|
||||
|
||||
- **Secrets store** — still requires `PostgresSecretsStore`; `LibSqlSecretsStore` exists but is not plumbed through the main startup path
|
||||
- **Settings reload** — `Config::from_db` skipped (requires `Store`)
|
||||
- **No incremental migrations** — schema is idempotent CREATE IF NOT EXISTS; no ALTER TABLE support; column additions require a new versioned approach
|
||||
- **No encryption at rest** — only secrets (API tokens) are AES-256-GCM encrypted; all other data is plaintext SQLite
|
||||
- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; however, the vector index is fixed at `F32_BLOB(1536)` while PostgreSQL switched to unbounded `vector` in V9
|
||||
- **Write serialization** — WAL mode allows concurrent readers but only one writer at a time; busy timeout is 5 s, which may cause timeouts under high write concurrency
|
||||
|
||||
## Running Locally with libSQL
|
||||
|
||||
```bash
|
||||
# Use local SQLite file (default)
|
||||
DATABASE_BACKEND=libsql LIBSQL_PATH=~/.ironclaw/test.db cargo run
|
||||
|
||||
# Use Turso cloud (embedded replica syncs local file to cloud)
|
||||
DATABASE_BACKEND=libsql LIBSQL_URL=libsql://xxx.turso.io LIBSQL_AUTH_TOKEN=xxx cargo run
|
||||
|
||||
# In-memory (tests only — data is lost when the process exits)
|
||||
# Use LibSqlBackend::new_memory() directly in test code
|
||||
```
|
||||
|
||||
## Testing the libSQL Backend
|
||||
|
||||
Use `LibSqlBackend::new_memory()` in unit tests — no files, no cleanup required:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_my_feature() {
|
||||
let backend = LibSqlBackend::new_memory().await.unwrap();
|
||||
backend.run_migrations().await.unwrap();
|
||||
// backend implements Database — call any trait method
|
||||
}
|
||||
```
|
||||
|
||||
For concurrency tests that require multiple connections sharing state, use `LibSqlBackend::new_local(&tmp_path)` with a `tempfile::tempdir()`. In-memory databases do not share state between connections.
|
||||
|
||||
## Sharing the libSQL Database Handle
|
||||
|
||||
`LibSqlBackend::shared_db()` returns an `Arc<LibSqlDatabase>` for passing to satellite stores (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore`) that need their own connections per-operation but should share the same underlying database file. These stores call `.connect()` on the shared handle themselves. This is the correct pattern — do not pass a live `Connection` to satellite stores.
|
||||
|
||||
## Pattern: Fix the Pattern, Not the Instance
|
||||
|
||||
When fixing a bug in one backend's SQL, always grep for the same pattern in the other backend. A fix to `postgres.rs` that doesn't also fix the libSQL module (e.g., `libsql/jobs.rs`) is half a fix. The same applies to satellite types like `LibSqlSecretsStore` or `LibSqlWasmToolStore`.
|
||||
@@ -292,8 +292,6 @@ impl Database for LibSqlBackend {
|
||||
conn.execute_batch(libsql_migrations::SCHEMA)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Migration(format!("libSQL migration failed: {}", e)))?;
|
||||
// Apply incremental migrations (V9+) tracked in _migrations table.
|
||||
libsql_migrations::run_incremental(&conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+20
-30
@@ -561,10 +561,7 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
.join(",")
|
||||
);
|
||||
|
||||
// vector_top_k requires a libsql_vector_idx index. After the V9
|
||||
// migration the index is dropped (to support flexible embedding
|
||||
// dimensions), so this query may fail. Fall back to FTS-only.
|
||||
match conn
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT c.id, c.document_id, d.path, c.content
|
||||
@@ -576,34 +573,27 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
params![vector_json, pre_limit, user_id, agent_id_str.as_deref()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::SearchFailed {
|
||||
reason: format!("Vector query failed: {}", e),
|
||||
})?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::SearchFailed {
|
||||
reason: format!("Vector row fetch failed: {}", e),
|
||||
})?
|
||||
{
|
||||
Ok(mut rows) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) =
|
||||
rows.next()
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::SearchFailed {
|
||||
reason: format!("Vector row fetch failed: {}", e),
|
||||
})?
|
||||
{
|
||||
results.push(RankedResult {
|
||||
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
|
||||
document_id: get_text(&row, 1).parse().unwrap_or_default(),
|
||||
document_path: get_text(&row, 2),
|
||||
content: get_text(&row, 3),
|
||||
rank: results.len() as u32 + 1,
|
||||
});
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"Vector index query failed (expected after V9 migration), \
|
||||
falling back to FTS-only: {e}"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
results.push(RankedResult {
|
||||
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
|
||||
document_id: get_text(&row, 1).parse().unwrap_or_default(),
|
||||
document_path: get_text(&row, 2),
|
||||
content: get_text(&row, 3),
|
||||
rank: results.len() as u32 + 1,
|
||||
});
|
||||
}
|
||||
results
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
+5
-137
@@ -2,9 +2,6 @@
|
||||
//!
|
||||
//! Consolidates all PostgreSQL migrations (V1-V8) into a single SQLite-compatible
|
||||
//! schema. Run once on database creation; idempotent via `IF NOT EXISTS`.
|
||||
//!
|
||||
//! Incremental migrations (V9+) are tracked in the `_migrations` table and run
|
||||
//! exactly once per database, in version order.
|
||||
|
||||
/// Consolidated schema for libSQL.
|
||||
///
|
||||
@@ -15,7 +12,7 @@
|
||||
/// - `BYTEA` -> `BLOB`
|
||||
/// - `NUMERIC` -> `TEXT` (preserve precision for rust_decimal)
|
||||
/// - `TEXT[]` -> `TEXT` (JSON array)
|
||||
/// - `VECTOR` -> `BLOB` (raw little-endian F32 bytes, any dimension)
|
||||
/// - `VECTOR(1536)` -> `F32_BLOB(1536)` (libsql native)
|
||||
/// - `TSVECTOR` -> FTS5 virtual table
|
||||
/// - `BIGSERIAL` -> `INTEGER PRIMARY KEY AUTOINCREMENT`
|
||||
/// - PL/pgSQL functions -> SQLite triggers
|
||||
@@ -224,16 +221,16 @@ CREATE TABLE IF NOT EXISTS memory_chunks (
|
||||
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
embedding F32_BLOB(1536),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (document_id, chunk_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
|
||||
|
||||
-- No vector index: BLOB column accepts any embedding dimension.
|
||||
-- Vector search uses brute-force cosine distance (fast enough for
|
||||
-- personal assistant workspaces). Matches PostgreSQL after V9 migration.
|
||||
-- Vector index for semantic search (libSQL native)
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding
|
||||
ON memory_chunks (libsql_vector_idx(embedding));
|
||||
|
||||
-- FTS5 virtual table for full-text search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
|
||||
@@ -569,132 +566,3 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
|
||||
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, datetime('now'));
|
||||
|
||||
"#;
|
||||
|
||||
/// Incremental migrations applied after the base schema.
|
||||
///
|
||||
/// Each entry is `(version, name, sql)`. Migrations are idempotent: the
|
||||
/// `_migrations` table tracks which versions have been applied.
|
||||
pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[(
|
||||
9,
|
||||
"flexible_embedding_dimension",
|
||||
// Rebuild memory_chunks to remove the fixed F32_BLOB(1536) type
|
||||
// constraint so any embedding dimension works. Existing embeddings
|
||||
// are preserved; users only need to re-embed if they change models.
|
||||
//
|
||||
// The vector index (libsql_vector_idx) requires a fixed-dimension
|
||||
// F32_BLOB(N), so we drop it entirely. Vector search falls back to
|
||||
// brute-force cosine distance which is fast enough for personal
|
||||
// assistant workspaces. This matches PostgreSQL after its V9 migration.
|
||||
//
|
||||
// SQLite cannot ALTER COLUMN types, so we recreate the table.
|
||||
r#"
|
||||
-- Drop vector index (requires fixed F32_BLOB(N), incompatible with flexible dimensions)
|
||||
DROP INDEX IF EXISTS idx_memory_chunks_embedding;
|
||||
|
||||
-- Drop FTS triggers that reference the old table
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_insert;
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_delete;
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_update;
|
||||
|
||||
-- Recreate table with flexible BLOB column (any embedding dimension)
|
||||
CREATE TABLE IF NOT EXISTS memory_chunks_new (
|
||||
_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (document_id, chunk_index)
|
||||
);
|
||||
|
||||
-- Copy all existing data (embeddings preserved as-is)
|
||||
INSERT OR IGNORE INTO memory_chunks_new (_rowid, id, document_id, chunk_index, content, embedding, created_at)
|
||||
SELECT _rowid, id, document_id, chunk_index, content, embedding, created_at FROM memory_chunks;
|
||||
|
||||
-- Swap tables
|
||||
DROP TABLE memory_chunks;
|
||||
ALTER TABLE memory_chunks_new RENAME TO memory_chunks;
|
||||
|
||||
-- Recreate indexes (no vector index — see comment above)
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
|
||||
|
||||
-- Recreate FTS triggers
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
||||
VALUES ('delete', old._rowid, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
||||
VALUES ('delete', old._rowid, old.content);
|
||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
||||
END;
|
||||
"#,
|
||||
)];
|
||||
|
||||
/// Run incremental migrations that haven't been applied yet.
|
||||
///
|
||||
/// Each migration is wrapped in a transaction. On success the version is
|
||||
/// recorded in `_migrations` so it won't run again.
|
||||
pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::error::DatabaseError> {
|
||||
use crate::error::DatabaseError;
|
||||
|
||||
for &(version, name, sql) in INCREMENTAL_MIGRATIONS {
|
||||
// Check if already applied
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT 1 FROM _migrations WHERE version = ?1",
|
||||
libsql::params![version],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to check migration {version}: {e}"))
|
||||
})?;
|
||||
|
||||
if rows.next().await.ok().flatten().is_some() {
|
||||
continue; // Already applied
|
||||
}
|
||||
|
||||
tracing::info!(version, name, "libSQL: applying incremental migration");
|
||||
|
||||
// Wrap migration + recording in a transaction for atomicity.
|
||||
// If the process crashes mid-migration, the transaction rolls back
|
||||
// and the migration will be retried on next startup.
|
||||
let tx = conn.transaction().await.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"libSQL migration V{version}: failed to start transaction: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
tx.execute_batch(sql).await.map_err(|e| {
|
||||
DatabaseError::Migration(format!("libSQL migration V{version} ({name}) failed: {e}"))
|
||||
})?;
|
||||
|
||||
// Record as applied (inside the same transaction)
|
||||
tx.execute(
|
||||
"INSERT INTO _migrations (version, name) VALUES (?1, ?2)",
|
||||
libsql::params![version, name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"Failed to record migration V{version} ({name}): {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"libSQL migration V{version} ({name}): commit failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::info!(version, name, "libSQL: migration applied successfully");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
//! Format-specific text extraction routines.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// Extract text from document bytes based on MIME type and optional filename.
|
||||
pub fn extract_text(data: &[u8], mime: &str, filename: Option<&str>) -> Result<String, String> {
|
||||
let base_mime = mime.split(';').next().unwrap_or(mime).trim();
|
||||
|
||||
match base_mime {
|
||||
// PDF
|
||||
"application/pdf" => extract_pdf(data),
|
||||
|
||||
// Office XML formats
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
|
||||
extract_docx(data)
|
||||
}
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => {
|
||||
extract_pptx(data)
|
||||
}
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => extract_xlsx(data),
|
||||
|
||||
// Legacy Office (best-effort: treat as binary, try text extraction)
|
||||
"application/msword" | "application/vnd.ms-powerpoint" | "application/vnd.ms-excel" => {
|
||||
// Legacy binary formats — try to extract any text strings
|
||||
extract_binary_strings(data)
|
||||
}
|
||||
|
||||
// Plain text family
|
||||
"text/plain"
|
||||
| "text/csv"
|
||||
| "text/tab-separated-values"
|
||||
| "text/markdown"
|
||||
| "text/html"
|
||||
| "text/xml"
|
||||
| "text/x-python"
|
||||
| "text/x-java"
|
||||
| "text/x-c"
|
||||
| "text/x-c++"
|
||||
| "text/x-rust"
|
||||
| "text/x-go"
|
||||
| "text/x-ruby"
|
||||
| "text/x-shellscript"
|
||||
| "text/javascript"
|
||||
| "text/css"
|
||||
| "text/x-toml"
|
||||
| "text/x-yaml"
|
||||
| "text/x-log" => extract_utf8(data),
|
||||
|
||||
// JSON / XML / YAML application types
|
||||
"application/json" | "application/xml" | "application/x-yaml" | "application/yaml"
|
||||
| "application/toml" | "application/x-sh" => extract_utf8(data),
|
||||
|
||||
// RTF
|
||||
"application/rtf" | "text/rtf" => extract_rtf(data),
|
||||
|
||||
// Fallback: try to infer from filename extension
|
||||
_ => {
|
||||
if let Some(text) = try_extract_by_extension(data, filename) {
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(format!("unsupported document type: {base_mime}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_pdf(data: &[u8]) -> Result<String, String> {
|
||||
pdf_extract::extract_text_from_mem(data)
|
||||
.map(|t| t.trim().to_string())
|
||||
.map_err(|e| format!("PDF extraction failed: {e}"))
|
||||
}
|
||||
|
||||
fn extract_docx(data: &[u8]) -> Result<String, String> {
|
||||
extract_office_xml(data, "word/document.xml")
|
||||
}
|
||||
|
||||
fn extract_pptx(data: &[u8]) -> Result<String, String> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("invalid PPTX archive: {e}"))?;
|
||||
|
||||
// Collect slide filenames (ppt/slides/slide1.xml, slide2.xml, ...)
|
||||
let mut slide_names: Vec<String> = Vec::new();
|
||||
for i in 0..archive.len() {
|
||||
if let Ok(file) = archive.by_index(i) {
|
||||
let name = file.name().to_string();
|
||||
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
slide_names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
slide_names.sort();
|
||||
|
||||
let mut all_text = Vec::new();
|
||||
for name in &slide_names {
|
||||
if let Ok(mut file) = archive.by_name(name) {
|
||||
let mut xml = String::new();
|
||||
if file.read_to_string(&mut xml).is_ok() {
|
||||
let text = strip_xml_tags(&xml);
|
||||
if !text.is_empty() {
|
||||
all_text.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_text.is_empty() {
|
||||
return Err("no text found in PPTX slides".to_string());
|
||||
}
|
||||
Ok(all_text.join("\n\n---\n\n"))
|
||||
}
|
||||
|
||||
fn extract_xlsx(data: &[u8]) -> Result<String, String> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("invalid XLSX archive: {e}"))?;
|
||||
|
||||
// Read shared strings (xl/sharedStrings.xml)
|
||||
let shared_strings = if let Ok(mut file) = archive.by_name("xl/sharedStrings.xml") {
|
||||
let mut xml = String::new();
|
||||
file.read_to_string(&mut xml)
|
||||
.map_err(|e| format!("failed to read shared strings: {e}"))?;
|
||||
parse_xlsx_shared_strings(&xml)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Read sheet data
|
||||
let mut sheet_names: Vec<String> = Vec::new();
|
||||
for i in 0..archive.len() {
|
||||
if let Ok(file) = archive.by_index(i) {
|
||||
let name = file.name().to_string();
|
||||
if name.starts_with("xl/worksheets/sheet") && name.ends_with(".xml") {
|
||||
sheet_names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
sheet_names.sort();
|
||||
|
||||
let mut all_text = Vec::new();
|
||||
for name in &sheet_names {
|
||||
if let Ok(mut file) = archive.by_name(name) {
|
||||
let mut xml = String::new();
|
||||
if file.read_to_string(&mut xml).is_ok() {
|
||||
let text = parse_xlsx_sheet(&xml, &shared_strings);
|
||||
if !text.is_empty() {
|
||||
all_text.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_text.is_empty() && !shared_strings.is_empty() {
|
||||
// Fallback: just return shared strings
|
||||
return Ok(shared_strings.join("\n"));
|
||||
}
|
||||
|
||||
if all_text.is_empty() {
|
||||
return Err("no text found in XLSX".to_string());
|
||||
}
|
||||
Ok(all_text.join("\n\n"))
|
||||
}
|
||||
|
||||
fn extract_office_xml(data: &[u8], content_path: &str) -> Result<String, String> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("invalid Office XML archive: {e}"))?;
|
||||
|
||||
let mut file = archive
|
||||
.by_name(content_path)
|
||||
.map_err(|e| format!("content file not found in archive: {e}"))?;
|
||||
|
||||
let mut xml = String::new();
|
||||
file.read_to_string(&mut xml)
|
||||
.map_err(|e| format!("failed to read content: {e}"))?;
|
||||
|
||||
let text = strip_xml_tags(&xml);
|
||||
if text.is_empty() {
|
||||
return Err("no text content found".to_string());
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn extract_utf8(data: &[u8]) -> Result<String, String> {
|
||||
// Try UTF-8 first, fall back to lossy decoding
|
||||
match std::str::from_utf8(data) {
|
||||
Ok(s) => Ok(s.to_string()),
|
||||
Err(_) => Ok(String::from_utf8_lossy(data).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_rtf(data: &[u8]) -> Result<String, String> {
|
||||
// Basic RTF text extraction: strip control words and groups
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut result = String::new();
|
||||
let mut depth = 0i32;
|
||||
let mut chars = text.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'{' => depth += 1,
|
||||
'}' => depth = (depth - 1).max(0),
|
||||
'\\' => {
|
||||
// Skip control word
|
||||
let mut word = String::new();
|
||||
while let Some(&next) = chars.peek() {
|
||||
if next.is_ascii_alphabetic() {
|
||||
word.push(chars.next().unwrap());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Skip optional numeric parameter
|
||||
while let Some(&next) = chars.peek() {
|
||||
if next.is_ascii_digit() || next == '-' {
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Consume trailing space
|
||||
if let Some(&' ') = chars.peek() {
|
||||
chars.next();
|
||||
}
|
||||
// Convert common control words to text
|
||||
match word.as_str() {
|
||||
"par" | "line" => result.push('\n'),
|
||||
"tab" => result.push('\t'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if depth <= 1 {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = result.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err("no text found in RTF".to_string());
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn extract_binary_strings(data: &[u8]) -> Result<String, String> {
|
||||
// Extract printable ASCII/UTF-8 runs from binary data (last resort)
|
||||
let mut strings = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for &byte in data {
|
||||
if (0x20..0x7F).contains(&byte) {
|
||||
current.push(byte as char);
|
||||
} else {
|
||||
if current.len() >= 4 {
|
||||
strings.push(std::mem::take(&mut current));
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() >= 4 {
|
||||
strings.push(current);
|
||||
}
|
||||
|
||||
if strings.is_empty() {
|
||||
return Err("no readable text in binary document".to_string());
|
||||
}
|
||||
Ok(strings.join(" "))
|
||||
}
|
||||
|
||||
/// Strip XML tags and return just the text content.
|
||||
fn strip_xml_tags(xml: &str) -> String {
|
||||
let mut result = String::with_capacity(xml.len() / 2);
|
||||
let mut in_tag = false;
|
||||
let mut last_was_space = true;
|
||||
|
||||
for ch in xml.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
}
|
||||
'>' => {
|
||||
in_tag = false;
|
||||
// Add space between tag-delimited text runs
|
||||
if !last_was_space && !result.is_empty() {
|
||||
result.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
}
|
||||
_ if !in_tag => {
|
||||
if ch.is_whitespace() {
|
||||
if !last_was_space {
|
||||
result.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
} else {
|
||||
result.push(ch);
|
||||
last_was_space = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode common XML entities
|
||||
result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse XLSX shared strings XML into a Vec of strings.
|
||||
fn parse_xlsx_shared_strings(xml: &str) -> Vec<String> {
|
||||
// Shared strings are in <si><t>text</t></si> elements
|
||||
let mut strings = Vec::new();
|
||||
let mut in_t = false;
|
||||
let mut current = String::new();
|
||||
let mut in_tag = false;
|
||||
let mut tag_name = String::new();
|
||||
|
||||
for ch in xml.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
tag_name.clear();
|
||||
}
|
||||
'>' => {
|
||||
in_tag = false;
|
||||
let tag = tag_name.trim().to_string();
|
||||
if tag == "t" || tag.starts_with("t ") {
|
||||
in_t = true;
|
||||
current.clear();
|
||||
} else if tag == "/t" {
|
||||
in_t = false;
|
||||
strings.push(std::mem::take(&mut current));
|
||||
} else if tag == "/si" {
|
||||
in_t = false;
|
||||
}
|
||||
}
|
||||
_ if in_tag => {
|
||||
tag_name.push(ch);
|
||||
}
|
||||
_ if in_t => {
|
||||
current.push(ch);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
strings
|
||||
}
|
||||
|
||||
/// Parse XLSX sheet XML into tab-separated rows.
|
||||
fn parse_xlsx_sheet(xml: &str, shared_strings: &[String]) -> String {
|
||||
// Simple extraction: find <v> values in <c> cells, resolve shared string refs
|
||||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut current_row: Vec<String> = Vec::new();
|
||||
let mut in_v = false;
|
||||
let mut in_row = false;
|
||||
let mut current_val = String::new();
|
||||
let mut cell_type = String::new();
|
||||
let mut in_tag = false;
|
||||
let mut tag_buf = String::new();
|
||||
|
||||
for ch in xml.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
tag_buf.clear();
|
||||
}
|
||||
'>' => {
|
||||
in_tag = false;
|
||||
let tag = tag_buf.trim().to_string();
|
||||
if tag == "row" || tag.starts_with("row ") {
|
||||
in_row = true;
|
||||
current_row.clear();
|
||||
} else if tag == "/row" {
|
||||
in_row = false;
|
||||
if !current_row.is_empty() {
|
||||
rows.push(std::mem::take(&mut current_row));
|
||||
}
|
||||
} else if in_row && (tag.starts_with("c ") || tag == "c") {
|
||||
// Extract type attribute: t="s" means shared string
|
||||
cell_type.clear();
|
||||
if let Some(t_pos) = tag.find("t=\"") {
|
||||
let rest = &tag[t_pos + 3..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
cell_type = rest[..end].to_string();
|
||||
}
|
||||
}
|
||||
} else if tag == "v" || tag.starts_with("v ") {
|
||||
in_v = true;
|
||||
current_val.clear();
|
||||
} else if tag == "/v" {
|
||||
in_v = false;
|
||||
let val = if cell_type == "s" {
|
||||
// Shared string reference
|
||||
current_val
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|idx| shared_strings.get(idx))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
current_val.clone()
|
||||
};
|
||||
current_row.push(val);
|
||||
} else if tag == "/c" {
|
||||
cell_type.clear();
|
||||
}
|
||||
}
|
||||
_ if in_tag => {
|
||||
tag_buf.push(ch);
|
||||
}
|
||||
_ if in_v => {
|
||||
current_val.push(ch);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
rows.iter()
|
||||
.map(|row| row.join("\t"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Try to extract text based on filename extension when MIME type is generic.
|
||||
fn try_extract_by_extension(data: &[u8], filename: Option<&str>) -> Option<String> {
|
||||
let ext = filename?.rsplit('.').next()?.to_lowercase();
|
||||
|
||||
match ext.as_str() {
|
||||
"pdf" => extract_pdf(data).ok(),
|
||||
"docx" => extract_docx(data).ok(),
|
||||
"pptx" => extract_pptx(data).ok(),
|
||||
"xlsx" => extract_xlsx(data).ok(),
|
||||
"doc" | "ppt" | "xls" => extract_binary_strings(data).ok(),
|
||||
"rtf" => extract_rtf(data).ok(),
|
||||
"txt" | "csv" | "tsv" | "json" | "xml" | "yaml" | "yml" | "toml" | "md" | "markdown"
|
||||
| "py" | "js" | "ts" | "rs" | "go" | "java" | "c" | "cpp" | "h" | "hpp" | "rb" | "sh"
|
||||
| "bash" | "zsh" | "fish" | "css" | "html" | "htm" | "sql" | "log" | "ini" | "cfg"
|
||||
| "conf" | "env" | "gitignore" | "dockerfile" => extract_utf8(data).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_xml_basic() {
|
||||
let xml = "<root><p>Hello</p><p>World</p></root>";
|
||||
assert_eq!(strip_xml_tags(xml), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_xml_entities() {
|
||||
let xml = "<t>A & B < C</t>";
|
||||
assert_eq!(strip_xml_tags(xml), "A & B < C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_utf8_valid() {
|
||||
assert_eq!(extract_utf8(b"hello").unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_utf8_lossy() {
|
||||
let data = b"hello \xff world";
|
||||
let result = extract_utf8(data).unwrap();
|
||||
assert!(result.contains("hello"));
|
||||
assert!(result.contains("world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_by_extension_txt() {
|
||||
let result = try_extract_by_extension(b"content", Some("notes.txt"));
|
||||
assert_eq!(result, Some("content".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_by_extension_unknown() {
|
||||
let result = try_extract_by_extension(b"data", Some("file.xyz"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_by_extension_no_filename() {
|
||||
let result = try_extract_by_extension(b"data", None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_basic_extraction() {
|
||||
let rtf = br"{\rtf1\ansi Hello World\par Second line}";
|
||||
let result = extract_rtf(rtf).unwrap();
|
||||
assert!(result.contains("Hello World"));
|
||||
assert!(result.contains("Second line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_shared_strings_parsing() {
|
||||
let xml = r#"<sst><si><t>Name</t></si><si><t>Age</t></si></sst>"#;
|
||||
let strings = parse_xlsx_shared_strings(xml);
|
||||
assert_eq!(strings, vec!["Name", "Age"]);
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
//! Document text extraction pipeline.
|
||||
//!
|
||||
//! Provides a [`DocumentExtractionMiddleware`] that detects document attachments
|
||||
//! on incoming messages and extracts text content so the LLM can reason about them.
|
||||
//!
|
||||
//! Supported formats:
|
||||
//! - **PDF** — via `pdf-extract`
|
||||
//! - **Office XML** (DOCX, PPTX, XLSX) — ZIP + XML text extraction
|
||||
//! - **Plain text** (TXT, CSV, JSON, XML, Markdown, code) — UTF-8 decode
|
||||
|
||||
mod extractors;
|
||||
|
||||
use crate::channels::{AttachmentKind, IncomingMessage};
|
||||
|
||||
/// Maximum document size to extract (10 MB).
|
||||
const MAX_DOCUMENT_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Maximum extracted text length to keep (100K chars ≈ ~25K tokens).
|
||||
const MAX_EXTRACTED_TEXT_LEN: usize = 100_000;
|
||||
|
||||
/// Middleware that processes document attachments on incoming messages.
|
||||
///
|
||||
/// For each document attachment with inline data, attempts to:
|
||||
/// 1. Extract text based on MIME type
|
||||
/// 2. Set `extracted_text` on the attachment
|
||||
///
|
||||
/// Downloading from `source_url` is intentionally not supported to prevent SSRF.
|
||||
/// Channels must populate `attachment.data` via `store_attachment_data`.
|
||||
#[derive(Default)]
|
||||
pub struct DocumentExtractionMiddleware;
|
||||
|
||||
impl DocumentExtractionMiddleware {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Process an incoming message, extracting text from document attachments.
|
||||
pub async fn process(&self, msg: &mut IncomingMessage) {
|
||||
let mut extractions = Vec::new();
|
||||
|
||||
for (i, attachment) in msg.attachments.iter().enumerate() {
|
||||
if attachment.kind != AttachmentKind::Document {
|
||||
continue;
|
||||
}
|
||||
if attachment.extracted_text.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if too large
|
||||
if let Some(size) = attachment.size_bytes.filter(|&s| s > MAX_DOCUMENT_SIZE) {
|
||||
tracing::warn!(
|
||||
attachment_id = %attachment.id,
|
||||
size,
|
||||
"Document too large for extraction, skipping"
|
||||
);
|
||||
let mb = size as f64 / (1024.0 * 1024.0);
|
||||
let max_mb = MAX_DOCUMENT_SIZE as f64 / (1024.0 * 1024.0);
|
||||
extractions.push((
|
||||
i,
|
||||
format!(
|
||||
"[Document too large for text extraction: {mb:.1} MB exceeds {max_mb:.0} MB limit. \
|
||||
Please send a smaller file or copy-paste the relevant text.]"
|
||||
),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use inline data only — downloading from source_url is intentionally
|
||||
// not supported to prevent SSRF. Channels must populate attachment.data
|
||||
// via store_attachment_data before emitting the message.
|
||||
if attachment.data.is_empty() {
|
||||
extractions.push((
|
||||
i,
|
||||
"[Document has no inline data. \
|
||||
Please try sending the file again.]"
|
||||
.to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Enforce size limit before cloning to avoid unnecessary allocation
|
||||
if attachment.data.len() as u64 > MAX_DOCUMENT_SIZE {
|
||||
let mb = attachment.data.len() as f64 / (1024.0 * 1024.0);
|
||||
let max_mb = MAX_DOCUMENT_SIZE as f64 / (1024.0 * 1024.0);
|
||||
extractions.push((
|
||||
i,
|
||||
format!(
|
||||
"[Document too large for text extraction: {mb:.1} MB exceeds {max_mb:.0} MB limit. \
|
||||
Please send a smaller file or copy-paste the relevant text.]"
|
||||
),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = attachment.data.clone();
|
||||
|
||||
let mime = &attachment.mime_type;
|
||||
let filename = attachment.filename.as_deref();
|
||||
match extractors::extract_text(&data, mime, filename) {
|
||||
Ok(text) => {
|
||||
// Truncate at a char boundary to avoid panicking on multi-byte UTF-8
|
||||
let text = if text.len() > MAX_EXTRACTED_TEXT_LEN {
|
||||
let boundary = text
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= MAX_EXTRACTED_TEXT_LEN)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let mut truncated = text[..boundary].to_string();
|
||||
truncated.push_str("\n\n[... truncated, document too long ...]");
|
||||
truncated
|
||||
} else {
|
||||
text
|
||||
};
|
||||
tracing::info!(
|
||||
attachment_id = %attachment.id,
|
||||
mime_type = %mime,
|
||||
text_len = text.len(),
|
||||
"Extracted text from document"
|
||||
);
|
||||
extractions.push((i, text));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
attachment_id = %attachment.id,
|
||||
mime_type = %mime,
|
||||
error = %e,
|
||||
"Failed to extract text from document"
|
||||
);
|
||||
let name = filename.unwrap_or("document");
|
||||
extractions.push((
|
||||
i,
|
||||
format!(
|
||||
"[Failed to extract text from '{name}' ({mime}): {e}. \
|
||||
The file format may not be supported.]"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i, text) in extractions {
|
||||
msg.attachments[i].extracted_text = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::IncomingAttachment;
|
||||
|
||||
fn doc_attachment(mime: &str, filename: &str, data: Vec<u8>) -> IncomingAttachment {
|
||||
IncomingAttachment {
|
||||
id: "doc_1".to_string(),
|
||||
kind: AttachmentKind::Document,
|
||||
mime_type: mime.to_string(),
|
||||
filename: Some(filename.to_string()),
|
||||
size_bytes: Some(data.len() as u64),
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
data,
|
||||
duration_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_plain_text() {
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let mut msg = IncomingMessage::new("test", "user1", "check this").with_attachments(vec![
|
||||
doc_attachment("text/plain", "notes.txt", b"Hello world".to_vec()),
|
||||
]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
assert_eq!(
|
||||
msg.attachments[0].extracted_text.as_deref(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_csv() {
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let mut msg = IncomingMessage::new("test", "user1", "analyze").with_attachments(vec![
|
||||
doc_attachment("text/csv", "data.csv", b"name,age\nAlice,30".to_vec()),
|
||||
]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
assert_eq!(
|
||||
msg.attachments[0].extracted_text.as_deref(),
|
||||
Some("name,age\nAlice,30")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_json() {
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let data = br#"{"key": "value"}"#.to_vec();
|
||||
let mut msg = IncomingMessage::new("test", "user1", "parse")
|
||||
.with_attachments(vec![doc_attachment("application/json", "data.json", data)]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
assert!(msg.attachments[0].extracted_text.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_already_extracted() {
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let mut att = doc_attachment("text/plain", "test.txt", b"data".to_vec());
|
||||
att.extracted_text = Some("Already done".to_string());
|
||||
let mut msg = IncomingMessage::new("test", "user1", "").with_attachments(vec![att]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
assert_eq!(
|
||||
msg.attachments[0].extracted_text.as_deref(),
|
||||
Some("Already done")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_audio_attachments() {
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let mut att = doc_attachment("text/plain", "test.txt", b"data".to_vec());
|
||||
att.kind = AttachmentKind::Audio;
|
||||
let mut msg = IncomingMessage::new("test", "user1", "").with_attachments(vec![att]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
assert!(msg.attachments[0].extracted_text.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_oversized_documents() {
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let mut att = doc_attachment("text/plain", "huge.txt", vec![]);
|
||||
att.size_bytes = Some(MAX_DOCUMENT_SIZE + 1);
|
||||
let mut msg = IncomingMessage::new("test", "user1", "").with_attachments(vec![att]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
let text = msg.attachments[0].extracted_text.as_deref().unwrap();
|
||||
assert!(
|
||||
text.contains("too large"),
|
||||
"Expected 'too large' error, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncates_long_text() {
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let long_text = "x".repeat(MAX_EXTRACTED_TEXT_LEN + 1000);
|
||||
let mut msg =
|
||||
IncomingMessage::new("test", "user1", "read").with_attachments(vec![doc_attachment(
|
||||
"text/plain",
|
||||
"long.txt",
|
||||
long_text.into_bytes(),
|
||||
)]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
let extracted = msg.attachments[0].extracted_text.as_ref().unwrap();
|
||||
assert!(extracted.len() < MAX_EXTRACTED_TEXT_LEN + 100);
|
||||
assert!(extracted.ends_with("[... truncated, document too long ...]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_pdf_text() {
|
||||
// Minimal valid PDF with text "Hello World"
|
||||
let pdf_bytes = include_bytes!("../../tests/fixtures/hello.pdf");
|
||||
let middleware = DocumentExtractionMiddleware::new();
|
||||
let mut msg =
|
||||
IncomingMessage::new("test", "user1", "review").with_attachments(vec![doc_attachment(
|
||||
"application/pdf",
|
||||
"hello.pdf",
|
||||
pdf_bytes.to_vec(),
|
||||
)]);
|
||||
|
||||
middleware.process(&mut msg).await;
|
||||
let text = msg.attachments[0].extracted_text.as_deref().unwrap_or("");
|
||||
assert!(
|
||||
text.contains("Hello"),
|
||||
"PDF extraction should contain 'Hello', got: {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -238,220 +238,4 @@ mod tests {
|
||||
let rate = collector.success_rate();
|
||||
assert!((rate - 0.666).abs() < 0.01);
|
||||
}
|
||||
|
||||
// --- QualityMetrics default ---
|
||||
|
||||
#[test]
|
||||
fn test_quality_metrics_default() {
|
||||
let m = QualityMetrics::default();
|
||||
assert_eq!(m.total_actions, 0);
|
||||
assert_eq!(m.successful_actions, 0);
|
||||
assert_eq!(m.failed_actions, 0);
|
||||
assert_eq!(m.total_time, Duration::ZERO);
|
||||
assert_eq!(m.total_cost, Decimal::ZERO);
|
||||
assert!(m.tool_metrics.is_empty());
|
||||
assert!(m.error_types.is_empty());
|
||||
}
|
||||
|
||||
// --- ToolMetrics::success_rate ---
|
||||
|
||||
#[test]
|
||||
fn test_tool_metrics_success_rate_zero_calls() {
|
||||
let tm = ToolMetrics::default();
|
||||
assert_eq!(tm.success_rate(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_metrics_success_rate_mixed() {
|
||||
let tm = ToolMetrics {
|
||||
calls: 4,
|
||||
successes: 3,
|
||||
failures: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!((tm.success_rate() - 0.75).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_metrics_success_rate_all_failures() {
|
||||
let tm = ToolMetrics {
|
||||
calls: 5,
|
||||
successes: 0,
|
||||
failures: 5,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(tm.success_rate(), 0.0);
|
||||
}
|
||||
|
||||
// --- MetricsCollector ---
|
||||
|
||||
#[test]
|
||||
fn test_collector_default_is_new() {
|
||||
let a = MetricsCollector::new();
|
||||
let b = MetricsCollector::default();
|
||||
assert_eq!(a.metrics().total_actions, b.metrics().total_actions);
|
||||
assert_eq!(a.success_rate(), b.success_rate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_success_rate_empty_collector() {
|
||||
let collector = MetricsCollector::new();
|
||||
assert_eq!(collector.success_rate(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_success_accumulates_cost() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_success("a", Duration::from_millis(100), Some(dec!(1.50)));
|
||||
c.record_success("a", Duration::from_millis(200), Some(dec!(2.50)));
|
||||
assert_eq!(c.metrics().total_cost, dec!(4.00));
|
||||
let tool = c.tool_metrics("a").unwrap();
|
||||
assert_eq!(tool.total_cost, dec!(4.00));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_success_none_cost_does_not_change_total() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_success("x", Duration::from_secs(1), Some(dec!(1.00)));
|
||||
c.record_success("x", Duration::from_secs(1), None);
|
||||
assert_eq!(c.metrics().total_cost, dec!(1.00));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_failure_does_not_add_cost() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_failure("t", "oops", Duration::from_secs(1));
|
||||
assert_eq!(c.metrics().total_cost, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_avg_time_updates() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_success("t", Duration::from_secs(2), None);
|
||||
c.record_success("t", Duration::from_secs(4), None);
|
||||
let tool = c.tool_metrics("t").unwrap();
|
||||
// total 6s / 2 calls = 3s avg
|
||||
assert_eq!(tool.avg_time, Duration::from_secs(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_time_across_success_and_failure() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_success("a", Duration::from_secs(3), None);
|
||||
c.record_failure("b", "err", Duration::from_secs(7));
|
||||
assert_eq!(c.metrics().total_time, Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_metrics_returns_none_for_unknown() {
|
||||
let c = MetricsCollector::new();
|
||||
assert!(c.tool_metrics("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_clears_everything() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_success("t", Duration::from_secs(1), Some(dec!(5.00)));
|
||||
c.record_failure("t", "error", Duration::from_secs(1));
|
||||
c.reset();
|
||||
assert_eq!(c.metrics().total_actions, 0);
|
||||
assert_eq!(c.metrics().successful_actions, 0);
|
||||
assert_eq!(c.metrics().failed_actions, 0);
|
||||
assert_eq!(c.metrics().total_cost, Decimal::ZERO);
|
||||
assert!(c.metrics().tool_metrics.is_empty());
|
||||
assert!(c.metrics().error_types.is_empty());
|
||||
assert_eq!(c.success_rate(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_tools_tracked_independently() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_success("alpha", Duration::from_secs(1), None);
|
||||
c.record_success("alpha", Duration::from_secs(1), None);
|
||||
c.record_failure("beta", "bad", Duration::from_secs(1));
|
||||
c.record_success("beta", Duration::from_secs(1), None);
|
||||
|
||||
let alpha = c.tool_metrics("alpha").unwrap();
|
||||
assert_eq!(alpha.calls, 2);
|
||||
assert_eq!(alpha.successes, 2);
|
||||
assert_eq!(alpha.failures, 0);
|
||||
|
||||
let beta = c.tool_metrics("beta").unwrap();
|
||||
assert_eq!(beta.calls, 2);
|
||||
assert_eq!(beta.successes, 1);
|
||||
assert_eq!(beta.failures, 1);
|
||||
}
|
||||
|
||||
// --- categorize_error ---
|
||||
|
||||
#[test]
|
||||
fn test_categorize_error_all_types() {
|
||||
assert_eq!(categorize_error("Connection timeout"), "timeout");
|
||||
assert_eq!(categorize_error("TIMEOUT exceeded"), "timeout");
|
||||
assert_eq!(categorize_error("rate limit hit"), "rate_limit");
|
||||
assert_eq!(categorize_error("Rate Limit 429"), "rate_limit");
|
||||
assert_eq!(categorize_error("auth failure"), "auth");
|
||||
assert_eq!(categorize_error("Unauthorized"), "auth");
|
||||
assert_eq!(categorize_error("resource not found"), "not_found");
|
||||
assert_eq!(categorize_error("HTTP 404"), "not_found");
|
||||
assert_eq!(categorize_error("invalid parameter X"), "invalid_input");
|
||||
assert_eq!(categorize_error("bad parameter"), "invalid_input");
|
||||
assert_eq!(categorize_error("Invalid JSON"), "invalid_input");
|
||||
assert_eq!(categorize_error("network error"), "network");
|
||||
assert_eq!(categorize_error("connection refused"), "network");
|
||||
assert_eq!(categorize_error("something else entirely"), "unknown");
|
||||
assert_eq!(categorize_error(""), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_types_accumulated_in_collector() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_failure("t", "timeout!", Duration::from_secs(1));
|
||||
c.record_failure("t", "another timeout", Duration::from_secs(1));
|
||||
c.record_failure("t", "auth denied", Duration::from_secs(1));
|
||||
|
||||
assert_eq!(c.metrics().error_types.get("timeout"), Some(&2));
|
||||
assert_eq!(c.metrics().error_types.get("auth"), Some(&1));
|
||||
}
|
||||
|
||||
// --- MetricsSummary ---
|
||||
|
||||
#[test]
|
||||
fn test_summary_empty_collector() {
|
||||
let c = MetricsCollector::new();
|
||||
let s = c.summary();
|
||||
assert_eq!(s.total_actions, 0);
|
||||
assert_eq!(s.success_rate, 0.0);
|
||||
assert_eq!(s.total_cost, Decimal::ZERO);
|
||||
assert!(s.most_used_tool.is_none());
|
||||
assert!(s.most_failed_tool.is_none());
|
||||
assert!(s.top_errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_most_used_and_most_failed() {
|
||||
let mut c = MetricsCollector::new();
|
||||
// "alpha" gets 3 calls (all success)
|
||||
c.record_success("alpha", Duration::from_secs(1), None);
|
||||
c.record_success("alpha", Duration::from_secs(1), None);
|
||||
c.record_success("alpha", Duration::from_secs(1), None);
|
||||
// "beta" gets 2 calls (both failures)
|
||||
c.record_failure("beta", "err", Duration::from_secs(1));
|
||||
c.record_failure("beta", "err", Duration::from_secs(1));
|
||||
|
||||
let s = c.summary();
|
||||
assert_eq!(s.most_used_tool.as_deref(), Some("alpha"));
|
||||
assert_eq!(s.most_failed_tool.as_deref(), Some("beta"));
|
||||
assert_eq!(s.total_actions, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_top_errors_populated() {
|
||||
let mut c = MetricsCollector::new();
|
||||
c.record_failure("t", "timeout", Duration::from_secs(1));
|
||||
c.record_failure("t", "auth error", Duration::from_secs(1));
|
||||
let s = c.summary();
|
||||
assert!(!s.top_errors.is_empty());
|
||||
assert!(s.top_errors.len() <= 3);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-254
@@ -331,10 +331,6 @@ mod tests {
|
||||
}
|
||||
|
||||
fn create_action(success: bool) -> ActionRecord {
|
||||
create_action_with_error(success, "Test error")
|
||||
}
|
||||
|
||||
fn create_action_with_error(success: bool, error_msg: &str) -> ActionRecord {
|
||||
let mut action = ActionRecord::new(0, "test", serde_json::json!({}));
|
||||
if success {
|
||||
action = action.succeed(
|
||||
@@ -343,257 +339,8 @@ mod tests {
|
||||
std::time::Duration::from_secs(1),
|
||||
);
|
||||
} else {
|
||||
action = action.fail(error_msg, std::time::Duration::from_secs(1));
|
||||
action = action.fail("Test error", std::time::Duration::from_secs(1));
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
fn completed_job(title: &str) -> JobContext {
|
||||
let mut job = JobContext::new(title, "test job");
|
||||
job.transition_to(crate::context::JobState::InProgress, None)
|
||||
.unwrap();
|
||||
job.transition_to(crate::context::JobState::Completed, None)
|
||||
.unwrap();
|
||||
job
|
||||
}
|
||||
|
||||
// --- EvaluationResult construction ---
|
||||
|
||||
#[test]
|
||||
fn test_evaluation_result_success_defaults() {
|
||||
let result = EvaluationResult::success("all good", 85);
|
||||
assert!(result.success);
|
||||
assert_eq!(result.confidence, 0.9);
|
||||
assert_eq!(result.reasoning, "all good");
|
||||
assert!(result.issues.is_empty());
|
||||
assert!(result.suggestions.is_empty());
|
||||
assert_eq!(result.quality_score, 85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluation_result_failure_defaults() {
|
||||
let issues = vec!["bad thing".to_string(), "worse thing".to_string()];
|
||||
let result = EvaluationResult::failure("went wrong", issues.clone());
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.confidence, 0.9);
|
||||
assert_eq!(result.reasoning, "went wrong");
|
||||
assert_eq!(result.issues, issues);
|
||||
assert_eq!(result.quality_score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluation_result_serde_roundtrip() {
|
||||
let result = EvaluationResult {
|
||||
success: true,
|
||||
confidence: 0.75,
|
||||
reasoning: "looks fine".to_string(),
|
||||
issues: vec!["minor".to_string()],
|
||||
suggestions: vec!["try harder".to_string()],
|
||||
quality_score: 60,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
let deserialized: EvaluationResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.success, result.success);
|
||||
assert_eq!(deserialized.confidence, result.confidence);
|
||||
assert_eq!(deserialized.reasoning, result.reasoning);
|
||||
assert_eq!(deserialized.issues, result.issues);
|
||||
assert_eq!(deserialized.suggestions, result.suggestions);
|
||||
assert_eq!(deserialized.quality_score, result.quality_score);
|
||||
}
|
||||
|
||||
// --- RuleBasedEvaluator builder ---
|
||||
|
||||
#[test]
|
||||
fn test_rule_based_evaluator_default() {
|
||||
let eval = RuleBasedEvaluator::default();
|
||||
assert_eq!(eval.min_action_success_rate, 0.8);
|
||||
assert_eq!(eval.max_failures, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rule_based_evaluator_builder_methods() {
|
||||
let eval = RuleBasedEvaluator::new()
|
||||
.with_min_success_rate(0.5)
|
||||
.with_max_failures(10);
|
||||
assert_eq!(eval.min_action_success_rate, 0.5);
|
||||
assert_eq!(eval.max_failures, 10);
|
||||
}
|
||||
|
||||
// --- RuleBasedEvaluator::evaluate edge cases ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_actions_fails() {
|
||||
let eval = RuleBasedEvaluator::new();
|
||||
let job = completed_job("empty");
|
||||
let result = eval.evaluate(&job, &[], None).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.issues.iter().any(|i| i.contains("No actions")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_actions_succeed_completed_job_gets_100() {
|
||||
let eval = RuleBasedEvaluator::new();
|
||||
let job = completed_job("perfect");
|
||||
let actions = vec![
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(result.success);
|
||||
// 100% success rate -> base 80, completion bonus 20 -> 100
|
||||
assert_eq!(result.quality_score, 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_quality_score_no_completion_bonus_for_pending_job() {
|
||||
// Even if all actions succeed, a non-completed job gets flagged
|
||||
let eval = RuleBasedEvaluator::new();
|
||||
let job = JobContext::new("pending", "still pending");
|
||||
let actions = vec![create_action(true)];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
// Job not in completed state => issues present
|
||||
assert!(!result.success);
|
||||
assert!(
|
||||
result
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.contains("not in completed state"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submitted_state_counts_as_completed() {
|
||||
let eval = RuleBasedEvaluator::new();
|
||||
let mut job = JobContext::new("submitted", "test");
|
||||
job.transition_to(crate::context::JobState::InProgress, None)
|
||||
.unwrap();
|
||||
job.transition_to(crate::context::JobState::Completed, None)
|
||||
.unwrap();
|
||||
job.transition_to(crate::context::JobState::Submitted, None)
|
||||
.unwrap();
|
||||
let actions = vec![create_action(true)];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
// Submitted is treated like completed for state check (no issue),
|
||||
// but completion bonus only applies for Completed state
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_success_rate_below_threshold_fails() {
|
||||
let eval = RuleBasedEvaluator::new().with_min_success_rate(0.9);
|
||||
let job = completed_job("threshold");
|
||||
// 4 out of 5 = 80%, below 90% threshold
|
||||
let actions = vec![
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(false),
|
||||
];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(
|
||||
result
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.contains("success rate") && i.contains("below threshold"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_too_many_failures_flagged() {
|
||||
let eval = RuleBasedEvaluator::new().with_max_failures(1);
|
||||
let job = completed_job("failures");
|
||||
// 8 successes, 2 failures: rate is 80% (passes default 0.8) but failures > max 1
|
||||
let actions = vec![
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(false),
|
||||
create_action(false),
|
||||
];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(
|
||||
result
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.contains("Too many failures"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_critical_error_detected() {
|
||||
let eval = RuleBasedEvaluator::new().with_max_failures(10);
|
||||
let job = completed_job("critical");
|
||||
let actions = vec![
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action_with_error(false, "A CRITICAL system failure occurred"),
|
||||
];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.issues.iter().any(|i| i.contains("Critical error")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fatal_error_detected() {
|
||||
let eval = RuleBasedEvaluator::new().with_max_failures(10);
|
||||
let job = completed_job("fatal");
|
||||
let actions = vec![
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action(true),
|
||||
create_action_with_error(false, "Fatal: disk full"),
|
||||
];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(result.issues.iter().any(|i| i.contains("Critical error")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_quality_score_capped_at_50_with_issues() {
|
||||
let eval = RuleBasedEvaluator::new()
|
||||
.with_min_success_rate(0.0)
|
||||
.with_max_failures(100);
|
||||
// Job not completed => issues present, quality capped
|
||||
let job = JobContext::new("capped", "test");
|
||||
let actions = vec![create_action(true)];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.quality_score <= 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_failed_result_includes_suggestions() {
|
||||
let eval = RuleBasedEvaluator::new().with_max_failures(0);
|
||||
let job = completed_job("suggestions");
|
||||
let actions = vec![create_action(false)];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(!result.suggestions.is_empty());
|
||||
assert_eq!(result.confidence, 0.85);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_single_successful_action_completed_job() {
|
||||
let eval = RuleBasedEvaluator::new();
|
||||
let job = completed_job("single");
|
||||
let actions = vec![create_action(true)];
|
||||
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
||||
assert!(result.success);
|
||||
// 100% rate -> base 80, + 20 completion = 100
|
||||
assert_eq!(result.quality_score, 100);
|
||||
assert!(result.reasoning.contains("1/1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ impl OnlineDiscovery {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -182,7 +181,6 @@ impl OnlineDiscovery {
|
||||
source: ExtensionSource::Discovered { url },
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -327,180 +325,4 @@ mod tests {
|
||||
// Just make sure it constructs without panicking
|
||||
let _discovery = OnlineDiscovery::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_titlecase_single_char() {
|
||||
assert_eq!(titlecase("a"), "A");
|
||||
assert_eq!(titlecase("Z"), "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_titlecase_mixed_case() {
|
||||
assert_eq!(titlecase("hELLO wORLD"), "HELLO WORLD");
|
||||
// Only first char is uppercased, rest is left as-is
|
||||
assert_eq!(titlecase("alREADY weird"), "AlREADY Weird");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_titlecase_multiple_spaces() {
|
||||
// split_whitespace collapses multiple spaces
|
||||
assert_eq!(titlecase("hello world"), "Hello World");
|
||||
assert_eq!(titlecase(" leading trailing "), "Leading Trailing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_titlecase_punctuation() {
|
||||
assert_eq!(titlecase("hello-world"), "Hello-world");
|
||||
assert_eq!(titlecase("it's fine"), "It's Fine");
|
||||
assert_eq!(titlecase("one. two"), "One. Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_source_wasm_download() {
|
||||
let src = ExtensionSource::WasmDownload {
|
||||
wasm_url: "https://example.com/tool.wasm".to_string(),
|
||||
capabilities_url: Some("https://example.com/caps.json".to_string()),
|
||||
};
|
||||
assert_eq!(extract_source(&src), "https://example.com/tool.wasm");
|
||||
|
||||
let src_no_caps = ExtensionSource::WasmDownload {
|
||||
wasm_url: "https://other.com/bin.wasm".to_string(),
|
||||
capabilities_url: None,
|
||||
};
|
||||
assert_eq!(extract_source(&src_no_caps), "https://other.com/bin.wasm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_source_wasm_buildable() {
|
||||
let src = ExtensionSource::WasmBuildable {
|
||||
source_dir: "/home/user/my-tool".to_string(),
|
||||
build_dir: Some("/home/user/my-tool/target".to_string()),
|
||||
crate_name: Some("my_tool".to_string()),
|
||||
};
|
||||
assert_eq!(extract_source(&src), "/home/user/my-tool");
|
||||
|
||||
let src_minimal = ExtensionSource::WasmBuildable {
|
||||
source_dir: "./src".to_string(),
|
||||
build_dir: None,
|
||||
crate_name: None,
|
||||
};
|
||||
assert_eq!(extract_source(&src_minimal), "./src");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_online_discovery_default() {
|
||||
let d = OnlineDiscovery::default();
|
||||
// Verify it constructed (no panic) and the client is usable
|
||||
let _ = d.http_client;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_search_response_empty_items() {
|
||||
let json = r#"{"total_count": 0, "items": []}"#;
|
||||
let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_search_response_missing_items_field() {
|
||||
// items has #[serde(default)], so missing field should give empty vec
|
||||
let json = r#"{"total_count": 0}"#;
|
||||
let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_search_response_multiple_items() {
|
||||
let json = r#"{
|
||||
"items": [
|
||||
{
|
||||
"name": "mcp-server-a",
|
||||
"full_name": "org/mcp-server-a",
|
||||
"html_url": "https://github.com/org/mcp-server-a",
|
||||
"description": "First server",
|
||||
"topics": ["mcp"]
|
||||
},
|
||||
{
|
||||
"name": "mcp-server-b",
|
||||
"full_name": "org/mcp-server-b",
|
||||
"html_url": "https://github.com/org/mcp-server-b",
|
||||
"description": null,
|
||||
"topics": ["mcp", "tools"]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.items.len(), 2);
|
||||
assert_eq!(resp.items[0].name, "mcp-server-a");
|
||||
assert_eq!(resp.items[1].name, "mcp-server-b");
|
||||
assert_eq!(resp.items[0].description, Some("First server".to_string()));
|
||||
assert!(resp.items[1].description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_all_fields() {
|
||||
let json = r#"{
|
||||
"name": "cool-mcp",
|
||||
"full_name": "user/cool-mcp",
|
||||
"html_url": "https://github.com/user/cool-mcp",
|
||||
"description": "A cool MCP server",
|
||||
"homepage": "https://cool-mcp.dev",
|
||||
"topics": ["mcp-server", "model-context-protocol", "rust"]
|
||||
}"#;
|
||||
let repo: super::GitHubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "cool-mcp");
|
||||
assert_eq!(repo.full_name, "user/cool-mcp");
|
||||
assert_eq!(repo.html_url, "https://github.com/user/cool-mcp");
|
||||
assert_eq!(repo.description.as_deref(), Some("A cool MCP server"));
|
||||
assert_eq!(repo.homepage.as_deref(), Some("https://cool-mcp.dev"));
|
||||
assert_eq!(repo.topics.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_missing_optional_fields() {
|
||||
let json = r#"{
|
||||
"name": "bare-repo",
|
||||
"full_name": "user/bare-repo",
|
||||
"html_url": "https://github.com/user/bare-repo"
|
||||
}"#;
|
||||
let repo: super::GitHubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "bare-repo");
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.homepage.is_none());
|
||||
assert!(repo.topics.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_timeout_completes() {
|
||||
use crate::extensions::discovery::with_timeout;
|
||||
|
||||
let result = with_timeout(async { 42 }, std::time::Duration::from_secs(1)).await;
|
||||
assert_eq!(result, Some(42));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_timeout_expires() {
|
||||
use crate::extensions::discovery::with_timeout;
|
||||
|
||||
let result = with_timeout(
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)),
|
||||
std::time::Duration::from_millis(10),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_empty_query() {
|
||||
let discovery = OnlineDiscovery::new();
|
||||
let results = discovery.discover("").await;
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_whitespace_only_query() {
|
||||
let discovery = OnlineDiscovery::new();
|
||||
let results = discovery.discover(" \t\n ").await;
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+9
-348
@@ -18,8 +18,7 @@ use crate::extensions::discovery::OnlineDiscovery;
|
||||
use crate::extensions::registry::ExtensionRegistry;
|
||||
use crate::extensions::{
|
||||
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
|
||||
InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState, UpgradeOutcome,
|
||||
UpgradeResult,
|
||||
InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
|
||||
};
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::pairing::PairingStore;
|
||||
@@ -413,7 +412,6 @@ impl ExtensionManager {
|
||||
has_auth: false,
|
||||
installed: true,
|
||||
activation_error: None,
|
||||
version: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -429,28 +427,15 @@ impl ExtensionManager {
|
||||
{
|
||||
match discover_tools(&self.wasm_tools_dir).await {
|
||||
Ok(tools) => {
|
||||
for (name, discovered) in tools {
|
||||
for (name, _discovered) in tools {
|
||||
let active = self.tool_registry.has(&name).await;
|
||||
|
||||
let registry_entry = self
|
||||
let display_name = self
|
||||
.registry
|
||||
.get_with_kind(&name, Some(ExtensionKind::WasmTool))
|
||||
.await;
|
||||
let display_name = registry_entry.as_ref().map(|e| e.display_name.clone());
|
||||
.await
|
||||
.map(|e| e.display_name);
|
||||
let auth_state = self.check_tool_auth_status(&name).await;
|
||||
let version = if let Some(ref cap_path) = discovered.capabilities_path {
|
||||
tokio::fs::read(cap_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
crate::tools::wasm::CapabilitiesFile::from_bytes(&bytes).ok()
|
||||
})
|
||||
.and_then(|cap| cap.version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let version =
|
||||
version.or_else(|| registry_entry.and_then(|e| e.version.clone()));
|
||||
extensions.push(InstalledExtension {
|
||||
name: name.clone(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
@@ -464,7 +449,6 @@ impl ExtensionManager {
|
||||
has_auth: auth_state != ToolAuthState::NoAuth,
|
||||
installed: true,
|
||||
activation_error: None,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -482,31 +466,15 @@ impl ExtensionManager {
|
||||
Ok(channels) => {
|
||||
let active_names = self.active_channel_names.read().await;
|
||||
let errors = self.activation_errors.read().await;
|
||||
for (name, discovered) in channels {
|
||||
for (name, _discovered) in channels {
|
||||
let active = active_names.contains(&name);
|
||||
let auth_state = self.check_channel_auth_status(&name).await;
|
||||
let activation_error = errors.get(&name).cloned();
|
||||
let registry_entry = self
|
||||
let display_name = self
|
||||
.registry
|
||||
.get_with_kind(&name, Some(ExtensionKind::WasmChannel))
|
||||
.await;
|
||||
let display_name = registry_entry.as_ref().map(|e| e.display_name.clone());
|
||||
let version = if let Some(ref cap_path) = discovered.capabilities_path {
|
||||
tokio::fs::read(cap_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(
|
||||
&bytes,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.and_then(|cap| cap.version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let version =
|
||||
version.or_else(|| registry_entry.and_then(|e| e.version.clone()));
|
||||
.await
|
||||
.map(|e| e.display_name);
|
||||
extensions.push(InstalledExtension {
|
||||
name,
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
@@ -520,7 +488,6 @@ impl ExtensionManager {
|
||||
has_auth: false,
|
||||
installed: true,
|
||||
activation_error,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -559,7 +526,6 @@ impl ExtensionManager {
|
||||
has_auth: false,
|
||||
installed: false,
|
||||
activation_error: None,
|
||||
version: entry.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -671,207 +637,6 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade installed WASM extensions to match the current host WIT version.
|
||||
///
|
||||
/// If `name` is `Some`, upgrades only that extension. If `None`, checks all
|
||||
/// installed WASM tools and channels and upgrades any that are outdated.
|
||||
///
|
||||
/// The upgrade preserves authentication secrets — only the `.wasm` binary
|
||||
/// (and `.capabilities.json`) are replaced.
|
||||
pub async fn upgrade(&self, name: Option<&str>) -> Result<UpgradeResult, ExtensionError> {
|
||||
// Collect extensions to check
|
||||
let mut candidates: Vec<(String, ExtensionKind)> = Vec::new();
|
||||
|
||||
if let Some(name) = name {
|
||||
Self::validate_extension_name(name)?;
|
||||
let kind = self.determine_installed_kind(name).await?;
|
||||
if kind == ExtensionKind::McpServer {
|
||||
return Err(ExtensionError::Other(
|
||||
"MCP servers don't have WIT versions and cannot be upgraded this way"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
candidates.push((name.to_string(), kind));
|
||||
} else {
|
||||
// Discover all installed WASM tools
|
||||
if self.wasm_tools_dir.exists()
|
||||
&& let Ok(tools) = discover_tools(&self.wasm_tools_dir).await
|
||||
{
|
||||
for (tool_name, _) in tools {
|
||||
candidates.push((tool_name, ExtensionKind::WasmTool));
|
||||
}
|
||||
}
|
||||
// Discover all installed WASM channels
|
||||
if self.wasm_channels_dir.exists()
|
||||
&& let Ok(channels) =
|
||||
crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await
|
||||
{
|
||||
for (ch_name, _) in channels {
|
||||
candidates.push((ch_name, ExtensionKind::WasmChannel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(UpgradeResult {
|
||||
results: Vec::new(),
|
||||
message: "No WASM extensions installed.".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut outcomes = Vec::new();
|
||||
|
||||
for (ext_name, kind) in &candidates {
|
||||
let outcome = self.upgrade_one(ext_name, *kind).await;
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
|
||||
let upgraded = outcomes.iter().filter(|o| o.status == "upgraded").count();
|
||||
let up_to_date = outcomes
|
||||
.iter()
|
||||
.filter(|o| o.status == "already_up_to_date")
|
||||
.count();
|
||||
let failed = outcomes.iter().filter(|o| o.status == "failed").count();
|
||||
|
||||
let message = format!(
|
||||
"{} extension(s) checked: {} upgraded, {} already up to date, {} failed",
|
||||
outcomes.len(),
|
||||
upgraded,
|
||||
up_to_date,
|
||||
failed
|
||||
);
|
||||
|
||||
Ok(UpgradeResult {
|
||||
results: outcomes,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Upgrade a single WASM extension if its WIT version is outdated.
|
||||
async fn upgrade_one(&self, name: &str, kind: ExtensionKind) -> UpgradeOutcome {
|
||||
let (cap_dir, host_wit) = match kind {
|
||||
ExtensionKind::WasmTool => (&self.wasm_tools_dir, crate::tools::wasm::WIT_TOOL_VERSION),
|
||||
ExtensionKind::WasmChannel => (
|
||||
&self.wasm_channels_dir,
|
||||
crate::tools::wasm::WIT_CHANNEL_VERSION,
|
||||
),
|
||||
ExtensionKind::McpServer => {
|
||||
return UpgradeOutcome {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
status: "failed".to_string(),
|
||||
detail: "MCP servers cannot be upgraded this way".to_string(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Read current WIT version from capabilities
|
||||
let cap_path = cap_dir.join(format!("{}.capabilities.json", name));
|
||||
let declared_wit = if cap_path.exists() {
|
||||
match tokio::fs::read(&cap_path).await {
|
||||
Ok(bytes) => {
|
||||
let wit: Option<String> = match kind {
|
||||
ExtensionKind::WasmTool => {
|
||||
crate::tools::wasm::CapabilitiesFile::from_bytes(&bytes)
|
||||
.ok()
|
||||
.and_then(|c| c.wit_version)
|
||||
}
|
||||
ExtensionKind::WasmChannel => {
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes)
|
||||
.ok()
|
||||
.and_then(|c| c.wit_version)
|
||||
}
|
||||
ExtensionKind::McpServer => None,
|
||||
};
|
||||
wit
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Check if upgrade is needed
|
||||
let needs_upgrade =
|
||||
crate::tools::wasm::check_wit_version_compat(name, declared_wit.as_deref(), host_wit)
|
||||
.is_err();
|
||||
|
||||
if !needs_upgrade {
|
||||
return UpgradeOutcome {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
status: "already_up_to_date".to_string(),
|
||||
detail: format!(
|
||||
"WIT {} matches host WIT {}",
|
||||
declared_wit.as_deref().unwrap_or("unknown"),
|
||||
host_wit
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Check registry for a newer version
|
||||
let entry = self.registry.get_with_kind(name, Some(kind)).await;
|
||||
let Some(entry) = entry else {
|
||||
return UpgradeOutcome {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
status: "not_in_registry".to_string(),
|
||||
detail: format!(
|
||||
"Extension '{}' has outdated WIT {} (host: {}), \
|
||||
but is not in the registry. Reinstall manually with a URL.",
|
||||
name,
|
||||
declared_wit.as_deref().unwrap_or("unknown"),
|
||||
host_wit
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// Delete old .wasm file (keep secrets intact)
|
||||
let wasm_path = cap_dir.join(format!("{}.wasm", name));
|
||||
if wasm_path.exists()
|
||||
&& let Err(e) = tokio::fs::remove_file(&wasm_path).await
|
||||
{
|
||||
return UpgradeOutcome {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
status: "failed".to_string(),
|
||||
detail: format!("Failed to remove old WASM binary: {}", e),
|
||||
};
|
||||
}
|
||||
// Also remove old capabilities so install_from_entry can write the new one
|
||||
if cap_path.exists() {
|
||||
let _ = tokio::fs::remove_file(&cap_path).await;
|
||||
}
|
||||
|
||||
// Reinstall from registry
|
||||
match self.install_from_entry(&entry).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
extension = %name,
|
||||
old_wit = ?declared_wit,
|
||||
new_host_wit = %host_wit,
|
||||
"Upgraded WASM extension"
|
||||
);
|
||||
UpgradeOutcome {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
status: "upgraded".to_string(),
|
||||
detail: format!(
|
||||
"Upgraded from WIT {} to host WIT {}. Restart to activate.",
|
||||
declared_wit.as_deref().unwrap_or("unknown"),
|
||||
host_wit
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => UpgradeOutcome {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
status: "failed".to_string(),
|
||||
detail: format!("Reinstall failed: {}. Old files were removed.", e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get detailed info about an installed extension (version, wit_version, host compatibility).
|
||||
pub async fn extension_info(&self, name: &str) -> Result<serde_json::Value, ExtensionError> {
|
||||
Self::validate_extension_name(name)?;
|
||||
@@ -3571,7 +3336,6 @@ fn combine_install_errors(
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::extensions::manager::{
|
||||
FallbackDecision, combine_install_errors, fallback_decision, infer_kind_from_url,
|
||||
};
|
||||
@@ -3857,107 +3621,4 @@ mod tests {
|
||||
assert_eq!(std::fs::read_to_string(&tool_cap).unwrap(), tool_caps);
|
||||
assert_eq!(std::fs::read_to_string(&channel_cap).unwrap(), channel_caps);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upgrade_no_installed_extensions() {
|
||||
let manager = make_manager_with_temp_dirs();
|
||||
let result = manager.upgrade(None).await.unwrap();
|
||||
assert!(result.results.is_empty());
|
||||
assert!(result.message.contains("No WASM extensions installed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upgrade_mcp_server_rejected() {
|
||||
let manager = make_manager_with_temp_dirs();
|
||||
// MCP servers can't be upgraded via tool_upgrade
|
||||
let err = manager.upgrade(Some("some-mcp")).await;
|
||||
// It will fail with NotInstalled because there's no MCP server named "some-mcp",
|
||||
// but if it were installed, the MCP code path would be rejected.
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upgrade_up_to_date_extension() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let channels_dir = dir.path().join("channels");
|
||||
std::fs::create_dir_all(&channels_dir).unwrap();
|
||||
|
||||
// Write a fake .wasm file and capabilities with current WIT version
|
||||
let wasm_path = channels_dir.join("test-channel.wasm");
|
||||
std::fs::write(&wasm_path, b"\0asm fake").unwrap();
|
||||
|
||||
let cap_path = channels_dir.join("test-channel.capabilities.json");
|
||||
let caps = serde_json::json!({
|
||||
"type": "channel",
|
||||
"name": "test-channel",
|
||||
"wit_version": crate::tools::wasm::WIT_CHANNEL_VERSION,
|
||||
});
|
||||
std::fs::write(&cap_path, serde_json::to_string(&caps).unwrap()).unwrap();
|
||||
|
||||
let manager = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
|
||||
|
||||
let result = manager.upgrade(Some("test-channel")).await.unwrap();
|
||||
assert_eq!(result.results.len(), 1);
|
||||
assert_eq!(result.results[0].status, "already_up_to_date");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_upgrade_outdated_not_in_registry() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let channels_dir = dir.path().join("channels");
|
||||
std::fs::create_dir_all(&channels_dir).unwrap();
|
||||
|
||||
// Write a fake .wasm file and capabilities with OLD WIT version
|
||||
let wasm_path = channels_dir.join("custom-channel.wasm");
|
||||
std::fs::write(&wasm_path, b"\0asm fake").unwrap();
|
||||
|
||||
let cap_path = channels_dir.join("custom-channel.capabilities.json");
|
||||
let caps = serde_json::json!({
|
||||
"type": "channel",
|
||||
"name": "custom-channel",
|
||||
"wit_version": "0.1.0",
|
||||
});
|
||||
std::fs::write(&cap_path, serde_json::to_string(&caps).unwrap()).unwrap();
|
||||
|
||||
let manager = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
|
||||
|
||||
let result = manager.upgrade(Some("custom-channel")).await.unwrap();
|
||||
assert_eq!(result.results.len(), 1);
|
||||
assert_eq!(result.results[0].status, "not_in_registry");
|
||||
}
|
||||
|
||||
fn make_manager_with_temp_dirs() -> ExtensionManager {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
make_manager_custom_dirs(dir.path().join("tools"), dir.path().join("channels"))
|
||||
}
|
||||
|
||||
fn make_manager_custom_dirs(
|
||||
tools_dir: std::path::PathBuf,
|
||||
channels_dir: std::path::PathBuf,
|
||||
) -> ExtensionManager {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::mcp::session::McpSessionManager;
|
||||
|
||||
std::fs::create_dir_all(&tools_dir).ok();
|
||||
std::fs::create_dir_all(&channels_dir).ok();
|
||||
|
||||
let master_key =
|
||||
secrecy::SecretString::from("0123456789abcdef0123456789abcdef".to_string());
|
||||
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
||||
|
||||
ExtensionManager::new(
|
||||
Arc::new(McpSessionManager::new()),
|
||||
Arc::new(InMemorySecretsStore::new(crypto)),
|
||||
Arc::new(ToolRegistry::new()),
|
||||
None,
|
||||
None,
|
||||
tools_dir,
|
||||
channels_dir,
|
||||
None,
|
||||
"test".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +70,6 @@ pub struct RegistryEntry {
|
||||
pub fallback_source: Option<Box<ExtensionSource>>,
|
||||
/// How authentication works.
|
||||
pub auth_hint: AuthHint,
|
||||
/// Extension version (semver), if known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Where the extension binary or server lives.
|
||||
@@ -149,26 +146,6 @@ pub struct InstallResult {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Result of upgrading one or more extensions.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UpgradeResult {
|
||||
/// Per-extension upgrade outcomes.
|
||||
pub results: Vec<UpgradeOutcome>,
|
||||
/// Summary message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Outcome for a single extension upgrade.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UpgradeOutcome {
|
||||
pub name: String,
|
||||
pub kind: ExtensionKind,
|
||||
/// What happened: "upgraded", "already_up_to_date", "failed", "not_in_registry".
|
||||
pub status: String,
|
||||
/// Human-readable detail.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Auth readiness state for the extensions list UI.
|
||||
///
|
||||
/// Used by `check_tool_auth_status` and `check_channel_auth_status` to
|
||||
@@ -476,9 +453,6 @@ pub struct InstalledExtension {
|
||||
/// Last activation error for WASM channels.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activation_error: Option<String>,
|
||||
/// Extension version from capabilities file (semver).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Error type for extension operations.
|
||||
@@ -643,421 +617,4 @@ mod tests {
|
||||
assert!(result.instructions().is_none());
|
||||
assert!(result.setup_url().is_none());
|
||||
}
|
||||
|
||||
// ── ExtensionKind ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extension_kind_display() {
|
||||
assert_eq!(ExtensionKind::McpServer.to_string(), "mcp_server");
|
||||
assert_eq!(ExtensionKind::WasmTool.to_string(), "wasm_tool");
|
||||
assert_eq!(ExtensionKind::WasmChannel.to_string(), "wasm_channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_kind_serde_roundtrip() {
|
||||
for kind in [
|
||||
ExtensionKind::McpServer,
|
||||
ExtensionKind::WasmTool,
|
||||
ExtensionKind::WasmChannel,
|
||||
] {
|
||||
let json = serde_json::to_value(kind).unwrap();
|
||||
let back: ExtensionKind = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, kind);
|
||||
}
|
||||
// Verify the serialized strings match rename_all = "snake_case"
|
||||
assert_eq!(
|
||||
serde_json::to_value(ExtensionKind::McpServer).unwrap(),
|
||||
"mcp_server"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ExtensionKind::WasmTool).unwrap(),
|
||||
"wasm_tool"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ExtensionKind::WasmChannel).unwrap(),
|
||||
"wasm_channel"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ExtensionSource ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extension_source_serde_mcp_url() {
|
||||
let src = ExtensionSource::McpUrl {
|
||||
url: "https://mcp.example.com".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&src).unwrap();
|
||||
assert_eq!(json["type"], "mcp_url");
|
||||
assert_eq!(json["url"], "https://mcp.example.com");
|
||||
let back: ExtensionSource = serde_json::from_value(json).unwrap();
|
||||
assert!(
|
||||
matches!(back, ExtensionSource::McpUrl { url } if url == "https://mcp.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_source_serde_wasm_download() {
|
||||
let src = ExtensionSource::WasmDownload {
|
||||
wasm_url: "https://cdn.example.com/tool.wasm".to_string(),
|
||||
capabilities_url: Some("https://cdn.example.com/caps.json".to_string()),
|
||||
};
|
||||
let json = serde_json::to_value(&src).unwrap();
|
||||
assert_eq!(json["type"], "wasm_download");
|
||||
assert_eq!(json["wasm_url"], "https://cdn.example.com/tool.wasm");
|
||||
assert_eq!(
|
||||
json["capabilities_url"],
|
||||
"https://cdn.example.com/caps.json"
|
||||
);
|
||||
let back: ExtensionSource = serde_json::from_value(json).unwrap();
|
||||
assert!(
|
||||
matches!(back, ExtensionSource::WasmDownload { capabilities_url: Some(c), .. } if c.contains("caps.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_source_serde_wasm_buildable() {
|
||||
let src = ExtensionSource::WasmBuildable {
|
||||
source_dir: "/home/user/tools/my-tool".to_string(),
|
||||
build_dir: Some("target/wasm32-wasip2/release".to_string()),
|
||||
crate_name: Some("my_tool".to_string()),
|
||||
};
|
||||
let json = serde_json::to_value(&src).unwrap();
|
||||
assert_eq!(json["type"], "wasm_buildable");
|
||||
assert_eq!(json["source_dir"], "/home/user/tools/my-tool");
|
||||
let back: ExtensionSource = serde_json::from_value(json).unwrap();
|
||||
assert!(
|
||||
matches!(back, ExtensionSource::WasmBuildable { source_dir, .. } if source_dir.contains("my-tool"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_source_serde_discovered() {
|
||||
let src = ExtensionSource::Discovered {
|
||||
url: "https://discovered.example.com".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&src).unwrap();
|
||||
assert_eq!(json["type"], "discovered");
|
||||
let back: ExtensionSource = serde_json::from_value(json).unwrap();
|
||||
assert!(matches!(back, ExtensionSource::Discovered { url } if url.contains("discovered")));
|
||||
}
|
||||
|
||||
// ── AuthHint ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn auth_hint_serde_all_variants() {
|
||||
// Dcr
|
||||
let json = serde_json::to_value(&AuthHint::Dcr).unwrap();
|
||||
assert_eq!(json["type"], "dcr");
|
||||
let back: AuthHint = serde_json::from_value(json).unwrap();
|
||||
assert!(matches!(back, AuthHint::Dcr));
|
||||
|
||||
// OAuthPreConfigured
|
||||
let hint = AuthHint::OAuthPreConfigured {
|
||||
setup_url: "https://dev.example.com/apps".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&hint).unwrap();
|
||||
assert_eq!(json["type"], "o_auth_pre_configured");
|
||||
assert_eq!(json["setup_url"], "https://dev.example.com/apps");
|
||||
let back: AuthHint = serde_json::from_value(json).unwrap();
|
||||
assert!(
|
||||
matches!(back, AuthHint::OAuthPreConfigured { setup_url } if setup_url.contains("dev.example"))
|
||||
);
|
||||
|
||||
// CapabilitiesAuth
|
||||
let json = serde_json::to_value(&AuthHint::CapabilitiesAuth).unwrap();
|
||||
assert_eq!(json["type"], "capabilities_auth");
|
||||
let back: AuthHint = serde_json::from_value(json).unwrap();
|
||||
assert!(matches!(back, AuthHint::CapabilitiesAuth));
|
||||
|
||||
// None
|
||||
let json = serde_json::to_value(&AuthHint::None).unwrap();
|
||||
assert_eq!(json["type"], "none");
|
||||
let back: AuthHint = serde_json::from_value(json).unwrap();
|
||||
assert!(matches!(back, AuthHint::None));
|
||||
}
|
||||
|
||||
// ── SearchResult ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn search_result_serde_registry_source() {
|
||||
// SearchResult uses #[serde(flatten)] on entry, which means
|
||||
// RegistryEntry.source (ExtensionSource) and SearchResult.source
|
||||
// (ResultSource) collide on the "source" key. The last writer wins
|
||||
// during serialization, so we test serialize-only (no roundtrip).
|
||||
let entry = RegistryEntry {
|
||||
name: "notion".to_string(),
|
||||
display_name: "Notion".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Notion integration".to_string(),
|
||||
keywords: vec!["notes".to_string(), "wiki".to_string()],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.notion.so".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
};
|
||||
let sr = SearchResult {
|
||||
entry,
|
||||
source: ResultSource::Registry,
|
||||
validated: false,
|
||||
};
|
||||
let json = serde_json::to_value(&sr).unwrap();
|
||||
assert_eq!(json["name"], "notion");
|
||||
assert_eq!(json["kind"], "mcp_server");
|
||||
assert_eq!(json["description"], "Notion integration");
|
||||
assert_eq!(json["validated"], false);
|
||||
// The flattened entry fields are present at the top level
|
||||
assert!(json.get("auth_hint").is_some());
|
||||
assert_eq!(json["keywords"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_result_serde_discovered_source() {
|
||||
let entry = RegistryEntry {
|
||||
name: "custom-api".to_string(),
|
||||
display_name: "Custom API".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Discovered MCP server".to_string(),
|
||||
keywords: vec![],
|
||||
source: ExtensionSource::Discovered {
|
||||
url: "https://custom.example.com/.well-known/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
version: None,
|
||||
};
|
||||
let sr = SearchResult {
|
||||
entry,
|
||||
source: ResultSource::Discovered,
|
||||
validated: true,
|
||||
};
|
||||
let json = serde_json::to_value(&sr).unwrap();
|
||||
assert_eq!(json["name"], "custom-api");
|
||||
assert_eq!(json["display_name"], "Custom API");
|
||||
assert_eq!(json["validated"], true);
|
||||
assert!(json.get("keywords").is_some());
|
||||
}
|
||||
|
||||
// ── InstallResult ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn install_result_serde_roundtrip() {
|
||||
let ir = InstallResult {
|
||||
name: "weather".to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
message: "Installed successfully".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&ir).unwrap();
|
||||
assert_eq!(json["name"], "weather");
|
||||
assert_eq!(json["kind"], "wasm_tool");
|
||||
assert_eq!(json["message"], "Installed successfully");
|
||||
let back: InstallResult = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.name, "weather");
|
||||
assert_eq!(back.kind, ExtensionKind::WasmTool);
|
||||
}
|
||||
|
||||
// ── ActivateResult ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn activate_result_serde_roundtrip() {
|
||||
let ar = ActivateResult {
|
||||
name: "slack".to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
tools_loaded: vec!["send_message".to_string(), "read_channel".to_string()],
|
||||
message: "Activated with 2 tools".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&ar).unwrap();
|
||||
assert_eq!(json["name"], "slack");
|
||||
assert_eq!(json["kind"], "wasm_channel");
|
||||
assert_eq!(json["tools_loaded"].as_array().unwrap().len(), 2);
|
||||
let back: ActivateResult = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.tools_loaded, vec!["send_message", "read_channel"]);
|
||||
}
|
||||
|
||||
// ── InstalledExtension ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn installed_extension_serde_defaults() {
|
||||
// Minimal JSON: optional fields absent, defaults kick in
|
||||
let json = serde_json::json!({
|
||||
"name": "echo",
|
||||
"kind": "wasm_tool",
|
||||
"authenticated": false,
|
||||
"active": false,
|
||||
});
|
||||
let ext: InstalledExtension = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(ext.name, "echo");
|
||||
assert!(ext.installed, "installed should default to true");
|
||||
assert!(!ext.needs_setup, "needs_setup should default to false");
|
||||
assert!(!ext.has_auth);
|
||||
assert!(ext.tools.is_empty());
|
||||
assert!(ext.display_name.is_none());
|
||||
assert!(ext.description.is_none());
|
||||
assert!(ext.url.is_none());
|
||||
assert!(ext.activation_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_extension_serde_all_fields() {
|
||||
let ext = InstalledExtension {
|
||||
name: "gmail".to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
display_name: Some("Gmail Tool".to_string()),
|
||||
description: Some("Read and send emails".to_string()),
|
||||
url: Some("https://gmail.example.com".to_string()),
|
||||
authenticated: true,
|
||||
active: true,
|
||||
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
|
||||
needs_setup: true,
|
||||
has_auth: true,
|
||||
installed: false,
|
||||
activation_error: Some("token expired".to_string()),
|
||||
version: None,
|
||||
};
|
||||
let json = serde_json::to_value(&ext).unwrap();
|
||||
assert_eq!(json["display_name"], "Gmail Tool");
|
||||
assert_eq!(json["description"], "Read and send emails");
|
||||
assert_eq!(json["url"], "https://gmail.example.com");
|
||||
assert_eq!(json["needs_setup"], true);
|
||||
assert_eq!(json["installed"], false);
|
||||
assert_eq!(json["activation_error"], "token expired");
|
||||
|
||||
let back: InstalledExtension = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.name, "gmail");
|
||||
assert_eq!(back.tools.len(), 2);
|
||||
assert!(back.needs_setup);
|
||||
assert!(!back.installed);
|
||||
assert_eq!(back.activation_error.as_deref(), Some("token expired"));
|
||||
}
|
||||
|
||||
// ── ExtensionError Display ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extension_error_display_all_variants() {
|
||||
let cases: Vec<(ExtensionError, &str)> = vec![
|
||||
(
|
||||
ExtensionError::NotFound("foo".into()),
|
||||
"Extension not found: foo",
|
||||
),
|
||||
(
|
||||
ExtensionError::AlreadyInstalled("bar".into()),
|
||||
"Extension already installed: bar",
|
||||
),
|
||||
(
|
||||
ExtensionError::NotInstalled("baz".into()),
|
||||
"Extension not installed: baz",
|
||||
),
|
||||
(
|
||||
ExtensionError::AuthFailed("bad token".into()),
|
||||
"Authentication failed: bad token",
|
||||
),
|
||||
(
|
||||
ExtensionError::ActivationFailed("crash".into()),
|
||||
"Activation failed: crash",
|
||||
),
|
||||
(
|
||||
ExtensionError::InstallFailed("disk full".into()),
|
||||
"Installation failed: disk full",
|
||||
),
|
||||
(
|
||||
ExtensionError::DiscoveryFailed("timeout".into()),
|
||||
"Discovery failed: timeout",
|
||||
),
|
||||
(
|
||||
ExtensionError::InvalidUrl("not a url".into()),
|
||||
"Invalid URL: not a url",
|
||||
),
|
||||
(
|
||||
ExtensionError::DownloadFailed("404".into()),
|
||||
"Download failed: 404",
|
||||
),
|
||||
(
|
||||
ExtensionError::Config("missing key".into()),
|
||||
"Config error: missing key",
|
||||
),
|
||||
(
|
||||
ExtensionError::Other("something broke".into()),
|
||||
"something broke",
|
||||
),
|
||||
(
|
||||
ExtensionError::FallbackFailed {
|
||||
primary: Box::new(ExtensionError::DownloadFailed("404".into())),
|
||||
fallback: Box::new(ExtensionError::InstallFailed("no cargo".into())),
|
||||
},
|
||||
"Primary install failed: Download failed: 404; fallback install also failed: Installation failed: no cargo",
|
||||
),
|
||||
];
|
||||
for (err, expected) in cases {
|
||||
assert_eq!(err.to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ToolAuthState ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_auth_state_equality() {
|
||||
assert_eq!(ToolAuthState::Ready, ToolAuthState::Ready);
|
||||
assert_eq!(ToolAuthState::NeedsAuth, ToolAuthState::NeedsAuth);
|
||||
assert_eq!(ToolAuthState::NeedsSetup, ToolAuthState::NeedsSetup);
|
||||
assert_eq!(ToolAuthState::NoAuth, ToolAuthState::NoAuth);
|
||||
|
||||
assert_ne!(ToolAuthState::Ready, ToolAuthState::NeedsAuth);
|
||||
assert_ne!(ToolAuthState::NeedsSetup, ToolAuthState::NoAuth);
|
||||
assert_ne!(ToolAuthState::Ready, ToolAuthState::NoAuth);
|
||||
}
|
||||
|
||||
// ── ResultSource ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn result_source_serde() {
|
||||
let json = serde_json::to_value(ResultSource::Registry).unwrap();
|
||||
assert_eq!(json, "registry");
|
||||
let back: ResultSource = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, ResultSource::Registry);
|
||||
|
||||
let json = serde_json::to_value(ResultSource::Discovered).unwrap();
|
||||
assert_eq!(json, "discovered");
|
||||
let back: ResultSource = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, ResultSource::Discovered);
|
||||
}
|
||||
|
||||
// ── AuthResult::status_str ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn auth_result_status_str_all_variants() {
|
||||
assert_eq!(
|
||||
AuthResult::authenticated("a", ExtensionKind::McpServer).status_str(),
|
||||
"authenticated"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthResult::no_auth_required("b", ExtensionKind::WasmTool).status_str(),
|
||||
"no_auth_required"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthResult::awaiting_authorization(
|
||||
"c",
|
||||
ExtensionKind::WasmChannel,
|
||||
"https://x.com".into(),
|
||||
"local".into(),
|
||||
)
|
||||
.status_str(),
|
||||
"awaiting_authorization"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthResult::awaiting_token("d", ExtensionKind::WasmTool, "paste token".into(), None)
|
||||
.status_str(),
|
||||
"awaiting_token"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthResult::needs_setup(
|
||||
"e",
|
||||
ExtensionKind::McpServer,
|
||||
"configure oauth".into(),
|
||||
Some("https://setup.example.com".into()),
|
||||
)
|
||||
.status_str(),
|
||||
"needs_setup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "linear".to_string(),
|
||||
@@ -266,7 +265,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "github".to_string(),
|
||||
@@ -287,7 +285,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "slack-mcp".to_string(),
|
||||
@@ -308,7 +305,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "sentry".to_string(),
|
||||
@@ -329,7 +325,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "stripe".to_string(),
|
||||
@@ -350,7 +345,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "cloudflare".to_string(),
|
||||
@@ -371,7 +365,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "asana".to_string(),
|
||||
@@ -390,7 +383,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "intercom".to_string(),
|
||||
@@ -410,7 +402,6 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
// WASM channels (telegram, slack, discord, whatsapp) come from the embedded
|
||||
// registry catalog (registry/channels/*.json) with WasmDownload URLs pointing
|
||||
@@ -436,7 +427,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
};
|
||||
|
||||
let score = score_entry(&entry, &["notion".to_string()]);
|
||||
@@ -460,7 +450,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
};
|
||||
|
||||
let score = score_entry(&entry, &["calendar".to_string()]);
|
||||
@@ -484,7 +473,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
};
|
||||
|
||||
let score = score_entry(&entry, &["wiki".to_string()]);
|
||||
@@ -508,7 +496,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
};
|
||||
|
||||
let score = score_entry(&entry, &["xyzfoobar".to_string()]);
|
||||
@@ -573,7 +560,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
};
|
||||
|
||||
registry.cache_discovered(vec![discovered]).await;
|
||||
@@ -600,7 +586,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
version: None,
|
||||
};
|
||||
|
||||
registry.cache_discovered(vec![entry.clone()]).await;
|
||||
@@ -626,7 +611,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
// This shares a name with the builtin slack-mcp but has a different kind, so both should appear
|
||||
RegistryEntry {
|
||||
@@ -642,7 +626,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -679,7 +662,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
}];
|
||||
|
||||
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
||||
@@ -707,7 +689,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "telegram".to_string(),
|
||||
@@ -722,7 +703,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -785,7 +765,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
version: None,
|
||||
};
|
||||
let channel_entry = RegistryEntry {
|
||||
name: "cached-ext".to_string(),
|
||||
@@ -800,7 +779,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
version: None,
|
||||
};
|
||||
|
||||
registry
|
||||
@@ -844,7 +822,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "telegram".to_string(),
|
||||
@@ -859,7 +836,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -908,7 +884,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "myext".to_string(),
|
||||
@@ -923,7 +898,6 @@ mod tests {
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::None,
|
||||
version: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ pub mod cli;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod db;
|
||||
pub mod document_extraction;
|
||||
pub mod error;
|
||||
pub mod estimation;
|
||||
pub mod evaluation;
|
||||
@@ -68,7 +67,6 @@ pub mod setup;
|
||||
pub mod skills;
|
||||
pub mod tools;
|
||||
pub mod tracing_fmt;
|
||||
pub mod transcription;
|
||||
pub mod tunnel;
|
||||
pub mod util;
|
||||
pub mod worker;
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
# LLM Module
|
||||
|
||||
Multi-provider LLM integration with circuit breaker, retry, failover, and response caching.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `mod.rs` | Provider factory (`create_llm_provider`, `build_provider_chain`); `LlmBackend` enum |
|
||||
| `provider.rs` | `LlmProvider` trait, `ChatMessage`, `ToolCall`, `CompletionRequest`, `sanitize_tool_messages` |
|
||||
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
|
||||
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
|
||||
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
|
||||
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
|
||||
| `retry.rs` | Exponential backoff retry wrapper; `is_retryable()` classification |
|
||||
| `failover.rs` | `FailoverProvider` — tries providers in order with per-provider cooldown |
|
||||
| `response_cache.rs` | In-memory LLM response cache with TTL and LRU eviction (keyed by SHA-256) |
|
||||
| `costs.rs` | Static per-model cost table (OpenAI, Anthropic, local/Ollama heuristics) |
|
||||
| `rig_adapter.rs` | Adapter bridging rig-core `CompletionModel` → `LlmProvider`; used by OpenAI, Anthropic, Ollama, Tinfoil |
|
||||
| `smart_routing.rs` | `SmartRoutingProvider` — 13-dimension complexity scorer routes cheap vs primary model |
|
||||
| `recording.rs` | `RecordingLlm` — trace capture for E2E replay testing (`IRONCLAW_RECORD_TRACE`) |
|
||||
|
||||
## Provider Selection
|
||||
|
||||
Set via `LLM_BACKEND` env var:
|
||||
|
||||
| Value | Provider | Key env vars |
|
||||
|-------|----------|-------------|
|
||||
| `nearai` (default) | NEAR AI Chat Completions | `NEARAI_SESSION_TOKEN` or `NEARAI_API_KEY` |
|
||||
| `openai` | OpenAI | `OPENAI_API_KEY` |
|
||||
| `anthropic` | Anthropic | `ANTHROPIC_API_KEY` |
|
||||
| `ollama` | Ollama local | `OLLAMA_BASE_URL` |
|
||||
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
|
||||
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
||||
|
||||
## NEAR AI Provider Gotchas
|
||||
|
||||
**Dual auth modes:**
|
||||
- **Session token** (default): `NEARAI_SESSION_TOKEN=sess_...`, base URL = `https://private.near.ai`. Tokens are persisted to `~/.ironclaw/session.json` (mode 0600) and optionally to the DB `settings` table (`nearai.session_token`). On 401 responses where the body contains "session" + "expired"/"invalid", `NearAiChatProvider` calls `session.handle_auth_failure()` which triggers the interactive OAuth login flow and retries once. Plain `AuthFailed` 401s are not retried.
|
||||
- **API key**: Set `NEARAI_API_KEY` (from `cloud.near.ai`), base URL defaults to `https://cloud-api.near.ai`. 401s with API key auth are immediately returned as `LlmError::AuthFailed` — no renewal.
|
||||
|
||||
**Session renewal is interactive:** When `SessionExpired` triggers renewal, it blocks and prompts the user in the terminal (GitHub/Google OAuth or manual API key entry). This is unsuitable for headless/hosted deployments — set `NEARAI_SESSION_TOKEN` env var instead.
|
||||
|
||||
**Tool message flattening:** NEAR AI's API doesn't support `role: "tool"` messages in the standard format. `nearai_chat.rs` defaults `flatten_tool_messages = true`, converting tool results to user messages with `[Tool result from <name>]: <content>` format. Use `NearAiChatProvider::new_with_flatten(..., false)` to disable for compliant endpoints.
|
||||
|
||||
**Pricing auto-fetch:** On startup, `NearAiChatProvider` fires a background task to fetch per-model pricing from `/v1/model/list`. If the fetch fails, it silently falls back to `costs::model_cost()` / `costs::default_cost()`. Pricing is stored in-memory only.
|
||||
|
||||
**HTTP request timeout:** The NEAR AI HTTP client has a 120-second timeout per request. Rate limit `Retry-After` headers are parsed (both delay-seconds and HTTP-date formats) and forwarded as `LlmError::RateLimited { retry_after }` for the `RetryProvider` to honor.
|
||||
|
||||
## Circuit Breaker
|
||||
|
||||
State machine in `circuit_breaker.rs`:
|
||||
```
|
||||
Closed (normal)
|
||||
→ Open (after failure_threshold consecutive transient failures; default: 5)
|
||||
→ HalfOpen (after recovery_timeout; default: 30s)
|
||||
→ Closed (after half_open_successes_needed probe successes; default: 2)
|
||||
→ Open (if any probe fails)
|
||||
```
|
||||
|
||||
**Transient vs non-transient errors:** Only `RequestFailed`, `RateLimited`, `InvalidResponse`, `SessionExpired`, `SessionRenewalFailed`, `Http`, and `Io` count toward the threshold. `AuthFailed`, `ContextLengthExceeded`, `ModelNotAvailable`, and `Json` errors never trip the breaker — they indicate caller problems, not backend degradation.
|
||||
|
||||
Configure via `NearAiConfig` fields: `circuit_breaker_threshold` (None = disabled), `circuit_breaker_recovery_secs` (default: 30).
|
||||
|
||||
The circuit breaker wraps the entire provider chain. When open, it immediately returns `LlmError::RequestFailed` with a message including remaining cooldown seconds. The `FailoverProvider` sitting outside can then try a fallback model.
|
||||
|
||||
## Failover Chain
|
||||
|
||||
`FailoverProvider` in `failover.rs` wraps a list of `LlmProvider` instances. On a retryable error, it tries the next provider in the list. Providers that fail repeatedly enter a cooldown period and are skipped (unless all providers are in cooldown, in which case the least-recently-cooled one is tried).
|
||||
|
||||
**Cooldown defaults:** `failure_threshold = 3` consecutive retryable failures → cooldown for `cooldown_duration = 300s`. Configure via `NearAiConfig` fields: `failover_cooldown_secs`, `failover_cooldown_threshold`.
|
||||
|
||||
**Current wiring:** The failover is set up between primary model and `NEARAI_FALLBACK_MODEL` (a different model name on the same NEAR AI backend), not across different LLM provider types. Cross-provider failover (e.g., NEAR AI → Anthropic) requires manual construction.
|
||||
|
||||
## Retry
|
||||
|
||||
`RetryProvider` in `retry.rs` wraps any `LlmProvider` with exponential backoff. Retries on: `RequestFailed`, `RateLimited`, `InvalidResponse`, `SessionRenewalFailed`, `Http`, `Io`. Does **not** retry: `AuthFailed`, `SessionExpired`, `ContextLengthExceeded`, `ModelNotAvailable`, `Json`.
|
||||
|
||||
**Backoff schedule:** base 1s doubled per attempt with ±25% jitter, minimum floor 100ms. Attempt 0: ~1s, attempt 1: ~2s, attempt 2: ~4s. For `RateLimited`, uses the `retry_after` duration from the error (provider-supplied) instead of backoff.
|
||||
|
||||
Configure via `NearAiConfig.max_retries` (env: `NEARAI_MAX_RETRIES`; default: 3). Set to 0 to disable.
|
||||
|
||||
## LlmProvider Trait
|
||||
|
||||
The full trait (all methods must be implemented or rely on defaults):
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
// Required
|
||||
fn model_name(&self) -> &str;
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal); // (input, output) per token
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError>;
|
||||
async fn complete_with_tools(&self, request: ToolCompletionRequest) -> Result<ToolCompletionResponse, LlmError>;
|
||||
|
||||
// Optional (have defaults)
|
||||
async fn list_models(&self) -> Result<Vec<String>, LlmError> { Ok(vec![]) }
|
||||
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> { /* name only */ }
|
||||
fn effective_model_name(&self, requested_model: Option<&str>) -> String { /* uses active */ }
|
||||
fn active_model_name(&self) -> String { self.model_name().to_string() }
|
||||
fn set_model(&self, _model: &str) -> Result<(), LlmError> { /* Err: not supported */ }
|
||||
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { /* uses cost_per_token */ }
|
||||
}
|
||||
```
|
||||
|
||||
Key notes:
|
||||
- `model_name()` returns the configured model name; `active_model_name()` returns the currently active model (may differ if `set_model()` was called — only `NearAiChatProvider` supports this).
|
||||
- `cost_per_token()` returns `(Decimal, Decimal)` using `rust_decimal`. Look up via `costs::model_cost()` in your constructor; fall back to `costs::default_cost()` for unknowns.
|
||||
- `RigAdapter` ignores per-request model overrides (logs a warning). Only `NearAiChatProvider` supports per-request model overrides via `CompletionRequest::model`.
|
||||
- `complete_with_tools()` is never cached (tool calls can have side effects) — `CachedProvider` always passes them through.
|
||||
|
||||
To add a new provider:
|
||||
1. Create `src/llm/myprovider.rs` implementing `LlmProvider`
|
||||
2. Add variant to `LlmBackend` in `mod.rs`
|
||||
3. Wire into the factory match in `mod.rs`
|
||||
4. Add env vars to `config/llm.rs` and `.env.example`
|
||||
|
||||
## Response Cache
|
||||
|
||||
`CachedProvider` in `response_cache.rs` caches `complete()` responses. `complete_with_tools()` is never cached (side effects). Cache key is SHA-256 of `(model_name, messages_json, max_tokens, temperature, stop_sequences)`. LRU eviction when `max_entries` is reached; TTL-based expiry on access.
|
||||
|
||||
**Defaults:** TTL = 1 hour, max entries = 1000. Configure via `NearAiConfig` fields: `response_cache_enabled` (env: `NEARAI_RESPONSE_CACHE_ENABLED`), `response_cache_ttl_secs`, `response_cache_max_entries`. Cache is in-memory only — evicted on restart.
|
||||
|
||||
## OpenAI-Compatible Custom Headers
|
||||
|
||||
Set `LLM_EXTRA_HEADERS=Key:Value,Key2:Value2` to inject headers into every request. Useful for OpenRouter attribution (`HTTP-Referer`, `X-Title`). Invalid header names/values are skipped with a warning (not a fatal error).
|
||||
|
||||
## Provider Chain Construction
|
||||
|
||||
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. The chain is:
|
||||
|
||||
```
|
||||
Raw provider
|
||||
→ RetryProvider (per-provider backoff; wraps both primary and fallback)
|
||||
→ SmartRoutingProvider (cheap/primary split when NEARAI_CHEAP_MODEL is set)
|
||||
→ FailoverProvider (fallback model; only when NEARAI_FALLBACK_MODEL is set)
|
||||
→ CircuitBreakerProvider (fast-fail; only when NEARAI_CIRCUIT_BREAKER_THRESHOLD is set)
|
||||
→ CachedProvider (response cache; only when NEARAI_RESPONSE_CACHE_ENABLED=true)
|
||||
→ RecordingLlm (trace capture; only when IRONCLAW_RECORD_TRACE is set)
|
||||
```
|
||||
|
||||
`build_provider_chain()` also returns a separate standalone cheap LLM provider (for heartbeat/evaluation tasks — not part of the decorator chain).
|
||||
|
||||
## reasoning.rs Contents
|
||||
|
||||
`reasoning.rs` does **not** contain an `IntentClassifier`. It contains:
|
||||
- `Reasoning` struct — the main reasoning engine used by the agent worker; calls `complete_with_tools()` and handles tool dispatch
|
||||
- `ReasoningContext` — carries messages, available tools, job description, and metadata into a reasoning call
|
||||
- `RespondResult`, `ActionPlan`, `ToolSelection` — output types from the reasoning engine
|
||||
- `TokenUsage` — input/output token counts
|
||||
- `SILENT_REPLY_TOKEN` (`"NO_REPLY"`) and `is_silent_reply()` — used by the dispatcher to suppress empty responses in group chats
|
||||
- Thinking-tag stripping — regex-based removal of `<thinking>`, `<reflection>`, `<scratchpad>`, `<|think|>`, `<final>`, etc. from model responses before returning to the user
|
||||
|
||||
## costs.rs Details
|
||||
|
||||
`costs.rs` provides a static lookup table (`model_cost(model_id)`) returning `(input_cost, output_cost)` per token as `rust_decimal::Decimal`. Provider prefixes like `"openai/gpt-4o"` are stripped before lookup. Returns `None` for unknown models — callers should fall back to `default_cost()` (roughly GPT-4o pricing). Local model heuristic (`is_local_model()`) returns zero cost for Ollama-style identifiers (llama*, mistral*, `:latest`, `:instruct`, etc.).
|
||||
|
||||
## rig_adapter.rs Details
|
||||
|
||||
`RigAdapter<M>` bridges any rig-core `CompletionModel` to `LlmProvider`. It is actively used in production for all non-NEAR AI providers (OpenAI, Anthropic, Ollama, Tinfoil, OpenAI-compatible). Key behaviors:
|
||||
- **Per-request model overrides are silently ignored** (warning logged); the model is baked at construction time.
|
||||
- **OpenAI strict-mode schema normalization** is applied to all tool definitions: `additionalProperties: false`, all properties added to `required`, optional fields made nullable via `"type": ["T", "null"]`. This happens transparently at the provider boundary.
|
||||
- **System messages** are extracted into the rig-core `preamble` field (concatenated with newlines if multiple).
|
||||
- **Tool call IDs** are generated (`generated_tool_call_{seed}`) if the provider returns empty/whitespace IDs.
|
||||
- **Tool name normalization**: strips `proxy_` prefix if it matches a known tool (handles some proxy implementations).
|
||||
- **OpenAI uses Chat Completions API** (`completions_api()`), not the newer Responses API — the Responses API path panics when tool results are sent back (rig-core doesn't thread `call_id` through `ToolCall`).
|
||||
|
||||
## Streaming Support
|
||||
|
||||
No streaming support. All providers use non-streaming (blocking) Chat Completions requests. The `complete()` and `complete_with_tools()` methods return only after the full response is available.
|
||||
|
||||
## Trace Recording
|
||||
|
||||
Set `IRONCLAW_RECORD_TRACE=1` to enable live trace recording via `RecordingLlm`. Traces are JSON files containing: memory snapshot, HTTP exchanges from tools, and LLM steps (user inputs, text responses, tool call responses). Replay these in E2E tests via `TraceLlm`. Configure output path with `IRONCLAW_TRACE_OUTPUT` (default: `trace_{timestamp}.json`).
|
||||
@@ -245,14 +245,6 @@ impl LlmProvider for CircuitBreakerProvider {
|
||||
self.inner.cost_per_token()
|
||||
}
|
||||
|
||||
fn cache_write_multiplier(&self) -> Decimal {
|
||||
self.inner.cache_write_multiplier()
|
||||
}
|
||||
|
||||
fn cache_read_discount(&self) -> Decimal {
|
||||
self.inner.cache_read_discount()
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
self.check_allowed().await?;
|
||||
match self.inner.complete(request).await {
|
||||
|
||||
@@ -10,12 +10,6 @@ use rust_decimal_macros::dec;
|
||||
///
|
||||
/// Returns `Some((input_cost, output_cost))` for known models, `None` otherwise.
|
||||
pub fn model_cost(model_id: &str) -> Option<(Decimal, Decimal)> {
|
||||
// OpenRouter free-tier models: `:free` suffix or the `openrouter/free` router
|
||||
// should always report zero cost (see #463).
|
||||
if model_id.ends_with(":free") || model_id == "openrouter/free" || model_id == "free" {
|
||||
return Some((Decimal::ZERO, Decimal::ZERO));
|
||||
}
|
||||
|
||||
// Normalize: strip provider prefixes (e.g., "openai/gpt-4o" -> "gpt-4o")
|
||||
let id = model_id
|
||||
.rsplit_once('/')
|
||||
@@ -153,44 +147,4 @@ mod tests {
|
||||
// "openai/gpt-4o" should resolve to same as "gpt-4o"
|
||||
assert_eq!(model_cost("openai/gpt-4o"), model_cost("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openrouter_free_suffix_zero_cost() {
|
||||
// Models with `:free` suffix should report zero cost (#463)
|
||||
let (input, output) = model_cost("stepfun/step-3.5-flash:free").unwrap();
|
||||
assert_eq!(input, Decimal::ZERO);
|
||||
assert_eq!(output, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openrouter_free_router_zero_cost() {
|
||||
// The "openrouter/free" router model should report zero cost (#463)
|
||||
let (input, output) = model_cost("openrouter/free").unwrap();
|
||||
assert_eq!(input, Decimal::ZERO);
|
||||
assert_eq!(output, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bare_free_zero_cost() {
|
||||
// Edge case: bare "free" after prefix stripping
|
||||
let (input, output) = model_cost("free").unwrap();
|
||||
assert_eq!(input, Decimal::ZERO);
|
||||
assert_eq!(output, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_free_suffix_various_providers() {
|
||||
// Various provider-prefixed free models
|
||||
for model in &[
|
||||
"google/gemma-3-27b-it:free",
|
||||
"meta-llama/llama-4-maverick:free",
|
||||
"microsoft/phi-4:free",
|
||||
"nousresearch/deephermes-3-llama-3-8b-preview:free",
|
||||
] {
|
||||
let (input, output) =
|
||||
model_cost(model).unwrap_or_else(|| panic!("{model} should return Some"));
|
||||
assert_eq!(input, Decimal::ZERO, "{model} input cost should be zero");
|
||||
assert_eq!(output, Decimal::ZERO, "{model} output cost should be zero");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,14 +296,6 @@ impl LlmProvider for FailoverProvider {
|
||||
self.providers[self.last_used.load(Ordering::Relaxed)].cost_per_token()
|
||||
}
|
||||
|
||||
fn cache_write_multiplier(&self) -> Decimal {
|
||||
self.providers[self.last_used.load(Ordering::Relaxed)].cache_write_multiplier()
|
||||
}
|
||||
|
||||
fn cache_read_discount(&self) -> Decimal {
|
||||
self.providers[self.last_used.load(Ordering::Relaxed)].cache_read_discount()
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let (provider_idx, response) = self
|
||||
.try_providers(|provider| {
|
||||
@@ -412,8 +404,6 @@ mod tests {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
}))),
|
||||
tool_complete_result: Mutex::new(Some(Ok(ToolCompletionResponse {
|
||||
content: Some(content.to_string()),
|
||||
@@ -421,8 +411,6 @@ mod tests {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
}))),
|
||||
}
|
||||
}
|
||||
@@ -804,8 +792,6 @@ mod tests {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -831,8 +817,6 @@ mod tests {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+179
-174
@@ -14,7 +14,6 @@ mod nearai_chat;
|
||||
mod provider;
|
||||
mod reasoning;
|
||||
pub mod recording;
|
||||
pub mod registry;
|
||||
pub mod response_cache;
|
||||
pub mod retry;
|
||||
mod rig_adapter;
|
||||
@@ -25,16 +24,14 @@ pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
||||
pub use failover::{CooldownConfig, FailoverProvider};
|
||||
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
|
||||
pub use provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
||||
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||
ToolDefinition, ToolResult,
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
|
||||
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
|
||||
};
|
||||
pub use reasoning::{
|
||||
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
||||
TOOL_INTENT_NUDGE, TokenUsage, ToolSelection, is_silent_reply, llm_signals_tool_intent,
|
||||
TokenUsage, ToolSelection, is_silent_reply,
|
||||
};
|
||||
pub use recording::RecordingLlm;
|
||||
pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry};
|
||||
pub use response_cache::{CachedProvider, ResponseCacheConfig};
|
||||
pub use retry::{RetryConfig, RetryProvider};
|
||||
pub use rig_adapter::RigAdapter;
|
||||
@@ -46,29 +43,26 @@ use std::sync::Arc;
|
||||
use rig::client::CompletionClient;
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use crate::config::{LlmConfig, NearAiConfig, RegistryProviderConfig};
|
||||
use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
|
||||
use crate::error::LlmError;
|
||||
|
||||
/// Create an LLM provider based on configuration.
|
||||
///
|
||||
/// - NearAI backend: Uses session manager for authentication
|
||||
/// - Registry providers: Looked up by protocol and constructed generically
|
||||
/// - `NearAi` backend: Uses session manager for authentication (Responses API)
|
||||
/// or API key (Chat Completions API)
|
||||
/// - Other backends: Use rig-core adapter with provider-specific clients
|
||||
pub fn create_llm_provider(
|
||||
config: &LlmConfig,
|
||||
session: Arc<SessionManager>,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" {
|
||||
return create_llm_provider_with_config(&config.nearai, session);
|
||||
match config.backend {
|
||||
LlmBackend::NearAi => create_llm_provider_with_config(&config.nearai, session),
|
||||
LlmBackend::OpenAi => create_openai_provider(config),
|
||||
LlmBackend::Anthropic => create_anthropic_provider(config),
|
||||
LlmBackend::Ollama => create_ollama_provider(config),
|
||||
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
|
||||
LlmBackend::Tinfoil => create_tinfoil_provider(config),
|
||||
}
|
||||
|
||||
let reg_config = config
|
||||
.provider
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: config.backend.clone(),
|
||||
})?;
|
||||
|
||||
create_registry_provider(reg_config)
|
||||
}
|
||||
|
||||
/// Create an LLM provider from a `NearAiConfig` directly.
|
||||
@@ -93,179 +87,184 @@ pub fn create_llm_provider_with_config(
|
||||
Ok(Arc::new(NearAiChatProvider::new(config.clone(), session)?))
|
||||
}
|
||||
|
||||
/// Create a provider from a registry-resolved config.
|
||||
///
|
||||
/// Dispatches on `RegistryProviderConfig::protocol` to build the appropriate
|
||||
/// rig-core client. This single function replaces what used to be 5 separate
|
||||
/// `create_*_provider` functions.
|
||||
fn create_registry_provider(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
match config.protocol {
|
||||
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
||||
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
||||
ProviderProtocol::Ollama => create_ollama_from_registry(config),
|
||||
fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let oai = config.openai.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::providers::openai;
|
||||
|
||||
// Use CompletionsClient (Chat Completions API) instead of the default Client
|
||||
// (Responses API). The Responses API path in rig-core panics when tool results
|
||||
// are sent back because ironclaw doesn't thread `call_id` through its ToolCall
|
||||
// type. The Chat Completions API works correctly with the existing code.
|
||||
let client: openai::CompletionsClient = if let Some(ref base_url) = oai.base_url {
|
||||
tracing::info!(
|
||||
"Using OpenAI direct API (chat completions, model: {}, base_url: {})",
|
||||
oai.model,
|
||||
base_url,
|
||||
);
|
||||
openai::Client::builder()
|
||||
.base_url(base_url)
|
||||
.api_key(oai.api_key.expose_secret())
|
||||
.build()
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Using OpenAI direct API (chat completions, model: {}, base_url: default)",
|
||||
oai.model,
|
||||
);
|
||||
openai::Client::new(oai.api_key.expose_secret())
|
||||
}
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "openai".to_string(),
|
||||
reason: format!("Failed to create OpenAI client: {}", e),
|
||||
})?
|
||||
.completions_api();
|
||||
|
||||
let model = client.completion_model(&oai.model);
|
||||
Ok(Arc::new(RigAdapter::new(model, &oai.model)))
|
||||
}
|
||||
|
||||
fn create_openai_compat_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
fn create_anthropic_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let anth = config
|
||||
.anthropic
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "anthropic".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::providers::anthropic;
|
||||
|
||||
let client: anthropic::Client = if let Some(ref base_url) = anth.base_url {
|
||||
anthropic::Client::builder()
|
||||
.api_key(anth.api_key.expose_secret())
|
||||
.base_url(base_url)
|
||||
.build()
|
||||
} else {
|
||||
anthropic::Client::new(anth.api_key.expose_secret())
|
||||
}
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "anthropic".to_string(),
|
||||
reason: format!("Failed to create Anthropic client: {}", e),
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&anth.model);
|
||||
tracing::info!(
|
||||
"Using Anthropic direct API (model: {}, base_url: {})",
|
||||
anth.model,
|
||||
anth.base_url.as_deref().unwrap_or("default"),
|
||||
);
|
||||
Ok(Arc::new(RigAdapter::new(model, &anth.model)))
|
||||
}
|
||||
|
||||
fn create_ollama_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let oll = config.ollama.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "ollama".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::client::Nothing;
|
||||
use rig::providers::ollama;
|
||||
|
||||
let client: ollama::Client = ollama::Client::builder()
|
||||
.base_url(&oll.base_url)
|
||||
.api_key(Nothing)
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "ollama".to_string(),
|
||||
reason: format!("Failed to create Ollama client: {}", e),
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&oll.model);
|
||||
tracing::info!(
|
||||
"Using Ollama (base_url: {}, model: {})",
|
||||
oll.base_url,
|
||||
oll.model
|
||||
);
|
||||
Ok(Arc::new(RigAdapter::new(model, &oll.model)))
|
||||
}
|
||||
|
||||
const TINFOIL_BASE_URL: &str = "https://inference.tinfoil.sh/v1";
|
||||
|
||||
fn create_tinfoil_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let tf = config
|
||||
.tinfoil
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "tinfoil".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.base_url(TINFOIL_BASE_URL)
|
||||
.api_key(tf.api_key.expose_secret())
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "tinfoil".to_string(),
|
||||
reason: format!("Failed to create Tinfoil client: {}", e),
|
||||
})?;
|
||||
|
||||
// Tinfoil currently only supports the Chat Completions API and not the newer Responses API,
|
||||
// so we must explicitly select the completions API here (unlike other OpenAI-compatible providers).
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model(&tf.model);
|
||||
tracing::info!("Using Tinfoil private inference (model: {})", tf.model);
|
||||
Ok(Arc::new(RigAdapter::new(model, &tf.model)))
|
||||
}
|
||||
|
||||
fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let compat = config
|
||||
.openai_compatible
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_compatible".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::providers::openai;
|
||||
|
||||
let mut extra_headers = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in &config.extra_headers {
|
||||
for (key, value) in &compat.extra_headers {
|
||||
let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name");
|
||||
tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header name");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let val = match reqwest::header::HeaderValue::from_str(value) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value");
|
||||
tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header value");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
extra_headers.insert(name, val);
|
||||
}
|
||||
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
provider = %config.provider_id,
|
||||
"No API key configured for {}. Requests will likely fail with 401. \
|
||||
Check your .env or secrets store.",
|
||||
config.provider_id,
|
||||
);
|
||||
"no-key".to_string()
|
||||
});
|
||||
|
||||
let mut builder = openai::Client::builder().api_key(&api_key);
|
||||
if !config.base_url.is_empty() {
|
||||
builder = builder.base_url(&config.base_url);
|
||||
}
|
||||
if !extra_headers.is_empty() {
|
||||
builder = builder.http_headers(extra_headers);
|
||||
}
|
||||
|
||||
let client: openai::Client = builder.build().map_err(|e| LlmError::RequestFailed {
|
||||
provider: config.provider_id.clone(),
|
||||
reason: format!("Failed to create OpenAI-compatible client: {e}"),
|
||||
})?;
|
||||
|
||||
// Use CompletionsClient (Chat Completions API) instead of the default
|
||||
// Client (Responses API). The Responses API path in rig-core handles
|
||||
// tool results differently, which breaks IronClaw's tool call flow.
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
tracing::info!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
"Using OpenAI-compatible provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
}
|
||||
|
||||
fn create_anthropic_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
use crate::config::CacheRetention;
|
||||
use crate::config::helpers::optional_env;
|
||||
use rig::providers::anthropic;
|
||||
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string())
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: config.provider_id.clone(),
|
||||
})?;
|
||||
|
||||
let client: anthropic::Client = if config.base_url.is_empty() {
|
||||
anthropic::Client::new(&api_key)
|
||||
} else {
|
||||
anthropic::Client::builder()
|
||||
.api_key(&api_key)
|
||||
.base_url(&config.base_url)
|
||||
.build()
|
||||
}
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: config.provider_id.clone(),
|
||||
reason: format!("Failed to create Anthropic client: {e}"),
|
||||
})?;
|
||||
|
||||
// Resolve prompt cache retention from env (default: Short).
|
||||
// Injects top-level cache_control via additional_params for Anthropic
|
||||
// automatic caching (the API auto-places the breakpoint at the last
|
||||
// cacheable block).
|
||||
let cache_retention: CacheRetention = optional_env("ANTHROPIC_CACHE_RETENTION")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|val| match val.parse::<CacheRetention>() {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
tracing::warn!("Invalid ANTHROPIC_CACHE_RETENTION: {e}; defaulting to short");
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
if cache_retention != CacheRetention::None {
|
||||
tracing::info!(
|
||||
model = %config.model,
|
||||
retention = %cache_retention,
|
||||
"Anthropic automatic prompt caching enabled"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
|
||||
"Using Anthropic provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(
|
||||
RigAdapter::new(model, &config.model).with_cache_retention(cache_retention),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_ollama_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
use rig::client::Nothing;
|
||||
use rig::providers::ollama;
|
||||
|
||||
let client: ollama::Client = ollama::Client::builder()
|
||||
.base_url(&config.base_url)
|
||||
.api_key(Nothing)
|
||||
let client: openai::CompletionsClient = openai::Client::builder()
|
||||
.base_url(&compat.base_url)
|
||||
.api_key(
|
||||
compat
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string())
|
||||
.unwrap_or_else(|| "no-key".to_string()),
|
||||
)
|
||||
.http_headers(extra_headers)
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: config.provider_id.clone(),
|
||||
reason: format!("Failed to create Ollama client: {e}"),
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&config.model);
|
||||
provider: "openai_compatible".to_string(),
|
||||
reason: format!("Failed to create OpenAI-compatible client: {}", e),
|
||||
})?
|
||||
.completions_api();
|
||||
|
||||
let model = client.completion_model(&compat.model);
|
||||
tracing::info!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
"Using Ollama provider"
|
||||
"Using OpenAI-compatible endpoint (chat completions, base_url: {}, model: {})",
|
||||
compat.base_url,
|
||||
compat.model
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
@@ -280,9 +279,9 @@ pub fn create_cheap_llm_provider(
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if config.backend != "nearai" {
|
||||
if config.backend != LlmBackend::NearAi {
|
||||
tracing::warn!(
|
||||
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is '{}', not nearai. \
|
||||
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is {:?}, not NearAi. \
|
||||
Cheap model setting will be ignored.",
|
||||
config.backend
|
||||
);
|
||||
@@ -457,13 +456,16 @@ pub fn build_provider_chain(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::NearAiConfig;
|
||||
use crate::config::{LlmBackend, NearAiConfig};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_nearai_config() -> NearAiConfig {
|
||||
NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
cheap_model: None,
|
||||
base_url: "https://api.near.ai".to_string(),
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: PathBuf::from("/tmp/test-session.json"),
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 3,
|
||||
@@ -480,10 +482,13 @@ mod tests {
|
||||
|
||||
fn test_llm_config() -> LlmConfig {
|
||||
LlmConfig {
|
||||
backend: "nearai".to_string(),
|
||||
session: SessionConfig::default(),
|
||||
backend: LlmBackend::NearAi,
|
||||
nearai: test_nearai_config(),
|
||||
provider: None,
|
||||
openai: None,
|
||||
anthropic: None,
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
tinfoil: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +519,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
|
||||
let mut config = test_llm_config();
|
||||
config.backend = "openai".to_string();
|
||||
config.backend = LlmBackend::OpenAi;
|
||||
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
|
||||
+22
-819
@@ -138,45 +138,13 @@ impl NearAiChatProvider {
|
||||
}
|
||||
|
||||
/// Resolve the Bearer token for the current auth mode.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. `config.api_key` (set at construction from env/config)
|
||||
/// 2. Session token (OAuth flow)
|
||||
/// 3. `NEARAI_API_KEY` env var (set by interactive `api_key_login()`)
|
||||
///
|
||||
/// The env var fallback (#3) only triggers after `ensure_authenticated()`
|
||||
/// runs, because `api_key_login()` sets the env var but not a session token.
|
||||
async fn resolve_bearer_token(&self) -> Result<String, LlmError> {
|
||||
// 1. Config-level API key takes priority
|
||||
if let Some(ref api_key) = self.config.api_key {
|
||||
return Ok(api_key.expose_secret().to_string());
|
||||
}
|
||||
|
||||
// 2. Existing session token (OAuth was already completed)
|
||||
if self.session.has_token().await {
|
||||
Ok(api_key.expose_secret().to_string())
|
||||
} else {
|
||||
let token = self.session.get_token().await?;
|
||||
return Ok(token.expose_secret().to_string());
|
||||
Ok(token.expose_secret().to_string())
|
||||
}
|
||||
|
||||
// No token yet, trigger interactive login
|
||||
self.session.ensure_authenticated().await?;
|
||||
|
||||
// 3. After login, check if a session token was stored (OAuth path)
|
||||
if self.session.has_token().await {
|
||||
let token = self.session.get_token().await?;
|
||||
return Ok(token.expose_secret().to_string());
|
||||
}
|
||||
|
||||
// 4. api_key_login() sets NEARAI_API_KEY env var but not a session token
|
||||
if let Ok(key) = std::env::var("NEARAI_API_KEY")
|
||||
&& !key.is_empty()
|
||||
{
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
Err(LlmError::AuthFailed {
|
||||
provider: "nearai".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a single request to the chat completions API.
|
||||
@@ -499,8 +467,6 @@ impl LlmProvider for NearAiChatProvider {
|
||||
finish_reason,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -606,8 +572,6 @@ impl LlmProvider for NearAiChatProvider {
|
||||
finish_reason,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -671,68 +635,11 @@ struct ChatCompletionRequest {
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
/// Content field that serializes as either a string or an array of content parts.
|
||||
///
|
||||
/// - `Text("hello")` → `"content": "hello"`
|
||||
/// - `Parts([...])` → `"content": [{"type": "text", ...}, {"type": "image_url", ...}]`
|
||||
#[derive(Debug, Clone)]
|
||||
enum MessageContent {
|
||||
Text(String),
|
||||
Parts(Vec<crate::llm::ContentPart>),
|
||||
}
|
||||
|
||||
impl Serialize for MessageContent {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
MessageContent::Text(s) => serializer.serialize_str(s),
|
||||
MessageContent::Parts(parts) => parts.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for MessageContent {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
use serde::de;
|
||||
use serde_json::Value;
|
||||
|
||||
let val = Value::deserialize(deserializer)?;
|
||||
match val {
|
||||
Value::String(s) => Ok(MessageContent::Text(s)),
|
||||
Value::Array(arr) => Ok(MessageContent::Text(
|
||||
// For deserialization (responses), we only need the text content
|
||||
arr.iter()
|
||||
.find_map(|v| {
|
||||
if v.get("type")?.as_str()? == "text" {
|
||||
v.get("text")?.as_str().map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)),
|
||||
Value::Null => Ok(MessageContent::Text(String::new())),
|
||||
_ => Err(de::Error::custom(
|
||||
"expected string, array, or null for content",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageContent {
|
||||
fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
MessageContent::Text(s) if !s.is_empty() => Some(s),
|
||||
MessageContent::Text(_) => None,
|
||||
MessageContent::Parts(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ChatCompletionMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<MessageContent>,
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -900,8 +807,10 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) {
|
||||
// Convert assistant tool_calls into descriptive text
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(text) = msg.content.as_ref().and_then(|c| c.as_text()) {
|
||||
parts.push(text.to_string());
|
||||
if let Some(ref text) = msg.content
|
||||
&& !text.is_empty()
|
||||
{
|
||||
parts.push(text.clone());
|
||||
}
|
||||
for tc in calls {
|
||||
parts.push(format!(
|
||||
@@ -911,7 +820,7 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
}
|
||||
ChatCompletionMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(MessageContent::Text(parts.join("\n"))),
|
||||
content: Some(parts.join("\n")),
|
||||
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
@@ -920,13 +829,10 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
} else if msg.role == "tool" {
|
||||
// Convert tool result into a user message
|
||||
let tool_name = msg.name.as_deref().unwrap_or("unknown");
|
||||
let result = msg.content.as_ref().and_then(|c| c.as_text()).unwrap_or("");
|
||||
let result = msg.content.as_deref().unwrap_or("");
|
||||
ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(MessageContent::Text(format!(
|
||||
"[Tool `{}` returned: {}]",
|
||||
tool_name, result
|
||||
))),
|
||||
content: Some(format!("[Tool `{}` returned: {}]", tool_name, result)),
|
||||
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
@@ -964,13 +870,8 @@ impl From<ChatMessage> for ChatCompletionMessage {
|
||||
|
||||
let content = if role == "assistant" && tool_calls.is_some() && msg.content.is_empty() {
|
||||
None
|
||||
} else if !msg.content_parts.is_empty() {
|
||||
// Build multimodal content array: text + image parts
|
||||
let mut parts = vec![crate::llm::ContentPart::Text { text: msg.content }];
|
||||
parts.extend(msg.content_parts);
|
||||
Some(MessageContent::Parts(parts))
|
||||
} else {
|
||||
Some(MessageContent::Text(msg.content))
|
||||
Some(msg.content)
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -1082,6 +983,8 @@ mod tests {
|
||||
NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: std::path::PathBuf::from("/tmp/session.json"),
|
||||
api_key: Some(secrecy::SecretString::from("test-key".to_string())),
|
||||
cheap_model: None,
|
||||
fallback_model: None,
|
||||
@@ -1135,10 +1038,7 @@ mod tests {
|
||||
let msg = ChatMessage::user("Hello");
|
||||
let chat_msg: ChatCompletionMessage = msg.into();
|
||||
assert_eq!(chat_msg.role, "user");
|
||||
assert_eq!(
|
||||
chat_msg.content.as_ref().and_then(|c| c.as_text()),
|
||||
Some("Hello")
|
||||
);
|
||||
assert_eq!(chat_msg.content, Some("Hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1212,14 +1112,14 @@ mod tests {
|
||||
let messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(MessageContent::Text("You are helpful.".to_string())),
|
||||
content: Some("You are helpful.".to_string()),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(MessageContent::Text("Hello".to_string())),
|
||||
content: Some("Hello".to_string()),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -1236,7 +1136,7 @@ mod tests {
|
||||
let messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(MessageContent::Text("test".to_string())),
|
||||
content: Some("test".to_string()),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -1257,7 +1157,7 @@ mod tests {
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(MessageContent::Text("hi".to_string())),
|
||||
content: Some("hi".to_string()),
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
name: Some("echo".to_string()),
|
||||
tool_calls: None,
|
||||
@@ -1274,7 +1174,6 @@ mod tests {
|
||||
result[1]
|
||||
.content
|
||||
.as_ref()
|
||||
.and_then(|c| c.as_text())
|
||||
.unwrap()
|
||||
.contains("[Called tool `echo`")
|
||||
);
|
||||
@@ -1286,7 +1185,6 @@ mod tests {
|
||||
result[2]
|
||||
.content
|
||||
.as_ref()
|
||||
.and_then(|c| c.as_text())
|
||||
.unwrap()
|
||||
.contains("[Tool `echo` returned: hi]")
|
||||
);
|
||||
@@ -1297,7 +1195,7 @@ mod tests {
|
||||
let messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(MessageContent::Text("Let me check that.".to_string())),
|
||||
content: Some("Let me check that.".to_string()),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: Some(vec![ChatCompletionToolCall {
|
||||
@@ -1311,7 +1209,7 @@ mod tests {
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(MessageContent::Text("found it".to_string())),
|
||||
content: Some("found it".to_string()),
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
@@ -1319,11 +1217,7 @@ mod tests {
|
||||
];
|
||||
|
||||
let result = flatten_tool_messages(messages);
|
||||
let text = result[0]
|
||||
.content
|
||||
.as_ref()
|
||||
.and_then(|c| c.as_text())
|
||||
.unwrap();
|
||||
let text = result[0].content.as_ref().unwrap();
|
||||
assert!(text.starts_with("Let me check that."));
|
||||
assert!(text.contains("[Called tool `search`"));
|
||||
}
|
||||
@@ -1505,695 +1399,4 @@ mod tests {
|
||||
);
|
||||
assert!(tool_calls.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_config_api_key() {
|
||||
// When config.api_key is set, it takes top priority.
|
||||
let cfg = test_nearai_config("http://localhost:8318");
|
||||
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(token, "test-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_session_token() {
|
||||
// When config.api_key is None but session has a token, use session token.
|
||||
let mut cfg = test_nearai_config("http://localhost:8318");
|
||||
cfg.api_key = None;
|
||||
let session = test_session();
|
||||
session
|
||||
.set_token(secrecy::SecretString::from("session-tok-123".to_string()))
|
||||
.await;
|
||||
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(token, "session-tok-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_session_beats_env_var() {
|
||||
// Session token takes priority over NEARAI_API_KEY env var.
|
||||
// This prevents unexpected auth mode switches mid-run.
|
||||
let mut cfg = test_nearai_config("http://localhost:8318");
|
||||
cfg.api_key = None;
|
||||
let session = test_session();
|
||||
session
|
||||
.set_token(secrecy::SecretString::from("oauth-token".to_string()))
|
||||
.await;
|
||||
|
||||
// Set env var that should NOT be used when session token exists
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::set_var("NEARAI_API_KEY", "env-api-key-should-not-win");
|
||||
}
|
||||
|
||||
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(
|
||||
token, "oauth-token",
|
||||
"session token must take priority over env var"
|
||||
);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::remove_var("NEARAI_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_config_beats_session_and_env() {
|
||||
// Config API key should win even when session token AND env var are set.
|
||||
let cfg = test_nearai_config("http://localhost:8318");
|
||||
let session = test_session();
|
||||
session
|
||||
.set_token(secrecy::SecretString::from("session-tok".to_string()))
|
||||
.await;
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::set_var("NEARAI_API_KEY", "env-key");
|
||||
}
|
||||
|
||||
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(
|
||||
token, "test-key",
|
||||
"config api_key must win over session token and env var"
|
||||
);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::remove_var("NEARAI_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
// -- ModelInfo serde alias tests ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_model_info_deserialize_with_name_field() {
|
||||
let json = r#"{"name": "claude-3-5-sonnet"}"#;
|
||||
let info: ModelInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.name, "claude-3-5-sonnet");
|
||||
assert!(info.provider.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_info_deserialize_with_id_alias() {
|
||||
let json = r#"{"id": "gpt-4o", "provider": "openai"}"#;
|
||||
let info: ModelInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.name, "gpt-4o");
|
||||
assert_eq!(info.provider, Some("openai".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_info_deserialize_with_model_alias() {
|
||||
let json = r#"{"model": "llama-3.1-70b"}"#;
|
||||
let info: ModelInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.name, "llama-3.1-70b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_info_roundtrip_serializes_as_name() {
|
||||
let info = ModelInfo {
|
||||
name: "test-model".to_string(),
|
||||
provider: Some("nearai".to_string()),
|
||||
};
|
||||
let json = serde_json::to_value(&info).unwrap();
|
||||
// Serialization always uses the field name "name", not the aliases
|
||||
assert_eq!(json["name"], "test-model");
|
||||
assert_eq!(json["provider"], "nearai");
|
||||
assert!(json.get("id").is_none());
|
||||
assert!(json.get("model").is_none());
|
||||
}
|
||||
|
||||
// -- ChatCompletionRequest serialization ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_request_serialization_minimal() {
|
||||
let req = ChatCompletionRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
messages: vec![ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(MessageContent::Text("Hello".to_string())),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["model"], "gpt-4o");
|
||||
assert_eq!(json["messages"][0]["role"], "user");
|
||||
assert_eq!(json["messages"][0]["content"], "Hello");
|
||||
// Optional fields should be absent, not null
|
||||
assert!(json.get("temperature").is_none());
|
||||
assert!(json.get("max_tokens").is_none());
|
||||
assert!(json.get("tools").is_none());
|
||||
assert!(json.get("tool_choice").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_serialization_with_tools() {
|
||||
let req = ChatCompletionRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
messages: vec![],
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(1024),
|
||||
tools: Some(vec![ChatCompletionTool {
|
||||
tool_type: "function".to_string(),
|
||||
function: ChatCompletionFunction {
|
||||
name: "get_weather".to_string(),
|
||||
description: Some("Get the weather".to_string()),
|
||||
parameters: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string"}
|
||||
}
|
||||
})),
|
||||
},
|
||||
}]),
|
||||
tool_choice: Some("auto".to_string()),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
// f32 precision: 0.7f32 serializes as 0.699999988... in JSON
|
||||
let temp = json["temperature"].as_f64().unwrap();
|
||||
assert!(
|
||||
(temp - 0.7).abs() < 0.001,
|
||||
"temperature should be ~0.7, got {temp}"
|
||||
);
|
||||
assert_eq!(json["max_tokens"], 1024);
|
||||
assert_eq!(json["tool_choice"], "auto");
|
||||
// Tool uses "type" key (via rename), not "tool_type"
|
||||
assert_eq!(json["tools"][0]["type"], "function");
|
||||
assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_omits_null_content_on_assistant_messages() {
|
||||
// When an assistant message has tool_calls but no content, content
|
||||
// should serialize as absent (skip_serializing_if) not "content": null.
|
||||
let msg = ChatCompletionMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: Some(vec![ChatCompletionToolCall {
|
||||
id: "call_1".to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: ChatCompletionToolCallFunction {
|
||||
name: "echo".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
}]),
|
||||
};
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
assert!(
|
||||
json.get("content").is_none(),
|
||||
"content should be omitted when None"
|
||||
);
|
||||
assert!(json.get("tool_call_id").is_none());
|
||||
assert!(json.get("name").is_none());
|
||||
assert!(json["tool_calls"].is_array());
|
||||
}
|
||||
|
||||
// -- ChatCompletionResponse deserialization -------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_response_deserialize_basic() {
|
||||
let json = serde_json::json!({
|
||||
"id": "chatcmpl-abc123",
|
||||
"object": "chat.completion",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Hello!"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15
|
||||
}
|
||||
});
|
||||
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(resp.id, Some("chatcmpl-abc123".to_string()));
|
||||
assert_eq!(resp.choices.len(), 1);
|
||||
assert_eq!(resp.choices[0].message.content, Some("Hello!".to_string()));
|
||||
assert_eq!(resp.choices[0].finish_reason, Some("stop".to_string()));
|
||||
let usage = resp.usage.unwrap();
|
||||
assert_eq!(usage.prompt_tokens, Some(10));
|
||||
assert_eq!(usage.completion_tokens, Some(5));
|
||||
assert_eq!(usage.total_tokens, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_deserialize_missing_optional_fields() {
|
||||
// Minimal response: no id, no usage, no finish_reason
|
||||
let json = serde_json::json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Hi"
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
});
|
||||
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
|
||||
assert!(resp.id.is_none());
|
||||
assert!(resp.usage.is_none());
|
||||
assert!(resp.choices[0].finish_reason.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_deserialize_with_tool_calls() {
|
||||
let json = serde_json::json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"NYC\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "call_def",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_time",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
|
||||
let tc = resp.choices[0].message.tool_calls.as_ref().unwrap();
|
||||
assert_eq!(tc.len(), 2);
|
||||
assert_eq!(tc[0].id, "call_abc");
|
||||
assert_eq!(tc[0].function.name, "get_weather");
|
||||
assert_eq!(tc[0].function.arguments, "{\"city\":\"NYC\"}");
|
||||
assert_eq!(tc[1].id, "call_def");
|
||||
assert_eq!(tc[1].function.name, "get_time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_deserialize_ignores_unknown_fields() {
|
||||
// Real API responses have extra fields like "object", "created", "model"
|
||||
let json = serde_json::json!({
|
||||
"id": "chatcmpl-xyz",
|
||||
"object": "chat.completion",
|
||||
"created": 1700000000,
|
||||
"model": "gpt-4o",
|
||||
"system_fingerprint": "fp_abc123",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "ok"
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"logprobs": null
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 6
|
||||
}
|
||||
});
|
||||
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(resp.choices[0].message.content, Some("ok".to_string()));
|
||||
}
|
||||
|
||||
// -- parse_usage and saturate_u32 -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_with_all_fields() {
|
||||
let usage = ChatCompletionUsage {
|
||||
prompt_tokens: Some(100),
|
||||
completion_tokens: Some(50),
|
||||
total_tokens: Some(150),
|
||||
};
|
||||
assert_eq!(parse_usage(Some(&usage)), (100, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_none() {
|
||||
assert_eq!(parse_usage(None), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_missing_completion_falls_back_to_total_minus_prompt() {
|
||||
let usage = ChatCompletionUsage {
|
||||
prompt_tokens: Some(100),
|
||||
completion_tokens: None,
|
||||
total_tokens: Some(180),
|
||||
};
|
||||
// output = total - prompt = 80
|
||||
assert_eq!(parse_usage(Some(&usage)), (100, 80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_missing_completion_and_prompt_uses_total() {
|
||||
let usage = ChatCompletionUsage {
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
total_tokens: Some(200),
|
||||
};
|
||||
// input = 0 (no prompt), output = total = 200
|
||||
assert_eq!(parse_usage(Some(&usage)), (0, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_all_none() {
|
||||
let usage = ChatCompletionUsage {
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
total_tokens: None,
|
||||
};
|
||||
assert_eq!(parse_usage(Some(&usage)), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saturate_u32_within_range() {
|
||||
assert_eq!(saturate_u32(0), 0);
|
||||
assert_eq!(saturate_u32(42), 42);
|
||||
assert_eq!(saturate_u32(u32::MAX as u64), u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saturate_u32_overflow_clamps() {
|
||||
assert_eq!(saturate_u32(u32::MAX as u64 + 1), u32::MAX);
|
||||
assert_eq!(saturate_u32(u64::MAX), u32::MAX);
|
||||
}
|
||||
|
||||
// -- Pricing types deserialization ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_model_cost_deserialize() {
|
||||
let json = r#"{"amount": 3.0, "scale": 6}"#;
|
||||
let mc: ModelCost = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(mc.amount, 3.0);
|
||||
assert_eq!(mc.scale, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_cost_scale_defaults_to_zero() {
|
||||
let json = r#"{"amount": 0.5}"#;
|
||||
let mc: ModelCost = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(mc.scale, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_cost_to_decimal_negative_scale() {
|
||||
// amount=2, scale=-3 → 2 * 10^3 = 2000
|
||||
let mc = ModelCost {
|
||||
amount: 2.0,
|
||||
scale: -3,
|
||||
};
|
||||
let result = model_cost_to_decimal(&mc).unwrap();
|
||||
assert_eq!(result, dec!(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pricing_model_entry_deserialize_camel_case_aliases() {
|
||||
let json = serde_json::json!({
|
||||
"modelId": "claude-3-5-sonnet",
|
||||
"inputCostPerToken": {"amount": 3.0, "scale": 6},
|
||||
"outputCostPerToken": {"amount": 15.0, "scale": 6},
|
||||
"metadata": {"aliases": ["claude-sonnet", "claude-3.5-sonnet"]}
|
||||
});
|
||||
let entry: PricingModelEntry = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(entry.model_id, Some("claude-3-5-sonnet".to_string()));
|
||||
let input = model_cost_to_decimal(entry.input_cost_per_token.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(input, dec!(0.000003));
|
||||
let output = model_cost_to_decimal(entry.output_cost_per_token.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(output, dec!(0.000015));
|
||||
assert_eq!(
|
||||
entry.metadata.unwrap().aliases,
|
||||
vec!["claude-sonnet", "claude-3.5-sonnet"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pricing_model_entry_deserialize_snake_case() {
|
||||
let json = serde_json::json!({
|
||||
"model_id": "gpt-4o",
|
||||
"input_cost_per_token": {"amount": 5.0, "scale": 6},
|
||||
"output_cost_per_token": {"amount": 15.0, "scale": 6}
|
||||
});
|
||||
let entry: PricingModelEntry = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(entry.model_id, Some("gpt-4o".to_string()));
|
||||
assert!(entry.input_cost_per_token.is_some());
|
||||
assert!(entry.metadata.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pricing_response_models_wrapper() {
|
||||
let json = serde_json::json!({
|
||||
"models": [
|
||||
{"model_id": "m1", "input_cost_per_token": {"amount": 1.0, "scale": 6},
|
||||
"output_cost_per_token": {"amount": 2.0, "scale": 6}}
|
||||
]
|
||||
});
|
||||
let resp: PricingResponse = serde_json::from_value(json).unwrap();
|
||||
assert!(resp.models.is_some());
|
||||
assert_eq!(resp.models.unwrap().len(), 1);
|
||||
assert!(resp.data.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pricing_response_data_wrapper() {
|
||||
let json = serde_json::json!({
|
||||
"data": [
|
||||
{"model_id": "m1"},
|
||||
{"model_id": "m2"}
|
||||
]
|
||||
});
|
||||
let resp: PricingResponse = serde_json::from_value(json).unwrap();
|
||||
assert!(resp.models.is_none());
|
||||
assert_eq!(resp.data.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
// -- flatten_tool_messages edge cases -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_flatten_tool_result_missing_name_uses_unknown() {
|
||||
let messages = vec![ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(MessageContent::Text("result data".to_string())),
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}];
|
||||
let result = flatten_tool_messages(messages);
|
||||
assert_eq!(result[0].role, "user");
|
||||
assert!(
|
||||
result[0]
|
||||
.content
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.contains("[Tool `unknown` returned:")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_tool_result_missing_content_uses_empty() {
|
||||
let messages = vec![ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: None,
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
name: Some("my_tool".to_string()),
|
||||
tool_calls: None,
|
||||
}];
|
||||
let result = flatten_tool_messages(messages);
|
||||
assert_eq!(result[0].role, "user");
|
||||
assert!(
|
||||
result[0]
|
||||
.content
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.contains("[Tool `my_tool` returned: ]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_multiple_tool_calls_in_single_assistant_message() {
|
||||
let messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: Some(vec![
|
||||
ChatCompletionToolCall {
|
||||
id: "call_1".to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: ChatCompletionToolCallFunction {
|
||||
name: "search".to_string(),
|
||||
arguments: r#"{"q":"a"}"#.to_string(),
|
||||
},
|
||||
},
|
||||
ChatCompletionToolCall {
|
||||
id: "call_2".to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: ChatCompletionToolCallFunction {
|
||||
name: "fetch".to_string(),
|
||||
arguments: r#"{"url":"http://x"}"#.to_string(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(MessageContent::Text("found".to_string())),
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(MessageContent::Text("fetched".to_string())),
|
||||
tool_call_id: Some("call_2".to_string()),
|
||||
name: Some("fetch".to_string()),
|
||||
tool_calls: None,
|
||||
},
|
||||
];
|
||||
let result = flatten_tool_messages(messages);
|
||||
assert_eq!(result.len(), 3);
|
||||
// Assistant message has both calls described
|
||||
let assistant_text = result[0].content.as_ref().unwrap().as_text().unwrap();
|
||||
assert!(assistant_text.contains("[Called tool `search`"));
|
||||
assert!(assistant_text.contains("[Called tool `fetch`"));
|
||||
assert!(result[0].tool_calls.is_none());
|
||||
// Both tool results become user messages
|
||||
assert_eq!(result[1].role, "user");
|
||||
assert_eq!(result[2].role, "user");
|
||||
}
|
||||
|
||||
// -- ChatMessage → ChatCompletionMessage edge cases -----------------------
|
||||
|
||||
#[test]
|
||||
fn test_assistant_empty_content_with_tool_calls_becomes_none() {
|
||||
// When content is empty string and tool_calls are present, content
|
||||
// should be None to avoid sending `"content": ""` which some APIs reject.
|
||||
let msg = ChatMessage::assistant_with_tool_calls(
|
||||
None,
|
||||
vec![ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "test".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
);
|
||||
let chat_msg: ChatCompletionMessage = msg.into();
|
||||
assert!(
|
||||
chat_msg.content.is_none(),
|
||||
"empty content with tool_calls should serialize as None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_message_conversion() {
|
||||
let msg = ChatMessage::system("You are a helpful assistant.");
|
||||
let chat_msg: ChatCompletionMessage = msg.into();
|
||||
assert_eq!(chat_msg.role, "system");
|
||||
assert_eq!(
|
||||
chat_msg.content.as_ref().unwrap().as_text().unwrap(),
|
||||
"You are a helpful assistant."
|
||||
);
|
||||
assert!(chat_msg.tool_calls.is_none());
|
||||
assert!(chat_msg.tool_call_id.is_none());
|
||||
}
|
||||
|
||||
// -- ChatCompletionUsage deserialization -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_usage_deserialize_partial_fields() {
|
||||
// Some providers only return total_tokens
|
||||
let json = r#"{"total_tokens": 500}"#;
|
||||
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
|
||||
assert!(usage.prompt_tokens.is_none());
|
||||
assert!(usage.completion_tokens.is_none());
|
||||
assert_eq!(usage.total_tokens, Some(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_deserialize_empty_object() {
|
||||
let json = "{}";
|
||||
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
|
||||
assert!(usage.prompt_tokens.is_none());
|
||||
assert!(usage.completion_tokens.is_none());
|
||||
assert!(usage.total_tokens.is_none());
|
||||
}
|
||||
|
||||
// -- ChatCompletionToolCall serde roundtrip --------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_serde_roundtrip() {
|
||||
let tc = ChatCompletionToolCall {
|
||||
id: "call_abc".to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: ChatCompletionToolCallFunction {
|
||||
name: "get_weather".to_string(),
|
||||
arguments: r#"{"city":"London"}"#.to_string(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&tc).unwrap();
|
||||
// "type" not "call_type" in serialized form
|
||||
assert_eq!(json["type"], "function");
|
||||
assert!(json.get("call_type").is_none());
|
||||
assert_eq!(json["id"], "call_abc");
|
||||
|
||||
// Deserialize back
|
||||
let deserialized: ChatCompletionToolCall = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(deserialized.id, "call_abc");
|
||||
assert_eq!(deserialized.call_type, "function");
|
||||
assert_eq!(deserialized.function.name, "get_weather");
|
||||
assert_eq!(deserialized.function.arguments, r#"{"city":"London"}"#);
|
||||
}
|
||||
|
||||
// -- api_url edge cases ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_api_url_with_trailing_v1_slash() {
|
||||
let cfg = test_nearai_config("http://example.com/v1/");
|
||||
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
|
||||
// Trailing slash gets trimmed, then /v1 is detected
|
||||
assert_eq!(provider.api_url("models"), "http://example.com/v1/models");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_url_with_deep_base_path() {
|
||||
let cfg = test_nearai_config("http://example.com/api/proxy");
|
||||
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
|
||||
assert_eq!(
|
||||
provider.api_url("chat/completions"),
|
||||
"http://example.com/api/proxy/v1/chat/completions"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,38 +16,11 @@ pub enum Role {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// A part of multimodal message content (OpenAI Chat Completions format).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentPart {
|
||||
/// Text content part.
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
/// Image URL content part (supports data: URLs for inline base64 images).
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: ImageUrl },
|
||||
}
|
||||
|
||||
/// Image URL reference for multimodal content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageUrl {
|
||||
/// URL or data: URI (e.g., "data:image/jpeg;base64,...").
|
||||
pub url: String,
|
||||
/// Detail level hint: "auto", "low", or "high".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// A message in a conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
/// Multimodal content parts (images, etc.).
|
||||
/// When non-empty, providers serialize content as an array of parts
|
||||
/// (with `content` included as a text part) instead of a plain string.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub content_parts: Vec<ContentPart>,
|
||||
/// Tool call ID if this is a tool result message.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
@@ -66,7 +39,6 @@ impl ChatMessage {
|
||||
Self {
|
||||
role: Role::System,
|
||||
content: content.into(),
|
||||
content_parts: Vec::new(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -78,21 +50,6 @@ impl ChatMessage {
|
||||
Self {
|
||||
role: Role::User,
|
||||
content: content.into(),
|
||||
content_parts: Vec::new(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message with multimodal content parts (e.g., images).
|
||||
///
|
||||
/// The text `content` is included as the primary text alongside the parts.
|
||||
pub fn user_with_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
||||
Self {
|
||||
role: Role::User,
|
||||
content: content.into(),
|
||||
content_parts: parts,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -104,7 +61,6 @@ impl ChatMessage {
|
||||
Self {
|
||||
role: Role::Assistant,
|
||||
content: content.into(),
|
||||
content_parts: Vec::new(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -119,7 +75,6 @@ impl ChatMessage {
|
||||
Self {
|
||||
role: Role::Assistant,
|
||||
content: content.unwrap_or_default(),
|
||||
content_parts: Vec::new(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
@@ -139,7 +94,6 @@ impl ChatMessage {
|
||||
Self {
|
||||
role: Role::Tool,
|
||||
content: content.into(),
|
||||
content_parts: Vec::new(),
|
||||
tool_call_id: Some(tool_call_id.into()),
|
||||
name: Some(name.into()),
|
||||
tool_calls: None,
|
||||
@@ -199,12 +153,6 @@ pub struct CompletionResponse {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub finish_reason: FinishReason,
|
||||
/// Tokens read from the provider's server-side prompt cache (Anthropic).
|
||||
/// Zero when caching is not supported or on a cache miss.
|
||||
pub cache_read_input_tokens: u32,
|
||||
/// Tokens written to the provider's server-side prompt cache (Anthropic).
|
||||
/// Zero when caching is not supported or no new prefix was cached.
|
||||
pub cache_creation_input_tokens: u32,
|
||||
}
|
||||
|
||||
/// Why the completion finished.
|
||||
@@ -306,10 +254,6 @@ pub struct ToolCompletionResponse {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub finish_reason: FinishReason,
|
||||
/// Tokens read from the provider's server-side prompt cache (Anthropic).
|
||||
pub cache_read_input_tokens: u32,
|
||||
/// Tokens written to the provider's server-side prompt cache (Anthropic).
|
||||
pub cache_creation_input_tokens: u32,
|
||||
}
|
||||
|
||||
/// Metadata about a model returned by the provider's API.
|
||||
@@ -384,23 +328,6 @@ pub trait LlmProvider: Send + Sync {
|
||||
let (input_cost, output_cost) = self.cost_per_token();
|
||||
input_cost * Decimal::from(input_tokens) + output_cost * Decimal::from(output_tokens)
|
||||
}
|
||||
|
||||
/// Cost multiplier for cache-creation tokens (Anthropic prompt caching).
|
||||
///
|
||||
/// Returns `1.0` by default (no surcharge). Anthropic providers return
|
||||
/// `1.25` for 5-minute TTL or `2.0` for 1-hour TTL.
|
||||
fn cache_write_multiplier(&self) -> Decimal {
|
||||
Decimal::ONE
|
||||
}
|
||||
|
||||
/// Discount divisor for cache-read tokens.
|
||||
///
|
||||
/// Cached-read cost = `input_rate / cache_read_discount()`.
|
||||
/// Returns `1` by default (no discount). Anthropic returns `10` (90% off),
|
||||
/// OpenAI would return `2` (50% off).
|
||||
fn cache_read_discount(&self) -> Decimal {
|
||||
Decimal::ONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a message list to ensure tool_use / tool_result integrity.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user