diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf1a3cc..6c2dd75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,24 @@ jobs: - "--no-default-features --features store" - "--no-default-features --features config" - "--no-default-features --features client-async" + - "--no-default-features --features cache" + - "--no-default-features --features discovery-async" - "--no-default-features --features graph" + - "--no-default-features --features graph-async" + - "--no-default-features --features graph-pool" + - "--no-default-features --features graph-cjk" - "--no-default-features --features graph-pg" + - "--no-default-features --features mcp" + - "--no-default-features --features mcp-http" + - "--no-default-features --features tokens" + - "--no-default-features --features safety" + - "--no-default-features --features telemetry" + - "--no-default-features --features search" + - "--no-default-features --features federation" + - "--no-default-features --features embedding" + - "--no-default-features --features embedding-openai" + - "--no-default-features --features vector-index" + - "--no-default-features --features install" - "--no-default-features --features qdrant" - "--no-default-features --features elastic" - "--no-default-features --features full" diff --git a/AGENTS.md b/AGENTS.md index 00751c3..eefee90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ | Command | Description | |---------|-------------| -| `cargo test --all-features` | Run all tests (483 passed, 12 ignored) | +| `cargo test --all-features` | Run all tests (599 passed, 13 ignored) | | `cargo clippy --all-features -- -D warnings` | Lint | | `cargo fmt --all -- --check` | Format check | | `cargo bench` | Run criterion benchmarks | diff --git a/CHANGELOG.md b/CHANGELOG.md index 572f84f..2a6f22b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.13.0] - 2026-07-03 + +### Added + +- **llm**: `LLMRequest::tools` and `LLMRequest::response_format` are now **forwarded to the provider APIs**. OpenAI receives `tools` (`type: "function"`) and `response_format` (`json_object` / `json_schema`); Anthropic receives `tools` (with `input_schema`) and, for `ResponseFormat::JsonSchema`, `output_config.format`. Previously both fields were accepted by the builder but silently dropped. +- **llm**: `LLMResponse::tool_calls: Vec` — tool calls the model requested are parsed back from OpenAI `tool_calls` and Anthropic `tool_use` content blocks. `LLMResponse` now also captures `finish_reason` (OpenAI `finish_reason` / Anthropic `stop_reason`), `id`, and `created` from the provider response. +- **mcp**: protocol-version negotiation — `initialize` echoes the client's requested `protocolVersion` when supported (`2025-06-18`, `2025-03-26`, `2024-11-05`), otherwise proposes the server's latest (`2025-06-18`). Exposed via `McpServer::negotiate_protocol_version` and the `SUPPORTED_PROTOCOL_VERSIONS` / `LATEST_PROTOCOL_VERSION` constants. +- **mcp**: `ping` method (returns `{}`), **prompts** support (`prompts/list`, `prompts/get`, `McpServer::register_prompt` / `set_prompt_handler`, `PromptDescription` / `PromptArgument`), and `resources/templates/list`. The `prompts` capability is advertised in `initialize` when prompts are registered. Both stdio and HTTP/SSE transports support all new methods. +- **error**: `KernelError::Embedding` and `KernelError::Discovery` variants (with `KernelError::embedding` / `KernelError::discovery` constructors). + +### Changed + +- **error** (**breaking**): the `embedding` and `discovery` subsystems now return `crate::error::Result` (`KernelError`) instead of `anyhow::Result` — the `EmbeddingProvider` / `VectorIndex` / `AsyncVectorIndex` traits, all provider and index constructors (`FastembedProvider`, `OpenAIEmbeddingClient`, `Qwen3Provider`, `NomicMoeProvider`, `LazyFastembedProvider`, `TurbovecIndex`, `QdrantVectorIndex`, `ElasticsearchVectorIndex`), `DiscoverySource`, `chunk_batch`, the `discovery::fetch*` functions, and `provider::sync::*`. `anyhow` no longer appears in the library's public surface. Downstream code that matched on `anyhow::Error` must switch to `KernelError`. +- **mcp** (**breaking**): `McpServer::initialize_response` now takes the client's requested protocol version (`initialize_response(Option<&str>)`). +- **mcp**: `ToolDescription` and `ResourceDescription` now serialize with the correct MCP wire-format field names — `inputSchema` (was `input_schema`) and `mimeType` (was `mime_type`). +- **mcp**: JSON-RPC request `id`s are preserved verbatim (string **or** number) in responses, per JSON-RPC 2.0 — previously only integer ids round-tripped. +- **mcp**: `tools/call` reports **tool-execution failures in-band** as a result with `isError: true` (so the model can react), and reserves the JSON-RPC error path (`-32602`) for an unknown tool — matching the MCP spec. + +### Fixed + +- **embedding**: `LazyFastembedProvider::embed_batch` no longer panics with an index-out-of-bounds when the inner provider returns fewer vectors than inputs (a truncated/malformed response); it now returns a `KernelError::Embedding`. +- **llm**: `CacheClient::complete` offloads the synchronous `KvStore` read/write to `tokio::task::spawn_blocking`, so a slow or remote store (or a single-threaded runtime) no longer blocks the async reactor on the completion hot path. + +### CI + +- Isolated per-feature build/test matrix entries added for `cache`, `discovery-async`, `graph-async`, `graph-pool`, `graph-cjk`, `mcp`, `mcp-http`, `tokens`, `safety`, `telemetry`, `search`, `federation`, `embedding`, `embedding-openai`, `vector-index`, and `install`, so a missing `#[cfg]` gate is caught even when a sibling feature isn't co-enabled. + ## [0.12.0] - 2026-07-02 ### Changed @@ -27,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **graph**: new optional `graph-pg-tls` feature adding TLS support to `PgGraph` connections, closing #48. `PgGraph::connect_native_tls(url)` is a one-call convenience constructor using `native-tls` with the system trust store (full certificate chain and hostname verification, not weakened) — covers the common case of a Postgres server requiring `sslmode=require`+ (e.g. RDS with `rds.force_ssl`). `PgGraph::connect_tls` / `connect_config_tls` are generic over any `postgres::tls::MakeTlsConnect` implementor for custom CAs, client certificates, or a caller-vendored connector. Existing `connect` / `connect_config` (`NoTls`) are unchanged — fully backward compatible, no new mandatory deps for `graph-pg` consumers. + ## [0.10.0] - 2026-06-29 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6bcb913..7769365 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -58,8 +58,10 @@ representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[INSERT CONTACT METHOD]. +reported to the project maintainers via GitHub — open a private report through +the repository's **Security** tab ("Report a vulnerability") or contact a +maintainer of the [`epicsagas/llm-kernel`](https://github.com/epicsagas/llm-kernel) +repository directly. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/Cargo.lock b/Cargo.lock index 40c6656..02f8a8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,9 +154,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-slice" @@ -2350,7 +2350,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llm-kernel" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1b3290d..b773d25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "llm-kernel" -version = "0.12.0" +version = "0.13.0" edition = "2024" description = "Foundation library for Rust AI-native apps — provider catalog, LLM client, MCP server, search, telemetry, and safety" license = "Apache-2.0" @@ -152,9 +152,12 @@ safety = ["dep:regex"] eval = ["dep:clap", "tokens", "safety", "embedding", "search"] eval-full = ["eval", "graph"] -# Everything except Windows-only execution backends. -# embedding-fastembed-directml is intentionally excluded: it pulls in the ort -# RC dependency which is not appropriate for cross-platform builds. +# Everything except Windows-only execution backends and dev-only CLI tooling. +# Intentionally excluded: +# - embedding-fastembed-directml: pulls in the ort RC dependency, Windows-only. +# - embedding-fastembed-dynamic-linking: opt-in dynamic ONNX linking (see #50). +# - eval / eval-full: developer CLI tooling (the `llm-kernel-eval` binary), +# not part of the library surface. Enable explicitly to run the eval suite. full = ["provider", "discovery", "discovery-async", "client-async", "cache", "secrets", "store", "config", "graph", "graph-async", "graph-pool", "graph-cjk", "graph-pg", "graph-pg-tls", "mcp", "mcp-http", "tokens", "install", "search", "embedding", "embedding-openai", "embedding-fastembed", "embedding-fastembed-qwen3", "embedding-fastembed-nomic-moe", "vector-index", "qdrant", "elastic", "federation", "telemetry", "safety", "catalog-sync"] [dependencies] diff --git a/PROGRESS.md b/PROGRESS.md index 13860f9..914557a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,24 +1,42 @@ # Progress -> Auto-generated status snapshot. Last updated: 2026-06-15 +> Auto-generated status snapshot. Last updated: 2026-07-03 -## Current Version: v0.9.0 +## Current Version: v0.13.0 | Metric | Value | |--------|-------| -| Version | `0.9.0` | +| Version | `0.13.0` | | Edition | Rust 2024, MSRV 1.92 | -| Lines of code | ~18,100 | -| Total tests | 511 (499 passed, 12 ignored, 0 failed) | +| Lines of code | ~23,000 | +| Total tests | `--all-features`: 599 passed, 13 ignored, 0 failed | | Backend features | `graph-pg` (PostgreSQL), `qdrant` (vector search), `elastic` (Elasticsearch vector search) | -| Open PRs | 1 | -| Open branches | 1 | -| Last commit | `feat: v0.9.0 search integrations` | +| Last commit | `chore: bump v0.13.0` | --- ## Recent Releases +### v0.13.0 (2026-07-03) + +- **error**: unified `embedding` + `discovery` public APIs onto `KernelError` (new `Embedding` / `Discovery` variants) — no more `anyhow::Result` in the library's public surface (**breaking**) +- **llm**: `LLMRequest::tools` and `response_format` are now forwarded to OpenAI and Anthropic; tool calls are parsed back into `LLMResponse::tool_calls` +- **mcp**: protocol version negotiation (2025-06-18), `ping`, prompts (`prompts/list` / `prompts/get`), string/number JSON-RPC ids, `tools/call` in-band `isError`, and camelCase wire format (`inputSchema` / `mimeType`) +- **embedding**: fixed a `LazyFastembedProvider::embed_batch` panic on a truncated provider response; `CacheClient` now offloads blocking store I/O via `spawn_blocking` +- **ci**: isolated per-feature build/test matrix entries (`mcp`, `tokens`, `safety`, `search`, `cache`, …) + +### v0.12.0 (2026-07-02) + +- **embedding** (breaking): `ModelState::Failed(String)` → `Failed { message, panicked }`; dropped default `ort-load-dynamic` so `embedding-fastembed` statically links ONNX Runtime, made the model-load path panic-safe via `catch_unwind`, and added `LazyFastembedProvider::reset()` + opt-in `embedding-fastembed-dynamic-linking` (#50) + +### v0.11.0 (2026-07-01) + +- **graph** (`graph-pg-tls`): TLS support for `PgGraph` connections — `connect_native_tls` / `connect_tls` / `connect_config_tls` (#48) + +### v0.10.0 (2026-06-29) + +- **graph**: pure-Rust CSR graph algorithms (`algo/`) — weighted PageRank, connected components, label propagation, Dijkstra, Jaccard/Adamic-Adar similarity; `smart_recall`'s graph boost now ranks by true PageRank centrality (SQLite + PostgreSQL share one impl) + ### v0.9.0 (2026-06-15) - **embedding** (`elastic`): `ElasticsearchVectorIndex` — `AsyncVectorIndex` over Elasticsearch 8.x (dense_vector cosine, bulk upsert/delete, knn `_search`, `_count`). 공식 `elasticsearch` 크레이트가 alpha-only라 **직접 구현한 reqwest 클라이언트** 사용 (v1.0.0 semver lock 안전) @@ -151,8 +169,8 @@ safety → secret masking, error classification, prompt-injection detectio | Check | Status | |-------|--------| -| All tests pass | ✅ 489/489 (477 passed, 12 ignored) | +| All tests pass | ✅ 593 passed, 13 ignored, 0 failed (`--all-features`) | | Clippy clean | ✅ (verified before each release) | | CI passing | ✅ Linux + macOS dual runner | | Crate structure | ✅ Monolithic (subcrate removed) | -| Roadmap on track | ✅ v0.7.0 complete, v0.8.0 ready to start | +| Roadmap on track | ✅ v0.13.0 complete, v1.0.0 next | diff --git a/QUICKSTART.md b/QUICKSTART.md index da1c107..6c40777 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -13,21 +13,21 @@ Add to your `Cargo.toml`. The `provider` feature is enabled by default. ```toml [dependencies] -llm-kernel = "0.6.0" +llm-kernel = "0.13.0" ``` For the async LLM client: ```toml [dependencies] -llm-kernel = { version = "0.6.0", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` To enable everything: ```toml [dependencies] -llm-kernel = { version = "0.6.0", features = ["full"] } +llm-kernel = { version = "0.13.0", features = ["full"] } ``` ## 2. Browse the provider catalog @@ -82,7 +82,7 @@ println!("{}", response.content); ```toml [dependencies] -llm-kernel = { version = "0.6.0", features = ["graph"] } +llm-kernel = { version = "0.13.0", features = ["graph"] } ``` ```rust @@ -117,7 +117,7 @@ for scored in &results { ```toml [dependencies] -llm-kernel = { version = "0.6.0", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ```rust diff --git a/README.md b/README.md index 7cd4fe3..cc3eb4b 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ llm-kernel provides the foundational layer for building LLM-powered tools, agent - **Model discovery** — dynamic model discovery from models.dev, Ollama, OpenAI-compatible endpoints - **Credential vault** — dotenv-style API key management with atomic writes - **Config loader** — TOML config with auto-create from template -- **Knowledge graph** — `GraphBackend` trait (SQLite impl), FTS5 search, smart recall, BFS traversal, CJK search, schema migrations, async wrappers -- **MCP server** — JSON-RPC 2.0 server framework with stdio and HTTP/SSE transports, async handlers, Bearer auth +- **Knowledge graph** — `GraphBackend` trait (SQLite impl), FTS5 search, smart recall, BFS traversal, CJK search, schema migrations, async wrappers, pure-Rust graph algorithms (PageRank, community detection, shortest path, similarity) +- **MCP server** — JSON-RPC 2.0 server framework (protocol 2025-06-18) with stdio and HTTP/SSE transports, tools, resources, prompts, `ping`, async handlers, Bearer auth - **Key-value store** — `KvStore` trait powering LLM response caching and other byte-oriented stores - **Embedding** — provider trait + cosine similarity, local ONNX (44 models), Qwen3 candle, Nomic V2 MoE candle, OpenAI remote, compressed vector indexing ([full model list →](EMBEDDING_MODELS.md)) - **Search** — Reciprocal Rank Fusion for hybrid search result merging @@ -46,12 +46,12 @@ Each module is gated behind a feature flag so you only pay for what you use. | `secrets` | SecretVault credential management | | | `store` | SQLite init helpers (WAL, FTS5, schema versioning) + `KvStore` | | | `config` | TOML config loader | | -| `graph` | Knowledge graph — `GraphBackend` trait, SQLite impl, FTS5, smart recall, BFS, migrations | | +| `graph` | Knowledge graph — `GraphBackend` trait, SQLite impl, FTS5, smart recall, BFS, migrations, graph algorithms (PageRank, community, shortest-path, similarity) | | | `graph-async` | Async graph wrappers (requires tokio) | | | `graph-pool` | Multi-connection async graph pool (`AsyncPoolGraph`, WAL concurrency) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | | `graph-pg` | PostgreSQL `GraphBackend` (`PgGraph`) + SQLite↔PostgreSQL migration CLI | | -| `mcp` | MCP server — JSON-RPC 2.0, stdio transport, async handlers, Bearer auth | | +| `mcp` | MCP server — JSON-RPC 2.0 (protocol 2025-06-18), stdio transport, tools/resources/prompts, `ping`, async handlers, Bearer auth | | | `mcp-http` | MCP remote transport — HTTP/SSE (axum + tokio) | | | `cache` | LLM response cache — `CacheClient` over `KvStore` | | | `tokens` | Token estimation, budgeting, and sentence-aware document chunking | | @@ -80,28 +80,28 @@ Add to your `Cargo.toml`: ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` The `provider` feature is enabled by default. For the async client: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` For the knowledge graph with async wrappers: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` For local embedding (ONNX, no API key): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## Usage @@ -337,11 +337,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### MCP server ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ @@ -571,9 +571,9 @@ llm-kernel is a **lightweight foundation layer** — compose it with rig or lang - **`EmbeddingProvider` trait** — unified interface for `FastembedProvider` (ONNX), `Qwen3Provider` (candle), `NomicMoeProvider` (candle), `OpenAIEmbeddingClient` (remote) - **`VectorIndex` trait** — unified interface for compressed vector indexes; `TurbovecIndex` (TurboQuant) implements 2-bit/4-bit quantized ANN search with SIMD kernels - **`ProviderIndex`** — zero-copy access to embedded catalog, queryable by provider or model -- **`McpServer`** — JSON-RPC 2.0 server with stdio transport, Bearer auth, tool registration +- **`McpServer`** — JSON-RPC 2.0 server (protocol 2025-06-18) with stdio transport, Bearer auth, tools/resources/prompts registration, `ping` - **`SecretVault`** — `HashMap` with dotenv load/save and symlink guards -- **`graph`** — SQLite knowledge graph with FTS5 search, composite scoring recall, BFS traversal, importance decay +- **`graph`** — SQLite knowledge graph with FTS5 search, composite scoring recall, BFS traversal, importance decay, and pure-Rust CSR graph algorithms (PageRank, connected components, label propagation, Dijkstra, Jaccard/Adamic-Adar similarity) - **`TelemetryEvent`** — enum-gated variants for structured observability (no PII) - **`safety`** — secret masking, error classification, bidi/ANSI/null sanitization, prompt-injection detection - **`SearchProvider`** — unified sync interface for ranking backends; `KeywordIndex` reference impl plus RRF / weighted-sum / CombMNZ fusion diff --git a/ROADMAP.md b/ROADMAP.md index 96939f3..2eadb0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ llm-kernel development roadmap from v0.3.2 to v1.0.0. * **[Future Milestones Feasibility Study](docs/research/future_roadmap_evaluation.md)** * **[Graph Performance Maximization Strategy](docs/research/graph_performance_strategy.md)** -> **Current phase: v0.9.0 complete ✅ — Next: v1.0.0 Production Readiness** +> **Current phase: v0.13.0 complete ✅ — Next: v1.0.0 Production Readiness** Each phase has a clear theme, concrete deliverables, and exit criteria. The library's core philosophy — zero-mandatory-dep composability with feature gates — is preserved throughout. @@ -179,6 +179,56 @@ Elasticsearch and cross-engine search federation. --- +### v0.10.0 — Graph Algorithms ✅ + +Pure-Rust, zero-dependency graph algorithms closing the Neo4j/GDS gap, compiled in behind the existing `graph` feature (no `Cargo.toml` change, no `petgraph`). + +| # | Deliverable | Scope | Key Files | +|---|-------------|-------|-----------| +| 1 | `CsrGraph` snapshot + weighted PageRank | M | `src/graph/algo/pagerank.rs` | +| 2 | Connected components + label propagation | M | `src/graph/algo/community.rs` | +| 3 | Dijkstra weighted shortest path | S | `src/graph/algo/path.rs` | +| 4 | Jaccard / common-neighbors / Adamic-Adar / link prediction | S | `src/graph/algo/similarity.rs` | +| 5 | `smart_recall` graph boost ranks by true PageRank centrality (SQLite + PostgreSQL share one impl) | M | `src/graph/recall.rs`, `src/graph/pg.rs` | + +**Exit criteria:** algorithms re-exported from `graph` as backend-agnostic free functions, PageRank eval scenario + criterion benchmarks in place, zero backend drift. + +--- + +### v0.11.0 — PostgreSQL TLS ✅ + +Optional `graph-pg-tls` feature adding TLS to `PgGraph` connections +(`connect_native_tls` / `connect_tls` / `connect_config_tls`), closing #48. +Existing `NoTls` constructors are unchanged. + +--- + +### v0.12.0 — Embedding Robustness ✅ + +`ModelState::Failed(String)` → `Failed { message, panicked }`; dropped the +default `ort-load-dynamic` so `embedding-fastembed` statically links ONNX +Runtime, made the model-load path panic-safe via `catch_unwind` + +`LazyFastembedProvider::reset()`, and added the opt-in +`embedding-fastembed-dynamic-linking` feature (#50). + +--- + +### v0.13.0 — Consistency & Protocol Compliance ✅ + +Unify the public error surface and bring the LLM client and MCP server up to spec. + +| # | Deliverable | Scope | Key Files | +|---|-------------|-------|-----------| +| 1 | Unify `embedding` + `discovery` public APIs from `anyhow::Result` to `KernelError` (`Embedding` / `Discovery` variants) | L | `src/error.rs`, `src/embedding/*`, `src/discovery/*` | +| 2 | Forward `LLMRequest::tools` + `response_format` to OpenAI/Anthropic; parse tool calls into `LLMResponse::tool_calls` | M | `src/llm/client.rs`, `src/llm/types.rs` | +| 3 | MCP server: protocol 2025-06-18 negotiation, `ping`, prompts, string/number ids, `tools/call` `isError`, camelCase wire format | M | `src/mcp/*` | +| 4 | Fix `LazyFastembedProvider::embed_batch` panic on truncated provider response; offload blocking cache I/O via `spawn_blocking` | S | `src/embedding/lazy.rs`, `src/llm/cache.rs` | +| 5 | Isolated per-feature CI checks | S | `.github/workflows/ci.yml` | + +**Exit criteria:** no `anyhow` in the public library surface, MCP dispatch conforms to the spec, all features build in isolation. + +--- + ### v1.0.0 — Production Readiness API stability guarantee. Once shipped, all public types and signatures are locked under semver. @@ -186,10 +236,10 @@ API stability guarantee. Once shipped, all public types and signatures are locke | # | Deliverable | Scope | Key Files | |---|-------------|-------|-----------| | 1 | Audit public API surface; reduce `pub` → `pub(crate)` where appropriate | L | All modules | -| 2 | Comprehensive doc comments with `# Example` sections on every public item | L | All modules | +| 2 | `# Example` sections on every public item (`#![deny(missing_docs)]` already enforced since v0.3.4) | L | All modules | | 3 | Performance baseline + CI regression detection (`--perf-baseline`) | M | `src/bin/eval.rs`, `benches/` | | 4 | `cargo-semver-checks` in CI as blocking job | M | `.github/workflows/ci.yml` | -| 5 | Security audit + `SECURITY.md` | M | New `SECURITY.md`, `src/safety/`, `src/secrets/` | +| 5 | Security audit (`SECURITY.md` already published; `cargo audit` + gitleaks already in CI) | M | `src/safety/`, `src/secrets/` | | 6 | Document `full` feature set and platform compatibility matrix | S | `README.md` | **Exit criteria:** `cargo-semver-checks` passes, every public item documented with examples, perf baselines in CI, security review complete, at least one external project integrated successfully. @@ -223,6 +273,18 @@ v0.3.2 ├── v0.9.0 Search Integrations ✅ │ Elasticsearch, federation │ + ├── v0.10.0 Graph Algorithms ✅ + │ CSR PageRank, community, Dijkstra, similarity + │ + ├── v0.11.0 PostgreSQL TLS ✅ + │ graph-pg-tls + │ + ├── v0.12.0 Embedding Robustness ✅ + │ static ONNX linking, panic-safe load + │ + ├── v0.13.0 Consistency & Protocol Compliance ✅ + │ KernelError unification, tool forwarding, MCP 2025-06-18 + │ └── v1.0.0 Production Readiness API audit, semver lock, perf baselines, security audit ``` diff --git a/SECURITY.md b/SECURITY.md index 71ff61c..c291049 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,13 @@ ## Supported Versions -| Version | Supported | -| ------- | ---------- | -| 0.1.x | ✅ | +Until a 1.0 release, security fixes land on the latest published minor and are +not backported to earlier `0.x` versions. + +| Version | Supported | +| -------- | --------- | +| 0.13.x | ✅ | +| < 0.13 | ❌ | ## Reporting a Vulnerability diff --git a/docs/i18n/de/README.md b/docs/i18n/de/README.md index ce8d7ca..14dccde 100644 --- a/docs/i18n/de/README.md +++ b/docs/i18n/de/README.md @@ -51,7 +51,7 @@ Jedes Modul wird durch ein Feature-Flag gesteuert, sodass Sie nur bezahlen, was | `secrets` | SecretVault-Anmeldeinformationsverwaltung | | | `store` | SQLite-Initialisierungshilfen (WAL, FTS5, Schema-Versionierung) | | | `config` | TOML-Konfigurationslader | | -| `graph` | Wissensgraph — SQLite, FTS5, intelligenter Recall, BFS-Traversierung | | +| `graph` | Wissensgraph — SQLite, FTS5, intelligenter Recall, BFS-Traversierung |, Graph-Algorithmen(PageRank/community/shortest-path/similarity) | | `graph-async` | Async-Graph-Wrapper (erfordert tokio) | | | `graph-pool` | Multi-Verbindungs-Async-Graph-Pool (`AsyncPoolGraph`, WAL-Konkurrenz) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ Zu Ihrer `Cargo.toml` hinzufügen: ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` Das `provider`-Feature ist standardmäßig aktiviert. Für den Async-Client: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` Für den Wissensgraphen mit Async-Wrappern: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` Für lokales Embedding (ONNX, kein API-Schlüssel): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## Verwendung @@ -332,11 +332,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### MCP-Server ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index b89263e..3332b65 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -51,7 +51,7 @@ Cada módulo está detrás de un flag de característica para que solo pagues po | `secrets` | Gestión de credenciales SecretVault | | | `store` | Helpers de inicialización SQLite (WAL, FTS5, versionado de esquema) | | | `config` | Cargador de configuración TOML | | -| `graph` | Grafo de conocimiento — SQLite, FTS5, recuperación inteligente, recorrido BFS | | +| `graph` | Grafo de conocimiento — SQLite, FTS5, recuperación inteligente, recorrido BFS |, algoritmos de grafos(PageRank/community/shortest-path/similarity) | | `graph-async` | Wrappers de grafo asíncronos (requiere tokio) | | | `graph-pool` | Pool de grafo asíncrono multi-conexión (`AsyncPoolGraph`, concurrencia WAL) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ Añade a tu `Cargo.toml`: ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` La característica `provider` está habilitada por defecto. Para el cliente asíncrono: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` Para el grafo de conocimiento con wrappers asíncronos: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` Para embedding local (ONNX, sin clave API): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## Uso @@ -331,11 +331,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### Servidor MCP ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/fr/README.md b/docs/i18n/fr/README.md index f9026b5..afc4216 100644 --- a/docs/i18n/fr/README.md +++ b/docs/i18n/fr/README.md @@ -51,7 +51,7 @@ Chaque module est derriere un indicateur de fonctionnalite afin que vous ne payi | `secrets` | Gestion de credentials SecretVault | | | `store` | Aides d'initialisation SQLite (WAL, FTS5, versionnage de schema) | | | `config` | Chargeur de configuration TOML | | -| `graph` | Graphe de connaissances -- SQLite, FTS5, rappel intelligent, parcours BFS | | +| `graph` | Graphe de connaissances -- SQLite, FTS5, rappel intelligent, parcours BFS |, algorithmes de graphe(PageRank/community/shortest-path/similarity) | | `graph-async` | Enveloppes de graphe asynchrones (necessite tokio) | | | `graph-pool` | Pool de graphe asynchrone multi-connexion (`AsyncPoolGraph`, concurrence WAL) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ Ajoutez a votre `Cargo.toml` : ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` La fonctionnalite `provider` est activee par defaut. Pour le client asynchrone : ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` Pour le graphe de connaissances avec enveloppes asynchrones : ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` Pour l'embedding local (ONNX, sans cle API) : ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## Utilisation @@ -333,11 +333,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### Serveur MCP ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md index 3ed74df..4f76755 100644 --- a/docs/i18n/it/README.md +++ b/docs/i18n/it/README.md @@ -51,7 +51,7 @@ Ogni modulo è protetto da una flag di feature, così paghi solo per ciò che ut | `secrets` | Gestione credenziali SecretVault | | | `store` | Helper init SQLite (WAL, FTS5, versionamento schema) | | | `config` | Caricatore configurazione TOML | | -| `graph` | Grafo di conoscenza — SQLite, FTS5, richiamo intelligente, attraversamento BFS | | +| `graph` | Grafo di conoscenza — SQLite, FTS5, richiamo intelligente, attraversamento BFS |, algoritmi su grafo(PageRank/community/shortest-path/similarity) | | `graph-async` | Wrapper grafo asincroni (richiede tokio) | | | `graph-pool` | Pool grafo asincrono multi-connessione (`AsyncPoolGraph`, concorrenza WAL) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ Aggiungi al tuo `Cargo.toml`: ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` La feature `provider` è abilitata per impostazione predefinita. Per il client asincrono: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` Per il grafo di conoscenza con wrapper asincroni: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` Per l'embedding locale (ONNX, nessuna chiave API): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## Utilizzo @@ -330,11 +330,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### Server MCP ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/ja/README.md b/docs/i18n/ja/README.md index d703083..79e3f33 100644 --- a/docs/i18n/ja/README.md +++ b/docs/i18n/ja/README.md @@ -51,7 +51,7 @@ llm-kernelは、RustでLLM搭載ツール、エージェント、サーバーを | `secrets` | SecretVaultクレデンシャル管理 | | | `store` | SQLite初期化ヘルパー(WAL、FTS5、スキーマバージョニング) | | | `config` | TOML設定ローダー | | -| `graph` | ナレッジグラフ — SQLite、FTS5、スマートリコール、BFSトラバーサル | | +| `graph` | ナレッジグラフ — SQLite、FTS5、スマートリコール、BFSトラバーサル、グラフアルゴリズム(PageRank/community/shortest-path/similarity) | | | `graph-async` | 非同期グラフラッパー(tokioが必要) | | | `graph-pool` | マルチ接続非同期グラフプール(`AsyncPoolGraph`、WAL同時実行) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ llm-kernelは、RustでLLM搭載ツール、エージェント、サーバーを ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` `provider`フィーチャーはデフォルトで有効です。非同期クライアントを使用する場合: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` 非同期ラッパー付きナレッジグラフを使用する場合: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` ローカルエンベディング(ONNX、APIキー不要)を使用する場合: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## 使用方法 @@ -327,11 +327,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### MCPサーバー ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/ko/README.md b/docs/i18n/ko/README.md index 0c95e04..d70001f 100644 --- a/docs/i18n/ko/README.md +++ b/docs/i18n/ko/README.md @@ -51,7 +51,7 @@ llm-kernel은 Rust로 LLM 기반 도구, 에이전트, 서버를 구축하기 | `secrets` | SecretVault 자격 증명 관리 | | | `store` | SQLite 초기화 헬퍼 (WAL, FTS5, 스키마 버전 관리) | | | `config` | TOML 설정 로더 | | -| `graph` | 지식 그래프 — SQLite, FTS5, 스마트 리콜, BFS 순회 | | +| `graph` | 지식 그래프 — SQLite, FTS5, 스마트 리콜, BFS 순회, 그래프 알고리즘(PageRank/community/shortest-path/similarity) | | | `graph-async` | 비동기 그래프 래퍼 (tokio 필요) | | | `graph-pool` | 다중 연결 비동기 그래프 풀 (`AsyncPoolGraph`, WAL 동시성) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ llm-kernel은 Rust로 LLM 기반 도구, 에이전트, 서버를 구축하기 ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` `provider` 기능이 기본으로 활성화됩니다. 비동기 클라이언트를 사용하려면: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` 비동기 래퍼가 포함된 지식 그래프를 사용하려면: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` 로컬 임베딩을 사용하려면 (ONNX, API 키 불필요): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## 사용법 @@ -329,11 +329,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### MCP 서버 ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/pt/README.md b/docs/i18n/pt/README.md index 931f8e4..f3439da 100644 --- a/docs/i18n/pt/README.md +++ b/docs/i18n/pt/README.md @@ -51,7 +51,7 @@ Cada módulo é controlado por uma feature flag para que você só pague pelo qu | `secrets` | Gerenciamento de credenciais SecretVault | | | `store` | Helpers de inicialização SQLite (WAL, FTS5, versionamento de schema) | | | `config` | Carregador de configuração TOML | | -| `graph` | Grafo de conhecimento — SQLite, FTS5, recall inteligente, travessia BFS | | +| `graph` | Grafo de conhecimento — SQLite, FTS5, recall inteligente, travessia BFS |, algoritmos de grafo(PageRank/community/shortest-path/similarity) | | `graph-async` | Wrappers assíncronos do grafo (requer tokio) | | | `graph-pool` | Pool assíncrono multi-conexão do grafo (`AsyncPoolGraph`, concorrência WAL) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ Adicione ao seu `Cargo.toml`: ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` A feature `provider` é habilitada por padrão. Para o cliente assíncrono: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` Para o grafo de conhecimento com wrappers assíncronos: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` Para embedding local (ONNX, sem chave de API): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## Uso @@ -332,11 +332,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### Servidor MCP ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index 0468fad..15674a1 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -51,7 +51,7 @@ llm-kernel предоставляет базовый слой для созда | `secrets` | Управление учётными данными SecretVault | | | `store` | Вспомогательные функции инициализации SQLite (WAL, FTS5, версионирование схемы) | | | `config` | Загрузчик конфигурации TOML | | -| `graph` | Граф знаний — SQLite, FTS5, умное извлечение, обход BFS | | +| `graph` | Граф знаний — SQLite, FTS5, умное извлечение, обход BFS |, графовые алгоритмы(PageRank/community/shortest-path/similarity) | | `graph-async` | Асинхронные обёртки графа (требует tokio) | | | `graph-pool` | Пул асинхронных подключений графа (`AsyncPoolGraph`, конкурентность WAL) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ llm-kernel предоставляет базовый слой для созда ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` Функция `provider` включена по умолчанию. Для асинхронного клиента: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` Для графа знаний с асинхронными обёртками: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` Для локальных эмбеддингов (ONNX, без API-ключа): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## Использование @@ -328,11 +328,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### MCP-сервер ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/zh-Hans/README.md b/docs/i18n/zh-Hans/README.md index 6294ab8..d0639c8 100644 --- a/docs/i18n/zh-Hans/README.md +++ b/docs/i18n/zh-Hans/README.md @@ -51,7 +51,7 @@ llm-kernel 为 Rust 中构建 LLM 驱动的工具、代理和服务器提供基 | `secrets` | SecretVault 凭证管理 | | | `store` | SQLite 初始化辅助(WAL、FTS5、模式版本控制) | | | `config` | TOML 配置加载器 | | -| `graph` | 知识图谱 — SQLite、FTS5、智能召回、BFS 遍历 | | +| `graph` | 知识图谱 — SQLite、FTS5、智能召回、BFS 遍历 |, 图算法(PageRank/community/shortest-path/similarity) | | `graph-async` | 异步图谱封装(依赖 tokio) | | | `graph-pool` | 多连接异步图谱连接池(`AsyncPoolGraph`,WAL 并发) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ llm-kernel 为 Rust 中构建 LLM 驱动的工具、代理和服务器提供基 ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` `provider` 特性默认启用。如需异步客户端: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` 如需带异步封装的知识图谱: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` 如需本地嵌入(ONNX,无需 API 密钥): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## 使用方法 @@ -327,11 +327,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### MCP 服务器 ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/docs/i18n/zh-Hant/README.md b/docs/i18n/zh-Hant/README.md index 3ebdf38..c46f789 100644 --- a/docs/i18n/zh-Hant/README.md +++ b/docs/i18n/zh-Hant/README.md @@ -51,7 +51,7 @@ llm-kernel 提供了使用 Rust 建構 LLM 驅動工具、代理與伺服器的 | `secrets` | SecretVault 憑證管理 | | | `store` | SQLite 初始化輔助(WAL、FTS5、Schema 版本控制) | | | `config` | TOML 設定載入器 | | -| `graph` | 知識圖譜 — SQLite、FTS5、智慧回憶、BFS 遍歷 | | +| `graph` | 知識圖譜 — SQLite、FTS5、智慧回憶、BFS 遍歷 |, 圖演算法(PageRank/community/shortest-path/similarity) | | `graph-async` | 非同步圖譜封裝(需要 tokio) | | | `graph-pool` | 多連線非同步圖譜連線池(`AsyncPoolGraph`、WAL 並行) | | | `graph-cjk` | CJK-aware graph search via Rust-side segmentation (no schema change) | | @@ -84,28 +84,28 @@ llm-kernel 提供了使用 Rust 建構 LLM 驅動工具、代理與伺服器的 ```toml [dependencies] -llm-kernel = "0.9.1" +llm-kernel = "0.13.0" ``` `provider` 功能預設啟用。如需非同步客戶端: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["client-async"] } +llm-kernel = { version = "0.13.0", features = ["client-async"] } ``` 如需附帶非同步封裝的知識圖譜: ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["graph", "graph-async"] } +llm-kernel = { version = "0.13.0", features = ["graph", "graph-async"] } ``` 如需本地嵌入(ONNX,無需 API 金鑰): ```toml [dependencies] -llm-kernel = { version = "0.9.1", features = ["embedding-fastembed"] } +llm-kernel = { version = "0.13.0", features = ["embedding-fastembed"] } ``` ## 使用方式 @@ -327,11 +327,11 @@ println!("{} nodes, {} edges", stats.total_nodes, stats.total_edges); ### MCP 伺服器 ```rust -use llm_kernel::mcp::{McpServer, Tool, JsonRpcRequest}; +use llm_kernel::mcp::{McpServer, ToolDescription}; use serde_json::json; let mut server = McpServer::new("my-server", "1.0.0"); -server.register_tool(Tool { +server.register_tool(ToolDescription { name: "greet".into(), description: "Say hello".into(), input_schema: json!({ diff --git a/src/discovery/models_dev.rs b/src/discovery/models_dev.rs index 30f0dc3..d2784d8 100644 --- a/src/discovery/models_dev.rs +++ b/src/discovery/models_dev.rs @@ -12,6 +12,7 @@ //! models.dev metadata (pricing, limits, modalities, capabilities) so they can //! feed [`crate::provider::ProviderIndex::with_discovered`]. +use crate::error::{KernelError, Result}; use crate::provider::{ModelCapabilities, ModelCost, ModelDescriptor, ModelLimit, ModelModalities}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -268,26 +269,28 @@ fn raw_to_entry(provider_id: &str, m: &RawModel) -> ModelEntry { // --------------------------------------------------------------------------- /// Fetch the raw response body from `url` with a bounded timeout. -fn http_get(url: &str) -> anyhow::Result { +fn http_get(url: &str) -> Result { let config = ureq::config::Config::builder() .timeout_global(Some(std::time::Duration::from_secs(10))) .build(); let agent = ureq::Agent::new_with_config(config); - let mut resp = agent.get(url).call()?; - Ok(resp.body_mut().read_to_string()?) + let mut resp = agent.get(url).call().map_err(KernelError::discovery)?; + resp.body_mut() + .read_to_string() + .map_err(KernelError::discovery) } /// Fetch and parse the models.dev catalog from `url` (no disk cache). /// /// **Trust boundary:** the URL is used verbatim with no host allowlist; pass /// only admin-configured values. -pub fn fetch_from(url: &str) -> anyhow::Result { +pub fn fetch_from(url: &str) -> Result { let body = http_get(url)?; serde_json::from_str(&body).map_err(Into::into) } /// Fetch and parse the catalog from the default models.dev endpoint (no cache). -pub fn fetch() -> anyhow::Result { +pub fn fetch() -> Result { fetch_from(MODELS_DEV_URL) } @@ -296,7 +299,7 @@ pub fn fetch() -> anyhow::Result { /// The cache file is written byte-identical to the upstream response, so it /// diffs cleanly against `https://models.dev/api.json` and round-trips through /// [`load_cache`]. -pub fn fetch_and_cache(cache_path: &str) -> anyhow::Result { +pub fn fetch_and_cache(cache_path: &str) -> Result { let body = http_get(MODELS_DEV_URL)?; if let Some(parent) = Path::new(cache_path).parent() { @@ -308,7 +311,7 @@ pub fn fetch_and_cache(cache_path: &str) -> anyhow::Result { } /// Load previously cached models.dev data. -pub fn load_cache(cache_path: &str) -> anyhow::Result> { +pub fn load_cache(cache_path: &str) -> Result> { if !Path::new(cache_path).exists() { return Ok(None); } diff --git a/src/discovery/ollama.rs b/src/discovery/ollama.rs index 774a005..82723ed 100644 --- a/src/discovery/ollama.rs +++ b/src/discovery/ollama.rs @@ -1,5 +1,7 @@ use serde::Deserialize; +use crate::error::{KernelError, Result}; + #[derive(Debug, Clone, Deserialize)] struct OllamaTag { name: String, @@ -11,15 +13,18 @@ struct OllamaResponse { } /// Fetch available model names from an Ollama instance. -pub fn fetch_ollama_models(base_url: &str) -> anyhow::Result> { +pub fn fetch_ollama_models(base_url: &str) -> Result> { let url = format!("{}/api/tags", base_url.trim_end_matches('/')); let config = ureq::config::Config::builder() .timeout_global(Some(std::time::Duration::from_secs(2))) .build(); let agent = ureq::Agent::new_with_config(config); - let mut resp = agent.get(&url).call()?; + let mut resp = agent.get(&url).call().map_err(KernelError::discovery)?; - let payload: OllamaResponse = resp.body_mut().read_json()?; + let payload: OllamaResponse = resp + .body_mut() + .read_json() + .map_err(KernelError::discovery)?; Ok(payload.models.into_iter().map(|m| m.name).collect()) } diff --git a/src/discovery/openai_compat.rs b/src/discovery/openai_compat.rs index 99dd942..87c0968 100644 --- a/src/discovery/openai_compat.rs +++ b/src/discovery/openai_compat.rs @@ -1,5 +1,7 @@ use serde::Deserialize; +use crate::error::{KernelError, Result}; + #[derive(Debug, Clone, Deserialize)] struct OpenAIModelEntry { id: String, @@ -11,15 +13,18 @@ struct OpenAIResponse { } /// Fetch available model IDs from an OpenAI-compatible endpoint. -pub fn fetch_openai_compatible_models(base_url: &str) -> anyhow::Result> { +pub fn fetch_openai_compatible_models(base_url: &str) -> Result> { let url = format!("{}/v1/models", base_url.trim_end_matches('/')); let config = ureq::config::Config::builder() .timeout_global(Some(std::time::Duration::from_secs(2))) .build(); let agent = ureq::Agent::new_with_config(config); - let mut resp = agent.get(&url).call()?; + let mut resp = agent.get(&url).call().map_err(KernelError::discovery)?; - let payload: OpenAIResponse = resp.body_mut().read_json()?; + let payload: OpenAIResponse = resp + .body_mut() + .read_json() + .map_err(KernelError::discovery)?; Ok(payload.data.into_iter().map(|m| m.id).collect()) } diff --git a/src/discovery/source.rs b/src/discovery/source.rs index e88b2be..237579e 100644 --- a/src/discovery/source.rs +++ b/src/discovery/source.rs @@ -9,13 +9,15 @@ mod inner { use async_trait::async_trait; use std::time::Duration; + use crate::error::{KernelError, Result}; + /// Async source of discoverable models. #[async_trait] pub trait DiscoverySource: Send + Sync { /// Human-readable source name. fn name(&self) -> &'static str; /// Discover available models from this source. - async fn discover(&self) -> anyhow::Result>; + async fn discover(&self) -> Result>; } /// Async [`DiscoverySource`] backed by a models.dev-style catalog API. @@ -62,18 +64,25 @@ mod inner { "models.dev" } - async fn discover(&self) -> anyhow::Result> { + async fn discover(&self) -> Result> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) // Do not follow redirects: the base URL is a trusted catalog // endpoint, and a 3xx should surface as an error rather than be // silently chased to an unexpected host. .redirect(reqwest::redirect::Policy::none()) - .build()?; + .build() + .map_err(KernelError::discovery)?; let url = format!("{}/api.json", self.base_url.trim_end_matches('/')); // Surface non-success HTTP as a clear error before any body is // read, so a 4xx/5xx error page is not misread as malformed JSON. - let mut response = client.get(&url).send().await?.error_for_status()?; + let mut response = client + .get(&url) + .send() + .await + .map_err(KernelError::discovery)? + .error_for_status() + .map_err(KernelError::discovery)?; // Bound the response so a malformed or hostile endpoint cannot // drive unbounded memory allocation. Two layers: // 1. Fast-reject via Content-Length when the server advertises it. @@ -83,7 +92,9 @@ mod inner { if let Some(len) = response.content_length() && (len as usize) > MAX_BYTES { - anyhow::bail!("discovery response advertised {len} bytes (cap {MAX_BYTES})"); + return Err(KernelError::Discovery(format!( + "discovery response advertised {len} bytes (cap {MAX_BYTES})" + ))); } let body = read_capped_body(&mut response, MAX_BYTES).await?; let payload: crate::discovery::ModelsDevPayload = serde_json::from_slice(&body)?; @@ -101,11 +112,13 @@ mod inner { async fn read_capped_body( response: &mut reqwest::Response, max_bytes: usize, - ) -> anyhow::Result> { + ) -> Result> { let mut buf: Vec = Vec::new(); - while let Some(chunk) = response.chunk().await? { + while let Some(chunk) = response.chunk().await.map_err(KernelError::discovery)? { if buf.len() + chunk.len() > max_bytes { - anyhow::bail!("discovery response exceeded {max_bytes} bytes while streaming"); + return Err(KernelError::Discovery(format!( + "discovery response exceeded {max_bytes} bytes while streaming" + ))); } buf.extend_from_slice(&chunk); } @@ -130,7 +143,7 @@ mod tests { "static" } - async fn discover(&self) -> anyhow::Result> { + async fn discover(&self) -> crate::error::Result> { Ok(self.0.clone()) } } diff --git a/src/embedding/async_vector_index.rs b/src/embedding/async_vector_index.rs index b1e80a6..6857437 100644 --- a/src/embedding/async_vector_index.rs +++ b/src/embedding/async_vector_index.rs @@ -11,16 +11,15 @@ //! search, filtered search, length, dimensionality — and omits `save` because //! remote backends persist server-side (just as [`VectorIndex`] omits `load` //! to stay object-safe). Concrete implementations live in this crate behind -//! feature flags (e.g. the `qdrant` feature at `src/embedding/qdrant.rs`); -//! Elasticsearch will implement it in v0.9.0. +//! feature flags: the `qdrant` feature (`src/embedding/qdrant.rs`) and the +//! `elastic` feature (`src/embedding/elastic.rs`). //! //! The trait has no concrete dependencies beyond `async_trait`. It is defined //! behind the `embedding` feature so the shared contract stays in the kernel //! while the heavy client crates remain opt-in. -use anyhow::Result; - use crate::embedding::vector_index::SearchHit; +use crate::error::Result; /// Async, object-safe vector index for remote/shared backends. /// diff --git a/src/embedding/elastic.rs b/src/embedding/elastic.rs index 33dde80..f68b963 100644 --- a/src/embedding/elastic.rs +++ b/src/embedding/elastic.rs @@ -40,7 +40,7 @@ use std::time::Duration; -use anyhow::{Result, anyhow}; +use crate::error::{KernelError, Result}; use reqwest::header::CONTENT_TYPE; use serde::Deserialize; @@ -76,7 +76,7 @@ impl ElasticsearchVectorIndex { .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(30)) .build() - .map_err(|e| anyhow!(redact_credentials(&e.to_string())))?; + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string())))?; let idx = Self { client, base_url: url.trim_end_matches('/').to_string(), @@ -105,7 +105,7 @@ impl ElasticsearchVectorIndex { .head(format!("{}/{}", &self.base_url, &self.index)) .send() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string())))?; + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string())))?; if head.status().as_u16() == 200 { return Ok(()); } @@ -147,7 +147,7 @@ impl ElasticsearchVectorIndex { .json(&body) .send() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string()))) + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string()))) } async fn delete(&self, path: &str) -> Result { @@ -155,7 +155,7 @@ impl ElasticsearchVectorIndex { .delete(format!("{}{}", &self.base_url, path)) .send() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string()))) + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string()))) } async fn ndjson(&self, path: &str, body: String) -> Result { @@ -165,21 +165,20 @@ impl ElasticsearchVectorIndex { .body(body) .send() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string()))) + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string()))) } - async fn status_err(&self, resp: reqwest::Response) -> anyhow::Error { + async fn status_err(&self, resp: reqwest::Response) -> KernelError { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); // Redact FIRST (strip any embedded credentials), then cap the body so a // huge ES error response cannot bloat logs/errors. The order matters: // a credential past the cap is already masked before truncation runs. let body = truncate_error_body(&redact_credentials(&body)); - anyhow!( + KernelError::Embedding(format!( "elasticsearch returned status {status} for index `{}` [url redacted]: {}", - &self.index, - body - ) + &self.index, body + )) } } @@ -187,11 +186,11 @@ impl ElasticsearchVectorIndex { impl AsyncVectorIndex for ElasticsearchVectorIndex { async fn add(&self, vectors: &[Vec], ids: &[u64]) -> Result<()> { if vectors.len() != ids.len() { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "vectors.len() ({}) must equal ids.len() ({})", vectors.len(), ids.len() - )); + ))); } if vectors.is_empty() { return Ok(()); @@ -202,7 +201,7 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { &serde_json::to_string(&serde_json::json!({ "index": { "_index": &self.index, "_id": id.to_string() } })) - .map_err(|e| anyhow!("bulk encode: {e}"))?, + .map_err(|e| KernelError::Embedding(format!("bulk encode: {e}")))?, ); body.push('\n'); body.push_str( @@ -210,7 +209,7 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { "ext_id": id, "vector": v })) - .map_err(|e| anyhow!("bulk encode: {e}"))?, + .map_err(|e| KernelError::Embedding(format!("bulk encode: {e}")))?, ); body.push('\n'); } @@ -223,10 +222,10 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { } let parsed: BulkResponse = decode(resp).await?; if parsed.errors { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "elasticsearch bulk upsert reported per-item errors [url redacted]: {}", first_failing_bulk_item(&parsed.items) - )); + ))); } Ok(()) } @@ -241,7 +240,7 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { &serde_json::to_string(&serde_json::json!({ "delete": { "_index": &self.index, "_id": id.to_string() } })) - .map_err(|e| anyhow!("bulk encode: {e}"))?, + .map_err(|e| KernelError::Embedding(format!("bulk encode: {e}")))?, ); body.push('\n'); } @@ -253,10 +252,10 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { // mirrors Qdrant's "silently ignore missing ids" contract. let parsed: BulkResponse = decode(resp).await?; if parsed.errors { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "elasticsearch bulk delete reported per-item errors [url redacted]: {}", first_failing_bulk_item(&parsed.items) - )); + ))); } Ok(()) } @@ -282,7 +281,7 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { .json(&body) .send() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string())))?; + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string())))?; if !resp.status().is_success() { return Err(self.status_err(resp).await); } @@ -331,7 +330,7 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { .json(&body) .send() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string())))?; + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string())))?; if !resp.status().is_success() { return Err(self.status_err(resp).await); } @@ -356,7 +355,7 @@ impl AsyncVectorIndex for ElasticsearchVectorIndex { .json(&serde_json::json!({})) .send() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string())))?; + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string())))?; if !resp.status().is_success() { return Err(self.status_err(resp).await); } @@ -432,8 +431,8 @@ fn knn_num_candidates(k: usize) -> usize { base.min(MAX_KNN_CANDIDATES).max(k) } -/// Maximum number of characters of an ES error response body to embed in an -/// [`anyhow::Error`]. A huge ES error body (e.g. a verbose +/// Maximum number of characters of an ES error response body to embed in a +/// [`KernelError`](crate::error::KernelError). A huge ES error body (e.g. a verbose /// `mapper_parsing_exception`) could otherwise bloat logs and error chains; /// the cap keeps the diagnostic surface bounded while the `... [truncated]` /// marker signals that more is available on the ES side. @@ -470,39 +469,41 @@ fn truncate_error_body(s: &str) -> String { /// call. Pure — unit-testable offline. fn validate_index_name(index: &str) -> Result<()> { if index.is_empty() { - return Err(anyhow!("elasticsearch index name must not be empty")); + return Err(KernelError::Embedding( + "elasticsearch index name must not be empty".into(), + )); } // ES hard-rejects the literal names "." and ".." (reserved), distinct from // the leading-dot allowance for hidden/system indices like `.myindex`. if index == "." || index == ".." { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "elasticsearch index name must not be `.` or `..` (reserved): `{}`", index - )); + ))); } if index.len() > 255 { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "elasticsearch index name exceeds 255 bytes ({} bytes)", index.len() - )); + ))); } match index.as_bytes()[0] { b'_' | b'-' | b'+' => { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "elasticsearch index name must not start with `_`, `-`, or `+`: `{}`", index - )); + ))); } _ => {} } if let Some(bad) = index.bytes().find(|&c| { !(c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'_' | b'-' | b'.')) }) { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "elasticsearch index name contains an illegal byte 0x{bad:02x} (`{}`): \ only lowercase a-z, 0-9, `_`, `-`, `.` are allowed", index - )); + ))); } Ok(()) } @@ -530,7 +531,7 @@ fn first_failing_bulk_item(items: &[serde_json::Value]) -> String { async fn decode(resp: reqwest::Response) -> Result { resp.json::() .await - .map_err(|e| anyhow!(redact_credentials(&e.to_string()))) + .map_err(|e| KernelError::Embedding(redact_credentials(&e.to_string()))) } #[derive(Deserialize)] @@ -810,10 +811,10 @@ mod tests { /// letting the caller clean up the throwaway index on every exit path. async fn run_live_conformance(idx: &ElasticsearchVectorIndex) -> Result<()> { if idx.dim() != DIM { - return Err(anyhow!("dim mismatch")); + return Err(KernelError::Embedding("dim mismatch".into())); } if !idx.is_empty().await? { - return Err(anyhow!("not empty at start")); + return Err(KernelError::Embedding("not empty at start".into())); } idx.add( &[vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]], @@ -821,32 +822,34 @@ mod tests { ) .await?; if idx.len().await? != 2 { - return Err(anyhow!("len != 2 after add")); + return Err(KernelError::Embedding("len != 2 after add".into())); } let hits = idx.search(&[1.0, 0.0, 0.0, 0.0], 1).await?; if hits.len() != 1 || hits[0].id != 1 { - return Err(anyhow!("nearest neighbor != id 1")); + return Err(KernelError::Embedding("nearest neighbor != id 1".into())); } let filtered = idx.search_filtered(&[1.0, 0.0, 0.0, 0.0], 2, &[2]).await?; if filtered.len() != 1 || filtered[0].id != 2 { - return Err(anyhow!("filtered search != id 2")); + return Err(KernelError::Embedding("filtered search != id 2".into())); } // Re-upsert id 1 with a different vector; count stays 2 (replace). idx.add(&[vec![0.9, 0.1, 0.0, 0.0]], &[1]).await?; if idx.len().await? != 2 { - return Err(anyhow!("len != 2 after re-add")); + return Err(KernelError::Embedding("len != 2 after re-add".into())); } idx.remove(&[1]).await?; if idx.len().await? != 1 { - return Err(anyhow!("len != 1 after remove")); + return Err(KernelError::Embedding("len != 1 after remove".into())); } let after = idx.search(&[1.0, 0.0, 0.0, 0.0], 5).await?; if after.iter().any(|h| h.id == 1) { - return Err(anyhow!("id 1 still present after remove")); + return Err(KernelError::Embedding( + "id 1 still present after remove".into(), + )); } Ok(()) } diff --git a/src/embedding/fastembed.rs b/src/embedding/fastembed.rs index b923664..32ebbc9 100644 --- a/src/embedding/fastembed.rs +++ b/src/embedding/fastembed.rs @@ -17,6 +17,7 @@ use std::sync::Mutex; use crate::embedding::catalog::EmbeddingModel; use crate::embedding::types::{EmbeddingProvider, EmbeddingResult}; +use crate::error::{KernelError, Result}; /// Local ONNX embedding provider backed by fastembed-rs. /// @@ -33,13 +34,13 @@ impl FastembedProvider { /// /// `cache_dir` overrides the HuggingFace model cache directory. /// Pass `None` to use the default cache location. - pub fn new(model: EmbeddingModel, cache_dir: Option) -> anyhow::Result { + pub fn new(model: EmbeddingModel, cache_dir: Option) -> Result { let mut options = fastembed::TextInitOptions::new(model.as_fastembed()) .with_show_download_progress(false); if let Some(dir) = cache_dir { options = options.with_cache_dir(dir); } - let te = fastembed::TextEmbedding::try_new(options)?; + let te = fastembed::TextEmbedding::try_new(options).map_err(KernelError::embedding)?; Ok(Self { inner: Mutex::new(te), model, @@ -57,10 +58,7 @@ impl FastembedProvider { /// /// `cache_dir` overrides the HuggingFace model cache directory. #[cfg(all(feature = "embedding-fastembed-directml", target_os = "windows"))] - pub fn new_with_directml( - model: EmbeddingModel, - cache_dir: Option, - ) -> anyhow::Result { + pub fn new_with_directml(model: EmbeddingModel, cache_dir: Option) -> Result { use ort::execution_providers::DirectMLExecutionProvider; let mut options = fastembed::TextInitOptions::new(model.as_fastembed()) .with_show_download_progress(false) @@ -68,7 +66,7 @@ impl FastembedProvider { if let Some(dir) = cache_dir { options = options.with_cache_dir(dir); } - let te = fastembed::TextEmbedding::try_new(options)?; + let te = fastembed::TextEmbedding::try_new(options).map_err(KernelError::embedding)?; Ok(Self { inner: Mutex::new(te), model, @@ -80,14 +78,14 @@ impl FastembedProvider { model: EmbeddingModel, cache_dir: Option, max_length: usize, - ) -> anyhow::Result { + ) -> Result { let mut options = fastembed::TextInitOptions::new(model.as_fastembed()) .with_show_download_progress(false) .with_max_length(max_length); if let Some(dir) = cache_dir { options = options.with_cache_dir(dir); } - let te = fastembed::TextEmbedding::try_new(options)?; + let te = fastembed::TextEmbedding::try_new(options).map_err(KernelError::embedding)?; Ok(Self { inner: Mutex::new(te), model, @@ -106,7 +104,7 @@ impl EmbeddingProvider for FastembedProvider { self.model.as_str() } - fn embed(&self, text: &str) -> anyhow::Result { + fn embed(&self, text: &str) -> Result { let owned = match self.model.query_prefix() { Some(prefix) => format!("{prefix}{text}"), None => text.to_string(), @@ -114,12 +112,14 @@ impl EmbeddingProvider for FastembedProvider { let mut te = self .inner .lock() - .map_err(|e| anyhow::anyhow!("lock: {e}"))?; - let embeddings = te.embed(vec![owned], None)?; + .map_err(|e| KernelError::Embedding(format!("lock: {e}")))?; + let embeddings = te + .embed(vec![owned], None) + .map_err(KernelError::embedding)?; let vector = embeddings .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("empty embedding output"))?; + .ok_or_else(|| KernelError::Embedding("empty embedding output".into()))?; Ok(EmbeddingResult { vector, @@ -127,7 +127,7 @@ impl EmbeddingProvider for FastembedProvider { }) } - fn embed_batch(&self, texts: &[&str]) -> anyhow::Result> { + fn embed_batch(&self, texts: &[&str]) -> Result> { if texts.is_empty() { return Ok(vec![]); } @@ -143,8 +143,8 @@ impl EmbeddingProvider for FastembedProvider { let mut te = self .inner .lock() - .map_err(|e| anyhow::anyhow!("lock: {e}"))?; - let embeddings = te.embed(prepared, None)?; + .map_err(|e| KernelError::Embedding(format!("lock: {e}")))?; + let embeddings = te.embed(prepared, None).map_err(KernelError::embedding)?; Ok(embeddings .into_iter() diff --git a/src/embedding/lazy.rs b/src/embedding/lazy.rs index 509ff47..53bd4bb 100644 --- a/src/embedding/lazy.rs +++ b/src/embedding/lazy.rs @@ -30,6 +30,7 @@ use super::catalog::EmbeddingModel; use super::fastembed::FastembedProvider; use super::types::text_preview; use super::types::{EmbeddingProvider, EmbeddingResult}; +use crate::error::{KernelError, Result}; // --------------------------------------------------------------------------- // Load hook (panic-safe model instantiation) @@ -41,7 +42,7 @@ use super::types::{EmbeddingProvider, EmbeddingResult}; /// `Inner` lock and invoked without holding the mutex across the /// (potentially minutes-long) model download/init. pub(crate) type LoadFn = - Arc anyhow::Result + Send + Sync>; + Arc Result + Send + Sync>; /// Default loader: instantiate [`FastembedProvider`] directly. /// @@ -49,7 +50,7 @@ pub(crate) type LoadFn = /// it for an injectable panicking/failing loader via /// [`LazyFastembedProvider::new_with_loader`] without needing real ONNX weights /// on disk. -fn default_load(model: EmbeddingModel, cache_dir: PathBuf) -> anyhow::Result { +fn default_load(model: EmbeddingModel, cache_dir: PathBuf) -> Result { FastembedProvider::new(model, Some(cache_dir)) } @@ -73,13 +74,18 @@ fn load_catching_panics( load: &LoadFn, model: EmbeddingModel, cache_dir: PathBuf, -) -> (anyhow::Result, bool) { +) -> (Result, bool) { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load(model, cache_dir))); match result { Ok(inner) => (inner, false), Err(payload) => { let msg = panic_message(&payload); - (Err(anyhow::anyhow!("model init panicked: {msg}")), true) + ( + Err(KernelError::Embedding(format!( + "model init panicked: {msg}" + ))), + true, + ) } } } @@ -329,7 +335,7 @@ impl LazyFastembedProvider { /// yet loaded, this triggers download and initialisation on the calling /// thread. Concurrent callers wait on a `Condvar` for up to /// `load_timeout_secs`. - pub fn ensure_model(&self) -> Result<(), String> { + pub fn ensure_model(&self) -> std::result::Result<(), String> { let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); // Idle eviction: drop ONNX session if quiet for too long. @@ -428,7 +434,7 @@ impl EmbeddingProvider for LazyFastembedProvider { guard.model.as_str() } - fn embed(&self, text: &str) -> anyhow::Result { + fn embed(&self, text: &str) -> Result { // Check query cache { let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner()); @@ -441,7 +447,7 @@ impl EmbeddingProvider for LazyFastembedProvider { } // Ensure model is loaded (includes idle eviction check) - self.ensure_model().map_err(|e| anyhow::anyhow!("{e}"))?; + self.ensure_model().map_err(KernelError::Embedding)?; // Embed let result = { @@ -449,7 +455,7 @@ impl EmbeddingProvider for LazyFastembedProvider { guard.last_used = Some(Instant::now()); match &guard.provider { Some(p) => p.embed(text), - None => Err(anyhow::anyhow!("provider not available")), + None => Err(KernelError::Embedding("provider not available".into())), } }?; @@ -462,7 +468,7 @@ impl EmbeddingProvider for LazyFastembedProvider { Ok(result) } - fn embed_batch(&self, texts: &[&str]) -> anyhow::Result> { + fn embed_batch(&self, texts: &[&str]) -> Result> { if texts.is_empty() { return Ok(vec![]); } @@ -497,7 +503,7 @@ impl EmbeddingProvider for LazyFastembedProvider { } // Phase 3: ensure model is loaded - self.ensure_model().map_err(|e| anyhow::anyhow!("{e}"))?; + self.ensure_model().map_err(KernelError::Embedding)?; // Phase 4: batch embed the misses through the inner provider let batch_results = { @@ -505,10 +511,21 @@ impl EmbeddingProvider for LazyFastembedProvider { guard.last_used = Some(Instant::now()); match &guard.provider { Some(p) => p.embed_batch(&miss_texts), - None => Err(anyhow::anyhow!("provider not available")), + None => Err(KernelError::Embedding("provider not available".into())), } }?; + // A well-behaved provider returns exactly one vector per input. Guard + // against a truncated/malformed response so the merge below indexes + // `batch_results` safely instead of panicking on an out-of-bounds slot. + if batch_results.len() != miss_texts.len() { + return Err(KernelError::Embedding(format!( + "provider returned {} embeddings for {} inputs", + batch_results.len(), + miss_texts.len() + ))); + } + // Phase 5: merge results and insert new entries into cache { let mut cache = self.query_cache.lock().unwrap_or_else(|e| e.into_inner()); @@ -519,15 +536,19 @@ impl EmbeddingProvider for LazyFastembedProvider { } } - // Phase 6: assemble final results in original order - Ok(results + // Phase 6: assemble final results in original order. Every slot is now + // populated (all cache-hit or filled in Phase 5), so unwrap is safe. + results .into_iter() .zip(texts.iter()) - .map(|(opt, text)| EmbeddingResult { - vector: opt.unwrap(), - text_preview: text_preview(text), + .map(|(opt, text)| { + opt.map(|vector| EmbeddingResult { + vector, + text_preview: text_preview(text), + }) + .ok_or_else(|| KernelError::Embedding("internal: unfilled embedding slot".into())) }) - .collect()) + .collect() } } @@ -669,7 +690,7 @@ mod tests { fn panicking_loader() -> LoadFn { Arc::new( - |_model: EmbeddingModel, _cache: PathBuf| -> anyhow::Result { + |_model: EmbeddingModel, _cache: PathBuf| -> Result { panic!("simulated ort init failure (missing libonnxruntime.so)") }, ) @@ -678,8 +699,8 @@ mod tests { fn failing_loader(msg: &str) -> LoadFn { let msg = msg.to_string(); Arc::new( - move |_model: EmbeddingModel, _cache: PathBuf| -> anyhow::Result { - Err(anyhow::anyhow!("{msg}")) + move |_model: EmbeddingModel, _cache: PathBuf| -> Result { + Err(KernelError::Embedding(msg.clone())) }, ) } @@ -743,7 +764,7 @@ mod tests { let attempts_clone = Arc::clone(&attempts); let loader: LoadFn = Arc::new(move |_m, _c| { attempts_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Err(anyhow::anyhow!("transient failure")) + Err(KernelError::Embedding("transient failure".into())) }); let provider = LazyFastembedProvider::new_with_loader( EmbeddingModel::BGESmallENV15, diff --git a/src/embedding/nomic_moe.rs b/src/embedding/nomic_moe.rs index 2437b15..256180c 100644 --- a/src/embedding/nomic_moe.rs +++ b/src/embedding/nomic_moe.rs @@ -15,6 +15,7 @@ //! ``` use crate::embedding::types::{EmbeddingProvider, EmbeddingResult}; +use crate::error::{KernelError, Result}; /// Nomic V2 MoE embedding provider backed by candle-nn. /// @@ -37,7 +38,7 @@ impl NomicMoeProvider { /// Create a new provider using CPU with F32 precision. /// /// Downloads the model from HuggingFace on first call (cached locally). - pub fn new() -> anyhow::Result { + pub fn new() -> Result { Self::with_options( NOMIC_EMBED_TEXT_V2_MOE, candle_core::Device::Cpu, @@ -52,8 +53,9 @@ impl NomicMoeProvider { device: candle_core::Device, dtype: candle_core::DType, max_length: usize, - ) -> anyhow::Result { - let te = fastembed::NomicV2MoeTextEmbedding::from_hf(model_id, &device, dtype, max_length)?; + ) -> Result { + let te = fastembed::NomicV2MoeTextEmbedding::from_hf(model_id, &device, dtype, max_length) + .map_err(KernelError::embedding)?; let dim = te.config().hidden_size; Ok(Self { inner: te, @@ -77,12 +79,12 @@ impl EmbeddingProvider for NomicMoeProvider { &self.model_id } - fn embed(&self, text: &str) -> anyhow::Result { - let embeddings = self.inner.embed(&[text])?; + fn embed(&self, text: &str) -> Result { + let embeddings = self.inner.embed(&[text]).map_err(KernelError::embedding)?; let vector = embeddings .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("empty embedding output"))?; + .ok_or_else(|| KernelError::Embedding("empty embedding output".into()))?; let preview = if text.len() > 64 { format!("{}…", &text[..64]) @@ -95,11 +97,11 @@ impl EmbeddingProvider for NomicMoeProvider { }) } - fn embed_batch(&self, texts: &[&str]) -> anyhow::Result> { + fn embed_batch(&self, texts: &[&str]) -> Result> { if texts.is_empty() { return Ok(vec![]); } - let embeddings = self.inner.embed(texts)?; + let embeddings = self.inner.embed(texts).map_err(KernelError::embedding)?; Ok(embeddings .into_iter() .zip(texts.iter()) diff --git a/src/embedding/openai.rs b/src/embedding/openai.rs index 3b6bdd5..32b59f0 100644 --- a/src/embedding/openai.rs +++ b/src/embedding/openai.rs @@ -3,6 +3,7 @@ use serde::Deserialize; use crate::embedding::types::{EmbeddingProvider, EmbeddingResult}; +use crate::error::{KernelError, Result}; #[derive(Deserialize)] struct EmbeddingData { @@ -72,9 +73,9 @@ impl OpenAIEmbeddingClient { /// /// Always uses `text-embedding-3-small` (1536-dim). For a different model /// use [`new_with_model`](Self::new_with_model) after reading the key manually. - pub fn from_env() -> anyhow::Result { + pub fn from_env() -> Result { let key = std::env::var("OPENAI_API_KEY") - .map_err(|_| anyhow::anyhow!("OPENAI_API_KEY not set"))?; + .map_err(|_| KernelError::Embedding("OPENAI_API_KEY not set".into()))?; Ok(Self::new_small(key)) } } @@ -90,7 +91,7 @@ impl EmbeddingProvider for OpenAIEmbeddingClient { &self.model } - fn embed(&self, text: &str) -> anyhow::Result { + fn embed(&self, text: &str) -> Result { let config = ureq::config::Config::builder() .timeout_global(Some(std::time::Duration::from_secs(30))) .build(); @@ -105,15 +106,19 @@ impl EmbeddingProvider for OpenAIEmbeddingClient { .post("https://api.openai.com/v1/embeddings") .header("Authorization", format!("Bearer {}", self.api_key)) .header("Content-Type", "application/json") - .send_json(body)?; + .send_json(body) + .map_err(KernelError::embedding)?; - let payload: EmbeddingResponse = resp.body_mut().read_json()?; + let payload: EmbeddingResponse = resp + .body_mut() + .read_json() + .map_err(KernelError::embedding)?; let vector = payload .data .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("empty embedding response"))? + .ok_or_else(|| KernelError::Embedding("empty embedding response".into()))? .embedding; Ok(EmbeddingResult { @@ -122,7 +127,7 @@ impl EmbeddingProvider for OpenAIEmbeddingClient { }) } - fn embed_batch(&self, texts: &[&str]) -> anyhow::Result> { + fn embed_batch(&self, texts: &[&str]) -> Result> { if texts.is_empty() { return Ok(vec![]); } @@ -141,9 +146,13 @@ impl EmbeddingProvider for OpenAIEmbeddingClient { .post("https://api.openai.com/v1/embeddings") .header("Authorization", format!("Bearer {}", self.api_key)) .header("Content-Type", "application/json") - .send_json(body)?; + .send_json(body) + .map_err(KernelError::embedding)?; - let payload: EmbeddingResponse = resp.body_mut().read_json()?; + let payload: EmbeddingResponse = resp + .body_mut() + .read_json() + .map_err(KernelError::embedding)?; // The OpenAI API does not guarantee that `data` is returned in input // order; sort by `index` before zipping with `texts`. @@ -151,11 +160,11 @@ impl EmbeddingProvider for OpenAIEmbeddingClient { data.sort_unstable_by_key(|d| d.index); if data.len() != texts.len() { - anyhow::bail!( + return Err(KernelError::Embedding(format!( "API returned {} embeddings for {} inputs", data.len(), texts.len() - ); + ))); } let results = data diff --git a/src/embedding/qdrant.rs b/src/embedding/qdrant.rs index 7e060cf..ed10d78 100644 --- a/src/embedding/qdrant.rs +++ b/src/embedding/qdrant.rs @@ -5,7 +5,7 @@ //! — remote vector services are async-only and naturally shared, so they //! cannot implement the synchronous `VectorIndex`. -use anyhow::{Result, anyhow}; +use crate::error::{KernelError, Result}; use qdrant_client::qdrant::point_id::PointIdOptions; use qdrant_client::qdrant::{ Condition, CountPointsBuilder, CreateCollectionBuilder, DeletePointsBuilder, Distance, Filter, @@ -30,7 +30,9 @@ impl QdrantVectorIndex { /// Connect to `url` (e.g. `http://localhost:6334`) and ensure `collection` /// exists with a Cosine-distance vector config of `dim` dimensions. pub async fn new(url: &str, collection: &str, dim: usize) -> Result { - let client = Qdrant::from_url(url).build()?; + let client = Qdrant::from_url(url) + .build() + .map_err(KernelError::embedding)?; let idx = Self { client, collection: collection.to_string(), @@ -42,21 +44,30 @@ impl QdrantVectorIndex { /// Create the collection if it does not already exist. async fn ensure_collection(&self) -> Result<()> { - if !self.client.collection_exists(&self.collection).await? { + if !self + .client + .collection_exists(&self.collection) + .await + .map_err(KernelError::embedding)? + { self.client .create_collection( CreateCollectionBuilder::new(&self.collection).vectors_config( VectorParamsBuilder::new(self.dim as u64, Distance::Cosine), ), ) - .await?; + .await + .map_err(KernelError::embedding)?; } Ok(()) } /// Drop the collection (useful for test cleanup or full reset). pub async fn delete_collection(&self) -> Result<()> { - self.client.delete_collection(&self.collection).await?; + self.client + .delete_collection(&self.collection) + .await + .map_err(KernelError::embedding)?; Ok(()) } @@ -86,17 +97,17 @@ impl QdrantVectorIndex { impl AsyncVectorIndex for QdrantVectorIndex { async fn add(&self, vectors: &[Vec], ids: &[u64]) -> Result<()> { if vectors.len() != ids.len() { - return Err(anyhow!( + return Err(KernelError::Embedding(format!( "vectors.len() ({}) must equal ids.len() ({})", vectors.len(), ids.len() - )); + ))); } if vectors.is_empty() { return Ok(()); } let payload = Payload::try_from(serde_json::json!({})) - .map_err(|e| anyhow!("invalid empty payload: {e}"))?; + .map_err(|e| KernelError::Embedding(format!("invalid empty payload: {e}")))?; let points: Vec = vectors .iter() .zip(ids.iter()) @@ -104,7 +115,8 @@ impl AsyncVectorIndex for QdrantVectorIndex { .collect(); self.client .upsert_points(UpsertPointsBuilder::new(&self.collection, points).wait(true)) - .await?; + .await + .map_err(KernelError::embedding)?; Ok(()) } @@ -121,7 +133,8 @@ impl AsyncVectorIndex for QdrantVectorIndex { .points(id_list) .wait(true), ) - .await?; + .await + .map_err(KernelError::embedding)?; Ok(()) } @@ -134,7 +147,8 @@ impl AsyncVectorIndex for QdrantVectorIndex { .limit(k as u64) .with_payload(false), ) - .await?; + .await + .map_err(KernelError::embedding)?; Ok(res.result.iter().filter_map(Self::scored_to_hit).collect()) } @@ -158,7 +172,8 @@ impl AsyncVectorIndex for QdrantVectorIndex { .with_payload(false) .filter(filter), ) - .await?; + .await + .map_err(KernelError::embedding)?; Ok(res.result.iter().filter_map(Self::scored_to_hit).collect()) } @@ -166,7 +181,8 @@ impl AsyncVectorIndex for QdrantVectorIndex { let res = self .client .count(CountPointsBuilder::new(&self.collection).exact(true)) - .await?; + .await + .map_err(KernelError::embedding)?; Ok(res.result.map(|c| c.count as usize).unwrap_or(0)) } @@ -208,10 +224,10 @@ mod tests { /// letting the caller clean up the throwaway collection in every case. async fn run_live_conformance(idx: &QdrantVectorIndex) -> Result<()> { if idx.dim() != DIM { - return Err(anyhow!("dim mismatch")); + return Err(KernelError::Embedding("dim mismatch".into())); } if !idx.is_empty().await? { - return Err(anyhow!("not empty at start")); + return Err(KernelError::Embedding("not empty at start".into())); } idx.add( &[vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]], @@ -219,31 +235,33 @@ mod tests { ) .await?; if idx.len().await? != 2 { - return Err(anyhow!("len != 2 after add")); + return Err(KernelError::Embedding("len != 2 after add".into())); } let hits = idx.search(&[1.0, 0.0, 0.0, 0.0], 1).await?; if hits.len() != 1 || hits[0].id != 1 { - return Err(anyhow!("nearest neighbor != id 1")); + return Err(KernelError::Embedding("nearest neighbor != id 1".into())); } let filtered = idx.search_filtered(&[1.0, 0.0, 0.0, 0.0], 2, &[2]).await?; if filtered.len() != 1 || filtered[0].id != 2 { - return Err(anyhow!("filtered search != id 2")); + return Err(KernelError::Embedding("filtered search != id 2".into())); } idx.add(&[vec![0.9, 0.1, 0.0, 0.0]], &[1]).await?; if idx.len().await? != 2 { - return Err(anyhow!("len != 2 after re-add")); + return Err(KernelError::Embedding("len != 2 after re-add".into())); } idx.remove(&[1]).await?; if idx.len().await? != 1 { - return Err(anyhow!("len != 1 after remove")); + return Err(KernelError::Embedding("len != 1 after remove".into())); } let after = idx.search(&[1.0, 0.0, 0.0, 0.0], 5).await?; if after.iter().any(|h| h.id == 1) { - return Err(anyhow!("id 1 still present after remove")); + return Err(KernelError::Embedding( + "id 1 still present after remove".into(), + )); } Ok(()) } diff --git a/src/embedding/qwen3.rs b/src/embedding/qwen3.rs index 9c2c1cf..7be0667 100644 --- a/src/embedding/qwen3.rs +++ b/src/embedding/qwen3.rs @@ -15,6 +15,7 @@ //! ``` use crate::embedding::types::{EmbeddingProvider, EmbeddingResult}; +use crate::error::{KernelError, Result}; /// Qwen3 embedding provider backed by candle-nn. /// @@ -43,7 +44,7 @@ impl Qwen3Provider { /// Create a new provider using CPU with F32 precision. /// /// Downloads the model from HuggingFace on first call (cached locally). - pub fn new(model_id: &str) -> anyhow::Result { + pub fn new(model_id: &str) -> Result { Self::with_options( model_id, candle_core::Device::Cpu, @@ -58,8 +59,9 @@ impl Qwen3Provider { device: candle_core::Device, dtype: candle_core::DType, max_length: usize, - ) -> anyhow::Result { - let te = fastembed::Qwen3TextEmbedding::from_hf(model_id, &device, dtype, max_length)?; + ) -> Result { + let te = fastembed::Qwen3TextEmbedding::from_hf(model_id, &device, dtype, max_length) + .map_err(KernelError::embedding)?; let dim = te.config().hidden_size; Ok(Self { inner: te, @@ -83,12 +85,12 @@ impl EmbeddingProvider for Qwen3Provider { &self.model_id } - fn embed(&self, text: &str) -> anyhow::Result { - let embeddings = self.inner.embed(&[text])?; + fn embed(&self, text: &str) -> Result { + let embeddings = self.inner.embed(&[text]).map_err(KernelError::embedding)?; let vector = embeddings .into_iter() .next() - .ok_or_else(|| anyhow::anyhow!("empty embedding output"))?; + .ok_or_else(|| KernelError::Embedding("empty embedding output".into()))?; let preview = if text.len() > 64 { format!("{}…", &text[..64]) @@ -101,11 +103,11 @@ impl EmbeddingProvider for Qwen3Provider { }) } - fn embed_batch(&self, texts: &[&str]) -> anyhow::Result> { + fn embed_batch(&self, texts: &[&str]) -> Result> { if texts.is_empty() { return Ok(vec![]); } - let embeddings = self.inner.embed(texts)?; + let embeddings = self.inner.embed(texts).map_err(KernelError::embedding)?; Ok(embeddings .into_iter() .zip(texts.iter()) diff --git a/src/embedding/turbovec.rs b/src/embedding/turbovec.rs index c4e9d54..3c5dd2b 100644 --- a/src/embedding/turbovec.rs +++ b/src/embedding/turbovec.rs @@ -2,9 +2,8 @@ use std::path::Path; -use anyhow::{Result, anyhow, ensure}; - use super::vector_index::{SearchHit, VectorIndex}; +use crate::error::{KernelError, Result}; /// Compressed vector index backed by TurboQuant. /// @@ -34,12 +33,13 @@ impl TurbovecIndex { /// - **2-bit**: 16x compression, lower recall at low k /// - **4-bit**: 8x compression, higher recall (recommended default) pub fn new(dim: usize, bit_width: u8) -> Result { - ensure!( - bit_width == 2 || bit_width == 4, - "bit_width must be 2 or 4, got {bit_width}" - ); + if bit_width != 2 && bit_width != 4 { + return Err(KernelError::Embedding(format!( + "bit_width must be 2 or 4, got {bit_width}" + ))); + } let inner = turbovec::IdMapIndex::new(dim, bit_width as usize) - .map_err(|e| anyhow!("failed to create index: {e}"))?; + .map_err(|e| KernelError::Embedding(format!("failed to create index: {e}")))?; Ok(Self { inner, dim, @@ -59,35 +59,37 @@ impl TurbovecIndex { /// `TurbovecIndex::load(path)`. pub fn load(path: &Path) -> Result { let inner = turbovec::IdMapIndex::load(path) - .map_err(|e| anyhow!("failed to load vector index: {e}"))?; + .map_err(|e| KernelError::Embedding(format!("failed to load vector index: {e}")))?; let meta_path = path.with_extension("meta.json"); - let meta: IndexMeta = serde_json::from_str(&std::fs::read_to_string(&meta_path)?)?; - ensure!( - meta.bit_width == 2 || meta.bit_width == 4, - "corrupted index meta: bit_width must be 2 or 4, got {}", - meta.bit_width, - ); - ensure!( - meta.dim > 0, - "corrupted index meta: dim must be positive, got {}", - meta.dim, - ); + let meta: IndexMeta = serde_json::from_str(&std::fs::read_to_string(&meta_path)?) + .map_err(KernelError::embedding)?; + if meta.bit_width != 2 && meta.bit_width != 4 { + return Err(KernelError::Embedding(format!( + "corrupted index meta: bit_width must be 2 or 4, got {}", + meta.bit_width + ))); + } + if meta.dim == 0 { + return Err(KernelError::Embedding( + "corrupted index meta: dim must be positive, got 0".into(), + )); + } // Cross-validate: loaded index vs sidecar metadata. let inner_dim = inner.dim(); - ensure!( - inner_dim == 0 || inner_dim == meta.dim, - "index-meta mismatch: index dim={}, meta dim={}", - inner_dim, - meta.dim, - ); + if inner_dim != 0 && inner_dim != meta.dim { + return Err(KernelError::Embedding(format!( + "index-meta mismatch: index dim={inner_dim}, meta dim={}", + meta.dim + ))); + } let inner_bw = inner.bit_width(); - ensure!( - inner_bw == meta.bit_width as usize, - "index-meta mismatch: index bit_width={}, meta bit_width={}", - inner_bw, - meta.bit_width, - ); + if inner_bw != meta.bit_width as usize { + return Err(KernelError::Embedding(format!( + "index-meta mismatch: index bit_width={inner_bw}, meta bit_width={}", + meta.bit_width + ))); + } Ok(Self { inner, @@ -97,12 +99,13 @@ impl TurbovecIndex { } fn validate_dim(&self, v: &[f32]) -> Result<()> { - ensure!( - v.len() == self.dim, - "vector dimension mismatch: expected {}, got {}", - self.dim, - v.len(), - ); + if v.len() != self.dim { + return Err(KernelError::Embedding(format!( + "vector dimension mismatch: expected {}, got {}", + self.dim, + v.len() + ))); + } Ok(()) } @@ -126,22 +129,23 @@ impl VectorIndex for TurbovecIndex { let flat: Vec = vectors.iter().flat_map(|v| v.iter().copied()).collect(); self.inner .add_with_ids_2d(&flat, self.dim, &ids) - .map_err(|e| anyhow!("add failed: {e}"))?; + .map_err(|e| KernelError::Embedding(format!("add failed: {e}")))?; Ok(()) } fn add_with_ids(&mut self, vectors: &[Vec], ids: &[u64]) -> Result<()> { - ensure!( - vectors.len() == ids.len(), - "vectors ({} entries) and ids ({} entries) must have the same length", - vectors.len(), - ids.len(), - ); + if vectors.len() != ids.len() { + return Err(KernelError::Embedding(format!( + "vectors ({} entries) and ids ({} entries) must have the same length", + vectors.len(), + ids.len() + ))); + } self.validate_dims(vectors)?; let flat: Vec = vectors.iter().flat_map(|v| v.iter().copied()).collect(); self.inner .add_with_ids_2d(&flat, self.dim, ids) - .map_err(|e| anyhow!("add failed: {e}"))?; + .map_err(|e| KernelError::Embedding(format!("add failed: {e}")))?; Ok(()) } @@ -202,13 +206,13 @@ impl VectorIndex for TurbovecIndex { self.inner .write(&tmp_index) - .map_err(|e| anyhow!("failed to write vector index: {e}"))?; + .map_err(|e| KernelError::Embedding(format!("failed to write vector index: {e}")))?; let meta = IndexMeta { dim: self.dim, bit_width: self.bit_width, }; - let json = serde_json::to_string_pretty(&meta)?; + let json = serde_json::to_string_pretty(&meta).map_err(KernelError::embedding)?; std::fs::write(&tmp_meta, &json)?; // Fsync temp files to ensure data is on disk. diff --git a/src/embedding/types.rs b/src/embedding/types.rs index 9f93390..2815a19 100644 --- a/src/embedding/types.rs +++ b/src/embedding/types.rs @@ -1,5 +1,7 @@ //! Embedding types and trait definitions. +use crate::error::{KernelError, Result}; + /// A single embedding result. #[derive(Debug, Clone)] pub struct EmbeddingResult { @@ -71,7 +73,7 @@ pub trait EmbeddingProvider: Send + Sync { fn dim(&self) -> usize; /// Embed a single text string. - fn embed(&self, text: &str) -> anyhow::Result; + fn embed(&self, text: &str) -> Result; /// Embed multiple texts in batch. /// @@ -79,7 +81,7 @@ pub trait EmbeddingProvider: Send + Sync { /// Returns an error on the **first** failure — successful results up to /// that point are discarded. For fine-grained error handling, call /// [`embed`](Self::embed) individually and collect results manually. - fn embed_batch(&self, texts: &[&str]) -> anyhow::Result> { + fn embed_batch(&self, texts: &[&str]) -> Result> { texts.iter().map(|t| self.embed(t)).collect() } @@ -104,12 +106,9 @@ pub trait EmbeddingProvider: Send + Sync { /// let chunks = chunk_batch(&["a", "b", "c", "d", "e"], 2).unwrap(); /// assert_eq!(chunks, vec![vec!["a", "b"], vec!["c", "d"], vec!["e"]]); /// ``` -pub fn chunk_batch<'a>( - texts: &[&'a str], - max_batch_size: usize, -) -> anyhow::Result>> { +pub fn chunk_batch<'a>(texts: &[&'a str], max_batch_size: usize) -> Result>> { if max_batch_size == 0 { - anyhow::bail!("max_batch_size must be > 0"); + return Err(KernelError::Embedding("max_batch_size must be > 0".into())); } Ok(texts.chunks(max_batch_size).map(|c| c.to_vec()).collect()) } diff --git a/src/embedding/vector_index.rs b/src/embedding/vector_index.rs index 4d196ea..332c5d3 100644 --- a/src/embedding/vector_index.rs +++ b/src/embedding/vector_index.rs @@ -13,7 +13,7 @@ use std::path::Path; -use anyhow::Result; +use crate::error::Result; /// A single search hit from vector index lookup. /// diff --git a/src/error.rs b/src/error.rs index 64aed43..65b2ee1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -46,11 +46,34 @@ pub enum KernelError { #[error("Search error: {0}")] Search(String), + /// An embedding provider or vector-index error. + #[error("Embedding error: {0}")] + Embedding(String), + + /// A model-discovery error. + #[error("Discovery error: {0}")] + Discovery(String), + /// A serialization/deserialization error. #[cfg(feature = "provider")] #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), } +impl KernelError { + /// Construct an [`KernelError::Embedding`] from any displayable error. + /// + /// Convenience for mapping external errors (HTTP clients, ONNX runtimes) at + /// `?` sites: `.map_err(KernelError::embedding)?`. + pub fn embedding(e: impl std::fmt::Display) -> Self { + Self::Embedding(e.to_string()) + } + + /// Construct an [`KernelError::Discovery`] from any displayable error. + pub fn discovery(e: impl std::fmt::Display) -> Self { + Self::Discovery(e.to_string()) + } +} + /// Alias for `Result`. pub type Result = std::result::Result; diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 2959a49..32bebd6 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -8,6 +8,7 @@ //! - **Search**: FTS5 full-text search and dynamic filtering //! - **Recall**: [`smart_recall`] — composite scoring with recency, importance, access, FTS, graph boost //! - **Traversal**: [`graph_neighbors`] (1-hop), [`related_nodes`] (BFS via recursive CTE) +//! - **Algorithms**: pure-Rust CSR algorithms in [`algo`] — [`pagerank()`], [`connected_components()`], [`label_propagation()`], [`dijkstra()`], [`jaccard_similarity()`] //! - **Lifecycle**: [`decay_importance`], [`tag_stale_nodes`], [`compute_stats`] //! //! All functions take `&rusqlite::Connection` — no hardcoded paths. diff --git a/src/llm/cache.rs b/src/llm/cache.rs index 697dc2e..d270441 100644 --- a/src/llm/cache.rs +++ b/src/llm/cache.rs @@ -115,29 +115,33 @@ impl CacheClient { pub fn inner(&self) -> &C { &self.inner } +} - /// Look up a non-expired cached response for `key`, if any. - fn lookup(&self, key: &str) -> Option { - let bytes = self.store.get(key).ok()??; - let entry: CachedResponse = serde_json::from_slice(&bytes).ok()?; - if let Some(ttl) = self.ttl { - let age = now_secs().saturating_sub(entry.stored_at_secs); - if age > ttl.as_secs() { - return None; - } +/// Look up a non-expired cached response for `key`, if any. +/// +/// Free function (rather than a method) so it can run inside +/// [`tokio::task::spawn_blocking`] with only an owned `Arc` +/// captured — see [`CacheClient::complete`]. +fn lookup(store: &dyn KvStore, ttl: Option, key: &str) -> Option { + let bytes = store.get(key).ok()??; + let entry: CachedResponse = serde_json::from_slice(&bytes).ok()?; + if let Some(ttl) = ttl { + let age = now_secs().saturating_sub(entry.stored_at_secs); + if age > ttl.as_secs() { + return None; } - Some(entry.response) } + Some(entry.response) +} - /// Store a response under `key` (best-effort; failures are dropped). - fn store_entry(&self, key: &str, response: &LLMResponse) { - let entry = CachedResponse { - stored_at_secs: now_secs(), - response: response.clone(), - }; - if let Ok(bytes) = serde_json::to_vec(&entry) { - let _ = self.store.put(key, &bytes); - } +/// Store a response under `key` (best-effort; failures are dropped). +fn store_entry(store: &dyn KvStore, key: &str, response: &LLMResponse) { + let entry = CachedResponse { + stored_at_secs: now_secs(), + response: response.clone(), + }; + if let Ok(bytes) = serde_json::to_vec(&entry) { + let _ = store.put(key, &bytes); } } @@ -146,12 +150,35 @@ impl LLMClient for CacheClient { async fn complete(&self, request: LLMRequest) -> Result { let key = cache_key(self.inner.model_name(), &request); - if let Some(response) = self.lookup(&key) { - return Ok(response); + // The `KvStore` API is synchronous. `CacheClient` wraps an arbitrary + // `Arc` on the hot path of every completion, so offload the + // read/write to the blocking pool: a slow (or remote) store, or a + // single-threaded runtime, must not stall the async reactor. + { + let store = self.store.clone(); + let ttl = self.ttl; + let key = key.clone(); + let hit = tokio::task::spawn_blocking(move || lookup(store.as_ref(), ttl, &key)) + .await + .unwrap_or(None); + if let Some(response) = hit { + return Ok(response); + } } let response = self.inner.complete(request).await?; - self.store_entry(&key, &response); + + { + let store = self.store.clone(); + let key = key.clone(); + let to_store = response.clone(); + // Best-effort write; join errors (task panic) are ignored, matching + // the store's own dropped-error semantics. + let _ = + tokio::task::spawn_blocking(move || store_entry(store.as_ref(), &key, &to_store)) + .await; + } + Ok(response) } @@ -168,7 +195,6 @@ impl LLMClient for CacheClient { mod tests { use super::*; use crate::error::KernelError; - use crate::llm::types::TokenUsage; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -186,10 +212,7 @@ mod tests { Ok(LLMResponse { content: self.body.clone(), model: self.model.to_string(), - usage: TokenUsage::default(), - finish_reason: None, - id: None, - created: None, + ..Default::default() }) } fn model_name(&self) -> &str { @@ -295,10 +318,7 @@ mod tests { response: LLMResponse { content: "stale".into(), model: "mock".into(), - usage: TokenUsage::default(), - finish_reason: None, - id: None, - created: None, + ..Default::default() }, }; store diff --git a/src/llm/client.rs b/src/llm/client.rs index 754fb01..6d90060 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -3,7 +3,66 @@ use std::time::Duration; use async_trait::async_trait; use crate::error::{KernelError, Result}; -use crate::llm::types::{LLMRequest, LLMResponse, LLMStream, ModelConfig, StreamEvent, TokenUsage}; +use crate::llm::tool::{ToolCall, ToolDefinition}; +use crate::llm::types::{ + LLMRequest, LLMResponse, LLMStream, ModelConfig, ResponseFormat, StreamEvent, TokenUsage, +}; + +/// Convert kernel [`ToolDefinition`]s into OpenAI `tools` (`type: "function"`). +fn openai_tools(tools: &[ToolDefinition]) -> Vec { + tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.input_schema, + } + }) + }) + .collect() +} + +/// Map a [`ResponseFormat`] to OpenAI's `response_format` object, or `None` for +/// the provider default (plain text). +fn openai_response_format(rf: &ResponseFormat) -> Option { + match rf { + ResponseFormat::Text => None, + ResponseFormat::Json => Some(serde_json::json!({ "type": "json_object" })), + ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({ + "type": "json_schema", + "json_schema": { "name": "response", "schema": schema, "strict": true } + })), + } +} + +/// Convert kernel [`ToolDefinition`]s into Anthropic `tools` (with `input_schema`). +fn anthropic_tools(tools: &[ToolDefinition]) -> Vec { + tools + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "description": t.description, + "input_schema": t.input_schema, + }) + }) + .collect() +} + +/// Map a [`ResponseFormat`] to Anthropic's `output_config`. Only +/// [`ResponseFormat::JsonSchema`] has a native equivalent; `Json` (schemaless) +/// and `Text` return `None`. +fn anthropic_output_config(rf: &ResponseFormat) -> Option { + match rf { + ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({ + "format": { "type": "json_schema", "schema": schema } + })), + ResponseFormat::Json | ResponseFormat::Text => None, + } +} /// Build a `reqwest::Client` with connect and total timeouts. fn http_client() -> Result { @@ -106,9 +165,13 @@ struct OpenAIChatRequest { max_tokens: Option, #[serde(skip_serializing_if = "std::ops::Not::not")] stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + response_format: Option, } -#[derive(serde::Serialize, serde::Deserialize)] +#[derive(serde::Serialize)] struct OpenAIChatMessage { role: String, content: String, @@ -116,6 +179,10 @@ struct OpenAIChatMessage { #[derive(serde::Deserialize)] struct OpenAIChatResponse { + #[serde(default)] + id: Option, + #[serde(default)] + created: Option, choices: Vec, model: String, usage: Option, @@ -123,7 +190,32 @@ struct OpenAIChatResponse { #[derive(serde::Deserialize)] struct OpenAIChoice { - message: OpenAIChatMessage, + message: OpenAIRespMessage, + #[serde(default)] + finish_reason: Option, +} + +/// Response-side assistant message. `content` is `null` on tool-call turns, so +/// it is optional and defaults to empty. +#[derive(serde::Deserialize)] +struct OpenAIRespMessage { + #[serde(default)] + content: Option, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(serde::Deserialize)] +struct OpenAIToolCall { + id: String, + function: OpenAIFunctionCall, +} + +#[derive(serde::Deserialize)] +struct OpenAIFunctionCall { + name: String, + #[serde(default)] + arguments: String, } #[derive(serde::Deserialize)] @@ -139,6 +231,15 @@ impl LLMClient for OpenAIClient { let model = request.model.clone().unwrap_or_else(|| self.model.clone()); let temperature = request.temperature; let max_tokens = request.max_tokens; + let tools = request + .tools + .as_deref() + .map(openai_tools) + .filter(|t| !t.is_empty()); + let response_format = request + .response_format + .as_ref() + .and_then(openai_response_format); let messages: Vec<_> = request .into_openai_messages() .into_iter() @@ -151,6 +252,8 @@ impl LLMClient for OpenAIClient { temperature, max_tokens, stream: false, + tools, + response_format, }; let resp = self @@ -179,12 +282,27 @@ impl LLMClient for OpenAIClient { .await .map_err(|e| KernelError::LlmApi(e.to_string()))?; - let content = chat_resp - .choices - .into_iter() - .next() - .map(|c| c.message.content) - .unwrap_or_default(); + let id = chat_resp.id; + let created = chat_resp.created; + let first = chat_resp.choices.into_iter().next(); + let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone()); + let (content, tool_calls) = match first { + Some(c) => { + let content = c.message.content.unwrap_or_default(); + let calls = c + .message + .tool_calls + .into_iter() + .map(|tc| ToolCall { + id: tc.id, + name: tc.function.name, + arguments: tc.function.arguments, + }) + .collect(); + (content, calls) + } + None => (String::new(), Vec::new()), + }; let usage = chat_resp.usage.map(|u| TokenUsage { prompt_tokens: u.prompt_tokens, @@ -196,9 +314,10 @@ impl LLMClient for OpenAIClient { content, model: chat_resp.model, usage: usage.unwrap_or_default(), - finish_reason: None, - id: None, - created: None, + tool_calls, + finish_reason, + id, + created, }) } @@ -222,6 +341,10 @@ impl LLMClient for OpenAIClient { temperature, max_tokens, stream: true, + // Streaming is text-only here: the SSE parser emits text deltas and + // does not reassemble streamed tool-call fragments. + tools: None, + response_format: None, }; let resp = self @@ -426,9 +549,13 @@ struct AnthropicRequest { messages: Vec, #[serde(skip_serializing_if = "std::ops::Not::not")] stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + output_config: Option, } -#[derive(serde::Serialize, serde::Deserialize)] +#[derive(serde::Serialize)] struct AnthropicMessage { role: String, content: String, @@ -436,14 +563,29 @@ struct AnthropicMessage { #[derive(serde::Deserialize)] struct AnthropicResponse { + #[serde(default)] + id: Option, content: Vec, model: String, + #[serde(default)] + stop_reason: Option, usage: AnthropicUsage, } +/// A response content block. `text` blocks carry `text`; `tool_use` blocks +/// carry `id`/`name`/`input`. #[derive(serde::Deserialize)] struct AnthropicContentBlock { + #[serde(rename = "type")] + block_type: String, + #[serde(default)] text: Option, + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + input: Option, } #[derive(serde::Deserialize)] @@ -459,6 +601,15 @@ impl LLMClient for AnthropicClient { let max_tokens = request.max_tokens.unwrap_or(4096); let temperature = request.temperature; let system = request.system.clone(); + let tools = request + .tools + .as_deref() + .map(anthropic_tools) + .filter(|t| !t.is_empty()); + let output_config = request + .response_format + .as_ref() + .and_then(anthropic_output_config); let messages: Vec = request .into_anthropic_messages() .into_iter() @@ -472,6 +623,8 @@ impl LLMClient for AnthropicClient { system, messages, stream: false, + tools, + output_config, }; let resp = self @@ -502,12 +655,31 @@ impl LLMClient for AnthropicClient { .await .map_err(|e| KernelError::LlmApi(e.to_string()))?; - let content = chat_resp - .content - .into_iter() - .filter_map(|b| b.text) - .collect::>() - .join(""); + let mut content = String::new(); + let mut tool_calls = Vec::new(); + for block in chat_resp.content { + match block.block_type.as_str() { + "text" => { + if let Some(t) = block.text { + content.push_str(&t); + } + } + "tool_use" => { + if let (Some(id), Some(name)) = (block.id, block.name) { + let arguments = block + .input + .map(|v| v.to_string()) + .unwrap_or_else(|| "{}".to_string()); + tool_calls.push(ToolCall { + id, + name, + arguments, + }); + } + } + _ => {} + } + } Ok(LLMResponse { content, @@ -517,8 +689,9 @@ impl LLMClient for AnthropicClient { completion_tokens: chat_resp.usage.output_tokens, total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens, }, - finish_reason: None, - id: None, + tool_calls, + finish_reason: chat_resp.stop_reason, + id: chat_resp.id, created: None, }) } @@ -545,6 +718,10 @@ impl LLMClient for AnthropicClient { system, messages, stream: true, + // Streaming is text-only here: the SSE parser emits text deltas and + // does not reassemble streamed tool-use blocks. + tools: None, + output_config: None, }; let resp = self @@ -707,4 +884,128 @@ mod tests { fn anthropic_unknown_event_ignored() { assert!(parse_anthropic_sse("ping", "{}").is_none()); } + + fn sample_tool() -> ToolDefinition { + ToolDefinition { + name: "get_weather".into(), + description: "Get weather".into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { "location": { "type": "string" } }, + "required": ["location"] + }), + } + } + + #[test] + fn openai_tools_use_function_wrapper() { + let out = openai_tools(&[sample_tool()]); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["type"], "function"); + assert_eq!(out[0]["function"]["name"], "get_weather"); + // input_schema is forwarded verbatim as `parameters`. + assert_eq!(out[0]["function"]["parameters"]["required"][0], "location"); + } + + #[test] + fn openai_response_format_maps_each_variant() { + assert!(openai_response_format(&ResponseFormat::Text).is_none()); + assert_eq!( + openai_response_format(&ResponseFormat::Json).unwrap()["type"], + "json_object" + ); + let schema = serde_json::json!({"type": "object"}); + let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap(); + assert_eq!(js["type"], "json_schema"); + assert_eq!(js["json_schema"]["strict"], true); + } + + #[test] + fn anthropic_tools_use_input_schema_key() { + let out = anthropic_tools(&[sample_tool()]); + assert_eq!(out[0]["name"], "get_weather"); + assert_eq!(out[0]["input_schema"]["type"], "object"); + assert!(out[0].get("function").is_none()); + } + + #[test] + fn anthropic_output_config_only_for_json_schema() { + assert!(anthropic_output_config(&ResponseFormat::Text).is_none()); + assert!(anthropic_output_config(&ResponseFormat::Json).is_none()); + let schema = serde_json::json!({"type": "object"}); + let cfg = anthropic_output_config(&ResponseFormat::JsonSchema { schema }).unwrap(); + assert_eq!(cfg["format"]["type"], "json_schema"); + } + + #[test] + fn openai_request_serializes_tools_and_format() { + let body = OpenAIChatRequest { + model: "gpt-4o".into(), + messages: vec![OpenAIChatMessage { + role: "user".into(), + content: "hi".into(), + }], + temperature: 0.7, + max_tokens: None, + stream: false, + tools: Some(openai_tools(&[sample_tool()])), + response_format: Some(serde_json::json!({ "type": "json_object" })), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["tools"][0]["function"]["name"], "get_weather"); + assert_eq!(json["response_format"]["type"], "json_object"); + // Omitted when None (backward-compatible request shape). + assert!(json.get("max_tokens").is_none()); + } + + #[test] + fn openai_response_parses_tool_calls() { + let raw = r#"{ + "id": "chatcmpl-1", + "created": 1700000000, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_abc", + "type": "function", + "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } + }"#; + let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(resp.id.as_deref(), Some("chatcmpl-1")); + let choice = resp.choices.into_iter().next().unwrap(); + assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls")); + assert!(choice.message.content.is_none()); + assert_eq!(choice.message.tool_calls.len(), 1); + assert_eq!(choice.message.tool_calls[0].function.name, "get_weather"); + } + + #[test] + fn anthropic_response_parses_tool_use_block() { + let raw = r#"{ + "id": "msg_1", + "model": "claude-sonnet-4-6", + "stop_reason": "tool_use", + "content": [ + { "type": "text", "text": "Let me check." }, + { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } } + ], + "usage": { "input_tokens": 12, "output_tokens": 8 } + }"#; + let resp: AnthropicResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(resp.stop_reason.as_deref(), Some("tool_use")); + assert_eq!(resp.content.len(), 2); + assert_eq!(resp.content[0].block_type, "text"); + assert_eq!(resp.content[1].block_type, "tool_use"); + assert_eq!(resp.content[1].name.as_deref(), Some("get_weather")); + assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris"); + } } diff --git a/src/llm/middleware.rs b/src/llm/middleware.rs index 33eb478..aaa31ec 100644 --- a/src/llm/middleware.rs +++ b/src/llm/middleware.rs @@ -107,7 +107,6 @@ impl LLMClient for MiddlewareClient #[cfg(test)] mod tests { use super::*; - use crate::llm::types::TokenUsage; use std::sync::{Arc, Mutex}; /// A middleware that records hook invocations. @@ -144,10 +143,7 @@ mod tests { response: std::sync::Mutex::new(Some(Ok(LLMResponse { content: "hello".into(), model: "mock".into(), - usage: TokenUsage::default(), - finish_reason: None, - id: None, - created: None, + ..Default::default() }))), } } diff --git a/src/llm/retry.rs b/src/llm/retry.rs index c3d3b93..15a4953 100644 --- a/src/llm/retry.rs +++ b/src/llm/retry.rs @@ -261,10 +261,7 @@ mod tests { Ok(LLMResponse { content: "ok".into(), model: "mock".into(), - usage: Default::default(), - finish_reason: None, - id: None, - created: None, + ..Default::default() }) } diff --git a/src/llm/types.rs b/src/llm/types.rs index e630171..86c2485 100644 --- a/src/llm/types.rs +++ b/src/llm/types.rs @@ -258,10 +258,18 @@ pub struct LLMRequest { pub model: Option, /// Desired response format. `None` uses the provider default. /// - /// Note: client-side handling (passing to provider APIs) is planned for v0.5.0. + /// Forwarded to the provider by [`OpenAIClient`](crate::llm::OpenAIClient) + /// (OpenAI `response_format`) and, for [`ResponseFormat::JsonSchema`], by + /// [`AnthropicClient`](crate::llm::AnthropicClient) (Anthropic + /// `output_config.format`). [`ResponseFormat::Json`] without a schema has no + /// native Anthropic equivalent and is a no-op there. #[serde(skip_serializing_if = "Option::is_none")] pub response_format: Option, /// Tool definitions available to the model for this request. + /// + /// Forwarded to both OpenAI (`tools` with `type: "function"`) and Anthropic + /// (`tools` with `input_schema`). Any tool calls the model makes are returned + /// in [`LLMResponse::tool_calls`]. #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, } @@ -424,6 +432,13 @@ pub struct LLMResponse { pub model: String, /// Token usage statistics. pub usage: TokenUsage, + /// Tool calls the model requested this turn. + /// + /// Empty unless the request supplied [`LLMRequest::tools`] and the model + /// chose to call one. Each entry carries the provider-assigned call `id`, + /// tool `name`, and JSON-encoded `arguments`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, /// Reason the generation stopped (e.g. `"stop"`, `"length"`, `"tool_calls"`). #[serde(default, skip_serializing_if = "Option::is_none")] pub finish_reason: Option, diff --git a/src/mcp/http.rs b/src/mcp/http.rs index 426ecae..86daac1 100644 --- a/src/mcp/http.rs +++ b/src/mcp/http.rs @@ -57,6 +57,8 @@ pub async fn serve(server: Arc, addr: SocketAddr) -> std::io::Result< /// JSON-RPC code for "method not found". const ERR_METHOD_NOT_FOUND: i32 = -32601; +/// JSON-RPC code for invalid params (unknown tool / prompt / resource). +const ERR_INVALID_PARAMS: i32 = -32602; /// JSON-RPC code for a tool-execution / internal error. const ERR_INTERNAL: i32 = -32603; /// JSON-RPC code for unauthorized access. @@ -65,17 +67,39 @@ const ERR_UNAUTHORIZED: i32 = -32001; /// Dispatch a single JSON-RPC request against the server (async path). /// /// `tools/call` is awaited via [`McpServer::call_tool_async`]; `initialize`, -/// `tools/list`, and `resources/list` are handled synchronously. Notifications -/// (no `id`) return `None`. +/// `ping`, `tools/list`, `resources/list`, `resources/templates/list`, +/// `prompts/list`, `prompts/get`, and `resources/read` are handled +/// synchronously. Notifications (no `id`) return `None`. async fn dispatch_async(server: &McpServer, req: &Value) -> Option { // Notifications (no id) get no response. let id = req.get("id")?.clone(); let method = req.get("method").and_then(|v| v.as_str()).unwrap_or(""); let result: Result = match method { - "initialize" => Ok(server.initialize_response()), + "initialize" => { + let requested = req + .pointer("/params/protocolVersion") + .and_then(|v| v.as_str()); + Ok(server.initialize_response(requested)) + } + "ping" => Ok(serde_json::json!({})), "tools/list" => Ok(serde_json::json!({ "tools": server.tools() })), "resources/list" => Ok(serde_json::json!({ "resources": server.resources() })), + "resources/templates/list" => Ok(serde_json::json!({ "resourceTemplates": [] })), + "prompts/list" => Ok(serde_json::json!({ "prompts": server.prompts() })), + "prompts/get" => { + let name = req + .pointer("/params/name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let args = req + .pointer("/params/arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + server + .get_prompt(name, args) + .map_err(|e| (ERR_INVALID_PARAMS, e.to_string())) + } "resources/read" => { let uri = req .pointer("/params/uri") @@ -99,15 +123,21 @@ async fn dispatch_async(server: &McpServer, req: &Value) -> Option { .pointer("/params/arguments") .cloned() .unwrap_or(serde_json::json!(null)); - server - .call_tool_async(name, params) - .await - .map(|r| { - serde_json::json!({ - "content": [{ "type": "text", "text": r.to_string() }] - }) - }) - .map_err(|e| (ERR_INTERNAL, e.to_string())) + if !server.has_tool(name) { + Err((ERR_INVALID_PARAMS, format!("Unknown tool: {name}"))) + } else { + // Execution failures are reported in-band with isError: true. + match server.call_tool_async(name, params).await { + Ok(r) => Ok(serde_json::json!({ + "content": [{ "type": "text", "text": r.to_string() }], + "isError": false + })), + Err(e) => Ok(serde_json::json!({ + "content": [{ "type": "text", "text": e.to_string() }], + "isError": true + })), + } + } } _ => Err((ERR_METHOD_NOT_FOUND, format!("Method not found: {method}"))), }; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 93bb2cf..42e026d 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -5,7 +5,7 @@ //! //! ## Quick start //! -//! ```no_run +//! ``` //! use llm_kernel::mcp::{McpServer, ToolDescription, JsonRpcDispatcher}; //! //! let mut server = McpServer::new("my-server", "1.0.0"); @@ -21,9 +21,16 @@ //! }), //! }); //! -//! server.set_handler("greet", |params| { +//! server.set_handler("greet", |_params| { //! Ok(serde_json::json!({ "greeting": "Hello!" })) //! }); +//! +//! // Route a JSON-RPC request through the stdio dispatcher. +//! let dispatcher = JsonRpcDispatcher::new(&server); +//! let response = dispatcher +//! .dispatch(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) +//! .unwrap(); +//! assert!(response.contains("greet")); //! ``` pub mod auth; @@ -32,8 +39,10 @@ pub mod server; pub mod transport; pub use auth::BearerAuth; -pub use schema::{ResourceDescription, ToolDescription}; -pub use server::{AsyncToolHandler, Handler, McpServer}; +pub use schema::{PromptArgument, PromptDescription, ResourceDescription, ToolDescription}; +pub use server::{ + AsyncToolHandler, Handler, LATEST_PROTOCOL_VERSION, McpServer, SUPPORTED_PROTOCOL_VERSIONS, +}; pub use transport::JsonRpcDispatcher; /// HTTP/SSE remote transport for MCP (axum + tokio). diff --git a/src/mcp/schema.rs b/src/mcp/schema.rs index e4152c6..d681cdf 100644 --- a/src/mcp/schema.rs +++ b/src/mcp/schema.rs @@ -1,4 +1,8 @@ -//! Tool and resource schema definitions for MCP. +//! Tool, resource, and prompt schema definitions for MCP. +//! +//! Field names follow the Model Context Protocol wire format (camelCase for +//! `inputSchema` / `mimeType`), so the serialized JSON is what MCP clients +//! expect from `tools/list`, `resources/list`, and `prompts/list`. use serde::{Deserialize, Serialize}; @@ -10,6 +14,9 @@ pub struct ToolDescription { /// Human-readable description of what the tool does. pub description: String, /// JSON Schema describing the tool's input parameters. + /// + /// Serialized as `inputSchema` per the MCP wire format. + #[serde(rename = "inputSchema")] pub input_schema: serde_json::Value, } @@ -24,23 +31,66 @@ pub struct ResourceDescription { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// MIME type (e.g. "text/markdown"). - #[serde(skip_serializing_if = "Option::is_none")] + /// + /// Serialized as `mimeType` per the MCP wire format. + #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] pub mime_type: Option, } +/// A single argument accepted by an MCP prompt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptArgument { + /// Argument name. + pub name: String, + /// Human-readable description of the argument. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Whether the argument must be supplied. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub required: bool, +} + +/// Describes an MCP prompt (a reusable, parameterized message template) that a +/// client can list via `prompts/list` and render via `prompts/get`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptDescription { + /// The prompt name (unique within the server). + pub name: String, + /// Human-readable description of what the prompt is for. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Arguments the prompt accepts (used to fill the template). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub arguments: Vec, +} + #[cfg(test)] mod tests { use super::*; #[test] - fn tool_description_serializes() { + fn tool_description_serializes_camelcase_input_schema() { let tool = ToolDescription { name: "search".into(), description: "Search documents".into(), input_schema: serde_json::json!({"type": "object"}), }; - let json = serde_json::to_string(&tool).unwrap(); - assert!(json.contains("\"search\"")); + let json = serde_json::to_value(&tool).unwrap(); + assert!(json.get("inputSchema").is_some(), "expected camelCase key"); + assert!(json.get("input_schema").is_none()); + } + + #[test] + fn resource_description_serializes_camelcase_mime_type() { + let res = ResourceDescription { + uri: "docs://readme".into(), + name: "README".into(), + description: Some("Project readme".into()), + mime_type: Some("text/markdown".into()), + }; + let json = serde_json::to_value(&res).unwrap(); + assert_eq!(json["mimeType"], "text/markdown"); + assert!(json.get("mime_type").is_none()); } #[test] @@ -54,5 +104,35 @@ mod tests { let json = serde_json::to_string(&res).unwrap(); let back: ResourceDescription = serde_json::from_str(&json).unwrap(); assert_eq!(back.uri, "docs://readme"); + assert_eq!(back.mime_type.as_deref(), Some("text/markdown")); + } + + #[test] + fn prompt_description_serializes() { + let prompt = PromptDescription { + name: "summarize".into(), + description: Some("Summarize a document".into()), + arguments: vec![PromptArgument { + name: "text".into(), + description: Some("The text to summarize".into()), + required: true, + }], + }; + let json = serde_json::to_value(&prompt).unwrap(); + assert_eq!(json["name"], "summarize"); + assert_eq!(json["arguments"][0]["name"], "text"); + assert_eq!(json["arguments"][0]["required"], true); + } + + #[test] + fn prompt_argument_omits_false_required() { + let arg = PromptArgument { + name: "opt".into(), + description: None, + required: false, + }; + let json = serde_json::to_value(&arg).unwrap(); + assert!(json.get("required").is_none(), "false required is omitted"); + assert!(json.get("description").is_none()); } } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 1533ca5..2221ac8 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -7,7 +7,16 @@ use std::sync::Arc; use async_trait::async_trait; use crate::mcp::auth::BearerAuth; -use crate::mcp::schema::{ResourceDescription, ToolDescription}; +use crate::mcp::schema::{PromptDescription, ResourceDescription, ToolDescription}; + +/// MCP protocol versions this server understands, newest first. +/// +/// During `initialize` the server echoes the client's requested version when it +/// appears here, otherwise it falls back to [`LATEST_PROTOCOL_VERSION`]. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +/// The newest MCP protocol version this server implements. +pub const LATEST_PROTOCOL_VERSION: &str = "2025-06-18"; /// Handler function type for MCP tool calls (synchronous). pub type Handler = @@ -40,15 +49,17 @@ where } } -/// An MCP server that manages tools, resources, and dispatches calls. +/// An MCP server that manages tools, resources, prompts, and dispatches calls. pub struct McpServer { server_name: String, server_version: String, tools: Vec, resources: Vec, + prompts: Vec, handlers: HashMap, async_handlers: HashMap>, resource_handlers: HashMap, + prompt_handlers: HashMap, auth: Option, } @@ -60,9 +71,11 @@ impl McpServer { server_version: version.into(), tools: Vec::new(), resources: Vec::new(), + prompts: Vec::new(), handlers: HashMap::new(), async_handlers: HashMap::new(), resource_handlers: HashMap::new(), + prompt_handlers: HashMap::new(), auth: None, } } @@ -179,6 +192,54 @@ impl McpServer { handler(params) } + /// Register a prompt with the server. + pub fn register_prompt(&mut self, prompt: PromptDescription) { + self.prompts.push(prompt); + } + + /// Set the handler for a prompt by name. + /// + /// The handler receives the `prompts/get` arguments object and returns the + /// result value — typically `{ "description": ..., "messages": [...] }`. + pub fn set_prompt_handler( + &mut self, + prompt_name: &str, + handler: impl Fn(serde_json::Value) -> crate::error::Result + + Send + + Sync + + 'static, + ) { + self.prompt_handlers + .insert(prompt_name.to_string(), Box::new(handler)); + } + + /// List all registered prompts. + pub fn prompts(&self) -> &[PromptDescription] { + &self.prompts + } + + /// Render a prompt by name with the given arguments. + pub fn get_prompt( + &self, + name: &str, + params: serde_json::Value, + ) -> crate::error::Result { + let handler = self + .prompt_handlers + .get(name) + .ok_or_else(|| crate::error::KernelError::Config(format!("unknown prompt: {name}")))?; + handler(params) + } + + /// Whether a tool with `name` is registered (has a sync or async handler). + /// + /// Lets a transport distinguish an *unknown tool* (a protocol-level invalid + /// params error) from a tool that ran and *failed* (reported in-band with + /// `isError: true`). + pub fn has_tool(&self, name: &str) -> bool { + self.handlers.contains_key(name) || self.async_handlers.contains_key(name) + } + /// Call a tool by name with the given parameters. pub fn call_tool( &self, @@ -211,14 +272,39 @@ impl McpServer { ))) } - /// Build the `initialize` response. - pub fn initialize_response(&self) -> serde_json::Value { + /// Resolve the protocol version to report in `initialize`. + /// + /// Echoes `requested` when it is one of [`SUPPORTED_PROTOCOL_VERSIONS`]; + /// otherwise returns [`LATEST_PROTOCOL_VERSION`] (per the MCP spec, the + /// server proposes its own latest when it cannot honor the client's). + pub fn negotiate_protocol_version(&self, requested: Option<&str>) -> &'static str { + match requested { + Some(v) => SUPPORTED_PROTOCOL_VERSIONS + .iter() + .find(|&&s| s == v) + .copied() + .unwrap_or(LATEST_PROTOCOL_VERSION), + None => LATEST_PROTOCOL_VERSION, + } + } + + /// Build the `initialize` response, negotiating the protocol version against + /// the client's requested version. + /// + /// The advertised capabilities reflect what the server actually supports: + /// `tools` and `resources` are always present; `prompts` is included only + /// when at least one prompt is registered. + pub fn initialize_response(&self, requested_version: Option<&str>) -> serde_json::Value { + let mut capabilities = serde_json::json!({ + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false }, + }); + if !self.prompts.is_empty() { + capabilities["prompts"] = serde_json::json!({ "listChanged": false }); + } serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": { "listChanged": false }, - "resources": { "subscribe": false, "listChanged": false }, - }, + "protocolVersion": self.negotiate_protocol_version(requested_version), + "capabilities": capabilities, "serverInfo": { "name": self.server_name, "version": self.server_version, @@ -257,9 +343,83 @@ mod tests { #[test] fn initialize_response_shape() { let server = McpServer::new("my-server", "2.0.0"); - let resp = server.initialize_response(); + let resp = server.initialize_response(None); assert_eq!(resp["serverInfo"]["name"], "my-server"); - assert_eq!(resp["protocolVersion"], "2024-11-05"); + assert_eq!(resp["protocolVersion"], LATEST_PROTOCOL_VERSION); + // No prompts registered → no prompts capability advertised. + assert!(resp["capabilities"].get("prompts").is_none()); + } + + #[test] + fn initialize_negotiates_supported_version() { + let server = McpServer::new("s", "1.0"); + // A supported version the client asked for is echoed back. + assert_eq!( + server.initialize_response(Some("2024-11-05"))["protocolVersion"], + "2024-11-05" + ); + // An unsupported version falls back to the server's latest. + assert_eq!( + server.initialize_response(Some("1999-01-01"))["protocolVersion"], + LATEST_PROTOCOL_VERSION + ); + } + + #[test] + fn initialize_advertises_prompts_when_registered() { + let mut server = McpServer::new("s", "1.0"); + server.register_prompt(PromptDescription { + name: "greet".into(), + description: None, + arguments: Vec::new(), + }); + let resp = server.initialize_response(None); + assert!(resp["capabilities"]["prompts"].is_object()); + } + + #[test] + fn register_and_get_prompt() { + let mut server = McpServer::new("s", "1.0"); + server.register_prompt(PromptDescription { + name: "greet".into(), + description: Some("Greet someone".into()), + arguments: vec![crate::mcp::schema::PromptArgument { + name: "name".into(), + description: None, + required: true, + }], + }); + server.set_prompt_handler("greet", |params| { + let who = params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("world"); + Ok(serde_json::json!({ + "messages": [{ + "role": "user", + "content": { "type": "text", "text": format!("Hello, {who}!") } + }] + })) + }); + assert_eq!(server.prompts().len(), 1); + let result = server + .get_prompt("greet", serde_json::json!({ "name": "Ada" })) + .unwrap(); + assert_eq!(result["messages"][0]["content"]["text"], "Hello, Ada!"); + } + + #[test] + fn unknown_prompt_returns_error() { + let server = McpServer::new("s", "1.0"); + assert!(server.get_prompt("missing", serde_json::json!({})).is_err()); + } + + #[test] + fn has_tool_reports_registration() { + let mut server = McpServer::new("s", "1.0"); + server.set_handler("echo", |p| Ok(p)); + assert!(server.has_tool("echo")); + assert!(!server.has_tool("nope")); } #[test] diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index ea47478..6014b35 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -29,10 +29,12 @@ impl<'a> JsonRpcDispatcher<'a> { ) -> Option { let provided = auth_header.unwrap_or(""); if !self.server.check_auth(provided) { - // Return an error for every request id we can parse, else a fixed-id error. + // Echo back the request id (string or number) when we can parse one, + // else a null id per JSON-RPC. let id = serde_json::from_str::(request.trim()) .ok() - .and_then(|v| v.get("id").and_then(|id| id.as_i64())); + .and_then(|v| v.get("id").cloned()) + .unwrap_or(serde_json::Value::Null); return Some(self.error_response(id, -32001, "Unauthorized")); } self.dispatch(request) @@ -46,7 +48,11 @@ impl<'a> JsonRpcDispatcher<'a> { let reqs: Vec = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { - return Some(self.error_response(None, -32700, &format!("Parse error: {e}"))); + return Some(self.error_response( + serde_json::Value::Null, + -32700, + &format!("Parse error: {e}"), + )); } }; let responses: Vec = reqs @@ -62,7 +68,11 @@ impl<'a> JsonRpcDispatcher<'a> { let req: serde_json::Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { - return Some(self.error_response(None, -32700, &format!("Parse error: {e}"))); + return Some(self.error_response( + serde_json::Value::Null, + -32700, + &format!("Parse error: {e}"), + )); } }; self.dispatch_single(&req) @@ -71,21 +81,36 @@ impl<'a> JsonRpcDispatcher<'a> { /// Dispatch a single pre-parsed JSON-RPC request. fn dispatch_single(&self, req: &serde_json::Value) -> Option { - // Notifications (no id) don't get responses + // Notifications (the `id` member is absent) don't get responses. This is + // distinct from a null id, which is a request that must be answered. req.get("id")?; - - let id = req.get("id").and_then(|v| v.as_i64()); + // Preserve the id verbatim (JSON-RPC ids may be a string or a number). + let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null); let method = req.get("method").and_then(|v| v.as_str()).unwrap_or(""); let result = match method { - "initialize" => Ok(self.server.initialize_response()), + "initialize" => { + let requested = req + .get("params") + .and_then(|p| p.get("protocolVersion")) + .and_then(|v| v.as_str()); + Ok(self.server.initialize_response(requested)) + } + "ping" => Ok(serde_json::json!({})), "tools/list" => Ok(serde_json::json!({ "tools": self.server.tools() })), "resources/list" => Ok(serde_json::json!({ "resources": self.server.resources() })), - "tools/call" => self.handle_tool_call(req), + "resources/templates/list" => Ok(serde_json::json!({ + "resourceTemplates": [] + })), + "prompts/list" => Ok(serde_json::json!({ + "prompts": self.server.prompts() + })), + "prompts/get" => self.handle_prompt_get(req), + "tools/call" => return Some(self.handle_tool_call(&id, req)), "resources/read" => self.handle_resource_read(req), _ => Err((-32601, format!("Method not found: {method}"))), }; @@ -115,10 +140,13 @@ impl<'a> JsonRpcDispatcher<'a> { Ok(()) } - fn handle_tool_call( - &self, - req: &serde_json::Value, - ) -> std::result::Result { + /// Handle `tools/call`. Returns a full JSON-RPC response string. + /// + /// An **unknown tool** is a protocol error (`-32602`, invalid params). A + /// tool that runs and **fails** is reported in-band as a successful result + /// with `isError: true`, per the MCP spec — so the model sees the error and + /// can adapt rather than the whole request failing at the transport layer. + fn handle_tool_call(&self, id: &serde_json::Value, req: &serde_json::Value) -> String { let tool_name = req .get("params") .and_then(|p| p.get("name")) @@ -131,17 +159,47 @@ impl<'a> JsonRpcDispatcher<'a> { .cloned() .unwrap_or(serde_json::json!(null)); - self.server - .call_tool(tool_name, params) - .map(|result| { + if !self.server.has_tool(tool_name) { + return self.error_response(id.clone(), -32602, &format!("Unknown tool: {tool_name}")); + } + + match self.server.call_tool(tool_name, params) { + Ok(result) => self.success_response( + id.clone(), serde_json::json!({ - "content": [{ - "type": "text", - "text": result.to_string() - }] - }) - }) - .map_err(|e| (-32603, e.to_string())) + "content": [{ "type": "text", "text": result.to_string() }], + "isError": false + }), + ), + Err(e) => self.success_response( + id.clone(), + serde_json::json!({ + "content": [{ "type": "text", "text": e.to_string() }], + "isError": true + }), + ), + } + } + + /// Handle `prompts/get`: render a registered prompt with the given + /// arguments. An unknown prompt is an invalid-params error. + fn handle_prompt_get( + &self, + req: &serde_json::Value, + ) -> std::result::Result { + let name = req + .get("params") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or(""); + let args = req + .get("params") + .and_then(|p| p.get("arguments")) + .cloned() + .unwrap_or(serde_json::json!({})); + self.server + .get_prompt(name, args) + .map_err(|e| (-32602, e.to_string())) } fn handle_resource_read( @@ -166,29 +224,25 @@ impl<'a> JsonRpcDispatcher<'a> { .map_err(|e| (-32603, e.to_string())) } - fn success_response(&self, id: Option, result: serde_json::Value) -> String { - let mut resp = serde_json::json!({ + fn success_response(&self, id: serde_json::Value, result: serde_json::Value) -> String { + serde_json::to_string(&serde_json::json!({ "jsonrpc": "2.0", + "id": id, "result": result, - }); - if let Some(id) = id { - resp["id"] = serde_json::json!(id); - } - serde_json::to_string(&resp).unwrap_or_default() + })) + .unwrap_or_default() } - fn error_response(&self, id: Option, code: i32, message: &str) -> String { - let mut resp = serde_json::json!({ + fn error_response(&self, id: serde_json::Value, code: i32, message: &str) -> String { + serde_json::to_string(&serde_json::json!({ "jsonrpc": "2.0", + "id": id, "error": { "code": code, "message": message, } - }); - if let Some(id) = id { - resp["id"] = serde_json::json!(id); - } - serde_json::to_string(&resp).unwrap_or_default() + })) + .unwrap_or_default() } } @@ -262,13 +316,132 @@ mod tests { } #[test] - fn dispatch_unknown_tool() { + fn dispatch_unknown_tool_is_invalid_params() { + // An unknown tool is a protocol error (-32602), not an in-band failure. let server = test_server(); let dispatcher = JsonRpcDispatcher::new(&server); let req = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"missing","arguments":{}}}"#; let resp = dispatcher.dispatch(req).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap(); - assert_eq!(parsed["error"]["code"], -32603); + assert_eq!(parsed["error"]["code"], -32602); + } + + #[test] + fn dispatch_ping_returns_empty_result() { + let server = test_server(); + let dispatcher = JsonRpcDispatcher::new(&server); + let resp = dispatcher + .dispatch(r#"{"jsonrpc":"2.0","id":9,"method":"ping"}"#) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!(parsed["id"], 9); + assert!(parsed["result"].is_object()); + assert_eq!(parsed["result"].as_object().unwrap().len(), 0); + } + + #[test] + fn dispatch_preserves_string_id() { + let server = test_server(); + let dispatcher = JsonRpcDispatcher::new(&server); + let resp = dispatcher + .dispatch(r#"{"jsonrpc":"2.0","id":"req-abc","method":"tools/list"}"#) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!(parsed["id"], "req-abc"); + } + + #[test] + fn initialize_echoes_client_protocol_version() { + let server = test_server(); + let dispatcher = JsonRpcDispatcher::new(&server); + let resp = dispatcher + .dispatch( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}"#, + ) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!(parsed["result"]["protocolVersion"], "2024-11-05"); + } + + #[test] + fn tool_execution_error_reported_in_band() { + // A registered tool whose handler fails → result with isError: true, + // NOT a JSON-RPC error object. + let mut server = McpServer::new("t", "1.0"); + server.register_tool(ToolDescription { + name: "boom".into(), + description: "always fails".into(), + input_schema: serde_json::json!({"type": "object"}), + }); + server.set_handler("boom", |_| { + Err(crate::error::KernelError::Config("kaboom".into())) + }); + let dispatcher = JsonRpcDispatcher::new(&server); + let req = r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"boom","arguments":{}}}"#; + let resp = dispatcher.dispatch(req).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert!( + parsed.get("error").is_none(), + "should not be a protocol error" + ); + assert_eq!(parsed["result"]["isError"], true); + assert!( + parsed["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("kaboom") + ); + } + + #[test] + fn dispatch_prompts_list_and_get() { + let mut server = McpServer::new("t", "1.0"); + server.register_prompt(crate::mcp::schema::PromptDescription { + name: "greet".into(), + description: Some("Greet".into()), + arguments: Vec::new(), + }); + server.set_prompt_handler("greet", |_| { + Ok(serde_json::json!({ + "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }] + })) + }); + let dispatcher = JsonRpcDispatcher::new(&server); + + let list = dispatcher + .dispatch(r#"{"jsonrpc":"2.0","id":1,"method":"prompts/list"}"#) + .unwrap(); + let list: serde_json::Value = serde_json::from_str(&list).unwrap(); + assert_eq!(list["result"]["prompts"][0]["name"], "greet"); + + let got = dispatcher + .dispatch(r#"{"jsonrpc":"2.0","id":2,"method":"prompts/get","params":{"name":"greet","arguments":{}}}"#) + .unwrap(); + let got: serde_json::Value = serde_json::from_str(&got).unwrap(); + assert_eq!(got["result"]["messages"][0]["content"]["text"], "hi"); + } + + #[test] + fn dispatch_resource_templates_list_is_empty() { + let server = test_server(); + let dispatcher = JsonRpcDispatcher::new(&server); + let resp = dispatcher + .dispatch(r#"{"jsonrpc":"2.0","id":1,"method":"resources/templates/list"}"#) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert!(parsed["result"]["resourceTemplates"].is_array()); + } + + #[test] + fn notification_without_id_gets_no_response() { + let server = test_server(); + let dispatcher = JsonRpcDispatcher::new(&server); + // `notifications/initialized` is a notification (no id) → no response. + assert!( + dispatcher + .dispatch(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .is_none() + ); } #[test] diff --git a/src/provider/sync.rs b/src/provider/sync.rs index a9a8974..4574396 100644 --- a/src/provider/sync.rs +++ b/src/provider/sync.rs @@ -12,6 +12,7 @@ //! `merge_catalog` is pure (no I/O) so it is unit-testable without network. use crate::discovery::ModelsDevPayload; +use crate::error::Result; use crate::provider::mapping::{self, Mapping}; use crate::provider::{ModelDescriptor, ServiceDescriptor}; use serde::{Deserialize, Serialize}; @@ -20,7 +21,7 @@ use serde::{Deserialize, Serialize}; /// /// **Trust boundary:** `api_url` is forwarded verbatim to [`crate::discovery::fetch_from`] /// — pass only admin-configured values. -pub fn fetch_models_dev(api_url: Option<&str>) -> anyhow::Result { +pub fn fetch_models_dev(api_url: Option<&str>) -> Result { match api_url { Some(url) => crate::discovery::fetch_from(url), None => crate::discovery::fetch(), @@ -34,14 +35,14 @@ struct Envelope { } /// Parse a `catalog.json` document into its provider list. -pub fn parse_catalog(json: &str) -> anyhow::Result> { +pub fn parse_catalog(json: &str) -> Result> { let env: Envelope = serde_json::from_str(json)?; Ok(env.providers) } /// Serialize a provider list back into the canonical `catalog.json` form /// (pretty-printed, 2-space indent, trailing newline). -pub fn serialize_catalog(providers: &[ServiceDescriptor]) -> anyhow::Result { +pub fn serialize_catalog(providers: &[ServiceDescriptor]) -> Result { let env = Envelope { providers: providers.to_vec(), }; @@ -143,7 +144,7 @@ impl std::fmt::Display for CatalogDiff { pub fn merge_catalog( current: &[ServiceDescriptor], upstream: &ModelsDevPayload, -) -> anyhow::Result<(Vec, CatalogDiff)> { +) -> Result<(Vec, CatalogDiff)> { let mut diff = CatalogDiff { providers_seen: current.len(), ..CatalogDiff::default() diff --git a/src/search/federation.rs b/src/search/federation.rs index fcc6651..5019edc 100644 --- a/src/search/federation.rs +++ b/src/search/federation.rs @@ -351,10 +351,10 @@ mod async_tests { use std::sync::Arc; use std::time::Duration; - use anyhow::{Result, anyhow}; use async_trait::async_trait; use crate::embedding::{AsyncVectorIndex, SearchHit}; + use crate::error::{KernelError, Result}; use crate::search::federation::{FederatedSearch, FusionStrategy}; /// Configurable stub backend: returns canned hits, optionally fails, or @@ -379,7 +379,7 @@ mod async_tests { tokio::time::sleep(d).await; } if self.fail { - return Err(anyhow!("stub backend failure")); + return Err(KernelError::Embedding("stub backend failure".into())); } Ok(self.hits.clone()) }