diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 904a0945..d3377b6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -350,61 +350,12 @@ jobs: name: oxo-call-${{ matrix.target }} path: dist/*.zip - # ─── Build WebAssembly binary (always, release artifacts only on tags) ────── - build-wasm: - name: Build WebAssembly (wasm32-wasip1) - runs-on: ubuntu-latest - needs: [test] - # always() breaks the skip-cascade from sync-version through test. - if: always() && needs.test.result == 'success' - - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ (github.event_name == 'workflow_dispatch' && inputs.tag != '') && format('refs/tags/{0}', inputs.tag) || github.ref }} - - - name: Install Rust (stable) with wasm32-wasip1 - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-wasip1 - - - name: Cache cargo - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-wasm32-wasip1-${{ hashFiles('**/Cargo.lock') }} - - - name: Build release (wasm32-wasip1) - run: cargo build --release --target wasm32-wasip1 - - - name: Package binary - if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.tag != '') - shell: bash - run: | - set -euxo pipefail - BIN_NAME="oxo-call" - TARGET_DIR="target/wasm32-wasip1/release" - mkdir -p dist - tar -C "${TARGET_DIR}" -czvf \ - "dist/${BIN_NAME}-${RELEASE_TAG}-wasm32-wasip1.tar.gz" \ - "${BIN_NAME}.wasm" - - - name: Upload artifact - if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.tag != '') - uses: actions/upload-artifact@v7 - with: - name: oxo-call-wasm32-wasip1 - path: dist/*.tar.gz - # ─── GitHub Release (attach all binary artifacts) ───────────────────────── release: permissions: contents: write name: GitHub Release - needs: [sync-version, build-linux, build-macos, build-windows, build-wasm] + needs: [sync-version, build-linux, build-macos, build-windows] runs-on: ubuntu-latest if: >- always() && @@ -412,8 +363,7 @@ jobs: (needs.sync-version.result == 'success' || needs.sync-version.result == 'skipped') && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' && - needs.build-windows.result == 'success' && - needs.build-wasm.result == 'success' + needs.build-windows.result == 'success' steps: - uses: actions/checkout@v6 diff --git a/Cargo.toml b/Cargo.toml index 805ca1f7..4c225472 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,9 +34,6 @@ tracing = "0.1" regex = "1.12.3" lru = "0.12" hex = "0.4" - -# ── Native-only dependencies (not available on wasm32) ──────────────────────── -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1", features = ["full"] } reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } directories = "6.0" @@ -44,10 +41,6 @@ which = "8.0" indicatif = "0.18" termimad = "0.34" -# ── WebAssembly dependencies ────────────────────────────────────────────────── -[target.'cfg(target_arch = "wasm32")'.dependencies] -tokio = { version = "1", features = ["rt", "macros"] } - [dev-dependencies] tempfile = "3" wiremock = "0.6.5" diff --git a/docs/guide/src/reference/architecture.md b/docs/guide/src/reference/architecture.md index 76d0c0ea..d2e4f088 100644 --- a/docs/guide/src/reference/architecture.md +++ b/docs/guide/src/reference/architecture.md @@ -1,7 +1,5 @@ # System Architecture -![System Architecture Diagram](../images/architecture.svg) - ## Overview oxo-call is a Rust workspace with three crates: @@ -12,42 +10,177 @@ oxo-call is a Rust workspace with three crates: | `crates/license-issuer` | Maintainer-only license signing tool | No | | `crates/oxo-bench` | Benchmarking and evaluation suite | No | -The architecture is designed to make command generation usable in production science and engineering workflows, not just impressive in a demo. The key idea is that `oxo-call` reduces ambiguity before the model answers, then records enough provenance afterward for users to trust and reproduce the result. +The architecture is designed around a layered system that makes command generation usable in production science and engineering workflows. The core idea: **Describe your task in plain language — oxo-call fetches the tool's documentation, asks your LLM backend to generate the exact flags you need.** -## Module Structure +## Layered Architecture + +```text +┌─────────────────────────────────────────────────────────────────────────┐ +│ User Interface Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ CLI Client │ │ Chat Mode │ │ Web API │ │ SDK/API │ │ +│ │ (cli.rs) │ │ (chat.rs) │ │ (server.rs) │ │ (lib.rs) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Language Processing Layer │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Universal Task Translator (Any Language → Optimized English) │ │ +│ │ • task_normalizer.rs • task_complexity.rs • sanitize.rs │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ AI Orchestration Layer │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Runner Pipeline (runner/) │ │ +│ │ • core.rs (orchestration) • batch.rs (parallel execution) │ │ +│ │ • retry.rs (error recovery) • utils.rs (tool detection) │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ LLM Integration (llm/) │ │ +│ │ • provider.rs (multi-provider support) • types.rs (traits) │ │ +│ │ • Copilot / OpenAI / Anthropic / Ollama / DeepSeek / etc. │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Command Generation (generator.rs) │ │ +│ │ • LLM-based • Rule-based • Composite strategies │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Knowledge Enhancement Layer │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Documentation System │ │ +│ │ • docs.rs (resolver + caching) • doc_processor.rs (extraction)│ │ +│ │ • doc_summarizer.rs (compression) • index.rs (search index) │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Skill │ │ MCP Skill │ │ Mini Skill │ │ Context │ │ +│ │ Manager │ │ Provider │ │ Cache │ │ Builder │ │ +│ │ (skill.rs) │ │ (mcp.rs) │ │ │ │ (context.rs│ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Execution & Monitoring Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Workflow │ │ DAG │ │ History │ │ Job │ │ +│ │ Templates │ │ Engine │ │ Tracker │ │ Manager │ │ +│ │ (workflow.rs) │ │ (engine.rs) │ │ (history.rs) │ │ (job.rs) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Workflow Graph Visualization (workflow_graph.rs) │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ LLM Backend │ │ Cache Layer │ │ Config │ │ Remote │ │ +│ │ (Multiple │ │ (cache.rs) │ │ Management │ │ Execution │ │ +│ │ Providers) │ │ │ │ (config.rs) │ │ (server.rs)│ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ License │ │ Error │ │ Copilot │ │ +│ │ Verifier │ │ Handling │ │ Auth │ │ +│ │ (license.rs) │ │ (error.rs) │ │(copilot_auth)│ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Layer Descriptions + +**User Interface Layer** — Multiple entry points for interacting with oxo-call: +- **CLI Client** (`cli.rs`, `main.rs`): Primary command-line interface with Clap-based argument parsing +- **Chat Mode** (`chat.rs`): Interactive conversational AI for bioinformatics tool guidance +- **Web API** (`server.rs`): Remote server management for SSH/HPC execution +- **SDK/API** (`lib.rs`): Programmatic Rust API for downstream crates and integrations + +**Language Processing Layer** — Normalizes and analyzes user input before LLM processing: +- **Task Normalizer** (`task_normalizer.rs`): Translates natural-language tasks into optimized prompts +- **Task Complexity** (`task_complexity.rs`): Estimates task complexity for adaptive prompt tier selection +- **Sanitizer** (`sanitize.rs`): Anonymizes sensitive data before sending to LLM + +**AI Orchestration Layer** — Core intelligence pipeline: +- **Runner Pipeline** (`runner/`): Orchestrates the full docs→skill→LLM→execute flow +- **LLM Integration** (`llm/`): Multi-provider abstraction (GitHub Copilot, OpenAI, Anthropic, Ollama, DeepSeek, and more) +- **Command Generator** (`generator.rs`): Extensible generation strategies via the `CommandGenerator` trait + +**Knowledge Enhancement Layer** — Grounds LLM calls in real documentation and domain expertise: +- **Documentation System** (`docs.rs`, `doc_processor.rs`, `doc_summarizer.rs`): Fetches, parses, and caches tool documentation +- **Skill System** (`skill.rs`): Domain-specific knowledge injection (user → community → MCP → built-in) +- **MCP Provider** (`mcp.rs`): Model Context Protocol for external skill servers +- **Context Builder** (`context.rs`): Assembles enriched context for LLM prompts + +**Execution & Monitoring Layer** — Runs commands and tracks results: +- **Workflow Engine** (`engine.rs`): DAG-based parallel workflow execution with tokio +- **Workflow Templates** (`workflow.rs`): Pre-built bioinformatics pipelines (RNA-seq, WGS, etc.) +- **History Tracker** (`history.rs`): JSONL command history with full provenance (UUID, model, exit code) +- **Job Manager** (`job.rs`): Background job tracking and management + +**Infrastructure Layer** — Platform services and configuration: +- **LLM Backend**: Multi-provider support with adaptive prompt tiers +- **Cache Layer** (`cache.rs`): Semantic hash-based response caching to reduce API costs +- **Config Management** (`config.rs`): TOML-based configuration with environment variable overrides +- **License Verifier** (`license.rs`): Ed25519 offline license verification -The main CLI crate contains the following modules with clear separation of concerns: +## Module Structure ```text main.rs — Command dispatcher & license gate ├─→ cli.rs — Command definitions (Clap) ├─→ handlers.rs — Extracted command-handler helpers (formatting, suggestions) ├─→ license.rs — Ed25519 offline verification - ├─→ runner.rs — Core orchestration pipeline + provenance tracking - │ ├─→ docs.rs — Documentation resolver - │ ├─→ doc_processor.rs — Structured doc extraction (flag catalog, examples, quality) - │ ├─→ skill.rs — Skill loading system + depth validation - │ │ └─→ mcp.rs — MCP skill provider (JSON-RPC / HTTP) - │ ├─→ llm.rs — LLM client, prompt builder & provider trait - │ ├─→ llm_workflow.rs — Fast/Quality workflow executor - │ ├─→ cache.rs — LLM response cache with semantic hash - │ ├─→ generator.rs — CommandGenerator trait (extensible strategies) - │ └─→ history.rs — Command history tracker with provenance - ├─→ chat.rs — Interactive chat with AI about bioinformatics tools - ├─→ sanitize.rs — Data anonymization for LLM contexts - ├─→ server.rs — Remote server management (SSH / HPC) - ├─→ workflow.rs — Templates & registry - │ └─→ engine.rs — DAG execution engine - ├─→ config.rs — Configuration management - ├─→ index.rs — Documentation index - └─→ error.rs — Error type definitions + ├─→ runner/ — Core orchestration pipeline + provenance tracking + │ ├─→ core.rs — Main runner logic + │ ├─→ batch.rs — Batch/parallel execution + │ ├─→ retry.rs — Auto-retry with error recovery + │ └─→ utils.rs — Tool detection & spinner utilities + ├─→ docs.rs — Documentation resolver + ├─→ doc_processor.rs — Structured doc extraction (flag catalog, examples) + ├─→ doc_summarizer.rs — Documentation compression + ├─→ skill.rs — Skill loading system + depth validation + │ └─→ mcp.rs — MCP skill provider (JSON-RPC / HTTP) + ├─→ llm/ — LLM integration + │ ├─→ provider.rs — Multi-provider client + │ └─→ types.rs — LlmProvider trait & types + ├─→ llm_workflow.rs — Fast/Quality workflow executor + ├─→ generator.rs — CommandGenerator trait (extensible strategies) + ├─→ cache.rs — LLM response cache with semantic hash + ├─→ history.rs — Command history tracker with provenance + ├─→ chat.rs — Interactive AI chat mode + ├─→ sanitize.rs — Data anonymization for LLM contexts + ├─→ server.rs — Remote server management (SSH / HPC) + ├─→ workflow.rs — Templates & registry + │ └─→ engine.rs — DAG execution engine + ├─→ workflow_graph.rs — DAG visualization + ├─→ task_normalizer.rs — Task normalization + ├─→ task_complexity.rs — Complexity estimation + ├─→ context.rs — Context assembly + ├─→ config.rs — Configuration management + ├─→ index.rs — Documentation index + ├─→ job.rs — Job management + ├─→ format.rs — Output formatting + ├─→ mini_skill_cache.rs — Lightweight skill caching + ├─→ copilot_auth.rs — GitHub Copilot authentication + └─→ error.rs — Error type definitions lib.rs — Programmatic API surface (re-exports all modules) ``` ## Execution Flow -![Command Generation Flow](../images/command-flow.svg) - ### Command Generation (run/dry-run) ```text @@ -84,7 +217,7 @@ lib.rs — Programmatic API surface (re-exports all modules) 2. **Docs-first grounding**: Documentation fetched before LLM call to prevent hallucination 3. **Offline-first**: Cached docs, no license server, optional remote fetching 4. **Skill-augmented prompting**: Domain knowledge injected without code changes -5. **Platform independence**: WASM conditional compilation, cross-platform config dirs +5. **Native performance**: Direct native compilation for all major platforms (Linux, macOS, Windows) 6. **Strict LLM contract**: ARGS:/EXPLANATION: format with retry on invalid response 7. **Adaptive prompt compression**: Three prompt tiers (Full/Medium/Compact) auto-selected by model size and context window, ensuring reliable output from 0.5B to 200B+ parameter models 8. **Extensible generation**: CommandGenerator trait enables multiple generation strategies (LLM, rule-based, composite) with chain-of-responsibility pattern diff --git a/docs/guide/src/tutorials/installation.md b/docs/guide/src/tutorials/installation.md index 82eeffaf..6a4ef371 100644 --- a/docs/guide/src/tutorials/installation.md +++ b/docs/guide/src/tutorials/installation.md @@ -18,7 +18,6 @@ Pre-built binaries are the easiest way to get started. Download from the [Releas | macOS | aarch64 (Apple Silicon) | `oxo-call-vX.Y.Z-aarch64-apple-darwin.tar.gz` | | Windows | x86_64 | `oxo-call-vX.Y.Z-x86_64-pc-windows-msvc.zip` | | Windows | aarch64 | `oxo-call-vX.Y.Z-aarch64-pc-windows-msvc.zip` | -| WebAssembly | wasm32-wasip1 | `oxo-call-vX.Y.Z-wasm32-wasip1.tar.gz` (advanced) | 2. Extract and move to your PATH: diff --git a/docs/index.html b/docs/index.html index 67e79ff5..e58cda1d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -603,19 +603,9 @@ .community-links { grid-template-columns: 1fr; } } - /* ── Wasm note ──────────────────────────────────────────────────────── */ + /* ── Wasm note (removed) ──────────────────────────────────────────── */ .wasm-badge { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.2rem 0.7rem; - background: rgba(88,166,255,0.1); - border: 1px solid rgba(88,166,255,0.3); - border-radius: 20px; - font-family: 'JetBrains Mono', monospace; - font-size: 0.72rem; - color: var(--accent); - margin-bottom: 1rem; + display: none; } .pulse { width: 6px; height: 6px; @@ -902,7 +892,6 @@ 🦀 Rust 2024 🧬 Bioinformatics 🤖 LLM-powered - ⚡ WebAssembly

Model-intelligent
orchestration for
CLI bioinformatics

@@ -1413,7 +1402,7 @@

Get started in 60 seconds

tar xzf oxo-call-*-x86_64-unknown-linux-gnu.tar.gz sudo mv oxo-call /usr/local/bin/ -# Available for: Linux (x86_64/aarch64), macOS (Intel/Apple Silicon), Windows, WASM +# Available for: Linux (x86_64/aarch64), macOS (Intel/Apple Silicon), Windows
@@ -1481,15 +1470,6 @@

Get started in 60 seconds

