diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..ed9452d9 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "haiku-rag", + "interface": { + "displayName": "haiku.rag" + }, + "plugins": [ + { + "name": "haiku-rag", + "source": { + "source": "local", + "path": "./plugins/haiku-rag" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..93de1bc0 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "haiku-rag", + "description": "The haiku.rag knowledge base as Claude Code tools and a skill.", + "owner": { + "name": "Yiorgis Gozadinos" + }, + "plugins": [ + { + "name": "haiku-rag", + "source": "./plugins/haiku-rag", + "description": "Search, read and analyze your haiku.rag knowledge base from Claude Code." + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8834d6de..d858aa1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,31 @@ ## [Unreleased] +### Added + +- Claude Code and Codex plugin under `plugins/haiku-rag/`: two client manifests + sharing the server configuration and the `haiku-rag` Agent Skill. +- MCP tool `execute_code(code, filter, sources)`: runs a program in the + analysis sandbox over the selected documents and returns what it printed; + one sandbox per call. +- In the analysis sandbox, `search()` results carry `chunk_meta`, + `list_documents()` rows and `metadata.json` carry the document `metadata`, + and `/documents/{id}/chunks.jsonl` lists chunk ids with their metadata. + `recovery_hint` in `haiku.rag.sandbox`. +- MCP tools `get_document_outline` (heading tree with page numbers) and + `get_document_section` (one section's text, subsections included), built + on `document_items`. `build_toc` in `haiku.rag.context`. +- MCP server `instructions`, `version`, and read-only `ToolAnnotations` on + every tool; every parameter carries a description. `filter` on + `search_documents` and `search_documents_by_image`. `DocumentInfo.metadata`. + ### Changed +- `pydantic-monty>=0.0.23`. The analysis sandbox gains `collections`, + `itertools`, `functools`, `dataclasses`, function decorators and + `str.format`. +- `fastmcp>=4.0.2,<5.0.0`, on MCP Python SDK 2. The MCP server answers both the + session-based and the sessionless (2026-07-28) protocol. - Default models are `ollama:qwen3.8`: `ModelConfig`, `qa.model`, `processing.title_model` (was `ollama:gpt-oss`) and `processing.conversion_options.picture_description.model` (was @@ -16,6 +39,35 @@ - `processing.conversion_options.picture_description.model` defaults to `enable_thinking: false`, and the field now reaches the VLM: docling's picture-description request carries `reasoning_effort` in `params`. +- MCP `search_documents` and `search_documents_by_image` expand results to + their section (`HaikuRAG.expand_context`) and return the agent rendering + as text (rank, `Document ID`, `Collection` over several databases, title, + headings, the matched chunk's metadata, passage) and pictures as + `ImageContent` blocks, with no structured content. + `SearchResult.format_for_agent(include_document_id=, include_chunk_meta=)`; + `collect_pictures` in `haiku.rag.tools.search`. +- MCP tools raise on failure, with the error's message; an empty result no + longer doubles as an error. +- `haiku-rag mcp` covers the configured `lancedb.databases` set. `sources` on + `search_documents`, `search_documents_by_image` and `execute_code`; `source` + on `get_document`; an unknown name is a tool error. `DocumentInfo.source`. + +### Fixed + +- `toc.json` `item_range` in the analysis sandbox is a line slice into + `items.jsonl`, as documented; it held item positions. +- Past `analysis.code_timeout` a sandbox program starts no further host call. + Files served from memory and in-code `search()` / `list_documents()` were + not checked against the deadline. + +### Removed + +- MCP tools `ask_question` and `analyze`. +- `format_citations` in `haiku.rag.utils`; `format_citations_rich` stays. +- MCP write tools `add_document_from_file`, `add_document_from_url`, + `add_document_from_text` and `delete_document`. The server opens the + database read-only; ingest with `haiku-rag add`, `add-src`, `delete` or + `haiku-ingester`. `create_mcp_server` loses `read_only`. ## [0.82.1] - 2026-09-03 diff --git a/README.md b/README.md index 3829c502..5970c003 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.pydantic.dev/ - **Hybrid search** — Vector + full-text with Reciprocal Rank Fusion - **Multimodal & cross-modal search** — Multimodal embedders (vLLM, VoyageAI, Cohere) put picture vectors in the same space as text; supports text-as-query → figure hits and image-as-query - **Question answering** — RAG capability with citations (page numbers, section headings) -- **Vision QA** — Vision-capable models receive figure bytes alongside chunk text; attach your own images to questions in `ask`, `analyze`, MCP, and the chat TUI +- **Vision QA** — Vision-capable models receive figure bytes alongside chunk text; attach your own images to questions in `ask`, `analyze` and the chat TUI - **Reranking** — local cross-encoders, Cohere, Zero Entropy, or vLLM - **Analysis capability** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis) - **Evidence compaction** — Optional capability that replaces earlier questions' search results on the request with the evidence they cited, so long conversations stop resending everything they retrieved @@ -110,12 +110,26 @@ For direct agent composition, see the [capabilities documentation](https://ggoza ## MCP Server -Use with AI assistants like Claude Desktop: +Use with AI assistants like Claude Code, Codex, and Claude Desktop: ```bash haiku-rag mcp --stdio ``` +In Claude Code, install the plugin, which registers the server and a skill: + +```bash +claude plugin marketplace add ggozad/haiku.rag +claude plugin install haiku-rag +``` + +In Codex, install the same plugin from its marketplace: + +```bash +codex plugin marketplace add ggozad/haiku.rag +codex plugin add haiku-rag@haiku-rag +``` + Add to your Claude Desktop configuration: ```json @@ -129,7 +143,7 @@ Add to your Claude Desktop configuration: } ``` -Provides tools for document management, search, QA, and analysis directly in your AI assistant. +Provides search, document reading, and analysis tools directly in your AI assistant. ## Examples diff --git a/docker/Dockerfile b/docker/Dockerfile index 51e4f336..fa5713d1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -40,4 +40,4 @@ EXPOSE 8001 8765 # Default command: read-only MCP server. The companion ingester service is # launched via docker-compose against the same image. -CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"] +CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"] diff --git a/docker/Dockerfile.slim b/docker/Dockerfile.slim index 529cb6c0..72844915 100644 --- a/docker/Dockerfile.slim +++ b/docker/Dockerfile.slim @@ -39,4 +39,4 @@ EXPOSE 8001 8765 # Default command: read-only MCP server. The companion ingester service is # launched via docker-compose against the same image. -CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"] +CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"] diff --git a/docs/capabilities/analysis.md b/docs/capabilities/analysis.md index b3a58138..18cd12bd 100644 --- a/docs/capabilities/analysis.md +++ b/docs/capabilities/analysis.md @@ -16,7 +16,7 @@ When `qa.max_searches` or `analysis.max_executions` runs out, the exhausted tool | `analysis_execute_code(code)` | Run Python against the virtual document filesystem. | | `analysis_cite(chunk_ids)` | Register retrieved or filesystem-derived chunk IDs. | -The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, and `toc.json`. +The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, `chunks.jsonl` (chunk ids with their metadata) and `toc.json`. In code, `await search()` results carry `chunk_meta` and `await list_documents()` rows carry `metadata`. The interpreter's limits and the per-call budgets are listed under [MCP, Code](../mcp.md#code). ## Compose an agent diff --git a/docs/cli.md b/docs/cli.md index f9baf5d6..5e0efae8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ The `haiku-rag` CLI provides complete document management functionality. haiku-rag add -h ``` - With `lancedb.databases` configured, `search`, `ask`, `analyze`, and `chat` use the full set by default. Select one database for other commands with `--db-name` or `--db`. `settings`, `init-config`, and `download-models` do not open a database. See [Multiple Databases](configuration/storage.md#multiple-databases). + With `lancedb.databases` configured, `search`, `ask`, `analyze`, `chat`, and `mcp` use the full set by default. Select one database for other commands with `--db-name` or `--db`. `settings`, `init-config`, and `download-models` do not open a database. See [Multiple Databases](configuration/storage.md#multiple-databases). ## Document Management @@ -477,9 +477,6 @@ haiku-rag mcp --port 9000 # Bind to all interfaces (containers, trusted LAN) haiku-rag mcp --host 0.0.0.0 - -# Read-only mode (no write tools) -haiku-rag --read-only mcp ``` See [MCP](mcp.md) for details. For continuous document ingestion diff --git a/docs/configuration/qa.md b/docs/configuration/qa.md index 7b059bd8..bee12f8f 100644 --- a/docs/configuration/qa.md +++ b/docs/configuration/qa.md @@ -20,7 +20,7 @@ Context expansion is automatic and section-aware. For structured documents (with ## Question Answering Configuration -Configure the RAG capability (used by `client.ask`, `haiku-rag ask`, and the MCP `ask_question` tool): +Configure the RAG capability (used by `client.ask` and `haiku-rag ask`): ```yaml qa: @@ -50,13 +50,13 @@ analysis: provider: anthropic name: claude-sonnet-4-20250514 temperature: 0.0 # Default: 0.0 (deterministic for code generation) - code_timeout: 60.0 # Max seconds a call may spend reading documents + code_timeout: 60.0 # Per call: compute stops, no read or search starts past it max_output_chars: 50000 # Truncate output after this many chars max_executions: 15 # Max execute_code calls per question ``` - **model**: LLM configuration (see [Providers](providers.md#model-settings)). When unset, falls back to `qa.model`. -- **code_timeout**: Seconds a single `execute_code` call may spend reading documents (default: 60). The sandbox refuses further reads past this point. Code that computes without reading is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question. +- **code_timeout**: Seconds a single `execute_code` call has (default: 60). Past it the sandbox starts no further host call, a document read or an in-code `search()` / `list_documents()`; one already running finishes. Code that computes without host calls is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question. - **max_output_chars**: Truncate code output after this many characters (default: 50000) - **max_executions**: Maximum `execute_code` calls per question before the capability is told to answer from what it has (default: 15) diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index fd35edb3..fb0057f9 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -196,7 +196,7 @@ writing process per database URI, any number of read-only consumers. The recommended layout for production is "different buckets, same account, separate IAM roles per process": - **Ingestion process** — IAM role with `s3:Get/List` on the documents bucket and `s3:Get/Put/Delete` on the LanceDB bucket. Runs `haiku-ingester serve` (with `ingester.sources[type=s3]` pointing at the documents bucket). Exactly one such process per LanceDB URI. -- **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag --read-only mcp`, the chat TUI, etc. They never see the documents bucket. +- **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag mcp`, the chat TUI, etc. They never see the documents bucket. Each process picks up its own credentials from the AWS default chain (env vars, IAM instance role, AWS profile), so no credentials are hard-coded in the configuration files. @@ -281,9 +281,9 @@ Conversion, chunking, and title generation do not access a database and remain a Commands use database sets as follows: -- **Set-capable**: `search`, `ask`, `analyze`, and `chat` use the full configured set, or the single database selected by `--db-name`. +- **Set-capable**: `search`, `ask`, `analyze`, `chat`, and `mcp` use the full configured set, or the single database selected by `--db-name`. - **Config-only**: `settings`, `init-config`, and `download-models` do not open a database. -- **Single-database**: everything else — document writes, `rebuild`, `vacuum`, `migrate`, `init`, `info`, `history`, `tag`, `doctor`, `list`, `inspect`, `visualize`, and `mcp` — works on one database, selected with the global `--db-name` option. +- **Single-database**: everything else — document writes, `rebuild`, `vacuum`, `migrate`, `init`, `info`, `history`, `tag`, `doctor`, `list`, `inspect`, and `visualize` — works on one database, selected with the global `--db-name` option. ```bash haiku-rag search "query" # every configured database diff --git a/docs/mcp.md b/docs/mcp.md index 06403bb3..35212650 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -19,15 +19,71 @@ haiku-rag mcp --host 0.0.0.0 --port 8001 # stdio transport (for Claude Desktop) haiku-rag mcp --stdio -# Read-only mode (excludes write tools) -haiku-rag --read-only mcp --stdio ``` `--host` defaults to `127.0.0.1` (loopback only). Bind to `0.0.0.0` only when you want the MCP server reachable from outside the local machine — e.g. inside a Docker container with port mapping, or on a trusted LAN. -**Read-only mode:** When `--read-only` is specified, write tools (`add_document_from_file`, `add_document_from_url`, `add_document_from_text`, `delete_document`) are not registered. Only search and query tools remain available. +The server opens the database read-only. Ingestion goes through the CLI +(`haiku-rag add`, `add-src`, `delete`) or [`haiku-ingester`](ingester.md). + +## Collections + +With several databases in `lancedb.databases`, the server covers all of +them, as `haiku-rag search` does. Results and documents name theirs in +`source`. `sources` on `search_documents`, `search_documents_by_image` +and `execute_code` restricts a call to a subset; `source` on `get_document` names the database holding the +document. A name the server does not cover is an error. +`haiku-rag --db-name NAME mcp` serves one. See +[Multiple Databases](configuration/storage.md#multiple-databases). + +## Claude Code + +The repository ships a plugin that registers the server and a skill telling +Claude when and how to use it: + +```bash +claude plugin marketplace add ggozad/haiku.rag +claude plugin install haiku-rag +``` + +The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH +and the configuration decides the database. The skill pre-approves every tool +and is also invocable as `/haiku-rag`. To register the server without the +plugin: + +```bash +claude mcp add haiku-rag -- haiku-rag mcp --stdio +``` + +The skill works with that registration too: copy `plugins/haiku-rag/skills/haiku-rag` +into `~/.claude/skills/` and change the tool prefix in its `allowed-tools` from +`mcp__plugin_haiku-rag_haiku-rag__` to `mcp__haiku-rag__`. + +## Codex + +The repository's Codex plugin registers the server and installs the same Agent +Skill: + +```bash +codex plugin marketplace add ggozad/haiku.rag +codex plugin add haiku-rag@haiku-rag +``` + +The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH. +Invoke the skill as `$haiku-rag`. Codex can also select it automatically from +its description. To register the server without the plugin: + +```bash +codex mcp add haiku-rag -- haiku-rag mcp --stdio +``` + +The skill works with that registration too: copy +`plugins/haiku-rag/skills/haiku-rag` into `~/.agents/skills/`. +The `allowed-tools` field supplies Claude Code's tool pre-approval and may be +ignored by other Agent Skills clients. Codex configures MCP tool approvals +separately in `config.toml`. ## Claude Desktop Integration @@ -57,63 +113,95 @@ With a custom database path: } ``` -After restarting Claude Desktop, you can ask Claude to search your documents, add new content, or answer questions using your knowledge base. +After restarting Claude Desktop, you can ask Claude to search your documents or answer questions using your knowledge base. -## Available Tools +## Tools -### Document Management +Every tool is read-only and says so in its annotations. Each parameter carries +a description in the tool schema, so the listing below names them without +repeating it. -- **`add_document_from_file`** - Add documents from local file paths - - `file_path` (required): Path to the file - - `metadata` (optional): Key-value metadata - - `title` (optional): Human-readable title +| Tool | Registered | Parameters | +|---|---|---| +| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` | +| `search_documents_by_image` | multimodal embedder only | `image_base64`, `limit`, `include_images`, `filter`, `sources` | +| `get_document` | always | `document_id`, `source` | +| `get_document_outline` | always | `document_id`, `source` | +| `get_document_section` | always | `document_id`, `section_id`, `source` | +| `list_documents` | always | `limit`, `offset`, `filter` | +| `execute_code` | always | `code`, `filter`, `sources` | -- **`add_document_from_url`** - Add documents from URLs - - `url` (required): URL to fetch - - `metadata` (optional): Key-value metadata - - `title` (optional): Human-readable title +`search_documents` runs hybrid search, vector and full-text. Its text content +is the rendering the in-process agents read: results best first, each with its +rank, `Document ID`, `Collection` when the server covers several, the document +title, section headings, the matched chunk's metadata when it has any, and the +passage expanded to its section the way the agents get it +(`search.max_context_chars` caps it). Pictures in the results follow as +image blocks, one per distinct picture, each preceded by a line naming its +result; `include_images: false` leaves them out. Search results carry no +structured content, so every client shows the model the same text and +images. Scores are not comparable across +queries or search types, so rank is the signal. `search_documents_by_image` +embeds the query image and searches by vector similarity alone. -- **`add_document_from_text`** - Add documents from raw text content - - `content` (required): Text content - - `uri` (optional): URI identifier - - `metadata` (optional): Key-value metadata - - `title` (optional): Human-readable title +`get_document` returns a document whole, in reading order. For a long one, +`get_document_outline` returns the heading tree with page numbers and +`get_document_section` the text of one section, subsections included; a +node's `id` in the outline is the `section_id`. A document without headings +has an empty outline. `list_documents` returns titles, URIs and metadata, +which is how a client learns what a filter can match. -- **`get_document`** - Retrieve a document by ID - - `document_id` (required): The document ID +### Code -- **`list_documents`** - List documents with pagination and filtering - - `limit` (optional): Maximum number to return - - `offset` (optional): Number to skip - - `filter` (optional): SQL WHERE clause for filtering +`execute_code` runs a Python program in the sandbox of the +[analysis capability](capabilities/analysis.md), over the documents `filter` +and `sources` select, and returns what it printed. The program reads +`/documents/{document_id}/` (`metadata.json`, `content.txt`, `items.jsonl`, +`chunks.jsonl`, `toc.json`) and can `await search()` and +`await list_documents()`; the tool description spells out the fields and the +patterns that matter. Each call is one program: nothing carries over between +calls, and the sandbox is created and closed per call. A failing program is a +tool error carrying the interpreter's message and any output printed before +it. No model runs on the server. Claude Code moves a call still running after +about two minutes to a background task. -- **`delete_document`** - Delete a document by ID - - `document_id` (required): The document ID +The interpreter is [Monty](https://github.com/pydantic/monty), a Python subset. +Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`, +`collections`, `itertools`, `functools` and `dataclasses`. Absent, and often +reached for: `decimal` and `statistics`. No generator functions, class +inheritance or `match` statements, and a file object cannot be iterated. Files are read-only, and +there is no network and no filesystem +beyond `/documents`. `analysis.code_timeout` is the call's budget: compute is +stopped at it, and past it no further host call starts, a file read or an +in-code search alike, though one already running finishes. +`analysis.max_output_chars` bounds the output. -### Search +### Filters -- **`search_documents`** - Search using hybrid search (vector + full-text) - - `query` (required): Search query - - `limit` (optional): Maximum results (uses config default if not specified) - - `include_images` (optional, default `true`): Attach base64-encoded picture bytes to picture-labeled results +`filter` is a SQL WHERE clause over the document columns `id`, `uri`, `title`, +`metadata`, `created_at`, `updated_at`. `metadata` is a JSON string, so match +its keys with LIKE: -- **`search_documents_by_image`** - Search using an image as the query (registered only when the configured embedder supports images) - - `image_base64` (required): Base64-encoded image (PNG/JPEG bytes) - - `limit` (optional): Maximum results - - `include_images` (optional, default `true`) +```sql +metadata LIKE '%"author": "Smith"%' +uri LIKE '%.pdf' +title = 'Q3 report' +``` -### Question Answering +### Errors -- **`ask_question`** - Ask questions about your documents - - `question` (required): The question to ask - - `cite` (optional): Include source citations (default: false) - - `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable QA model) +A failure is an MCP error carrying its message, never an empty result: a +document or section id that matches nothing, a collection the server does not +cover, a filter the query engine rejects, invalid base64, a program that fails +in `execute_code` with the error it hit, and anything unexpected with its own +message. -- **`analyze`** - Answer complex analytical questions via code execution - - `question` (required): The question to answer - - `filter` (optional): SQL WHERE clause to restrict document access - - `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable analysis model) - - Best for aggregation, computation, and multi-document analysis +### Instructions + +The server publishes `instructions` describing the knowledge base: what it +holds, when to reach for it, the collection names when it covers several, and +`prompts.domain_preamble` when set. Claude Code and Codex show them to the +model. Claude Desktop does not, so every tool description stands on its own. ## Continuous ingestion diff --git a/examples/docker/docker-compose.yml b/examples/docker/docker-compose.yml index a889bf7d..45f068ac 100644 --- a/examples/docker/docker-compose.yml +++ b/examples/docker/docker-compose.yml @@ -103,7 +103,6 @@ services: "haiku-rag", "--config", "/app/haiku.rag.yaml", - "--read-only", "mcp", "--host", "0.0.0.0", diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 0c71ec47..8bc934ae 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -934,7 +934,7 @@ class HaikuRAGApp: # The resolved scope: a path overrides a configured URI, and a derived # single-database configuration drops the name results and citations # carry. - server = _mcp_server_covering(self.scope, self.config, self.read_only) + server = _mcp_server_covering(self.scope, self.config) try: if transport == "stdio": await server.run_stdio_async() diff --git a/haiku_rag_slim/haiku/rag/capabilities/analysis.py b/haiku_rag_slim/haiku/rag/capabilities/analysis.py index fb88a081..2dafbbe8 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/analysis.py +++ b/haiku_rag_slim/haiku/rag/capabilities/analysis.py @@ -19,7 +19,7 @@ from haiku.rag.capabilities._base import ( ) from haiku.rag.capabilities._tools import merge_results from haiku.rag.config.models import AppConfig -from haiku.rag.sandbox import AnalysisContext, Sandbox +from haiku.rag.sandbox import AnalysisContext, Sandbox, recovery_hint STATE_NAMESPACE = "analysis" _CAPABILITY_ID = "haiku-rag-analysis" @@ -49,21 +49,6 @@ def multiple_collections_instructions() -> str: return _multiple_collections_path.read_text().rstrip() -def _recovery_hint(stderr: str) -> str: - """Name the workaround for sandbox limits models trip over repeatedly. - - The instructions already say file objects are not iterable, and models write - ``for line in open(...)`` regardless. Carrying the fix in the error gives - them something to act on for the retry. - """ - if "TextIOWrapper" in stderr and "not iterable" in stderr: - return ( - "\n\nHint: file objects cannot be iterated here. Read lines with " - '.readlines() or .read().split("\\n").' - ) - return "" - - @dataclass class AnalysisCapability(RAGCapabilityBase[AnalysisState]): """Deferred capability for sandboxed computation over a RAG corpus.""" @@ -139,7 +124,7 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]): ) if not result.success: raise ToolFailed( - f"{result.stderr}{_recovery_hint(result.stderr)}" + f"{result.stderr}{recovery_hint(result.stderr)}" f"\n\nOutput: {result.stdout}" ) return result.stdout or "No output." diff --git a/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md b/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md index cd0a6bea..808edf59 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md +++ b/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md @@ -13,11 +13,11 @@ You can mix the two. The rule: always call `analysis_cite` before answering — Execute Python code in a sandboxed interpreter. Variables persist between calls — you can build state incrementally. Use `print()` to output results. Inside the code, these functions are available (use `await`): -- `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels, picture_refs (subset of doc_item_refs labeled `picture`) -- `await list_documents()` → list of dicts with keys: id, title, uri, created_at +- `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels, picture_refs (subset of doc_item_refs labeled `picture`), chunk_meta (the matched chunk's stored metadata, custom keys included) +- `await list_documents()` → list of dicts with keys: id, title, uri, created_at, metadata -Available modules: `json`, `re`, `math`, `pathlib` -Not supported: class inheritance and metaclasses, generators/yield, match statements, decorators, `collections`, iterating a file object (`for line in f`) +Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`, `collections`, `itertools`, `functools` and `dataclasses`. `decimal` and `statistics` do not exist. +Not supported: class inheritance and metaclasses, generators/yield, match statements, iterating a file object (`for line in f`) ### analysis_search Search the knowledge base directly (outside code execution). Each result has a `Type:` (paragraph, table, code, list_item, picture). When the Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text — use it directly to answer questions about figures, diagrams, charts, screenshots. @@ -39,16 +39,17 @@ All documents are mounted as a virtual filesystem at `/documents/`: ``` /documents/{document_id}/ - metadata.json # {"id", "title", "uri", "created_at"} + metadata.json # {"id", "title", "uri", "created_at", "metadata"} content.txt # Full document text items.jsonl # Structured items (one JSON object per line) + chunks.jsonl # Chunks in order with their metadata (one JSON object per line) toc.json # Section tree derived from heading_level ``` `{document_id}` is an internal identifier, not the user-facing `uri` (filename, URL, etc.). When you only know a document by its URI or title, use `await list_documents()` to enumerate ids and match against `uri` / `title` — that's a single call to the host. Iterating `/documents/` and reading every `metadata.json` works too but is much slower on portal-scale corpora. ### Reading files -Read with `Path.read_text()` or `open()` (including `with` blocks); file objects support `.read()`, `.readline()`, and `.readlines()`. A file object cannot be iterated, so read line-wise with `.readlines()` or `.read().split("\n")` instead of `for line in f`. Files are read-only; writing raises `PermissionError`. +Read with `Path.read_text()` or `open()` (including `with` blocks); file objects support `.read()`, `.readline()`, and `.readlines()`. A file object cannot be iterated, so read line-wise with `.readlines()` or `.read().split("\n")` instead of `for line in f`. Files are read-only; writing raises `PermissionError`. There is no network. A call has a time limit, named in the error when it is hit, and output past a size is cut with an `... (output truncated)` marker. ```python from pathlib import Path @@ -70,7 +71,7 @@ for line in Path(f'/documents/{doc_id}/items.jsonl').read_text().strip().split(" ``` ### metadata.json -Document metadata: `id`, `title`, `uri`, `created_at`. +Document metadata: `id`, `title`, `uri`, `created_at`, and `metadata`, the keys stored with the document. ### content.txt Full text content. Use for regex or keyword search across a whole document. @@ -86,6 +87,9 @@ Each row carries: - `chunk_ids`: chunks that contain this item — pass to `analysis_cite()` to ground an answer that read this item directly - `heading_level`: H-level for `section_header` rows; `0` on non-header rows +### chunks.jsonl +The document's chunks in order, one JSON object per line: `chunk_id` and `metadata`, the chunk's stored metadata (`doc_item_refs`, `headings`, `labels`, `page_numbers`, and any custom keys such as paragraph or footnote numbers). To read by chunk metadata, keep the matching rows and take the `items.jsonl` rows whose `chunk_ids` name them. + ### toc.json Section tree derived from `heading_level`: `{"doc_id", "title", "tree": [...]}` where each node has `{self_ref, level, title, page_numbers, item_range: [start, end_exclusive], chunk_ids, children}`. `item_range` is a line slice into `items.jsonl` — `items[start:end]`. `chunk_ids` aggregates the citable chunks across all items in the section — pass directly to `analysis_cite()` to ground a section-scoped answer without a corpus-wide `search()` call. `tree: []` for docs with no headers. @@ -112,6 +116,6 @@ You MUST call `analysis_cite` before producing your final answer, every time, wi - Use `print()` to output results — the output is your only feedback - When you write code, execute it — don't describe what code would do. But not every question needs code; simple lookups are best answered by `analysis_search → analysis_cite`. - Use `await` for all async functions inside `analysis_execute_code` (`search`, `list_documents`) -- Read files with `Path.read_text()` or `open()`/`with`. For lines use `.readlines()` or `.read().split("\n")`, never `for line in f`. The `collections` module is unavailable. +- Read files with `Path.read_text()` or `open()`/`with`. For lines use `.readlines()` or `.read().split("\n")`, never `for line in f`. - Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `analysis_cite` tool separately to register citations. `cite{...}` markdown-style inline references do nothing; only an actual `analysis_cite` tool call registers a citation. - **Before you write your final answer, invoke the `analysis_cite` tool with the supporting chunk_ids, or with an empty list if there are none.** This is the last tool call before answering, every time. diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index 836b1efb..c47d3971 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -886,7 +886,7 @@ def mcp( ), ) -> None: """Run the MCP server.""" - app = create_app(db) + app = create_app(db, covers_set=True) transport = "stdio" if stdio else None diff --git a/haiku_rag_slim/haiku/rag/context.py b/haiku_rag_slim/haiku/rag/context.py index b4214da5..82fe6ed7 100644 --- a/haiku_rag_slim/haiku/rag/context.py +++ b/haiku_rag_slim/haiku/rag/context.py @@ -32,6 +32,8 @@ In both cases: - Results without doc_item_refs pass through unexpanded """ +from typing import Any + from haiku.rag.store.models.chunk import SearchResult from haiku.rag.store.models.document_item import DocumentItem @@ -488,3 +490,77 @@ def expand_with_items( final_results.append(built) return final_results + passthrough + + +def build_toc( + items: list["DocumentItem"], + chunk_index: dict[str, list[str]], +) -> list[dict[str, Any]]: + """Build a nested section tree from items in position order. + + Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting + follows the explicit levels: a header pops the stack until the top is at + a strictly shallower level, then becomes a child of that top (or a root). + + ``item_range = [start, end_exclusive]`` indexes the position-ordered item + list, which is the line numbering of the sandbox's ``items.jsonl``: ``start`` + is the header's index and ``end_exclusive`` the index of the next header + whose level is the same or shallower (the next sibling or ancestor that + ends this section), or the item count if no such header exists. Indices, + not positions: positions may have gaps. + + ``chunk_ids`` aggregates the chunks covered by all items in the section's + ``item_range`` (deduped, order preserved). Pass directly to ``cite()`` to + ground a section-scoped answer without a corpus-wide ``search()`` call. + + Items without a section_header label (or with ``heading_level == 0``) are + skipped. When all section_headers carry the same level the output is a + flat sibling list (see docling-project/docling#2121 for an upstream case + where every PDF section_header is emitted at level=1). + """ + # Defensive: every consumer is supposed to pass items in position order, + # but the end_exclusive lookahead below silently miscomputes section + # boundaries if it's not — better to sort once than trust the caller. + items = sorted(items, key=lambda i: i.position) + header_indices = [ + idx + for idx, i in enumerate(items) + if i.label == "section_header" and i.heading_level > 0 + ] + if not header_indices: + return [] + + ends: list[int] = [] + for n, idx in enumerate(header_indices): + end = len(items) + for later in header_indices[n + 1 :]: + if items[later].heading_level <= items[idx].heading_level: + end = later + break + ends.append(end) + + roots: list[dict[str, Any]] = [] + stack: list[tuple[int, dict[str, Any]]] = [] + for idx, end in zip(header_indices, ends, strict=True): + h = items[idx] + seen: set[str] = set() + chunk_ids: list[str] = [] + for item in items[idx:end]: + for cid in chunk_index.get(item.self_ref, []): + if cid not in seen: + seen.add(cid) + chunk_ids.append(cid) + node: dict[str, Any] = { + "self_ref": h.self_ref, + "level": h.heading_level, + "title": h.text, + "page_numbers": list(h.page_numbers), + "item_range": [idx, end], + "chunk_ids": chunk_ids, + "children": [], + } + while stack and stack[-1][0] >= h.heading_level: + stack.pop() + (stack[-1][1]["children"] if stack else roots).append(node) + stack.append((h.heading_level, node)) + return roots diff --git a/haiku_rag_slim/haiku/rag/mcp.py b/haiku_rag_slim/haiku/rag/mcp.py index f5f808a3..72dbf165 100644 --- a/haiku_rag_slim/haiku/rag/mcp.py +++ b/haiku_rag_slim/haiku/rag/mcp.py @@ -1,66 +1,168 @@ import asyncio +import base64 from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager +from importlib import metadata from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Annotated from fastmcp import FastMCP +from fastmcp.exceptions import ToolError +from fastmcp.tools import ToolResult +from mcp.types import ContentBlock, ImageContent, TextContent, ToolAnnotations +from pydantic import Field from haiku.rag.client import HaikuRAG from haiku.rag.config import AppConfig, get_config +from haiku.rag.context import build_toc +from haiku.rag.sandbox import AnalysisContext, Sandbox, recovery_hint from haiku.rag.store.models import Document, SearchResult -from haiku.rag.tools.document import DocumentInfo -from haiku.rag.utils import format_citations +from haiku.rag.store.schema import DocumentMetaRecord +from haiku.rag.tools.document import DocumentInfo, DocumentSection, OutlineNode +from haiku.rag.tools.search import collect_pictures if TYPE_CHECKING: + from typing import Any + from haiku.rag.client.scope import DatabaseScope + from haiku.rag.store.models.document_item import DocumentItem + +_FILTER_COLUMNS = ", ".join(DocumentMetaRecord.model_fields) + +Filter = Annotated[ + str | None, + Field( + description=( + f"SQL WHERE clause over the document columns {_FILTER_COLUMNS}, " + "restricting which documents are used. `metadata` is a JSON string, " + 'so match its keys with LIKE: metadata LIKE \'%"author": "Smith"%\'. ' + "Also uri LIKE '%.pdf', title = 'Q3 report'." + ) + ), +] +Sources = Annotated[ + list[str] | None, + Field(description="Collections to use, by name. All of them by default."), +] -def _decode_images(images_base64: list[str] | None) -> list[bytes] | None: - if not images_base64: - return None - import base64 +def _read_only(title: str) -> ToolAnnotations: + return ToolAnnotations(title=title, read_only_hint=True, open_world_hint=False) - return [base64.b64decode(b64, validate=True) for b64 in images_base64] + +def _decode_image(image_base64: str) -> bytes: + try: + return base64.b64decode(image_base64, validate=True) + except ValueError as e: + # binascii.Error for characters outside the alphabet or bad padding, + # ValueError itself for non-ASCII input. + raise ToolError("Invalid base64 image") from e + + +def _instructions(scope: "DatabaseScope", config: AppConfig) -> str: + """What the server is for, naming no tools: the client has every tool's + description from the listing.""" + lines = [ + "haiku-rag is the user's knowledge base: documents they ingested, " + "searchable by meaning and keyword, readable whole or section by section, " + "or computed across with code." + ] + lines.append( + "Use it whenever a question could be answered from those documents, " + "before answering from memory, and say when it had nothing relevant." + ) + if scope.covers_multiple: + lines.append( + f"It holds several collections: {', '.join(scope.names)}. Results " + "name theirs in `source`; pass `sources` to use a subset." + ) + if config.prompts.domain_preamble: + lines.append(config.prompts.domain_preamble) + return "\n".join(lines) + + +def _search_result(results: list[SearchResult], covers_multiple: bool) -> ToolResult: + """Results as the in-process agents read them, plus the matched chunk's + metadata, then each distinct picture as an image block labelled with its + result. No structured content: a client given both shows the model the + JSON and drops the text, or shows both.""" + total = len(results) + text = "\n\n".join( + result.format_for_agent( + rank=rank, + total=total, + include_collection=covers_multiple, + include_document_id=True, + include_chunk_meta=True, + ) + for rank, result in enumerate(results, 1) + ) + content: list[ContentBlock] = [ + TextContent(type="text", text=text or "No results found.") + ] + pictures, _ = collect_pictures(results) + for source, chunk_id, self_ref, picture in pictures: + collection = f" in {source}" if covers_multiple and source else "" + content.append( + TextContent( + type="text", + text=f"Picture {self_ref} of search result [{chunk_id}]{collection}", + ) + ) + content.append( + ImageContent( + type="image", + data=base64.b64encode(picture.data).decode("ascii"), + mime_type="image/png", + ) + ) + return ToolResult(content=content) + + +def _node(toc: "dict[str, Any]") -> OutlineNode: + return OutlineNode( + id=toc["self_ref"], + title=toc["title"], + level=toc["level"], + page_numbers=toc["page_numbers"], + children=[_node(child) for child in toc["children"]], + ) + + +def _find(toc: list["dict[str, Any]"], section_id: str) -> "dict[str, Any] | None": + for node in toc: + if node["self_ref"] == section_id: + return node + found = _find(node["children"], section_id) + if found is not None: + return found + return None def create_mcp_server( - db_path: Path | None = None, - config: AppConfig | None = None, - read_only: bool = False, + db_path: Path | None = None, config: AppConfig | None = None ) -> FastMCP: - """Create an MCP server over one database. + """Create an MCP server over the databases the configuration places. Args: db_path: Path to the database file, where `config` places none; or - None to serve the database the configuration places. Beside + None to serve the databases the configuration places. Beside `lancedb.databases` a path raises `AmbiguousDatabaseError`. config: Configuration to use. - read_only: If True, write tools (add_document_*, delete_document) are not registered. """ from haiku.rag.client.scope import DatabaseScope config = config if config is not None else get_config() - return _covering( - DatabaseScope.resolve(config, database_path=db_path), config, read_only - ) + return _covering(DatabaseScope.resolve(config, database_path=db_path), config) -def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> FastMCP: +def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP: """An MCP server over databases someone already resolved. Internal, as ``HaikuRAG._covering`` is: the public factory takes a path and resolves it, which is its own job. A caller that resolved already passes the - scope, so the configured name survives, which results and citations carry as - ``source``. + scope, so the configured name survives, which results carry as ``source``. """ - from haiku.rag.store.exceptions import AmbiguousDatabaseError - - if scope.covers_multiple: - raise AmbiguousDatabaseError( - "an MCP server serves one database, and this scope covers " - f"{', '.join(scope.names)}; name the one to serve" - ) client: HaikuRAG | None = None stack = AsyncExitStack() client_lock = asyncio.Lock() @@ -76,7 +178,7 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas async with client_lock: if client is None: client = await stack.enter_async_context( - HaikuRAG._covering(scope, config, read_only=read_only) + HaikuRAG._covering(scope, config, read_only=True) ) return client @@ -95,90 +197,53 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas finally: client = None - mcp = FastMCP("haiku-rag", lifespan=lifespan) + # Explicit: the setting is also read from the environment, and the contract + # is that every failure reaches the client with its message. + mcp = FastMCP( + "haiku-rag", + instructions=_instructions(scope, config), + version=metadata.version("haiku.rag-slim"), + lifespan=lifespan, + mask_error_details=False, + ) - # Write tools - only registered when not in read-only mode - if not read_only: - - @mcp.tool() - async def add_document_from_file( - file_path: str, - metadata: dict[str, Any] | None = None, - title: str | None = None, - ) -> str | None: - """Add a document to the RAG system from a file path.""" - try: - rag = await _client() - result = await rag.create_document_from_source( - Path(file_path), title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents (directories) - if isinstance(result, list): - return result[0].id if result else None - return result.id - except Exception: - return None - - @mcp.tool() - async def add_document_from_url( - url: str, metadata: dict[str, Any] | None = None, title: str | None = None - ) -> str | None: - """Add a document to the RAG system from a URL.""" - try: - rag = await _client() - result = await rag.create_document_from_source( - url, title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents - if isinstance(result, list): - return result[0].id if result else None - return result.id - except Exception: - return None - - @mcp.tool() - async def add_document_from_text( - content: str, - uri: str | None = None, - metadata: dict[str, Any] | None = None, - title: str | None = None, - ) -> str | None: - """Add a document to the RAG system from text content.""" - try: - rag = await _client() - document = await rag.create_document( - content, uri, title=title, metadata=metadata or {} - ) - return document.id - except Exception: - return None - - @mcp.tool() - async def delete_document(document_id: str) -> bool: - """Delete a document by its ID.""" - try: - rag = await _client() - return await rag.delete_document(document_id) - except Exception: - return False - - # Read tools - always registered - @mcp.tool() + @mcp.tool(annotations=_read_only("Search documents")) async def search_documents( - query: str, limit: int | None = None, include_images: bool = True - ) -> list[SearchResult]: - """Search the RAG system for documents using hybrid search (vector similarity + full-text search). + query: str, + limit: int | None = None, + include_images: bool = True, + filter: Filter = None, + sources: Sources = None, + ) -> ToolResult: + """Search the knowledge base by meaning and keyword. - When include_images is True (default) and a picture-labeled chunk is - in the result set, ``SearchResult.image_data`` carries base64-encoded - PNG bytes keyed by self_ref. Set to False to omit the bytes from the - response (smaller JSON payload for plain-text consumers). + Use this first for any question the documents might answer; it needs + no model and is the cheapest call. Results come best first, each with + its rank, `Document ID`, `Collection` when the server covers several, + the document title, section headings, the matched chunk's metadata + when it has any, and the matching passage expanded to its section; + pass the id and collection to the document tools. Pictures in the + results follow as images, each labelled with its result. Ranks, not scores, + are the signal: scores are not comparable across queries. If nothing + relevant comes back, rephrase once or narrow with `filter` before + concluding the material is absent. + + Args: + query: What to look for, in natural language or keywords. + limit: How many results to return; the server's configured default + when omitted. + include_images: Return the pictures in the results as images. + False for a smaller response. """ - try: - rag = await _client() - return await rag.search(query, limit=limit, include_images=include_images) - except Exception: - return [] + rag = await _client() + results = await rag.search( + query, + limit=limit, + filter=filter, + include_images=include_images, + sources=sources, + ) + return _search_result(await rag.expand_context(results), rag.covers_multiple) # Image-as-query tool, only registered when the configured embedder # supports image embeddings. Probed at server-build time when no Store is @@ -188,123 +253,208 @@ def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> Fas if get_embedder(config).supports_images: - @mcp.tool() + @mcp.tool(annotations=_read_only("Search documents by image")) async def search_documents_by_image( image_base64: str, limit: int | None = None, include_images: bool = True, - ) -> list[SearchResult]: - """Search the RAG system using an image as the query. + filter: Filter = None, + sources: Sources = None, + ) -> ToolResult: + """Search the knowledge base with an image as the query. - ``image_base64`` is a base64-encoded image (PNG/JPEG bytes). The - image is embedded via the configured multimodal embedder and the - chunks table is searched vector-only. ``include_images`` controls - whether picture bytes are attached to picture-labeled results. + Use this when the question is about a picture rather than words. + The image is embedded and matched against document text and + figures by vector similarity alone. Results have the shape of + `search_documents` results. + + Args: + image_base64: The query image, PNG or JPEG bytes as base64. + limit: How many results to return; the server's configured + default when omitted. + include_images: Return the pictures in the results as images. + False for a smaller response. """ - import base64 - - try: - raw = base64.b64decode(image_base64) - except Exception: - return [] - try: - rag = await _client() - return await rag.search(raw, limit=limit, include_images=include_images) - except Exception: - return [] - - @mcp.tool() - async def get_document(document_id: str) -> Document | None: - """Get a document by its ID.""" - try: + raw = _decode_image(image_base64) rag = await _client() - return await rag.get_document_by_id(document_id) - except Exception: - return None + results = await rag.search( + raw, + limit=limit, + filter=filter, + include_images=include_images, + sources=sources, + ) + return _search_result( + await rag.expand_context(results), rag.covers_multiple + ) - @mcp.tool() + @mcp.tool(annotations=_read_only("Get document")) + async def get_document(document_id: str, source: str | None = None) -> Document: + """Read one document whole, in reading order. + + Use this after a search when a passage is not enough. Returns the + document's content, title, uri and metadata. Ids come from search + results and `list_documents`. + + Args: + document_id: The document's id. + source: The collection holding it. Without one every collection + is asked. + """ + rag = await _client() + document = await rag.get_document_by_id(document_id, source) + if document is None: + raise ToolError(f"No document with id {document_id!r}") + return document + + async def _items_of(document_id: str, source: str | None) -> list["DocumentItem"]: + """A document's items in reading order, from the database holding it.""" + rag = await _client() + document = await rag.get_document_by_id(document_id, source) + if document is None: + raise ToolError(f"No document with id {document_id!r}") + owner = await rag.reader_for(source or document.source) + assert owner is not None, "a stored document names its database" + return await owner.document_item_repository.get_all_items(document_id) + + @mcp.tool(annotations=_read_only("Document outline")) + async def get_document_outline( + document_id: str, source: str | None = None + ) -> list[OutlineNode]: + """The heading tree of a document, with page numbers. + + Use this on a long document to see its structure before reading, then + pass a node's `id` to `get_document_section`. Returns the headings + nested by level; an empty list means the document has no headings, + so read it with `get_document`. + + Args: + document_id: The document's id. + source: The collection holding it. Without one every collection + is asked. + """ + return [ + _node(toc) for toc in build_toc(await _items_of(document_id, source), {}) + ] + + @mcp.tool(annotations=_read_only("Document section")) + async def get_document_section( + document_id: str, section_id: str, source: str | None = None + ) -> DocumentSection: + """The text of one section of a document, subsections included. + + Use this to read a part of a long document instead of the whole. + `section_id` is a node `id` from `get_document_outline`. Returns the + section's heading, page numbers and text in reading order, up to the + next heading of the same or a higher level. + + Args: + document_id: The document's id. + section_id: The `id` of a node in the document's outline. + source: The collection holding it. Without one every collection + is asked. + """ + items = await _items_of(document_id, source) + node = _find(build_toc(items, {}), section_id) + if node is None: + raise ToolError(f"No section {section_id!r} in document {document_id!r}") + start, end = node["item_range"] + ordered = sorted(items, key=lambda item: item.position) + return DocumentSection( + id=node["self_ref"], + title=node["title"], + page_numbers=node["page_numbers"], + content="\n\n".join(item.text for item in ordered[start:end] if item.text), + ) + + @mcp.tool(annotations=_read_only("List documents")) async def list_documents( limit: int | None = None, offset: int | None = None, - filter: str | None = None, + filter: Filter = None, ) -> list[DocumentInfo]: - """List all documents with optional pagination and filtering. + """List what the knowledge base holds. + + Use this to see which documents exist, their titles, URIs and + metadata, and so what a `filter` can match. Not a search: it returns + no passages. Args: - limit: Maximum number of documents to return. - offset: Number of documents to skip. - filter: Optional SQL WHERE clause to filter documents. + limit: How many documents to return. + offset: How many documents to skip, for paging. """ - try: - rag = await _client() - documents = await rag.list_documents(limit, offset, filter) + rag = await _client() + documents = await rag.list_documents(limit, offset, filter) + return [ + DocumentInfo( + id=doc.id, + title=doc.title or "Untitled", + uri=doc.uri or "", + created=doc.created_at.strftime("%Y-%m-%d"), + source=doc.source, + metadata=doc.metadata, + ) + for doc in documents + ] - return [ - DocumentInfo( - id=doc.id, - title=doc.title or "Untitled", - uri=doc.uri or "", - created=doc.created_at.strftime("%Y-%m-%d"), - ) - for doc in documents - ] - except Exception: - return [] - - @mcp.tool() - async def ask_question( - question: str, - cite: bool = False, - images_base64: list[str] | None = None, + @mcp.tool(annotations=_read_only("Run code over the documents")) + async def execute_code( + code: str, filter: Filter = None, sources: Sources = None ) -> str: - """Ask a question using the QA agent. + """Run a Python program over the documents and return what it printed. + + Use this when the answer is a count, an aggregate, a comparison across + many documents, a lookup by document or chunk metadata, or a pattern + over whole documents: whatever a search cannot rank. The program runs + in a sandboxed interpreter on the server. Each call is one program, + nothing carries over between calls, and `print` is the only output. + + Inside the program, `/documents/{document_id}/` holds `metadata.json` + (id, title, uri, created_at, metadata), `content.txt` (the whole text), + `items.jsonl` (one item per line: self_ref, label, text, page_numbers, + heading_level, chunk_ids), `chunks.jsonl` (one chunk per line: chunk_id, + metadata) and `toc.json` (`doc_id`, `title`, `tree`; each node has + self_ref, level, title, page_numbers, item_range as a slice into + items.jsonl, chunk_ids and children; an empty tree means no headings). + Read files with `Path.read_text()` or `open()`; a file object cannot be + iterated, use `.readlines()`. `await search(query, limit=10)` returns + dicts with chunk_id, content, document_id, document_title, document_uri, + source, score, page_numbers, headings, doc_item_refs, labels, + picture_refs (the doc_item_refs that are pictures) and chunk_meta. + `await list_documents()` returns dicts with id, title, uri, created_at, + source and metadata. Both see the documents `filter` and `sources` + select. Useful modules include json, re, math, pathlib, datetime, + collections, itertools, functools and dataclasses; decimal and + statistics do not exist. No generator functions, match statements or + class inheritance. + Files are read-only, there is no network, a call has a time limit named + in the error when it is hit, and output past a size is truncated. + + Map a title or URI to a document id with one `list_documents()` call + rather than reading every `metadata.json`. The files carry no `source`, + so over several collections group by the `source` of `list_documents()` + rows. For a known document's structure read its `toc.json` before + searching: `search()` ranks across every document. A hit's + `doc_item_refs` are `self_ref` values in `items.jsonl`, which places it + in its section. `chunk_ids` on items and `chunk_id` in `chunks.jsonl` + join the two files; they are not citations. Args: - question: The question to ask. - cite: Whether to include citations in the response. - images_base64: Base64-encoded images attached to the question - (requires a vision-capable QA model). - - Returns: - The answer as a string. + code: The program. Use `await` on search and list_documents. """ + rag = await _client() + sandbox = Sandbox._covering( + scope, config, AnalysisContext(filter=filter, sources=sources), rag=rag + ) try: - images = _decode_images(images_base64) - rag = await _client() - answer, citations = await rag.ask(question, images=images) - if cite and citations: - answer += "\n\n" + format_citations(citations) - return answer - except Exception as e: - return f"Error answering question: {e!s}" - - @mcp.tool() - async def analyze( - question: str, - filter: str | None = None, - images_base64: list[str] | None = None, - ) -> str: - """Answer complex questions using the analysis capability. - - Use this for questions requiring computation, aggregation, or - structural traversal across documents. The capability can write and - execute Python code in a sandboxed interpreter. - - Args: - question: The question to answer. - filter: Optional SQL WHERE clause to filter documents. - images_base64: Base64-encoded images attached to the question - (requires a vision-capable analysis model). - - Returns: - The answer as a string. - """ - try: - images = _decode_images(images_base64) - rag = await _client() - result = await rag.analyze(question, filter=filter, images=images) - return result.answer - except Exception as e: - return f"Error running analysis capability: {e!s}" + result = await sandbox.execute(code) + finally: + await sandbox.close() + if not result.success: + raise ToolError( + f"{result.stderr}{recovery_hint(result.stderr)}" + f"\n\nOutput: {result.stdout}" + ) + return result.stdout or "No output." return mcp diff --git a/haiku_rag_slim/haiku/rag/sandbox/__init__.py b/haiku_rag_slim/haiku/rag/sandbox/__init__.py index 1ca4b1de..0e7576f3 100644 --- a/haiku_rag_slim/haiku/rag/sandbox/__init__.py +++ b/haiku_rag_slim/haiku/rag/sandbox/__init__.py @@ -1,10 +1,11 @@ from haiku.rag.sandbox.dependencies import AnalysisContext from haiku.rag.sandbox.models import AnalysisResult -from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult +from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult, recovery_hint __all__ = [ "AnalysisContext", "AnalysisResult", "Sandbox", "SandboxResult", + "recovery_hint", ] diff --git a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py index 4102d961..512b5b24 100644 --- a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py +++ b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py @@ -17,8 +17,9 @@ from pydantic_monty import ( ) from haiku.rag.config.models import AppConfig +from haiku.rag.context import build_toc from haiku.rag.sandbox.dependencies import AnalysisContext -from haiku.rag.store.models.chunk import SearchResult +from haiku.rag.store.models.chunk import Chunk, SearchResult from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem from haiku.rag.utils import gather_all @@ -29,6 +30,9 @@ if TYPE_CHECKING: from haiku.rag.client.scope import DatabaseScope +_MAX_HOST_CALLS = 10_000_000 + + @dataclass class SandboxResult: """Result of executing code in the sandbox.""" @@ -38,79 +42,19 @@ class SandboxResult: success: bool -def _build_toc( - items: list["DocumentItem"], - chunk_index: dict[str, list[str]], -) -> list[dict[str, Any]]: - """Build a nested section tree from items in position order. +def recovery_hint(stderr: str) -> str: + """Name the workaround for sandbox limits models trip over repeatedly. - Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting - follows the explicit levels: a header pops the stack until the top is at - a strictly shallower level, then becomes a child of that top (or a root). - - ``item_range = [position, end_exclusive]`` where ``end_exclusive`` is the - position of the next header whose level is the same or shallower (i.e. - the next sibling or ancestor that ends this section), or the total item - count if no such header exists. - - ``chunk_ids`` aggregates the chunks covered by all items in the section's - ``item_range`` (deduped, order preserved). Pass directly to ``cite()`` to - ground a section-scoped answer without a corpus-wide ``search()`` call. - - Items without a section_header label (or with ``heading_level == 0``) are - skipped. When all section_headers carry the same level the output is a - flat sibling list (see docling-project/docling#2121 for an upstream case - where every PDF section_header is emitted at level=1). + The instructions already say file objects are not iterable, and models write + ``for line in open(...)`` regardless. Carrying the fix in the error gives + them something to act on for the retry. """ - # Defensive: every consumer is supposed to pass items in position order, - # but the end_exclusive lookahead below silently miscomputes section - # boundaries if it's not — better to sort once than trust the caller. - items = sorted(items, key=lambda i: i.position) - headers: list[DocumentItem] = [ - i for i in items if i.label == "section_header" and i.heading_level > 0 - ] - if not headers: - return [] - - total = max((i.position for i in items), default=-1) + 1 - items_by_position: dict[int, DocumentItem] = {i.position: i for i in items} - - ends: list[int] = [] - for idx, h in enumerate(headers): - end = total - for j in range(idx + 1, len(headers)): - if headers[j].heading_level <= h.heading_level: - end = headers[j].position - break - ends.append(end) - - roots: list[dict[str, Any]] = [] - stack: list[tuple[int, dict[str, Any]]] = [] - for h, end in zip(headers, ends, strict=True): - seen: set[str] = set() - chunk_ids: list[str] = [] - for pos in range(h.position, end): - item = items_by_position.get(pos) - if item is None: - continue - for cid in chunk_index.get(item.self_ref, []): - if cid not in seen: - seen.add(cid) - chunk_ids.append(cid) - node: dict[str, Any] = { - "self_ref": h.self_ref, - "level": h.heading_level, - "title": h.text, - "page_numbers": list(h.page_numbers), - "item_range": [h.position, end], - "chunk_ids": chunk_ids, - "children": [], - } - while stack and stack[-1][0] >= h.heading_level: - stack.pop() - (stack[-1][1]["children"] if stack else roots).append(node) - stack.append((h.heading_level, node)) - return roots + if "TextIOWrapper" in stderr and "not iterable" in stderr: + return ( + "\n\nHint: file objects cannot be iterated here. Read lines with " + '.readlines() or .read().split("\\n").' + ) + return "" class Sandbox: @@ -120,7 +64,8 @@ class Sandbox: The interpreter runs in a subprocess worker checked out of an ``AsyncMonty`` pool. External functions (search, list_documents) are called by Monty code using ``await`` and resolved asynchronously on the host. Documents are - exposed via a virtual filesystem at ``/documents/{id}/``. + exposed via a virtual filesystem at ``/documents/{id}/``: ``metadata.json``, + ``content.txt``, ``items.jsonl``, ``chunks.jsonl`` and ``toc.json``. The session persists across ``execute()`` calls within the same Sandbox instance — variables carry over. Call ``close()`` to return the worker to @@ -150,6 +95,7 @@ class Sandbox: _doc_items: dict[str, list["DocumentItem"]] _doc_chunk_index: dict[str, dict[str, list[str]]] _items_jsonl_cache: dict[str, str] + _chunks_jsonl_cache: dict[str, str] _toc_json_cache: dict[str, str] _opened: "HaikuRAG | None" _pool: AsyncMonty | None @@ -216,6 +162,7 @@ class Sandbox: self._doc_items = {} self._doc_chunk_index = {} self._items_jsonl_cache = {} + self._chunks_jsonl_cache = {} self._toc_json_cache = {} self._pool = None self._session = None @@ -328,14 +275,43 @@ class Sandbox: assert self._loop is not None, ( "VFS reads happen during execute(); the loop must be captured first." ) - if self._deadline is not None and self._loop.time() > self._deadline: + if self._past_deadline(): coro.close() - raise TimeoutError( - "time limit exceeded: no further document reads after " - f"{self._config.analysis.code_timeout}s" - ) + raise self._time_limit() return asyncio.run_coroutine_threadsafe(coro, self._loop).result() + def _past_deadline(self) -> bool: + return ( + self._deadline is not None + and self._loop is not None + and self._loop.time() > self._deadline + ) + + def _time_limit(self) -> TimeoutError: + return TimeoutError( + "time limit exceeded: no further document reads or calls after " + f"{self._config.analysis.code_timeout}s" + ) + + def _check_deadline(self) -> None: + """Refuse a host call once the call's time is up. + + Monty's watchdog counts only time the worker spends computing, so every + host call, a file served from memory and an in-code search included, + checks the deadline before it runs. + """ + if self._past_deadline(): + raise self._time_limit() + + def _timed( + self, read: Callable[["PurePosixPath"], str] + ) -> Callable[["PurePosixPath"], str]: + def call(path: "PurePosixPath") -> str: + self._check_deadline() + return read(path) + + return call + async def _discard_session(self) -> None: """Drop a session whose worker is gone. @@ -371,6 +347,7 @@ class Sandbox: context = self._context async def search(query: str, limit: int = 10) -> list[dict[str, Any]]: + self._check_deadline() # Picture bytes are deliberately not attached to in-code search # results: the Monty interpreter has no PIL/base64/hashlib, so the # agent's Python can't do anything with them. The driving model @@ -404,11 +381,13 @@ class Sandbox: "doc_item_refs": r.doc_item_refs, "labels": r.labels, "picture_refs": picture_refs, + "chunk_meta": r.chunk_meta, } ) return out async def list_documents() -> list[dict[str, Any]]: + self._check_deadline() docs, _ = await self._documents() return [ { @@ -417,6 +396,7 @@ class Sandbox: "uri": d.uri, "created_at": str(d.created_at), "source": d.source, + "metadata": d.metadata, } for d in docs ] @@ -433,6 +413,7 @@ class Sandbox: - metadata.json: CallbackFile (eager, small) - content.txt: CallbackFile (lazy, can be large) - items.jsonl: CallbackFile (lazy, bulk-cached) + - chunks.jsonl: CallbackFile (lazy, bulk-cached) - toc.json: CallbackFile (lazy, bulk-cached) """ files: list[CallbackFile] = [] @@ -507,6 +488,31 @@ class Sandbox: return read_items + def _make_chunks_reader( + did: str, + ) -> Callable[["PurePosixPath"], str]: + def read_chunks(_path: "PurePosixPath") -> str: + cached = sandbox._chunks_jsonl_cache.get(did) + if cached is not None: + return cached + + async def _fetch() -> list[Chunk]: + async with sandbox._connection(sandbox._owners.get(did)) as rag: + return await rag.chunk_repository.get_by_document_id(did) + + chunks = sandbox._run_on_loop(_fetch()) + jsonl = "\n".join( + json.dumps( + {"chunk_id": chunk.id, "metadata": chunk.metadata}, + ensure_ascii=False, + ) + for chunk in chunks + ) + sandbox._chunks_jsonl_cache[did] = jsonl + return jsonl + + return read_chunks + def _make_toc_reader( did: str, ) -> Callable[["PurePosixPath"], str]: @@ -520,7 +526,7 @@ class Sandbox: { "doc_id": did, "title": doc_titles.get(did), - "tree": _build_toc(items, chunk_index), + "tree": build_toc(items, chunk_index), }, ensure_ascii=False, ) @@ -541,6 +547,7 @@ class Sandbox: "title": doc.title, "uri": doc.uri, "created_at": str(doc.created_at), + "metadata": doc.metadata, }, ensure_ascii=False, ) @@ -550,7 +557,7 @@ class Sandbox: files.append( CallbackFile( f"{doc_dir}/metadata.json", - read=lambda _path, text=metadata: text, + read=self._timed(lambda _path, text=metadata: text), write=_deny_write, ) ) @@ -571,14 +578,21 @@ class Sandbox: files.append( CallbackFile( f"{doc_dir}/content.txt", - read=_make_content_reader(doc_id), + read=self._timed(_make_content_reader(doc_id)), write=_deny_write, ) ) files.append( CallbackFile( f"{doc_dir}/items.jsonl", - read=_make_items_reader(doc_id), + read=self._timed(_make_items_reader(doc_id)), + write=_deny_write, + ) + ) + files.append( + CallbackFile( + f"{doc_dir}/chunks.jsonl", + read=self._timed(_make_chunks_reader(doc_id)), write=_deny_write, ) ) @@ -589,7 +603,7 @@ class Sandbox: files.append( CallbackFile( f"{doc_dir}/toc.json", - read=_make_toc_reader(doc_id), + read=self._timed(_make_toc_reader(doc_id)), write=_deny_write, ) ) @@ -601,12 +615,19 @@ class Sandbox: Monty spends ``max_duration_secs`` across the session's whole life, and the session is reused so variables persist between calls: the budget - covers the whole run. ``code_timeout`` is enforced per call elsewhere: the read - deadline in ``_run_on_loop`` bounds a call that reads, and the pool's - ``request_timeout`` bounds one that computes. + covers the whole run. ``code_timeout`` is enforced per call elsewhere: past + its deadline no further host call starts (``_check_deadline``), and the + pool's ``request_timeout`` bounds compute. + + ``max_suspensions`` counts host callbacks per session, document reads + included, defaults to 1000 and cannot be disabled. The time budgets are + the governors here, so it is set where no program reaches it. """ analysis = self._config.analysis - return {"max_duration_secs": analysis.code_timeout * analysis.max_executions} + return { + "max_duration_secs": analysis.code_timeout * analysis.max_executions, + "max_suspensions": _MAX_HOST_CALLS, + } async def _ensure_initialized(self) -> tuple[AsyncMontySession, OSAccess]: """Check out a worker session and build the VFS on first use.""" diff --git a/haiku_rag_slim/haiku/rag/store/models/chunk.py b/haiku_rag_slim/haiku/rag/store/models/chunk.py index 6c2b4215..fc48966f 100644 --- a/haiku_rag_slim/haiku/rag/store/models/chunk.py +++ b/haiku_rag_slim/haiku/rag/store/models/chunk.py @@ -1,3 +1,4 @@ +import json from typing import TYPE_CHECKING, Literal from pydantic import BaseModel, PrivateAttr @@ -143,8 +144,9 @@ class SearchResult(BaseModel): consumers (UIs). Never part of ``format_for_agent`` output. ``chunk_meta`` is the anchor chunk's unparsed ``Chunk.metadata`` and does not - include the metadata of any other chunks merged with it. Never part of - ``format_for_agent`` output. + include the metadata of any other chunks merged with it. Left out of + ``format_for_agent`` output unless ``include_chunk_meta`` asks for its custom + keys. ``source`` names the database a result came from: the name from ``lancedb.databases`` or a path's stem, never a path or URI, so a location @@ -202,6 +204,8 @@ class SearchResult(BaseModel): total: int | None = None, *, include_collection: bool = False, + include_document_id: bool = False, + include_chunk_meta: bool = False, ) -> str: """Format this search result for inclusion in agent context. @@ -215,7 +219,11 @@ class SearchResult(BaseModel): `include_collection` is the caller's decision, not this result's: a search spanning one collection has nothing to distinguish, whether or - not that collection is named. + not that collection is named. `include_document_id` is for a reader + that will fetch the document by id from the text alone. + `include_chunk_meta` renders the metadata stored with the matched + chunk beyond haiku.rag's own structural keys; on an expanded result it + locates the hit, not the whole passage. """ if rank is not None and total is not None: parts = [f"[{self.chunk_id}] [rank {rank} of {total}]"] @@ -224,6 +232,9 @@ class SearchResult(BaseModel): else: parts = [f"[{self.chunk_id}] (score: {self.score:.2f})"] + if include_document_id and self.document_id: + parts.append(f"Document ID: {self.document_id}") + if include_collection and self.source: parts.append(f"Collection: {self.source}") @@ -242,6 +253,16 @@ class SearchResult(BaseModel): if primary_label: parts.append(f"Type: {primary_label}") + if include_chunk_meta: + custom = { + key: value + for key, value in self.chunk_meta.items() + if key not in ChunkMetadata.model_fields + } + if custom: + rendered = json.dumps(custom, ensure_ascii=False, sort_keys=True) + parts.append(f"Matched chunk metadata: {rendered}") + # Surface picture captions when present. Order matches the binary # attachments emitted by build_image_content_from_results, so the model # can correlate caption ↔ attached image by position (BinaryContent diff --git a/haiku_rag_slim/haiku/rag/tools/document.py b/haiku_rag_slim/haiku/rag/tools/document.py index c4bb7ddb..89648127 100644 --- a/haiku_rag_slim/haiku/rag/tools/document.py +++ b/haiku_rag_slim/haiku/rag/tools/document.py @@ -27,6 +27,27 @@ class DocumentInfo(BaseModel): title: str uri: str created: str + source: str | None = None + metadata: dict = {} + + +class OutlineNode(BaseModel): + """A heading in a document's outline. `id` is the heading item's self_ref.""" + + id: str + title: str + level: int + page_numbers: list[int] = [] + children: list["OutlineNode"] = [] + + +class DocumentSection(BaseModel): + """One section's text in reading order, subsections included.""" + + id: str + title: str + page_numbers: list[int] = [] + content: str class DocumentListResponse(BaseModel): diff --git a/haiku_rag_slim/haiku/rag/tools/search.py b/haiku_rag_slim/haiku/rag/tools/search.py index c779a19a..54bd1b99 100644 --- a/haiku_rag_slim/haiku/rag/tools/search.py +++ b/haiku_rag_slim/haiku/rag/tools/search.py @@ -52,31 +52,16 @@ def decode_picture(data: bytes, self_ref: str) -> BinaryContent | None: return BinaryContent(data=data, media_type="image/png", identifier=self_ref) -def build_image_content_from_results( - results: list[SearchResult], - include_collection: bool = False, - exclude: AbstractSet[PictureKey] = frozenset(), -) -> tuple[list[str | BinaryContent], set[PictureKey]]: - """Decode and validate picture bytes attached to search results, labelled. +def collect_pictures( + results: list[SearchResult], exclude: AbstractSet[PictureKey] = frozenset() +) -> tuple[list[tuple[str | None, str | None, str, BinaryContent]], set[PictureKey]]: + """Every distinct, decodable picture attached to ``results``, in order. - Returns the labelled content and the ``PictureKey`` of every picture it - emitted. Dedup keyed on ``PictureKey`` so the same picture in - different chunks is sent once, and a copy in another collection is its - own; ``exclude`` seeds that dedup with pictures already sent. Pictures that fail - ``PIL.Image.verify()`` are skipped — the model adapter renders one - vision placeholder per ``BinaryContent``, so emitting one for an - image the server can't decode leaves the processor with an - off-by-one count. - - Every picture is preceded by a line naming the result it belongs to. - ``ToolReturn.content`` reaches the model as a user-role message, so - retrieved pictures are otherwise indistinguishable from ones the user - attached, and models narrate them as part of the question: unlabelled, - gemma4-26b answered about a figure from an unrelated document, and with a - single note ahead of the batch it still called them "images in the prompt". - The label also names the chunk to cite for a figure, which - ``BinaryContent.identifier`` cannot do — it does not survive serialization - to the vision API. + Returns ``(source, chunk_id, self_ref, picture)`` per picture and the + ``PictureKey`` of each. Dedup keyed on ``PictureKey`` so the same picture in + different chunks is emitted once, and a copy in another collection is its + own; ``exclude`` seeds that dedup with pictures already sent. Pictures that + fail ``PIL.Image.verify()`` are skipped. """ collected: list[tuple[str | None, str | None, str, BinaryContent]] = [] seen: set[PictureKey] = set(exclude) @@ -94,7 +79,33 @@ def build_image_content_from_results( collected.append((result.source, result.chunk_id, self_ref, picture)) seen.add(key) emitted.add(key) + return collected, emitted + +def build_image_content_from_results( + results: list[SearchResult], + include_collection: bool = False, + exclude: AbstractSet[PictureKey] = frozenset(), +) -> tuple[list[str | BinaryContent], set[PictureKey]]: + """Decode and validate picture bytes attached to search results, labelled. + + Returns the labelled content and the ``PictureKey`` of every picture it + emitted, as ``collect_pictures`` decides them. An undecodable picture is + skipped because the model adapter renders one vision placeholder per + ``BinaryContent``, so emitting one for an image the server can't decode + leaves the processor with an off-by-one count. + + Every picture is preceded by a line naming the result it belongs to. + ``ToolReturn.content`` reaches the model as a user-role message, so + retrieved pictures are otherwise indistinguishable from ones the user + attached, and models narrate them as part of the question: unlabelled, + gemma4-26b answered about a figure from an unrelated document, and with a + single note ahead of the batch it still called them "images in the prompt". + The label also names the chunk to cite for a figure, which + ``BinaryContent.identifier`` cannot do — it does not survive serialization + to the vision API. + """ + collected, emitted = collect_pictures(results, exclude) content: list[str | BinaryContent] = [] total = len(collected) for position, (source, chunk_id, self_ref, picture) in enumerate(collected, 1): diff --git a/haiku_rag_slim/haiku/rag/utils.py b/haiku_rag_slim/haiku/rag/utils.py index 321ce3aa..6327b808 100644 --- a/haiku_rag_slim/haiku/rag/utils.py +++ b/haiku_rag_slim/haiku/rag/utils.py @@ -393,43 +393,6 @@ def _citation_label(c: "Citation") -> str: return c.document_title or c.document_uri -def format_citations(citations: "list[Citation]") -> str: - """Format citations as plain text with preserved formatting. - - Used by things like the MCP server where Rich renderables are not available. - Pictures referenced by the chunk are surfaced as ``[Figure: ]`` markers. - """ - if not citations: - return "" - - lines = ["## Citations\n"] - - for i, c in enumerate(citations): - idx = c.index if c.index is not None else (i + 1) - title = c.document_title or c.document_uri - header = f"[{idx}] {title}" - - location_parts = [] - pages = _citation_pages(c) - if pages: - location_parts.append(pages) - section = _citation_section(c) - if section: - location_parts.append(f"Section: {section}") - - source = c.document_uri - if location_parts: - source += f" - {', '.join(location_parts)}" - - lines.append(f"{header} {source}") - for ref in c.picture_refs: - lines.append(f"[Figure: {ref}]") - lines.append(c.content) - lines.append("") - - return "\n".join(lines) - - def truncated(text: str, limit: int) -> str: """The first `limit` characters of `text`, with `…` appended when anything was dropped. A cut result is `limit` characters plus the mark.""" diff --git a/haiku_rag_slim/pyproject.toml b/haiku_rag_slim/pyproject.toml index e438dbfd..a304e941 100644 --- a/haiku_rag_slim/pyproject.toml +++ b/haiku_rag_slim/pyproject.toml @@ -40,12 +40,12 @@ dependencies = [ "docling-core>=2.82.0,<3.0.0", "httpx>=0.28.1", "jinja2>=3.1.0", - "fastmcp>=3.3.0", + "fastmcp>=4.0.2,<5.0.0", "lancedb==0.37.1", "pathspec>=1.0.4", "pydantic>=2.12.5", "pydantic-ai-slim[openai,logfire,ag-ui]>=2.18.0,<3.0.0", - "pydantic-monty>=0.0.19", + "pydantic-monty>=0.0.23", "pypdfium2>=5.0", "python-dotenv>=1.2.2", "pyyaml>=6.0.3", diff --git a/plugins/haiku-rag/.claude-plugin/plugin.json b/plugins/haiku-rag/.claude-plugin/plugin.json new file mode 100644 index 00000000..cf30ea82 --- /dev/null +++ b/plugins/haiku-rag/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "haiku-rag", + "version": "0.82.1", + "description": "Search, read and analyze your haiku.rag knowledge base from Claude Code.", + "author": { + "name": "Yiorgis Gozadinos", + "email": "ggozadinos@gmail.com" + }, + "homepage": "https://ggozad.github.io/haiku.rag/mcp/", + "repository": "https://github.com/ggozad/haiku.rag", + "license": "MIT" +} diff --git a/plugins/haiku-rag/.codex-plugin/plugin.json b/plugins/haiku-rag/.codex-plugin/plugin.json new file mode 100644 index 00000000..f27c32ac --- /dev/null +++ b/plugins/haiku-rag/.codex-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "haiku-rag", + "version": "0.82.1", + "description": "Search, read and analyze your haiku.rag knowledge base from Codex.", + "author": { + "name": "Yiorgis Gozadinos", + "email": "ggozadinos@gmail.com", + "url": "https://github.com/ggozad" + }, + "homepage": "https://ggozad.github.io/haiku.rag/mcp/", + "repository": "https://github.com/ggozad/haiku.rag", + "license": "MIT", + "keywords": ["rag", "knowledge-base", "search", "documents", "mcp"], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "haiku.rag", + "shortDescription": "Search and analyze your haiku.rag knowledge base", + "longDescription": "Search, read, and compute over documents in your local haiku.rag knowledge base through MCP tools.", + "developerName": "Yiorgis Gozadinos", + "category": "Productivity", + "capabilities": ["Interactive", "Read"], + "websiteURL": "https://ggozad.github.io/haiku.rag/", + "defaultPrompt": "Search my haiku.rag knowledge base and cite the relevant documents." + } +} diff --git a/plugins/haiku-rag/.mcp.json b/plugins/haiku-rag/.mcp.json new file mode 100644 index 00000000..f6fb25f9 --- /dev/null +++ b/plugins/haiku-rag/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "haiku-rag": { + "command": "haiku-rag", + "args": ["mcp", "--stdio"] + } + } +} diff --git a/plugins/haiku-rag/skills/haiku-rag/SKILL.md b/plugins/haiku-rag/skills/haiku-rag/SKILL.md new file mode 100644 index 00000000..532186dc --- /dev/null +++ b/plugins/haiku-rag/skills/haiku-rag/SKILL.md @@ -0,0 +1,77 @@ +--- +name: haiku-rag +description: Search, read and compute over the user's haiku.rag knowledge base + through the haiku-rag MCP tools. Use whenever a request could be answered + from the user's ingested documents, when asked to find, look up, check or + cite something in their documents or knowledge base, or when the question is + about the user's own material rather than general knowledge. +compatibility: Requires the haiku-rag MCP server to be registered in the client. +allowed-tools: + - mcp__plugin_haiku-rag_haiku-rag__search_documents + - mcp__plugin_haiku-rag_haiku-rag__search_documents_by_image + - mcp__plugin_haiku-rag_haiku-rag__get_document + - mcp__plugin_haiku-rag_haiku-rag__get_document_outline + - mcp__plugin_haiku-rag_haiku-rag__get_document_section + - mcp__plugin_haiku-rag_haiku-rag__list_documents + - mcp__plugin_haiku-rag_haiku-rag__execute_code +--- + +# Working with the knowledge base + +Check the knowledge base before answering from memory whenever the question +could be about the user's documents. Say so when it has nothing relevant. + +## Find + +`search_documents` is the first call. Results come best first with the document +title, section headings, the matched chunk's metadata when it has any, and the +passage in its section. Pictures in the results arrive as images: answer +figure questions from them. `filter` restricts which documents are searched, +`limit` how many results come back. If it misses, rephrase once or narrow with +a filter before concluding the material is not there. When the question is +about an image rather than words and the server offers +`search_documents_by_image`, it takes the image as the query. + +## Read + +Every search result shows its `Document ID` (and `Collection` when there are +several); pass them to the read tools. `get_document` returns a document's +whole text in reading order. For a long one, `get_document_outline` gives the +heading tree with page numbers and `get_document_section` the text of one +section, subsections included. + +## Compute + +`execute_code` runs a Python program on the server over the same documents. +Under `/documents/{id}/` each has `metadata.json`, `content.txt`, `items.jsonl`, +`chunks.jsonl` and `toc.json`, and the program can `await search(query)` and +`await list_documents()`. Write code when the answer is a count, an aggregate, a +comparison across many documents, a lookup by document or chunk metadata, or a +pattern over whole documents: whatever search cannot rank. Each call is one +program and variables do not carry over, so gather, compute and `print` a +compact result in the same program. `filter` and `sources` select the documents +it sees. For a known document's structure read its `toc.json` first; `search()` +ranks across every document. Map a title or URI to an id with one +`list_documents()` call rather than reading every `metadata.json`; the files +carry no `source`, so over several collections group by its rows. Answer and +cite from what it printed. + +## Explore + +`list_documents` shows what is stored: titles, URIs and metadata. It is how you +learn what a filter can match. + +## Filters + +A SQL WHERE clause over the document columns `id`, `uri`, `title`, +`created_at`, `updated_at`, `metadata`. `metadata` is a JSON string, so match +it with LIKE: `metadata LIKE '%"author": "Smith"%'`. Also `uri LIKE '%.pdf'`, +`title = 'Q3 report'`. + +## Results and citations + +Rank is the signal; scores are not comparable across queries and are never +confidence. Cite the document title or URI, the section heading and page +numbers when present, and the matched chunk's metadata when it carries locators +such as paragraph or footnote numbers. When results carry `source`, the server +covers several collections: name it, and pass `sources` to search a subset. diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 5c5a1e2e..f696c2f0 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -2,7 +2,8 @@ """ Version bumping script for haiku.rag workspace. -Updates version in all pyproject.toml files and CHANGELOG.md. +Updates version in all pyproject.toml files, both plugin manifests, and +CHANGELOG.md. """ import re @@ -54,6 +55,19 @@ def update_example_dependencies(file_path: Path, new_version: str) -> None: print(f"✓ Updated example dependencies in {file_path.relative_to(Path.cwd())}") +def update_plugin_version(file_path: Path, new_version: str) -> None: + """Update the version in a plugin manifest.""" + content = file_path.read_text() + updated = re.sub( + r'^(\s*"version": )"[^"]+"', + rf'\1"{new_version}"', + content, + flags=re.MULTILINE, + ) + file_path.write_text(updated) + print(f"✓ Updated {file_path.relative_to(Path.cwd())}") + + def update_changelog(changelog_path: Path, new_version: str) -> None: """Update CHANGELOG.md with new version.""" content = changelog_path.read_text() @@ -122,10 +136,16 @@ def main(): root / "app" / "backend" / "pyproject.toml", ] + plugin_files = [ + root / "plugins" / "haiku-rag" / ".claude-plugin" / "plugin.json", + root / "plugins" / "haiku-rag" / ".codex-plugin" / "plugin.json", + ] changelog_file = root / "CHANGELOG.md" # Check all files exist - for file in pyproject_files + example_pyproject_files + [changelog_file]: + for file in ( + pyproject_files + example_pyproject_files + plugin_files + [changelog_file] + ): if not file.exists(): print(f"Error: {file} not found") sys.exit(1) @@ -155,6 +175,9 @@ def main(): for file in example_pyproject_files: update_example_dependencies(file, new_version) + for file in plugin_files: + update_plugin_version(file, new_version) + # Update CHANGELOG.md update_changelog(changelog_file, new_version) diff --git a/tests/sandbox/test_sandbox.py b/tests/sandbox/test_sandbox.py index ed4a8a2d..1d3635dc 100644 --- a/tests/sandbox/test_sandbox.py +++ b/tests/sandbox/test_sandbox.py @@ -18,6 +18,18 @@ def vcr_cassette_dir(): class TestSandboxBasics: """Test basic sandbox functionality.""" + @pytest.mark.asyncio + async def test_the_documented_modules_import(self, sandbox): + """The modules the instructions and the MCP description promise.""" + result = await sandbox.execute( + "import json, re, math, pathlib, datetime\n" + "import collections, itertools, functools, dataclasses\n" + "print(collections.Counter('aab').most_common(1)," + " list(itertools.islice(itertools.count(), 2)))" + ) + assert result.success, result.stderr + assert "[('a', 2)] [0, 1]" in result.stdout + @pytest.mark.asyncio async def test_execute_simple_code(self, sandbox): """Test executing simple code in the sandbox.""" @@ -112,6 +124,41 @@ class TestSandboxListDocuments: assert "Test Document" in result.stdout assert temp_db_path.stem in result.stdout + @pytest.mark.asyncio + async def test_list_documents_carries_metadata(self, temp_db_path): + """Rows carry the document's metadata, so a corpus-wide pass over it is + one call rather than a file read per document.""" + from docling_core.types.doc.document import DoclingDocument + from docling_core.types.doc.labels import DocItemLabel + + config = AppConfig() + docling = DoclingDocument(name="d") + docling.add_text(label=DocItemLabel.TEXT, text="Test content") + async with HaikuRAG(temp_db_path, create=True) as client: + await client.import_document( + docling, + [ + Chunk( + content="Test content", + embedding=[0.1] * config.embeddings.model.vector_dim, + order=0, + ) + ], + uri="test://doc1", + title="Test Document", + metadata={"author": "Ada"}, + ) + + sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) + try: + result = await sb.execute( + "docs = await list_documents()\nprint(docs[0]['metadata']['author'])" + ) + finally: + await sb.close() + assert result.success, result.stderr + assert "Ada" in result.stdout + class TestSandboxSearch: """Test search function in sandbox.""" @@ -188,6 +235,51 @@ class TestSandboxSearch: assert "str" in result.stdout assert "True" in result.stdout + @pytest.mark.asyncio + async def test_search_returns_the_matched_chunks_metadata( + self, temp_db_path, monkeypatch + ): + """Results carry the stored metadata of the chunk that matched, custom + keys included.""" + from docling_core.types.doc.document import DoclingDocument + from docling_core.types.doc.labels import DocItemLabel + + from haiku.rag.embeddings import EmbedderWrapper + + config = AppConfig() + dim = config.embeddings.model.vector_dim + + async def embed_query(self, text): + return [0.1] * dim + + monkeypatch.setattr(EmbedderWrapper, "embed_query", embed_query) + docling = DoclingDocument(name="d") + docling.add_text(label=DocItemLabel.TEXT, text="Paragraph fourteen.") + async with HaikuRAG(temp_db_path, create=True) as client: + await client.import_document( + docling, + [ + Chunk( + content="Paragraph fourteen.", + embedding=[0.1] * dim, + order=0, + metadata={"para_no": "14"}, + ) + ], + uri="test://paras", + ) + + sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) + try: + result = await sb.execute( + "results = await search('fourteen', limit=1)\n" + "print(results[0]['chunk_meta']['para_no'])" + ) + finally: + await sb.close() + assert result.success, result.stderr + assert "14" in result.stdout + class TestSandboxExternalFunctionEdgeCases: """Test edge cases in external function dispatch.""" @@ -240,6 +332,23 @@ class TestSandboxExternalFunctionEdgeCases: assert not result.success assert "external error" in result.stderr + @pytest.mark.asyncio + async def test_a_failing_search_keeps_its_message_for_the_program( + self, sandbox, monkeypatch + ): + """A host-side failure inside search() reaches the program with its + message, which the agent reads to repair its code.""" + + async def boom(self, *args, **kwargs): + raise ValueError("failed at /secret/path") + + monkeypatch.setattr(HaikuRAG, "search", boom) + + result = await sandbox.execute("await search('hello')") + + assert not result.success + assert "ValueError: failed at /secret/path" in result.stderr + class TestSandboxOutputTruncation: """Test output truncation behavior.""" @@ -312,13 +421,14 @@ class TestSandboxVFS: @pytest.mark.asyncio @pytest.mark.vcr() async def test_metadata_json(self, temp_db_path): - """metadata.json contains document title and uri.""" + """metadata.json contains document title, uri and stored metadata.""" config = AppConfig() async with HaikuRAG(temp_db_path, create=True) as client: doc = await client.create_document( content="Test content", uri="test://doc1", title="Test Document", + metadata={"author": "Ada"}, ) context = AnalysisContext() @@ -328,11 +438,13 @@ class TestSandboxVFS: "import json\n" f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n" "print(meta['title'])\n" - "print(meta['uri'])" + "print(meta['uri'])\n" + "print(meta['metadata']['author'])" ) - assert result.success + assert result.success, result.stderr assert "Test Document" in result.stdout assert "test://doc1" in result.stdout + assert "Ada" in result.stdout @pytest.mark.asyncio @pytest.mark.vcr() @@ -386,6 +498,59 @@ class TestSandboxVFS: assert result.success assert result.stdout.count("True") == 6 + @pytest.mark.asyncio + async def test_chunks_jsonl(self, temp_db_path): + """chunks.jsonl lists a document's chunks in order with their stored + metadata; a chunk found by its metadata leads to its items through + their chunk_ids.""" + from docling_core.types.doc.document import DoclingDocument + from docling_core.types.doc.labels import DocItemLabel + + config = AppConfig() + dim = config.embeddings.model.vector_dim + docling = DoclingDocument(name="d") + docling.add_text(label=DocItemLabel.TEXT, text="Paragraph thirteen.") + docling.add_text(label=DocItemLabel.TEXT, text="Paragraph fourteen.") + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.import_document( + docling, + [ + Chunk( + content="Paragraph thirteen.", + embedding=[0.1] * dim, + order=0, + metadata={"para_no": "13", "doc_item_refs": ["#/texts/0"]}, + ), + Chunk( + content="Paragraph fourteen.", + embedding=[0.1] * dim, + order=1, + metadata={"para_no": "14", "doc_item_refs": ["#/texts/1"]}, + ), + ], + uri="test://paras", + ) + + sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) + try: + result = await sb.execute( + "from pathlib import Path\n" + "import json\n" + f"root = Path('/documents/{doc.id}')\n" + "def rows(name):\n" + " return [json.loads(l) for l in (root / name).read_text().strip().split('\\n')]\n" + "chunks = rows('chunks.jsonl')\n" + "print(len(chunks))\n" + "hit = [c for c in chunks if c['metadata'].get('para_no') == '14']\n" + "print(len(hit))\n" + "items = rows('items.jsonl')\n" + "print([i['text'] for i in items if hit[0]['chunk_id'] in i['chunk_ids']])" + ) + finally: + await sb.close() + assert result.success, result.stderr + assert result.stdout.splitlines() == ["2", "1", "['Paragraph fourteen.']"] + @pytest.mark.asyncio @pytest.mark.vcr() async def test_open_read(self, temp_db_path): @@ -433,7 +598,8 @@ class TestSandboxVFS: @pytest.mark.asyncio @pytest.mark.parametrize( - "filename", ["content.txt", "items.jsonl", "toc.json", "metadata.json"] + "filename", + ["content.txt", "items.jsonl", "chunks.jsonl", "toc.json", "metadata.json"], ) async def test_write_denied_for_every_document_file(self, temp_db_path, filename): """Every file in the document VFS is read-only, metadata.json included.""" @@ -797,6 +963,61 @@ class TestSandboxReadDeadline: cannot check its duration budget while one is in flight. The sandbox enforces the budget itself, before each read.""" + @pytest.mark.asyncio + async def test_the_deadline_covers_reads_from_memory_and_in_code_calls( + self, temp_db_path, monkeypatch + ): + """Once a call's time is up, a file served from memory and an in-code + listing are refused like a database read. A slow first read spends the + budget; the watchdog does not count time spent waiting on the host.""" + from docling_core.types.doc.document import DoclingDocument + from docling_core.types.doc.labels import DocItemLabel + + config = AppConfig() + config.analysis.code_timeout = 1.0 + docling = DoclingDocument(name="d") + docling.add_text(label=DocItemLabel.TEXT, text="Foxes and dogs.") + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.import_document( + docling, + [ + Chunk( + content="Foxes and dogs.", + embedding=[0.1] * config.embeddings.model.vector_dim, + order=0, + ) + ], + uri="test://deadline-paths", + ) + repository = type(client.document_repository) + + async def slow_content(self, *args, **kwargs): + await asyncio.sleep(1.3) + return "body" + + monkeypatch.setattr(repository, "get_content", slow_content) + sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) + try: + result = await sb.execute( + "from pathlib import Path\n" + f"root = Path('/documents/{doc.id}')\n" + "print(len((root / 'content.txt').read_text()))\n" + "try:\n" + " (root / 'metadata.json').read_text()\n" + " print('static: read')\n" + "except Exception as e:\n" + " print('static:', type(e).__name__)\n" + "await list_documents()\n" + "print('listed')" + ) + finally: + await sb.close() + + assert "static: TimeoutError" in result.stdout + assert "listed" not in result.stdout + assert not result.success + assert "time limit exceeded" in result.stderr + @pytest.mark.asyncio async def test_read_after_deadline_raises_without_scheduling(self, sandbox): """A read attempted past the deadline fails instead of querying.""" @@ -825,7 +1046,51 @@ class TestSandboxReadDeadline: sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) - assert sb._session_limits() == {"max_duration_secs": 15.0} + limits = sb._session_limits() + + assert limits["max_duration_secs"] == 15.0 + cap = limits["max_suspensions"] + assert cap is not None + assert cap >= 1_000_000 + + @pytest.mark.asyncio + async def test_a_program_may_read_more_than_a_thousand_times(self, temp_db_path): + """Monty caps host callbacks per checkout at 1000 unless told otherwise; + a corpus-wide pass over documents reads far more than that.""" + from docling_core.types.doc.document import DoclingDocument + from docling_core.types.doc.labels import DocItemLabel + + config = AppConfig() + docling = DoclingDocument(name="d") + docling.add_text(label=DocItemLabel.TEXT, text="Foxes and dogs.") + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.import_document( + docling, + [ + Chunk( + content="Foxes and dogs.", + embedding=[0.1] * config.embeddings.model.vector_dim, + order=0, + ) + ], + uri="test://many-reads", + ) + + sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) + try: + result = await sb.execute( + "from pathlib import Path\n" + f"p = Path('/documents/{doc.id}/content.txt')\n" + "n = 0\n" + "for i in range(1100):\n" + " n += len(p.read_text())\n" + "print(n)" + ) + finally: + await sb.close() + + assert result.success, result.stderr + assert result.stdout.strip() == str(1100 * len("Foxes and dogs.")) @pytest.mark.asyncio async def test_refused_read_fails_the_execution(self, temp_db_path, monkeypatch): diff --git a/tests/sandbox/test_sandbox_toc.py b/tests/sandbox/test_sandbox_toc.py index 2d359212..9ac5c464 100644 --- a/tests/sandbox/test_sandbox_toc.py +++ b/tests/sandbox/test_sandbox_toc.py @@ -16,6 +16,7 @@ import pytest from haiku.rag.client import HaikuRAG from haiku.rag.config.models import AppConfig from haiku.rag.sandbox import AnalysisContext, Sandbox +from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.document import Document from haiku.rag.store.models.document_item import DocumentItem @@ -434,6 +435,66 @@ class TestVfsReadPaths: "nope", ) + async def test_chunks_jsonl_lists_chunks_in_order_with_their_metadata( + self, temp_db_path + ): + """One row per chunk, in chunk order, carrying the stored metadata as + is; the second read of a document is served from the sandbox's cache.""" + config = AppConfig() + dim = config.embeddings.model.vector_dim + async with HaikuRAG(temp_db_path, create=True) as client: + doc_id = await _empty_doc(client, uri="test://paras", title="Paras") + for order, para_no in enumerate(["13", "14"]): + await client.chunk_repository.create( + Chunk( + document_id=doc_id, + content=f"Paragraph {para_no}.", + embedding=[0.1] * dim, + order=order, + metadata={"para_no": para_no, "doc_item_refs": []}, + ) + ) + + sandbox = Sandbox(temp_db_path, config, AnalysisContext()) + first = await _read_vfs_text(sandbox, f"/documents/{doc_id}/chunks.jsonl") + rows = [json.loads(line) for line in first.split("\n")] + + assert [row["metadata"]["para_no"] for row in rows] == ["13", "14"] + assert all(set(row) == {"chunk_id", "metadata"} for row in rows) + assert rows[0]["metadata"] == {"para_no": "13", "doc_item_refs": []} + assert sandbox._chunks_jsonl_cache[doc_id] == first + assert ( + await _read_vfs_text(sandbox, f"/documents/{doc_id}/chunks.jsonl") == first + ) + + async def test_item_range_is_a_line_slice_into_items_jsonl(self, temp_db_path): + """`item_range` indexes lines of items.jsonl, as documented, not item + positions: a gap in positions must not pull the next heading into a + section.""" + async with HaikuRAG(temp_db_path, create=True) as client: + doc_id = await _empty_doc(client, uri="test://slice", title="Slice") + items = [ + _header(doc_id, 0, 1, "Intro"), + _para(doc_id, 1), + _header(doc_id, 3, 1, "Methods"), + _para(doc_id, 4), + ] + await client.document_item_repository.create_items(doc_id, items) + + sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext()) + toc = await _read_toc(sandbox, doc_id) + raw = await _read_vfs_text(sandbox, f"/documents/{doc_id}/items.jsonl") + lines = raw.split("\n") + + intro, methods = toc["tree"] + assert intro["item_range"] == [0, 2] + assert methods["item_range"] == [2, 4] + start, end = intro["item_range"] + assert [json.loads(line)["self_ref"] for line in lines[start:end]] == [ + "#/texts/0", + "#/texts/1", + ] + async def test_toc_skips_gaps_in_item_positions(self, temp_db_path): """Positions need not be contiguous — a heading's span may cover positions that carry no item.""" diff --git a/tests/test_app.py b/tests/test_app.py index ebe43f9b..cf7104da 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -31,10 +31,6 @@ def client(): @pytest.fixture def app(tmp_path, client, monkeypatch): class StubHaikuRAG: - # run_mcp passes db_path positionally; every other caller uses kwargs. - def __init__(self, *args, **kwargs): - pass - @classmethod def _covering(cls, *args, **kwargs): return cls() diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py new file mode 100644 index 00000000..4b6fdc27 --- /dev/null +++ b/tests/test_bump_version.py @@ -0,0 +1,41 @@ +import importlib.util +import json +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "bump_version", Path(__file__).resolve().parents[1] / "scripts" / "bump_version.py" +) +assert _spec is not None and _spec.loader is not None +bump_version = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(bump_version) + + +def test_update_plugin_version_rewrites_only_the_version_field(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + manifest = tmp_path / "plugin.json" + manifest.write_text( + '{\n "name": "haiku-rag",\n "version": "0.1.0",\n "license": "MIT"\n}\n' + ) + + bump_version.update_plugin_version(manifest, "0.2.0") + + assert json.loads(manifest.read_text()) == { + "name": "haiku-rag", + "version": "0.2.0", + "license": "MIT", + } + assert manifest.read_text().endswith("}\n") + + +def test_the_shipped_plugin_manifests_carry_the_package_version(): + root = Path(__file__).resolve().parents[1] + package_version = bump_version.get_current_version( + root / "haiku_rag_slim" / "pyproject.toml" + ) + for client in ("claude", "codex"): + manifest = json.loads( + ( + root / "plugins" / "haiku-rag" / f".{client}-plugin" / "plugin.json" + ).read_text() + ) + assert manifest["version"] == package_version diff --git a/tests/test_chunk.py b/tests/test_chunk.py index 98b24674..a24ff97a 100644 --- a/tests/test_chunk.py +++ b/tests/test_chunk.py @@ -264,6 +264,37 @@ def test_search_result_format_for_agent_omits_chunk_meta(): assert "para_no" not in formatted +def test_search_result_format_for_agent_chunk_meta_is_opt_in(): + """A caller that asks sees the chunk's own metadata, never the structural + keys haiku.rag stores beside it.""" + result = SearchResult( + content="Some content.", + score=0.9, + chunk_id="chunk-1", + chunk_meta={ + "para_no": "12", + "doc_item_refs": ["#/texts/0"], + "page_numbers": [1], + "headings": ["Intro"], + "labels": ["paragraph"], + }, + ) + + opted = result.format_for_agent(rank=1, total=1, include_chunk_meta=True) + + assert "para_no" in opted + assert "12" in opted + assert "doc_item_refs" not in opted + assert "#/texts/0" not in opted + + structural_only = result.model_copy( + update={"chunk_meta": {"doc_item_refs": ["#/texts/0"], "page_numbers": [1]}} + ) + assert structural_only.format_for_agent( + rank=1, total=1, include_chunk_meta=True + ) == structural_only.format_for_agent(rank=1, total=1) + + def test_search_result_format_for_agent_omits_document_meta(): """Document metadata is UI plumbing, never shown to the model.""" result = SearchResult( @@ -414,6 +445,17 @@ def test_search_result_format_for_agent_source_line(fields, expected_source): assert expected_source in result.format_for_agent() +def test_search_result_format_for_agent_document_id_is_opt_in(): + """The capabilities' rendering is unchanged; only a caller that asks gets + the id it will fetch the document by.""" + result = SearchResult(content="x", score=0.5, chunk_id="c1", document_id="doc-1") + + assert "Document ID" not in result.format_for_agent(rank=1, total=1) + assert "Document ID: doc-1" in result.format_for_agent( + rank=1, total=1, include_document_id=True + ) + + @pytest.mark.parametrize( "labels,expected", [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c2a7b44..50d389a0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1050,6 +1050,21 @@ def test_mcp_without_stdio_leaves_the_transport_unset(app_stub): assert app_stub.run_mcp.call_args.kwargs["transport"] is None +def test_mcp_covers_the_configured_set(monkeypatch): + seen = {} + + def create_app(db=None, *, covers_set=False): + seen["covers_set"] = covers_set + return AsyncMock() + + monkeypatch.setattr("haiku.rag.cli.create_app", create_app) + + result = runner.invoke(cli, ["mcp", "--stdio"]) + + assert result.exit_code == 0, result.output + assert seen["covers_set"] is True + + def test_version_flag_prints_the_version(): result = runner.invoke(cli, ["--version"]) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7b465b45..6252ab8b 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,10 +1,16 @@ +import re +from pathlib import Path + import pytest +from fastmcp.exceptions import ToolError from haiku.rag.client import HaikuRAG from haiku.rag.mcp import _covering as _mcp_covering from haiku.rag.mcp import create_mcp_server +from haiku.rag.store.exceptions import UnknownDatabaseError from haiku.rag.store.models import Chunk, Document, SearchResult from haiku.rag.tools.document import DocumentInfo +from tests.multi_db.helpers import _config, _seed, _seed_expandable @pytest.fixture(autouse=True) @@ -29,6 +35,22 @@ def mock_embedder(monkeypatch): monkeypatch.setattr(EmbedderWrapper, "embed_documents", fake_embed_documents) +@pytest.fixture +def multimodal_embedder(monkeypatch): + """An embedder reporting image support, so the image-query tool registers.""" + from haiku.rag.embeddings import EmbedderWrapper + + class StubMultimodal(EmbedderWrapper): + supports_images = True + + def __init__(self): + super().__init__(embedder=None, vector_dim=2560) + + monkeypatch.setattr( + "haiku.rag.embeddings.get_embedder", lambda *a, **kw: StubMultimodal() + ) + + @pytest.fixture async def mcp_db(temp_db_path): """Create a test database with sample documents.""" @@ -37,6 +59,7 @@ async def mcp_db(temp_db_path): "Artificial intelligence is transforming industries worldwide.", title="AI Overview", uri="test://ai-overview", + metadata={"author": "Ada"}, ) await rag.create_document( "Machine learning is a subset of artificial intelligence.", @@ -46,38 +69,105 @@ async def mcp_db(temp_db_path): return temp_db_path +@pytest.fixture +async def two_dbs(tmp_path): + """Two configured databases, alpha and beta, one document each.""" + config = _config(tmp_path, ["alpha", "beta"]) + await _seed(config, "alpha", ["alpha document about cats"]) + await _seed(config, "beta", ["beta document about cats"]) + return config + + +def _covering_all(config): + from haiku.rag.client.scope import DatabaseScope + + return _mcp_covering(DatabaseScope.resolve(config), config) + + async def _get_tool(mcp, name): """Get a tool function from an MCP server by name.""" tool = await mcp.get_tool(name) return tool.fn +async def _call(mcp, name, **kwargs): + """Call a tool over the wire, returning the result whether or not it errored.""" + from fastmcp import Client + + async with Client(mcp) as client: + return await client.call_tool(name, kwargs, raise_on_error=False) + + +def _results(tool_result) -> list[dict]: + """A tool's structured result list, as the client sees it.""" + return tool_result.structured_content["result"] + + +_HEADER = re.compile(r"^\[[^\]]+\] \[rank \d+ of \d+\]$", re.MULTILINE) + + +def _rendered(search_result) -> list[str]: + """The result blocks of a search, split from the text the model reads.""" + text = search_result.content[0].text + starts = [match.start() for match in _HEADER.finditer(text)] + return [text[a:b].strip() for a, b in zip(starts, starts[1:] + [len(text)])] + + +def _line(block: str, name: str) -> str | None: + """The value of a `Name: value` line in a rendered result, if present.""" + match = re.search(rf"^{re.escape(name)}: (.+)$", block, re.MULTILINE) + return match.group(1) if match else None + + +def _png_b64() -> str: + import base64 + from io import BytesIO + + from PIL import Image as PILImage + + buf = BytesIO() + PILImage.new("RGB", (4, 4), "red").save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + + class TestMCPReadTools: @pytest.mark.asyncio async def test_search_documents(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) search = await _get_tool(mcp, "search_documents") - results = await search(query="artificial intelligence") - assert len(results) > 0 - assert all(isinstance(r, SearchResult) for r in results) + blocks = _rendered(await search(query="artificial intelligence")) + assert blocks + assert all("Content:" in block for block in blocks) @pytest.mark.asyncio async def test_search_documents_with_limit(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) search = await _get_tool(mcp, "search_documents") - results = await search(query="artificial intelligence", limit=1) - assert len(results) == 1 + blocks = _rendered(await search(query="artificial intelligence", limit=1)) + assert len(blocks) == 1 @pytest.mark.asyncio @pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") - async def test_search_documents_preserves_chunk_meta_through_serialization( - self, mcp_db - ): - """Chunk_meta must survive FastMCP's actual wire serialization. + async def test_search_documents_with_filter(self, mcp_db): + from fastmcp import Client - Calling the tool function directly bypasses that serialization step entirely.""" + async with Client(create_mcp_server(mcp_db)) as client: + result = await client.call_tool( + "search_documents", + {"query": "artificial intelligence", "filter": "title = 'ML Basics'"}, + ) + + blocks = _rendered(result) + assert blocks + assert all('"ML Basics"' in block for block in blocks) + + @pytest.mark.asyncio + @pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") + async def test_search_documents_carries_the_matched_chunks_metadata(self, mcp_db): + """The chunk's own metadata reaches the text the model reads, over the + wire, without haiku.rag's structural keys.""" from fastmcp import Client async with HaikuRAG(mcp_db, create=True) as rag: @@ -93,21 +183,35 @@ class TestMCPReadTools: ) await rag.store.chunks_table.optimize() - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) async with Client(mcp) as client: result = await client.call_tool( "search_documents", {"query": "artificial intelligence"} ) - results = result.structured_content["result"] - assert results - assert any( - r["chunk_meta"] == {"fake-metadata-for-testing": "42"} for r in results - ) + text = result.content[0].text + assert "fake-metadata-for-testing" in text + assert "42" in text + assert "doc_item_refs" not in text + assert result.structured_content is None + + @pytest.mark.asyncio + @pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") + async def test_search_results_come_expanded(self, tmp_path): + """The passage is the hit in its section, as the in-process agents read + it, not the chunk that matched.""" + config = _config(tmp_path, ["alpha"]) + sentences = ["Gardens need water.", "Roses need pruning.", "Tulips need sun."] + await _seed_expandable(config, "alpha", sentences) + + result = await _call(_covering_all(config), "search_documents", query="gardens") + + [hit] = _rendered(result) + assert all(sentence in hit for sentence in sentences) @pytest.mark.asyncio async def test_get_document(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) get_doc = await _get_tool(mcp, "get_document") # First get the ID via list @@ -122,7 +226,7 @@ class TestMCPReadTools: @pytest.mark.asyncio async def test_get_document_excludes_docling_fields(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) get_doc = await _get_tool(mcp, "get_document") list_docs = await _get_tool(mcp, "list_documents") @@ -134,17 +238,9 @@ class TestMCPReadTools: assert "docling_document" not in serialized assert "docling_version" not in serialized - @pytest.mark.asyncio - async def test_get_document_not_found(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) - get_doc = await _get_tool(mcp, "get_document") - - result = await get_doc(document_id="nonexistent-id") - assert result is None - @pytest.mark.asyncio async def test_list_documents(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) list_docs = await _get_tool(mcp, "list_documents") results = await list_docs() @@ -153,7 +249,7 @@ class TestMCPReadTools: @pytest.mark.asyncio async def test_list_documents_with_limit(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) list_docs = await _get_tool(mcp, "list_documents") results = await list_docs(limit=1) @@ -161,74 +257,709 @@ class TestMCPReadTools: @pytest.mark.asyncio async def test_list_documents_with_filter(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) list_docs = await _get_tool(mcp, "list_documents") results = await list_docs(filter="title = 'AI Overview'") assert len(results) == 1 assert results[0].title == "AI Overview" - -class TestMCPWriteTools: @pytest.mark.asyncio - async def test_write_tools_registered_when_not_read_only(self, temp_db_path): - async with HaikuRAG(temp_db_path, create=True): - pass - mcp = create_mcp_server(temp_db_path, read_only=False) - tools = await mcp.list_tools() - tool_names = [t.name for t in tools] - assert "add_document_from_text" in tool_names - assert "add_document_from_file" in tool_names - assert "add_document_from_url" in tool_names - assert "delete_document" in tool_names + @pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") + async def test_list_documents_carries_metadata(self, mcp_db): + from fastmcp import Client + + async with Client(create_mcp_server(mcp_db)) as client: + result = await client.call_tool("list_documents", {}) + + [overview] = [ + d + for d in result.structured_content["result"] + if d["title"] == "AI Overview" + ] + assert overview["metadata"] == {"author": "Ada"} + + +@pytest.fixture +async def outlined_db(temp_db_path): + """A database with one document whose items carry a heading hierarchy. + + Rows are written through the repositories, so no embedder is involved. + Returns the path and the document id.""" + from haiku.rag.store.models.document import Document as DocumentModel + from haiku.rag.store.models.document_item import DocumentItem + + def header(pos, level, text): + return DocumentItem( + document_id="", + position=pos, + self_ref=f"#/texts/{pos}", + label="section_header", + text=text, + page_numbers=[pos // 4 + 1], + heading_level=level, + ) + + def para(pos): + return DocumentItem( + document_id="", + position=pos, + self_ref=f"#/texts/{pos}", + label="paragraph", + text=f"para{pos}", + page_numbers=[pos // 4 + 1], + ) + + async with HaikuRAG(temp_db_path, create=True) as rag: + doc = await rag.document_repository.create( + DocumentModel(content="x", uri="test://outlined", title="Outlined") + ) + items = [ + header(0, 1, "Intro"), + para(1), + header(2, 2, "Background"), + para(3), + header(4, 3, "Prior Work"), + para(5), + header(6, 2, "Approach"), + para(7), + header(8, 1, "Methods"), + para(9), + ] + for item in items: + item.document_id = doc.id + await rag.document_item_repository.create_items(doc.id, items) + return temp_db_path, doc.id + + +class TestMCPDocumentNavigation: + @pytest.mark.asyncio + async def test_the_outline_nests_headings_by_level(self, outlined_db): + db, doc_id = outlined_db + outline = await _get_tool(create_mcp_server(db), "get_document_outline") + + roots = await outline(document_id=doc_id) + + assert [n.title for n in roots] == ["Intro", "Methods"] + intro = roots[0] + assert (intro.id, intro.level, intro.page_numbers) == ("#/texts/0", 1, [1]) + assert [c.title for c in intro.children] == ["Background", "Approach"] + assert [c.title for c in intro.children[0].children] == ["Prior Work"] + assert intro.children[0].children[0].level == 3 + assert roots[1].children == [] @pytest.mark.asyncio - async def test_write_tools_not_registered_when_read_only(self, temp_db_path): - async with HaikuRAG(temp_db_path, create=True): - pass - mcp = create_mcp_server(temp_db_path, read_only=True) - tools = await mcp.list_tools() - tool_names = [t.name for t in tools] - assert "add_document_from_text" not in tool_names - assert "delete_document" not in tool_names + async def test_a_document_without_headings_has_an_empty_outline(self, mcp_db): + mcp = create_mcp_server(mcp_db) + [doc] = await (await _get_tool(mcp, "list_documents"))(limit=1) + outline = await _get_tool(mcp, "get_document_outline") + + assert await outline(document_id=doc.id) == [] @pytest.mark.asyncio - async def test_add_document_from_text(self, temp_db_path): - async with HaikuRAG(temp_db_path, create=True): - pass - mcp = create_mcp_server(temp_db_path, read_only=False) - add_text = await _get_tool(mcp, "add_document_from_text") + async def test_a_section_covers_its_subsections_and_stops_at_its_sibling( + self, outlined_db + ): + db, doc_id = outlined_db + section = await _get_tool(create_mcp_server(db), "get_document_section") - doc_id = await add_text(content="Test content for MCP", title="MCP Test Doc") - assert doc_id is not None + background = await section(document_id=doc_id, section_id="#/texts/2") - get_doc = await _get_tool(mcp, "get_document") - doc = await get_doc(document_id=doc_id) - assert doc.title == "MCP Test Doc" - assert doc.content == "Test content for MCP" + assert background.title == "Background" + assert background.content.split("\n\n") == [ + "Background", + "para3", + "Prior Work", + "para5", + ] + assert background.page_numbers == [1] + + intro = await section(document_id=doc_id, section_id="#/texts/0") + assert intro.content.startswith("Intro") + assert "para7" in intro.content + assert "Methods" not in intro.content @pytest.mark.asyncio - async def test_delete_document(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=False) + async def test_a_section_stops_at_the_next_heading_across_a_position_gap( + self, temp_db_path + ): + from haiku.rag.store.models.document import Document as DocumentModel + from haiku.rag.store.models.document_item import DocumentItem + + def item(pos, label, text, level=0): + return DocumentItem( + document_id="", + position=pos, + self_ref=f"#/texts/{pos}", + label=label, + text=text, + heading_level=level, + ) + + async with HaikuRAG(temp_db_path, create=True) as rag: + doc = await rag.document_repository.create( + DocumentModel(content="x", uri="test://gapped", title="Gapped") + ) + items = [ + item(0, "section_header", "Intro", 1), + item(1, "paragraph", "para1"), + item(3, "section_header", "Methods", 1), + item(4, "paragraph", "para4"), + ] + for i in items: + i.document_id = doc.id + await rag.document_item_repository.create_items(doc.id, items) + + section = await _call( + create_mcp_server(temp_db_path), + "get_document_section", + document_id=doc.id, + section_id="#/texts/0", + ) + + assert section.structured_content["content"] == "Intro\n\npara1" + + @pytest.mark.asyncio + async def test_an_unknown_section_or_document_is_an_error(self, outlined_db): + db, doc_id = outlined_db + mcp = create_mcp_server(db) + section = await _get_tool(mcp, "get_document_section") + outline = await _get_tool(mcp, "get_document_outline") + + with pytest.raises(ToolError, match="#/texts/99"): + await section(document_id=doc_id, section_id="#/texts/99") + with pytest.raises(ToolError, match="nonexistent-id"): + await outline(document_id="nonexistent-id") + with pytest.raises(ToolError, match="nonexistent-id"): + await section(document_id="nonexistent-id", section_id="#/texts/0") + + @pytest.mark.asyncio + @pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") + async def test_outline_and_section_serialize_over_the_wire(self, outlined_db): + db, doc_id = outlined_db + mcp = create_mcp_server(db) + + outline = await _call(mcp, "get_document_outline", document_id=doc_id) + section = await _call( + mcp, "get_document_section", document_id=doc_id, section_id="#/texts/8" + ) + + assert not outline.is_error and not section.is_error + [intro, methods] = outline.structured_content["result"] + assert set(intro) == {"id", "title", "level", "page_numbers", "children"} + assert intro["children"][0]["children"][0]["title"] == "Prior Work" + assert set(section.structured_content) == { + "id", + "title", + "page_numbers", + "content", + } + assert section.structured_content["content"] == "Methods\n\npara9" + + @pytest.mark.asyncio + async def test_source_routes_to_the_database_holding_the_document(self, two_dbs): + from haiku.rag.store.models.document_item import DocumentItem + + async with HaikuRAG(config=two_dbs, sources=["beta"]) as beta: + [doc] = await beta.list_documents() + await beta.document_item_repository.create_items( + doc.id, + [ + DocumentItem( + document_id=doc.id, + position=0, + self_ref="#/texts/0", + label="section_header", + text="Only in beta", + heading_level=1, + ) + ], + ) + mcp = _covering_all(two_dbs) + outline = await _get_tool(mcp, "get_document_outline") + section = await _get_tool(mcp, "get_document_section") + + named = await outline(document_id=doc.id, source="beta") + found = await outline(document_id=doc.id) + assert [n.title for n in named] == [n.title for n in found] == ["Only in beta"] + assert ( + await section(document_id=doc.id, section_id="#/texts/0", source="beta") + ).title == "Only in beta" + with pytest.raises(UnknownDatabaseError, match="nope"): + await outline(document_id=doc.id, source="nope") + with pytest.raises(ToolError, match=doc.id): + await outline(document_id=doc.id, source="alpha") + + +@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") +class TestMCPSearchResultShape: + """Text as the in-process agents read it, one image per distinct picture, + and the results as structured content without picture bytes.""" + + @staticmethod + def _serve(monkeypatch, results): + async def fake_search(self, *args, **kwargs): + return results + + monkeypatch.setattr(HaikuRAG, "search", fake_search) + + @pytest.mark.asyncio + async def test_text_ranks_then_one_image_per_distinct_picture( + self, mcp_db, monkeypatch + ): + from mcp.types import ImageContent, TextContent + + shared = {"#/pictures/0": _png_b64()} + self._serve( + monkeypatch, + [ + SearchResult( + content="a", + score=0.9, + chunk_id="c1", + document_id="d1", + image_data=shared, + ), + SearchResult( + content="b", + score=0.8, + chunk_id="c2", + document_id="d1", + image_data=shared, + ), + SearchResult( + content="c", + score=0.7, + chunk_id="c3", + document_id="d2", + image_data={"#/pictures/3": _png_b64()}, + ), + ], + ) + + result = await _call(create_mcp_server(mcp_db), "search_documents", query="q") + + text, *rest = result.content + assert isinstance(text, TextContent) + assert "[rank 1 of 3]" in text.text and "[rank 3 of 3]" in text.text + assert "score" not in text.text + assert "Document ID: d1" in text.text + images = [block for block in rest if isinstance(block, ImageContent)] + labels = [block.text for block in rest if isinstance(block, TextContent)] + assert len(images) == 2 + assert all(image.mime_type == "image/png" for image in images) + assert [ + label for label in labels if "[c1]" in label and "#/pictures/0" in label + ] + assert [ + label for label in labels if "[c3]" in label and "#/pictures/3" in label + ] + assert [block.split("]")[0] for block in _rendered(result)] == [ + "[c1", + "[c2", + "[c3", + ] + assert result.structured_content is None + + @pytest.mark.asyncio + async def test_an_undecodable_picture_yields_no_image(self, mcp_db, monkeypatch): + import base64 + + self._serve( + monkeypatch, + [ + SearchResult( + content="a", + score=0.9, + chunk_id="c1", + document_id="d1", + image_data={ + "#/pictures/0": base64.b64encode(b"not a png").decode() + }, + ) + ], + ) + + result = await _call(create_mcp_server(mcp_db), "search_documents", query="q") + + assert len(result.content) == 1 + assert "[rank 1 of 1]" in result.content[0].text + + @pytest.mark.asyncio + async def test_no_results_says_so(self, mcp_db, monkeypatch): + self._serve(monkeypatch, []) + + result = await _call(create_mcp_server(mcp_db), "search_documents", query="q") + + assert [block.text for block in result.content] == ["No results found."] + assert result.structured_content is None + + @pytest.mark.asyncio + async def test_search_text_alone_drives_the_document_tools(self, two_dbs): + """Over two databases, every result's `Document ID` and `Collection` + parsed from the text are working arguments for the outline and + section tools.""" + import re + + from haiku.rag.store.models.document_item import DocumentItem + + for name in ("alpha", "beta"): + async with HaikuRAG(config=two_dbs, sources=[name]) as rag: + [doc] = await rag.list_documents() + await rag.document_item_repository.create_items( + doc.id, + [ + DocumentItem( + document_id=doc.id, + position=0, + self_ref="#/texts/0", + label="section_header", + text=f"Heading in {name}", + heading_level=1, + ) + ], + ) + mcp = _covering_all(two_dbs) + + search = await _call(mcp, "search_documents", query="cats") + pairs = re.findall( + r"Document ID: (\S+)\nCollection: (\S+)", search.content[0].text + ) + + assert len(pairs) == len(_rendered(search)) == 2 + assert {source for _, source in pairs} == {"alpha", "beta"} + for document_id, source in pairs: + outline = await _call( + mcp, "get_document_outline", document_id=document_id, source=source + ) + [node] = _results(outline) + section = await _call( + mcp, + "get_document_section", + document_id=document_id, + section_id=node["id"], + source=source, + ) + assert section.structured_content["title"] == f"Heading in {source}" + + +@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") +class TestMCPDescribesItself: + """What a client learns from initialize and list_tools, over the wire.""" + + @pytest.mark.asyncio + async def test_instructions_and_version_are_set(self, mcp_db): + from importlib import metadata + + from fastmcp import Client + + async with Client(create_mcp_server(mcp_db)) as client: + instructions = client.instructions + server_info = client.server_info + + assert instructions + assert server_info is not None + assert server_info.version == metadata.version("haiku.rag-slim") + + @pytest.mark.asyncio + async def test_instructions_name_the_collections_when_covering_several( + self, two_dbs + ): + from fastmcp import Client + + from haiku.rag.client.scope import DatabaseScope + + async with Client(_covering_all(two_dbs)) as client: + covering_both = client.instructions + one = DatabaseScope.resolve(two_dbs, database_name="alpha") + async with Client(_mcp_covering(one, two_dbs)) as client: + covering_one = client.instructions + + assert "alpha" in covering_both + assert "beta" in covering_both + assert "beta" not in covering_one + + @pytest.mark.asyncio + async def test_instructions_carry_the_domain_preamble(self, mcp_db): + from fastmcp import Client + + from haiku.rag.config import get_config + + config = get_config().model_copy(deep=True) + config.prompts.domain_preamble = "Everything here is about zebras." + + async with Client(create_mcp_server(mcp_db, config=config)) as client: + with_preamble = client.instructions + async with Client(create_mcp_server(mcp_db)) as client: + without = client.instructions + + assert "Everything here is about zebras." in with_preamble + assert "zebras" not in without + + @pytest.mark.asyncio + async def test_every_tool_is_annotated_read_only(self, mcp_db, multimodal_embedder): + from fastmcp import Client + + async with Client(create_mcp_server(mcp_db)) as client: + tools = await client.list_tools() + + assert len(tools) == 7 + for tool in tools: + assert tool.annotations is not None, tool.name + assert tool.annotations.read_only_hint is True, tool.name + assert tool.annotations.open_world_hint is False, tool.name + assert tool.annotations.title, tool.name + + @pytest.mark.asyncio + async def test_every_parameter_is_described(self, mcp_db, multimodal_embedder): + from fastmcp import Client + + async with Client(create_mcp_server(mcp_db)) as client: + tools = await client.list_tools() + + undescribed = [ + f"{tool.name}.{name}" + for tool in tools + for name, schema in tool.input_schema.get("properties", {}).items() + if not schema.get("description") + ] + assert len(tools) == 7 + assert undescribed == [] + + +class TestMCPToolSet: + @pytest.mark.asyncio + async def test_the_server_registers_read_tools_only(self, mcp_db): + mcp = create_mcp_server(mcp_db) + + assert {t.name for t in await mcp.list_tools()} == { + "search_documents", + "get_document", + "get_document_outline", + "get_document_section", + "list_documents", + "execute_code", + } + + +_COUNT_DOCUMENTS = ( + "from pathlib import Path\n" + "n = 0\n" + "for d in Path('/documents').iterdir():\n" + " n += 1\n" + "print(n)" +) + + +class TestMCPExecuteCode: + """`execute_code` runs one program per call in the analysis sandbox over + the documents the filter and sources select, and returns what it printed.""" + + @pytest.mark.asyncio + async def test_a_program_reads_the_documents_and_returns_what_it_printed( + self, mcp_db + ): + result = await _call( + create_mcp_server(mcp_db), "execute_code", code=_COUNT_DOCUMENTS + ) + + assert not result.is_error + assert result.content[0].text.strip() == "2" + + @pytest.mark.asyncio + async def test_a_silent_program_says_so(self, mcp_db): + result = await _call(create_mcp_server(mcp_db), "execute_code", code="x = 1") + + assert not result.is_error + assert result.content[0].text == "No output." + + @pytest.mark.asyncio + async def test_filter_narrows_the_documents_a_program_sees(self, mcp_db): + result = await _call( + create_mcp_server(mcp_db), + "execute_code", + code=_COUNT_DOCUMENTS, + filter="title = 'AI Overview'", + ) + + assert result.content[0].text.strip() == "1" + + @pytest.mark.asyncio + async def test_sources_narrows_the_documents_a_program_sees(self, two_dbs): + mcp = _covering_all(two_dbs) + + both = await _call(mcp, "execute_code", code=_COUNT_DOCUMENTS) + beta = await _call(mcp, "execute_code", code=_COUNT_DOCUMENTS, sources=["beta"]) + + assert both.content[0].text.strip() == "2" + assert beta.content[0].text.strip() == "1" + + @pytest.mark.asyncio + async def test_a_failing_program_is_an_error_carrying_the_cause_and_its_output( + self, mcp_db + ): + code = ( + "from pathlib import Path\n" + "print('before')\n" + "for d in Path('/documents').iterdir():\n" + " for line in open(d / 'items.jsonl'):\n" + " pass" + ) + + result = await _call(create_mcp_server(mcp_db), "execute_code", code=code) + + assert result.is_error + text = result.content[0].text + assert "not iterable" in text + assert ".readlines()" in text + assert "Output: before" in text + + @pytest.mark.asyncio + async def test_calls_share_no_state(self, mcp_db): + mcp = create_mcp_server(mcp_db) + + first = await _call(mcp, "execute_code", code="x = 1\nprint(x)") + second = await _call(mcp, "execute_code", code="print(x)") + + assert first.content[0].text.strip() == "1" + assert second.is_error + assert "NameError" in second.content[0].text + + @pytest.mark.asyncio + async def test_every_call_closes_its_sandbox(self, mcp_db, monkeypatch): + from haiku.rag.sandbox import Sandbox + + closed = [] + close = Sandbox.close + + async def closing(self): + closed.append(self) + await close(self) + + monkeypatch.setattr(Sandbox, "close", closing) + mcp = create_mcp_server(mcp_db) + + await _call(mcp, "execute_code", code="print(1)") + await _call(mcp, "execute_code", code="raise ValueError('x')") + + assert len(closed) == 2 + assert closed[0] is not closed[1] + + @pytest.mark.asyncio + async def test_a_program_reaches_chunk_metadata(self, mcp_db): + async with HaikuRAG(mcp_db, create=True) as rag: + doc = await rag.get_document_by_uri("test://ai-overview") + embedding = (await rag.embedder.embed_documents(["x"]))[0] + await rag.chunk_repository.create( + Chunk( + document_id=doc.id, + content="Paragraph fourteen.", + metadata={"para_no": "14"}, + embedding=embedding, + ) + ) + code = ( + "from pathlib import Path\n" + "import json\n" + f"text = Path('/documents/{doc.id}/chunks.jsonl').read_text()\n" + "rows = [json.loads(line) for line in text.strip().split('\\n')]\n" + "print(len([r for r in rows if r['metadata'].get('para_no') == '14']))" + ) + + result = await _call(create_mcp_server(mcp_db), "execute_code", code=code) + + assert not result.is_error, result.content[0].text + assert result.content[0].text.strip() == "1" + + +class TestMCPCoversTheConfiguredSet: + @pytest.mark.asyncio + async def test_results_name_the_database_they_came_from(self, two_dbs): + mcp = _covering_all(two_dbs) + search = await _get_tool(mcp, "search_documents") + + blocks = _rendered(await search(query="cats")) + + assert {_line(block, "Collection") for block in blocks} == {"alpha", "beta"} + + @pytest.mark.asyncio + async def test_sources_narrows_the_search(self, two_dbs): + mcp = _covering_all(two_dbs) + search = await _get_tool(mcp, "search_documents") + + blocks = _rendered(await search(query="cats", sources=["beta"])) + + assert blocks + assert {_line(block, "Collection") for block in blocks} == {"beta"} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "tool_name,kwargs", + [ + ("search_documents", {"query": "cats", "sources": ["nope"]}), + ( + "search_documents_by_image", + {"image_base64": "AAAA", "sources": ["nope"]}, + ), + ("get_document", {"document_id": "x", "source": "nope"}), + ("execute_code", {"code": "print(1)", "sources": ["nope"]}), + ], + ) + async def test_an_unknown_database_is_an_error_not_an_empty_result( + self, two_dbs, multimodal_embedder, tool_name, kwargs + ): + result = await _call(_covering_all(two_dbs), tool_name, **kwargs) + + assert result.is_error + assert "nope" in result.content[0].text + + @pytest.mark.asyncio + async def test_a_filtered_search_touches_only_the_selected_databases(self, two_dbs): + """alpha is gone; a filtered search selecting beta must not notice.""" + import shutil + + shutil.rmtree(two_dbs.lancedb.databases["alpha"]) + mcp = _covering_all(two_dbs) + search = await _get_tool(mcp, "search_documents") + + blocks = _rendered( + await search(query="cats", filter="uri LIKE '%beta%'", sources=["beta"]) + ) + assert blocks + assert {_line(block, "Collection") for block in blocks} == {"beta"} + none = await search(query="cats", filter="uri LIKE '%beta%'", sources=[]) + assert _rendered(none) == [] + + @pytest.mark.asyncio + async def test_the_listing_covers_every_database(self, two_dbs): + mcp = _covering_all(two_dbs) list_docs = await _get_tool(mcp, "list_documents") - delete_doc = await _get_tool(mcp, "delete_document") - docs = await list_docs() - assert len(docs) == 2 + documents = await list_docs() - result = await delete_doc(document_id=docs[0].id) - assert result is True - - docs_after = await list_docs() - assert len(docs_after) == 1 + assert {d.source for d in documents} == {"alpha", "beta"} @pytest.mark.asyncio - async def test_delete_document_not_found(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=False) - delete_doc = await _get_tool(mcp, "delete_document") + async def test_get_document_reaches_whichever_database_holds_it(self, two_dbs): + mcp = _covering_all(two_dbs) + list_docs = await _get_tool(mcp, "list_documents") + get_doc = await _get_tool(mcp, "get_document") + [beta] = [d for d in await list_docs() if d.source == "beta"] - result = await delete_doc(document_id="nonexistent-id") - assert result is False + found = await get_doc(document_id=beta.id) + named = await get_doc(document_id=beta.id, source="beta") + + assert found.id == named.id == beta.id + assert found.source == named.source == "beta" + + @pytest.mark.asyncio + async def test_the_public_factory_covers_a_configured_set(self, two_dbs): + mcp = create_mcp_server(config=two_dbs) + search = await _get_tool(mcp, "search_documents") + + blocks = _rendered(await search(query="cats")) + + assert {_line(block, "Collection") for block in blocks} == {"alpha", "beta"} class TestMCPImageQuery: @@ -237,286 +968,202 @@ class TestMCPImageQuery: @pytest.mark.asyncio async def test_image_query_tool_absent_for_text_only_embedder(self, mcp_db): """Default text-only embedder must not expose the image-query tool.""" - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) names = {t.name for t in await mcp.list_tools()} assert "search_documents_by_image" not in names @pytest.mark.asyncio async def test_image_query_tool_registered_for_multimodal_embedder( - self, mcp_db, monkeypatch + self, mcp_db, multimodal_embedder, monkeypatch ): """When the embedder reports supports_images=True, the tool exists - and routes a base64 image through ``client.search``.""" - from haiku.rag.embeddings import EmbedderWrapper + and routes the decoded image and the selection through ``client.search``.""" + seen = {} - class StubMultimodal(EmbedderWrapper): - supports_images = True + async def fake_search(self, query, **kwargs): + seen.update(query=query, **kwargs) + return [] - def __init__(self): - super().__init__(embedder=None, vector_dim=2560) + monkeypatch.setattr(HaikuRAG, "search", fake_search) - async def embed_image(self, image): - # Produce a deterministic-ish vector of the right dim. - return [0.0] * 2560 - - monkeypatch.setattr( - "haiku.rag.embeddings.get_embedder", - lambda *a, **kw: StubMultimodal(), - ) - - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) names = {t.name for t in await mcp.list_tools()} assert "search_documents_by_image" in names search_by_image = await _get_tool(mcp, "search_documents_by_image") - # Standalone PNG header (won't decode to a real image but our stub doesn't care). import base64 - png_b64 = base64.b64encode(b"\x89PNG\r\n\x1a\n").decode("ascii") - results = await search_by_image(image_base64=png_b64) - # Empty list is fine (the stub vector won't match the toy fixture). - assert isinstance(results, list) - - @pytest.mark.asyncio - async def test_image_query_returns_empty_on_invalid_base64( - self, mcp_db, monkeypatch - ): - """Garbage base64 from the caller is swallowed, returning an empty - list rather than crashing the MCP server.""" - from haiku.rag.embeddings import EmbedderWrapper - - class StubMultimodal(EmbedderWrapper): - supports_images = True - - def __init__(self): - super().__init__(embedder=None, vector_dim=2560) - - monkeypatch.setattr( - "haiku.rag.embeddings.get_embedder", - lambda *a, **kw: StubMultimodal(), + png = b"\x89PNG\r\n\x1a\n" + results = await search_by_image( + image_base64=base64.b64encode(png).decode("ascii"), + filter="uri LIKE 'x%'", + sources=[], ) - mcp = create_mcp_server(mcp_db, read_only=True) + assert _rendered(results) == [] + assert seen["query"] == png + assert seen["filter"] == "uri LIKE 'x%'" + assert seen["sources"] == [] + + @pytest.mark.asyncio + async def test_image_query_rejects_characters_outside_the_alphabet( + self, mcp_db, multimodal_embedder, monkeypatch + ): + """A lenient decoder would drop the stray characters and search.""" + searched = False + + async def fake_search(self, query, **kwargs): + nonlocal searched + searched = True + return [] + + monkeypatch.setattr(HaikuRAG, "search", fake_search) + mcp = create_mcp_server(mcp_db) search_by_image = await _get_tool(mcp, "search_documents_by_image") - # Not valid base64 (contains non-base64 chars) — the strict decoder - # in search_documents_by_image rejects it. - results = await search_by_image(image_base64="!!! not base64 !!!") - assert results == [] + with pytest.raises(ToolError): + await search_by_image(image_base64="AAAA!!!!") + assert not searched -class TestMCPImageInput: - @pytest.mark.asyncio - async def test_ask_question_decodes_images(self, mcp_db, monkeypatch): - from base64 import b64encode - - captured = {} - - async def fake_ask(self, question, filter=None, images=None): - captured["images"] = images - return ("answer", []) - - monkeypatch.setattr(HaikuRAG, "ask", fake_ask) - mcp = create_mcp_server(mcp_db, read_only=True) - ask = await _get_tool(mcp, "ask_question") - - png = b"fake image bytes" - result = await ask(question="q", images_base64=[b64encode(png).decode()]) - assert result == "answer" - assert captured["images"] == [png] +@pytest.mark.filterwarnings("ignore:Found propagated trace context:RuntimeWarning") +class TestMCPErrorContract: + """A failure is an error on the wire carrying its message, never an empty + result.""" @pytest.mark.asyncio - async def test_analyze_decodes_images(self, mcp_db, monkeypatch): - from base64 import b64encode - from types import SimpleNamespace + async def test_an_unknown_document_is_an_error(self, mcp_db): + result = await _call( + create_mcp_server(mcp_db), "get_document", document_id="nonexistent-id" + ) - captured = {} - - async def fake_analyze(self, question, filter=None, images=None): - captured["images"] = images - return SimpleNamespace(answer="answer") - - monkeypatch.setattr(HaikuRAG, "analyze", fake_analyze) - mcp = create_mcp_server(mcp_db, read_only=True) - analyze = await _get_tool(mcp, "analyze") - - jpeg = b"fake jpeg bytes" - result = await analyze(question="q", images_base64=[b64encode(jpeg).decode()]) - assert result == "answer" - assert captured["images"] == [jpeg] - - @pytest.mark.asyncio - async def test_ask_question_rejects_invalid_base64(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) - ask = await _get_tool(mcp, "ask_question") - - result = await ask(question="q", images_base64=["!!! not base64 !!!"]) - assert "Error" in result - - @pytest.mark.asyncio - async def test_ask_question_without_images_passes_none(self, mcp_db, monkeypatch): - captured = {} - - async def fake_ask(self, question, filter=None, images=None): - captured["images"] = images - return ("answer", []) - - monkeypatch.setattr(HaikuRAG, "ask", fake_ask) - mcp = create_mcp_server(mcp_db, read_only=True) - ask = await _get_tool(mcp, "ask_question") - - result = await ask(question="q") - assert result == "answer" - assert captured["images"] is None - - -class TestMCPFileAndUrlIngestion: - @pytest.mark.asyncio - async def test_add_document_from_file(self, temp_db_path, tmp_path): - async with HaikuRAG(temp_db_path, create=True): - pass - source = tmp_path / "note.txt" - source.write_text("Ingested from a file path.") - - mcp = create_mcp_server(temp_db_path, read_only=False) - add_file = await _get_tool(mcp, "add_document_from_file") - - doc_id = await add_file(file_path=str(source), title="File Doc") - assert doc_id is not None - - get_doc = await _get_tool(mcp, "get_document") - doc = await get_doc(document_id=doc_id) - assert doc.title == "File Doc" + assert result.is_error + assert "nonexistent-id" in result.content[0].text @pytest.mark.asyncio @pytest.mark.parametrize( "tool_name,kwargs", [ - ("add_document_from_file", {"file_path": "/tmp/x.txt"}), - ("add_document_from_url", {"url": "https://example.com/x.txt"}), + ("search_documents", {"query": "x"}), + ("list_documents", {}), + ("execute_code", {"code": "print(1)"}), ], ) - @pytest.mark.parametrize( - "results,expected", - [ - ( - [Document(id="first", content="a"), Document(id="second", content="b")], - "first", - ), - ([], None), - ], - ids=["directory_reports_first_id", "empty_directory_reports_none"], - ) - async def test_add_tools_handle_multi_document_sources( - self, mcp_db, monkeypatch, tool_name, kwargs, results, expected - ): - """A source resolving to several documents reports the first id.""" - - async def fake_from_source(self, source, title=None, metadata=None, **kw): - return results - - monkeypatch.setattr(HaikuRAG, "create_document_from_source", fake_from_source) - mcp = create_mcp_server(mcp_db, read_only=False) - add = await _get_tool(mcp, tool_name) - - assert await add(**kwargs) == expected - - @pytest.mark.asyncio - async def test_add_document_from_url(self, mcp_db, monkeypatch): - async def fake_from_source(self, source, title=None, metadata=None, **kwargs): - assert source == "https://example.com/doc.txt" - return Document(id="url-doc", content="fetched") - - monkeypatch.setattr(HaikuRAG, "create_document_from_source", fake_from_source) - mcp = create_mcp_server(mcp_db, read_only=False) - add_url = await _get_tool(mcp, "add_document_from_url") - - assert await add_url(url="https://example.com/doc.txt") == "url-doc" - - -class TestMCPToolsDegradeOnError: - """Every tool swallows client failures and returns its empty value rather - than propagating an exception to the MCP transport.""" - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "client_method,tool_name,kwargs,expected", - [ - ( - "create_document_from_source", - "add_document_from_file", - {"file_path": "/tmp/x.txt"}, - None, - ), - ( - "create_document_from_source", - "add_document_from_url", - {"url": "https://example.com/x"}, - None, - ), - ("create_document", "add_document_from_text", {"content": "x"}, None), - ("delete_document", "delete_document", {"document_id": "x"}, False), - ("search", "search_documents", {"query": "x"}, []), - ("get_document_by_id", "get_document", {"document_id": "x"}, None), - ("list_documents", "list_documents", {}, []), - ], - ) - async def test_tool_returns_empty_value_when_client_raises( - self, mcp_db, monkeypatch, client_method, tool_name, kwargs, expected - ): - async def boom(self, *args, **kw): - raise RuntimeError("client exploded") - - monkeypatch.setattr(HaikuRAG, client_method, boom) - mcp = create_mcp_server(mcp_db, read_only=False) - tool = await _get_tool(mcp, tool_name) - - assert await tool(**kwargs) == expected - - @pytest.mark.asyncio - async def test_list_documents_returns_empty_for_invalid_filter(self, mcp_db): - mcp = create_mcp_server(mcp_db, read_only=True) - list_docs = await _get_tool(mcp, "list_documents") - - assert await list_docs(filter="no_such_column = 1") == [] - - @pytest.mark.asyncio - async def test_analyze_reports_the_error(self, mcp_db, monkeypatch): - async def boom(self, question, filter=None, images=None): - raise RuntimeError("sandbox exploded") - - monkeypatch.setattr(HaikuRAG, "analyze", boom) - mcp = create_mcp_server(mcp_db, read_only=True) - analyze = await _get_tool(mcp, "analyze") - - assert "sandbox exploded" in await analyze(question="q") - - @pytest.mark.asyncio - async def test_ask_question_appends_citations_when_requested( - self, mcp_db, monkeypatch - ): - from haiku.rag.store.models.citation import Citation - - citation = Citation( - chunk_id="c1", - document_id="d1", - content="cited text", - document_uri="test://ai-overview", - document_title="AI Overview", + async def test_an_invalid_filter_is_an_error(self, mcp_db, tool_name, kwargs): + result = await _call( + create_mcp_server(mcp_db), tool_name, filter="no_such_column = 1", **kwargs ) - async def fake_ask(self, question, filter=None, images=None): - return ("the answer", [citation]) + assert result.is_error + assert "no_such_column" in result.content[0].text - monkeypatch.setattr(HaikuRAG, "ask", fake_ask) - mcp = create_mcp_server(mcp_db, read_only=True) - ask = await _get_tool(mcp, "ask_question") + @pytest.mark.asyncio + @pytest.mark.parametrize( + "payload", ["!!! not base64 !!!", "é"], ids=["outside_alphabet", "non_ascii"] + ) + async def test_invalid_base64_is_an_error( + self, mcp_db, multimodal_embedder, payload + ): + result = await _call( + create_mcp_server(mcp_db), "search_documents_by_image", image_base64=payload + ) - with_cite = await ask(question="q", cite=True) - assert with_cite.startswith("the answer") - assert "AI Overview" in with_cite + assert result.is_error + assert "base64" in result.content[0].text - assert await ask(question="q", cite=False) == "the answer" + @pytest.mark.asyncio + async def test_a_host_failure_inside_a_program_carries_its_message( + self, mcp_db, monkeypatch + ): + """One contract for the sandbox: the client reads the same error the + program did, message included.""" + + async def boom(self, *args, **kwargs): + raise RuntimeError("boom at /secret/path") + + monkeypatch.setattr(HaikuRAG, "search", boom) + result = await _call( + create_mcp_server(mcp_db), "execute_code", code="await search('x')" + ) + + assert result.is_error + assert "RuntimeError: boom at /secret/path" in result.content[0].text + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "client_method,tool_name,kwargs", + [ + ("search", "search_documents", {"query": "x"}), + ("search", "search_documents_by_image", {"image_base64": "AAAA"}), + ("get_document_by_id", "get_document", {"document_id": "x"}), + ("list_documents", "list_documents", {}), + ], + ) + async def test_an_unexpected_failure_carries_its_message( + self, mcp_db, multimodal_embedder, monkeypatch, client_method, tool_name, kwargs + ): + async def boom(self, *args, **kw): + raise RuntimeError("boom at /secret/path") + + monkeypatch.setattr(HaikuRAG, client_method, boom) + result = await _call(create_mcp_server(mcp_db), tool_name, **kwargs) + + assert result.is_error + assert "boom at /secret/path" in result.content[0].text + + +class TestAgentPlugins: + """The shared Claude Code and Codex plugin points at this MCP server.""" + + root = Path(__file__).resolve().parents[1] + + def test_the_manifests_name_the_plugin_and_its_server(self): + import json + + claude_plugin = json.loads( + (self.root / "plugins/haiku-rag/.claude-plugin/plugin.json").read_text() + ) + codex_plugin = json.loads( + (self.root / "plugins/haiku-rag/.codex-plugin/plugin.json").read_text() + ) + claude_marketplace = json.loads( + (self.root / ".claude-plugin/marketplace.json").read_text() + ) + codex_marketplace = json.loads( + (self.root / ".agents/plugins/marketplace.json").read_text() + ) + servers = json.loads((self.root / "plugins/haiku-rag/.mcp.json").read_text()) + + assert claude_plugin["name"] == codex_plugin["name"] == "haiku-rag" + assert claude_plugin["description"] + assert codex_plugin["description"] + assert codex_plugin["skills"] == "./skills/" + assert codex_plugin["mcpServers"] == "./.mcp.json" + [claude_entry] = claude_marketplace["plugins"] + assert claude_entry["name"] == claude_plugin["name"] + assert claude_entry["source"] == "./plugins/haiku-rag" + [codex_entry] = codex_marketplace["plugins"] + assert codex_entry["name"] == codex_plugin["name"] + assert codex_entry["source"]["path"] == "./plugins/haiku-rag" + assert servers["mcpServers"]["haiku-rag"]["args"] == ["mcp", "--stdio"] + + @pytest.mark.asyncio + async def test_the_skill_pre_approves_every_tool_the_server_registers( + self, mcp_db, multimodal_embedder + ): + import yaml + + text = (self.root / "plugins/haiku-rag/skills/haiku-rag/SKILL.md").read_text() + _, frontmatter, _ = text.split("---", 2) + skill = yaml.safe_load(frontmatter) + prefix = "mcp__plugin_haiku-rag_haiku-rag__" + + assert skill["name"] == "haiku-rag" + assert skill["description"] + assert all(tool.startswith(prefix) for tool in skill["allowed-tools"]) + approved = {tool.removeprefix(prefix) for tool in skill["allowed-tools"]} + registered = {t.name for t in await create_mcp_server(mcp_db).list_tools()} + assert approved == registered class TestMCPClientLifetime: @@ -534,7 +1181,7 @@ class TestMCPClientLifetime: monkeypatch.setattr(Store, "_initialize", counted) - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) search = await _get_tool(mcp, "search_documents") list_docs = await _get_tool(mcp, "list_documents") await search(query="artificial intelligence") @@ -559,7 +1206,7 @@ class TestMCPClientLifetime: monkeypatch.setattr(Store, "_initialize", counted) - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) list_docs = await _get_tool(mcp, "list_documents") results = await asyncio.gather(*(list_docs() for _ in range(5))) @@ -567,18 +1214,6 @@ class TestMCPClientLifetime: assert opens == 1 assert all(len(r) == 2 for r in results) - @pytest.mark.asyncio - async def test_a_write_is_visible_to_the_next_read(self, mcp_db): - """One connection sees its own writes, whatever the consistency interval.""" - mcp = create_mcp_server(mcp_db, read_only=False) - list_docs = await _get_tool(mcp, "list_documents") - delete_doc = await _get_tool(mcp, "delete_document") - - docs = await list_docs() - assert await delete_doc(document_id=docs[0].id) is True - - assert len(await list_docs()) == len(docs) - 1 - @pytest.mark.asyncio async def test_lifespan_opens_and_closes_once(self, mcp_db, monkeypatch): from haiku.rag.store.engine import Store @@ -593,7 +1228,7 @@ class TestMCPClientLifetime: monkeypatch.setattr(Store, "_initialize", counted) - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) # _lifespan_manager is what every transport enters; the public # lifespan() combines provider lifespans only. async with mcp._lifespan_manager(): @@ -624,47 +1259,19 @@ class TestMCPClientLifetime: ) scope = DatabaseScope.resolve(config, database_name="alpha") - mcp = _mcp_covering(scope, config, read_only=True) + mcp = _mcp_covering(scope, config) async with mcp._lifespan_manager(): search = await _get_tool(mcp, "search_documents") - results = await search(query="artificial intelligence") + blocks = _rendered(await search(query="artificial intelligence")) listing = await _get_tool(mcp, "list_documents") documents = await listing() - assert results - assert {r.source for r in results} == {"alpha"} + assert blocks + assert {_line(block, "Collection") for block in blocks} == {None} titles = {d.title for d in documents} assert "AI Overview" in titles assert "Zebras" not in titles - def test_a_scope_covering_a_set_is_refused(self, tmp_path): - from haiku.rag.client.scope import DatabaseScope - from haiku.rag.config.models import AppConfig, LanceDBConfig - from haiku.rag.store.exceptions import AmbiguousDatabaseError - - config = AppConfig( - lancedb=LanceDBConfig( - databases={"alpha": str(tmp_path / "a"), "beta": str(tmp_path / "b")} - ) - ) - - with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"): - _mcp_covering(DatabaseScope.resolve(config), config, read_only=True) - - def test_the_public_factory_refuses_a_configured_set_too(self, tmp_path): - """It resolves the same scope, so it reaches the same refusal.""" - from haiku.rag.config.models import AppConfig, LanceDBConfig - from haiku.rag.store.exceptions import AmbiguousDatabaseError - - config = AppConfig( - lancedb=LanceDBConfig( - databases={"alpha": str(tmp_path / "a"), "beta": str(tmp_path / "b")} - ) - ) - - with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"): - create_mcp_server(config=config) - def test_the_public_factory_refuses_a_path_beside_a_configured_set(self, tmp_path): """A path and `lancedb.databases` both place the database.""" from haiku.rag.config.models import AppConfig, LanceDBConfig @@ -696,7 +1303,7 @@ class TestMCPClientLifetime: async def run_stdio_async(self): return None - def fake_covering(scope, config, read_only): + def fake_covering(scope, config, agents=True): seen.update(scope=scope, config=config) return _Server() @@ -715,7 +1322,7 @@ class TestMCPClientLifetime: @pytest.mark.asyncio async def test_startup_fails_when_the_database_cannot_open(self, tmp_path): - mcp = create_mcp_server(tmp_path / "does-not-exist.lancedb", read_only=True) + mcp = create_mcp_server(tmp_path / "does-not-exist.lancedb") with pytest.raises(FileNotFoundError): async with mcp._lifespan_manager(): @@ -737,7 +1344,7 @@ class TestMCPClientLifetime: monkeypatch.setattr(Store, "_initialize", counted) - mcp = create_mcp_server(mcp_db, read_only=True) + mcp = create_mcp_server(mcp_db) search = await _get_tool(mcp, "search_documents") async with mcp._lifespan_manager(): @@ -745,28 +1352,18 @@ class TestMCPClientLifetime: assert opens == 1 async with mcp._lifespan_manager(): - results = await search(query="artificial intelligence") + blocks = _rendered(await search(query="artificial intelligence")) assert opens == 2 - assert len(results) > 0 + assert blocks @pytest.mark.asyncio - async def test_same_dim_drift_starts_read_only_but_not_writable(self, mcp_db): - """Validation is unchanged: same-dimension identity drift warns in - read-only mode and raises in writable mode. The MCP server no longer - opts out of it for deletion.""" + async def test_same_dim_drift_starts(self, mcp_db): + """Same-dimension identity drift warns on a read-only open and raises + on a writable one; the server starts, so it opened read-only.""" from haiku.rag.config import get_config - from haiku.rag.store.exceptions import ConfigMismatchError drifted = get_config().model_copy(deep=True) drifted.embeddings.model.name = "a-different-model" - async with create_mcp_server( - mcp_db, config=drifted, read_only=True - )._lifespan_manager(): + async with create_mcp_server(mcp_db, config=drifted)._lifespan_manager(): pass - - with pytest.raises(ConfigMismatchError): - async with create_mcp_server( - mcp_db, config=drifted, read_only=False - )._lifespan_manager(): - pass diff --git a/tests/test_utils.py b/tests/test_utils.py index ca0ae454..f63bf07e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -662,117 +662,6 @@ def test_format_bytes(): assert format_bytes(1125899906842624) == "1.0 PB" -# --- format_citations tests --- - - -def test_format_citations_empty(): - from haiku.rag.utils import format_citations - - assert format_citations([]) == "" - - -def test_format_citations_with_citation(): - from haiku.rag.store.models.citation import Citation - from haiku.rag.utils import format_citations - - citation = Citation( - document_id="doc1", - chunk_id="chunk1", - document_uri="test://doc", - document_title="Test Doc", - content="Some content", - page_numbers=[1], - headings=["Intro"], - ) - result = format_citations([citation]) - assert "[1] Test Doc" in result - assert "doc1" not in result - assert "chunk1" not in result - assert "test://doc" in result - assert "p. 1" in result - assert "Section: Intro" in result - assert "Some content" in result - - -def test_format_citations_multiple_pages(): - from haiku.rag.store.models.citation import Citation - from haiku.rag.utils import format_citations - - citation = Citation( - document_id="doc1", - chunk_id="chunk1", - document_uri="test://doc", - content="Content", - page_numbers=[1, 2, 3], - ) - result = format_citations([citation]) - assert "[1] test://doc" in result - assert "pp. 1-3" in result - # No title: the URI stands in, and the document id never leaks. - assert "doc1" not in result - - -def test_format_citations_with_index(): - from haiku.rag.store.models.citation import Citation - from haiku.rag.utils import format_citations - - citation = Citation( - index=5, - document_id="doc1", - chunk_id="chunk1", - document_uri="test://doc", - document_title="Test Doc", - content="Content", - ) - result = format_citations([citation]) - assert "[5] Test Doc" in result - - -def test_format_citations_sequential_indices(): - from haiku.rag.store.models.citation import Citation - from haiku.rag.utils import format_citations - - citations = [ - Citation( - document_id="doc1", - chunk_id="chunk1", - document_uri="test://doc1", - document_title="First", - content="Content 1", - ), - Citation( - document_id="doc2", - chunk_id="chunk2", - document_uri="test://doc2", - document_title="Second", - content="Content 2", - ), - ] - result = format_citations(citations) - assert "[1] First" in result - assert "[2] Second" in result - - -# --- format_citations tests (pictures) --- - - -def test_format_citations_picture_refs_render_as_markers(): - from haiku.rag.store.models.citation import Citation - from haiku.rag.utils import format_citations - - citation = Citation( - document_id="doc1", - chunk_id="chunk1", - document_uri="test://doc", - document_title="Test Doc", - content="text body", - picture_refs=["#/pictures/0", "#/pictures/3"], - ) - result = format_citations([citation]) - assert "[Figure: #/pictures/0]" in result - assert "[Figure: #/pictures/3]" in result - - # --- format_citations_rich tests --- @@ -813,6 +702,22 @@ async def test_format_citations_rich_header_and_footer(): assert "chunk: chunk-uuid-1" in output +async def test_format_citations_rich_names_a_single_page(): + from haiku.rag.store.models.citation import Citation + from haiku.rag.utils import format_citations_rich + + citation = Citation( + document_id="doc1", + chunk_id="chunk1", + document_uri="test://doc", + content="Body", + page_numbers=[3], + ) + output = _render_rich(await format_citations_rich([citation])) + assert "p. 3" in output + assert "pp." not in output + + async def test_format_citations_rich_names_the_database_when_federating(): """Across databases, a citation has to say which one it came from.""" from unittest.mock import AsyncMock diff --git a/uv.lock b/uv.lock index 2754fbb2..eba87258 100644 --- a/uv.lock +++ b/uv.lock @@ -1209,21 +1209,22 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.3.1" +version = "4.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/a9/5c5a01b6abd5346bf60b97cfd29e4a86661940c27dd562bfcda07fd03519/fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd", size = 28681699, upload-time = "2026-05-15T15:50:39.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1c/981a1854f91a08872f4b8b9a627d5d751cafc1340d29b21b747f0b520b0a/fastmcp-4.0.2.tar.gz", hash = "sha256:60d5c5ead3b6a117bfada5c0f95fe5c1aba53d1577079ecbdf42eeff0cd9b931", size = 42306015, upload-time = "2026-09-02T23:28:08.386Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/11/6b1bdada6ccfe647d615ae63f9106f8136aec17971e9361546af01c7d38e/fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522", size = 7903, upload-time = "2026-05-15T15:50:36.424Z" }, + { url = "https://files.pythonhosted.org/packages/58/3f/b97cfb92e0d6db8232c67c258117cd0dd9def86c8b472270bd7196d5cd9d/fastmcp-4.0.2-py3-none-any.whl", hash = "sha256:9075e64a94634ad660971ed14374c87be06f2a16a921028ca87987e6aa2f3bfa", size = 8078, upload-time = "2026-09-02T23:28:03.777Z" }, ] [[package]] name = "fastmcp-slim" -version = "3.3.1" +version = "4.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mcp-types" }, { name = "platformdirs" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -1231,26 +1232,28 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/a0/627103e517e1d0d6f1eec633d5662d13e776f01b45ad188e4f5f7478b438/fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88", size = 567007, upload-time = "2026-05-15T15:50:10.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/7d/c2597734e3a0859d62c9d8f6f35067d1e296537512e280db3af19204be64/fastmcp_slim-4.0.2.tar.gz", hash = "sha256:86b99bdcb872b52d964c79bc6d43ce79f40ed5538b589d102792b4a7cf3947f4", size = 684052, upload-time = "2026-09-02T23:27:39.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/ee/97047f4cc2d7b1d46670d08d8ad01a96e7a748cc01c0b4b351ad8eddbc7a/fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043", size = 738644, upload-time = "2026-05-15T15:50:08.336Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c0/c022eba3a25ebb56111de1b5f76fbfca81925def58880464263582acfcd8/fastmcp_slim-4.0.2-py3-none-any.whl", hash = "sha256:6bd5b5885628f73263fa2247ea1d26e4a514499a6e079ee3e340cd03a7fe5ed8", size = 858100, upload-time = "2026-09-02T23:27:38.459Z" }, ] [package.optional-dependencies] client = [ { name = "authlib" }, { name = "exceptiongroup" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, ] server = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "griffelib" }, - { name = "httpx" }, + { name = "httpx2" }, + { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, @@ -1261,6 +1264,7 @@ server = [ { name = "pyperclip" }, { name = "python-multipart" }, { name = "pyyaml" }, + { name = "starlette" }, { name = "uncalled-for" }, { name = "uvicorn" }, { name = "watchfiles" }, @@ -1755,7 +1759,7 @@ requires-dist = [ { name = "docling", marker = "extra == 'docling'", specifier = ">=2.102.2,<3.0.0" }, { name = "docling-core", specifier = ">=2.82.0,<3.0.0" }, { name = "fastapi", marker = "extra == 'ingester'", specifier = ">=0.125" }, - { name = "fastmcp", specifier = ">=3.3.0" }, + { name = "fastmcp", specifier = ">=4.0.2,<5.0.0" }, { name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.0" }, @@ -1772,7 +1776,7 @@ requires-dist = [ { name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" }, { name = "pydantic-ai-slim", extras = ["openai", "logfire", "ag-ui"], specifier = ">=2.18.0,<3.0.0" }, { name = "pydantic-ai-slim", extras = ["voyageai"], marker = "extra == 'voyageai'" }, - { name = "pydantic-monty", specifier = ">=0.0.19" }, + { name = "pydantic-monty", specifier = ">=0.0.23" }, { name = "pypdfium2", specifier = ">=5.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -1887,15 +1891,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - [[package]] name = "httpx2" version = "2.8.0" @@ -2590,15 +2585,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -2608,9 +2603,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" }, ] [[package]] @@ -3983,94 +3991,82 @@ wheels = [ [[package]] name = "pydantic-monty" -version = "0.0.19" +version = "0.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "pydantic-monty-client" }, { name = "pydantic-monty-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/01/cd7927f51500a13c1661e2db6488f6ff668194c4378d0fb218928892aaba/pydantic_monty-0.0.23.tar.gz", hash = "sha256:ee674b81ed12f81cfbe0db210fe5e803c754d0682634331931f4d70d5742058d", size = 6713, upload-time = "2026-09-05T19:26:03.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b3/a259a600df30a54ee9e576c90bb7cb6c2b2d4750774c91748922cc202c5e/pydantic_monty-0.0.23-py3-none-any.whl", hash = "sha256:cddcf7d4d7dd163b56e4411f450ea0244a7205988c77cd85441ec89fedaab91a", size = 6302, upload-time = "2026-09-05T19:24:01.829Z" }, +] + +[[package]] +name = "pydantic-monty-client" +version = "0.0.23" +source = { registry = "https://pypi.org/simple" } +dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/f8/c04df414086488834d102ab85cf8172c114108f842fb142ac92a490213fd/pydantic_monty-0.0.19.tar.gz", hash = "sha256:f3f9e256058b4085349dd4ad347795d5203a8310e9eaad9a2bff93890ac5b86d", size = 1484032, upload-time = "2026-07-24T10:00:13.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/e9/dcbf9a90ccd195be0db5257c2234d002d551d47f9d8987543eddd3095812/pydantic_monty_client-0.0.23.tar.gz", hash = "sha256:8b31f75afebb60c0416c869c8d6667a75e84b0f05b7a0b44e51b77ef98f294d7", size = 1983536, upload-time = "2026-09-05T19:26:04.167Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/bf/152bbb3315dfa46d4e4aae71779230e50c67a34d859a3470fd75c01b795c/pydantic_monty-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b073e64edfd62cca918d792d6fe559512472f981f949e53a7aec673201f5f554", size = 2492733, upload-time = "2026-07-24T09:56:49.612Z" }, - { url = "https://files.pythonhosted.org/packages/46/16/9d37f1bf94c47c593a06ecb918bb64a2c8a38af24d6fa2b0d0e031938edf/pydantic_monty-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5825ae6c40270166f0f31b6e62d59b0fe47201d12b1be5842efc22d3b7dadcee", size = 2234610, upload-time = "2026-07-24T09:56:51.257Z" }, - { url = "https://files.pythonhosted.org/packages/82/4f/31732f4b9c2b6574eb564e420593b9be3f80d92440211970fab23e882bc5/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5b6821c0035ff2c02cf0f37823fb905890f7238ce923bbc3ca937740f9554836", size = 2303116, upload-time = "2026-07-24T09:56:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/21/2c/c21e6179361100dd8b9ad410df775d176a29e0b85f2ffc3144fd29840ee9/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:5fb4514d99a10e304237a82baeb6072e934ce8462dcd5fb5c3fea0ee0ef16aeb", size = 2007947, upload-time = "2026-07-24T09:56:54.006Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f0/ad64f4894334499f689bdce7e5b5dda6b680d98989424092dcbf21666564/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:2b0f154ac2e6450befa337a99a8519e2f1195cc59f88bd73d780067ace0c4c97", size = 2151468, upload-time = "2026-07-24T09:56:55.626Z" }, - { url = "https://files.pythonhosted.org/packages/2a/97/4ff5f9170e4865ec28fb152bc6ebaa8bd1873e695ee98af2103607ba42e8/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:6fcb4e3a312397432f7c5a0aa04a765670d9f37f4e23f37cb580b3faa2f218b1", size = 2300164, upload-time = "2026-07-24T09:56:57.318Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b1/c9bfb6708bc5d9f6b040db46156c6e3f16de68ab22f07e2de4f25782414b/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:955d308171ba0260f75d2dede18c46d9732647f94b8f7fba3076bb539f155dc8", size = 2164984, upload-time = "2026-07-24T09:56:58.66Z" }, - { url = "https://files.pythonhosted.org/packages/35/2d/8c1492216632f53e229cb2886c7fd09613d5990f4856f93f7ae0364da9bc/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3d7e1a6cc1977b24e01b5322888a5ba3f05b112a04a1646ba51f2c34c562a3d9", size = 2357823, upload-time = "2026-07-24T09:57:00.048Z" }, - { url = "https://files.pythonhosted.org/packages/9b/cc/ae4adaaf343de00748fc3598402c1523e48db7094a9c1e0fa26177699440/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4904785a34f71f59aa1eb6dc081bafd09e4caa2f028cd379aa3aa846b8a1c27d", size = 2497387, upload-time = "2026-07-24T09:57:01.686Z" }, - { url = "https://files.pythonhosted.org/packages/ec/09/eaef87ed6cbffed9c720dd3cb850e748b0aab11f5ec1ecffb56250973f7d/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce15a5326e24cf7045ec9c1456ddb92abd09df91708771c3ae5e72d8e6374c81", size = 2738391, upload-time = "2026-07-24T09:57:03.3Z" }, - { url = "https://files.pythonhosted.org/packages/63/af/58be6fd6ea87e27bd57435013ca1f63d645a0c990c2f23708bcdd048af24/pydantic_monty-0.0.19-cp312-cp312-win32.whl", hash = "sha256:600eb259415e8b2dfef4be38d030c945b3fbb4fb85e727cb97131e7329ab017f", size = 1908274, upload-time = "2026-07-24T09:57:05.023Z" }, - { url = "https://files.pythonhosted.org/packages/20/b7/1cb54e43113cb69c40fb765cfee3be1c222d81153b432131f50508569aee/pydantic_monty-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:2c98b1c99994f92ab487a762b107067ab70036f64231e05aa3f6d2b16018688e", size = 2111335, upload-time = "2026-07-24T09:57:06.614Z" }, - { url = "https://files.pythonhosted.org/packages/24/17/0926da051f34ccaa45bf528777dc99e5ea611669cdd7b715be1e086c1fe0/pydantic_monty-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c01fb1162cf87dbf145b875450eabfde6b35b26f27ed63468398cb4c37732064", size = 2496669, upload-time = "2026-07-24T09:57:07.982Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/c38f79e24971b4d2205ee156df6e5c6ccdcc720152f54a956cc26e7488b0/pydantic_monty-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e97dc97de32410003fb5517b72ab47799b4546a3ab48a75e60260cf73734da76", size = 2234599, upload-time = "2026-07-24T09:57:09.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/89/fb2ed1677ad2c3e2801aa119fdc280d1c64f3480e1015811e0942c9a7d20/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:06318e6add780f66259067829ab16062ce7a556dba0884372a881881b4a7c3bf", size = 2305696, upload-time = "2026-07-24T09:57:10.97Z" }, - { url = "https://files.pythonhosted.org/packages/24/ec/e36ff1c2c97f46420d57680199e7ae2e1eb306d2cfda22be2448e53e7484/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:5a765a36141bbeb074d89b424d5488bed4e358c603c72606cc9d81ee44d83de2", size = 2007600, upload-time = "2026-07-24T09:57:12.333Z" }, - { url = "https://files.pythonhosted.org/packages/da/88/b47670d3e28f99dc2f4c2686dc42233843dce1a607f3d3cc8a387f3b9b74/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:48f5ebc048779c854f993834586ba372df3b65500d1ed7f147023abc29aeb2c4", size = 2151382, upload-time = "2026-07-24T09:57:13.8Z" }, - { url = "https://files.pythonhosted.org/packages/9f/18/559d71fc66a769c22f6b2a8470515aa6e69a141ab617900fe28a806080b7/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2eb7502f93d8bb568d255fccca95e3b9daca05bb674b9c5323fcba14d088932c", size = 2302643, upload-time = "2026-07-24T09:57:15.42Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b4/171be42e2ec211bd01fe4be7b6de9ab0c346081edc33830f9f9b781341ab/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:608994600a839dd863940ac84810c48d378390a4fea8c77e5145feb84037e610", size = 2169103, upload-time = "2026-07-24T09:57:16.805Z" }, - { url = "https://files.pythonhosted.org/packages/84/ce/8c47253c8f3f528f0fa2d87dde85623dc3f211189a372e2d69cb6ad896b1/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:13a652f9dffa6d25ea458fec83108f60f29682caf42cecef91955b5b8cb04365", size = 2358155, upload-time = "2026-07-24T09:57:18.51Z" }, - { url = "https://files.pythonhosted.org/packages/97/4e/239107b83bae3a20e4f5424f6c6f3f88bae1fd1e734603c298167985f8be/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6167c966db7d8d2fe03940f8da596de6dcd184dcf5cf6209f0a1a9af8792550d", size = 2500858, upload-time = "2026-07-24T09:57:19.878Z" }, - { url = "https://files.pythonhosted.org/packages/48/55/02a7059f20e7198e511ac0b88a3957a2c01b8991326359b7c1bb590a09b8/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3102b44307d04f41897ae51d4daeab4fc9b5bf84a78c036781b488876ef998c3", size = 2742212, upload-time = "2026-07-24T09:57:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/a8/28/b632fe0e8eeba3f2b4dbec6ebd4c9b569c20e9597007f2d0fbbf04101307/pydantic_monty-0.0.19-cp313-cp313-win32.whl", hash = "sha256:e43da52776796a894f40533a7e5a322e98d9aaf7d8f6fbb7dc21a0de60a93f41", size = 1908553, upload-time = "2026-07-24T09:57:22.765Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d3/b90872f017871339ceb03e70fa4915ef8682128a476a66adffedfff874d8/pydantic_monty-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:fd8c195875f8f44d55bc7d1d53c4e43248b184b3ac803d8131c92d4cc05a1aef", size = 2111345, upload-time = "2026-07-24T09:57:24.351Z" }, - { url = "https://files.pythonhosted.org/packages/eb/11/c2aed55502bfc9620837312f0e2fca7a3d4bc959824a66d81b72144d7256/pydantic_monty-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2692dc4452937cf2200afd257297e9ac3ccff122b80aa7a69935e8275d684193", size = 2497017, upload-time = "2026-07-24T09:57:25.836Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d0/d4b44a81c71308109cfa642a24b803057615ca1609530c5e66c376780efe/pydantic_monty-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a4717f829b35c4bc5d9f6f52a52d19b90729bd494bcb69133bd4c0afcab9c76", size = 2247468, upload-time = "2026-07-24T09:57:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/a9/89/52848dce3acbb1d58df34496db3c4718814cc39f6db01a5025bfdc12b530/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:99f7282213ad6ebf7daf149a88d1517e6328afbf6780180512b9de62f30d292b", size = 2306181, upload-time = "2026-07-24T09:57:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/90/05/f7791c79c7be2240c43a9287196ed49034f773e3f4158492f028e486ebc2/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:978788b1c56fa0c49927e0a35151f0a635ef2f8d947d16915541ff864d2112f5", size = 2008738, upload-time = "2026-07-24T09:57:30.423Z" }, - { url = "https://files.pythonhosted.org/packages/93/a1/d714258eeb2583acaab2035834acc54ac6baa65bac456f897d901bc0c03a/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:8b821cac39deeb1abb2994d5a65f117ce26e55c586d0f570d425ff469ff48e3c", size = 2152275, upload-time = "2026-07-24T09:57:31.725Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/cdb1fa761e992a489b07a17adb80c21bf99eabd1286ca62f9e73acda1f4b/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b43c4ffa5651f0eca97458dc673d7952064ae5eb5b836d23967a7d41483bb8f4", size = 2303533, upload-time = "2026-07-24T09:57:33.131Z" }, - { url = "https://files.pythonhosted.org/packages/db/9b/e6685cf82521e68e0dcb94e0c97a1a20410b1266fbce7008c0caa3484039/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:4054610601358943a3dd740a8d59a6812cc682e94d6903cb72baadae1ef5d2ac", size = 2169585, upload-time = "2026-07-24T09:57:34.511Z" }, - { url = "https://files.pythonhosted.org/packages/df/f4/6f031a628d3de72d95bedbb18292ccf998d9a422aff58eedf37347a0b367/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0b2a34c320968a3cef3d933737e99c932f13c55667315128f366afdaeea2be04", size = 2375171, upload-time = "2026-07-24T09:57:35.907Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d3/4367bccdf2c06a977c0d5ddf816190d570a07199f011034df16d51c84723/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ffaf950f4284bb193a54f18fa4e3e8c225b5b52265813abffe492074e90c65fb", size = 2501475, upload-time = "2026-07-24T09:57:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0e/7a0e1d5c016848afc9a8605aa4f16e6960d68806e775865b986aacaf8f88/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:bb1e5ee6762f9494bfb0f4cc316ad3e9a8c7917e3c984a23eb2e2322d401e5cb", size = 2742547, upload-time = "2026-07-24T09:57:39.284Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/92f77cf088f1df72a5dbab109c8c0e82f7ddeb18e65835a8dffcf967bb4a/pydantic_monty-0.0.19-cp314-cp314-win32.whl", hash = "sha256:b68ef6503b39f2f014162e3d8e7f48b9722a43ceb7b6d7199dc6d545f4c37b34", size = 1907832, upload-time = "2026-07-24T09:57:40.982Z" }, - { url = "https://files.pythonhosted.org/packages/cc/3e/85e1a914f81659ea25c34916fdef27fcda2b00323dafb76cf2a5321f7c11/pydantic_monty-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:14ef37b43c5bf90966ca51bcad0fae892a3c2546cd151fadc039e0a25ca74073", size = 2123187, upload-time = "2026-07-24T09:57:42.352Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f3/4e4975daee5ac4a86e95b08124e304cf7af774df50891b28479bb2c28ede/pydantic_monty_client-0.0.23-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7f309a03e75c418b34e469e547171059ebd1ebcd11971ed129b6905e2a7c057f", size = 3930126, upload-time = "2026-09-05T19:24:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/6e/23/927d73209b509db9604606d88aaff90f56474bd9bdcfe9f39a873bfc945c/pydantic_monty_client-0.0.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e3efb7e2af60e17c16f3baa49ea3a2e2b90c88610666f294591b9819d3ccc69", size = 3702556, upload-time = "2026-09-05T19:24:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c9/bfb5847d79e8bdeefbeea531b90f09e2c5f156bc361730975171a7e93146/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f065e8286378f26d29ceaeb2fb10712f202f45656967706675f93b3a17b99f78", size = 3728660, upload-time = "2026-09-05T19:24:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/13/9c/50af7e5a67876cb7c421401ff5f3885ae4fbd60324fea3c25e5fd31926af/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:d3e353536016174f9e0aca289f7d3a81a07ac7574e2a610902152e0ebf5feb5a", size = 3425561, upload-time = "2026-09-05T19:24:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e3/1d1a53f72576c190ea2f1a6c0fb5eeb722bea6901df8ddfba294ff49b08b/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:ef186bfe5344a84515cd51dc9ee5914084b1d616fad2409328d8edfbfb4d5ac3", size = 3633369, upload-time = "2026-09-05T19:24:44.21Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fb/c5987eeff60e9d736a720ef1c6f3dadee39619f596517d8983a99ce4873a/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:7062196b773cc8b956b0dbece42203eb493270e0707e37447801255ad26f065e", size = 3843321, upload-time = "2026-09-05T19:24:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/19/43/cc1e93c74103a225dc39c30966ff3bec9973be0bbb338b080a55e4653bda/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:15ddde16a1398c601a1bb791e5c3a796ce83222b79ecb21bd7070e52aa007257", size = 3735663, upload-time = "2026-09-05T19:24:46.866Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/b7103e26fe6103e217c281f6581b918a2455f5a1600fc197b82010953b19/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b41dbca54254850a5ab68bed4c75992d6eeacd283265f7445c4ae705e29fed5a", size = 4006344, upload-time = "2026-09-05T19:24:48.17Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/6b21d3d52dcb1cdbeaa593baed170d2454a61bf5aad2692e6e3b3136c5f8/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1081ab9038004739ede448249d0b1c3d409fb8641ee9bad9ab5347d2bb415600", size = 3896737, upload-time = "2026-09-05T19:24:50.226Z" }, + { url = "https://files.pythonhosted.org/packages/02/df/6a4ed64e3c21389b7e277a7fc98df1021767f9e27edd487b9c5cb1cc4448/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:182798245ed7ba503c49c9cf5f313876df9b81f57fcbfdf12efa1aa25be0fcd1", size = 4199695, upload-time = "2026-09-05T19:24:51.844Z" }, + { url = "https://files.pythonhosted.org/packages/98/a7/5f166faeff1af270c1d139c90792fe9a2edf00b3c5e16d5014feb29c8d18/pydantic_monty_client-0.0.23-cp312-cp312-win32.whl", hash = "sha256:4d6354723ba6165d3856eac1439d2abed9d231730069c858219456e866a3bc16", size = 3288615, upload-time = "2026-09-05T19:24:53.371Z" }, + { url = "https://files.pythonhosted.org/packages/99/d9/ff24b9dd6d00b65724962f21f10c5844492d1c8d101d86e601155f6c0425/pydantic_monty_client-0.0.23-cp312-cp312-win_amd64.whl", hash = "sha256:099173c188a063ebecd79ea5a2268d06f0031ec93dbaee03b62f4386e024f5dc", size = 3927504, upload-time = "2026-09-05T19:24:54.748Z" }, + { url = "https://files.pythonhosted.org/packages/cf/75/ca3594de58d97c1f34a5c8c6965a3acc69a73e7e616c8b99b8f6fc221c55/pydantic_monty_client-0.0.23-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:80fc4d09ade86f9d527dd79d8ab6f26e510d687c962ca0d499b391905f6efd9e", size = 3934648, upload-time = "2026-09-05T19:24:56.682Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/ee39d943b7eb874a91df0fa0175c8153509322772bc5abace77f5e1763c1/pydantic_monty_client-0.0.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48f6cbb37c7a56dce5e1c4000e286447d5fb51dcf1e1cf5b69185922c43965e6", size = 3703648, upload-time = "2026-09-05T19:24:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/07/2b/67bc41a8a6c178bd840e1e6115a7af3b0289ba7b1f7b4d8f39a23d0abd43/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9b71eb5edd68ef9de22064dd5cf28feca283b5aef8f0ff00bb1308deba5e56bd", size = 3730495, upload-time = "2026-09-05T19:25:00.1Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2a/0d94df69fef37fd3d4fbc0330e7f8442eb9f1bcb7f2ece69f90ef0b349b6/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:2a0aa01d8c1321b610b1c0633b7ffcf9dcbb7ecaa79e5bd6d355483b23440231", size = 3425587, upload-time = "2026-09-05T19:25:01.593Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/61f1f175987a3ad595ee31b883ca1521f7defe3c733b7eb11f228b608a36/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:bfea6ada82fb43308833852980b147edca3a8de48bc2ce4e7cb08c6f1e13144a", size = 3633833, upload-time = "2026-09-05T19:25:03.397Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/173b79e38316b878534fb268bcb461730301ca2e7d656f71599f3be4cdb8/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4ca25b02433751eb0735badd7004543f5eb799a7afc7354d0b0caeadc2e754d2", size = 3845772, upload-time = "2026-09-05T19:25:04.92Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/66707709d9d11222e04efd934486f22b1c44bdf4a62954516cfc746c5845/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:a4a7b40bb0d48b513a72977ccd9192d585c85bb470447a80a0a128a258d4c2c2", size = 3738982, upload-time = "2026-09-05T19:25:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/91/ac/d3ffac7ea991b490271df273213765408df4dec6943fed99ff36db1f0642/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6acfe4ffe75aac0b89f13ea1a527f388fc0d09643bca25d7c78f59b4fed5be25", size = 4011071, upload-time = "2026-09-05T19:25:07.746Z" }, + { url = "https://files.pythonhosted.org/packages/fa/26/c6599214e286e55eb7dcc8b43617b74878fe4d55f69b8e0d12abb0576019/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:538a8edd9afcc3461fe9b744beda644d1daca60fc76423e695795de57d939d4f", size = 3899476, upload-time = "2026-09-05T19:25:09.499Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4d/e3179f513cb4e112d6acd1fafff1217eaf5567ea351f00dc61dd92a7d8ae/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef72181626512a8b7c3c6365d60e416c57bef219ee68f98475f7ec7c59db0db0", size = 4203793, upload-time = "2026-09-05T19:25:11.253Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/2889ff38031ab76eababba29eec92c7d4e9f23a84a58aad306f4609219d8/pydantic_monty_client-0.0.23-cp313-cp313-win32.whl", hash = "sha256:3509dce955db0b7ccbf8a2b458d6562e289295281b7910841e42a1de656a59dc", size = 3288733, upload-time = "2026-09-05T19:25:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f6/e8344f4b3c4d0b8b37fee2b3d27676ae8ad35c5ef731c6ba48ae4012448c/pydantic_monty_client-0.0.23-cp313-cp313-win_amd64.whl", hash = "sha256:c661a74a80158460d633ac5510ccc5c80f77eb3c6c51a54a71d1d9a6108a6fdb", size = 3931122, upload-time = "2026-09-05T19:25:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/96/73/f4f36e465afd5346737a46b59c50e50ec133b9f05a0ba7f3adab0c5e8ab4/pydantic_monty_client-0.0.23-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:28bbf56a7f48dda2acf1cc11807daa50af8cc94e34e1c57fac5d16f99b3e7392", size = 3935351, upload-time = "2026-09-05T19:25:15.497Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ab/0f6f9e9018c2107c9f0bf036167cadc2d1e7edf8275727c9333311f0f8d5/pydantic_monty_client-0.0.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff363c8a2afb25d6e716f2a67a6b350aaa864a278b0ba60a390231d4b351940b", size = 3704385, upload-time = "2026-09-05T19:25:16.944Z" }, + { url = "https://files.pythonhosted.org/packages/54/71/2a0eded398b705c65a6d56cb8139c9fce4d878e01594570c98574734818a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:94b056d3ee44ff3fa39be0790debba41f65fcb7db9d26461f039a01e49349c0a", size = 3730307, upload-time = "2026-09-05T19:25:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c8/ba9fba363a8c96ef4635cf26f3d6d3715e7663823d166676bdf8f5bc06a3/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:f94396bdd59f74918871307fb7f63f4ed852ab770a91a70ed29d159b63823e97", size = 3426378, upload-time = "2026-09-05T19:25:19.958Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/73d07f0b6f2608e155e121c573b4c5f42fa9dba672f4a5b827045ead3bd9/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:fbc150afe82ab299ceb80a9c308224f3d10ac6a1d98cc9214c24baede8fc42d5", size = 3635275, upload-time = "2026-09-05T19:25:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/6a812e7bce36c48d87d84d0cda46002c86bcda8aa5fdf18667d48174815a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:a080f9e5427b3223189c456337ec7866cb51c2033592c89f289ef457153800a8", size = 3846543, upload-time = "2026-09-05T19:25:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/2b/06/19788b96be88fe6f58783b7a24225fb3129de7d4807c0837ae50c67172d6/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:ccae280613d9e15f8344cf82cda25d24dc7b598e191c4549c5f3118f327659b4", size = 3741406, upload-time = "2026-09-05T19:25:24.837Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/306c0715579eb2ecf63166470535951d11907b162ac8d54cbdcea8eb479a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f41b870c7ce2e0b852ae50355749b47d92452ced6f166d32ee9df5da714356a2", size = 4011087, upload-time = "2026-09-05T19:25:26.483Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9d/07e939e83c4b223a0a5941664c2447dcd24c0d3c11c78721889d02d16443/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2d48db758abafcf45cdbfd710f998a6b0af991267587831498739ac11fd7e3b5", size = 3899694, upload-time = "2026-09-05T19:25:27.818Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b7/fdbf223f919b2b8ffdd2df79912b3eec1db274656486534b1ffd84b8dbc1/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7fedc8d482e9338e8e97d2d433cf82b10f671d98416b427a52ab034978731e3d", size = 4204183, upload-time = "2026-09-05T19:25:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/b231969ef89209c02b172fddb6182f328b56f89b6c494b238cd923c50766/pydantic_monty_client-0.0.23-cp314-cp314-win32.whl", hash = "sha256:3bc5e57df0e44057b97614149ea749438f3e3cb48c93c9a06f0ffc753824a28d", size = 3289691, upload-time = "2026-09-05T19:25:30.833Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5f/5e681044f43f6e3fd42dffc84548d86c0a15b06486f29194eca54caa1b8e/pydantic_monty_client-0.0.23-cp314-cp314-win_amd64.whl", hash = "sha256:e2b664edc793fda985f7fb6a03fddfe536fd6de4b7dba0765282938a883f4c51", size = 3929684, upload-time = "2026-09-05T19:25:32.378Z" }, ] [[package]] name = "pydantic-monty-runtime" -version = "0.0.19" +version = "0.0.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/29/e44460fd934584ddecc6b8a8acddbc0605e86ea9d027910acdbf526c8f14/pydantic_monty_runtime-0.0.19.tar.gz", hash = "sha256:717e11349d7234575750cec881ac390961de02a40bd09f875dc5e097705b896b", size = 1322628, upload-time = "2026-07-24T10:00:14.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/1d/c1139f1f82460046d505d7a6871d4d22f9e36cdab967807487f6a70cb51f/pydantic_monty_runtime-0.0.23.tar.gz", hash = "sha256:d181f557cd3d19ee826459dea234a2b440e2b6de093e66384629843959146071", size = 1727575, upload-time = "2026-09-05T19:26:05.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/11/a6f4e12982b2232b9036db334fbcfecbacf46b9acaf311f9c3110e431c53/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:be34905548e31237fc683f5a34986489c127727e8f65481e8c87c4ad0b3a4dc2", size = 9449108, upload-time = "2026-07-24T09:58:44.394Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a0/1e720b1bba457c6a1ab4fca20c571ae058238c7df3e1892b5efebad05a8b/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0f402f20672c1e25d4915de7e97023519461b9ffbedacd08c427d40daa77bdd6", size = 9735875, upload-time = "2026-07-24T09:58:46.952Z" }, - { url = "https://files.pythonhosted.org/packages/51/97/4439b312d9463881630dd9d998d2c8fe2b8ef457b451202c71816e25688c/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9ec0d25dfbe65b9a5dfe77ff86353e95a86774d786a1d496206163f828f072fd", size = 9171496, upload-time = "2026-07-24T09:58:49.504Z" }, - { url = "https://files.pythonhosted.org/packages/1e/67/fa7b99afe1917b89eec3b4b6375f468c76eeea8227dd48361b7f2db90d97/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:7ecc2f8eeabf6483908db4cc69b7d4c156a95675dcbdda2a7540a22ed904bfb2", size = 9565495, upload-time = "2026-07-24T09:58:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a6/83a9dceb8d9f5dffd9a082b60590bb61b0ac48dca19aa02847ebbab1ad46/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:fc1e508b9dc2cab64e27004d0f4ce44c7e1d255e85bafba390884fa07d696319", size = 10199598, upload-time = "2026-07-24T09:58:54.394Z" }, - { url = "https://files.pythonhosted.org/packages/c6/5e/bafd9dfa9a9a0d26b9882053aaea2280d791225c1e3b33b4b48f23fd5f92/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:9e3f6e366f62e54d7bd0fb995f007f42d23c26eee560d57c04d75d5adde3b45c", size = 10355698, upload-time = "2026-07-24T09:58:56.713Z" }, - { url = "https://files.pythonhosted.org/packages/0a/00/ef55cdc9f5ade20475622bb8dba617efce00ef9da2b94b36a76172edadce/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:2ddf4f2d1d063f330bb54b2dc00f6d5bcfbdd3defdc5988b856d350f62160e26", size = 10197859, upload-time = "2026-07-24T09:58:59.158Z" }, - { url = "https://files.pythonhosted.org/packages/a8/67/d1c359658458cf97296fda4359bfc71de8c9f968fb3db68eb7ce00136d43/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ce2c9550c30a5c94b63dfe578243d0823511feeecb020723f5140f9737505410", size = 10661715, upload-time = "2026-07-24T09:59:01.576Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/9841d93f956bfbd0bbbac75edf42bb3b13b5bad7d25b09d9a221b5e54d71/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:05563e178b6be783de088abe4a25cef9cfbc04811f7c1e1873860252e711edac", size = 9143212, upload-time = "2026-07-24T09:59:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ce/0cd3643cc5051456b7baad6f16d85fb1d05daefd0556e4c82ea8f22cc9a2/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de74aa4df47147d104bccdac4b39c1f3fd543091be3fd0156f77eeea3e483bc3", size = 9731590, upload-time = "2026-07-24T09:59:06.323Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5a/69f4eabec2538df364242395ab3ef77b30a124a0e4b461231589651a1e97/pydantic_monty_runtime-0.0.19-cp312-cp312-win32.whl", hash = "sha256:5208056d9e23d951768ba4b94df3caf7fd84bfe951f68ec4a1803eb03377bbeb", size = 9227834, upload-time = "2026-07-24T09:59:08.694Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4b/221a21f477aef0c488cbe1467111b0988658bc4a42cfc6b404201bc432af/pydantic_monty_runtime-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:e4bba0c6024a3a8bd8c8a8cba25233a19cf686218e97afb4c059aa0c625a4b8b", size = 10941520, upload-time = "2026-07-24T09:59:10.959Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a8/1ba497c35eca33273f2144b8e78d94832cc33aec5f69d8bef56968a61933/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:84dd041652581335503af7c60ee7362a462d946a62e8c4a44a939984034d0d25", size = 9449108, upload-time = "2026-07-24T09:59:13.497Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6e/08c05c9729a35d6c9831bfd57d535bd1257f601d15b62936c27f1c0cfb47/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:263ab33dce2008ca6c83b4013b0280a98a1991d4071c55413d00b539940fe8aa", size = 9735874, upload-time = "2026-07-24T09:59:15.918Z" }, - { url = "https://files.pythonhosted.org/packages/c0/64/63fbdb4c069ceb2af6261fe5923864b5be309799087f16a5dccc96141c12/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:64f56d48097d8a158125534b20e8b22003c5e727082a303907da8e61aae0c7e3", size = 9171498, upload-time = "2026-07-24T09:59:18.799Z" }, - { url = "https://files.pythonhosted.org/packages/1d/cf/82390fe7f0e1100662e6cac7a1c0de716ea4bf851a53aa2b0ef324fc4e3e/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:a4d80fb4598524cd5039a596f8e6900adb2a5a2da66d012a3734a2b441ad06c3", size = 9565494, upload-time = "2026-07-24T09:59:21.232Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/caa8bf32ba0c0e8ce31ef2773b1a1f60d688e0237cd396e48bbef9f7161f/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:1c5cd0b140c765772e0606a7047fbf95cc49d62d0d8b79b4f520dae0e38b3ba7", size = 10199597, upload-time = "2026-07-24T09:59:23.702Z" }, - { url = "https://files.pythonhosted.org/packages/48/25/ae9bb52518b2ce8fe7509d6cad4f4916b4458b748fecb180bef2d78165e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:7d80975c6e792088285f49a91e26de483ef95e5bffc4939b02d4c5ea1059749b", size = 10355699, upload-time = "2026-07-24T09:59:26.389Z" }, - { url = "https://files.pythonhosted.org/packages/a7/38/0875b1239b8ea57e4ea6de421cd240fc5a88c02e381f88d858f00439b1e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:f826cb911f78fe4fddf09fa3f38518484041908d08de927fa7dd134c002a2c77", size = 10197858, upload-time = "2026-07-24T09:59:28.889Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/9365473fe31e53a6dc4d6fea406d5d8f3f03a9b7d84d1f29a9e6d664c862/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:fdf56c6cd8c6163737d1ce284f9acb00feff77b7e952f041dc281b94336c4fc9", size = 10661715, upload-time = "2026-07-24T09:59:31.498Z" }, - { url = "https://files.pythonhosted.org/packages/20/f9/8d85b8f77d4006a8d80517a0781c49e6ca026f68747f801b63298e64197e/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a675f165773af0a2e0209dd96014aa9e613115cdc6dad297a7bb96cf87f83315", size = 9143210, upload-time = "2026-07-24T09:59:33.843Z" }, - { url = "https://files.pythonhosted.org/packages/58/53/ded73742ee9fa2160c711141c28ea2ee083f073019e89724049c5e67cd84/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9e42ab0287c0497d9d5529e8a53dd2f63f9d1f92f6592170baab894ea9956da6", size = 9731590, upload-time = "2026-07-24T09:59:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/4b/06/67be8320dc592b8c4caa8c4c1544c8ddf2760029d46843d078aa5a4cdb14/pydantic_monty_runtime-0.0.19-cp313-cp313-win32.whl", hash = "sha256:1f5ff1b9585e648304096705045fb6bd90d43b561568b0d373265e8d201b1234", size = 9227833, upload-time = "2026-07-24T09:59:39.022Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/bab34897974d83640f8773f03d2001142bc13e80e517bfce8c8c4a57157e/pydantic_monty_runtime-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:7181a2153ff257fe34109685148167b6e8219d4acfe8f346115b58152fd26aa8", size = 10941519, upload-time = "2026-07-24T09:59:41.538Z" }, - { url = "https://files.pythonhosted.org/packages/63/8d/f46ac4778b2ac64183607bc63ecc783f16ca9981de30eb8ec9aa5e7132cd/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4cc92139b476469c0d7e5caa147d38c2929de39bf1724cb22bbab80297823963", size = 9449107, upload-time = "2026-07-24T09:59:44.139Z" }, - { url = "https://files.pythonhosted.org/packages/a5/14/34a7bb4630d1bac0d055049568ecc888a87954c176428bde5764a7ff6ed7/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a97e00bd46305f34a85f2e2e3ac4c92dbd3340e7af694aafb192ff58c4fb40e", size = 9735874, upload-time = "2026-07-24T09:59:46.546Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/977de0b258c0858f52b23bd32afbfdf8a8c4614daff5d0b7d5c86332ce6e/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:46ad28b1b3c41113c9e89373da18bcc883a24da371d4eeb9b32d3d194286f1a5", size = 9171497, upload-time = "2026-07-24T09:59:48.768Z" }, - { url = "https://files.pythonhosted.org/packages/39/39/4374200ee4b938fc8b5026056f13bb677e15796bca1323a16210738431ad/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:dc61844187a32c2f9c2b69846c5d55679eb838b4f7e495b4d03509f89fbf41f9", size = 9565495, upload-time = "2026-07-24T09:59:51.149Z" }, - { url = "https://files.pythonhosted.org/packages/04/55/c4ee4b0a10610359e09cad9d327db19095914a3bf427fb3d8164ec2bcae0/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:b23b52a79ee6be0a943e4b8e5d996e6c489a37b23a337838164f22c9e8ed11a7", size = 10199598, upload-time = "2026-07-24T09:59:53.619Z" }, - { url = "https://files.pythonhosted.org/packages/87/62/cc9df084e9f930bbb2873b6dd832b377f76071aec309b1545589d818fd90/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:484496817f5f238c42aaea8a1945b545a17d8ef2a21e9e81799d55e481d25485", size = 10355699, upload-time = "2026-07-24T09:59:56.043Z" }, - { url = "https://files.pythonhosted.org/packages/c8/cc/bb13e6f655fcaee340032ad6a3cd1524957d0fa471ddc7e27bdb1f4c240b/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c3e7cbad58ae9bd3402581faa7d54d719f9c140dc1888f5c9439d45bff528ea1", size = 10197859, upload-time = "2026-07-24T09:59:58.481Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c3/c532715987383668ee835337e1485f51585bc8bf189f033370a05eff17f1/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5534067dc7ffdae809293da95d0e95b6d8481f4c88aff59385e19f466ba3c0f0", size = 10661715, upload-time = "2026-07-24T10:00:01.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6c/9a0ab28a061efc184398a0b4db34e459572bb2316cf766f0d6ce65b475e3/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c1ae32b06a4456ab223bafa54d29a349066d625d09063c28ba14ee9019f9b7c2", size = 9143211, upload-time = "2026-07-24T10:00:03.856Z" }, - { url = "https://files.pythonhosted.org/packages/23/5f/af5b3e6395834572975d98f4d1a00a57ee8029bf68ca5732347550f32b35/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:497cf8c3f30992f9aafc8707084eaffe2391b7b5dec067d04d5715f9a562c56b", size = 9731591, upload-time = "2026-07-24T10:00:06.351Z" }, - { url = "https://files.pythonhosted.org/packages/1a/98/96797fd269342cfdb49f03c22fd9a05ef91f711089081c4b3861b9c520e2/pydantic_monty_runtime-0.0.19-cp314-cp314-win32.whl", hash = "sha256:942feb948df8edb61ae7ba6ae77dc655e6985be06d72eef886562fe573ba3086", size = 9227832, upload-time = "2026-07-24T10:00:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/39/fc/02d15281c8e00b48df9af8f75a4fe06f3f8f33ef6a910507a45a19f2b61b/pydantic_monty_runtime-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:91d93339c70483ed9256b3b15e3375f6597ae65be280f9b89ba9ca0355f95f54", size = 10941519, upload-time = "2026-07-24T10:00:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/00/97/d1575be31396cf662ea8419d427c58279378a2e08b674d436ed4990751ea/pydantic_monty_runtime-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c8968491ae44648c11cf075c0f5d798d4aa7f7e4a0d0754add0b74d725c075c", size = 9828049, upload-time = "2026-09-05T19:25:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/15/09/01ae8472d860223618cd61a4656d8ff44519e1bd9e320ee8698dbb206aca/pydantic_monty_runtime-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c60e44cbef9cfdc9bd5e53631650490e66890f57e8c6bea5611dae502d8374d4", size = 11548563, upload-time = "2026-09-05T19:25:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/fe/30/ebf796ec2236b15cb9f5541e3a940536f37de41bd2561989985aa92fb396/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4bc24603678107b7f5b2b24a5946ca1e103379b2289589212c86959993a052b4", size = 11935977, upload-time = "2026-09-05T19:25:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/c2/40/8ed68e144f80c9d14e0532d631ffd78ee6a0ae8d7d3272ac037d5d703266/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:7c68422e06c5aae8b52f9835ee949256db0f261301515e4443d6647f249cf789", size = 10180097, upload-time = "2026-09-05T19:25:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f3/ea3632cebb754b81fa63c01b84c89299bba936c670fa519f3ff363166bcc/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_i686.whl", hash = "sha256:b8a049a554bff6ba7e553c1ae4b6290e554b6a1695bc1d47b1b03db485f7fb91", size = 10515148, upload-time = "2026-09-05T19:25:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a9/6a43d9d9f01f441ecd6ec6c31d9b8d45b143a37b9368f582b27378e50ec7/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_ppc64le.whl", hash = "sha256:9cea174ed1a56888bb58975feff34b0475504ab3831b3b1dc3189397d5bcccc3", size = 10746650, upload-time = "2026-09-05T19:25:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/dc2c2e066ce4f619f34a98b44c9c84fb398095421808c9dd6ac12463aea2/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_s390x.whl", hash = "sha256:28bb59cde9de0cec0d3d5d72029851757249c682a6747251f61e8cdece5995f7", size = 11092334, upload-time = "2026-09-05T19:25:49.314Z" }, + { url = "https://files.pythonhosted.org/packages/3b/82/6af8a19dd357d63e783e9647ef81d47db2f48c13e96558d25484cd0dec91/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:721d2d1455482c929c934412e61ecf56673b88a2c0c049e041295cf543ff1412", size = 12371608, upload-time = "2026-09-05T19:25:51.663Z" }, + { url = "https://files.pythonhosted.org/packages/f7/53/2939bce8c8a688771777ee2301851a712744d809d752c1c8dcb5d6a52574/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f0183e589ba18de4fac8a675fe0f93049baa293d5409eb8d39c639dc24af943f", size = 9593312, upload-time = "2026-09-05T19:25:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f9/180d9acbea18d8050c190280ef591cb52d05e4d0e6d026cc6b7b2613510c/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cbf56c19712c12072f6607d8e2323941441c9343734dcb07d2d2b4ce2dd98bf3", size = 10133520, upload-time = "2026-09-05T19:25:56.797Z" }, + { url = "https://files.pythonhosted.org/packages/e1/00/dcd69bd9fcc57c7a6ecfcd3b2199f4598853edb14d504f481dcda41b17cd/pydantic_monty_runtime-0.0.23-py3-none-win32.whl", hash = "sha256:854f58a472397f34588a22eb813f2acd65efc0bcb966ef1fe173c32366209d15", size = 9639882, upload-time = "2026-09-05T19:25:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c0/3f7c604ac93ec47a36a3733d6e7717b7096f0989f669d4133bd3e85f3375/pydantic_monty_runtime-0.0.23-py3-none-win_amd64.whl", hash = "sha256:742375f494e298a4f96933ac3694a0fe34c9cddd9b666669bc193f348bbed0b8", size = 10404629, upload-time = "2026-09-05T19:26:01.143Z" }, ] [[package]] @@ -5543,11 +5539,11 @@ wheels = [ [[package]] name = "uncalled-for" -version = "0.2.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, + { url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" }, ] [[package]]