--input-list samples.txt --jobs 4
-
-
- WASI binary (requires wasmtime) - -
-
# Download the .wasm binary from the GitHub Releases page, then:
-wasmtime oxo-call.wasm -- dry-run samtools "sort input.bam by coordinate"
-
-
Quick test license (copy & paste to get started) diff --git a/src/chat.rs b/src/chat.rs index d9082c1a..bdf958e7 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -18,13 +18,11 @@ use crate::error::{OxoError, Result}; use crate::llm::types::{ChatMessage, ChatRequest, ChatResponse}; use crate::skill::SkillManager; use colored::Colorize; -#[cfg(not(target_arch = "wasm32"))] use std::io::{self, BufRead, Write}; /// Render markdown text to the terminal using termimad. /// /// Falls back to plain text if rendering fails. -#[cfg(not(target_arch = "wasm32"))] fn render_markdown(text: &str) { use termimad::MadSkin; let skin = MadSkin::default(); @@ -39,9 +37,7 @@ pub struct ChatSession { verbose: bool, no_cache: bool, scenario: ChatScenario, - #[cfg(not(target_arch = "wasm32"))] client: reqwest::Client, - #[cfg(not(target_arch = "wasm32"))] conversation_history: Vec, } @@ -54,9 +50,7 @@ impl ChatSession { verbose: false, no_cache: false, scenario: ChatScenario::Full, - #[cfg(not(target_arch = "wasm32"))] client: reqwest::Client::new(), - #[cfg(not(target_arch = "wasm32"))] conversation_history: Vec::new(), } } @@ -77,197 +71,177 @@ impl ChatSession { } /// Run single-shot Q&A (non-interactive mode). - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn run_single(&self, tool: &str, question: &str, json: bool) -> Result<()> { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "Chat is not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - if self.verbose { - eprintln!("{}", "─".repeat(60).dimmed()); - eprintln!("{}: {}", "Tool".cyan().bold(), tool); - eprintln!("{}: {}", "Scenario".cyan().bold(), self.scenario_name()); - eprintln!( - "{}: {}", - "Model".cyan().bold(), - self.config.effective_model() - ); - eprintln!("{}", "─".repeat(60).dimmed()); - } - - let spinner = - crate::runner::make_spinner(&format!("Preparing context for '{tool}'...")); + if self.verbose { + eprintln!("{}", "─".repeat(60).dimmed()); + eprintln!("{}: {}", "Tool".cyan().bold(), tool); + eprintln!("{}: {}", "Scenario".cyan().bold(), self.scenario_name()); + eprintln!( + "{}: {}", + "Model".cyan().bold(), + self.config.effective_model() + ); + eprintln!("{}", "─".repeat(60).dimmed()); + } - let prompts_result = self.build_prompts(tool, question).await; + let spinner = crate::runner::make_spinner(&format!("Preparing context for '{tool}'...")); - spinner.finish_and_clear(); + let prompts_result = self.build_prompts(tool, question).await; - let (system_prompt, user_prompt) = prompts_result?; + spinner.finish_and_clear(); - let spinner = crate::runner::make_spinner("Waiting for LLM response..."); + let (system_prompt, user_prompt) = prompts_result?; - let api_result = self.call_api(&system_prompt, &user_prompt).await; + let spinner = crate::runner::make_spinner("Waiting for LLM response..."); - spinner.finish_and_clear(); + let api_result = self.call_api(&system_prompt, &user_prompt).await; - let response = api_result?; + spinner.finish_and_clear(); - if json { - let result = serde_json::json!({ - "tool": tool, - "question": question, - "scenario": self.scenario_name(), - "response": response, - }); - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - } else { - println!(); - render_markdown(&response); - println!(); - } + let response = api_result?; - Ok(()) + if json { + let result = serde_json::json!({ + "tool": tool, + "question": question, + "scenario": self.scenario_name(), + "response": response, + }); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + println!(); + render_markdown(&response); + println!(); } + + Ok(()) } /// Run interactive multi-turn chat session. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn run_interactive(&mut self, initial_tool: Option<&str>) -> Result<()> { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "Interactive chat is not supported in WebAssembly".to_string(), - )); + let mut current_tool = initial_tool.map(String::from); - #[cfg(not(target_arch = "wasm32"))] - { - let mut current_tool = initial_tool.map(String::from); + self.print_welcome(); - self.print_welcome(); - - if let Some(tool) = ¤t_tool { - println!( - " {} {}", - "🔧 Tool context:".cyan().bold(), - tool.white().bold() - ); - } - println!( - " {} {}", - "📋 Scenario:".dimmed(), - self.scenario_name().dimmed() - ); + if let Some(tool) = ¤t_tool { println!( " {} {}", - "🤖 Model:".dimmed(), - self.config.effective_model().dimmed() + "🔧 Tool context:".cyan().bold(), + tool.white().bold() ); - println!(); + } + println!( + " {} {}", + "📋 Scenario:".dimmed(), + self.scenario_name().dimmed() + ); + println!( + " {} {}", + "🤖 Model:".dimmed(), + self.config.effective_model().dimmed() + ); + println!(); - loop { - let prompt = if let Some(ref tool) = current_tool { - format!("{} {} ", "▶".green(), tool.cyan().bold()) - } else { - format!("{} ", "oxo▶".cyan().bold()) - }; - - print!("{}", prompt); - io::stdout().flush()?; - - let mut input = String::new(); - let stdin = io::stdin(); - match stdin.lock().read_line(&mut input) { - Ok(0) => { - println!("\n{}", "👋 Goodbye!".green().bold()); - break; - } - Ok(_) => {} - Err(e) => { - eprintln!("{} Failed to read input: {}", "✖ error:".red().bold(), e); - continue; - } + loop { + let prompt = if let Some(ref tool) = current_tool { + format!("{} {} ", "▶".green(), tool.cyan().bold()) + } else { + format!("{} ", "oxo▶".cyan().bold()) + }; + + print!("{}", prompt); + io::stdout().flush()?; + + let mut input = String::new(); + let stdin = io::stdin(); + match stdin.lock().read_line(&mut input) { + Ok(0) => { + println!("\n{}", "👋 Goodbye!".green().bold()); + break; } - - let input = input.trim(); - if input.is_empty() { + Ok(_) => {} + Err(e) => { + eprintln!("{} Failed to read input: {}", "✖ error:".red().bold(), e); continue; } + } - if self.handle_command(input, &mut current_tool) { - continue; - } + let input = input.trim(); + if input.is_empty() { + continue; + } + + if self.handle_command(input, &mut current_tool) { + continue; + } - let (tool, question) = self.parse_input(input, current_tool.as_deref()); - - match tool { - Some(t) => { - let spinner = - crate::runner::make_spinner(&format!("Loading context for '{t}'...")); - - let prompts_result = self.build_prompts(&t, &question).await; - - spinner.finish_and_clear(); - - let (system_prompt, user_prompt) = match prompts_result { - Ok(p) => p, - Err(e) => { - eprintln!(" {} {}", "✖ Context error:".red().bold(), e); - continue; - } - }; - - self.conversation_history.push(ChatMessage { - role: "user".to_string(), - content: user_prompt.clone(), - }); - - let spinner = crate::runner::make_spinner("Thinking..."); - - let api_result = self.call_api_with_history(&system_prompt).await; - - spinner.finish_and_clear(); - - match api_result { - Ok(response) => { - println!(); - println!("{}", "─".repeat(60).dimmed()); - render_markdown(&response); - println!("{}", "─".repeat(60).dimmed()); - println!(); - - self.conversation_history.push(ChatMessage { - role: "assistant".to_string(), - content: response, - }); - } - Err(e) => { - // Remove the user message we just added since the - // API call failed. - self.conversation_history.pop(); - eprintln!("\n {} {}\n", "✖ LLM error:".red().bold(), e); - } + let (tool, question) = self.parse_input(input, current_tool.as_deref()); + + match tool { + Some(t) => { + let spinner = + crate::runner::make_spinner(&format!("Loading context for '{t}'...")); + + let prompts_result = self.build_prompts(&t, &question).await; + + spinner.finish_and_clear(); + + let (system_prompt, user_prompt) = match prompts_result { + Ok(p) => p, + Err(e) => { + eprintln!(" {} {}", "✖ Context error:".red().bold(), e); + continue; + } + }; + + self.conversation_history.push(ChatMessage { + role: "user".to_string(), + content: user_prompt.clone(), + }); + + let spinner = crate::runner::make_spinner("Thinking..."); + + let api_result = self.call_api_with_history(&system_prompt).await; + + spinner.finish_and_clear(); + + match api_result { + Ok(response) => { + println!(); + println!("{}", "─".repeat(60).dimmed()); + render_markdown(&response); + println!("{}", "─".repeat(60).dimmed()); + println!(); + + self.conversation_history.push(ChatMessage { + role: "assistant".to_string(), + content: response, + }); + } + Err(e) => { + // Remove the user message we just added since the + // API call failed. + self.conversation_history.pop(); + eprintln!("\n {} {}\n", "✖ LLM error:".red().bold(), e); } - } - None => { - println!( - " {}", - "⚠ Please specify a tool or use /tool to set context.".yellow() - ); - println!( - " {}", - "Example: samtools How do I sort a BAM file?".dimmed() - ); } } + None => { + println!( + " {}", + "⚠ Please specify a tool or use /tool to set context.".yellow() + ); + println!( + " {}", + "Example: samtools How do I sort a BAM file?".dimmed() + ); + } } - - Ok(()) } + + Ok(()) } - #[cfg(not(target_arch = "wasm32"))] fn print_welcome(&self) { println!(); println!( @@ -315,7 +289,6 @@ impl ChatSession { println!(); } - #[cfg(not(target_arch = "wasm32"))] fn handle_command(&mut self, input: &str, current_tool: &mut Option) -> bool { let parts: Vec<&str> = input.split_whitespace().collect(); if parts.is_empty() { @@ -441,7 +414,6 @@ impl ChatSession { } } - #[cfg(not(target_arch = "wasm32"))] fn parse_input(&self, input: &str, current_tool: Option<&str>) -> (Option, String) { let parts: Vec<&str> = input.split_whitespace().collect(); if parts.is_empty() { @@ -473,7 +445,6 @@ impl ChatSession { } } - #[cfg(not(target_arch = "wasm32"))] async fn build_prompts(&self, tool: &str, question: &str) -> Result<(String, String)> { let system_prompt = self.build_system_prompt(); let context = self.build_context(tool).await?; @@ -498,7 +469,6 @@ impl ChatSession { } } - #[cfg(not(target_arch = "wasm32"))] async fn build_context(&self, tool: &str) -> Result { let mut context_parts = Vec::new(); @@ -542,7 +512,6 @@ impl ChatSession { Ok(context_parts.join("\n\n")) } - #[cfg(not(target_arch = "wasm32"))] async fn call_api(&self, system_prompt: &str, user_prompt: &str) -> Result { let provider = self.config.effective_provider(); let token_opt = self.config.effective_api_token(); @@ -629,7 +598,6 @@ impl ChatSession { Ok(content.trim().to_string()) } - #[cfg(not(target_arch = "wasm32"))] async fn call_api_with_history(&self, system_prompt: &str) -> Result { let provider = self.config.effective_provider(); let token_opt = self.config.effective_api_token(); diff --git a/src/config.rs b/src/config.rs index d0c3513c..5ef223b7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,5 @@ use crate::error::{OxoError, Result}; use crate::server::ServerConfig; -#[cfg(not(target_arch = "wasm32"))] use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -216,21 +215,15 @@ impl Default for Config { } impl Config { - #[cfg(not(target_arch = "wasm32"))] pub fn project_dirs() -> Option { ProjectDirs::from("io", "traitome", "oxo-call") } pub fn config_dir() -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - let dirs = Self::project_dirs().ok_or_else(|| { - OxoError::ConfigError("Cannot determine config directory".to_string()) - })?; - Ok(dirs.config_dir().to_path_buf()) - } - #[cfg(target_arch = "wasm32")] - Ok(PathBuf::from("/config/oxo-call")) + let dirs = Self::project_dirs().ok_or_else(|| { + OxoError::ConfigError("Cannot determine config directory".to_string()) + })?; + Ok(dirs.config_dir().to_path_buf()) } pub fn config_path() -> Result { @@ -238,18 +231,12 @@ impl Config { } pub fn data_dir() -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - if let Ok(override_dir) = std::env::var("OXO_CALL_DATA_DIR") { - return Ok(PathBuf::from(override_dir)); - } - let dirs = Self::project_dirs().ok_or_else(|| { - OxoError::ConfigError("Cannot determine data directory".to_string()) - })?; - Ok(dirs.data_dir().to_path_buf()) + if let Ok(override_dir) = std::env::var("OXO_CALL_DATA_DIR") { + return Ok(PathBuf::from(override_dir)); } - #[cfg(target_arch = "wasm32")] - Ok(PathBuf::from("/data/oxo-call")) + let dirs = Self::project_dirs() + .ok_or_else(|| OxoError::ConfigError("Cannot determine data directory".to_string()))?; + Ok(dirs.data_dir().to_path_buf()) } pub fn load() -> Result { diff --git a/src/docs.rs b/src/docs.rs index 57b4bdfe..4d82649e 100644 --- a/src/docs.rs +++ b/src/docs.rs @@ -2,7 +2,6 @@ use crate::config::Config; use crate::error::{OxoError, Result}; use colored::Colorize; use std::path::PathBuf; -#[cfg(not(target_arch = "wasm32"))] use std::process::Command; use uuid::Uuid; @@ -369,19 +368,12 @@ impl DocsFetcher { /// Many bioinformatics tools (bwa, samtools, bcftools) print usage when a /// subcommand is invoked without its required arguments. fn run_subcommand_no_args(&self, tool: &str, subcmd: &str) -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - let output = Command::new(tool) - .arg(subcmd) - .output() - .map_err(|e| OxoError::ToolNotFound(format!("{tool} {subcmd}: {e}")))?; + let output = Command::new(tool) + .arg(subcmd) + .output() + .map_err(|e| OxoError::ToolNotFound(format!("{tool} {subcmd}: {e}")))?; - extract_useful_output(tool, &output.stdout, &output.stderr) - } - #[cfg(target_arch = "wasm32")] - Err(OxoError::ToolNotFound(format!( - "{tool} {subcmd}: process execution is not supported in WebAssembly" - ))) + extract_useful_output(tool, &output.stdout, &output.stderr) } /// Try to detect the tool version using multiple strategies. @@ -414,67 +406,45 @@ impl DocsFetcher { None } - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] fn run_help_flag(&self, tool: &str, flag: &str) -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - let output = Command::new(tool) - .arg(flag) - .output() - .map_err(|e| OxoError::ToolNotFound(format!("{tool}: {e}")))?; + let output = Command::new(tool) + .arg(flag) + .output() + .map_err(|e| OxoError::ToolNotFound(format!("{tool}: {e}")))?; - extract_useful_output(tool, &output.stdout, &output.stderr) - } - #[cfg(target_arch = "wasm32")] - Err(OxoError::ToolNotFound(format!( - "{tool}: process execution is not supported in WebAssembly" - ))) + extract_useful_output(tool, &output.stdout, &output.stderr) } /// Run the tool with no arguments – many bioinformatics tools (bwa, samtools, etc.) /// print their usage/help when called without any arguments. fn run_no_args(&self, tool: &str) -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - let output = Command::new(tool) - .output() - .map_err(|e| OxoError::ToolNotFound(format!("{tool}: {e}")))?; + let output = Command::new(tool) + .output() + .map_err(|e| OxoError::ToolNotFound(format!("{tool}: {e}")))?; - extract_useful_output(tool, &output.stdout, &output.stderr) - } - #[cfg(target_arch = "wasm32")] - Err(OxoError::ToolNotFound(format!( - "{tool}: process execution is not supported in WebAssembly" - ))) + extract_useful_output(tool, &output.stdout, &output.stderr) } /// Try to get help for a shell built-in command via `bash -c "help "`. /// This handles commands like `cd`, `export`, `alias`, etc. that are not /// standalone executables and cannot be invoked directly. fn run_shell_builtin_help(&self, tool: &str) -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - // Use $1 with -- to safely pass the tool name without shell interpolation. - // validate_tool_name() already restricts to [a-zA-Z0-9._-], but defence - // in depth avoids any future risk if that validation changes. - let output = Command::new("bash") - .args(["-c", "help -- \"$1\"", "--", tool]) - .output() - .map_err(|e| OxoError::ToolNotFound(format!("{tool}: {e}")))?; - - if !output.status.success() { - return Err(OxoError::DocFetchError( - tool.to_string(), - "Not a shell built-in".to_string(), - )); - } - - extract_useful_output(tool, &output.stdout, &output.stderr) + // Use $1 with -- to safely pass the tool name without shell interpolation. + // validate_tool_name() already restricts to [a-zA-Z0-9._-], but defence + // in depth avoids any future risk if that validation changes. + let output = Command::new("bash") + .args(["-c", "help -- \"$1\"", "--", tool]) + .output() + .map_err(|e| OxoError::ToolNotFound(format!("{tool}: {e}")))?; + + if !output.status.success() { + return Err(OxoError::DocFetchError( + tool.to_string(), + "Not a shell built-in".to_string(), + )); } - #[cfg(target_arch = "wasm32")] - Err(OxoError::ToolNotFound(format!( - "{tool}: process execution is not supported in WebAssembly" - ))) + + extract_useful_output(tool, &output.stdout, &output.stderr) } /// Load documentation from local cache @@ -651,30 +621,22 @@ impl DocsFetcher { "Only http:// and https:// URLs are accepted".to_string(), )); } - #[cfg(target_arch = "wasm32")] - return Err(OxoError::DocFetchError( - tool.to_string(), - "Remote documentation fetching is not supported in WebAssembly".to_string(), - )); - #[cfg(not(target_arch = "wasm32"))] - { - let client = reqwest::Client::new(); - let response = client.get(url).send().await?; - if !response.status().is_success() { - return Err(OxoError::DocFetchError( - tool.to_string(), - format!("HTTP {}", response.status()), - )); - } - let content = response.text().await?; - // Limit size - let truncated = if content.len() > 50_000 { - format!("{}\n...[truncated]", &content[..50_000]) - } else { - content - }; - Ok(truncated) + let client = reqwest::Client::new(); + let response = client.get(url).send().await?; + if !response.status().is_success() { + return Err(OxoError::DocFetchError( + tool.to_string(), + format!("HTTP {}", response.status()), + )); } + let content = response.text().await?; + // Limit size + let truncated = if content.len() > 50_000 { + format!("{}\n...[truncated]", &content[..50_000]) + } else { + content + }; + Ok(truncated) } /// Read documentation from a single local file. diff --git a/src/engine.rs b/src/engine.rs index 6203c829..a165b6b8 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -50,7 +50,6 @@ use colored::Colorize; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::Path; -#[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use std::time::SystemTime; @@ -558,7 +557,6 @@ use std::path::PathBuf; /// `tokio::task::JoinSet`. Dependent tasks wait until all their prerequisites /// have succeeded or been skipped. If any task fails the whole run is aborted, /// but a checkpoint is saved so the workflow can be resumed. -#[cfg(not(target_arch = "wasm32"))] pub async fn execute(tasks: Vec, dry_run: bool) -> Result<()> { use tokio::task::JoinSet; @@ -739,7 +737,6 @@ pub async fn execute(tasks: Vec, dry_run: bool) -> Result<()> { } /// Run a single concrete task via `sh -c`. -#[cfg(not(target_arch = "wasm32"))] async fn run_single_task(task: ConcreteTask) -> Result<(String, bool)> { use tokio::process::Command; @@ -1424,7 +1421,6 @@ pub fn visualize_workflow(def: &WorkflowDef) -> Result<()> { /// This is a best-effort check: failures from the LLM are printed as warnings /// rather than propagated as errors, so the exit code of `workflow run` is not /// affected. -#[cfg(not(target_arch = "wasm32"))] pub async fn verify_workflow_results(def: &WorkflowDef, config: &crate::config::Config) { use crate::llm::LlmClient; use crate::runner::make_spinner; diff --git a/src/error.rs b/src/error.rs index 74ae276f..abc6899e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -36,7 +36,6 @@ pub enum OxoError { #[error("JSON error: {0}")] JsonError(#[from] serde_json::Error), - #[cfg(not(target_arch = "wasm32"))] #[error("HTTP error: {0}")] HttpError(#[from] reqwest::Error), @@ -52,17 +51,10 @@ pub type Result = std::result::Result; /// Initialize color-eyre for enhanced error reporting. /// /// Should be called early in `main()` to install panic and error handlers. -#[cfg(not(target_arch = "wasm32"))] pub fn install_error_handler() -> color_eyre::Result<()> { color_eyre::install() } -#[cfg(target_arch = "wasm32")] -pub fn install_error_handler() -> color_eyre::Result<()> { - // color-eyre doesn't fully support wasm32, skip installation - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/execution/feedback.rs b/src/execution/feedback.rs new file mode 100644 index 00000000..a1db8498 --- /dev/null +++ b/src/execution/feedback.rs @@ -0,0 +1,145 @@ +//! Feedback Collector — records user actions for self-evolution. +//! +//! Tracks whether LLM-generated commands succeeded/failed and whether +//! the user modified them, feeding this data back into the knowledge layer. + +use crate::config::Config; +use crate::error::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A feedback entry recording the outcome of a command generation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeedbackEntry { + /// Tool name. + pub tool: String, + /// Original task. + pub task: String, + /// Generated command. + pub generated_command: String, + /// Whether the command was executed as-is or modified. + pub was_modified: bool, + /// Modified command (if user changed it). + pub modified_command: Option, + /// Exit code of the executed command. + pub exit_code: i32, + /// Whether the user considered the result correct. + pub user_approved: bool, + /// Model used. + pub model: String, + /// Timestamp. + pub recorded_at: String, +} + +/// Aggregated feedback statistics for a tool. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct FeedbackStats { + /// Total executions tracked. + pub total: usize, + /// Successful executions (exit code 0). + pub successes: usize, + /// Times the user modified the generated command. + pub modifications: usize, + /// Success rate (0.0–1.0). + pub success_rate: f32, + /// Modification rate (0.0–1.0). + pub modification_rate: f32, +} + +/// Feedback collection and aggregation. +pub struct FeedbackCollector; + +#[allow(dead_code)] +impl FeedbackCollector { + fn feedback_path() -> Result { + Ok(Config::data_dir()?.join("feedback.jsonl")) + } + + /// Record a feedback entry. + pub fn record(entry: FeedbackEntry) -> Result<()> { + let path = Self::feedback_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let line = serde_json::to_string(&entry)?; + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path)?; + writeln!(f, "{line}")?; + Ok(()) + } + + /// Load all feedback entries. + pub fn load_all() -> Result> { + let path = Self::feedback_path()?; + if !path.exists() { + return Ok(vec![]); + } + let content = std::fs::read_to_string(&path)?; + let entries: Vec = content + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + Ok(entries) + } + + /// Get aggregated statistics for a specific tool. + pub fn stats_for_tool(tool: &str) -> Result { + let entries = Self::load_all()?; + let tool_entries: Vec<&FeedbackEntry> = entries + .iter() + .filter(|e| e.tool.to_lowercase() == tool.to_lowercase()) + .collect(); + + let total = tool_entries.len(); + if total == 0 { + return Ok(FeedbackStats::default()); + } + + let successes = tool_entries.iter().filter(|e| e.exit_code == 0).count(); + let modifications = tool_entries.iter().filter(|e| e.was_modified).count(); + + Ok(FeedbackStats { + total, + successes, + modifications, + success_rate: successes as f32 / total as f32, + modification_rate: modifications as f32 / total as f32, + }) + } + + /// Get overall statistics across all tools. + pub fn overall_stats() -> Result { + let entries = Self::load_all()?; + let total = entries.len(); + if total == 0 { + return Ok(FeedbackStats::default()); + } + + let successes = entries.iter().filter(|e| e.exit_code == 0).count(); + let modifications = entries.iter().filter(|e| e.was_modified).count(); + + Ok(FeedbackStats { + total, + successes, + modifications, + success_rate: successes as f32 / total as f32, + modification_rate: modifications as f32 / total as f32, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_feedback_stats_default() { + let stats = FeedbackStats::default(); + assert_eq!(stats.total, 0); + assert_eq!(stats.success_rate, 0.0); + } +} diff --git a/src/execution/mod.rs b/src/execution/mod.rs new file mode 100644 index 00000000..4dbf18cc --- /dev/null +++ b/src/execution/mod.rs @@ -0,0 +1,7 @@ +//! Execution & Monitoring Layer. +//! +//! Provides result analysis, feedback collection, and self-evolution +//! capabilities for continuous improvement of command generation. + +pub mod feedback; +pub mod result_analyzer; diff --git a/src/execution/result_analyzer.rs b/src/execution/result_analyzer.rs new file mode 100644 index 00000000..b02ecb4b --- /dev/null +++ b/src/execution/result_analyzer.rs @@ -0,0 +1,230 @@ +//! Result Analyzer — post-execution analysis and learning. +//! +//! Analyzes command execution results to extract actionable insights +//! that feed back into the knowledge layer for continuous improvement. + +use crate::knowledge::error_db::ErrorCategory; +use serde::{Deserialize, Serialize}; + +/// Analysis of a completed command execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct ExecutionAnalysis { + /// Whether the execution was successful. + pub success: bool, + /// Error category (if failed). + pub error_category: Option, + /// Key patterns detected in output. + pub output_patterns: Vec, + /// Suggested improvements for future runs. + pub improvements: Vec, + /// Resource usage hints inferred from output. + pub resource_hints: ResourceHints, +} + +/// Detected pattern in command output. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct OutputPattern { + /// Pattern category. + pub category: PatternCategory, + /// Description. + pub description: String, +} + +/// Categories of output patterns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PatternCategory { + /// Performance-related (e.g. "processed 1M reads in 30s"). + Performance, + /// Quality metrics (e.g. "mapping rate: 95%"). + QualityMetric, + /// Warning that may indicate an issue. + Warning, + /// File creation/modification. + FileOutput, +} + +/// Resource usage hints inferred from output. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct ResourceHints { + /// Suggested thread count based on observed performance. + pub suggested_threads: Option, + /// Estimated memory usage. + pub estimated_memory_gb: Option, + /// Whether the task was I/O bound. + pub io_bound: bool, +} + +/// The Result Analyzer. +#[allow(dead_code)] +pub struct ResultAnalyzer; + +impl Default for ResultAnalyzer { + fn default() -> Self { + Self::new() + } +} + +#[allow(dead_code)] +impl ResultAnalyzer { + pub fn new() -> Self { + Self + } + + /// Analyze the results of a command execution. + pub fn analyze( + &self, + _tool: &str, + exit_code: i32, + stdout: &str, + stderr: &str, + ) -> ExecutionAnalysis { + let success = exit_code == 0; + let error_category = if !success { + Some(ErrorCategory::classify(stderr)) + } else { + None + }; + + let output_patterns = self.detect_patterns(stdout, stderr); + let improvements = self.suggest_improvements(&output_patterns, stderr); + let resource_hints = self.infer_resource_hints(stdout, stderr); + + ExecutionAnalysis { + success, + error_category, + output_patterns, + improvements, + resource_hints, + } + } + + /// Detect patterns in command output. + fn detect_patterns(&self, stdout: &str, stderr: &str) -> Vec { + let mut patterns = Vec::new(); + let combined = format!("{stdout}\n{stderr}").to_lowercase(); + + // Performance patterns. + if combined.contains("processed") || combined.contains("elapsed") { + patterns.push(OutputPattern { + category: PatternCategory::Performance, + description: "Performance metrics detected in output".to_string(), + }); + } + + // Quality metrics. + let quality_indicators = [ + "mapping rate", + "mapped", + "properly paired", + "duplicate", + "quality", + "coverage", + ]; + for ind in quality_indicators { + if combined.contains(ind) { + patterns.push(OutputPattern { + category: PatternCategory::QualityMetric, + description: format!("Quality metric detected: {ind}"), + }); + break; + } + } + + // Warnings. + if combined.contains("warning") || combined.contains("[w::") { + patterns.push(OutputPattern { + category: PatternCategory::Warning, + description: "Warnings detected in output".to_string(), + }); + } + + patterns + } + + /// Suggest improvements based on detected patterns and output. + fn suggest_improvements(&self, patterns: &[OutputPattern], stderr: &str) -> Vec { + let mut improvements = Vec::new(); + + if patterns + .iter() + .any(|p| p.category == PatternCategory::Warning) + { + improvements.push( + "Review warnings in stderr — they may indicate data quality issues".to_string(), + ); + } + + let lower = stderr.to_lowercase(); + if lower.contains("sort order") || lower.contains("not sorted") { + improvements.push("Input file may need to be sorted first (samtools sort)".to_string()); + } + + if lower.contains("no index") || lower.contains("index file") { + improvements.push( + "Consider creating an index (samtools index) for faster random access".to_string(), + ); + } + + improvements + } + + /// Infer resource usage hints from output. + fn infer_resource_hints(&self, _stdout: &str, stderr: &str) -> ResourceHints { + let lower = stderr.to_lowercase(); + let io_bound = lower.contains("i/o") || lower.contains("disk"); + + ResourceHints { + suggested_threads: None, + estimated_memory_gb: None, + io_bound, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_analyze_success() { + let analyzer = ResultAnalyzer::new(); + let result = analyzer.analyze("samtools", 0, "1000 reads processed", ""); + assert!(result.success); + assert!(result.error_category.is_none()); + } + + #[test] + fn test_analyze_failure() { + let analyzer = ResultAnalyzer::new(); + let result = analyzer.analyze("samtools", 1, "", "samtools: No such file or directory"); + assert!(!result.success); + assert_eq!(result.error_category, Some(ErrorCategory::MissingInput)); + } + + #[test] + fn test_detect_quality_patterns() { + let analyzer = ResultAnalyzer::new(); + let result = analyzer.analyze( + "samtools", + 0, + "1000 + 0 mapped (95.00%)\n50 + 0 properly paired", + "", + ); + assert!( + result + .output_patterns + .iter() + .any(|p| p.category == PatternCategory::QualityMetric) + ); + } + + #[test] + fn test_suggest_sort_improvement() { + let analyzer = ResultAnalyzer::new(); + let result = analyzer.analyze("samtools", 1, "", "file is not sorted"); + assert!(result.improvements.iter().any(|s| s.contains("sorted"))); + } +} diff --git a/src/knowledge/best_practices.rs b/src/knowledge/best_practices.rs new file mode 100644 index 00000000..52346b73 --- /dev/null +++ b/src/knowledge/best_practices.rs @@ -0,0 +1,252 @@ +//! Bioinformatics best practices knowledge base. +//! +//! Provides domain-specific best practices that can be injected into LLM prompts +//! to improve command generation quality, especially for small models. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A best practice recommendation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BestPractice { + /// Category this practice applies to. + pub category: String, + /// Short title. + pub title: String, + /// Detailed recommendation. + pub recommendation: String, + /// Tools this applies to (empty = universal). + pub tools: Vec, +} + +/// In-memory best practices database. +#[allow(dead_code)] +pub struct BestPracticesDb { + practices: Vec, + /// Index: category → practice indices. + category_index: HashMap>, + /// Index: tool name → practice indices. + tool_index: HashMap>, +} + +impl Default for BestPracticesDb { + fn default() -> Self { + Self::new() + } +} + +#[allow(dead_code)] +impl BestPracticesDb { + /// Create a new DB loaded with embedded best practices. + pub fn new() -> Self { + let practices = Self::load_embedded(); + let mut category_index: HashMap> = HashMap::new(); + let mut tool_index: HashMap> = HashMap::new(); + + for (idx, p) in practices.iter().enumerate() { + category_index + .entry(p.category.clone()) + .or_default() + .push(idx); + for tool in &p.tools { + tool_index.entry(tool.to_lowercase()).or_default().push(idx); + } + } + + Self { + practices, + category_index, + tool_index, + } + } + + /// Get all practices relevant to a specific tool. + pub fn for_tool(&self, tool: &str) -> Vec<&BestPractice> { + let tool_lower = tool.to_lowercase(); + let mut indices: Vec = self + .tool_index + .get(&tool_lower) + .cloned() + .unwrap_or_default(); + + // Also include universal practices (empty tools list). + for (idx, p) in self.practices.iter().enumerate() { + if p.tools.is_empty() && !indices.contains(&idx) { + indices.push(idx); + } + } + + indices.into_iter().map(|i| &self.practices[i]).collect() + } + + /// Get practices for a category. + pub fn for_category(&self, category: &str) -> Vec<&BestPractice> { + self.category_index + .get(category) + .map(|indices| indices.iter().map(|&i| &self.practices[i]).collect()) + .unwrap_or_default() + } + + /// Format relevant best practices as a prompt injection string. + pub fn to_prompt_hint(&self, tool: &str) -> String { + let practices = self.for_tool(tool); + if practices.is_empty() { + return String::new(); + } + + let mut lines = vec!["[Best Practices]".to_string()]; + for p in practices.iter().take(5) { + lines.push(format!("• {}: {}", p.title, p.recommendation)); + } + lines.join("\n") + } + + /// Total number of practices. + pub fn len(&self) -> usize { + self.practices.len() + } + + /// Whether the DB is empty. + pub fn is_empty(&self) -> bool { + self.practices.is_empty() + } + + // ── Embedded data ──────────────────────────────────────────────────────── + + fn load_embedded() -> Vec { + vec![ + // ── Universal practices ────────────────────────────────────────── + BestPractice { + category: "general".to_string(), + title: "Use threads for parallelism".to_string(), + recommendation: "Most bioinformatics tools support multithreading. Use -@ (samtools), -t (bwa), --threads (many tools) to leverage available CPU cores. A good default is 4-8 threads.".to_string(), + tools: vec![], + }, + BestPractice { + category: "general".to_string(), + title: "Always specify output files explicitly".to_string(), + recommendation: "Use -o/--output to specify output files rather than relying on stdout redirection. This prevents partial writes on failure and makes commands more readable.".to_string(), + tools: vec![], + }, + BestPractice { + category: "general".to_string(), + title: "Pipe-friendly processing".to_string(), + recommendation: "For large files, prefer piping between tools (e.g., samtools view | samtools sort) to avoid writing intermediate files to disk.".to_string(), + tools: vec![], + }, + // ── Alignment ──────────────────────────────────────────────────── + BestPractice { + category: "alignment".to_string(), + title: "Sort BAM by coordinate after alignment".to_string(), + recommendation: "Always coordinate-sort BAM files after alignment. Most downstream tools (variant callers, viewers) require coordinate-sorted BAM.".to_string(), + tools: vec!["samtools".to_string(), "bwa".to_string(), "hisat2".to_string()], + }, + BestPractice { + category: "alignment".to_string(), + title: "Mark duplicates before variant calling".to_string(), + recommendation: "Run duplicate marking (samtools markdup or Picard MarkDuplicates) before variant calling to avoid PCR artefact bias.".to_string(), + tools: vec!["samtools".to_string(), "picard".to_string(), "gatk4".to_string()], + }, + BestPractice { + category: "alignment".to_string(), + title: "Index BAM files after sorting".to_string(), + recommendation: "Always create a .bai index after sorting: `samtools index sorted.bam`. Most tools require indexed BAM.".to_string(), + tools: vec!["samtools".to_string()], + }, + // ── Variant Calling ────────────────────────────────────────────── + BestPractice { + category: "variant-calling".to_string(), + title: "Use BQSR for GATK pipelines".to_string(), + recommendation: "Apply Base Quality Score Recalibration (BQSR) before calling variants with GATK HaplotypeCaller for improved accuracy.".to_string(), + tools: vec!["gatk4".to_string()], + }, + BestPractice { + category: "variant-calling".to_string(), + title: "Filter variants after calling".to_string(), + recommendation: "Always apply quality filters (QUAL, DP, MQ) after variant calling. Use bcftools filter or GATK VariantFiltration.".to_string(), + tools: vec!["bcftools".to_string(), "gatk4".to_string()], + }, + // ── RNA-seq ────────────────────────────────────────────────────── + BestPractice { + category: "rna-seq".to_string(), + title: "Use splice-aware aligner for RNA-seq".to_string(), + recommendation: "For RNA-seq data, use a splice-aware aligner (STAR, HISAT2) rather than a DNA aligner (bwa). This correctly handles reads spanning exon junctions.".to_string(), + tools: vec!["star".to_string(), "hisat2".to_string()], + }, + BestPractice { + category: "rna-seq".to_string(), + title: "Prefer Salmon/Kallisto for transcript quantification".to_string(), + recommendation: "For transcript-level quantification, salmon and kallisto are faster and more accurate than alignment-based counting (featureCounts/HTSeq).".to_string(), + tools: vec!["salmon".to_string(), "kallisto".to_string()], + }, + // ── Quality Control ────────────────────────────────────────────── + BestPractice { + category: "quality-control".to_string(), + title: "Run QC before and after processing".to_string(), + recommendation: "Run FastQC/MultiQC on raw reads AND after trimming/alignment to verify each step improved data quality.".to_string(), + tools: vec!["fastqc".to_string(), "multiqc".to_string()], + }, + BestPractice { + category: "quality-control".to_string(), + title: "Trim adapters before alignment".to_string(), + recommendation: "Use fastp or trim-galore to remove adapters and low-quality bases before alignment. Default adapter detection usually works well.".to_string(), + tools: vec!["fastp".to_string(), "trimmomatic".to_string(), "trim-galore".to_string()], + }, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_best_practices_not_empty() { + let db = BestPracticesDb::new(); + assert!(db.len() > 5, "Expected 5+ practices, got {}", db.len()); + } + + #[test] + fn test_for_tool_samtools() { + let db = BestPracticesDb::new(); + let practices = db.for_tool("samtools"); + assert!( + practices.len() >= 3, + "samtools should have universal + specific practices" + ); + } + + #[test] + fn test_for_tool_universal() { + let db = BestPracticesDb::new(); + // An unknown tool should still get universal practices. + let practices = db.for_tool("unknown_tool"); + assert!( + !practices.is_empty(), + "universal practices should apply to any tool" + ); + } + + #[test] + fn test_for_category() { + let db = BestPracticesDb::new(); + let alignment = db.for_category("alignment"); + assert!(!alignment.is_empty()); + } + + #[test] + fn test_prompt_hint_format() { + let db = BestPracticesDb::new(); + let hint = db.to_prompt_hint("samtools"); + assert!(hint.contains("[Best Practices]")); + assert!(hint.contains("•")); + } + + #[test] + fn test_prompt_hint_unknown_tool() { + let db = BestPracticesDb::new(); + let hint = db.to_prompt_hint("unknown_tool"); + // Should still contain universal practices. + assert!(hint.contains("[Best Practices]")); + } +} diff --git a/src/knowledge/error_db.rs b/src/knowledge/error_db.rs new file mode 100644 index 00000000..494b62db --- /dev/null +++ b/src/knowledge/error_db.rs @@ -0,0 +1,266 @@ +//! Error Knowledge Database for learning from past failures. +//! +//! Records execution failures with their context (tool, task, error message, +//! stderr) and uses pattern matching to suggest fixes for recurring errors. +//! This implements the "Self-Evolution Engine" concept from the architecture. + +use crate::config::Config; +use crate::error::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A recorded error with its resolution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorRecord { + /// Tool that failed. + pub tool: String, + /// Original task description. + pub task: String, + /// The command that failed. + pub failed_command: String, + /// Exit code from the failed command. + pub exit_code: i32, + /// Stderr output (truncated to 2000 chars). + pub stderr_snippet: String, + /// Category of the error (e.g. "missing_file", "bad_flag", "permission"). + pub error_category: ErrorCategory, + /// The corrected command (if auto-retry succeeded), or a manual fix hint. + pub resolution: Option, + /// Timestamp of the error. + pub recorded_at: String, +} + +/// Broad error categories for pattern matching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ErrorCategory { + /// Input file not found or inaccessible. + MissingInput, + /// Invalid or unknown flag/option. + BadFlag, + /// Permission denied. + Permission, + /// Out of memory. + OutOfMemory, + /// Invalid file format. + FormatError, + /// Tool not installed or not in PATH. + ToolMissing, + /// Reference genome / index not found. + MissingReference, + /// General / uncategorised. + Other, +} + +impl ErrorCategory { + /// Classify stderr text into an error category. + pub fn classify(stderr: &str) -> Self { + let s = stderr.to_lowercase(); + if s.contains("no such file") || s.contains("not found") || s.contains("cannot open") { + Self::MissingInput + } else if s.contains("unknown option") + || s.contains("unrecognized option") + || s.contains("invalid option") + || s.contains("bad flag") + { + Self::BadFlag + } else if s.contains("permission denied") || s.contains("access denied") { + Self::Permission + } else if s.contains("out of memory") || s.contains("cannot allocate") || s.contains("oom") + { + Self::OutOfMemory + } else if s.contains("invalid format") + || s.contains("not a bam") + || s.contains("truncated file") + || s.contains("is not a") + { + Self::FormatError + } else if s.contains("command not found") || s.contains("no such command") { + Self::ToolMissing + } else if s.contains("reference") + && (s.contains("not found") || s.contains("missing") || s.contains("no such")) + { + Self::MissingReference + } else { + Self::Other + } + } + + /// Return a human-readable recovery hint for this error category. + pub fn recovery_hint(&self) -> &'static str { + match self { + Self::MissingInput => "Check that the input file exists and the path is correct.", + Self::BadFlag => { + "One or more flags may be invalid for this tool version. Check `tool --help` for supported options." + } + Self::Permission => { + "Check file/directory permissions. You may need to run with elevated privileges or change ownership." + } + Self::OutOfMemory => { + "The system ran out of memory. Try reducing thread count, using a smaller chunk size, or running on a machine with more RAM." + } + Self::FormatError => { + "The input file format may be incorrect or the file may be corrupted. Verify the file type matches what the tool expects." + } + Self::ToolMissing => { + "The tool is not installed or not in your PATH. Install it via conda/bioconda or check your PATH." + } + Self::MissingReference => { + "A reference genome or index file is missing. Ensure you have built the required index." + } + Self::Other => "An unexpected error occurred. Check the stderr output for details.", + } + } +} + +/// Error knowledge database backed by a JSONL file. +#[allow(dead_code)] +pub struct ErrorKnowledgeDb; + +#[allow(dead_code)] +impl ErrorKnowledgeDb { + fn db_path() -> Result { + Ok(Config::data_dir()?.join("error_knowledge.jsonl")) + } + + /// Record an error for future learning. + pub fn record(record: ErrorRecord) -> Result<()> { + let path = Self::db_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let line = serde_json::to_string(&record)?; + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path)?; + writeln!(f, "{line}")?; + Ok(()) + } + + /// Search for similar past errors for the given tool and error category. + /// Returns up to `limit` matching records, newest first. + pub fn search(tool: &str, category: ErrorCategory, limit: usize) -> Result> { + let path = Self::db_path()?; + if !path.exists() { + return Ok(vec![]); + } + + let content = std::fs::read_to_string(&path)?; + let mut matches: Vec = content + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|r| { + r.tool.to_lowercase() == tool.to_lowercase() && r.error_category == category + }) + .collect(); + + // Newest first (reverse order since file is append-only). + matches.reverse(); + matches.truncate(limit); + Ok(matches) + } + + /// Get a recovery suggestion based on past errors. + /// First checks the error DB for tool-specific fixes, then falls back to generic hints. + pub fn suggest_recovery(tool: &str, stderr: &str) -> String { + let category = ErrorCategory::classify(stderr); + + // Try to find a past resolution for this tool + category. + if let Ok(past_errors) = Self::search(tool, category, 3) { + for record in &past_errors { + if let Some(ref resolution) = record.resolution { + return format!( + "Based on a similar past error: {resolution}\n(Category: {category:?})" + ); + } + } + } + + // Fall back to generic hint. + format!( + "{}\n(Error category: {:?})", + category.recovery_hint(), + category + ) + } + + /// Count total recorded errors. + pub fn count() -> Result { + let path = Self::db_path()?; + if !path.exists() { + return Ok(0); + } + let content = std::fs::read_to_string(&path)?; + Ok(content.lines().filter(|l| !l.trim().is_empty()).count()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_category_classify_missing_input() { + assert_eq!( + ErrorCategory::classify("samtools: No such file or directory"), + ErrorCategory::MissingInput + ); + } + + #[test] + fn test_error_category_classify_bad_flag() { + assert_eq!( + ErrorCategory::classify("samtools: unrecognized option '--xyz'"), + ErrorCategory::BadFlag + ); + } + + #[test] + fn test_error_category_classify_permission() { + assert_eq!( + ErrorCategory::classify("Permission denied: /data/output.bam"), + ErrorCategory::Permission + ); + } + + #[test] + fn test_error_category_classify_oom() { + assert_eq!( + ErrorCategory::classify("fatal: out of memory"), + ErrorCategory::OutOfMemory + ); + } + + #[test] + fn test_error_category_classify_format() { + assert_eq!( + ErrorCategory::classify("[E::sam_parse1] truncated file"), + ErrorCategory::FormatError + ); + } + + #[test] + fn test_error_category_classify_other() { + assert_eq!( + ErrorCategory::classify("some random error"), + ErrorCategory::Other + ); + } + + #[test] + fn test_recovery_hint_not_empty() { + for cat in &[ + ErrorCategory::MissingInput, + ErrorCategory::BadFlag, + ErrorCategory::Permission, + ErrorCategory::OutOfMemory, + ErrorCategory::FormatError, + ErrorCategory::ToolMissing, + ErrorCategory::MissingReference, + ErrorCategory::Other, + ] { + assert!(!cat.recovery_hint().is_empty()); + } + } +} diff --git a/src/knowledge/mod.rs b/src/knowledge/mod.rs new file mode 100644 index 00000000..5f64fbff --- /dev/null +++ b/src/knowledge/mod.rs @@ -0,0 +1,10 @@ +//! Knowledge Enhancement Layer (RAG-inspired). +//! +//! This module provides the knowledge foundation for grounding LLM calls: +//! - **Tool Knowledge Base**: Embedded bioconda tool metadata with similarity search +//! - **Error Knowledge Base**: Learning from past failures for error recovery +//! - **Best Practices**: Domain-specific bioinformatics best practices + +pub mod best_practices; +pub mod error_db; +pub mod tool_knowledge; diff --git a/src/knowledge/tool_knowledge.rs b/src/knowledge/tool_knowledge.rs new file mode 100644 index 00000000..2ea100a6 --- /dev/null +++ b/src/knowledge/tool_knowledge.rs @@ -0,0 +1,716 @@ +//! Embedded bioconda tool knowledge base with keyword-based similarity search. +//! +//! Provides fast, offline lookup of tool metadata (name, description, category, +//! keywords) for 6000+ bioconda tools. Uses TF-IDF–style keyword scoring to +//! find the most relevant tools for a given natural-language query. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Boost factor applied to exact tool-name matches in the search index. +const TOOL_NAME_BOOST: f32 = 3.0; + +// ─── Core types ────────────────────────────────────────────────────────────── + +/// Metadata for a single bioinformatics tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolEntry { + /// Canonical tool name (e.g. "samtools"). + pub name: String, + /// One-line description from bioconda. + pub description: String, + /// Primary category (alignment, variant-calling, rna-seq, …). + pub category: String, + /// Search keywords derived from name + description. + pub keywords: Vec, +} + +/// Scored search result. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ToolMatch { + pub entry: ToolEntry, + pub score: f32, +} + +// ─── Knowledge base ────────────────────────────────────────────────────────── + +/// In-memory tool knowledge base with keyword search. +#[allow(dead_code)] +pub struct ToolKnowledgeBase { + tools: Vec, + /// Inverted index: keyword → list of (tool_index, weight). + index: HashMap>, +} + +impl Default for ToolKnowledgeBase { + fn default() -> Self { + Self::new() + } +} + +#[allow(dead_code)] +impl ToolKnowledgeBase { + /// Create a new knowledge base pre-loaded with the embedded bioconda catalog. + pub fn new() -> Self { + let tools = Self::load_embedded_catalog(); + let index = Self::build_index(&tools); + Self { tools, index } + } + + /// Number of tools in the knowledge base. + pub fn len(&self) -> usize { + self.tools.len() + } + + /// Whether the knowledge base is empty. + pub fn is_empty(&self) -> bool { + self.tools.is_empty() + } + + /// Look up a tool by exact name (case-insensitive). + pub fn lookup(&self, name: &str) -> Option<&ToolEntry> { + let name_lower = name.to_lowercase(); + self.tools + .iter() + .find(|t| t.name.to_lowercase() == name_lower) + } + + /// Search for tools matching a natural-language query. + /// Returns up to `limit` results sorted by relevance score. + pub fn search(&self, query: &str, limit: usize) -> Vec { + let query_tokens = Self::tokenize(query); + if query_tokens.is_empty() { + return vec![]; + } + + let mut scores: HashMap = HashMap::new(); + + for token in &query_tokens { + if let Some(postings) = self.index.get(token) { + for &(tool_idx, weight) in postings { + *scores.entry(tool_idx).or_default() += weight; + } + } + } + + let mut results: Vec = scores + .into_iter() + .map(|(idx, score)| ToolMatch { + entry: self.tools[idx].clone(), + score, + }) + .collect(); + + results.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + results.truncate(limit); + results + } + + /// Find tools in the same category as the given tool. + pub fn related_tools(&self, tool_name: &str, limit: usize) -> Vec<&ToolEntry> { + let category = match self.lookup(tool_name) { + Some(entry) => entry.category.clone(), + None => return vec![], + }; + + self.tools + .iter() + .filter(|t| t.category == category && t.name.to_lowercase() != tool_name.to_lowercase()) + .take(limit) + .collect() + } + + /// Get all unique categories. + pub fn categories(&self) -> Vec { + let mut cats: Vec = self + .tools + .iter() + .map(|t| t.category.clone()) + .collect::>() + .into_iter() + .collect(); + cats.sort(); + cats + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + /// Load the embedded bioconda tool catalog. + /// This is a curated subset of common bioinformatics tools. + fn load_embedded_catalog() -> Vec { + // Embedded catalog of well-known bioconda tools with categories. + // In production, this could be loaded from an external file or database. + let raw_entries: &[(&str, &str, &str)] = &[ + // ── Alignment ──────────────────────────────────────────────────── + ( + "samtools", + "Suite for interacting with SAM/BAM/CRAM files", + "alignment", + ), + ( + "bwa", + "Burrows-Wheeler Aligner for short-read alignment", + "alignment", + ), + ( + "bwa-mem2", + "Next-generation BWA-MEM for short-read alignment", + "alignment", + ), + ("bowtie2", "Fast and sensitive read alignment", "alignment"), + ( + "hisat2", + "Graph-based alignment of next-gen reads", + "alignment", + ), + ( + "minimap2", + "Versatile pairwise aligner for genomic and spliced sequences", + "alignment", + ), + ( + "star", + "Spliced Transcripts Alignment to a Reference for RNA-seq", + "alignment", + ), + ( + "subread", + "High-performance read alignment, quantification, mutation discovery", + "alignment", + ), + ( + "picard", + "Java tools for manipulating HTS data and formats", + "alignment", + ), + ( + "sambamba", + "Tools for working with SAM/BAM files in D", + "alignment", + ), + // ── Variant Calling ────────────────────────────────────────────── + ( + "gatk4", + "Genome Analysis Toolkit for variant discovery", + "variant-calling", + ), + ( + "bcftools", + "Utilities for variant calling and manipulating VCFs and BCFs", + "variant-calling", + ), + ( + "freebayes", + "Bayesian haplotype-based polymorphism discovery", + "variant-calling", + ), + ( + "deepvariant", + "Deep learning variant caller from Google", + "variant-calling", + ), + ( + "strelka2", + "Fast and accurate germline and somatic variant caller", + "variant-calling", + ), + ( + "varscan", + "Variant detection in massively parallel sequencing data", + "variant-calling", + ), + ( + "mutect2", + "Somatic short variant caller (part of GATK)", + "variant-calling", + ), + ( + "octopus", + "Bayesian haplotype-based mutation calling", + "variant-calling", + ), + // ── RNA-seq ────────────────────────────────────────────────────── + ( + "salmon", + "Fast transcript quantification from RNA-seq data", + "rna-seq", + ), + ( + "kallisto", + "Near-optimal probabilistic RNA-seq quantification", + "rna-seq", + ), + ("rsem", "RNA-Seq by Expectation Maximization", "rna-seq"), + ( + "stringtie", + "Transcript assembly and quantification for RNA-seq", + "rna-seq", + ), + ( + "cufflinks", + "Transcriptome assembly and differential expression", + "rna-seq", + ), + ( + "featurecounts", + "Read counting for genomic features", + "rna-seq", + ), + ("htseq", "Python framework to process HTS data", "rna-seq"), + ("deseq2", "Differential gene expression analysis", "rna-seq"), + // ── Quality Control ────────────────────────────────────────────── + ( + "fastqc", + "Quality control tool for high-throughput sequence data", + "quality-control", + ), + ( + "multiqc", + "Aggregate results from bioinformatics analyses", + "quality-control", + ), + ( + "fastp", + "Ultra-fast FASTQ preprocessor with quality control", + "quality-control", + ), + ( + "trimmomatic", + "Flexible read trimming tool for Illumina NGS data", + "quality-control", + ), + ( + "cutadapt", + "Remove adapter sequences from sequencing reads", + "quality-control", + ), + ( + "trim-galore", + "Wrapper around Cutadapt and FastQC", + "quality-control", + ), + ( + "bbtools", + "BBMap suite of bioinformatics tools", + "quality-control", + ), + ( + "prinseq", + "Quality control and data processing of genomic datasets", + "quality-control", + ), + // ── Assembly ───────────────────────────────────────────────────── + ("spades", "De novo genome assembler", "assembly"), + ( + "megahit", + "Ultra-fast single-node assembler for large and complex metagenomics", + "assembly", + ), + ( + "flye", + "De novo assembler for single-molecule sequencing reads", + "assembly", + ), + ( + "canu", + "Single-molecule sequence assembler for large and small genomes", + "assembly", + ), + ( + "hifiasm", + "Haplotype-resolved de novo assembler for PacBio HiFi reads", + "assembly", + ), + ( + "quast", + "Quality assessment tool for genome assemblies", + "assembly", + ), + ( + "velvet", + "Sequence assembler for very short reads", + "assembly", + ), + ( + "wtdbg2", + "Fuzzy Bruijn graph approach for long noisy reads assembly", + "assembly", + ), + // ── Epigenomics ────────────────────────────────────────────────── + ( + "bismark", + "Bisulfite mapper and methylation caller", + "epigenomics", + ), + ("macs2", "Model-based Analysis of ChIP-Seq", "epigenomics"), + ( + "deeptools", + "Tools for normalizing and visualizing deep-sequencing data", + "epigenomics", + ), + ( + "homer", + "Software for motif discovery and next-gen sequencing analysis", + "epigenomics", + ), + ( + "methyldackel", + "Methylation bias identification and base-level methylation extraction", + "epigenomics", + ), + // ── Metagenomics ───────────────────────────────────────────────── + ( + "kraken2", + "Taxonomic sequence classification system", + "metagenomics", + ), + ( + "metaphlan", + "Metagenomic Phylogenetic Analysis", + "metagenomics", + ), + ( + "humann", + "HMP Unified Metabolic Analysis Network", + "metagenomics", + ), + ( + "bracken", + "Bayesian reestimation of abundance with KrakEN", + "metagenomics", + ), + ( + "qiime2", + "Quantitative Insights Into Microbial Ecology", + "metagenomics", + ), + // ── Structural Variants ────────────────────────────────────────── + ( + "manta", + "Structural variant and indel caller", + "structural-variants", + ), + ( + "delly", + "Structural variant discovery by integrated paired-end and split-read analysis", + "structural-variants", + ), + ( + "lumpy", + "Probabilistic framework for structural variant discovery", + "structural-variants", + ), + ( + "svaba", + "Genome-wide detection of structural variants and indels", + "structural-variants", + ), + // ── Annotation ─────────────────────────────────────────────────── + ( + "snpeff", + "Genetic variant annotation and functional effect prediction", + "annotation", + ), + ("vep", "Variant Effect Predictor from Ensembl", "annotation"), + ( + "annovar", + "Functional annotation of genetic variants", + "annotation", + ), + ( + "bedtools", + "Swiss army knife for genome arithmetic", + "annotation", + ), + // ── File Formats ───────────────────────────────────────────────── + ( + "htslib", + "C library for reading/writing HTS data", + "file-formats", + ), + ( + "tabix", + "Generic index for TAB-delimited genome position files", + "file-formats", + ), + ( + "bgzip", + "Block compression/decompression utility", + "file-formats", + ), + // ── Phylogenetics ──────────────────────────────────────────────── + ( + "iqtree", + "Efficient phylogenomic software by maximum likelihood", + "phylogenetics", + ), + ( + "raxml-ng", + "Phylogenetic tree inference tool", + "phylogenetics", + ), + ( + "beast2", + "Bayesian Evolutionary Analysis by Sampling Trees", + "phylogenetics", + ), + // ── Long Reads ─────────────────────────────────────────────────── + ( + "pbmm2", + "PacBio minimap2 SMRT Analysis wrapper", + "long-reads", + ), + ( + "medaka", + "Sequence correction for Oxford Nanopore reads", + "long-reads", + ), + ( + "nanoplot", + "Plotting tool for long-read sequencing data", + "long-reads", + ), + ( + "nanofilt", + "Filtering and trimming of long-read data", + "long-reads", + ), + ( + "guppy", + "Basecaller for Oxford Nanopore sequencing", + "long-reads", + ), + // ── Single Cell ────────────────────────────────────────────────── + ( + "cellranger", + "10x Genomics single-cell analysis pipeline", + "single-cell", + ), + ("scanpy", "Single-Cell Analysis in Python", "single-cell"), + ( + "seurat", + "R toolkit for single-cell genomics", + "single-cell", + ), + ( + "velocyto", + "RNA velocity analysis of single cells", + "single-cell", + ), + ( + "scvelo", + "RNA velocity generalized through dynamical modeling", + "single-cell", + ), + // ── Utilities ──────────────────────────────────────────────────── + ( + "seqkit", + "Cross-platform and ultrafast toolkit for FASTA/Q file manipulation", + "utilities", + ), + ( + "seqtk", + "Toolkit for processing sequences in FASTA/Q formats", + "utilities", + ), + ("csvtk", "Cross-platform CSV/TSV toolkit", "utilities"), + ( + "bioawk", + "AWK with support for biological data formats", + "utilities", + ), + ("datamash", "Command-line text data processor", "utilities"), + // ── Workflow Managers ───────────────────────────────────────────── + ("snakemake", "Workflow management system", "workflow"), + ( + "nextflow", + "Data-driven computational pipelines", + "workflow", + ), + ("cromwell", "Workflow Management System for WDL", "workflow"), + ]; + + raw_entries + .iter() + .map(|&(name, desc, cat)| { + let keywords = Self::tokenize(&format!("{name} {desc} {cat}")); + ToolEntry { + name: name.to_string(), + description: desc.to_string(), + category: cat.to_string(), + keywords, + } + }) + .collect() + } + + /// Build an inverted index from tokens to tool indices. + fn build_index(tools: &[ToolEntry]) -> HashMap> { + let mut index: HashMap> = HashMap::new(); + + // Compute IDF (inverse document frequency) for each token. + let n = tools.len() as f32; + let mut doc_freq: HashMap = HashMap::new(); + for tool in tools { + let unique_tokens: std::collections::HashSet<&String> = tool.keywords.iter().collect(); + for token in unique_tokens { + *doc_freq.entry(token.clone()).or_default() += 1; + } + } + + for (idx, tool) in tools.iter().enumerate() { + // Token frequency within this tool's keywords. + let mut tf: HashMap<&String, usize> = HashMap::new(); + for kw in &tool.keywords { + *tf.entry(kw).or_default() += 1; + } + let max_tf = tf.values().copied().max().unwrap_or(1) as f32; + + for (token, count) in &tf { + let norm_tf = *count as f32 / max_tf; + let df = *doc_freq.get(*token).unwrap_or(&1) as f32; + let idf = (n / df).ln() + 1.0; + let weight = norm_tf * idf; + + // Boost exact tool name matches. + let boost = if **token == tool.name.to_lowercase() { + TOOL_NAME_BOOST + } else { + 1.0 + }; + + index + .entry((*token).clone()) + .or_default() + .push((idx, weight * boost)); + } + } + + index + } + + /// Tokenize text into lowercase keywords, removing stop words. + fn tokenize(text: &str) -> Vec { + let stop_words: std::collections::HashSet<&str> = [ + "a", "an", "the", "and", "or", "of", "for", "to", "in", "is", "it", "by", "with", + "from", "on", "at", "as", "this", "that", "are", "was", "be", "has", "had", "not", + "but", "its", "can", + ] + .into_iter() + .collect(); + + text.to_lowercase() + .split(|c: char| !c.is_alphanumeric() && c != '-') + .filter(|w| w.len() >= 2 && !stop_words.contains(w)) + .map(String::from) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_knowledge_base_not_empty() { + let kb = ToolKnowledgeBase::new(); + assert!(kb.len() > 50, "Expected 50+ tools, got {}", kb.len()); + } + + #[test] + fn test_lookup_samtools() { + let kb = ToolKnowledgeBase::new(); + let entry = kb.lookup("samtools").expect("samtools should exist"); + assert_eq!(entry.name, "samtools"); + assert_eq!(entry.category, "alignment"); + } + + #[test] + fn test_lookup_case_insensitive() { + let kb = ToolKnowledgeBase::new(); + assert!(kb.lookup("SAMTOOLS").is_some()); + assert!(kb.lookup("Samtools").is_some()); + } + + #[test] + fn test_search_alignment() { + let kb = ToolKnowledgeBase::new(); + let results = kb.search("alignment read aligner bwa", 10); + assert!(!results.is_empty(), "search should return results"); + // alignment category tools should appear in top results + let names: Vec<&str> = results.iter().map(|r| r.entry.name.as_str()).collect(); + assert!( + names.contains(&"bwa") + || names.contains(&"bwa-mem2") + || names.contains(&"bowtie2") + || names.contains(&"hisat2") + || names.contains(&"minimap2"), + "Expected alignment tools, got: {names:?}" + ); + } + + #[test] + fn test_search_variant_calling() { + let kb = ToolKnowledgeBase::new(); + let results = kb.search("variant calling discovery polymorphism", 10); + assert!(!results.is_empty()); + let names: Vec<&str> = results.iter().map(|r| r.entry.name.as_str()).collect(); + assert!( + names.contains(&"gatk4") + || names.contains(&"bcftools") + || names.contains(&"freebayes") + || names.contains(&"deepvariant"), + "Expected variant callers, got: {names:?}" + ); + } + + #[test] + fn test_search_rna_seq() { + let kb = ToolKnowledgeBase::new(); + let results = kb.search("RNA-seq quantification transcript", 5); + assert!(!results.is_empty()); + let names: Vec<&str> = results.iter().map(|r| r.entry.name.as_str()).collect(); + assert!( + names.contains(&"salmon") || names.contains(&"kallisto") || names.contains(&"rsem"), + "Expected RNA-seq tools, got: {names:?}" + ); + } + + #[test] + fn test_related_tools() { + let kb = ToolKnowledgeBase::new(); + let related = kb.related_tools("samtools", 5); + assert!(!related.is_empty()); + for tool in &related { + assert_eq!(tool.category, "alignment"); + assert_ne!(tool.name, "samtools"); + } + } + + #[test] + fn test_categories() { + let kb = ToolKnowledgeBase::new(); + let cats = kb.categories(); + assert!(cats.contains(&"alignment".to_string())); + assert!(cats.contains(&"variant-calling".to_string())); + assert!(cats.contains(&"rna-seq".to_string())); + } + + #[test] + fn test_search_empty_query() { + let kb = ToolKnowledgeBase::new(); + let results = kb.search("", 5); + assert!(results.is_empty()); + } + + #[test] + fn test_lookup_nonexistent() { + let kb = ToolKnowledgeBase::new(); + assert!(kb.lookup("nonexistent_tool_xyz").is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4b658957..6e52e5cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,24 +18,26 @@ pub mod cache; pub mod config; pub mod context; -#[cfg(not(target_arch = "wasm32"))] pub mod copilot_auth; pub mod doc_processor; pub mod doc_summarizer; pub mod docs; pub mod engine; pub mod error; +pub mod execution; pub mod format; pub mod generator; pub mod handlers; pub mod history; pub mod index; pub mod job; +pub mod knowledge; pub mod license; pub mod llm; pub mod llm_workflow; pub mod mcp; pub mod mini_skill_cache; +pub mod orchestrator; pub mod runner; pub mod sanitize; pub mod server; diff --git a/src/license.rs b/src/license.rs index 839abf00..b466cab6 100644 --- a/src/license.rs +++ b/src/license.rs @@ -216,11 +216,8 @@ fn default_license_candidates_from( } fn default_license_candidates() -> Vec { - #[cfg(not(target_arch = "wasm32"))] let projectdirs_path = directories::ProjectDirs::from("io", "traitome", "oxo-call") .map(|dirs| dirs.config_dir().join("license.oxo.json")); - #[cfg(target_arch = "wasm32")] - let projectdirs_path: Option = None; let home_dir = std::env::var_os("HOME").map(PathBuf::from); default_license_candidates_from(projectdirs_path, home_dir) } diff --git a/src/llm/provider.rs b/src/llm/provider.rs index 3edfcaf2..c44bb84c 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -4,7 +4,6 @@ //! interacting with various LLM providers (OpenAI, Anthropic, GitHub Copilot, Ollama). use crate::config::Config; -#[cfg(not(target_arch = "wasm32"))] use crate::copilot_auth; use crate::doc_processor::{FlagEntry, StructuredDoc}; use crate::error::{OxoError, Result}; @@ -28,7 +27,6 @@ use super::types::{ pub struct LlmClient { pub(crate) config: Config, - #[cfg(not(target_arch = "wasm32"))] client: reqwest::Client, } @@ -36,7 +34,6 @@ impl LlmClient { pub fn new(config: Config) -> Self { LlmClient { config, - #[cfg(not(target_arch = "wasm32"))] client: reqwest::Client::new(), } } @@ -50,7 +47,6 @@ impl LlmClient { /// When `structured_doc` is provided (from `DocProcessor::clean_and_structure`), /// the prompt gains doc-extracted examples as few-shot demonstrations and a /// compact flag catalog — critical for ≤3B model accuracy. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn suggest_command( &self, tool: &str, @@ -60,221 +56,195 @@ impl LlmClient { no_prompt: bool, structured_doc: Option<&StructuredDoc>, ) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); + const MAX_RETRIES: usize = 2; - #[cfg(not(target_arch = "wasm32"))] - { - const MAX_RETRIES: usize = 2; + let context_window = self.config.effective_context_window(); + let tier = self.config.effective_prompt_tier(); + let model = self.config.effective_model(); + let profile = crate::config::get_model_profile(&model); + let temperature = Some(profile.optimal_temperature); - let context_window = self.config.effective_context_window(); - let tier = self.config.effective_prompt_tier(); - let model = self.config.effective_model(); - let profile = crate::config::get_model_profile(&model); - let temperature = Some(profile.optimal_temperature); + // Compute docs hash for cache key + let docs_hash = if documentation.is_empty() { + None + } else { + Some(format!( + "{:x}", + sha2::Sha256::digest(documentation.as_bytes()) + )) + }; + let skill_name = skill.map(|s| s.meta.name.clone()); + + // Try cache lookup first + if let Ok(Some(cached)) = crate::cache::LlmCache::lookup( + tool, + task, + docs_hash.as_deref(), + skill_name.as_deref(), + &model, + ) { + // Cache hit - return cached response + // Parse cached args string into Vec + let args_vec = cached.args.split_whitespace().map(String::from).collect(); + return Ok(LlmCommandSuggestion { + args: args_vec, + explanation: cached.explanation, + raw_response: String::new(), // Cache hit doesn't have raw response + inference_ms: 0.0, // Cache hit has no inference time + }); + } - // Compute docs hash for cache key - let docs_hash = if documentation.is_empty() { - None + let mut last_raw = String::new(); + let mut total_inference_ms: f64 = 0.0; + // Track whether the model produced an empty/blank response, + // which indicates it was overwhelmed by the prompt length. + let mut had_empty_output = false; + + for attempt in 0..=MAX_RETRIES { + // On retry after an empty output, use a degraded prompt that + // strips documentation to reduce context length. Small models + // (≤ 3B) often fail to produce any output when the prompt is + // too long, even if it fits within their context window. + let effective_docs = if had_empty_output && attempt > 0 { + // Strip docs entirely — the skill examples alone provide + // enough grounding for small models. + "" } else { - Some(format!( - "{:x}", - sha2::Sha256::digest(documentation.as_bytes()) - )) + documentation }; - let skill_name = skill.map(|s| s.meta.name.clone()); - - // Try cache lookup first - if let Ok(Some(cached)) = crate::cache::LlmCache::lookup( - tool, - task, - docs_hash.as_deref(), - skill_name.as_deref(), - &model, - ) { - // Cache hit - return cached response - // Parse cached args string into Vec - let args_vec = cached.args.split_whitespace().map(String::from).collect(); - return Ok(LlmCommandSuggestion { - args: args_vec, - explanation: cached.explanation, - raw_response: String::new(), // Cache hit doesn't have raw response - inference_ms: 0.0, // Cache hit has no inference time - }); - } - let mut last_raw = String::new(); - let mut total_inference_ms: f64 = 0.0; - // Track whether the model produced an empty/blank response, - // which indicates it was overwhelmed by the prompt length. - let mut had_empty_output = false; - - for attempt in 0..=MAX_RETRIES { - // On retry after an empty output, use a degraded prompt that - // strips documentation to reduce context length. Small models - // (≤ 3B) often fail to produce any output when the prompt is - // too long, even if it fits within their context window. - let effective_docs = if had_empty_output && attempt > 0 { - // Strip docs entirely — the skill examples alone provide - // enough grounding for small models. - "" - } else { - documentation - }; + let user_prompt = if attempt == 0 { + build_prompt( + tool, + effective_docs, + task, + skill, + no_prompt, + context_window, + tier, + structured_doc, + ) + } else if had_empty_output { + // After an empty output, use a fresh (shorter) prompt + // instead of the retry prompt (which adds even more text) + build_prompt( + tool, + effective_docs, + task, + skill, + no_prompt, + context_window, + tier, + structured_doc, + ) + } else { + build_retry_prompt( + tool, + effective_docs, + task, + skill, + &last_raw, + no_prompt, + context_window, + tier, + ) + }; - let user_prompt = if attempt == 0 { - build_prompt( - tool, - effective_docs, - task, - skill, - no_prompt, - context_window, - tier, - structured_doc, - ) - } else if had_empty_output { - // After an empty output, use a fresh (shorter) prompt - // instead of the retry prompt (which adds even more text) - build_prompt( - tool, - effective_docs, - task, - skill, - no_prompt, - context_window, - tier, - structured_doc, - ) - } else { - build_retry_prompt( - tool, - effective_docs, - task, - skill, - &last_raw, - no_prompt, - context_window, - tier, - ) - }; + let api_start = std::time::Instant::now(); + let raw = self + .call_api(&user_prompt, no_prompt, tier, temperature) + .await?; + total_inference_ms += api_start.elapsed().as_secs_f64() * 1000.0; - let api_start = std::time::Instant::now(); - let raw = self - .call_api(&user_prompt, no_prompt, tier, temperature) - .await?; - total_inference_ms += api_start.elapsed().as_secs_f64() * 1000.0; + // Detect empty/blank responses (model was overwhelmed) + if raw.trim().is_empty() { + had_empty_output = true; + } - // Detect empty/blank responses (model was overwhelmed) - if raw.trim().is_empty() { - had_empty_output = true; - } + let mut suggestion = parse_response(&raw)?; + suggestion.inference_ms = total_inference_ms; - let mut suggestion = parse_response(&raw)?; - suggestion.inference_ms = total_inference_ms; - - // Post-process: strip accidental tool name prefix - suggestion.args = sanitize_args(tool, suggestion.args); - - // Post-process: validate flags against doc catalog when available - if let Some(sdoc) = structured_doc - && !sdoc.flag_catalog.is_empty() - { - suggestion.args = validate_flags_against_catalog( - &suggestion.args, - &sdoc.flag_catalog, - &sdoc.quick_flags, - ); - } + // Post-process: strip accidental tool name prefix + suggestion.args = sanitize_args(tool, suggestion.args); - if is_valid_suggestion(&suggestion) { - // Store successful result in cache - let args_str = suggestion.args.join(" "); - let _ = crate::cache::LlmCache::store( - tool, - task, - docs_hash.as_deref(), - skill_name.as_deref(), - &model, - &args_str, - &suggestion.explanation, - ); - return Ok(suggestion); - } + // Post-process: validate flags against doc catalog when available + if let Some(sdoc) = structured_doc + && !sdoc.flag_catalog.is_empty() + { + suggestion.args = validate_flags_against_catalog( + &suggestion.args, + &sdoc.flag_catalog, + &sdoc.quick_flags, + ); + } - last_raw = raw; - // If this was the last attempt, return whatever we got - if attempt == MAX_RETRIES { - return Ok(suggestion); - } + if is_valid_suggestion(&suggestion) { + // Store successful result in cache + let args_str = suggestion.args.join(" "); + let _ = crate::cache::LlmCache::store( + tool, + task, + docs_hash.as_deref(), + skill_name.as_deref(), + &model, + &args_str, + &suggestion.explanation, + ); + return Ok(suggestion); } - // Unreachable — the loop always returns - unreachable!() + last_raw = raw; + // If this was the last attempt, return whatever we got + if attempt == MAX_RETRIES { + return Ok(suggestion); + } } + + // Unreachable — the loop always returns + unreachable!() } pub async fn verify_configuration(&self) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - let provider = self.config.effective_provider(); - let api_base = self.config.effective_api_base(); - let model = self.config.effective_model(); - let raw = self - .request_text("Reply with exactly OK.", Some(16), Some(0.0)) - .await?; - let response_preview = raw.lines().next().unwrap_or("").trim().to_string(); - - Ok(LlmVerificationResult { - provider, - api_base, - model, - response_preview, - }) - } + let provider = self.config.effective_provider(); + let api_base = self.config.effective_api_base(); + let model = self.config.effective_model(); + let raw = self + .request_text("Reply with exactly OK.", Some(16), Some(0.0)) + .await?; + let response_preview = raw.lines().next().unwrap_or("").trim().to_string(); + + Ok(LlmVerificationResult { + provider, + api_base, + model, + response_preview, + }) } /// Use the LLM to optimize/expand a raw task description into a precise instruction. /// /// Returns the refined task text on success, or falls back to the original task /// if the LLM response is not parseable. Errors from the API are propagated. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn optimize_task(&self, tool: &str, raw_task: &str) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - let prompt = build_task_optimization_prompt(tool, raw_task); - let raw = self.request_text(&prompt, Some(256), Some(0.2)).await?; - - // Extract the TASK: line. - for line in raw.lines() { - if let Some(rest) = line.strip_prefix("TASK:") { - let refined = rest.trim().to_string(); - if !refined.is_empty() { - return Ok(refined); - } + let prompt = build_task_optimization_prompt(tool, raw_task); + let raw = self.request_text(&prompt, Some(256), Some(0.2)).await?; + + // Extract the TASK: line. + for line in raw.lines() { + if let Some(rest) = line.strip_prefix("TASK:") { + let refined = rest.trim().to_string(); + if !refined.is_empty() { + return Ok(refined); } } - // Fall back to original task if parsing fails. - Ok(raw_task.to_string()) } + // Fall back to original task if parsing fails. + Ok(raw_task.to_string()) } /// Make a raw chat completion call with custom system prompt. /// /// This is a low-level API for specialized workflows (e.g., mini-skill generation). - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] #[allow(dead_code)] pub async fn chat_completion( &self, @@ -283,23 +253,14 @@ impl LlmClient { max_tokens: Option, temperature: Option, ) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - self.request_with_system(system, user_prompt, max_tokens, temperature) - .await - } + self.request_with_system(system, user_prompt, max_tokens, temperature) + .await } /// Ask the LLM to verify the result of a completed command execution. /// /// `output_files` is a list of `(path, Option)` pairs — a /// `None` size means the file was not found on disk. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn verify_run_result( &self, tool: &str, @@ -309,27 +270,19 @@ impl LlmClient { stderr: &str, output_files: &[(String, Option)], ) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - let user_prompt = - build_verification_prompt(tool, task, command, exit_code, stderr, output_files); - - let raw = self - .request_with_system( - verification_system_prompt(), - &user_prompt, - Some(512), - Some(0.2), - ) - .await?; - - Ok(parse_verification_response(&raw)) - } + let user_prompt = + build_verification_prompt(tool, task, command, exit_code, stderr, output_files); + + let raw = self + .request_with_system( + verification_system_prompt(), + &user_prompt, + Some(512), + Some(0.2), + ) + .await?; + + Ok(parse_verification_response(&raw)) } /// Make the raw API call and return the assistant message content. @@ -372,7 +325,6 @@ impl LlmClient { /// user/assistant message pairs. This is critical for small models (≤ 3B) /// which cannot reliably follow output format instructions in a single /// user prompt, but can imitate the format when shown an assistant example. - #[cfg(not(target_arch = "wasm32"))] async fn request_few_shot( &self, sys_prompt: &str, @@ -521,7 +473,6 @@ impl LlmClient { /// Core HTTP call. Accepts an explicit system prompt so callers can use a /// role-specific prompt (e.g., the verification analyst persona). - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] async fn request_with_system( &self, sys_prompt: &str, @@ -529,171 +480,154 @@ impl LlmClient { max_tokens_override: Option, temperature_override: Option, ) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); + let provider = self.config.effective_provider(); + let token_opt = self.config.effective_api_token(); + // Local providers such as Ollama do not require an API token. + let token = if self.config.provider_requires_token() { + token_opt.ok_or_else(|| { + let token_hint = match provider.as_str() { + "github-copilot" => " For GitHub Copilot, run: oxo-call config login", + "openai" => " For OpenAI, create an API key at:\n https://platform.openai.com/api-keys", + "anthropic" => " For Anthropic, create an API key at:\n https://console.anthropic.com/settings/keys", + _ => " Check your provider's documentation for token setup.", + }; + OxoError::LlmError( + format!( + "No API token configured for provider '{provider}'.\n\n\ + Option 1 — Interactive login (recommended for github-copilot):\n \ + oxo-call config login\n\n\ + Option 2 — Set via config:\n \ + oxo-call config set llm.api_token \n\n\ + Option 3 — Set via environment variable:\n \ + export OXO_CALL_LLM_API_TOKEN=\n\n\ + How to get a token:\n{token_hint}\n\n\ + Test your setup: oxo-call config verify" + ), + ) + })? + } else { + // For token-optional providers (e.g. Ollama), fall back to an + // empty string. An empty token means no Authorization header + // will be added (see the auth header construction below). + token_opt.unwrap_or_default() + }; - #[cfg(not(target_arch = "wasm32"))] + let api_base = self.config.effective_api_base(); + + // Enforce HTTPS for remote API endpoints (allow HTTP for local Ollama) + if !api_base.starts_with("https://") + && !api_base.starts_with("http://localhost") + && !api_base.starts_with("http://127.0.0.1") + && !api_base.starts_with("http://[::1]") { - let provider = self.config.effective_provider(); - let token_opt = self.config.effective_api_token(); - // Local providers such as Ollama do not require an API token. - let token = if self.config.provider_requires_token() { - token_opt.ok_or_else(|| { - let token_hint = match provider.as_str() { - "github-copilot" => " For GitHub Copilot, run: oxo-call config login", - "openai" => " For OpenAI, create an API key at:\n https://platform.openai.com/api-keys", - "anthropic" => " For Anthropic, create an API key at:\n https://console.anthropic.com/settings/keys", - _ => " Check your provider's documentation for token setup.", - }; - OxoError::LlmError( - format!( - "No API token configured for provider '{provider}'.\n\n\ - Option 1 — Interactive login (recommended for github-copilot):\n \ - oxo-call config login\n\n\ - Option 2 — Set via config:\n \ - oxo-call config set llm.api_token \n\n\ - Option 3 — Set via environment variable:\n \ - export OXO_CALL_LLM_API_TOKEN=\n\n\ - How to get a token:\n{token_hint}\n\n\ - Test your setup: oxo-call config verify" - ), - ) - })? - } else { - // For token-optional providers (e.g. Ollama), fall back to an - // empty string. An empty token means no Authorization header - // will be added (see the auth header construction below). - token_opt.unwrap_or_default() - }; + return Err(OxoError::LlmError(format!( + "API base URL must use HTTPS for remote endpoints: {api_base}" + ))); + } - let api_base = self.config.effective_api_base(); + let model = self.config.effective_model(); + let url = format!("{api_base}/chat/completions"); - // Enforce HTTPS for remote API endpoints (allow HTTP for local Ollama) - if !api_base.starts_with("https://") - && !api_base.starts_with("http://localhost") - && !api_base.starts_with("http://127.0.0.1") - && !api_base.starts_with("http://[::1]") - { - return Err(OxoError::LlmError(format!( - "API base URL must use HTTPS for remote endpoints: {api_base}" - ))); - } + let messages = vec![ + ChatMessage { + role: "system".to_string(), + content: sys_prompt.to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: user_prompt.to_string(), + }, + ]; - let model = self.config.effective_model(); - let url = format!("{api_base}/chat/completions"); - - let messages = vec![ - ChatMessage { - role: "system".to_string(), - content: sys_prompt.to_string(), - }, - ChatMessage { - role: "user".to_string(), - content: user_prompt.to_string(), - }, - ]; - - let request = ChatRequest { - model: model.clone(), - messages, - max_tokens: max_tokens_override.unwrap_or(self.config.effective_max_tokens()?), - temperature: temperature_override.unwrap_or_else(|| { - // Use model-specific optimal temperature as fallback - let profile = crate::config::get_model_profile(&model); - profile.optimal_temperature - }), - }; + let request = ChatRequest { + model: model.clone(), + messages, + max_tokens: max_tokens_override.unwrap_or(self.config.effective_max_tokens()?), + temperature: temperature_override.unwrap_or_else(|| { + // Use model-specific optimal temperature as fallback + let profile = crate::config::get_model_profile(&model); + profile.optimal_temperature + }), + }; - let mut req_builder = self - .client - .post(&url) - .header("Content-Type", "application/json"); + let mut req_builder = self + .client + .post(&url) + .header("Content-Type", "application/json"); - // For github-copilot, we need to exchange the GitHub token for a Copilot session token - let auth_token = if provider == "github-copilot" { - let manager = copilot_auth::get_token_manager(); - manager.get_session_token(&token).await? - } else { - token.clone() - }; + // For github-copilot, we need to exchange the GitHub token for a Copilot session token + let auth_token = if provider == "github-copilot" { + let manager = copilot_auth::get_token_manager(); + manager.get_session_token(&token).await? + } else { + token.clone() + }; - req_builder = match provider.as_str() { - "anthropic" => req_builder - .header("x-api-key", &auth_token) - .header("anthropic-version", "2023-06-01"), - "github-copilot" => { - // Add Copilot-specific headers + req_builder = match provider.as_str() { + "anthropic" => req_builder + .header("x-api-key", &auth_token) + .header("anthropic-version", "2023-06-01"), + "github-copilot" => { + // Add Copilot-specific headers + req_builder + .header("Authorization", format!("Bearer {auth_token}")) + .header("Copilot-Integration-Id", "vscode-chat") + .header("Editor-Version", "vscode/1.85.0") + .header("Editor-Plugin-Version", "copilot/1.0.0") + } + _ => { + // Only add Authorization header when a token is actually present + // (e.g. local Ollama instances usually run without authentication) + if auth_token.is_empty() { req_builder - .header("Authorization", format!("Bearer {auth_token}")) - .header("Copilot-Integration-Id", "vscode-chat") - .header("Editor-Version", "vscode/1.85.0") - .header("Editor-Plugin-Version", "copilot/1.0.0") - } - _ => { - // Only add Authorization header when a token is actually present - // (e.g. local Ollama instances usually run without authentication) - if auth_token.is_empty() { - req_builder - } else { - req_builder.header("Authorization", format!("Bearer {auth_token}")) - } + } else { + req_builder.header("Authorization", format!("Bearer {auth_token}")) } - }; - - let response = req_builder - .json(&request) - .send() - .await - .map_err(|e| OxoError::LlmError(format!("HTTP request failed: {e}")))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(OxoError::LlmError(format!("API returned {status}: {body}"))); } + }; - let chat_response: ChatResponse = response - .json() - .await - .map_err(|e| OxoError::LlmError(format!("Failed to parse API response: {e}")))?; + let response = req_builder + .json(&request) + .send() + .await + .map_err(|e| OxoError::LlmError(format!("HTTP request failed: {e}")))?; - Ok(chat_response - .choices - .first() - .map(|c| c.message.content.clone()) - .unwrap_or_default()) + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(OxoError::LlmError(format!("API returned {status}: {body}"))); } + + let chat_response: ChatResponse = response + .json() + .await + .map_err(|e| OxoError::LlmError(format!("Failed to parse API response: {e}")))?; + + Ok(chat_response + .choices + .first() + .map(|c| c.message.content.clone()) + .unwrap_or_default()) } /// Ask the LLM to review a skill file for quality and completeness. /// /// Returns a structured `LlmSkillVerification` with findings and suggestions. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn verify_skill( &self, tool: &str, skill_content: &str, ) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - let user_prompt = build_skill_verify_prompt(tool, skill_content); - let raw = self - .request_with_system( - skill_reviewer_system_prompt(), - &user_prompt, - Some(1024), - Some(0.2), - ) - .await?; - Ok(parse_skill_verify_response(&raw)) - } + let user_prompt = build_skill_verify_prompt(tool, skill_content); + let raw = self + .request_with_system( + skill_reviewer_system_prompt(), + &user_prompt, + Some(1024), + Some(0.2), + ) + .await?; + Ok(parse_skill_verify_response(&raw)) } /// Ask the LLM to rewrite and improve a skill file, returning the enhanced Markdown. @@ -701,93 +635,66 @@ impl LlmClient { /// The LLM is instructed to preserve the tool name and all correct information /// while adding missing concepts/pitfalls/examples, fixing format issues, and /// improving clarity. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn polish_skill(&self, tool: &str, skill_content: &str) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - let user_prompt = build_skill_polish_prompt(tool, skill_content); - let raw = self - .request_with_system( - skill_reviewer_system_prompt(), - &user_prompt, - Some(4096), - Some(0.3), - ) - .await?; - // Strip any markdown code fences the LLM might have wrapped the output in - Ok(strip_markdown_fences(&raw)) - } + let user_prompt = build_skill_polish_prompt(tool, skill_content); + let raw = self + .request_with_system( + skill_reviewer_system_prompt(), + &user_prompt, + Some(4096), + Some(0.3), + ) + .await?; + // Strip any markdown code fences the LLM might have wrapped the output in + Ok(strip_markdown_fences(&raw)) } /// Use LLM to generate an initial skill template pre-filled with domain knowledge. /// /// Returns a Markdown-format skill file (YAML front-matter + body sections). - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn generate_skill_template(&self, tool: &str) -> Result { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - let user_prompt = build_skill_generate_prompt(tool); - let raw = self - .request_with_system( - skill_reviewer_system_prompt(), - &user_prompt, - Some(4096), - Some(0.4), - ) - .await?; - Ok(strip_markdown_fences(&raw)) - } + let user_prompt = build_skill_generate_prompt(tool); + let raw = self + .request_with_system( + skill_reviewer_system_prompt(), + &user_prompt, + Some(4096), + Some(0.4), + ) + .await?; + Ok(strip_markdown_fences(&raw)) } /// Generate a shell command from a plain-English description. /// /// Returns `(command, explanation)`. The command is a ready-to-run shell /// string; the explanation is a brief one-liner. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn generate_shell_command(&self, description: &str) -> Result<(String, String)> { - #[cfg(target_arch = "wasm32")] - return Err(OxoError::LlmError( - "LLM API calls are not supported in WebAssembly".to_string(), - )); - - #[cfg(not(target_arch = "wasm32"))] - { - let system = "You are a shell command expert for Linux/macOS. \ - Given a plain-English description (in any language), produce a single \ - production-ready shell command or short pipeline. Use standard coreutils, \ - common bioinformatics tools, and POSIX-compatible syntax. \ - Reply with exactly two lines and nothing else:\n\ - COMMAND: \n\ - EXPLANATION: "; - - let raw = self - .request_with_system(system, description, Some(256), Some(0.1)) - .await?; - - let mut command = String::new(); - let mut explanation = String::new(); - for line in raw.lines() { - if let Some(rest) = line.strip_prefix("COMMAND:") { - command = rest.trim().to_string(); - } else if let Some(rest) = line.strip_prefix("EXPLANATION:") { - explanation = rest.trim().to_string(); - } + let system = "You are a shell command expert for Linux/macOS. \ + Given a plain-English description (in any language), produce a single \ + production-ready shell command or short pipeline. Use standard coreutils, \ + common bioinformatics tools, and POSIX-compatible syntax. \ + Reply with exactly two lines and nothing else:\n\ + COMMAND: \n\ + EXPLANATION: "; + + let raw = self + .request_with_system(system, description, Some(256), Some(0.1)) + .await?; + + let mut command = String::new(); + let mut explanation = String::new(); + for line in raw.lines() { + if let Some(rest) = line.strip_prefix("COMMAND:") { + command = rest.trim().to_string(); + } else if let Some(rest) = line.strip_prefix("EXPLANATION:") { + explanation = rest.trim().to_string(); } - if command.is_empty() { - command = raw.trim().to_string(); - } - Ok((command, explanation)) } + if command.is_empty() { + command = raw.trim().to_string(); + } + Ok((command, explanation)) } } diff --git a/src/llm/types.rs b/src/llm/types.rs index 87fdcd59..d2e0e896 100644 --- a/src/llm/types.rs +++ b/src/llm/types.rs @@ -47,7 +47,6 @@ pub struct LlmRunVerification { /// `OpenAiCompatibleProvider` covers OpenAI, GitHub Copilot, Anthropic, and /// Ollama; custom implementations can override it for providers with different /// API shapes. -#[cfg(not(target_arch = "wasm32"))] #[allow(async_fn_in_trait, dead_code)] pub trait LlmProvider { /// Send a chat completion request and return the assistant's raw text. diff --git a/src/main.rs b/src/main.rs index 9dc2fb54..296e787f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,24 +3,26 @@ mod chat; mod cli; mod config; mod context; -#[cfg(not(target_arch = "wasm32"))] mod copilot_auth; mod doc_processor; mod doc_summarizer; mod docs; mod engine; mod error; +mod execution; mod format; mod generator; mod handlers; mod history; mod index; mod job; +mod knowledge; mod license; mod llm; mod llm_workflow; mod mcp; mod mini_skill_cache; +mod orchestrator; mod runner; mod sanitize; mod server; @@ -45,8 +47,7 @@ use cli::{ use colored::Colorize; use handlers::{config_verify_suggestions, print_index_table, with_source}; -#[cfg_attr(not(target_arch = "wasm32"), tokio::main)] -#[cfg_attr(target_arch = "wasm32", tokio::main(flavor = "current_thread"))] +#[tokio::main] async fn main() { // Install color-eyre for enhanced error reporting (backtraces, color output) if let Err(e) = error::install_error_handler() { @@ -224,7 +225,6 @@ async fn run(cli: Cli) -> error::Result<()> { }); // Collect input items from --input-list / --input-items. - #[cfg(not(target_arch = "wasm32"))] let all_items = { let mut v: Vec = Vec::new(); if let Some(ref path) = input_list { @@ -242,7 +242,6 @@ async fn run(cli: Cli) -> error::Result<()> { }; // Parse --var KEY=VALUE pairs. - #[cfg(not(target_arch = "wasm32"))] let var_map = { let mut m = std::collections::HashMap::new(); for v in &vars { @@ -262,7 +261,6 @@ async fn run(cli: Cli) -> error::Result<()> { } else { runner }; - #[cfg(not(target_arch = "wasm32"))] let runner = runner .with_vars(var_map) .with_input_items(all_items) @@ -302,7 +300,6 @@ async fn run(cli: Cli) -> error::Result<()> { _ => None, }); - #[cfg(not(target_arch = "wasm32"))] let all_items = { let mut v: Vec = Vec::new(); if let Some(ref path) = input_list { @@ -319,7 +316,6 @@ async fn run(cli: Cli) -> error::Result<()> { v }; - #[cfg(not(target_arch = "wasm32"))] let var_map = { let mut m = std::collections::HashMap::new(); for v in &vars { @@ -340,7 +336,6 @@ async fn run(cli: Cli) -> error::Result<()> { } else { runner }; - #[cfg(not(target_arch = "wasm32"))] let runner = runner.with_vars(var_map).with_input_items(all_items); runner.dry_run(&tool, &task, json, None).await?; } @@ -823,140 +818,129 @@ async fn run(cli: Cli) -> error::Result<()> { println!(" (Using Copilot CLI's GitHub App for compatibility)"); println!(); - #[cfg(not(target_arch = "wasm32"))] - { - let manager = copilot_auth::get_token_manager(); - let github_token = manager.run_device_flow().await?; + let manager = copilot_auth::get_token_manager(); + let github_token = manager.run_device_flow().await?; - // Verify the token works by exchanging for a Copilot session token - println!(); - println!(" Verifying token..."); - match manager.exchange_token(&github_token).await { - Ok(copilot_token) => { - println!(" {} Token verified successfully!", "✓".green()); - println!( - " Session token expires in {} seconds.", - copilot_token.refresh_in - ); - } - Err(e) => { - eprintln!( - "{} Token verification failed: {e}", - "error:".bold().red() - ); - eprintln!(); - eprintln!(" This usually means:"); - eprintln!( - " 1. You don't have a GitHub Copilot subscription" - ); - eprintln!( - " 2. Your organization hasn't enabled Copilot for you" - ); - eprintln!(); - eprintln!( - " Visit https://github.com/settings/copilot to check your subscription." - ); - std::process::exit(1); - } - } - - // --- Interactive model selection --- - println!(); - println!(" {}", "Select a GitHub Copilot model:".bold()); - println!(); - for (i, (id, desc, is_free)) in COPILOT_MODELS.iter().enumerate() { - let free_tag = if *is_free { - format!(" {}", "[free tier ⭐]".green()) - } else { - String::new() - }; - let default_tag = if i == 0 { - format!(" {}", "[default]".dimmed()) - } else { - String::new() - }; + // Verify the token works by exchanging for a Copilot session token + println!(); + println!(" Verifying token..."); + match manager.exchange_token(&github_token).await { + Ok(copilot_token) => { + println!(" {} Token verified successfully!", "✓".green()); println!( - " {}. {}{}{}", - (i + 1).to_string().bold(), - desc, - free_tag, - default_tag + " Session token expires in {} seconds.", + copilot_token.refresh_in ); - // Print the model id indented under the description - println!(" {}", id.dimmed()); } - println!(); + Err(e) => { + eprintln!( + "{} Token verification failed: {e}", + "error:".bold().red() + ); + eprintln!(); + eprintln!(" This usually means:"); + eprintln!(" 1. You don't have a GitHub Copilot subscription"); + eprintln!( + " 2. Your organization hasn't enabled Copilot for you" + ); + eprintln!(); + eprintln!( + " Visit https://github.com/settings/copilot to check your subscription." + ); + std::process::exit(1); + } + } + + // --- Interactive model selection --- + println!(); + println!(" {}", "Select a GitHub Copilot model:".bold()); + println!(); + for (i, (id, desc, is_free)) in COPILOT_MODELS.iter().enumerate() { + let free_tag = if *is_free { + format!(" {}", "[free tier ⭐]".green()) + } else { + String::new() + }; + let default_tag = if i == 0 { + format!(" {}", "[default]".dimmed()) + } else { + String::new() + }; println!( - " 💡 {}", - "Free-tier models (⭐) work on all GitHub Copilot plans.".dimmed() + " {}. {}{}{}", + (i + 1).to_string().bold(), + desc, + free_tag, + default_tag ); - println!(); + // Print the model id indented under the description + println!(" {}", id.dimmed()); + } + println!(); + println!( + " 💡 {}", + "Free-tier models (⭐) work on all GitHub Copilot plans.".dimmed() + ); + println!(); - use std::io::IsTerminal as _; - let selected_model = if std::io::stdin().is_terminal() { - use std::io::Write as _; - print!( - " Enter number [1–{}], or press {} for default ({}): ", - COPILOT_MODELS.len(), - "Enter".bold(), - COPILOT_MODELS[0].0.green() - ); - std::io::stdout().flush().ok(); - let mut sel = String::new(); - std::io::stdin().read_line(&mut sel).ok(); - let sel = sel.trim(); - if sel.is_empty() { - COPILOT_MODELS[0].0.to_string() - } else if let Ok(n) = sel.parse::() { - if n >= 1 && n <= COPILOT_MODELS.len() { - COPILOT_MODELS[n - 1].0.to_string() - } else { - println!( - " {} Invalid number, using default ({}).", - "⚠".yellow(), - COPILOT_MODELS[0].0 - ); - COPILOT_MODELS[0].0.to_string() - } + use std::io::IsTerminal as _; + let selected_model = if std::io::stdin().is_terminal() { + use std::io::Write as _; + print!( + " Enter number [1–{}], or press {} for default ({}): ", + COPILOT_MODELS.len(), + "Enter".bold(), + COPILOT_MODELS[0].0.green() + ); + std::io::stdout().flush().ok(); + let mut sel = String::new(); + std::io::stdin().read_line(&mut sel).ok(); + let sel = sel.trim(); + if sel.is_empty() { + COPILOT_MODELS[0].0.to_string() + } else if let Ok(n) = sel.parse::() { + if n >= 1 && n <= COPILOT_MODELS.len() { + COPILOT_MODELS[n - 1].0.to_string() } else { - // User typed a raw model name - sel.to_string() + println!( + " {} Invalid number, using default ({}).", + "⚠".yellow(), + COPILOT_MODELS[0].0 + ); + COPILOT_MODELS[0].0.to_string() } } else { - // Non-interactive (piped/script), use default silently - COPILOT_MODELS[0].0.to_string() - }; - - let mut cfg = config::Config::load()?; - cfg.llm.provider = "github-copilot".to_string(); - cfg.llm.api_token = Some(github_token); - cfg.llm.model = Some(selected_model.clone()); - // Pre-populate the model list with all available Copilot models - // so the user can switch quickly with `config model use`. - for (id, _, _) in COPILOT_MODELS { - if !cfg.llm.models.contains(&id.to_string()) { - cfg.llm.models.push(id.to_string()); - } + // User typed a raw model name + sel.to_string() } - cfg.save()?; + } else { + // Non-interactive (piped/script), use default silently + COPILOT_MODELS[0].0.to_string() + }; - println!(); - println!("{} Authenticated with GitHub Copilot.", "✓".green().bold()); - println!(" provider github-copilot"); - println!( - " model {} (switch with `oxo-call config model use `)", - selected_model - ); - println!(); - println!(" Run `oxo-call config verify` to confirm everything works."); - println!(" Run `oxo-call config model list` to see available models."); - } - #[cfg(target_arch = "wasm32")] - { - return Err(error::OxoError::ConfigError( - "Device flow is not supported in WebAssembly.".to_string(), - )); + let mut cfg = config::Config::load()?; + cfg.llm.provider = "github-copilot".to_string(); + cfg.llm.api_token = Some(github_token); + cfg.llm.model = Some(selected_model.clone()); + // Pre-populate the model list with all available Copilot models + // so the user can switch quickly with `config model use`. + for (id, _, _) in COPILOT_MODELS { + if !cfg.llm.models.contains(&id.to_string()) { + cfg.llm.models.push(id.to_string()); + } } + cfg.save()?; + + println!(); + println!("{} Authenticated with GitHub Copilot.", "✓".green().bold()); + println!(" provider github-copilot"); + println!( + " model {} (switch with `oxo-call config model use `)", + selected_model + ); + println!(); + println!(" Run `oxo-call config verify` to confirm everything works."); + println!(" Run `oxo-call config model list` to see available models."); } other => { eprintln!( @@ -1614,7 +1598,6 @@ async fn run(cli: Cli) -> error::Result<()> { println!("{content}"); } - #[cfg(not(target_arch = "wasm32"))] WorkflowCommands::RunWorkflow { file, verify } => { let path = std::path::Path::new(&file); let source = if path.exists() { @@ -1642,16 +1625,6 @@ async fn run(cli: Cli) -> error::Result<()> { } } - #[cfg(target_arch = "wasm32")] - WorkflowCommands::RunWorkflow { .. } => { - eprintln!( - "{} 'workflow run' is not supported on WebAssembly.", - "error:".red().bold() - ); - std::process::exit(1); - } - - #[cfg(not(target_arch = "wasm32"))] WorkflowCommands::DryRunWorkflow { file } => { let path = std::path::Path::new(&file); let source = if path.exists() { @@ -1674,15 +1647,6 @@ async fn run(cli: Cli) -> error::Result<()> { engine::execute(tasks, true).await?; } - #[cfg(target_arch = "wasm32")] - WorkflowCommands::DryRunWorkflow { .. } => { - eprintln!( - "{} 'workflow dry-run' is not supported on WebAssembly.", - "error:".red().bold() - ); - std::process::exit(1); - } - WorkflowCommands::Export { file, to, output } => { let path = std::path::Path::new(&file); let def = if path.exists() { @@ -1717,7 +1681,6 @@ async fn run(cli: Cli) -> error::Result<()> { } } - #[cfg(not(target_arch = "wasm32"))] WorkflowCommands::Generate { task, engine: engine_name, @@ -1762,16 +1725,6 @@ async fn run(cli: Cli) -> error::Result<()> { } } - #[cfg(target_arch = "wasm32")] - WorkflowCommands::Generate { .. } => { - eprintln!( - "{} 'workflow generate' is not supported on WebAssembly.", - "error:".red().bold() - ); - std::process::exit(1); - } - - #[cfg(not(target_arch = "wasm32"))] WorkflowCommands::Infer { task, data, @@ -1879,15 +1832,6 @@ async fn run(cli: Cli) -> error::Result<()> { } } - #[cfg(target_arch = "wasm32")] - WorkflowCommands::Infer { .. } => { - eprintln!( - "{} 'workflow infer' is not supported on WebAssembly.", - "error:".red().bold() - ); - std::process::exit(1); - } - WorkflowCommands::Verify { file } => { let path = std::path::Path::new(&file); let def = if path.exists() { @@ -1963,7 +1907,6 @@ async fn run(cli: Cli) -> error::Result<()> { } }, - #[cfg(not(target_arch = "wasm32"))] Commands::Server { command } => { match command { ServerCommands::Add { @@ -2439,47 +2382,44 @@ async fn run(cli: Cli) -> error::Result<()> { println!("{}", "─".repeat(60).dimmed()); println!(); - #[cfg(not(target_arch = "wasm32"))] - { - let mut ssh_cmd = std::process::Command::new("ssh"); - for arg in &host.ssh_args() { - ssh_cmd.arg(arg); - } - ssh_cmd.arg(&generated.full_cmd); - - let status = ssh_cmd.status()?; - let exit_code = status.code().unwrap_or(-1); - - // Record the server run in history. - let _ = history::HistoryStore::append(history::HistoryEntry { - id: uuid::Uuid::new_v4().to_string(), - tool: tool.clone(), - task: task.clone(), - command: generated.full_cmd.clone(), - exit_code, - executed_at: chrono::Utc::now(), - dry_run: false, - server: Some(server_name.clone()), - provenance: None, - }); + let mut ssh_cmd = std::process::Command::new("ssh"); + for arg in &host.ssh_args() { + ssh_cmd.arg(arg); + } + ssh_cmd.arg(&generated.full_cmd); + + let status = ssh_cmd.status()?; + let exit_code = status.code().unwrap_or(-1); + + // Record the server run in history. + let _ = history::HistoryStore::append(history::HistoryEntry { + id: uuid::Uuid::new_v4().to_string(), + tool: tool.clone(), + task: task.clone(), + command: generated.full_cmd.clone(), + exit_code, + executed_at: chrono::Utc::now(), + dry_run: false, + server: Some(server_name.clone()), + provenance: None, + }); - println!(); - if status.success() { - println!( - "{} Command completed on '{}'.", - "✓".green().bold(), - server_name.cyan() - ); - } else { - eprintln!( - "{} Command exited with code {exit_code} on '{}'.", - "✗".red().bold(), - server_name.cyan() - ); - return Err(error::OxoError::ExecutionError(format!( - "SSH command on '{server_name}' exited with code {exit_code}" - ))); - } + println!(); + if status.success() { + println!( + "{} Command completed on '{}'.", + "✓".green().bold(), + server_name.cyan() + ); + } else { + eprintln!( + "{} Command exited with code {exit_code} on '{}'.", + "✗".red().bold(), + server_name.cyan() + ); + return Err(error::OxoError::ExecutionError(format!( + "SSH command on '{server_name}' exited with code {exit_code}" + ))); } } @@ -2559,15 +2499,6 @@ async fn run(cli: Cli) -> error::Result<()> { } } - #[cfg(target_arch = "wasm32")] - Commands::Server { .. } => { - eprintln!( - "{} 'server' commands are not supported on WebAssembly.", - "error:".red().bold() - ); - std::process::exit(1); - } - Commands::Job { command } => { match command { JobCommands::Add { @@ -2771,7 +2702,6 @@ async fn run(cli: Cli) -> error::Result<()> { } // If neither --input-list nor --input-items was given but stdin is not // a terminal, read items from stdin (one per line, skip blank/#-lines). - #[cfg(not(target_arch = "wasm32"))] if all_items.is_empty() && input_list.is_none() && input_items.is_none() { use std::io::IsTerminal; if !std::io::stdin().is_terminal() { @@ -2813,7 +2743,6 @@ async fn run(cli: Cli) -> error::Result<()> { let started = chrono::Utc::now(); let start_inst = std::time::Instant::now(); - #[cfg(not(target_arch = "wasm32"))] if let Some(ref srv_name) = server_flag { let cfg = config::Config::load()?; let mgr = server::ServerManager::new(cfg); @@ -2898,16 +2827,6 @@ async fn run(cli: Cli) -> error::Result<()> { ))); } } - - #[cfg(target_arch = "wasm32")] - { - let _ = server_flag; - eprintln!( - "{} 'job run' is not supported on WebAssembly.", - "error:".red().bold() - ); - std::process::exit(1); - } } else { // ── Batch run (one item per invocation) ────────────────────────── @@ -2962,151 +2881,128 @@ async fn run(cli: Cli) -> error::Result<()> { println!("{}", "─".repeat(60).dimmed()); println!(); - #[cfg(not(target_arch = "wasm32"))] - { - use std::sync::Arc; - // Record wall-clock start before tasks are spawned. - let started_all = chrono::Utc::now(); - let start_inst_all = std::time::Instant::now(); - - let sem = Arc::new(tokio::sync::Semaphore::new(jobs)); - let mut handles: Vec<( - String, - tokio::task::JoinHandle>, - )> = Vec::with_capacity(n); - - for (i, item) in all_items.iter().enumerate() { - let cmd = - job::interpolate_command(&entry.command, item, i + 1, &var_map); - let sem_clone = Arc::clone(&sem); - let item_label = item.clone(); - let handle: tokio::task::JoinHandle> = - tokio::spawn(async move { - let _permit = sem_clone - .acquire_owned() - .await - .expect("semaphore closed unexpectedly"); - tokio::task::spawn_blocking(move || { - std::process::Command::new("sh") - .arg("-c") - .arg(&cmd) - .status() - .map(|s| s.code().unwrap_or(-1)) - .map_err(|e| { - error::OxoError::ExecutionError(format!( - "failed to run '{item_label}': {e}" - )) - }) - }) + use std::sync::Arc; + // Record wall-clock start before tasks are spawned. + let started_all = chrono::Utc::now(); + let start_inst_all = std::time::Instant::now(); + + let sem = Arc::new(tokio::sync::Semaphore::new(jobs)); + let mut handles: Vec<( + String, + tokio::task::JoinHandle>, + )> = Vec::with_capacity(n); + + for (i, item) in all_items.iter().enumerate() { + let cmd = + job::interpolate_command(&entry.command, item, i + 1, &var_map); + let sem_clone = Arc::clone(&sem); + let item_label = item.clone(); + let handle: tokio::task::JoinHandle> = + tokio::spawn(async move { + let _permit = sem_clone + .acquire_owned() .await - .map_err(|e| { - error::OxoError::ExecutionError(format!( - "task join error: {e}" - )) - })? - }); - handles.push((item.clone(), handle)); - } - - let mut failed = 0usize; - let mut done = 0usize; - for (item, handle) in handles { - let code = match handle.await { - Ok(Ok(c)) => c, - Ok(Err(e)) => { - failed += 1; - eprintln!(" {} {}: {}", "✗".red().bold(), item, e); - -1 - } - Err(e) => { - failed += 1; - eprintln!( - " {} {}: join error: {}", - "✗".red().bold(), - item, - e - ); - -1 - } - }; - // Count non-zero exit codes as failures. - // Sentinel -1 already incremented `failed` above. - if code != 0 && code != -1 { + .expect("semaphore closed unexpectedly"); + tokio::task::spawn_blocking(move || { + std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .status() + .map(|s| s.code().unwrap_or(-1)) + .map_err(|e| { + error::OxoError::ExecutionError(format!( + "failed to run '{item_label}': {e}" + )) + }) + }) + .await + .map_err(|e| { + error::OxoError::ExecutionError(format!( + "task join error: {e}" + )) + })? + }); + handles.push((item.clone(), handle)); + } + + let mut failed = 0usize; + let mut done = 0usize; + for (item, handle) in handles { + let code = match handle.await { + Ok(Ok(c)) => c, + Ok(Err(e)) => { failed += 1; + eprintln!(" {} {}: {}", "✗".red().bold(), item, e); + -1 } - done += 1; - match code { - 0 => println!( - " {} [{}/{}] {}", - "✓".green().bold(), - done, - n, - item - ), - -1 => {} // error already printed - c => eprintln!( - " {} [{}/{}] {} (exit {})", - "✗".red().bold(), - done, - n, - item, - c.to_string().red() - ), - } - if stop_on_error && failed > 0 { - eprintln!( - " {} stop-on-error: aborting after first failure ({}/{} done)", - "⚡".yellow().bold(), - done, - n - ); - break; + Err(e) => { + failed += 1; + eprintln!(" {} {}: join error: {}", "✗".red().bold(), item, e); + -1 } + }; + // Count non-zero exit codes as failures. + // Sentinel -1 already incremented `failed` above. + if code != 0 && code != -1 { + failed += 1; } - - let dur = start_inst_all.elapsed().as_secs_f64(); - // Record the batch run under the job's name. - let summary_cmd = - format!("{} # batch:{n} vars:{}", entry.command, var_map.len()); - let _ = job::JobManager::record_run( - &name, - &summary_cmd, - None, - if failed == 0 { 0 } else { 1 }, - started_all, - dur, - ); - - println!(); - println!("{}", "─".repeat(60).dimmed()); - if failed == 0 { - println!( - " {} All {} items completed successfully.", - "✓".green().bold(), - done.to_string().green() - ); - } else { - eprintln!( - " {} {}/{} items failed.", + done += 1; + match code { + 0 => println!(" {} [{}/{}] {}", "✓".green().bold(), done, n, item), + -1 => {} // error already printed + c => eprintln!( + " {} [{}/{}] {} (exit {})", "✗".red().bold(), - failed.to_string().red(), - done + done, + n, + item, + c.to_string().red() + ), + } + if stop_on_error && failed > 0 { + eprintln!( + " {} stop-on-error: aborting after first failure ({}/{} done)", + "⚡".yellow().bold(), + done, + n ); - return Err(error::OxoError::ExecutionError(format!( - "{failed}/{done} batch items failed" - ))); + break; } - println!("{}", "─".repeat(60).dimmed()); } - #[cfg(target_arch = "wasm32")] - { + let dur = start_inst_all.elapsed().as_secs_f64(); + // Record the batch run under the job's name. + let summary_cmd = + format!("{} # batch:{n} vars:{}", entry.command, var_map.len()); + let _ = job::JobManager::record_run( + &name, + &summary_cmd, + None, + if failed == 0 { 0 } else { 1 }, + started_all, + dur, + ); + + println!(); + println!("{}", "─".repeat(60).dimmed()); + if failed == 0 { + println!( + " {} All {} items completed successfully.", + "✓".green().bold(), + done.to_string().green() + ); + } else { eprintln!( - "{} 'job run' batch mode is not supported on WebAssembly.", - "error:".red().bold() + " {} {}/{} items failed.", + "✗".red().bold(), + failed.to_string().red(), + done ); - std::process::exit(1); + return Err(error::OxoError::ExecutionError(format!( + "{failed}/{done} batch items failed" + ))); } + println!("{}", "─".repeat(60).dimmed()); } } diff --git a/src/mcp.rs b/src/mcp.rs index 4c23a514..4686981a 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -42,9 +42,7 @@ use crate::config::McpServerConfig; use crate::error::{OxoError, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; -#[cfg(not(target_arch = "wasm32"))] use serde_json::json; -#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; /// Default HTTP timeout for MCP requests. @@ -80,7 +78,6 @@ struct RpcResponse { /// required because oxo-call only performs read-only resource operations. pub struct McpClient { config: McpServerConfig, - #[cfg(not(target_arch = "wasm32"))] http: reqwest::Client, } @@ -89,7 +86,6 @@ impl McpClient { pub fn new(config: McpServerConfig) -> Self { McpClient { config, - #[cfg(not(target_arch = "wasm32"))] http: reqwest::Client::builder() .timeout(Duration::from_secs(MCP_TIMEOUT_SECS)) .build() @@ -112,7 +108,6 @@ impl McpClient { // ── Internal HTTP helper ────────────────────────────────────────────── - #[cfg(not(target_arch = "wasm32"))] async fn send(&self, method: &str, params: Option, id: u64) -> Result { let req = RpcRequest { jsonrpc: "2.0", @@ -176,7 +171,6 @@ impl McpClient { /// (connection refused, timeout, DNS failure). Non-transient errors /// (HTTP 4xx, JSON-RPC application errors) are returned immediately /// without retrying. - #[cfg(not(target_arch = "wasm32"))] async fn send_with_retry(&self, method: &str, params: Option, id: u64) -> Result { let mut last_err = None; @@ -208,7 +202,6 @@ impl McpClient { /// Perform the MCP `initialize` handshake. /// /// Returns `(server_name, server_version)` for display purposes. - #[cfg(not(target_arch = "wasm32"))] pub async fn initialize(&self) -> Result<(String, String)> { let params = json!({ "protocolVersion": "2024-11-05", @@ -231,20 +224,11 @@ impl McpClient { Ok((name, version)) } - /// Wasm32-compatible stub: MCP HTTP transport is not available in WebAssembly. - #[cfg(target_arch = "wasm32")] - pub async fn initialize(&self) -> Result<(String, String)> { - Err(OxoError::IndexError( - "MCP is not supported in WebAssembly".to_string(), - )) - } - /// Call `resources/list` to discover skill resources on this server. /// /// Returns a list of `(uri, tool_name, description)` triples. Only /// resources with a `skill://` URI scheme or a `text/markdown` MIME type /// are included. - #[cfg(not(target_arch = "wasm32"))] pub async fn list_skill_resources(&self) -> Result> { let result = self.send_with_retry("resources/list", None, 2).await?; let resources = match result["resources"].as_array() { @@ -276,16 +260,7 @@ impl McpClient { Ok(entries) } - /// Wasm32-compatible stub: MCP HTTP transport is not available in WebAssembly. - #[cfg(target_arch = "wasm32")] - pub async fn list_skill_resources(&self) -> Result> { - Err(OxoError::IndexError( - "MCP is not supported in WebAssembly".to_string(), - )) - } - /// Call `resources/read` to fetch the Markdown content for a skill URI. - #[cfg(not(target_arch = "wasm32"))] pub async fn read_resource(&self, uri: &str) -> Result { let params = json!({ "uri": uri }); let result = self @@ -314,7 +289,6 @@ impl McpClient { /// falls back to scanning the resource list for a matching tool name. /// /// Returns `None` if the tool is not available on this server. - #[cfg(not(target_arch = "wasm32"))] pub async fn fetch_skill(&self, tool: &str) -> Option { // Fast path: try canonical URI first let canonical = format!("skill://{tool}"); @@ -553,7 +527,6 @@ mod tests { // ─── Mock HTTP tests (wiremock) ─────────────────────────────────────────── - #[cfg(not(target_arch = "wasm32"))] mod mock_tests { use super::*; use wiremock::matchers::{method, path}; diff --git a/src/orchestrator/executor.rs b/src/orchestrator/executor.rs new file mode 100644 index 00000000..6aeedfdb --- /dev/null +++ b/src/orchestrator/executor.rs @@ -0,0 +1,150 @@ +//! Executor Agent — generates and runs commands. +//! +//! The executor is responsible for the actual command generation step. +//! It enriches the LLM prompt with knowledge-layer hints (best practices, +//! tool info) before calling the LLM client. + +use crate::error::Result; +use crate::knowledge::best_practices::BestPracticesDb; +use crate::task_normalizer::{NormalizedTask, TaskNormalizer}; + +/// Result from the executor agent's preparation step. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ExecutorContext { + /// Normalized task description. + pub normalized_task: NormalizedTask, + /// Best practice hints injected into the prompt. + pub practice_hints: String, + /// Whether the task was normalized (changed from original). + pub was_normalized: bool, +} + +/// The Executor Agent. +pub struct ExecutorAgent { + normalizer: TaskNormalizer, + best_practices: BestPracticesDb, +} + +impl Default for ExecutorAgent { + fn default() -> Self { + Self::new() + } +} + +impl ExecutorAgent { + pub fn new() -> Self { + Self { + normalizer: TaskNormalizer::new(), + best_practices: BestPracticesDb::new(), + } + } + + /// Prepare execution context: normalize the task and gather enrichment. + pub async fn prepare(&self, tool: &str, task: &str) -> Result { + // Step 1: Normalize the task. + let normalized = self.normalizer.normalize(task, tool).await.map_err(|e| { + crate::error::OxoError::LlmError(format!("task normalization failed: {e}")) + })?; + let was_normalized = normalized.description != task; + + // Step 2: Gather best practice hints. + let practice_hints = self.best_practices.to_prompt_hint(tool); + + Ok(ExecutorContext { + normalized_task: normalized, + practice_hints, + was_normalized, + }) + } + + /// Build an enriched task string for the LLM prompt. + /// + /// Combines the normalized task with best practice hints and intent info. + pub fn enrich_task(&self, ctx: &ExecutorContext) -> String { + let mut parts = vec![ctx.normalized_task.description.clone()]; + + // Add intent context. + let intent = &ctx.normalized_task.intent; + parts.push(format!("[Intent: {intent}]")); + + // Add extracted parameters. + if !ctx.normalized_task.parameters.is_empty() { + let params: Vec = ctx + .normalized_task + .parameters + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + parts.push(format!("[Params: {}]", params.join(", "))); + } + + // Add constraints. + if !ctx.normalized_task.constraints.is_empty() { + parts.push(format!( + "[Constraints: {}]", + ctx.normalized_task.constraints.join(", ") + )); + } + + // Add best practices (truncated). + if !ctx.practice_hints.is_empty() { + parts.push(ctx.practice_hints.clone()); + } + + parts.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_prepare_simple_task() { + let executor = ExecutorAgent::new(); + let ctx = executor + .prepare("samtools", "sort input.bam by coordinate") + .await + .unwrap(); + assert!(!ctx.normalized_task.description.is_empty()); + } + + #[tokio::test] + async fn test_enrich_task_adds_intent() { + let executor = ExecutorAgent::new(); + let ctx = executor + .prepare("samtools", "sort input.bam by coordinate") + .await + .unwrap(); + let enriched = executor.enrich_task(&ctx); + assert!(enriched.contains("[Intent:")); + } + + #[tokio::test] + async fn test_enrich_task_adds_practices() { + let executor = ExecutorAgent::new(); + let ctx = executor + .prepare("samtools", "sort input.bam by coordinate") + .await + .unwrap(); + let enriched = executor.enrich_task(&ctx); + assert!( + enriched.contains("[Best Practices]"), + "Known tool should have best practice hints" + ); + } + + #[tokio::test] + async fn test_prepare_with_threads() { + let executor = ExecutorAgent::new(); + let ctx = executor + .prepare("bwa", "align reads.fq to ref.fa with 8 threads") + .await + .unwrap(); + assert_eq!( + ctx.normalized_task.parameters.get("threads"), + Some(&"8".to_string()) + ); + } +} diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs new file mode 100644 index 00000000..38d0b894 --- /dev/null +++ b/src/orchestrator/mod.rs @@ -0,0 +1,14 @@ +//! AI Orchestration Layer (LangGraph-Inspired). +//! +//! Implements a multi-agent coordination system with four core agents: +//! - **Supervisor**: Routes tasks and decides orchestration strategy +//! - **Planner**: Decomposes complex tasks into steps +//! - **Executor**: Generates and runs commands +//! - **Validator**: Verifies results and provides feedback +//! +//! Supports adaptive single-call (Fast) and multi-agent (Quality) modes. + +pub mod executor; +pub mod planner; +pub mod supervisor; +pub mod validator; diff --git a/src/orchestrator/planner.rs b/src/orchestrator/planner.rs new file mode 100644 index 00000000..6b290f31 --- /dev/null +++ b/src/orchestrator/planner.rs @@ -0,0 +1,242 @@ +//! Planner Agent — decomposes complex tasks into executable steps. +//! +//! When the supervisor selects multi-stage mode, the planner analyzes the task +//! and produces a structured execution plan. For single-call mode, it +//! produces a trivial one-step plan. + +use serde::{Deserialize, Serialize}; + +/// A single step in an execution plan. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanStep { + /// Step number (1-based). + pub step: usize, + /// Tool to use for this step. + pub tool: String, + /// What this step should accomplish. + pub description: String, + /// Dependencies: step numbers that must complete first. + pub depends_on: Vec, + /// Whether this step requires validation. + pub needs_validation: bool, +} + +/// A complete task execution plan. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskPlan { + /// Original task. + pub original_task: String, + /// Ordered steps. + pub steps: Vec, + /// Overall strategy description. + pub strategy: String, + /// Estimated complexity (steps × validation needs). + pub estimated_llm_calls: usize, +} + +impl TaskPlan { + /// Create a trivial single-step plan (for SingleCall mode). + pub fn single_step(tool: &str, task: &str) -> Self { + Self { + original_task: task.to_string(), + steps: vec![PlanStep { + step: 1, + tool: tool.to_string(), + description: task.to_string(), + depends_on: vec![], + needs_validation: false, + }], + strategy: "Direct single-call execution".to_string(), + estimated_llm_calls: 1, + } + } + + /// Whether this is a multi-step plan. + pub fn is_multi_step(&self) -> bool { + self.steps.len() > 1 + } +} + +/// The Planner Agent — task decomposition. +pub struct PlannerAgent; + +impl Default for PlannerAgent { + fn default() -> Self { + Self::new() + } +} + +impl PlannerAgent { + pub fn new() -> Self { + Self + } + + /// Plan the execution of a task. + /// + /// For simple tasks, returns a single-step plan. + /// For complex tasks (multi-tool pipelines), decomposes into steps. + pub fn plan(&self, tool: &str, task: &str) -> TaskPlan { + let task_lower = task.to_lowercase(); + + // Detect multi-step patterns. + let is_pipeline = self.detect_pipeline(&task_lower); + + if is_pipeline { + self.plan_pipeline(tool, task) + } else { + TaskPlan::single_step(tool, task) + } + } + + /// Detect whether the task describes a multi-step pipeline. + fn detect_pipeline(&self, task: &str) -> bool { + let pipeline_indicators = [ + "then", + "after that", + "followed by", + "pipeline", + "workflow", + "step 1", + "step 2", + "first", + "second", + "finally", + // Chinese pipeline indicators + "然后", + "接着", + "之后", + "流程", + "管道", + ]; + pipeline_indicators.iter().any(|ind| task.contains(ind)) + || task.matches("&&").count() > 0 + || task.matches(';').count() > 1 + } + + /// Decompose a pipeline task into ordered steps. + fn plan_pipeline(&self, tool: &str, task: &str) -> TaskPlan { + // Split on natural-language step delimiters. + let delimiters = [ + " then ", + " after that ", + " followed by ", + ", then ", + " 然后 ", + " 接着 ", + " 之后 ", + ]; + + let mut parts: Vec = vec![task.to_string()]; + for delim in delimiters { + let mut new_parts = Vec::new(); + for part in &parts { + for sub in part.split(delim) { + let trimmed = sub.trim().to_string(); + if !trimmed.is_empty() { + new_parts.push(trimmed); + } + } + } + parts = new_parts; + } + + // Also split on "&&". + let mut final_parts = Vec::new(); + for part in &parts { + for sub in part.split("&&") { + let trimmed = sub.trim().to_string(); + if !trimmed.is_empty() { + final_parts.push(trimmed); + } + } + } + + if final_parts.len() <= 1 { + return TaskPlan::single_step(tool, task); + } + + let steps: Vec = final_parts + .iter() + .enumerate() + .map(|(i, desc)| { + let step_num = i + 1; + PlanStep { + step: step_num, + tool: tool.to_string(), + description: desc.clone(), + depends_on: if i > 0 { vec![step_num - 1] } else { vec![] }, + needs_validation: i == final_parts.len() - 1, // validate last step + } + }) + .collect(); + + let estimated_calls = steps.len() + steps.iter().filter(|s| s.needs_validation).count(); + + TaskPlan { + original_task: task.to_string(), + steps, + strategy: "Sequential pipeline execution".to_string(), + estimated_llm_calls: estimated_calls, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_task_single_step() { + let planner = PlannerAgent::new(); + let plan = planner.plan("samtools", "sort input.bam by coordinate"); + assert_eq!(plan.steps.len(), 1); + assert!(!plan.is_multi_step()); + assert_eq!(plan.estimated_llm_calls, 1); + } + + #[test] + fn test_pipeline_detection() { + let planner = PlannerAgent::new(); + + let plan = planner.plan( + "samtools", + "sort input.bam by coordinate then index the sorted file", + ); + assert!(plan.is_multi_step(), "Should detect 'then' as pipeline"); + assert_eq!(plan.steps.len(), 2); + } + + #[test] + fn test_pipeline_dependencies() { + let planner = PlannerAgent::new(); + let plan = planner.plan("samtools", "sort input.bam then index then flagstat"); + assert_eq!(plan.steps.len(), 3); + assert!(plan.steps[0].depends_on.is_empty()); + assert_eq!(plan.steps[1].depends_on, vec![1]); + assert_eq!(plan.steps[2].depends_on, vec![2]); + } + + #[test] + fn test_pipeline_with_ampersand() { + let planner = PlannerAgent::new(); + let plan = planner.plan( + "samtools", + "sort input.bam && index sorted.bam && flagstat sorted.bam", + ); + assert!(plan.is_multi_step()); + } + + #[test] + fn test_chinese_pipeline() { + let planner = PlannerAgent::new(); + let plan = planner.plan("samtools", "排序 input.bam 然后 建立索引"); + assert!(plan.is_multi_step(), "Should detect Chinese pipeline"); + } + + #[test] + fn test_single_step_plan_fields() { + let plan = TaskPlan::single_step("bwa", "align reads to reference"); + assert_eq!(plan.steps[0].tool, "bwa"); + assert_eq!(plan.strategy, "Direct single-call execution"); + } +} diff --git a/src/orchestrator/supervisor.rs b/src/orchestrator/supervisor.rs new file mode 100644 index 00000000..158c96e2 --- /dev/null +++ b/src/orchestrator/supervisor.rs @@ -0,0 +1,237 @@ +//! Supervisor Agent — routes tasks and selects orchestration strategy. +//! +//! The supervisor is the entry point for the orchestration layer. It +//! examines the user's task, the available context (skill, docs, history), +//! and decides whether to use a fast single-call or a multi-agent pipeline. + +use crate::knowledge::best_practices::BestPracticesDb; +use crate::knowledge::tool_knowledge::ToolKnowledgeBase; +use crate::task_complexity::{ComplexityResult, TaskComplexityEstimator}; +use serde::{Deserialize, Serialize}; + +// ─── Orchestration mode ────────────────────────────────────────────────────── + +/// How the system should orchestrate the task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OrchestrationMode { + /// Single LLM call — fastest, best for simple tasks with good skill/docs. + SingleCall, + /// Multi-stage pipeline — plan → execute → validate. + MultiStage, +} + +impl std::fmt::Display for OrchestrationMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SingleCall => write!(f, "single-call"), + Self::MultiStage => write!(f, "multi-stage"), + } + } +} + +impl OrchestrationMode { + /// Convert to the corresponding `WorkflowMode` used by the LLM pipeline. + pub fn to_workflow_mode(self) -> crate::llm_workflow::WorkflowMode { + match self { + Self::SingleCall => crate::llm_workflow::WorkflowMode::Fast, + Self::MultiStage => crate::llm_workflow::WorkflowMode::Quality, + } + } +} + +// ─── Supervisor Agent ──────────────────────────────────────────────────────── + +/// Decision output from the supervisor. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SupervisorDecision { + /// Selected orchestration mode. + pub mode: OrchestrationMode, + /// Task complexity analysis. + pub complexity: ComplexityResult, + /// Enrichment hints from knowledge layer (best practices, tool info). + pub enrichment_hints: Vec, + /// Domain category inferred from the tool/task. + pub domain: Option, + /// Reasons for the decision. + pub reasons: Vec, +} + +/// The Supervisor Agent — central decision maker. +pub struct SupervisorAgent { + complexity_estimator: TaskComplexityEstimator, + knowledge_base: ToolKnowledgeBase, + best_practices: BestPracticesDb, +} + +impl Default for SupervisorAgent { + fn default() -> Self { + Self::new() + } +} + +#[allow(dead_code)] +impl SupervisorAgent { + pub fn new() -> Self { + Self { + complexity_estimator: TaskComplexityEstimator::new(), + knowledge_base: ToolKnowledgeBase::new(), + best_practices: BestPracticesDb::new(), + } + } + + /// Analyze the task and decide on orchestration strategy. + pub fn decide( + &self, + tool: &str, + task: &str, + has_skill: bool, + doc_quality: f32, + force_mode: Option, + ) -> SupervisorDecision { + // Honour explicit override. + if let Some(forced) = force_mode { + return SupervisorDecision { + mode: forced, + complexity: ComplexityResult::default(), + enrichment_hints: self.gather_hints(tool), + domain: self.infer_domain(tool), + reasons: vec![format!("mode forced to {forced}")], + }; + } + + // Step 1: Estimate complexity. + let complexity = self + .complexity_estimator + .estimate(task, tool, has_skill, doc_quality); + + // Step 2: Decide mode based on complexity + available context. + let mut reasons = Vec::new(); + let mode = if complexity.score.is_complex() { + reasons.push("task complexity is high".to_string()); + if !has_skill { + reasons.push("no skill available — multi-stage helps".to_string()); + } + OrchestrationMode::MultiStage + } else { + reasons.push("task complexity is low".to_string()); + if has_skill { + reasons.push("skill available — single call sufficient".to_string()); + } + OrchestrationMode::SingleCall + }; + + // Step 3: Gather knowledge enrichment hints. + let enrichment_hints = self.gather_hints(tool); + let domain = self.infer_domain(tool); + + SupervisorDecision { + mode, + complexity, + enrichment_hints, + domain, + reasons, + } + } + + /// Gather enrichment hints from the knowledge layer. + fn gather_hints(&self, tool: &str) -> Vec { + let mut hints = Vec::new(); + + // Tool-specific best practices. + let practices = self.best_practices.for_tool(tool); + for p in practices.iter().take(3) { + hints.push(format!("{}: {}", p.title, p.recommendation)); + } + + // Related tools for context. + let related = self.knowledge_base.related_tools(tool, 3); + if !related.is_empty() { + let names: Vec<&str> = related.iter().map(|t| t.name.as_str()).collect(); + hints.push(format!("Related tools: {}", names.join(", "))); + } + + hints + } + + /// Infer the bioinformatics domain from the tool name. + fn infer_domain(&self, tool: &str) -> Option { + self.knowledge_base + .lookup(tool) + .map(|entry| entry.category.clone()) + } + + /// Access the knowledge base for external queries. + pub fn knowledge_base(&self) -> &ToolKnowledgeBase { + &self.knowledge_base + } + + /// Access the best practices DB. + pub fn best_practices(&self) -> &BestPracticesDb { + &self.best_practices + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_task_single_call() { + let supervisor = SupervisorAgent::new(); + let decision = supervisor.decide("samtools", "sort input.bam", true, 0.8, None); + assert_eq!(decision.mode, OrchestrationMode::SingleCall); + assert!(!decision.reasons.is_empty()); + } + + #[test] + fn test_complex_task_multi_stage() { + let supervisor = SupervisorAgent::new(); + let decision = supervisor.decide( + "unknown_tool", + "build a complex pipeline with multiple parallel steps for variant calling optimization", + false, + 0.2, + None, + ); + assert_eq!(decision.mode, OrchestrationMode::MultiStage); + } + + #[test] + fn test_forced_mode() { + let supervisor = SupervisorAgent::new(); + let decision = supervisor.decide( + "samtools", + "sort input.bam", + true, + 0.9, + Some(OrchestrationMode::MultiStage), + ); + assert_eq!(decision.mode, OrchestrationMode::MultiStage); + assert!(decision.reasons.iter().any(|r| r.contains("forced"))); + } + + #[test] + fn test_enrichment_hints_for_known_tool() { + let supervisor = SupervisorAgent::new(); + let decision = supervisor.decide("samtools", "sort input.bam", true, 0.8, None); + assert!( + !decision.enrichment_hints.is_empty(), + "Known tool should have enrichment hints" + ); + } + + #[test] + fn test_domain_inference() { + let supervisor = SupervisorAgent::new(); + let decision = supervisor.decide("gatk4", "call variants", true, 0.8, None); + assert_eq!(decision.domain, Some("variant-calling".to_string())); + } + + #[test] + fn test_unknown_tool_domain() { + let supervisor = SupervisorAgent::new(); + let decision = supervisor.decide("my_custom_tool", "do stuff", false, 0.5, None); + assert_eq!(decision.domain, None); + } +} diff --git a/src/orchestrator/validator.rs b/src/orchestrator/validator.rs new file mode 100644 index 00000000..5a9bdefe --- /dev/null +++ b/src/orchestrator/validator.rs @@ -0,0 +1,186 @@ +//! Validator Agent — verifies execution results and provides feedback. +//! +//! The validator inspects command output (exit code, stderr, output files) +//! and uses the error knowledge DB to provide recovery suggestions. + +use crate::knowledge::error_db::{ErrorCategory, ErrorKnowledgeDb}; +use serde::{Deserialize, Serialize}; + +/// Validation result for a command execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationResult { + /// Whether the command succeeded. + pub success: bool, + /// Validation summary. + pub summary: String, + /// Detected issues. + pub issues: Vec, + /// Recovery suggestions (from error DB + heuristics). + pub suggestions: Vec, + /// Error category (if failed). + pub error_category: Option, +} + +impl ValidationResult { + /// Create a success result. + pub fn success(summary: &str) -> Self { + Self { + success: true, + summary: summary.to_string(), + issues: vec![], + suggestions: vec![], + error_category: None, + } + } + + /// Create a failure result. + pub fn failure(summary: &str, issues: Vec, suggestions: Vec) -> Self { + Self { + success: false, + summary: summary.to_string(), + issues, + suggestions, + error_category: None, + } + } +} + +/// The Validator Agent. +pub struct ValidatorAgent; + +impl Default for ValidatorAgent { + fn default() -> Self { + Self::new() + } +} + +impl ValidatorAgent { + pub fn new() -> Self { + Self + } + + /// Validate a command execution result. + pub fn validate( + &self, + tool: &str, + _task: &str, + _command: &str, + exit_code: i32, + stderr: &str, + ) -> ValidationResult { + if exit_code == 0 && !self.has_warning_patterns(stderr) { + return ValidationResult::success("Command completed successfully"); + } + + let error_category = ErrorCategory::classify(stderr); + let mut issues = Vec::new(); + let mut suggestions = Vec::new(); + + // Collect issues. + if exit_code != 0 { + issues.push(format!("Command exited with code {exit_code}")); + } + + // Extract key error lines from stderr. + let error_lines: Vec<&str> = stderr + .lines() + .filter(|l| { + let lower = l.to_lowercase(); + lower.contains("error") + || lower.contains("fatal") + || lower.contains("fail") + || lower.contains("abort") + || lower.starts_with("[e::") + }) + .take(5) + .collect(); + + for line in &error_lines { + issues.push((*line).to_string()); + } + + // Get recovery suggestion from error DB. + let recovery = ErrorKnowledgeDb::suggest_recovery(tool, stderr); + suggestions.push(recovery); + + // Note: Error recording is done in the runner (runner/core.rs) to avoid + // duplication. The validator only reads from the error DB. + + let summary = + format!("Command failed (exit code {exit_code}, category: {error_category:?})"); + + let mut result = ValidationResult::failure(&summary, issues, suggestions); + result.error_category = Some(error_category); + result + } + + /// Check if stderr contains warning patterns even when exit code is 0. + fn has_warning_patterns(&self, stderr: &str) -> bool { + let lower = stderr.to_lowercase(); + lower.contains("[warning]") + || lower.contains("warn:") + || (lower.contains("error") && !lower.contains("error rate")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_success() { + let validator = ValidatorAgent::new(); + let result = validator.validate( + "samtools", + "sort input.bam", + "samtools sort input.bam", + 0, + "", + ); + assert!(result.success); + assert!(result.issues.is_empty()); + } + + #[test] + fn test_validate_failure_missing_file() { + let validator = ValidatorAgent::new(); + let result = validator.validate( + "samtools", + "sort input.bam", + "samtools sort input.bam", + 1, + "samtools sort: No such file or directory: 'input.bam'", + ); + assert!(!result.success); + assert_eq!(result.error_category, Some(ErrorCategory::MissingInput)); + assert!(!result.suggestions.is_empty()); + } + + #[test] + fn test_validate_failure_bad_flag() { + let validator = ValidatorAgent::new(); + let result = validator.validate( + "samtools", + "sort bam", + "samtools sort --invalid-flag", + 1, + "samtools sort: unrecognized option '--invalid-flag'", + ); + assert!(!result.success); + assert_eq!(result.error_category, Some(ErrorCategory::BadFlag)); + } + + #[test] + fn test_validate_success_with_benign_stderr() { + let validator = ValidatorAgent::new(); + let result = validator.validate( + "samtools", + "flagstat input.bam", + "samtools flagstat input.bam", + 0, + "0 + 0 mapped\n1000 + 0 total\nerror rate: 0.01", + ); + // "error rate" should not trigger a warning. + assert!(result.success); + } +} diff --git a/src/runner/batch.rs b/src/runner/batch.rs index c3a1c060..41c002e6 100644 --- a/src/runner/batch.rs +++ b/src/runner/batch.rs @@ -4,16 +4,11 @@ //! in parallel with configurable concurrency. use crate::error::{OxoError, Result}; -#[cfg(not(target_arch = "wasm32"))] use crate::history::{CommandProvenance, HistoryEntry, HistoryStore}; -#[cfg(not(target_arch = "wasm32"))] use crate::job; -#[cfg(not(target_arch = "wasm32"))] use chrono::Utc; use colored::Colorize; -#[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; -#[cfg(not(target_arch = "wasm32"))] use uuid::Uuid; use super::core::Runner; @@ -34,7 +29,6 @@ impl BatchRunner for Runner { /// /// When `self.stop_on_error` is true, remaining handles are aborted after /// the first failure, and the batch exits immediately with an error. - #[cfg(not(target_arch = "wasm32"))] async fn run_batch(&self, tool: &str, task: &str, json: bool) -> Result<()> { let result = self.prepare(tool, task).await?; let cmd_template = build_command_string(tool, &result.suggestion.args); @@ -241,7 +235,6 @@ impl BatchRunner for Runner { } /// Show the interpolated command for every input item without executing. - #[cfg(not(target_arch = "wasm32"))] async fn dry_run_batch(&self, tool: &str, task: &str, json: bool) -> Result<()> { let result = self.prepare(tool, task).await?; let cmd_template = build_command_string(tool, &result.suggestion.args); @@ -300,19 +293,4 @@ impl BatchRunner for Runner { Ok(()) } - - // WASM stubs - batch execution not supported - #[cfg(target_arch = "wasm32")] - async fn run_batch(&self, _tool: &str, _task: &str, _json: bool) -> Result<()> { - Err(OxoError::ExecutionError( - "Batch execution is not supported in WebAssembly".to_string(), - )) - } - - #[cfg(target_arch = "wasm32")] - async fn dry_run_batch(&self, _tool: &str, _task: &str, _json: bool) -> Result<()> { - Err(OxoError::ExecutionError( - "Batch execution is not supported in WebAssembly".to_string(), - )) - } } diff --git a/src/runner/core.rs b/src/runner/core.rs index 2392b115..3608045d 100644 --- a/src/runner/core.rs +++ b/src/runner/core.rs @@ -7,21 +7,22 @@ use crate::config::Config; use crate::doc_processor::DocProcessor; use crate::docs::DocsFetcher; use crate::error::{OxoError, Result}; -#[cfg(not(target_arch = "wasm32"))] +use crate::execution::feedback::{FeedbackCollector, FeedbackEntry}; +use crate::execution::result_analyzer::ResultAnalyzer; use crate::history::{CommandProvenance, HistoryEntry, HistoryStore}; -#[cfg(not(target_arch = "wasm32"))] use crate::job; +use crate::knowledge::error_db::ErrorKnowledgeDb; use crate::llm::{LlmClient, LlmCommandSuggestion}; -use crate::llm_workflow::{LlmWorkflowExecutor, WorkflowMode}; +use crate::llm_workflow::LlmWorkflowExecutor; +use crate::orchestrator::executor::ExecutorAgent; +use crate::orchestrator::planner::PlannerAgent; +use crate::orchestrator::supervisor::SupervisorAgent; +use crate::orchestrator::validator::ValidatorAgent; use crate::skill::SkillManager; -#[cfg(not(target_arch = "wasm32"))] use chrono::Utc; use colored::Colorize; -#[cfg(not(target_arch = "wasm32"))] use std::collections::HashMap; -#[cfg(not(target_arch = "wasm32"))] use std::process::Command; -#[cfg(not(target_arch = "wasm32"))] use uuid::Uuid; use super::batch::BatchRunner; @@ -71,21 +72,28 @@ pub struct Runner { /// [Ablation] When true, do not use the oxo-call system prompt. pub(crate) no_prompt: bool, /// Named variables substituted into the task description before the LLM call. - #[cfg(not(target_arch = "wasm32"))] pub(crate) vars: HashMap, /// Input items for batch/parallel execution (empty = single run). - #[cfg(not(target_arch = "wasm32"))] pub(crate) input_items: Vec, /// Maximum number of parallel jobs when `input_items` is non-empty. - #[cfg(not(target_arch = "wasm32"))] pub(crate) jobs: usize, /// When true, stop the batch after the first failed item. - #[cfg(not(target_arch = "wasm32"))] pub(crate) stop_on_error: bool, /// When true, automatically retry failed commands with LLM-corrected arguments. pub(crate) auto_retry: bool, /// Force a specific workflow scenario (auto-detected by default) pub(crate) force_scenario: Option, + // ── Orchestration layer ────────────────────────────────────────────────── + /// Supervisor agent for orchestration decisions. + supervisor: SupervisorAgent, + /// Planner agent for task decomposition. + planner: PlannerAgent, + /// Executor agent for task enrichment. + executor_agent: ExecutorAgent, + /// Validator agent for result verification. + validator_agent: ValidatorAgent, + /// Result analyzer for post-execution insights. + result_analyzer: ResultAnalyzer, } impl Runner { @@ -101,16 +109,17 @@ impl Runner { no_skill: false, no_doc: false, no_prompt: false, - #[cfg(not(target_arch = "wasm32"))] vars: HashMap::new(), - #[cfg(not(target_arch = "wasm32"))] input_items: Vec::new(), - #[cfg(not(target_arch = "wasm32"))] jobs: 1, - #[cfg(not(target_arch = "wasm32"))] stop_on_error: false, auto_retry: false, force_scenario: None, + supervisor: SupervisorAgent::new(), + planner: PlannerAgent::new(), + executor_agent: ExecutorAgent::new(), + validator_agent: ValidatorAgent::new(), + result_analyzer: ResultAnalyzer::new(), } } @@ -165,7 +174,6 @@ impl Runner { /// Set named variables that will be substituted into the task description /// (and, when an input list is present, into the generated command) before /// the LLM call. - #[cfg(not(target_arch = "wasm32"))] pub fn with_vars(mut self, vars: HashMap) -> Self { self.vars = vars; self @@ -175,21 +183,18 @@ impl Runner { /// /// When non-empty, the LLM is called once and the generated command /// template (which may contain `{item}`) is executed for every item. - #[cfg(not(target_arch = "wasm32"))] pub fn with_input_items(mut self, items: Vec) -> Self { self.input_items = items; self } /// Set the maximum number of parallel jobs (default: 1 = sequential). - #[cfg(not(target_arch = "wasm32"))] pub fn with_jobs(mut self, jobs: usize) -> Self { self.jobs = jobs.max(1); self } /// When enabled, abort the batch after the first failed item. - #[cfg(not(target_arch = "wasm32"))] pub fn with_stop_on_error(mut self, stop_on_error: bool) -> Self { self.stop_on_error = stop_on_error; self @@ -269,14 +274,7 @@ impl Runner { } None } else { - #[cfg(not(target_arch = "wasm32"))] - { - self.skill_manager.load_async(tool).await - } - #[cfg(target_arch = "wasm32")] - { - self.skill_manager.load(tool) - } + self.skill_manager.load_async(tool).await } }; @@ -451,8 +449,46 @@ impl Runner { prefs.to_prompt_hint() }; - // Build enriched task with context and preference hints - let enriched_task = if !context_hint.is_empty() || !preferences_hint.is_empty() { + // ── Orchestration: Supervisor decision ─────────────────────────────── + let doc_quality = structured_doc + .as_ref() + .map(|sd| sd.quality_score) + .unwrap_or(0.0); + let supervisor_decision = + self.supervisor + .decide(tool, task, skill.is_some(), doc_quality, None); + + if self.verbose { + eprintln!( + "{} Orchestrator: mode={}, domain={}, reasons=[{}]", + "[verbose]".dimmed(), + supervisor_decision.mode, + supervisor_decision.domain.as_deref().unwrap_or("unknown"), + supervisor_decision.reasons.join(", "), + ); + } + + // ── Orchestration: Planner → step decomposition ────────────────────── + let plan = self.planner.plan(tool, task); + if self.verbose && plan.is_multi_step() { + eprintln!( + "{} Planner: {} steps, strategy='{}'", + "[verbose]".dimmed(), + plan.steps.len(), + plan.strategy, + ); + } + + // ── Orchestration: Executor Agent → task enrichment ────────────────── + let executor_ctx = self.executor_agent.prepare(tool, task).await.ok(); + let enrichment_from_executor = executor_ctx + .as_ref() + .map(|ctx| self.executor_agent.enrich_task(ctx)) + .unwrap_or_default(); + + // Build enriched task with all sources: context, preferences, + // supervisor hints, and executor enrichment. + let enriched_task = { let mut parts = vec![effective_task.clone()]; if !context_hint.is_empty() { parts.push(context_hint); @@ -460,14 +496,24 @@ impl Runner { if !preferences_hint.is_empty() { parts.push(preferences_hint); } - parts.join("\n") - } else { - effective_task.clone() + // Add supervisor enrichment hints (best practices, related tools). + for hint in &supervisor_decision.enrichment_hints { + parts.push(hint.clone()); + } + // Add executor enrichment (normalized task, params, constraints). + if !enrichment_from_executor.is_empty() && enrichment_from_executor != effective_task { + parts.push(enrichment_from_executor); + } + if parts.len() == 1 { + effective_task.clone() + } else { + parts.join("\n") + } }; if self.verbose && enriched_task != effective_task { eprintln!( - "{} Enriched task with context/preferences", + "{} Enriched task with context/preferences/knowledge", "[verbose]".dimmed() ); } @@ -476,21 +522,13 @@ impl Runner { "Asking LLM to generate command arguments{skill_label}..." )); - // Select workflow mode: - // - If scenario is forced → use scenario's default mode - // - Default → Fast (single LLM call with doc-enriched prompt) - // - // The Fast mode is now the primary code path for all cases. - // Doc-extracted examples and flag catalog are injected into the - // prompt by the StructuredDoc, eliminating the need for the - // multi-call Quality pipeline in most scenarios. - // - // Quality mode (multi-call: normalize → mini-skill → generate) is - // activated only when explicitly requested via --scenario. + // Select workflow mode based on orchestrator decision: + // - Supervisor decision maps directly to workflow mode + // - --scenario override takes priority let effective_mode = if let Some(sc) = self.force_scenario { sc.default_mode() } else { - WorkflowMode::Fast + supervisor_decision.mode.to_workflow_mode() }; if self.verbose { @@ -578,7 +616,6 @@ impl Runner { /// dry-run: show the command that would be executed without running it. /// Records the generated command in history with `dry_run = true`. /// Pass `server` to tag the history entry with the remote server name. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn dry_run( &self, tool: &str, @@ -586,17 +623,14 @@ impl Runner { json: bool, server: Option<&str>, ) -> Result<()> { - // ── Native-only: apply vars + batch dispatch ────────────────────────── - #[cfg(not(target_arch = "wasm32"))] + // ── Apply vars + batch dispatch ────────────────────────── let _task_buf; - #[cfg(not(target_arch = "wasm32"))] let task: &str = if self.vars.is_empty() { task } else { _task_buf = job::interpolate_command(task, "", 0, &self.vars); &_task_buf }; - #[cfg(not(target_arch = "wasm32"))] if !self.input_items.is_empty() { return self.dry_run_batch(tool, task, json).await; } @@ -605,7 +639,6 @@ impl Runner { let full_cmd = build_command_string(tool, &result.suggestion.args); // Record in history before displaying, so the entry is always saved. - #[cfg(not(target_arch = "wasm32"))] { let tool_version = detect_tool_version(tool); let entry = HistoryEntry { @@ -677,17 +710,14 @@ impl Runner { /// run: execute the command for real pub async fn run(&self, tool: &str, task: &str, ask: bool, json: bool) -> Result<()> { - // ── Native-only: apply vars + batch dispatch ────────────────────────── - #[cfg(not(target_arch = "wasm32"))] + // ── Apply vars + batch dispatch ────────────────────────── let _task_buf; - #[cfg(not(target_arch = "wasm32"))] let task: &str = if self.vars.is_empty() { task } else { _task_buf = job::interpolate_command(task, "", 0, &self.vars); &_task_buf }; - #[cfg(not(target_arch = "wasm32"))] if !self.input_items.is_empty() { return self.run_batch(tool, task, json).await; } @@ -747,7 +777,6 @@ impl Runner { } // Validate input files exist before execution - #[cfg(not(target_arch = "wasm32"))] { let missing = validate_input_files(&result.suggestion.args); if !missing.is_empty() { @@ -785,170 +814,218 @@ impl Runner { println!(); } - // Process execution is not supported in WebAssembly - #[cfg(target_arch = "wasm32")] - return Err(OxoError::ExecutionError( - "Command execution is not supported in WebAssembly".to_string(), - )); + // Resolve companion binary (e.g., "bowtie2-build" when tool is "bowtie2") + let (eff_tool, eff_args) = effective_command(tool, &result.suggestion.args); + + // When the args contain shell operators (&&, ||, ;, |, >, …) the command + // must be dispatched through a shell so those operators are interpreted as + // shell syntax rather than being passed as literal strings to the tool. + let use_shell = super::utils::args_require_shell(&result.suggestion.args); + + // When verification is enabled, capture stderr for analysis. + let (exit_code, success, captured_stderr) = if self.verify { + let output = if use_shell { + Command::new("sh") + .args(["-c", &full_cmd]) + .output() + .map_err(|e| OxoError::ExecutionError(format!("sh: {e}")))? + } else { + Command::new(eff_tool) + .args(eff_args) + .output() + .map_err(|e| OxoError::ToolNotFound(format!("{eff_tool}: {e}")))? + }; - #[cfg(not(target_arch = "wasm32"))] - { - // Resolve companion binary (e.g., "bowtie2-build" when tool is "bowtie2") - let (eff_tool, eff_args) = effective_command(tool, &result.suggestion.args); - - // When the args contain shell operators (&&, ||, ;, |, >, …) the command - // must be dispatched through a shell so those operators are interpreted as - // shell syntax rather than being passed as literal strings to the tool. - let use_shell = super::utils::args_require_shell(&result.suggestion.args); - - // When verification is enabled, capture stderr for analysis. - let (exit_code, success, captured_stderr) = if self.verify { - let output = if use_shell { - Command::new("sh") - .args(["-c", &full_cmd]) - .output() - .map_err(|e| OxoError::ExecutionError(format!("sh: {e}")))? - } else { - Command::new(eff_tool) - .args(eff_args) - .output() - .map_err(|e| OxoError::ToolNotFound(format!("{eff_tool}: {e}")))? - }; - - // Stream captured output to terminal so the user still sees it. - use std::io::Write; - let _ = std::io::stdout().write_all(&output.stdout); - let _ = std::io::stderr().write_all(&output.stderr); - - let code = output.status.code().unwrap_or(-1); - let ok = output.status.success(); - let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned(); - (code, ok, stderr_text) + // Stream captured output to terminal so the user still sees it. + use std::io::Write; + let _ = std::io::stdout().write_all(&output.stdout); + let _ = std::io::stderr().write_all(&output.stderr); + + let code = output.status.code().unwrap_or(-1); + let ok = output.status.success(); + let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned(); + (code, ok, stderr_text) + } else { + let status = if use_shell { + Command::new("sh") + .args(["-c", &full_cmd]) + .status() + .map_err(|e| OxoError::ExecutionError(format!("sh: {e}")))? } else { - let status = if use_shell { - Command::new("sh") - .args(["-c", &full_cmd]) - .status() - .map_err(|e| OxoError::ExecutionError(format!("sh: {e}")))? - } else { - Command::new(eff_tool) - .args(eff_args) - .status() - .map_err(|e| OxoError::ToolNotFound(format!("{eff_tool}: {e}")))? - }; - let code = status.code().unwrap_or(-1); - let ok = status.success(); - (code, ok, String::new()) + Command::new(eff_tool) + .args(eff_args) + .status() + .map_err(|e| OxoError::ToolNotFound(format!("{eff_tool}: {e}")))? }; + let code = status.code().unwrap_or(-1); + let ok = status.success(); + (code, ok, String::new()) + }; - // Detect tool version for provenance (use effective tool binary) - let tool_version = detect_tool_version(eff_tool); + // Detect tool version for provenance (use effective tool binary) + let tool_version = detect_tool_version(eff_tool); + + // Record in history with provenance + let entry = HistoryEntry { + id: Uuid::new_v4().to_string(), + tool: tool.to_string(), + task: task.to_string(), + command: full_cmd.clone(), + exit_code, + executed_at: Utc::now(), + dry_run: false, + server: None, + provenance: Some(CommandProvenance { + tool_version, + docs_hash: Some(result.docs_hash), + skill_name: result.skill_name.clone(), + model: Some(self.config.effective_model()), + cache_hit: None, + }), + }; + let _ = HistoryStore::append(entry); - // Record in history with provenance - let entry = HistoryEntry { - id: Uuid::new_v4().to_string(), + if json { + let output = serde_json::json!({ + "tool": tool, + "task": task, + "effective_task": result.effective_task, + "command": full_cmd, + "args": result.suggestion.args, + "explanation": result.suggestion.explanation, + "dry_run": false, + "exit_code": exit_code, + "success": success, + "skill": result.skill_name, + "model": self.config.effective_model(), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!(); + println!("{}", "─".repeat(60).dimmed()); + if success { + println!( + " {} exit code {}", + "Completed successfully,".bold().green(), + exit_code.to_string().green() + ); + } else { + println!( + " {} exit code {}", + "Command failed,".bold().red(), + exit_code.to_string().red() + ); + } + println!("{}", "─".repeat(60).dimmed()); + } + + // LLM-based result verification (when --verify is enabled). + if self.verify { + self.run_verification(super::retry::VerifyParams { + tool, + task: &result.effective_task, + command: &full_cmd, + exit_code, + stderr: &captured_stderr, + args: &result.suggestion.args, + json, + }) + .await; + } + + // ── Orchestration: Validator Agent (always runs) ───────────────── + let validation = + self.validator_agent + .validate(tool, task, &full_cmd, exit_code, &captured_stderr); + + if self.verbose && !validation.success { + eprintln!( + "{} Validator: {} — {:?}", + "[verbose]".dimmed(), + validation.summary, + validation.error_category, + ); + for suggestion in &validation.suggestions { + eprintln!("{} → {}", "[verbose]".dimmed(), suggestion); + } + } + + // ── Execution: Result Analyzer ────────────────────────────────── + let analysis = self + .result_analyzer + .analyze(tool, exit_code, "", &captured_stderr); + + if self.verbose && !analysis.improvements.is_empty() { + for improvement in &analysis.improvements { + eprintln!("{} Improvement: {}", "[verbose]".dimmed(), improvement); + } + } + + // ── Execution: Feedback Collector ──────────────────────────────── + let _ = FeedbackCollector::record(FeedbackEntry { + tool: tool.to_string(), + task: task.to_string(), + generated_command: full_cmd.clone(), + was_modified: false, + modified_command: None, + exit_code, + user_approved: success, + model: self.config.effective_model(), + recorded_at: Utc::now().to_rfc3339(), + }); + + // ── Execution: Error Knowledge DB learning ────────────────────── + if !success { + let _ = ErrorKnowledgeDb::record(crate::knowledge::error_db::ErrorRecord { tool: tool.to_string(), task: task.to_string(), - command: full_cmd.clone(), + failed_command: full_cmd.clone(), exit_code, - executed_at: Utc::now(), - dry_run: false, - server: None, - provenance: Some(CommandProvenance { - tool_version, - docs_hash: Some(result.docs_hash), - skill_name: result.skill_name.clone(), - model: Some(self.config.effective_model()), - cache_hit: None, - }), - }; - let _ = HistoryStore::append(entry); + stderr_snippet: captured_stderr.chars().take(2000).collect(), + error_category: crate::knowledge::error_db::ErrorCategory::classify( + &captured_stderr, + ), + resolution: None, + recorded_at: Utc::now().to_rfc3339(), + }); + } - if json { - let output = serde_json::json!({ - "tool": tool, - "task": task, - "effective_task": result.effective_task, - "command": full_cmd, - "args": result.suggestion.args, - "explanation": result.suggestion.explanation, - "dry_run": false, - "exit_code": exit_code, - "success": success, - "skill": result.skill_name, - "model": self.config.effective_model(), - }); - println!("{}", serde_json::to_string_pretty(&output)?); - } else { + // ── Auto-retry on failure ───────────────────────────────────────── + if self.auto_retry && !success { + if !json { println!(); - println!("{}", "─".repeat(60).dimmed()); - if success { - println!( - " {} exit code {}", - "Completed successfully,".bold().green(), - exit_code.to_string().green() - ); - } else { - println!( - " {} exit code {}", - "Command failed,".bold().red(), - exit_code.to_string().red() - ); - } - println!("{}", "─".repeat(60).dimmed()); + println!( + " {} Analyzing failure and generating corrected command...", + "⟳".cyan().bold() + ); } - // LLM-based result verification (when --verify is enabled). - if self.verify { - self.run_verification(super::retry::VerifyParams { + let stderr_for_retry = if !captured_stderr.is_empty() { + captured_stderr.clone() + } else { + format!("Command failed with exit code {exit_code}") + }; + + match self + .auto_retry_on_failure( tool, - task: &result.effective_task, - command: &full_cmd, + &result.effective_task, + &full_cmd, exit_code, - stderr: &captured_stderr, - args: &result.suggestion.args, + &stderr_for_retry, json, - }) - .await; - } - - // ── Auto-retry on failure ───────────────────────────────────────── - if self.auto_retry && !success { - if !json { - println!(); - println!( - " {} Analyzing failure and generating corrected command...", - "⟳".cyan().bold() - ); - } - - let stderr_for_retry = if !captured_stderr.is_empty() { - captured_stderr.clone() - } else { - format!("Command failed with exit code {exit_code}") - }; - - match self - .auto_retry_on_failure( - tool, - &result.effective_task, - &full_cmd, - exit_code, - &stderr_for_retry, - json, - ) - .await - { - Ok(()) => {} - Err(e) => { - if !json { - eprintln!(" {} Auto-retry failed: {}", "✗".red().bold(), e); - } + ) + .await + { + Ok(()) => {} + Err(e) => { + if !json { + eprintln!(" {} Auto-retry failed: {}", "✗".red().bold(), e); } } } - - Ok(()) } + + Ok(()) } } diff --git a/src/runner/retry.rs b/src/runner/retry.rs index efceffcb..206b3bd9 100644 --- a/src/runner/retry.rs +++ b/src/runner/retry.rs @@ -4,14 +4,10 @@ //! LLM-corrected arguments and verifying execution results. use crate::error::{OxoError, Result}; -#[cfg(not(target_arch = "wasm32"))] use crate::history::{CommandProvenance, HistoryEntry, HistoryStore}; -#[cfg(not(target_arch = "wasm32"))] use chrono::Utc; use colored::Colorize; -#[cfg(not(target_arch = "wasm32"))] use std::process::Command; -#[cfg(not(target_arch = "wasm32"))] use uuid::Uuid; use super::core::Runner; @@ -20,7 +16,6 @@ use super::utils::{ }; /// Parameters for LLM-based run result verification. -#[cfg(not(target_arch = "wasm32"))] pub(crate) struct VerifyParams<'a> { pub(crate) tool: &'a str, pub(crate) task: &'a str, @@ -54,7 +49,6 @@ impl RetryRunner for Runner { /// The LLM receives the original command, exit code, and stderr, and /// generates a corrected command. The corrected command is shown to the /// user and executed. Up to `MAX_AUTO_RETRIES` attempts are made. - #[cfg(not(target_arch = "wasm32"))] async fn auto_retry_on_failure( &self, tool: &str, @@ -216,7 +210,6 @@ impl RetryRunner for Runner { } /// Perform LLM verification of a completed command run and print/return results. - #[cfg(not(target_arch = "wasm32"))] async fn run_verification(&self, params: VerifyParams<'_>) { let VerifyParams { tool, @@ -300,25 +293,4 @@ impl RetryRunner for Runner { } println!("{}", "─".repeat(60).dimmed()); } - - // WASM stubs - #[cfg(target_arch = "wasm32")] - async fn auto_retry_on_failure( - &self, - _tool: &str, - _task: &str, - _failed_cmd: &str, - _exit_code: i32, - _stderr: &str, - _json: bool, - ) -> Result<()> { - Err(OxoError::ExecutionError( - "Auto-retry is not supported in WebAssembly".to_string(), - )) - } - - #[cfg(target_arch = "wasm32")] - async fn run_verification(&self, _params: VerifyParams<'_>) { - // No-op in WASM - } } diff --git a/src/runner/tests.rs b/src/runner/tests.rs index c1a526b2..b300fea7 100644 --- a/src/runner/tests.rs +++ b/src/runner/tests.rs @@ -297,14 +297,12 @@ fn test_detect_tool_version_echo_command() { #[test] fn test_make_spinner_creates_without_panic() { let pb = make_spinner("Test message"); - #[cfg(not(target_arch = "wasm32"))] pb.finish_and_clear(); } #[test] fn test_make_spinner_with_empty_message() { let pb = make_spinner(""); - #[cfg(not(target_arch = "wasm32"))] pb.finish_and_clear(); } @@ -669,7 +667,6 @@ fn test_risk_warning_message() { // ─── Input file validation tests ────────────────────────────────────── -#[cfg(not(target_arch = "wasm32"))] #[test] fn test_validate_input_files_nonexistent() { let args: Vec = vec![ @@ -682,7 +679,6 @@ fn test_validate_input_files_nonexistent() { assert!(missing.contains(&"nonexistent_file.bam".to_string())); } -#[cfg(not(target_arch = "wasm32"))] #[test] fn test_validate_input_files_skips_output() { let args: Vec = vec!["sort".into(), "-o".into(), "nonexistent_output.bam".into()]; diff --git a/src/runner/utils.rs b/src/runner/utils.rs index 39b53cfa..bca603a1 100644 --- a/src/runner/utils.rs +++ b/src/runner/utils.rs @@ -7,7 +7,6 @@ //! - Validating input files //! - Creating progress spinners -#[cfg(not(target_arch = "wasm32"))] use indicatif::{ProgressBar, ProgressStyle}; use sha2::{Digest, Sha256}; @@ -175,24 +174,16 @@ pub(crate) fn sha256_hex(input: &str) -> String { /// Detect the version string of a tool by running `tool --version`. pub(crate) fn detect_tool_version(tool: &str) -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - use std::process::Command; - let output = Command::new(tool).arg("--version").output().ok()?; - if output.status.success() { - let version = String::from_utf8_lossy(&output.stdout); - let version = version.lines().next().unwrap_or("").trim(); - if !version.is_empty() { - return Some(version.to_string()); - } + use std::process::Command; + let output = Command::new(tool).arg("--version").output().ok()?; + if output.status.success() { + let version = String::from_utf8_lossy(&output.stdout); + let version = version.lines().next().unwrap_or("").trim(); + if !version.is_empty() { + return Some(version.to_string()); } - None - } - #[cfg(target_arch = "wasm32")] - { - let _ = tool; - None } + None } /// Extract a semantic version number from a version string. @@ -283,7 +274,6 @@ pub fn check_version_compatibility( // ─── Progress spinner ──────────────────────────────────────────────────────── /// Create a progress spinner with a message. -#[cfg(not(target_arch = "wasm32"))] pub fn make_spinner(msg: &str) -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.set_style( @@ -297,15 +287,6 @@ pub fn make_spinner(msg: &str) -> ProgressBar { pb } -/// Stub spinner for WASM. -#[cfg(target_arch = "wasm32")] -pub struct Spinner; - -#[cfg(target_arch = "wasm32")] -pub fn make_spinner(_msg: &str) -> Spinner { - Spinner -} - // ─── Output file detection ─────────────────────────────────────────────────── /// Detect output file paths from command arguments. @@ -483,7 +464,6 @@ pub fn risk_warning_message(risk: RiskLevel) -> Option<&'static str> { /// Scan command args for tokens that look like input file paths and check /// whether they exist on disk. Returns a list of file paths that were not found. -#[cfg(not(target_arch = "wasm32"))] pub fn validate_input_files(args: &[String]) -> Vec { const INPUT_FLAGS: &[&str] = &[ "-i", diff --git a/src/server.rs b/src/server.rs index e0770fcd..da2f26b8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -138,16 +138,9 @@ pub fn parse_ssh_config() -> Vec { } fn dirs_ssh_config() -> PathBuf { - #[cfg(not(target_arch = "wasm32"))] - { - directories::BaseDirs::new() - .map(|d| d.home_dir().join(".ssh").join("config")) - .unwrap_or_else(|| PathBuf::from("~/.ssh/config")) - } - #[cfg(target_arch = "wasm32")] - { - PathBuf::from("~/.ssh/config") - } + directories::BaseDirs::new() + .map(|d| d.home_dir().join(".ssh").join("config")) + .unwrap_or_else(|| PathBuf::from("~/.ssh/config")) } fn is_concrete_alias(alias: &str) -> bool { @@ -301,7 +294,6 @@ impl ServerManager { } /// Check SSH connectivity to a server. - #[cfg(not(target_arch = "wasm32"))] pub fn check_connection(&self, server: &ServerHost) -> Result { let mut cmd = std::process::Command::new("ssh"); for arg in &server.ssh_args() { @@ -327,7 +319,6 @@ impl ServerManager { } /// Detect the scheduler on an HPC server by checking for common commands. - #[cfg(not(target_arch = "wasm32"))] pub fn detect_scheduler(&self, server: &ServerHost) -> Option { let schedulers = [ ("slurm", "sinfo --version"), diff --git a/src/skill.rs b/src/skill.rs index 99ddcdd4..1c469ff6 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -806,7 +806,6 @@ impl SkillManager { /// MCP servers are queried in the order they appear in `config.toml`. The /// first server that returns a parseable skill wins. Network errors are /// silently ignored (a warning is printed with `--verbose`). - #[cfg(not(target_arch = "wasm32"))] pub async fn load_async(&self, tool: &str) -> Option { let tool_lc = tool.to_ascii_lowercase(); // 1. User-defined (highest priority) @@ -825,12 +824,6 @@ impl SkillManager { self.load_builtin(&tool_lc) } - /// Wasm32-compatible stub: falls back to synchronous load (MCP not available). - #[cfg(target_arch = "wasm32")] - pub async fn load_async(&self, tool: &str) -> Option { - self.load(tool) - } - /// Load a skill from the built-in registry (compiled into the binary). /// Matching is case-insensitive: "SAMTOOLS" and "SamTools" both load "samtools". pub fn load_builtin(&self, tool: &str) -> Option { @@ -893,7 +886,6 @@ impl SkillManager { } /// Try each configured MCP server in order; return the first parseable skill found. - #[cfg(not(target_arch = "wasm32"))] async fn load_mcp(&self, tool: &str) -> Option { use crate::mcp::McpClient; @@ -962,7 +954,6 @@ impl SkillManager { /// MCP servers are queried concurrently; errors are silently ignored. /// /// Returns `Vec<(tool_name, source_label)>` sorted alphabetically. - #[cfg(not(target_arch = "wasm32"))] pub async fn list_all_async(&self) -> Vec<(String, String)> { use crate::mcp::McpClient; @@ -992,61 +983,46 @@ impl SkillManager { result } - /// Wasm32-compatible stub: falls back to synchronous list (MCP not available). - #[cfg(target_arch = "wasm32")] - pub async fn list_all_async(&self) -> Vec<(String, String)> { - self.list_all() - } - // ── Install / remove ───────────────────────────────────────────────────── /// Install a skill from a URL into the community skills directory. /// /// Both `.md` (YAML front-matter + Markdown, preferred) and legacy `.toml` /// formats are accepted; the format is detected from the downloaded content. - #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] pub async fn install_from_url(&self, tool: &str, url: &str) -> Result { if !url.starts_with("https://") && !url.starts_with("http://") { return Err(OxoError::IndexError( "Only http:// and https:// URLs are accepted".to_string(), )); } - #[cfg(target_arch = "wasm32")] - return Err(OxoError::IndexError( - "Skill installation from URL is not supported in WebAssembly".to_string(), - )); - #[cfg(not(target_arch = "wasm32"))] - { - let client = reqwest::Client::new(); - let response = client.get(url).send().await?; - if !response.status().is_success() { - return Err(OxoError::IndexError(format!( - "HTTP {} fetching skill from {url}", - response.status() - ))); - } - let content = response.text().await?; - - // Detect format from content or URL extension - let is_md = url.ends_with(".md") || content.trim_start().starts_with("---"); - let skill = if is_md { - parse_skill_md(&content).ok_or_else(|| { - OxoError::IndexError( - "Invalid skill Markdown: could not parse front-matter and sections" - .to_string(), - ) - })? - } else { - toml::from_str(&content) - .map_err(|e| OxoError::IndexError(format!("Invalid skill TOML: {e}")))? - }; - - let dir = self.community_skill_dir()?; - std::fs::create_dir_all(&dir)?; - let ext = if is_md { "md" } else { "toml" }; - std::fs::write(dir.join(format!("{tool}.{ext}")), &content)?; - Ok(skill) + let client = reqwest::Client::new(); + let response = client.get(url).send().await?; + if !response.status().is_success() { + return Err(OxoError::IndexError(format!( + "HTTP {} fetching skill from {url}", + response.status() + ))); } + let content = response.text().await?; + + // Detect format from content or URL extension + let is_md = url.ends_with(".md") || content.trim_start().starts_with("---"); + let skill = if is_md { + parse_skill_md(&content).ok_or_else(|| { + OxoError::IndexError( + "Invalid skill Markdown: could not parse front-matter and sections".to_string(), + ) + })? + } else { + toml::from_str(&content) + .map_err(|e| OxoError::IndexError(format!("Invalid skill TOML: {e}")))? + }; + + let dir = self.community_skill_dir()?; + std::fs::create_dir_all(&dir)?; + let ext = if is_md { "md" } else { "toml" }; + std::fs::write(dir.join(format!("{tool}.{ext}")), &content)?; + Ok(skill) } /// Install a skill from the official oxo-call community registry on GitHub. diff --git a/src/workflow.rs b/src/workflow.rs index b09d7a0a..4db2e497 100644 --- a/src/workflow.rs +++ b/src/workflow.rs @@ -10,9 +10,7 @@ /// users can then export to Snakemake / Nextflow via `workflow export`. /// 4. **Compatibility export** — existing Snakemake / Nextflow templates are /// available for HPC environments that require those formats. -#[cfg(not(target_arch = "wasm32"))] use crate::config::Config; -#[cfg(not(target_arch = "wasm32"))] use crate::error::{OxoError, Result}; use colored::Colorize; use serde::{Deserialize, Serialize}; @@ -257,7 +255,6 @@ fn parse_workflow_response(raw: &str, engine: &str) -> Option /// - `"native"` (default) → `.oxo.toml` for the built-in oxo engine /// - `"snakemake"` → Snakefile /// - `"nextflow"` → Nextflow DSL2 `.nf` -#[cfg(not(target_arch = "wasm32"))] pub async fn generate_workflow( config: &Config, task: &str, @@ -647,7 +644,6 @@ pub fn build_infer_prompt(task: &str, ctx: &DataContext, data_dir: &str) -> Stri /// 1. Scans the data directory to discover sample names and file patterns. /// 2. Builds an enriched prompt that includes real paths and sample names. /// 3. Uses the native TOML system prompt so the output is immediately runnable. -#[cfg(not(target_arch = "wasm32"))] pub async fn infer_workflow( config: &Config, task: &str, @@ -1323,7 +1319,6 @@ mod tests { // ─── Mock HTTP tests for generate_workflow / infer_workflow ─────────────── - #[cfg(not(target_arch = "wasm32"))] mod mock_tests { use super::*; use crate::config::Config;