diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0cebaa24..756b971d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,45 @@
# Changelog
## [Unreleased]
+### Added
+
+- **Document virtual filesystem in analysis sandbox**: Documents mounted at `/documents/{id}/` with `metadata.json` (eager), `content.txt` (lazy), and `items.jsonl` (lazy). Standard Python `pathlib.Path` for browsing and reading document content and structure.
+- **`execute_code` skill tool**: Direct code execution in the sandbox, surfaced as individual AG-UI events in the chat TUI. Items VFS uses a lazy bulk cache (~1s for 1000 documents vs 60s+ per-document queries).
+- **`cite` skill tool**: Explicit citation registration with per-turn tracking via `citation_index` and `citations` fields in state
+- **`--skill` flag for chat TUI**: `haiku-rag chat -s rag -s analysis` to enable specific skills
+- **`--model` overrides all agents**: Chat, QA, research, and analysis agents all use the specified model
+- **Collapsible program display in chat TUI**: Analysis code execution results shown as expandable code blocks
+
+### Changed
+
+- **BREAKING: Flatten skill architecture**: Skill sub-agents now call `search`, `execute_code`, `cite`, `list_documents`, `get_document` directly — every tool call surfaces as an AG-UI event. Removes the 3rd agent layer where `ask`/`analyze`/`research` spawned inner agents whose tool calls were invisible.
+- **BREAKING: Rename RLM agent to analysis agent** throughout:
+ - `agents/rlm/` → `agents/analysis/`, all classes renamed (`RLMResult` → `AnalysisResult`, etc.)
+ - `client.rlm()` → `client.analyze()`
+ - CLI: `haiku-rag rlm` → `haiku-rag analyze`
+ - MCP: `rlm_question` → `analyze`
+ - Config: `rlm:` → `analysis:` in YAML, `RLMConfig` → `AnalysisConfig`
+ - Skill entrypoint: `rag-rlm` → `rag-analysis`
+- **Analysis sandbox `search()` returns expanded results** with `doc_item_refs` and `labels` for cross-referencing with `items.jsonl`
+- **`list_documents` skill tool** takes no parameters — returns all documents
+- **Per-turn citation tracking**: `citation_index: dict[str, Citation]` (deduplicated) + `citations: list[list[str]]` (per-turn chunk IDs) replaces flat citation list
+- **Search rate limiting**: Skill search tool enforces `config.qa.max_searches`
+- **Context expansion respects section boundaries**: Sections within the char budget are returned whole regardless of item count. Too-large sections expand bounded by section edges. Adjacent sections no longer merge — only overlapping ranges do.
+- **Visualization shows full expanded section**: `visualize_chunk` expands context before resolving bounding boxes, so all pages the section spans get highlighted.
+
+### Removed
+
+- **`ask` skill tool**: Replaced by direct `search` + `cite` — the skill sub-agent searches and answers directly
+- **`analyze` skill tool**: Replaced by direct `execute_code` + `search` + `cite`
+- **`research` skill tool**: Removed from skill layer (still available via CLI `haiku-rag research` and MCP)
+- **`get_document()`, `get_docling_document()`**: Removed from analysis sandbox — replaced by VFS
+- **`get_chunk()`**: Removed from analysis sandbox — search results include expanded context
+- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module
+- **`qa_history`, `reports` from skill state**: Conversational context handled by the outer chat agent
+- **`combine_filters`, `build_document_filter`**: Removed from public API
+- **`max_context_items`**: Removed from `SearchConfig` — `max_context_chars` is the sole expansion constraint
+- **`QAHistoryEntry`, `tools/qa.py`**: Removed unused QA history model and relevance threshold
+
## [0.40.1] - 2026-04-17
### Fixed
diff --git a/README.md b/README.md
index 1f09a20d..10231bfb 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Agentic RAG built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.p
- **Question answering** — QA agents with citations (page numbers, section headings)
- **Reranking** — MxBAI, Cohere, Zero Entropy, or vLLM
- **Research agents** — Multi-agent workflows via pydantic-graph: plan, search, evaluate, synthesize
-- **RLM agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
+- **Analysis agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
- **Conversational RAG** — Chat TUI and web application for multi-turn conversations with session memory
- **Document structure** — Stores full [DoclingDocument](https://docling-project.github.io/docling/concepts/docling_document/), enabling structure-aware context expansion
- **Multiple providers** — Embeddings: Ollama, OpenAI, VoyageAI, LM Studio, vLLM. QA/Research: any model supported by Pydantic AI
@@ -62,8 +62,8 @@ haiku-rag ask "What datasets were used for evaluation?" --cite
# Research mode — iterative planning and search
haiku-rag research "What are the limitations of the approach?"
-# RLM mode — complex analytical tasks via code execution
-haiku-rag rlm "How many documents mention transformers?"
+# Analyze — complex analytical tasks via code execution
+haiku-rag analyze "How many documents mention transformers?"
# Interactive chat — multi-turn conversations with memory
haiku-rag chat
@@ -138,7 +138,7 @@ Full documentation at: https://ggozad.github.io/haiku.rag/
- [CLI](https://ggozad.github.io/haiku.rag/cli/) - Command reference
- [Python API](https://ggozad.github.io/haiku.rag/python/) - Complete API docs
- [Agents](https://ggozad.github.io/haiku.rag/agents/) - QA and research agents
-- [RLM Agent](https://ggozad.github.io/haiku.rag/rlm/) - Complex analytical tasks via code execution
+- [Analysis Agent](https://ggozad.github.io/haiku.rag/agents/analysis/) - Complex analytical tasks via code execution
- [Applications](https://ggozad.github.io/haiku.rag/apps/) - Chat TUI, web app, and inspector
- [Server](https://ggozad.github.io/haiku.rag/server/) - File monitoring and MCP
- [MCP](https://ggozad.github.io/haiku.rag/mcp/) - Model Context Protocol integration
diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx
index d0c24f48..17a50eca 100644
--- a/app/frontend/components/Chat.tsx
+++ b/app/frontend/components/Chat.tsx
@@ -21,8 +21,8 @@ import { FilterIcon } from "../lib/icons";
import type { RAGState } from "../lib/sessionStorage";
import {
createSession,
- deriveCitationsHistory,
getActiveSessionId,
+ getLatestCitations,
getSession,
normalizeRAGState,
updateSessionMessages,
@@ -148,11 +148,11 @@ function ToolCallIndicator({
switch (toolName) {
case "search":
return ;
- case "ask":
- return ;
case "get_document":
return ;
case "execute_skill":
+ case "execute_code":
+ case "cite":
return ;
default:
return ;
@@ -163,16 +163,16 @@ function ToolCallIndicator({
switch (toolName) {
case "search":
return "Search";
- case "ask":
- return "Ask";
case "get_document":
return "Document";
case "execute_skill":
return "Skill";
- case "analyze":
- return "Analyze";
- case "research":
- return "Research";
+ case "execute_code":
+ return "Code";
+ case "cite":
+ return "Cite";
+ case "list_documents":
+ return "Documents";
default:
return toolName;
}
@@ -194,16 +194,20 @@ function ToolCallIndicator({
const query = args.query as string;
return {query};
}
- case "ask": {
- const question = args.question as string;
- return {question};
- }
case "get_document":
return {args.query as string};
- case "analyze":
- return {args.question as string};
- case "research":
- return {args.question as string};
+ case "execute_code": {
+ const code = args.code as string | undefined;
+ return (
+
+ {code ? code.slice(0, 80) : "Running code..."}
+
+ );
+ }
+ case "cite":
+ return Registering citations;
+ case "list_documents":
+ return Listing documents;
default:
return Processing...;
}
@@ -292,7 +296,7 @@ function MessageViewWithCitations({
isRunning?: boolean;
}) {
const ragState = useContext(ChatStateContext);
- const citationsHistory = ragState ? deriveCitationsHistory(ragState) : [];
+ const latestCitations = ragState ? getLatestCitations(ragState) : [];
// Collect completed tool_call_ids from skill_tool_result activity messages
const completedToolCallIds = useMemo(() => {
@@ -326,7 +330,6 @@ function MessageViewWithCitations({
{({ messageElements }) => {
const result: React.ReactNode[] = [];
let elemIdx = 0;
- let citIdx = 0;
let seenToolCalls = false;
for (const msg of messages) {
@@ -368,19 +371,15 @@ function MessageViewWithCitations({
}
// After an assistant text response that followed tool calls,
- // inject the next citations entry (one per turn)
+ // show citations from the latest turn
if (msg.role === "assistant" && msg.content && seenToolCalls) {
- if (citIdx < citationsHistory.length) {
- const citations = citationsHistory[citIdx];
- if (citations?.length) {
- result.push(
- ,
- );
- }
- citIdx++;
+ if (latestCitations.length > 0) {
+ result.push(
+ ,
+ );
}
seenToolCalls = false;
}
diff --git a/app/frontend/lib/sessionStorage.ts b/app/frontend/lib/sessionStorage.ts
index bf1f39c2..22a6b75f 100644
--- a/app/frontend/lib/sessionStorage.ts
+++ b/app/frontend/lib/sessionStorage.ts
@@ -9,33 +9,12 @@ export interface Citation {
content: string;
}
-export interface QAHistoryEntry {
- question: string;
- answer: string;
- citations: Citation[];
-}
-
-export interface DocumentInfo {
- id: string;
- title: string;
- uri: string;
- created: string;
-}
-
-export interface ResearchEntry {
- question: string;
- title: string;
- executive_summary: string;
-}
-
// Matches RAGState from the backend skill
export interface RAGState {
- citations: Citation[];
- qa_history: QAHistoryEntry[];
+ citation_index: Record;
+ citations: string[][];
document_filter: string | null;
searches: Record;
- documents: DocumentInfo[];
- reports: ResearchEntry[];
}
export interface StoredMessage {
@@ -59,20 +38,20 @@ const ACTIVE_SESSION_KEY = "haiku.rag.activeSession";
export function normalizeRAGState(state?: Partial): RAGState {
return {
+ citation_index: state?.citation_index ?? {},
citations: state?.citations ?? [],
- qa_history: state?.qa_history ?? [],
document_filter: state?.document_filter ?? null,
searches: state?.searches ?? {},
- documents: state?.documents ?? [],
- reports: state?.reports ?? [],
};
}
-// Derive per-turn citation arrays from qa_history
-export function deriveCitationsHistory(state: RAGState): Citation[][] {
- return state.qa_history
- .filter((entry) => entry.citations?.length > 0)
- .map((entry) => entry.citations);
+export function getLatestCitations(state: RAGState): Citation[] {
+ const turns = state.citations;
+ if (turns.length === 0) return [];
+ const latestIds = turns[turns.length - 1];
+ return latestIds
+ .map((id) => state.citation_index[id])
+ .filter((c): c is Citation => c !== undefined);
}
export function getAllSessions(): StoredSession[] {
diff --git a/docs/agents/analysis.md b/docs/agents/analysis.md
new file mode 100644
index 00000000..5f04f7f7
--- /dev/null
+++ b/docs/agents/analysis.md
@@ -0,0 +1,127 @@
+# Analysis Agent
+
+The analysis agent enables complex analytical tasks by writing and executing Python code in a sandboxed environment. It solves problems that traditional RAG struggles with:
+
+- **Aggregation**: "How many documents mention security vulnerabilities?"
+- **Computation**: "What's the average revenue across all quarterly reports?"
+- **Multi-document analysis**: "Compare the key findings between Report A and Report B"
+- **Structured data extraction**: "Extract all dollar amounts and compute totals"
+
+## How It Works
+
+1. The agent receives a question
+2. It writes Python code to explore the knowledge base
+3. Code executes in a sandboxed Python interpreter with access to search, LLM, and a virtual filesystem of documents
+4. The agent iterates: run code, examine results, refine approach
+5. Final answer is synthesized from the gathered data
+
+## CLI Usage
+
+```bash
+# Basic usage
+haiku-rag analyze "How many documents are in the database?"
+
+# With document filter (restricts what the agent can access)
+haiku-rag analyze "Summarize the key points" --filter "uri LIKE '%report%'"
+
+# Pre-load specific documents
+haiku-rag analyze "Compare these two reports" --document "Q1 Report" --document "Q2 Report"
+```
+
+## Python Usage
+
+```python
+from haiku.rag.client import HaikuRAG
+
+async with HaikuRAG(path_to_db) as client:
+ # Basic question
+ result = await client.analyze("How many documents mention 'security'?")
+ print(result.answer) # The answer
+ print(result.program) # The final consolidated program
+
+ # With filter (agent can only see filtered documents)
+ result = await client.analyze(
+ "What is the total revenue?",
+ filter="title LIKE '%Financial%'"
+ )
+
+ # Pre-load specific documents
+ result = await client.analyze(
+ "Compare the conclusions",
+ documents=["Report A", "Report B"]
+ )
+```
+
+## Sandbox Capabilities
+
+The agent's code runs in a sandboxed Python interpreter ([pydantic-monty](https://github.com/pydantic/monty)) with:
+
+### Functions
+
+| Function | Description |
+|----------|-------------|
+| `search(query, limit)` | Hybrid search (vector + full-text) with automatic context expansion. Returns `doc_item_refs` for cross-referencing with `items.jsonl` |
+| `list_documents()` | List all documents in the knowledge base |
+| `llm(prompt)` | Call an LLM for classification, summarization, or extraction |
+
+### Document Filesystem
+
+All documents are mounted as a virtual filesystem at `/documents/`. The agent uses standard Python `pathlib.Path` to browse and read files:
+
+```
+/documents/{document_id}/
+ metadata.json # {id, title, uri, created_at}
+ content.txt # Full document text
+ items.jsonl # Structured items: position, self_ref, label, text, page_numbers
+```
+
+- **`metadata.json`** — Loaded eagerly (small). Use `Path('/documents').iterdir()` to discover documents.
+- **`content.txt`** — Lazy-loaded on first read. Full document text for regex or keyword search.
+- **`items.jsonl`** — Lazy-loaded on first read. One JSON object per line with structured document elements. Tables are pre-rendered as markdown. Labels include `section_header`, `text`, `table`, `list_item`, `caption`, `formula`, `picture`, `code`, `footnote`, etc.
+
+Search results include `doc_item_refs` (e.g. `["#/texts/5", "#/tables/0"]`) that match `self_ref` values in `items.jsonl`, enabling navigation from search hits to document structure.
+
+When documents are pre-loaded via the `documents` parameter, they are also injected as a `documents` variable accessible in the sandbox code.
+
+### Python Features
+
+The interpreter supports a subset of Python: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `filter()`, `getattr()`, try/except, file I/O via `pathlib.Path`, and the `json`, `re`, `math` modules.
+
+Not supported: most imports (only `json`, `re`, `math`, `pathlib` are available), class definitions, generators/yield, match statements, decorators, `with` statements. For pattern matching, the agent can use `import re`, string methods, or the `llm()` function.
+
+### Security
+
+Code executes in an isolated interpreter with:
+
+- **Virtual filesystem only**: The `/documents/` filesystem is sandboxed — no access to the real filesystem
+- **No network access**: Code cannot make HTTP requests or open sockets
+- **No imports**: Only `json`, `re`, `math`, and `pathlib` modules are available
+- **Execution timeout**: Configurable limit (default 60s)
+- **Output truncation**: Large outputs are truncated to prevent memory issues
+
+## Context Filter
+
+The `filter` parameter restricts what documents the agent can access. Unlike tool parameters, the filter is applied automatically and cannot be bypassed by the LLM — both the VFS and search results are scoped to the filter:
+
+```python
+# Agent can only see documents with "confidential" in the URI
+result = await client.analyze(
+ "Summarize all findings",
+ filter="uri LIKE '%confidential%'"
+)
+```
+
+This is useful for scoping to specific document sets, enforcing access control, or limiting context for focused analysis.
+
+## Configuration
+
+Analysis settings can be configured in `haiku.rag.yaml`:
+
+```yaml
+analysis:
+ model:
+ provider: anthropic
+ name: claude-sonnet-4-20250514
+ code_timeout: 60.0 # Max seconds for code execution
+ max_output_chars: 50000 # Truncate output after this many chars
+```
diff --git a/docs/agents/index.md b/docs/agents/index.md
index 611a7ca7..fb95c234 100644
--- a/docs/agents/index.md
+++ b/docs/agents/index.md
@@ -4,7 +4,7 @@ Three agentic flows are provided by haiku.rag:
- **Simple QA Agent** — a focused question answering agent
- **Research Graph** — a multi-step research workflow with question decomposition
-- **RLM Agent** — complex analytical tasks via sandboxed Python code execution (see [RLM Agent](rlm.md))
+- **Analysis Agent** — complex analytical tasks via sandboxed Python code execution (see [Analysis Agent](analysis.md))
For multi-turn conversational RAG, haiku.rag provides [skills](../skills/index.md) built on [haiku.skills](https://github.com/ggozad/haiku.skills). The skills bundle search, Q&A, analysis, and research tools with session state management.
diff --git a/docs/agents/rlm.md b/docs/agents/rlm.md
deleted file mode 100644
index ea39c453..00000000
--- a/docs/agents/rlm.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# RLM Agent (Recursive Language Model)
-
-The RLM agent enables complex analytical tasks by writing and executing Python code in a sandboxed environment. It solves problems that traditional RAG struggles with:
-
-- **Aggregation**: "How many documents mention security vulnerabilities?"
-- **Computation**: "What's the average revenue across all quarterly reports?"
-- **Multi-document analysis**: "Compare the key findings between Report A and Report B"
-- **Structured data extraction**: "Extract all dollar amounts and compute totals"
-
-## How It Works
-
-1. The agent receives a question
-2. It writes Python code to explore the knowledge base
-3. Code executes in a sandboxed Python interpreter with access to knowledge base functions
-4. The agent iterates: run code, examine results, refine approach
-5. Final answer is synthesized from the gathered data
-
-## CLI Usage
-
-```bash
-# Basic usage
-haiku-rag rlm "How many documents are in the database?"
-
-# With document filter (restricts what the agent can access)
-haiku-rag rlm "Summarize the key points" --filter "uri LIKE '%report%'"
-
-# Pre-load specific documents
-haiku-rag rlm "Compare these two reports" --document "Q1 Report" --document "Q2 Report"
-```
-
-## Python Usage
-
-```python
-from haiku.rag.client import HaikuRAG
-
-async with HaikuRAG(path_to_db) as client:
- # Basic question
- result = await client.rlm("How many documents mention 'security'?")
- print(result.answer) # The answer
- print(result.program) # The final consolidated program
-
- # With filter (agent can only see filtered documents)
- result = await client.rlm(
- "What is the total revenue?",
- filter="title LIKE '%Financial%'"
- )
-
- # Pre-load specific documents
- result = await client.rlm(
- "Compare the conclusions",
- documents=["Report A", "Report B"]
- )
-```
-
-## Sandbox Capabilities
-
-The agent's code runs in a sandboxed Python interpreter ([pydantic-monty](https://github.com/pydantic/monty)) with access to these knowledge base functions:
-
-| Function | Description |
-|----------|-------------|
-| `search(query, limit)` | Hybrid search (vector + full-text) returning matching chunks with scores |
-| `list_documents(limit, offset)` | List documents in the knowledge base |
-| `get_document(id_or_title)` | Get full text content of a document |
-| `get_chunk(chunk_id)` | Get a chunk with metadata (headings, page numbers, labels) for citations |
-| `get_docling_document(document_id)` | Get the DoclingDocument structure as a dict (texts, tables, pictures) |
-| `llm(prompt)` | Call an LLM for classification, summarization, or extraction |
-
-When documents are pre-loaded via the `documents` parameter, they are injected as a `documents` variable accessible in the sandbox code.
-
-### Python Features
-
-The interpreter supports a subset of Python: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `filter()`, `getattr()`, try/except, and the `json`, `re`, `math` modules.
-
-Not supported: most imports (only `json`, `re`, `math` are available), class definitions, generators/yield, match statements, decorators, `with` statements. For pattern matching, the agent can use `import re`, string methods, or the `llm()` function.
-
-### Security
-
-Code executes in an isolated interpreter with:
-
-- **No filesystem access**: Code cannot read or write files
-- **No network access**: Code cannot make HTTP requests or open sockets
-- **No imports**: Only `json`, `re`, and `math` modules are available
-- **Execution timeout**: Configurable limit (default 60s)
-- **Output truncation**: Large outputs are truncated to prevent memory issues
-
-## Context Filter
-
-The `filter` parameter restricts what documents the agent can access. Unlike tool parameters, the filter is applied automatically and cannot be bypassed by the LLM:
-
-```python
-# Agent can only see documents with "confidential" in the URI
-result = await client.rlm(
- "Summarize all findings",
- filter="uri LIKE '%confidential%'"
-)
-```
-
-This is useful for scoping to specific document sets, enforcing access control, or limiting context for focused analysis.
-
-## Configuration
-
-RLM settings can be configured in `haiku.rag.yaml`:
-
-```yaml
-rlm:
- model:
- provider: anthropic
- name: claude-sonnet-4-20250514
- code_timeout: 60.0 # Max seconds for code execution
- max_output_chars: 50000 # Truncate output after this many chars
-```
diff --git a/docs/apps.md b/docs/apps.md
index 570c78bb..4da9dcdf 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -14,6 +14,12 @@ Conversational RAG from the terminal with streaming responses and session memory
```bash
haiku-rag chat
haiku-rag chat --db /path/to/database.lancedb
+
+# Enable analysis skill (code execution)
+haiku-rag chat -s rag -s analysis
+
+# Analysis only
+haiku-rag chat -s analysis
```
### Interface
@@ -46,7 +52,6 @@ Press `Ctrl+P` to open the command palette:
### Session Management
- Conversation history is maintained in memory for the session
-- Previous Q/A pairs are automatically used as context for follow-up questions via the `ask` tool
- Citations are tracked per response and can be inspected
- Document filter restricts all searches to selected documents
- Clearing chat resets session state
@@ -61,7 +66,7 @@ Browser-based conversational RAG with a CopilotKit frontend.
- Expandable citations with source documents, pages, and headings
- Visual grounding to view chunk source locations in documents
- Document filter to restrict searches to selected documents
-- Session state view for inspecting accumulated Q&A history, citations, and documents
+- Session state view for inspecting citations and search results
### Quick Start
diff --git a/docs/architecture.md b/docs/architecture.md
deleted file mode 100644
index 4c9ed1ff..00000000
--- a/docs/architecture.md
+++ /dev/null
@@ -1,233 +0,0 @@
-# Architecture
-
-High-level overview of haiku.rag components and data flow.
-
-## System Overview
-
-```mermaid
-flowchart TB
- subgraph Sources["Document Sources"]
- Files[Files]
- URLs[URLs]
- Text[Text]
- end
-
- subgraph Processing["Processing Pipeline"]
- Converter[Converter]
- Chunker[Chunker]
- Embedder[Embedder]
- end
-
- subgraph Storage["Storage Layer"]
- LanceDB[(LanceDB)]
- end
-
- subgraph Agents["Agent Layer"]
- QA[QA Agent]
- Skill[RAG Skill]
- Research[Research Graph]
- RLM[RLM Agent]
- end
-
- subgraph Apps["Applications"]
- CLI[CLI]
- ChatTUI[Chat TUI]
- WebApp[Web App]
- Inspector[Inspector]
- MCP[MCP Server]
- end
-
- Sources --> Converter
- Converter --> Chunker
- Chunker --> Embedder
- Embedder --> LanceDB
-
- LanceDB --> Agents
- Agents --> Apps
-```
-
-## Core Components
-
-### Storage Layer
-
-LanceDB provides vector storage with full-text search capabilities:
-
-- **DocumentRecord** - Document metadata and full content
-- **ChunkRecord** - Text chunks with embeddings and structural metadata
-- **SettingsRecord** - Database configuration and version info
-
-Repositories handle CRUD operations:
-
-- `DocumentRepository` - Create, read, update, delete documents
-- `ChunkRepository` - Chunk management and hybrid search
-- `SettingsRepository` - Configuration persistence
-
-### Processing Pipeline
-
-```mermaid
-flowchart LR
- Source[Source] --> Converter
- Converter --> DoclingDoc[DoclingDocument]
- DoclingDoc --> Chunker
- Chunker --> Chunks[Chunks]
- Chunks --> Embedder
- Embedder --> Vectors[Vectors]
- Vectors --> DB[(LanceDB)]
-```
-
-**Converters** transform sources into DoclingDocuments:
-
-- `docling-local` - Local Docling processing
-- `docling-serve` - Remote processing via docling-serve
-
-**Chunkers** split documents into semantic chunks:
-
-- Preserves document structure (tables, lists, code blocks)
-- Maintains provenance (page numbers, headings)
-- Configurable chunk size
-
-**Embedders** generate vector representations:
-
-| Provider | Models |
-|----------|--------|
-| Ollama | nomic-embed-text, mxbai-embed-large |
-| OpenAI | text-embedding-3-small, text-embedding-3-large |
-| VoyageAI | voyage-3, voyage-code-3 |
-| vLLM | Any compatible model |
-| LM Studio | Any compatible model |
-
-### Agent Layer
-
-Three agent types and a RAG skill for different use cases:
-
-```mermaid
-flowchart TB
- subgraph QA["QA Agent"]
- Q1[Question] --> S1[Search]
- S1 --> A1[Answer]
- end
-
- subgraph Skill["RAG Skill"]
- Q2[Question] --> Tools[Tool Selection]
- Tools --> S2[Search / Ask / Analyze]
- S2 --> A2[Answer]
- A2 --> State[RAG State]
- State -.-> Q2
- end
-
- subgraph Research["Research Graph"]
- Q3[Question] --> Plan[Plan Next]
- Plan --> SearchOne[Search One]
- SearchOne --> Eval[Evaluate]
- Eval -->|Continue| Plan
- Eval -->|Done| Synthesize[Synthesize]
- end
-
- subgraph RLM["RLM Agent"]
- Q4[Question] --> Code[Write Code]
- Code --> Execute[Execute]
- Execute --> Examine[Examine Results]
- Examine -->|Iterate| Code
- Examine -->|Done| A4[Answer]
- end
-```
-
-**QA Agent** - Single-turn question answering:
-
-- Searches for relevant chunks
-- Expands context around results
-- Generates answer with optional citations
-
-**RAG Skill** - Multi-turn conversational RAG via [haiku.skills](https://github.com/ggozad/haiku.skills):
-
-- Bundles search, list_documents, get_document, ask, analyze, and research tools
-- Managed `RAGState` for session state (citations, QA history, document filters)
-- Integrates with any pydantic-ai agent via `SkillToolset`
-- Powers both the Chat TUI and web application
-
-**Research Graph** - Iterative research workflow:
-
-- Proposes one question at a time, evaluates the answer, then decides whether to continue
-- Prior answers let the planner skip redundant searches
-- Synthesizes structured report
-
-**RLM Agent** - Complex analytical tasks via code execution:
-
-- Writes Python code to explore the knowledge base
-- Executes in sandboxed environment
-- Handles aggregation, computation, multi-document analysis
-- Iterates until answer is found
-
-### Applications
-
-| Application | Interface | Use Case |
-|-------------|-----------|----------|
-| CLI | Command line | Scripts, one-off queries, batch processing |
-| Chat TUI | Terminal | Interactive conversations |
-| Web App | Browser | Team collaboration, visual interface |
-| Inspector | Terminal | Database exploration, debugging |
-| MCP Server | Protocol | AI assistant integration |
-
-## Data Flow
-
-### Document Ingestion
-
-```mermaid
-sequenceDiagram
- participant User
- participant CLI
- participant Converter
- participant Chunker
- participant Embedder
- participant DB as LanceDB
-
- User->>CLI: add-src document.pdf
- CLI->>Converter: Convert to DoclingDocument
- Converter-->>CLI: DoclingDocument
- CLI->>Chunker: Split into chunks
- Chunker-->>CLI: Chunks with metadata
- CLI->>Embedder: Generate embeddings
- Embedder-->>CLI: Vectors
- CLI->>DB: Store document + chunks
- DB-->>User: Document ID
-```
-
-### Search and QA
-
-```mermaid
-sequenceDiagram
- participant User
- participant Agent
- participant Embedder
- participant DB as LanceDB
- participant LLM
-
- User->>Agent: Ask question
- Agent->>Embedder: Embed query
- Embedder-->>Agent: Query vector
- Agent->>DB: Hybrid search
- DB-->>Agent: Relevant chunks
- Agent->>Agent: Expand context
- Agent->>LLM: Generate answer
- LLM-->>Agent: Answer + citations
- Agent-->>User: Response
-```
-
-## Configuration
-
-Configuration flows through the system:
-
-```
-CLI args → Environment variables → haiku.rag.yaml → Defaults
-```
-
-Key configuration areas:
-
-- **Storage** - Database path, vacuum settings
-- **Embeddings** - Provider, model, dimensions
-- **Processing** - Chunk size, converter, chunker
-- **Search** - Limits, context expansion
-- **QA/Research** - Model, iterations, concurrency
-- **Providers** - Ollama, vLLM, docling-serve URLs
-
-See [Configuration](configuration/index.md) for details.
diff --git a/docs/cli.md b/docs/cli.md
index 56d1435c..211ce3b9 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -165,11 +165,18 @@ Launch an interactive chat session for multi-turn conversations:
```bash
haiku-rag chat
haiku-rag chat --db /path/to/database.lancedb
+
+# Enable analysis skill (code execution)
+haiku-rag chat -s rag -s analysis
```
!!! note
Requires the `tui` extra: `pip install haiku.rag-slim[tui]` (included in full `haiku.rag` package)
+Flags:
+
+- `--skill` / `-s`: Skills to enable — `rag` (default), `analysis`. Can be repeated for multiple skills.
+
The chat interface provides:
- Streaming responses with real-time tool execution
@@ -220,24 +227,24 @@ Flags:
Research parameters like `max_iterations` and `max_concurrency` are configured in your [configuration file](configuration/index.md) under the `research` section.
-## RLM (Recursive Language Model)
+## Analyze
Answer complex analytical questions via code execution:
```bash
-haiku-rag rlm "How many documents mention security?"
+haiku-rag analyze "How many documents mention security?"
```
Filter to specific documents:
```bash
-haiku-rag rlm "What is the total revenue?" --filter "title LIKE '%Financial%'"
+haiku-rag analyze "What is the total revenue?" --filter "title LIKE '%Financial%'"
```
Pre-load specific documents for comparison:
```bash
-haiku-rag rlm "Compare the conclusions" --document "Report A" --document "Report B"
+haiku-rag analyze "Compare the conclusions" --document "Report A" --document "Report B"
```
Flags:
@@ -245,7 +252,7 @@ Flags:
- `--filter` / `-f`: SQL WHERE clause to restrict document access
- `--document` / `-d`: Pre-load a document by title or ID (can repeat)
-See [RLM Agent](agents/rlm.md) for details on capabilities and configuration.
+See [Analysis Agent](agents/analysis.md) for details on capabilities and configuration.
## Create Skill
@@ -271,7 +278,7 @@ The generated package is a pip-installable Python package that registers as a `h
### Available Tools
-`analyze`, `ask`, `get_document`, `list_documents`, `research`, `search`
+`cite`, `execute_code`, `get_document`, `list_documents`, `search`
### Example
@@ -280,7 +287,7 @@ The generated package is a pip-installable Python package that registers as a `h
haiku-rag create-skill \
--name medic \
--db /path/to/medic.lancedb \
- --tools search,ask \
+ --tools search,cite \
--config-file /path/to/haiku.rag.yaml \
--description "Military medic knowledge base" \
--preamble "You are a military medic expert."
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index 83a13ded..9db9e9cc 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -99,7 +99,6 @@ research:
search:
limit: 10 # Default number of results to return
- max_context_items: 10 # Maximum items in expanded context
max_context_chars: 10000 # Maximum characters in expanded context
vector_index_metric: cosine # cosine, l2, or dot
vector_refine_factor: 30
diff --git a/docs/configuration/providers.md b/docs/configuration/providers.md
index cef725e6..7ab86f6e 100644
--- a/docs/configuration/providers.md
+++ b/docs/configuration/providers.md
@@ -22,7 +22,7 @@ qa:
**Available options:**
-- **temperature**: Sampling temperature (0.0-1.0+). Defaults vary by task: 0.3 for QA, research, and title generation; 0.0 for RLM and picture description.
+- **temperature**: Sampling temperature (0.0-1.0+). Defaults vary by task: 0.3 for QA, research, and title generation; 0.0 for analysis and picture description.
- Lower (0.0-0.3): Deterministic, focused responses
- Medium (0.4-0.7): Balanced
- Higher (0.8-1.0+): Creative, varied responses
diff --git a/docs/configuration/qa-research.md b/docs/configuration/qa-research.md
index 667e56ce..9911ae29 100644
--- a/docs/configuration/qa-research.md
+++ b/docs/configuration/qa-research.md
@@ -7,12 +7,10 @@ Configure search behavior and context expansion:
```yaml
search:
limit: 10 # Default number of results to return
- max_context_items: 10 # Maximum items in expanded context
max_context_chars: 10000 # Maximum characters in expanded context
```
- **limit**: Default number of search results to return when no limit is specified. Used by CLI, MCP server, QA, and research workflows. Default: 10
-- **max_context_items**: Limits how many document items (paragraphs, list items, etc.) can be included in expanded context. Default: 10.
- **max_context_chars**: Hard limit on total characters in expanded content. Default: 10000.
Context expansion is automatic and section-aware. For structured documents (with section headers), expansion includes the entire section containing the match. For sections that exceed the budget or are too small (e.g., a title+authors area), expansion grows outward item-by-item from the match center, skipping noise labels (footnotes, page headers) — this naturally crosses into adjacent sections until the budget is filled. For unstructured documents, expansion grows outward item-by-item. Results without `doc_item_refs` (e.g., custom chunks passed to `import_document`) pass through unexpanded.
@@ -58,12 +56,12 @@ research:
The research workflow uses an iterative feedback loop: the planner proposes one question at a time, sees the answer, then decides whether to continue or synthesize. This continues until the planner marks research as complete or `max_iterations` is reached.
-## RLM Configuration
+## Analysis Configuration
-Configure the RLM (Recursive Language Model) agent:
+Configure the analysis agent:
```yaml
-rlm:
+analysis:
model:
provider: anthropic
name: claude-sonnet-4-20250514
@@ -76,4 +74,4 @@ rlm:
- **code_timeout**: Maximum seconds for each code execution (default: 60)
- **max_output_chars**: Truncate code output after this many characters (default: 50000)
-See [RLM Agent](../agents/rlm.md) for usage details.
+See [Analysis Agent](../agents/analysis.md) for usage details.
diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md
index b57227b5..ebb233bb 100644
--- a/docs/configuration/storage.md
+++ b/docs/configuration/storage.md
@@ -112,7 +112,7 @@ search:
vector_refine_factor: 30 # Re-ranking factor for accuracy
```
-For search behavior settings (`limit`, `max_context_items`, `max_context_chars`), see [QA and Research](qa-research.md#search-settings).
+For search behavior settings (`limit`, `max_context_chars`), see [QA and Research](qa-research.md#search-settings).
- **vector_index_metric**: Distance metric for vector similarity:
- `cosine`: Cosine similarity (default, best for most embeddings)
diff --git a/docs/index.md b/docs/index.md
index 3f5d7d80..73f6a846 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,7 +8,7 @@ Agentic RAG built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.p
- **Question answering** — QA agents with citations (page numbers, section headings)
- **Reranking** — MxBAI, Cohere, Zero Entropy, or vLLM
- **Research agents** — Multi-agent workflows via pydantic-graph: plan, search, evaluate, synthesize
-- **RLM agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
+- **Analysis agent** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
- **Conversational RAG** — Chat TUI and web application for multi-turn conversations with session memory
- **Document structure** — Stores full [DoclingDocument](https://docling-project.github.io/docling/concepts/docling_document/), enabling structure-aware context expansion
- **Multiple providers** — Embeddings: Ollama, OpenAI, VoyageAI, LM Studio, vLLM. QA/Research: any model supported by Pydantic AI
@@ -59,13 +59,12 @@ haiku-rag chat # Interactive conversation mode
- [Getting started](tutorial.md) - Tutorial
- [Installation](installation.md) - Install haiku.rag with different providers
-- [Architecture](architecture.md) - System overview and data flow
- [Configuration](configuration/index.md) - Environment variables and settings
- [CLI](cli.md) - Command line interface usage
- [Python](python.md) - Python API reference
- [Custom Pipelines](custom-pipelines.md) - Build custom processing workflows
- [Agents](agents/index.md) - QA, chat, and research agents
-- [RLM Agent](agents/rlm.md) - Complex analytical tasks via code execution
+- [Analysis Agent](agents/analysis.md) - Complex analytical tasks via code execution
- [Applications](apps.md) - Chat TUI, web app, and inspector
- [Server](server.md) - File monitoring and server mode
- [MCP](mcp.md) - Model Context Protocol integration
diff --git a/docs/mcp.md b/docs/mcp.md
index 219100da..dc1328a0 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -50,7 +50,7 @@ The MCP server exposes `haiku.rag` as MCP tools for compatible MCP clients like
- `question` (required): The research question
- Returns a structured research report with findings, conclusions, and sources
-- **`rlm_question`** - Answer complex analytical questions via code execution
+- **`analyze`** - Answer complex analytical questions via code execution
- `question` (required): The question to answer
- `filter` (optional): SQL WHERE clause to restrict document access
- `document` (optional): Document title/ID to pre-load (can repeat)
diff --git a/docs/python.md b/docs/python.md
index 81679215..e0605c94 100644
--- a/docs/python.md
+++ b/docs/python.md
@@ -376,7 +376,6 @@ Context expansion is automatic and section-aware. For structured documents (with
Configuration:
-- **search.max_context_items**: Maximum items in expanded context. Default: 10.
- **search.max_context_chars**: Maximum characters in expanded context. Default: 10000.
**Smart Merging**: When expanded results overlap within the same document, they are automatically merged into a single result with continuous content and the highest relevance score.
@@ -420,32 +419,32 @@ The QA provider and model are configured in `haiku.rag.yaml` or can be passed di
See also: [Agents](agents/index.md) for details on the QA agent and the multi‑agent research workflow.
-## RLM (Recursive Language Model)
+## Analysis
Answer complex analytical questions via code execution:
```python
# Aggregation across documents
-result = await client.rlm("Which quarter had the highest revenue?")
+result = await client.analyze("Which quarter had the highest revenue?")
print(result.answer) # The answer
print(result.program) # The final consolidated program
# Computation within a document set
-result = await client.rlm(
+result = await client.analyze(
"What is the average deal size mentioned in these contracts?",
filter="uri LIKE '%contracts%'"
)
# Multi-document comparison
-result = await client.rlm(
+result = await client.analyze(
"What changed between these two versions of the policy?",
documents=["Policy v1.0", "Policy v2.0"]
)
```
-The RLM agent writes and executes Python code in a sandboxed environment to solve problems that traditional RAG struggles with: aggregation, computation, and multi-document analysis.
+The analysis agent writes and executes Python code in a sandboxed environment to solve problems that traditional RAG struggles with: aggregation, computation, and multi-document analysis.
-See [RLM Agent](agents/rlm.md) for details on capabilities and configuration.
+See [Analysis Agent](agents/analysis.md) for details on capabilities and configuration.
## Building Custom Agents
diff --git a/docs/skills/analysis.md b/docs/skills/analysis.md
new file mode 100644
index 00000000..00723540
--- /dev/null
+++ b/docs/skills/analysis.md
@@ -0,0 +1,67 @@
+# Analysis Skill
+
+The analysis skill provides computational analysis via code execution. It writes and runs Python code in a sandboxed interpreter to answer questions that require computation, aggregation, or data traversal.
+
+## `create_skill(db_path?, config?)`
+
+```python
+from haiku.rag.skills.analysis import create_skill
+
+skill = create_skill(db_path=db_path, config=config)
+```
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `db_path` | `None` | Path to LanceDB database. Falls back to `HAIKU_RAG_DB` env var, then config default. |
+| `config` | `None` | `AppConfig` instance. If None, uses `get_config()`. |
+
+## Tools
+
+| Tool | Purpose |
+|------|---------|
+| `search(query, limit?)` | Hybrid search (vector + full-text) with context expansion |
+| `list_documents()` | List all documents in the knowledge base |
+| `execute_code(code)` | Execute Python code in a sandboxed interpreter with VFS access |
+| `cite(chunk_ids)` | Register chunk IDs as citations for the current answer |
+
+## State
+
+The skill manages an `AnalysisState` under the `"analysis"` namespace:
+
+```python
+class AnalysisState(BaseModel):
+ document_filter: str | None = None
+ executions: list[CodeExecutionEntry] = []
+ citation_index: dict[str, Citation] = {}
+ citations: list[list[str]] = []
+ searches: dict[str, list[SearchResult]] = {}
+```
+
+- **document_filter** — SQL WHERE clause applied to `search` and `list_documents` calls.
+- **executions** — Each `execute_code` call appends a `CodeExecutionEntry` with code, stdout, stderr, and success status.
+- **citation_index** / **citations** — Same per-turn citation tracking as the RAG skill.
+- **searches** — Search results from both the `search` tool and sandbox-internal searches.
+
+## Usage with RAG Skill
+
+Combine both skills to give the agent full RAG + analysis capabilities:
+
+```python
+from haiku.rag.skills.rag import create_skill as create_rag_skill
+from haiku.rag.skills.analysis import create_skill as create_analysis_skill
+from haiku.skills.agent import SkillToolset
+from haiku.skills.prompts import build_system_prompt
+from pydantic_ai import Agent
+
+rag = create_rag_skill(db_path=db_path)
+analysis = create_analysis_skill(db_path=db_path)
+toolset = SkillToolset(skills=[rag, analysis])
+
+agent = Agent(
+ "openai:gpt-4o",
+ instructions=build_system_prompt(toolset.skill_catalog),
+ toolsets=[toolset],
+)
+```
+
+See the [Analysis Agent](../agents/analysis.md) documentation for details on how the underlying sandbox works.
diff --git a/docs/skills/index.md b/docs/skills/index.md
index 4d59c49f..5811ace8 100644
--- a/docs/skills/index.md
+++ b/docs/skills/index.md
@@ -7,7 +7,7 @@ haiku.rag exposes its RAG capabilities as [haiku.skills](https://github.com/ggoz
| Skill | Description |
|-------|-------------|
| [`rag`](rag.md) | Search, retrieve, and answer questions from the knowledge base |
-| [`rag-rlm`](rlm.md) | Computational analysis via code execution |
+| [`rag-analysis`](analysis.md) | Computational analysis via code execution |
## Discovery
@@ -16,7 +16,7 @@ Skills are registered as Python entrypoints under `haiku.skills`. They are disco
```bash
haiku-skills list --use-entrypoints
# rag — Search, retrieve and analyze documents using RAG.
-# rag-rlm — Analyze documents using code execution in a sandboxed interpreter.
+# rag-analysis — Analyze documents using code execution in a sandboxed interpreter.
```
## Usage
@@ -47,7 +47,7 @@ Use `create-skill` to generate a standalone skill package with an embedded datab
haiku-rag create-skill \
--name recipes \
--db /path/to/recipes.lancedb \
- --tools search,ask \
+ --tools search,cite \
--description "Recipe knowledge base" \
--preamble "You are a recipe expert."
```
@@ -88,7 +88,7 @@ Each skill manages its own state under a dedicated namespace. State is automatic
```python
rag_state = toolset.get_namespace("rag")
-rlm_state = toolset.get_namespace("rlm")
+analysis_state = toolset.get_namespace("analysis")
```
See the individual skill pages for state model details.
diff --git a/docs/skills/rag.md b/docs/skills/rag.md
index def50f98..064ea86c 100644
--- a/docs/skills/rag.md
+++ b/docs/skills/rag.md
@@ -1,6 +1,6 @@
# RAG Skill
-The RAG skill is the primary way to use haiku.rag tools. It bundles search, Q&A, document browsing, and research into a single skill with managed state.
+The RAG skill is the primary way to use haiku.rag tools. It bundles search, document browsing, and citation management into a single skill with managed state.
## `create_skill(db_path?, config?)`
@@ -20,10 +20,9 @@ skill = create_skill(db_path=db_path, config=config)
| Tool | Purpose |
|------|---------|
| `search(query, limit?)` | Hybrid search (vector + full-text) with context expansion |
-| `list_documents(limit?, offset?, filter?)` | Paginated document listing |
+| `list_documents()` | List all documents in the knowledge base |
| `get_document(query)` | Retrieve a document by ID, title, or URI |
-| `ask(question)` | Q&A with citations via the QA agent |
-| `research(question)` | Deep multi-agent research producing comprehensive reports |
+| `cite(chunk_ids)` | Register chunk IDs as citations for the current answer |
## State
@@ -31,17 +30,13 @@ The skill manages a `RAGState` under the `"rag"` namespace:
```python
class RAGState(BaseModel):
- citations: list[Citation] = []
- qa_history: list[QAHistoryEntry] = []
+ citation_index: dict[str, Citation] = {}
+ citations: list[list[str]] = []
document_filter: str | None = None
searches: dict[str, list[SearchResult]] = {}
- documents: list[DocumentInfo] = []
- reports: list[ResearchEntry] = []
```
-- **citations** — Accumulated citations from `ask` calls, with sequential indexing across calls.
-- **qa_history** — Questions and answers from `ask` calls. Prior Q&A is used as context for follow-up questions when embeddings are similar.
-- **document_filter** — SQL WHERE clause applied to `search`, `list_documents`, `ask`, and `research` calls. Set this to scope queries to specific documents.
+- **citation_index** — All citations indexed by chunk ID (deduplicated across turns).
+- **citations** — Per-turn lists of chunk IDs registered via the `cite` tool.
+- **document_filter** — SQL WHERE clause applied to `search` and `list_documents` calls. Set this to scope queries to specific documents.
- **searches** — Search results keyed by query string.
-- **documents** — Documents seen via `list_documents` or `get_document` (deduplicated by ID).
-- **reports** — Research reports from `research` calls.
diff --git a/docs/skills/rlm.md b/docs/skills/rlm.md
deleted file mode 100644
index 7df3d162..00000000
--- a/docs/skills/rlm.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# RLM Skill
-
-The RLM (Recursive Language Model) skill provides computational analysis via code execution. It writes and runs Python code in a sandboxed interpreter to answer questions that require computation, aggregation, or data traversal.
-
-## `create_skill(db_path?, config?)`
-
-```python
-from haiku.rag.skills.rlm import create_skill
-
-skill = create_skill(db_path=db_path, config=config)
-```
-
-| Parameter | Default | Description |
-|-----------|---------|-------------|
-| `db_path` | `None` | Path to LanceDB database. Falls back to `HAIKU_RAG_DB` env var, then config default. |
-| `config` | `None` | `AppConfig` instance. If None, uses `get_config()`. |
-
-## Tools
-
-| Tool | Purpose |
-|------|---------|
-| `analyze(question, document?, filter?)` | Answer analytical questions using code execution |
-
-**Parameters:**
-
-- `question` — The analytical question to answer.
-- `document` — Optional document ID or title to pre-load for analysis.
-- `filter` — Optional SQL WHERE clause to filter documents.
-
-## State
-
-The skill manages an `RLMState` under the `"rlm"` namespace:
-
-```python
-class RLMState(BaseModel):
- document_filter: str | None = None
- analyses: list[AnalysisEntry] = []
-
-class AnalysisEntry(BaseModel):
- question: str
- answer: str
- program: str | None = None
-```
-
-- **document_filter** — SQL WHERE clause applied to `analyze` calls (combined with any explicit `filter` parameter). Set this to scope analysis to specific documents.
-- **analyses** — Each `analyze` call appends an `AnalysisEntry` with the question, answer, and executed program.
-
-## Usage with RAG Skill
-
-Combine both skills to give the agent full RAG + analysis capabilities:
-
-```python
-from haiku.rag.skills.rag import create_skill as create_rag_skill
-from haiku.rag.skills.rlm import create_skill as create_rlm_skill
-from haiku.skills.agent import SkillToolset
-from haiku.skills.prompts import build_system_prompt
-from pydantic_ai import Agent
-
-rag = create_rag_skill(db_path=db_path)
-rlm = create_rlm_skill(db_path=db_path)
-toolset = SkillToolset(skills=[rag, rlm])
-
-agent = Agent(
- "openai:gpt-4o",
- instructions=build_system_prompt(toolset.skill_catalog),
- toolsets=[toolset],
-)
-```
-
-See the [RLM Agent](../agents/rlm.md) documentation for details on how the underlying agent works.
diff --git a/docs/tools.md b/docs/tools.md
index 378d967d..f6aca748 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -59,26 +59,8 @@ docs = create_document_toolset(config)
- `get_document(query)` — Retrieve a document by title or URI.
- `summarize_document(query)` — Generate an LLM summary of a document's content.
-### Analysis Toolset
-
-`create_analysis_toolset()` provides computational analysis via the RLM agent.
-
-```python
-from haiku.rag.tools import create_analysis_toolset
-
-analysis = create_analysis_toolset(config)
-```
-
-| Parameter | Default | Description |
-|-----------|---------|-------------|
-| `config` | required | `AppConfig` |
-| `base_filter` | `None` | SQL WHERE clause applied to searches |
-| `tool_name` | `"analyze"` | Name of the tool exposed to the agent |
-
## Filter Helpers
`haiku.rag.tools.filters` provides utilities for building SQL filters:
-- **`build_document_filter(document_name)`** — Builds a LIKE filter matching against both `uri` and `title`, case-insensitive. Also matches without spaces (e.g., "TB MED 593" matches "tbmed593").
-- **`build_multi_document_filter(document_names)`** — Combines multiple document name filters with OR logic.
-- **`combine_filters(filter1, filter2)`** — Combines two filters with AND logic. Returns `None` if both are `None`.
+- **`build_multi_document_filter(document_names)`** — Combines multiple document name filters with OR logic. Matches against both `uri` and `title`, case-insensitive.
diff --git a/docs/tuning.md b/docs/tuning.md
index d1a8d40f..c5ac4441 100644
--- a/docs/tuning.md
+++ b/docs/tuning.md
@@ -26,7 +26,7 @@ When configured, a cross-encoder reranker re-scores 10x the requested candidates
`limit` controls how many results reach the LLM. More candidates improve recall but increase token usage. See [Search Settings](configuration/qa-research.md#search-settings).
-Context expansion is automatic and section-aware — search results are expanded to include surrounding content from the same document section. For structured documents, expansion stays within section boundaries and filters noise (footnotes, page headers). For unstructured documents, expansion grows outward until the character budget is filled. `max_context_items` and `max_context_chars` cap expansion to prevent context bloat.
+Context expansion is automatic and section-aware — search results are expanded to include surrounding content from the same document section. For structured documents, expansion stays within section boundaries and filters noise (footnotes, page headers). For unstructured documents, expansion grows outward until the character budget is filled. `max_context_chars` caps expansion to prevent context bloat.
## Tuning Generation
diff --git a/evaluations/evaluations/benchmark.py b/evaluations/evaluations/benchmark.py
index dacfa33f..d6626ce7 100644
--- a/evaluations/evaluations/benchmark.py
+++ b/evaluations/evaluations/benchmark.py
@@ -49,7 +49,6 @@ def build_experiment_metadata(
"embedder_dim": config.embeddings.model.vector_dim,
"chunk_size": config.processing.chunk_size,
"search_limit": config.search.limit,
- "max_context_items": config.search.max_context_items,
"max_context_chars": config.search.max_context_chars,
"rerank_provider": config.reranking.model.provider
if config.reranking.model
diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py b/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py
new file mode 100644
index 00000000..f7507d7d
--- /dev/null
+++ b/haiku_rag_slim/haiku/rag/agents/analysis/__init__.py
@@ -0,0 +1,21 @@
+from haiku.rag.agents.analysis.agent import create_analysis_agent
+from haiku.rag.agents.analysis.dependencies import AnalysisContext, AnalysisDeps
+from haiku.rag.agents.analysis.models import (
+ AnalysisResult,
+ CodeExecution,
+ RawAnalysisResult,
+)
+from haiku.rag.agents.analysis.prompts import ANALYSIS_SYSTEM_PROMPT
+from haiku.rag.agents.analysis.sandbox import Sandbox, SandboxResult
+
+__all__ = [
+ "ANALYSIS_SYSTEM_PROMPT",
+ "AnalysisContext",
+ "AnalysisDeps",
+ "RawAnalysisResult",
+ "AnalysisResult",
+ "CodeExecution",
+ "Sandbox",
+ "SandboxResult",
+ "create_analysis_agent",
+]
diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/agent.py b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py
new file mode 100644
index 00000000..47e49d5c
--- /dev/null
+++ b/haiku_rag_slim/haiku/rag/agents/analysis/agent.py
@@ -0,0 +1,59 @@
+from pydantic_ai import Agent, RunContext
+
+from haiku.rag.agents.analysis.dependencies import AnalysisDeps
+from haiku.rag.agents.analysis.models import CodeExecution, RawAnalysisResult
+from haiku.rag.agents.analysis.prompts import ANALYSIS_SYSTEM_PROMPT
+from haiku.rag.config.models import AppConfig
+from haiku.rag.utils import get_model
+
+
+def create_analysis_agent(config: AppConfig) -> Agent[AnalysisDeps, RawAnalysisResult]:
+ """Create an analysis agent with code execution capability.
+
+ The analysis agent can write and execute Python code in a sandboxed
+ environment to solve problems that require computation, aggregation,
+ or complex traversal across documents.
+
+ Args:
+ config: Application configuration.
+
+ Returns:
+ A pydantic-ai Agent configured for analysis execution.
+ """
+ model = get_model(config.analysis.model, config)
+
+ agent: Agent[AnalysisDeps, RawAnalysisResult] = Agent( # type: ignore[assignment] # ty: ignore[invalid-assignment]
+ model,
+ deps_type=AnalysisDeps,
+ output_type=RawAnalysisResult,
+ instructions=ANALYSIS_SYSTEM_PROMPT,
+ retries=3,
+ )
+
+ @agent.tool
+ async def execute_code(ctx: RunContext[AnalysisDeps], code: str) -> CodeExecution:
+ """Execute Python code in a sandboxed interpreter.
+
+ The code has access to search() and llm() functions, and a
+ virtual filesystem at /documents/ with document content and structure.
+
+ Use print() to output results.
+
+ Args:
+ code: Python code to execute.
+
+ Returns:
+ Structured result with success status, stdout, and stderr.
+ """
+ result = await ctx.deps.sandbox.execute(code)
+
+ execution = CodeExecution(
+ code=code,
+ stdout=result.stdout,
+ stderr=result.stderr,
+ success=result.success,
+ )
+
+ return execution
+
+ return agent
diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/dependencies.py b/haiku_rag_slim/haiku/rag/agents/analysis/dependencies.py
new file mode 100644
index 00000000..c1d5a4e2
--- /dev/null
+++ b/haiku_rag_slim/haiku/rag/agents/analysis/dependencies.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from haiku.rag.store.models import Document
+
+if TYPE_CHECKING:
+ from haiku.rag.agents.analysis.sandbox import Sandbox
+
+
+@dataclass
+class AnalysisContext:
+ """Mutable context accumulating data during analysis execution."""
+
+ documents: list[Document] | None = None
+ filter: str | None = None
+
+
+@dataclass
+class AnalysisDeps:
+ """Dependencies for analysis agent."""
+
+ sandbox: "Sandbox"
+ context: AnalysisContext = field(default_factory=AnalysisContext)
diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/models.py b/haiku_rag_slim/haiku/rag/agents/analysis/models.py
similarity index 56%
rename from haiku_rag_slim/haiku/rag/agents/rlm/models.py
rename to haiku_rag_slim/haiku/rag/agents/analysis/models.py
index c0864a7f..6b88e027 100644
--- a/haiku_rag_slim/haiku/rag/agents/rlm/models.py
+++ b/haiku_rag_slim/haiku/rag/agents/analysis/models.py
@@ -1,8 +1,10 @@
from pydantic import BaseModel, Field
+from haiku.rag.agents.research.models import Citation
+
class CodeExecution(BaseModel):
- """Result of executing a code block in the RLM sandbox."""
+ """Result of executing a code block in the analysis sandbox."""
code: str = Field(description="The Python code that was executed")
stdout: str = Field(description="Standard output captured during execution")
@@ -10,8 +12,16 @@ class CodeExecution(BaseModel):
success: bool = Field(description="Whether execution completed without error")
-class RLMResult(BaseModel):
- """Result from RLM agent execution."""
+class RawAnalysisResult(BaseModel):
+ """Raw result from the analysis agent (LLM output)."""
answer: str = Field(description="The answer to the user's question")
program: str = Field(description="The final consolidated program")
+
+
+class AnalysisResult(BaseModel):
+ """Result from analysis execution with resolved citations."""
+
+ answer: str
+ program: str
+ citations: list[Citation] = Field(default_factory=list)
diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py
new file mode 100644
index 00000000..53147c9b
--- /dev/null
+++ b/haiku_rag_slim/haiku/rag/agents/analysis/prompts.py
@@ -0,0 +1,127 @@
+ANALYSIS_SYSTEM_PROMPT = """You are an analysis agent that solves complex research questions by writing and executing Python code.
+
+You MUST use the `execute_code` tool to run Python code. The functions and filesystem described below are ONLY available inside execute_code. Always execute code to answer questions; do not just describe what code would do.
+
+## Available Functions
+
+Inside execute_code, these functions are ALREADY available in the namespace. Do NOT import them - just call them with `await`:
+- results = await search("query") ✓ CORRECT
+- import search ✗ WRONG - will fail
+- results = search("query") ✗ WRONG - must use await
+
+### await search(query, limit=10) -> list[dict]
+Search the knowledge base using hybrid search (vector + full-text).
+Results are automatically expanded with surrounding context (adjacent paragraphs, complete tables, section content).
+Returns list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels
+
+### await list_documents() -> list[dict]
+List all documents in the knowledge base.
+Returns list of dicts with keys: id, title, uri, created_at
+
+### await llm(prompt) -> str
+Call an LLM directly with the given prompt. Returns the response as a string.
+Use this for classification, summarization, extraction, or any task where you
+already have the content and just need LLM reasoning.
+
+## Document Filesystem
+
+All documents in the knowledge base are available as files under `/documents/`. Use `from pathlib import Path` and standard file I/O to access them.
+
+### Directory structure
+```
+/documents/
+ {document_id}/
+ metadata.json # {"id", "title", "uri", "created_at"}
+ content.txt # Full document text
+ items.jsonl # Structured document items (one JSON object per line)
+```
+
+### metadata.json
+Small file with document metadata. Use to discover and identify documents.
+```python
+from pathlib import Path
+import json
+for doc_dir in Path('/documents').iterdir():
+ meta = json.loads((doc_dir / 'metadata.json').read_text())
+ print(meta['title'], meta['uri'])
+```
+
+### content.txt
+Full text content of the document. Use for regex, keyword search, or full-text analysis.
+```python
+content = Path(f'/documents/{doc_id}/content.txt').read_text()
+```
+
+### items.jsonl
+Structured document items as JSONL. Each line is a JSON object with:
+- `position`: sequential position in the document
+- `self_ref`: item reference (e.g. "#/texts/5", "#/tables/0")
+- `label`: item type — "section_header", "text", "table", "list_item", "caption", "formula", "picture", "code", "footnote", etc.
+- `text`: rendered content (tables are markdown with `|` columns)
+- `page_numbers`: list of page numbers where the item appears
+
+Use items.jsonl to find tables, section headers, or specific structural elements:
+```python
+import json
+items_text = Path(f'/documents/{doc_id}/items.jsonl').read_text()
+for line in items_text.strip().split(chr(10)):
+ item = json.loads(line)
+ if item['label'] == 'table':
+ print(f"Table on page {item['page_numbers']}: {item['text'][:100]}")
+```
+
+## Cross-referencing search results with items
+
+Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) that correspond to `self_ref` values in items.jsonl. Use this to navigate from a search hit to the surrounding document structure:
+```python
+results = await search("revenue", limit=5)
+r = results[0]
+doc_id = r['document_id']
+refs = set(r['doc_item_refs'])
+
+import json
+items_text = Path(f'/documents/{doc_id}/items.jsonl').read_text()
+for line in items_text.strip().split(chr(10)):
+ item = json.loads(line)
+ if item['self_ref'] in refs:
+ print(f"Matched: {item['label']} on page {item['page_numbers']}")
+```
+
+## Pre-loaded Documents Variable
+
+If documents were pre-loaded for this session, a `documents` variable is available:
+```python
+# documents is a list of dicts with keys: id, title, uri, content
+for doc in documents:
+ print(doc['title'], len(doc['content']))
+```
+Check if it exists with: `try: documents ... except NameError: ...`
+
+## Available Python Features
+
+The interpreter supports: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `map()`, `filter()`, `getattr()`, `sorted()`/`.sort(key=...)`, try/except, and the `json`, `re`, `math` modules. File I/O via `pathlib.Path` is supported for the `/documents/` filesystem.
+
+Not supported: most imports (only `json`, `re`, `math`, `pathlib` are available), class definitions, generators/yield, match statements, decorators, `with` statements.
+
+## Strategy Guide
+
+1. **Search First**: Start with `search()` to find relevant content. Results include expanded context and `doc_item_refs` for cross-referencing.
+2. **Discover Documents**: Use `list_documents()` to see what's in the knowledge base.
+3. **Use items.jsonl for Structure**: Find tables, section headers, or specific elements by label and page number. Tables are pre-rendered as markdown.
+4. **Use content.txt for Full Text**: When you need the complete document text (e.g., for regex across the whole document).
+5. **Iterate**: Run code, examine results, refine your approach. Don't try to solve everything in one execution.
+6. **Use llm() for Reasoning**: When you have content and need classification, summarization, or extraction, use `llm()` rather than writing complex parsing logic.
+
+## Output Format
+
+Your final response MUST be valid JSON matching this exact schema:
+```json
+{"answer": "Your answer here", "program": "Your final program here"}
+```
+
+- `answer`: A clear answer to the user's question with key findings and references to specific documents/chunks.
+- `program`: A single, self-contained Python program that produces the answer. Consolidate your exploratory code executions into one clean script.
+
+Do NOT return arbitrary JSON structures. Always use the exact format above.
+
+You MUST call execute_code at least once before providing your answer. Never give up without trying to execute code first."""
diff --git a/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py
new file mode 100644
index 00000000..88a61ea9
--- /dev/null
+++ b/haiku_rag_slim/haiku/rag/agents/analysis/sandbox.py
@@ -0,0 +1,316 @@
+import asyncio
+import atexit
+import concurrent.futures
+import json
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Literal
+
+import pydantic_monty
+from pydantic_monty import CallbackFile, MemoryFile, OSAccess
+
+from haiku.rag.agents.analysis.dependencies import AnalysisContext
+from haiku.rag.config.models import AppConfig
+from haiku.rag.store.models.chunk import SearchResult
+
+if TYPE_CHECKING:
+ from pathlib import PurePosixPath
+
+
+@dataclass
+class SandboxResult:
+ """Result of executing code in the sandbox."""
+
+ stdout: str
+ stderr: str
+ success: bool
+
+
+_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
+atexit.register(_executor.shutdown, wait=False)
+
+
+def _run_async(coro: Any) -> Any:
+ """Run an async coroutine from a sync context (CallbackFile read)."""
+ return _executor.submit(asyncio.run, coro).result()
+
+
+class Sandbox:
+ """Execute code in a sandboxed Python interpreter.
+
+ Uses pydantic-monty, a minimal secure Python interpreter written in Rust.
+ External functions (search, llm) are called by Monty code using ``await``
+ and resolved asynchronously on the host.
+ Documents are exposed via a virtual filesystem at ``/documents/{id}/``.
+
+ Each ``execute()`` call runs in a fresh interpreter — variables do not
+ persist between calls.
+
+ sandbox = Sandbox(db_path, config, context)
+ result = await sandbox.execute("print('hello')")
+ """
+
+ _db_path: Path
+ _config: AppConfig
+ _context: AnalysisContext
+ _search_results: "list[SearchResult]"
+ _items_cache: dict[str, str] | None
+
+ def __init__(
+ self,
+ db_path: Path,
+ config: AppConfig,
+ context: AnalysisContext,
+ ):
+ self._db_path = db_path
+ self._config = config
+ self._context = context
+ self._search_results = []
+ self._items_cache = None
+
+ def _build_external_functions(self) -> dict[str, Any]:
+ """Build async external functions for the Monty interpreter."""
+ db_path = self._db_path
+ config = self._config
+ context = self._context
+
+ async def search(query: str, limit: int = 10) -> list[dict[str, Any]]:
+ from haiku.rag.client import HaikuRAG
+
+ async with HaikuRAG(db_path, config=config, read_only=True) as rag:
+ results = await rag.search(query, limit=limit, filter=context.filter)
+ expanded = await rag.expand_context(results)
+ self._search_results.extend(expanded)
+ return [
+ {
+ "chunk_id": r.chunk_id,
+ "content": r.content,
+ "document_id": r.document_id,
+ "document_title": r.document_title,
+ "document_uri": r.document_uri,
+ "score": r.score,
+ "page_numbers": r.page_numbers,
+ "headings": r.headings,
+ "doc_item_refs": r.doc_item_refs,
+ "labels": r.labels,
+ }
+ for r in expanded
+ ]
+
+ async def list_documents() -> list[dict[str, Any]]:
+ from haiku.rag.client import HaikuRAG
+
+ async with HaikuRAG(db_path, config=config, read_only=True) as rag:
+ docs = await rag.list_documents(filter=context.filter)
+ return [
+ {
+ "id": d.id,
+ "title": d.title,
+ "uri": d.uri,
+ "created_at": str(d.created_at),
+ }
+ for d in docs
+ ]
+
+ async def llm(prompt: str) -> str:
+ from pydantic_ai import Agent
+
+ from haiku.rag.utils import get_model
+
+ model = get_model(config.analysis.model, config)
+ agent: Agent[None, str] = Agent(model, output_type=str)
+ result = await agent.run(prompt)
+ return result.output
+
+ return {
+ "search": search,
+ "list_documents": list_documents,
+ "llm": llm,
+ }
+
+ async def _build_vfs(self) -> OSAccess:
+ """Build the virtual filesystem with document data.
+
+ Mounts per-document directories with:
+ - metadata.json: MemoryFile (eager, small)
+ - content.txt: CallbackFile (lazy, can be large)
+ - items.jsonl: CallbackFile (lazy, can be large)
+ """
+ from haiku.rag.client import HaikuRAG
+
+ db_path = self._db_path
+ config = self._config
+ files: list[MemoryFile | CallbackFile] = []
+
+ def _deny_write(_path: "PurePosixPath", _content: str | bytes) -> None:
+ raise PermissionError(f"Document files are read-only: {_path}")
+
+ async with HaikuRAG(db_path, config=config, read_only=True) as rag:
+ docs = await rag.list_documents(filter=self._context.filter)
+
+ doc_ids = [doc.id for doc in docs if doc.id]
+
+ def _load_items_cache() -> dict[str, str]:
+ """Bulk-fetch all document items in one query, serialize to JSONL."""
+
+ async def _fetch() -> dict[str, str]:
+ from haiku.rag.client import HaikuRAG
+
+ async with HaikuRAG(db_path, config=config, read_only=True) as rag:
+ grouped = await rag.document_item_repository.get_all_items_grouped(
+ doc_ids
+ )
+ result: dict[str, str] = {}
+ for did, items in grouped.items():
+ lines = []
+ for item in items:
+ lines.append(
+ json.dumps(
+ {
+ "position": item.position,
+ "self_ref": item.self_ref,
+ "label": item.label,
+ "text": item.text,
+ "page_numbers": item.page_numbers,
+ },
+ ensure_ascii=False,
+ )
+ )
+ result[did] = "\n".join(lines)
+ return result
+
+ return _run_async(_fetch())
+
+ sandbox = self
+
+ def _make_items_reader(
+ did: str,
+ ) -> Callable[["PurePosixPath"], str]:
+ def read_items(_path: "PurePosixPath") -> str:
+ if sandbox._items_cache is None:
+ sandbox._items_cache = _load_items_cache()
+ return sandbox._items_cache.get(did, "")
+
+ return read_items
+
+ for doc in docs:
+ if not doc.id:
+ continue
+ doc_id: str = doc.id
+ doc_dir = f"/documents/{doc_id}"
+
+ metadata = json.dumps(
+ {
+ "id": doc_id,
+ "title": doc.title,
+ "uri": doc.uri,
+ "created_at": str(doc.created_at),
+ },
+ ensure_ascii=False,
+ )
+ files.append(MemoryFile(f"{doc_dir}/metadata.json", metadata))
+
+ def _make_content_reader(
+ did: str,
+ ) -> Callable[["PurePosixPath"], str]:
+ def read_content(_path: "PurePosixPath") -> str:
+ async def _fetch() -> str:
+ from haiku.rag.client import HaikuRAG
+
+ async with HaikuRAG(
+ db_path, config=config, read_only=True
+ ) as rag:
+ content = await rag.document_repository.get_content(did)
+ return content or ""
+
+ return _run_async(_fetch())
+
+ return read_content
+
+ files.append(
+ CallbackFile(
+ f"{doc_dir}/content.txt",
+ read=_make_content_reader(doc_id),
+ write=_deny_write,
+ )
+ )
+ files.append(
+ CallbackFile(
+ f"{doc_dir}/items.jsonl",
+ read=_make_items_reader(doc_id),
+ write=_deny_write,
+ )
+ )
+
+ return OSAccess(files)
+
+ async def execute(self, code: str) -> SandboxResult:
+ """Execute Python code in the Monty interpreter."""
+ external_fns = self._build_external_functions()
+ vfs = await self._build_vfs()
+
+ input_names: list[str] = []
+ inputs: dict[str, Any] | None = None
+ if self._context.documents:
+ input_names.append("documents")
+ inputs = {
+ "documents": [
+ {
+ "id": d.id,
+ "title": d.title,
+ "uri": d.uri,
+ "content": d.content,
+ }
+ for d in self._context.documents
+ ]
+ }
+
+ try:
+ monty = pydantic_monty.Monty(
+ code,
+ inputs=input_names,
+ )
+ except (
+ pydantic_monty.MontySyntaxError,
+ pydantic_monty.MontyRuntimeError,
+ ) as e:
+ return SandboxResult(stdout="", stderr=str(e), success=False)
+
+ stdout_lines: list[str] = []
+
+ def print_callback(_stream: Literal["stdout"], text: str) -> None:
+ stdout_lines.append(text)
+
+ max_chars = self._config.analysis.max_output_chars
+ limits: pydantic_monty.ResourceLimits = {
+ "max_duration_secs": self._config.analysis.code_timeout,
+ }
+
+ try:
+ output = await pydantic_monty.run_monty_async(
+ monty,
+ inputs=inputs,
+ external_functions=external_fns,
+ limits=limits,
+ print_callback=print_callback,
+ os=vfs,
+ )
+ except pydantic_monty.MontyRuntimeError as e:
+ stdout = "".join(stdout_lines)
+ if len(stdout) > max_chars:
+ stdout = stdout[:max_chars] + "\n... (output truncated)"
+ return SandboxResult(stdout=stdout, stderr=str(e), success=False)
+
+ stdout = "".join(stdout_lines)
+ if output is not None:
+ stdout_with_output = f"{stdout}{output}" if stdout else str(output)
+ else:
+ stdout_with_output = stdout
+
+ if len(stdout_with_output) > max_chars:
+ stdout_with_output = (
+ stdout_with_output[:max_chars] + "\n... (output truncated)"
+ )
+
+ return SandboxResult(stdout=stdout_with_output, stderr="", success=True)
diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/__init__.py b/haiku_rag_slim/haiku/rag/agents/rlm/__init__.py
deleted file mode 100644
index 82779408..00000000
--- a/haiku_rag_slim/haiku/rag/agents/rlm/__init__.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from haiku.rag.agents.rlm.agent import create_rlm_agent
-from haiku.rag.agents.rlm.dependencies import RLMContext, RLMDeps
-from haiku.rag.agents.rlm.models import CodeExecution, RLMResult
-from haiku.rag.agents.rlm.prompts import RLM_SYSTEM_PROMPT
-from haiku.rag.agents.rlm.sandbox import Sandbox, SandboxResult
-
-__all__ = [
- "CodeExecution",
- "RLMContext",
- "RLMDeps",
- "RLMResult",
- "RLM_SYSTEM_PROMPT",
- "Sandbox",
- "SandboxResult",
- "create_rlm_agent",
-]
diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/agent.py b/haiku_rag_slim/haiku/rag/agents/rlm/agent.py
deleted file mode 100644
index 8d2a13bb..00000000
--- a/haiku_rag_slim/haiku/rag/agents/rlm/agent.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from pydantic_ai import Agent, RunContext
-
-from haiku.rag.agents.rlm.dependencies import RLMDeps
-from haiku.rag.agents.rlm.models import CodeExecution, RLMResult
-from haiku.rag.agents.rlm.prompts import RLM_SYSTEM_PROMPT
-from haiku.rag.config.models import AppConfig
-from haiku.rag.utils import get_model
-
-
-def create_rlm_agent(config: AppConfig) -> Agent[RLMDeps, RLMResult]:
- """Create an RLM agent with code execution capability.
-
- The RLM (Recursive Language Model) agent can write and execute Python code
- in a sandboxed environment to solve problems that require computation,
- aggregation, or complex traversal across documents.
-
- Args:
- config: Application configuration.
-
- Returns:
- A pydantic-ai Agent configured for RLM execution.
- """
- model = get_model(config.rlm.model, config)
-
- agent: Agent[RLMDeps, RLMResult] = Agent( # type: ignore[assignment] # ty: ignore[invalid-assignment]
- model,
- deps_type=RLMDeps,
- output_type=RLMResult,
- instructions=RLM_SYSTEM_PROMPT,
- retries=3,
- )
-
- @agent.tool
- async def execute_code(ctx: RunContext[RLMDeps], code: str) -> CodeExecution:
- """Execute Python code in a sandboxed interpreter.
-
- The code has access to haiku.rag functions (search, list_documents,
- get_document, get_chunk, llm).
-
- Use print() to output results.
-
- Args:
- code: Python code to execute.
-
- Returns:
- Structured result with success status, stdout, and stderr.
- """
- result = await ctx.deps.sandbox.execute(code)
-
- execution = CodeExecution(
- code=code,
- stdout=result.stdout,
- stderr=result.stderr,
- success=result.success,
- )
-
- return execution
-
- return agent
diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/dependencies.py b/haiku_rag_slim/haiku/rag/agents/rlm/dependencies.py
deleted file mode 100644
index 02f0fdba..00000000
--- a/haiku_rag_slim/haiku/rag/agents/rlm/dependencies.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from dataclasses import dataclass, field
-from typing import TYPE_CHECKING
-
-from haiku.rag.store.models import Document
-
-if TYPE_CHECKING:
- from haiku.rag.agents.rlm.sandbox import Sandbox
-
-
-@dataclass
-class RLMContext:
- """Mutable context accumulating data during RLM execution."""
-
- documents: list[Document] | None = None
- filter: str | None = None
-
-
-@dataclass
-class RLMDeps:
- """Dependencies for RLM agent."""
-
- sandbox: "Sandbox"
- context: RLMContext = field(default_factory=RLMContext)
diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/prompts.py b/haiku_rag_slim/haiku/rag/agents/rlm/prompts.py
deleted file mode 100644
index 7608a6f1..00000000
--- a/haiku_rag_slim/haiku/rag/agents/rlm/prompts.py
+++ /dev/null
@@ -1,123 +0,0 @@
-RLM_SYSTEM_PROMPT = """You are a Recursive Language Model (RLM) agent that solves complex research questions by writing and executing Python code.
-
-You MUST use the `execute_code` tool to run Python code. The functions described below are ONLY available inside execute_code. Always execute code to answer questions; do not just describe what code would do.
-
-Inside execute_code, these functions are ALREADY available in the namespace. Do NOT import them - just call them with `await`:
-- results = await search("query") ✓ CORRECT
-- import search ✗ WRONG - will fail
-- results = search("query") ✗ WRONG - must use await
-
-## Available Functions
-
-### await search(query, limit=10) -> list[dict]
-Search the knowledge base using hybrid search (vector + full-text).
-Returns list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings
-
-### await list_documents(limit=10, offset=0) -> list[dict]
-List available documents in the knowledge base.
-Returns list of dicts with keys: id, title, uri, created_at
-
-### await get_document(id_or_title) -> str | None
-Get the full text content of a document by ID, title, or URI.
-Returns the document content as a string, or None if not found.
-
-### await get_chunk(chunk_id) -> dict | None
-Get a specific chunk by its ID (from search results).
-Returns dict with keys: chunk_id, content, document_id, document_title, headings, page_numbers, labels
-Use this to retrieve full chunk details and metadata for citation.
-
-### await get_docling_document(document_id) -> dict | None
-Get the full document structure as a dict (DoclingDocument format).
-Use `list_documents()` or search results to get document IDs first.
-- `texts`: list of text items, each with `text`, `label` (e.g. "title", "text", "section_header", "list_item"), and `prov` (provenance with page/bounding box)
-- `tables`: list of tables, each with `data` containing `grid` (list of rows, each row a list of cells with `text`), `num_rows`, `num_cols`
-- `pictures`: list of figures/images with metadata
-- `pages`: page dimensions and metadata
-
-### await llm(prompt) -> str
-Call an LLM directly with the given prompt. Returns the response as a string.
-Use this for classification, summarization, extraction, or any task where you
-already have the content and just need LLM reasoning.
-
-## Pre-loaded Documents Variable
-
-If documents were pre-loaded for this session, a `documents` variable is available:
-```python
-# documents is a list of dicts with keys: id, title, uri, content
-for doc in documents:
- print(doc['title'], len(doc['content']))
-```
-Check if it exists with: `try: documents ... except NameError: ...`
-
-## Available Python Features
-
-The interpreter supports: variables, arithmetic, strings, f-strings, lists, dicts, tuples, sets, loops, conditionals, comprehensions, functions, async/await, `map()`, `filter()`, `getattr()`, `sorted()`/`.sort(key=...)`, try/except, and the `json`, `re`, `math` modules.
-
-Not supported: most imports (only `json`, `re`, `math` are available), class definitions, generators/yield, match statements, decorators, `with` statements.
-
-For pattern matching or text extraction, use `import re`, string methods (`str.split`, `str.find`, `str.startswith`, `in` operator), or the `llm()` function.
-
-## Strategy Guide
-
-1. **Explore First**: Start by listing documents or searching to understand what's available. Document `title` is often None — use `uri` or `id` to identify documents instead.
-2. **If get_document returns None**: Use `await list_documents()` to see available documents (check `uri` and `id`), or `await search()` to find relevant content.
-3. **Iterative Refinement**: Run code, examine results, adjust your approach based on what you find.
-4. **Use llm() for Classification/Extraction**: When you need to classify, summarize, or extract structured data from content you already have, use `await llm()`.
-5. **Cite Your Sources**: Use get_chunk() to retrieve chunk metadata for citations. Track which documents/chunks informed your answer.
-
-## Example Patterns
-
-### Counting documents matching a condition
-```python
-docs = await list_documents(limit=100)
-count = 0
-for doc in docs:
- content = await get_document(doc['id'])
- if content and 'keyword' in content.lower():
- count += 1
- print(f"Found in: {doc['title']}")
-print(f"Total: {count}")
-```
-
-### Extracting data with regex
-```python
-import re
-numbers = []
-results = await search("financial data", limit=20)
-for r in results:
- amounts = re.findall(r'\\$([\\d,]+)', r['content'])
- for a in amounts:
- numbers.append(int(a.replace(',', '')))
-if numbers:
- print(f"Average: {sum(numbers) / len(numbers)}")
-```
-
-### Extracting tables from a document
-```python
-docs = await list_documents(limit=10)
-for d in docs:
- doc = await get_docling_document(d['id'])
- if doc:
- tables = doc.get('tables', [])
- if tables:
- print(f"{d['title']}: {len(tables)} table(s)")
- for i, table in enumerate(tables):
- grid = table.get('data', {}).get('grid', [])
- for row in grid:
- cells = [cell.get('text', '') for cell in row]
- print(f" Table {i}: {cells}")
-```
-
-## Output Format
-
-Your final response MUST be valid JSON matching this exact schema:
-```json
-{"answer": "Your complete answer here as a string", "program": "Your final consolidated program here as a string"}
-```
-
-- `answer`: A clear answer to the user's question with key findings and references to specific documents/chunks.
-- `program`: A single, self-contained Python program that produces the answer. Consolidate your exploratory code executions into one clean script.
-
-Do NOT return arbitrary JSON structures. Always use the exact format: {"answer": "...", "program": "..."}
-
-You MUST call execute_code at least once before providing your answer. Never give up without trying to execute code first."""
diff --git a/haiku_rag_slim/haiku/rag/agents/rlm/sandbox.py b/haiku_rag_slim/haiku/rag/agents/rlm/sandbox.py
deleted file mode 100644
index bf6880a4..00000000
--- a/haiku_rag_slim/haiku/rag/agents/rlm/sandbox.py
+++ /dev/null
@@ -1,205 +0,0 @@
-import json
-from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Literal
-
-import pydantic_monty
-
-from haiku.rag.agents.rlm.dependencies import RLMContext
-from haiku.rag.config.models import AppConfig
-from haiku.rag.store.compression import decompress_json
-
-if TYPE_CHECKING:
- from haiku.rag.client import HaikuRAG
-
-
-@dataclass
-class SandboxResult:
- """Result of executing code in the sandbox."""
-
- stdout: str
- stderr: str
- success: bool
-
-
-class Sandbox:
- """Execute code in a sandboxed Python interpreter.
-
- Uses pydantic-monty, a minimal secure Python interpreter written in Rust.
- External functions (search, list_documents, etc.) are called by Monty code
- using ``await`` and resolved asynchronously on the host.
-
- sandbox = Sandbox(client, config, context)
- result = await sandbox.execute("print('hello')")
- """
-
- _client: "HaikuRAG"
- _config: AppConfig
- _context: RLMContext
-
- def __init__(
- self,
- client: "HaikuRAG",
- config: AppConfig,
- context: RLMContext,
- ):
- self._client = client
- self._config = config
- self._context = context
-
- def _build_external_functions(self) -> dict[str, Any]:
- """Build async external functions for the Monty interpreter."""
- client = self._client
- config = self._config
- context = self._context
-
- async def search(query: str, limit: int = 10) -> list[dict[str, Any]]:
- results = await client.search(query, limit=limit, filter=context.filter)
- return [
- {
- "chunk_id": r.chunk_id,
- "content": r.content,
- "document_id": r.document_id,
- "document_title": r.document_title,
- "document_uri": r.document_uri,
- "score": r.score,
- "page_numbers": r.page_numbers,
- "headings": r.headings,
- }
- for r in results
- ]
-
- async def list_documents(
- limit: int = 10, offset: int = 0
- ) -> list[dict[str, Any]]:
- docs = await client.list_documents(
- limit=limit, offset=offset, filter=context.filter
- )
- return [
- {
- "id": d.id,
- "title": d.title,
- "uri": d.uri,
- "created_at": str(d.created_at),
- }
- for d in docs
- ]
-
- async def get_document(id_or_title: str) -> str | None:
- doc = await client.resolve_document(id_or_title)
- return doc.content if doc else None
-
- async def get_chunk(chunk_id: str) -> dict[str, Any] | None:
- chunk = await client.get_chunk_by_id(chunk_id)
- if not chunk:
- return None
- meta = chunk.get_chunk_metadata()
- doc_title = chunk.document_title
- if not doc_title and chunk.document_id:
- doc = await client.get_document_by_id(chunk.document_id)
- if doc:
- doc_title = doc.title
- return {
- "chunk_id": chunk.id,
- "content": chunk.content,
- "document_id": chunk.document_id,
- "document_title": doc_title,
- "headings": meta.headings,
- "page_numbers": meta.page_numbers,
- "labels": meta.labels,
- }
-
- async def get_docling_document(
- document_id: str,
- ) -> dict[str, Any] | None:
- doc = await client.get_document_by_id(document_id)
- if not doc or not doc.docling_document:
- return None
- json_str = decompress_json(doc.docling_document)
- return json.loads(json_str)
-
- async def llm(prompt: str) -> str:
- from pydantic_ai import Agent
-
- from haiku.rag.utils import get_model
-
- model = get_model(config.rlm.model, config)
- agent: Agent[None, str] = Agent(model, output_type=str)
- result = await agent.run(prompt)
- return result.output
-
- return {
- "search": search,
- "list_documents": list_documents,
- "get_document": get_document,
- "get_chunk": get_chunk,
- "get_docling_document": get_docling_document,
- "llm": llm,
- }
-
- async def execute(self, code: str) -> SandboxResult:
- """Execute Python code in the Monty interpreter."""
- external_fns = self._build_external_functions()
-
- input_names: list[str] = []
- inputs: dict[str, Any] | None = None
- if self._context.documents:
- input_names.append("documents")
- inputs = {
- "documents": [
- {
- "id": d.id,
- "title": d.title,
- "uri": d.uri,
- "content": d.content,
- }
- for d in self._context.documents
- ]
- }
-
- try:
- monty = pydantic_monty.Monty(
- code,
- inputs=input_names,
- )
- except (
- pydantic_monty.MontySyntaxError,
- pydantic_monty.MontyRuntimeError,
- ) as e:
- return SandboxResult(stdout="", stderr=str(e), success=False)
-
- stdout_lines: list[str] = []
-
- def print_callback(_stream: Literal["stdout"], text: str) -> None:
- stdout_lines.append(text)
-
- max_chars = self._config.rlm.max_output_chars
- limits: pydantic_monty.ResourceLimits = {
- "max_duration_secs": self._config.rlm.code_timeout,
- }
-
- try:
- output = await pydantic_monty.run_monty_async(
- monty,
- inputs=inputs,
- external_functions=external_fns,
- limits=limits,
- print_callback=print_callback,
- )
- except pydantic_monty.MontyRuntimeError as e:
- stdout = "".join(stdout_lines)
- if len(stdout) > max_chars:
- stdout = stdout[:max_chars] + "\n... (output truncated)"
- return SandboxResult(stdout=stdout, stderr=str(e), success=False)
-
- stdout = "".join(stdout_lines)
- if output is not None:
- stdout_with_output = f"{stdout}{output}" if stdout else str(output)
- else:
- stdout_with_output = stdout
-
- if len(stdout_with_output) > max_chars:
- stdout_with_output = (
- stdout_with_output[:max_chars] + "\n... (output truncated)"
- )
-
- return SandboxResult(stdout=stdout_with_output, stderr="", success=True)
diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py
index dba8aba6..ec98136c 100644
--- a/haiku_rag_slim/haiku/rag/app.py
+++ b/haiku_rag_slim/haiku/rag/app.py
@@ -446,13 +446,13 @@ class HaikuRAGApp: # pragma: no cover
for renderable in format_citations_rich(citations):
self.console.print(renderable)
- async def rlm(
+ async def analyze(
self,
question: str,
document: str | None = None,
filter: str | None = None,
):
- """Answer a question using the RLM agent with code execution.
+ """Answer a question using the analysis agent with code execution.
Args:
question: The question to answer
@@ -469,10 +469,14 @@ class HaikuRAGApp: # pragma: no cover
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
self.console.print()
- self.console.print("[dim]Running RLM agent with code execution...[/dim]")
+ self.console.print(
+ "[dim]Running analysis agent with code execution...[/dim]"
+ )
self.console.print()
- result = await self.client.rlm(question, documents=documents, filter=filter)
+ result = await self.client.analyze(
+ question, documents=documents, filter=filter
+ )
self.console.print("[bold yellow]Program:[/bold yellow]")
self.console.print(Syntax(result.program, "python"))
diff --git a/haiku_rag_slim/haiku/rag/chat/__init__.py b/haiku_rag_slim/haiku/rag/chat/__init__.py
index 78a1d341..981eb295 100644
--- a/haiku_rag_slim/haiku/rag/chat/__init__.py
+++ b/haiku_rag_slim/haiku/rag/chat/__init__.py
@@ -7,6 +7,7 @@ def run_chat(
read_only: bool = False,
before: datetime | None = None,
model: str | None = None,
+ skills: list[str] | None = None,
) -> None:
"""Run the chat TUI.
@@ -15,6 +16,7 @@ def run_chat(
read_only: Whether to open the database in read-only mode.
before: Query database as it existed before this datetime.
model: Model to use for the chat.
+ skills: Skills to enable ("rag", "analysis"). Defaults to ["rag"].
"""
try:
from haiku.rag.chat.app import ChatApp
@@ -24,18 +26,35 @@ def run_chat(
) from e
from haiku.rag.config import get_config
- from haiku.rag.skills.rag import create_skill
- from haiku.rag.utils import get_model
+ from haiku.rag.utils import get_model, parse_model_option
+ from haiku.skills.models import Skill
config = get_config()
if db_path is None:
db_path = config.storage.data_dir / "haiku.rag.lancedb"
- skill = create_skill(db_path=db_path, config=config)
+ if model:
+ model_config = parse_model_option(model)
+ config.qa.model = model_config
+ config.research.model = model_config
+ config.analysis.model = model_config
+
+ enabled = skills or ["rag"]
+ skill_list: list[Skill] = []
+
+ if "rag" in enabled:
+ from haiku.rag.skills.rag import create_skill as create_rag_skill
+
+ skill_list.append(create_rag_skill(db_path=db_path, config=config))
+
+ if "analysis" in enabled:
+ from haiku.rag.skills.analysis import create_skill as create_analysis_skill
+
+ skill_list.append(create_analysis_skill(db_path=db_path, config=config))
app = ChatApp(
db_path,
- skill=skill,
+ skills=skill_list,
read_only=read_only,
before=before,
model=model or get_model(config.qa.model, config),
diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py
index 5d6bcb14..b0f1a1ee 100644
--- a/haiku_rag_slim/haiku/rag/chat/app.py
+++ b/haiku_rag_slim/haiku/rag/chat/app.py
@@ -30,6 +30,7 @@ from textual.worker import Worker
from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget
from haiku.rag.client import HaikuRAG
from haiku.rag.config import get_config
+from haiku.rag.skills.analysis import AnalysisState
from haiku.rag.skills.rag import RAGState, get_agent_preamble
from haiku.skills.agent import (
SkillToolset,
@@ -51,6 +52,7 @@ if TYPE_CHECKING:
RAG_STATE_NAMESPACE = "rag"
+ANALYSIS_STATE_NAMESPACE = "analysis"
class ChatApp(App):
@@ -86,14 +88,14 @@ class ChatApp(App):
def __init__(
self,
db_path: Path,
- skill: Skill,
+ skills: list[Skill],
read_only: bool = False,
before: datetime | None = None,
model: str | None = None,
) -> None:
super().__init__()
self.db_path = db_path
- self._skill = skill
+ self._skills = skills
self.read_only = read_only
self.before = before
self._model = model
@@ -153,7 +155,7 @@ class ChatApp(App):
)
await self.client.__aenter__()
- self._toolset = SkillToolset(skills=[self._skill])
+ self._toolset = SkillToolset(skills=self._skills)
self._agent = Agent(
self._model,
instructions=build_system_prompt(
@@ -241,8 +243,7 @@ class ChatApp(App):
content=accumulated_text,
)
)
- # Show citations from RAG state
- await self._show_citations(chat_history)
+ await self._show_citations_and_programs(chat_history)
elif event.type == EventType.TOOL_CALL_START:
assert isinstance(event, ToolCallStartEvent)
chat_history.hide_thinking()
@@ -318,18 +319,32 @@ class ChatApp(App):
chat_input.disabled = False
chat_input.focus()
- async def _show_citations(self, chat_history: "ChatHistory") -> None:
- """Show citations from the RAG state after an agent response."""
+ async def _show_citations_and_programs(self, chat_history: "ChatHistory") -> None:
+ """Show citations and programs from skill states after an agent response."""
if not self._toolset:
return
- rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE)
- if rag_state is None:
- return
- citations = getattr(rag_state, "citations", [])
+ citations = []
+ for namespace in (RAG_STATE_NAMESPACE, ANALYSIS_STATE_NAMESPACE):
+ state = self._toolset.get_namespace(namespace)
+ if not state:
+ continue
+ citation_turns = getattr(state, "citations", [])
+ citation_index = getattr(state, "citation_index", {})
+ if citation_turns:
+ latest_ids = citation_turns[-1]
+ for cid in latest_ids:
+ if cid in citation_index:
+ citations.append(citation_index[cid])
if citations:
- # Show only new citations (since last response)
await chat_history.add_citations(citations)
+ analysis_state = self._toolset.get_namespace(ANALYSIS_STATE_NAMESPACE)
+ if analysis_state:
+ executions = getattr(analysis_state, "executions", [])
+ successful = [e for e in executions if e.success]
+ if successful:
+ await chat_history.add_program(successful[-1].code)
+
async def action_clear_chat(self) -> None:
"""Clear the chat history and reset session."""
chat_history = self.query_one(ChatHistory)
@@ -420,9 +435,11 @@ class ChatApp(App):
self._document_filter = event.selected
if self._toolset:
+ doc_filter = build_multi_document_filter(self._document_filter)
rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE)
if isinstance(rag_state, RAGState):
- rag_state.document_filter = build_multi_document_filter(
- self._document_filter
- )
- self._state = self._toolset.build_state_snapshot()
+ rag_state.document_filter = doc_filter
+ analysis_state = self._toolset.get_namespace(ANALYSIS_STATE_NAMESPACE)
+ if isinstance(analysis_state, AnalysisState):
+ analysis_state.document_filter = doc_filter
+ self._state = self._toolset.build_state_snapshot()
diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py
index fdd4f940..d619f76e 100644
--- a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py
+++ b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py
@@ -133,6 +133,25 @@ class CitationWidget(Collapsible):
event.stop()
+class ProgramWidget(Collapsible):
+ """Inline expandable program code block."""
+
+ def __init__(self, program: str, **kwargs) -> None:
+ content = f"```python\n{program}\n```"
+ super().__init__(
+ Markdown(content),
+ title="Program",
+ collapsed=True,
+ **kwargs,
+ )
+
+ def on_key(self, event: "Key") -> None:
+ """Handle Enter to toggle expand/collapse."""
+ if event.key == "enter":
+ self.collapsed = not self.collapsed
+ event.stop()
+
+
class ThinkingWidget(Static):
"""Thinking indicator shown while agent is processing."""
@@ -290,6 +309,22 @@ class ChatHistory(VerticalScroll):
text-style: italic;
}
+ /* Program */
+ ProgramWidget {
+ margin: 0 0 0 2;
+ background: $surface;
+ }
+
+ ProgramWidget > CollapsibleTitle {
+ padding: 0 1;
+ color: $text-muted;
+ }
+
+ ProgramWidget Contents {
+ padding: 1 2;
+ background: $panel;
+ }
+
/* Thinking indicator */
ThinkingWidget {
margin: 1 0 0 4;
@@ -365,6 +400,13 @@ class ChatHistory(VerticalScroll):
await self.mount(widget)
self.scroll_end(animate=False)
+ async def add_program(self, program: str) -> None:
+ """Add a collapsible program block after a response."""
+ if not program:
+ return
+ await self.mount(ProgramWidget(program))
+ self.scroll_end(animate=False)
+
async def show_thinking(self, text: str = "Thinking...") -> None:
"""Show the thinking indicator."""
try:
diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py b/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py
deleted file mode 100644
index 3dc74659..00000000
--- a/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from textual.app import ComposeResult
-from textual.binding import Binding
-from textual.containers import Horizontal, Vertical, VerticalScroll
-from textual.screen import ModalScreen
-from textual.widgets import Button, Markdown, Static
-
-
-class ContextModal(ModalScreen):
- """Modal screen for viewing session Q&A history."""
-
- BINDINGS = [
- Binding("escape", "cancel", "Close", show=False),
- Binding("ctrl+o", "cancel", "Close", show=False),
- ]
-
- CSS = """
- ContextModal {
- align: center middle;
- background: rgba(0, 0, 0, 0.5);
- }
-
- #context-container {
- width: 70;
- height: auto;
- max-height: 32;
- background: $surface;
- border: tall $primary;
- padding: 1 2;
- }
-
- #context-header {
- height: auto;
- margin-bottom: 1;
- }
-
- #context-description {
- height: auto;
- margin-bottom: 1;
- color: $text-muted;
- }
-
- #context-content {
- height: 1fr;
- max-height: 16;
- scrollbar-gutter: stable;
- }
-
- #button-row {
- height: auto;
- margin-top: 1;
- align: right middle;
- }
-
- #button-row Button {
- margin-left: 1;
- min-width: 10;
- }
- """
-
- def __init__(self, qa_history: list | None = None) -> None:
- super().__init__()
- self._qa_history = qa_history or []
-
- def compose(self) -> ComposeResult:
- with Vertical(id="context-container"):
- yield Static("[bold]Session Context[/bold]", id="context-header")
- yield Static(
- "Questions and answers from this session.",
- id="context-description",
- )
- with VerticalScroll(id="context-content"):
- yield Markdown(self._get_content())
- with Horizontal(id="button-row"):
- yield Button("Close", id="cancel-btn", variant="primary")
-
- def _get_content(self) -> str:
- if not self._qa_history:
- return "*No questions asked yet.*"
-
- parts = []
- for entry in self._qa_history:
- q = getattr(entry, "question", str(entry))
- a = getattr(entry, "answer", "")
- parts.append(f"**Q:** {q}\n\n**A:** {a}")
-
- return "\n\n---\n\n".join(parts)
-
- def on_button_pressed(self, event: Button.Pressed) -> None:
- """Handle button presses."""
- if event.button.id == "cancel-btn":
- self.action_cancel()
-
- def action_cancel(self) -> None:
- """Cancel and close."""
- self.app.pop_screen()
diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py
index 08c4ed7b..1210d73b 100644
--- a/haiku_rag_slim/haiku/rag/cli.py
+++ b/haiku_rag_slim/haiku/rag/cli.py
@@ -368,8 +368,8 @@ def ask( # pragma: no cover
)
-@_cli.command("rlm", help="Answer questions using code execution (RLM agent)")
-def rlm( # pragma: no cover
+@_cli.command("analyze", help="Answer questions using code execution (analysis agent)")
+def analyze( # pragma: no cover
question: str = typer.Argument(
help="The question to answer",
),
@@ -393,7 +393,7 @@ def rlm( # pragma: no cover
):
app = create_app(db)
asyncio.run(
- app.rlm(
+ app.analyze(
question=question,
document=document,
filter=filter,
@@ -645,17 +645,25 @@ def chat( # pragma: no cover
"--model",
help="Model to use for the chat (e.g. openai:gpt-4o)",
),
+ skill: list[str] | None = typer.Option(
+ None,
+ "--skill",
+ "-s",
+ help="Skills to enable: rag, analysis (can repeat, default: rag)",
+ ),
):
"""Launch the chat TUI for conversational RAG."""
from haiku.rag.chat import run_chat
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
+ skills = skill if skill else ["rag"]
run_chat(
db_path,
read_only=_read_only,
before=_before,
model=model,
+ skills=skills,
)
diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py
index 2ff5b509..17b906a3 100644
--- a/haiku_rag_slim/haiku/rag/client.py
+++ b/haiku_rag_slim/haiku/rag/client.py
@@ -30,11 +30,11 @@ from haiku.rag.utils import escape_sql_string
if TYPE_CHECKING:
from docling_core.types.doc.document import DoclingDocument
+ from haiku.rag.agents.analysis.models import AnalysisResult
from haiku.rag.agents.research.models import (
Citation,
ResearchReport,
)
- from haiku.rag.agents.rlm.models import RLMResult
logger = logging.getLogger(__name__)
@@ -1105,7 +1105,6 @@ class HaikuRAG:
"""
from haiku.rag.context import expand_with_items
- max_items = self._config.search.max_context_items
max_chars = self._config.search.max_context_chars
# Group by document_id for efficient processing
@@ -1132,7 +1131,6 @@ class HaikuRAG:
self.document_item_repository,
doc_id,
doc_results,
- max_items,
max_chars,
)
expanded_results.extend(expanded)
@@ -1192,17 +1190,17 @@ class HaikuRAG:
return await graph.run(state=state, deps=deps)
- async def rlm(
+ async def analyze(
self,
question: str,
documents: list[str] | None = None,
filter: str | None = None,
- ) -> "RLMResult":
- """Answer a question using the RLM agent with code execution.
+ ) -> "AnalysisResult":
+ """Answer a question using the analysis agent with code execution.
- The RLM (Recursive Language Model) agent can write and execute Python
- code in a sandboxed environment to solve problems that require
- computation, aggregation, or complex traversal across documents.
+ The analysis agent can write and execute Python code in a sandboxed
+ environment to solve problems that require computation, aggregation,
+ or complex traversal across documents.
Args:
question: The question to answer.
@@ -1210,16 +1208,16 @@ class HaikuRAG:
filter: SQL WHERE clause to filter documents during searches.
Returns:
- RLMResult with the answer and the final consolidated program.
+ AnalysisResult with the answer and the final consolidated program.
"""
- from haiku.rag.agents.rlm import (
- RLMContext,
- RLMDeps,
+ from haiku.rag.agents.analysis import (
+ AnalysisContext,
+ AnalysisDeps,
Sandbox,
- create_rlm_agent,
+ create_analysis_agent,
)
- context = RLMContext(filter=filter)
+ context = AnalysisContext(filter=filter)
if documents:
loaded_docs = []
@@ -1230,26 +1228,51 @@ class HaikuRAG:
context.documents = loaded_docs if loaded_docs else None
sandbox = Sandbox(
- client=self,
+ db_path=self.store.db_path,
config=self._config,
context=context,
)
- deps = RLMDeps(
+ deps = AnalysisDeps(
sandbox=sandbox,
context=context,
)
- agent = create_rlm_agent(self._config)
+ from haiku.rag.agents.analysis.models import AnalysisResult
+ from haiku.rag.agents.research.models import Citation
+
+ agent = create_analysis_agent(self._config)
result = await agent.run(question, deps=deps)
- return result.output
+ output = result.output
+ seen: set[str] = set()
+ citations: list[Citation] = []
+ for sr in sandbox._search_results:
+ if sr.chunk_id and sr.chunk_id not in seen:
+ seen.add(sr.chunk_id)
+ citations.append(
+ Citation(
+ index=len(seen),
+ document_id=sr.document_id or "",
+ chunk_id=sr.chunk_id,
+ document_uri=sr.document_uri or "",
+ document_title=sr.document_title,
+ page_numbers=sr.page_numbers,
+ headings=sr.headings,
+ content=sr.content,
+ )
+ )
+ return AnalysisResult(
+ answer=output.answer,
+ program=output.program,
+ citations=citations,
+ )
async def visualize_chunk(self, chunk: Chunk) -> list:
"""Render page images with bounding box highlights for a chunk.
- Gets the DoclingDocument from the chunk's document, resolves bounding boxes
- from chunk metadata, and renders all pages that contain bounding boxes with
- yellow/orange highlight overlays.
+ Expands the chunk's context to find the full section, then resolves
+ bounding boxes from all items in the expanded range. This ensures
+ visualization covers all pages the expanded content spans.
Args:
chunk: The chunk to visualize.
@@ -1262,6 +1285,8 @@ class HaikuRAG:
from PIL import ImageDraw
+ from haiku.rag.store.models.chunk import ChunkMetadata
+
# Get the document structure (from cache if available)
if not chunk.document_id:
return []
@@ -1274,9 +1299,23 @@ class HaikuRAG:
if not docling_doc:
return []
- # Resolve bounding boxes from chunk metadata
+ # Expand context to get all doc_item_refs in the section
chunk_meta = chunk.get_chunk_metadata()
- bounding_boxes = chunk_meta.resolve_bounding_boxes(docling_doc)
+ if chunk_meta.doc_item_refs:
+ search_result = SearchResult(
+ content=chunk.content,
+ score=1.0,
+ chunk_id=chunk.id,
+ document_id=chunk.document_id,
+ doc_item_refs=chunk_meta.doc_item_refs,
+ page_numbers=chunk_meta.page_numbers,
+ )
+ expanded = await self.expand_context([search_result])
+ refs = expanded[0].doc_item_refs if expanded else chunk_meta.doc_item_refs
+ meta = ChunkMetadata(doc_item_refs=refs)
+ else:
+ meta = chunk_meta
+ bounding_boxes = meta.resolve_bounding_boxes(docling_doc)
if not bounding_boxes:
return []
diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py
index 1dbde073..207441fa 100644
--- a/haiku_rag_slim/haiku/rag/config/models.py
+++ b/haiku_rag_slim/haiku/rag/config/models.py
@@ -96,7 +96,7 @@ class ResearchConfig(BaseModel):
max_concurrency: int = 1
-class RLMConfig(BaseModel):
+class AnalysisConfig(BaseModel):
model: ModelConfig = Field(
default_factory=lambda: ModelConfig(
provider="ollama",
@@ -174,7 +174,6 @@ class ProcessingConfig(BaseModel):
class SearchConfig(BaseModel):
limit: int = 10
- max_context_items: int = 10
max_context_chars: int = 10000
vector_index_metric: Literal["cosine", "l2", "dot"] = "cosine"
vector_refine_factor: int = 30
@@ -219,7 +218,7 @@ class AppConfig(BaseModel):
reranking: RerankingConfig = Field(default_factory=RerankingConfig)
qa: QAConfig = Field(default_factory=QAConfig)
research: ResearchConfig = Field(default_factory=ResearchConfig)
- rlm: RLMConfig = Field(default_factory=RLMConfig)
+ analysis: AnalysisConfig = Field(default_factory=AnalysisConfig)
processing: ProcessingConfig = Field(default_factory=ProcessingConfig)
search: SearchConfig = Field(default_factory=SearchConfig)
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
diff --git a/haiku_rag_slim/haiku/rag/context.py b/haiku_rag_slim/haiku/rag/context.py
index a66c8b84..2ee03ebd 100644
--- a/haiku_rag_slim/haiku/rag/context.py
+++ b/haiku_rag_slim/haiku/rag/context.py
@@ -6,12 +6,16 @@ the document_items table. The algorithm adapts to document structure:
For STRUCTURED documents (containing section_header or title labels):
1. Resolve matched doc_item_refs to positions in the items table
2. Find section boundaries around each match (section_header/title labels)
- 3. If the section fits within the budget, include it entirely
- 4. If the section exceeds the budget, OR the section is too small (under
- 20% of max_context_chars), expand item-by-item from the match center
- outward, skipping noise labels. This lets small sections (e.g., a
- title+authors area) grow into the next section's content.
- 5. Merge overlapping ranges from multiple results in the same document
+ 3. If the section fits within the char budget, include it entirely
+ 4. If the section exceeds the char budget, expand item-by-item from the
+ match center outward, bounded by section edges
+ 5. If the section is too small (under 20% of max_context_chars), expand
+ item-by-item crossing into adjacent sections until the budget is filled.
+ This lets small sections (e.g., title+authors) grow into neighboring
+ content.
+ 6. Merge overlapping ranges from multiple results in the same document.
+ Adjacent but non-overlapping ranges stay separate to preserve section
+ independence.
For UNSTRUCTURED documents (no section headers):
Expand outward item-by-item from the match center until the character
@@ -20,7 +24,6 @@ For UNSTRUCTURED documents (no section headers):
In both cases:
- max_context_chars caps total characters per expanded result
- - max_context_items caps total items per expanded result
- Noise labels (footnote, page_header, page_footer, document_index) are
excluded from content AND budget counting in structured documents
- Results without doc_item_refs pass through unexpanded
@@ -42,7 +45,7 @@ _MIN_SECTION_BUDGET_RATIO = 0.2
def _merge_ranges(
ranges: list[tuple[int, int, SearchResult]],
) -> list[tuple[int, int, list[SearchResult]]]:
- """Merge overlapping or adjacent ranges."""
+ """Merge overlapping ranges. Adjacent but non-overlapping ranges stay separate."""
if not ranges:
return []
@@ -55,7 +58,7 @@ def _merge_ranges(
)
for min_idx, max_idx, result in sorted_ranges[1:]:
- if cur_max >= min_idx - 1: # Overlapping or adjacent
+ if cur_max >= min_idx: # Truly overlapping
cur_max = max(cur_max, max_idx)
cur_results.append(result)
else:
@@ -69,27 +72,32 @@ def _merge_ranges(
def _expand_outward(
items: list[DocumentItem],
center_idx: int,
- max_items: int,
max_chars: int,
skip_noise: bool = False,
+ lo_bound: int = 0,
+ hi_bound: int | None = None,
) -> tuple[int, int]:
- """Expand item-by-item outward from center until budget is filled.
+ """Expand item-by-item outward from center until char budget is filled.
When skip_noise is True, noise labels are excluded from char counting
(used in structured documents so footnotes don't consume budget).
+
+ lo_bound and hi_bound constrain expansion (e.g., to section edges).
"""
+ if hi_bound is None:
+ hi_bound = len(items) - 1
lo = hi = center_idx
center_is_noise = skip_noise and items[center_idx].label in _NOISE_LABELS
char_count = 0 if center_is_noise else len(items[center_idx].text)
- while char_count < max_chars and hi - lo + 1 < max_items:
+ while char_count < max_chars:
grew = False
- if lo > 0:
+ if lo > lo_bound:
lo -= 1
if not (skip_noise and items[lo].label in _NOISE_LABELS):
char_count += len(items[lo].text)
grew = True
- if hi < len(items) - 1 and char_count < max_chars:
+ if hi < hi_bound and char_count < max_chars:
hi += 1
if not (skip_noise and items[hi].label in _NOISE_LABELS):
char_count += len(items[hi].text)
@@ -104,7 +112,6 @@ def _find_expansion_range(
items: list[DocumentItem],
matched_positions: set[int],
has_sections: bool,
- max_items: int,
max_chars: int,
) -> tuple[int, int]:
"""Find the expansion range for matched positions within a window of items."""
@@ -113,7 +120,7 @@ def _find_expansion_range(
center_idx = matched_indices[len(matched_indices) // 2]
if not has_sections:
- return _expand_outward(items, center_idx, max_items, max_chars)
+ return _expand_outward(items, center_idx, max_chars)
# Build section spans: [(start_idx, end_idx), ...]
headers = [
@@ -140,23 +147,34 @@ def _find_expansion_range(
if items[i].label not in _NOISE_LABELS
)
- # Section fits nicely in the budget — return it as-is
min_useful = int(max_chars * _MIN_SECTION_BUDGET_RATIO)
- if min_useful <= sec_chars <= max_chars and sec_end - sec_start + 1 <= max_items:
+
+ if sec_chars <= max_chars and sec_chars >= min_useful:
+ # Section fits in char budget — return it regardless of item count
return (items[sec_start].position, items[sec_end].position)
- # Section is too large or too small — expand item-by-item from center.
- # For too-large sections this stays within budget.
- # For too-small sections (e.g., title+authors) this naturally grows
- # into adjacent sections until the budget is filled.
- return _expand_outward(items, center_idx, max_items, max_chars, skip_noise=True)
+ if sec_chars > max_chars:
+ # Section too large — expand outward bounded by section edges
+ return _expand_outward(
+ items,
+ center_idx,
+ max_chars,
+ skip_noise=True,
+ lo_bound=sec_start,
+ hi_bound=sec_end,
+ )
+
+ # Section too small (e.g., title+authors) — expand across boundaries
+ return _expand_outward(items, center_idx, max_chars, skip_noise=True)
+
+
+_WINDOW_MARGIN = 100
async def expand_with_items(
document_item_repository: DocumentItemRepository,
document_id: str,
results: list[SearchResult],
- max_items: int,
max_chars: int,
) -> list[SearchResult]:
"""Expand results using the document_items table."""
@@ -172,7 +190,7 @@ async def expand_with_items(
# wide enough to find section boundaries (the nearest section_header/title
# above and below the match).
all_positions = sorted(ref_positions.values())
- window_margin = max_items * 10
+ window_margin = _WINDOW_MARGIN
window_start = max(0, min(all_positions) - window_margin)
window_end = max(all_positions) + window_margin
window_items = await document_item_repository.get_items_in_range(
@@ -194,9 +212,7 @@ async def expand_with_items(
passthrough.append(result)
continue
- lo, hi = _find_expansion_range(
- window_items, matched, has_sections, max_items, max_chars
- )
+ lo, hi = _find_expansion_range(window_items, matched, has_sections, max_chars)
ranges.append((lo, hi, result))
merged = _merge_ranges(ranges)
diff --git a/haiku_rag_slim/haiku/rag/mcp.py b/haiku_rag_slim/haiku/rag/mcp.py
index 90056476..11871dd5 100644
--- a/haiku_rag_slim/haiku/rag/mcp.py
+++ b/haiku_rag_slim/haiku/rag/mcp.py
@@ -183,12 +183,12 @@ def create_mcp_server(
return None
@mcp.tool()
- async def rlm_question(
+ async def analyze(
question: str,
document: str | None = None,
filter: str | None = None,
) -> str:
- """Answer complex questions using code execution (RLM agent).
+ """Answer complex questions using code execution (analysis agent).
Use this for questions requiring computation, aggregation, or
complex traversal across documents. The agent can write Python
@@ -205,9 +205,9 @@ def create_mcp_server(
try:
async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
documents = [document] if document else None
- result = await rag.rlm(question, documents=documents, filter=filter)
+ result = await rag.analyze(question, documents=documents, filter=filter)
return result.answer
except Exception as e:
- return f"Error running RLM agent: {e!s}"
+ return f"Error running analysis agent: {e!s}"
return mcp
diff --git a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py
index 50c50019..2d284bfe 100644
--- a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py
+++ b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py
@@ -8,9 +8,8 @@ AVAILABLE_TOOLS: set[str] = {
"list_documents",
"get_document",
"search",
- "ask",
- "research",
- "analyze",
+ "execute_code",
+ "cite",
}
DEFAULT_PREAMBLE = (
diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2
index 8bf07475..fc25efe6 100644
--- a/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2
+++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2
@@ -7,48 +7,58 @@ description: {{ description }}
{{ preamble }}
-## How to decide which tool to use
-{% if "ask" in tool_names %}
+## Tools
+{% if "search" in tool_names %}
-**Default rule:** If the user is asking a question, use **ask**. Only use **search** when the user explicitly wants to browse or find passages.
+### search
+Search the knowledge base using hybrid search (vector + full-text). Returns ranked results with context-expanded content. Use for answering questions, finding passages, exploring topics.
{% endif %}
{% if "list_documents" in tool_names %}
-- **list_documents** — Use when the user wants to browse or see what documents are available (e.g., "what documents do you have?", "show me the documents", "list available docs").
+
+### list_documents
+List all documents in the knowledge base.
{% endif %}
{% if "get_document" in tool_names %}
-- **get_document** — Use when the user wants the full content of a specific document (e.g., "get the paper about X", "show me document Y"). Accepts a document ID, title, or URI — partial matches work.
+
+### get_document
+Retrieve a document by ID, title, or URI. Partial matches work.
{% endif %}
-{% if "search" in tool_names %}
-- **search** — Use when the user wants to browse, explore, or find specific passages across documents (e.g., "search for embeddings", "find mentions of transformers"). Returns all matching results as sources.
+{% if "execute_code" in tool_names %}
+
+### execute_code
+Execute Python code in a sandboxed interpreter. Inside the code you have access to `await search()`, `await list_documents()`, `await llm()`, and a virtual filesystem at `/documents/` with document content and structure.
{% endif %}
-{% if "ask" in tool_names %}
-- **ask** — Use for factual questions that need a synthesized answer (e.g., "what is DocLayNet?", "explain the methodology"). Searches, synthesizes, and returns only the chunks actually used as citations. Always include the citations in your response.
-{% endif %}
-{% if "research" in tool_names %}
-- **research** — Deep multi-agent research that produces comprehensive reports. **Only use when the user explicitly requests deep research** (e.g., "do a deep research on X", "research this topic thoroughly"). Never call this tool on your own — it is slow and expensive.
-{% endif %}
-{% if "analyze" in tool_names %}
-- **analyze** — Use for complex analytical questions that require computation, aggregation, or data traversal across documents (e.g., "how many pages?", "compare table 3 across documents", "calculate average word count"). Executes Python code in a sandboxed interpreter.
+{% if "cite" in tool_names %}
+
+### cite
+Register chunk IDs as citations. Call after formulating your answer with chunk_id values from search results that support it. Do NOT include chunk IDs in your answer text.
{% endif %}
{% if "search" in tool_names %}
-## When search returns irrelevant results
+## How to answer questions
-If your first search returns results that clearly don't match the question, **do not keep searching with variations**. Instead:
-{% if "ask" in tool_names %}
-- Use **ask** if the question is factual
+1. Call `search` with relevant keywords from the question
+2. Review results — they are ordered by relevance (rank 1 = best match)
+3. If needed, search again with different keywords (up to 3-4 searches total)
+4. Synthesize a concise answer based strictly on the retrieved content
+{% if "cite" in tool_names %}
+5. Call `cite` with the chunk IDs you referenced
+{% endif %}
+
+## Guidelines
+
+- Base answers strictly on retrieved content — do not use external knowledge
+- Be concise and direct — avoid elaboration unless asked
+- If results don't match the question, report that the knowledge base lacks the information
+{% if "cite" in tool_names %}
+- Do NOT include chunk IDs or UUIDs in your answer text — use the `cite` tool separately
{% endif %}
-- Report that the knowledge base doesn't contain relevant information
{% endif %}
{% if "get_document" in tool_names %}
## When the user mentions a specific document
If the user says "search in [doc]", "find in [doc]", or "answer from [doc]":
-- Extract the **topic** as the `query`/`question` parameter
-- Use **get_document** or **list_documents** first to identify the document, then search/ask with a filter
-
-Examples:
-- "search for embeddings in the ML paper" -> first identify "ML paper", then search for "embeddings"
-- "what does the DocLayNet paper say about annotations?" -> ask with question="what are the annotation methods?"
+- Use **get_document** or **list_documents** first to identify the document
+- Then search for the topic
{% endif %}
diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2
index a82437af..f342e6ee 100644
--- a/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2
+++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2
@@ -5,23 +5,14 @@ from pydantic import BaseModel, Field
from haiku.rag.config.models import AppConfig
from haiku.skills.models import Skill
from haiku.skills.parser import parse_skill_md
-{% if "ask" in tool_names or "research" in tool_names %}
+{% if "cite" in tool_names %}
from haiku.rag.agents.research.models import Citation
{% endif %}
-{% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %}
-from haiku.rag.tools.document import DocumentInfo
-{% endif %}
-{% if "ask" in tool_names %}
-from haiku.rag.tools.qa import QAHistoryEntry
-{% endif %}
{% if "search" in tool_names %}
from haiku.rag.store.models.chunk import SearchResult
{% endif %}
-{% if "research" in tool_names %}
-from haiku.rag.skills._tools import ResearchEntry
-{% endif %}
-{% if "analyze" in tool_names %}
-from haiku.rag.skills._tools import AnalysisEntry
+{% if "execute_code" in tool_names %}
+from haiku.rag.skills._tools import CodeExecutionEntry
{% endif %}
_TOOL_NAMES = {{ tool_names | tojson }}
@@ -36,24 +27,16 @@ _CONFIG_PATH = _ASSETS_DIR / "haiku.rag.yaml"
class SkillState(BaseModel):
-{% if "ask" in tool_names or "research" in tool_names %}
- citations: list[Citation] = Field(default_factory=list)
-{% endif %}
-{% if "ask" in tool_names %}
- qa_history: list[QAHistoryEntry] = Field(default_factory=list)
+{% if "cite" in tool_names %}
+ citation_index: dict[str, Citation] = Field(default_factory=dict)
+ citations: list[list[str]] = Field(default_factory=list)
{% endif %}
document_filter: str | None = None
{% if "search" in tool_names %}
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
{% endif %}
-{% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %}
- documents: list[DocumentInfo] = Field(default_factory=list)
-{% endif %}
-{% if "research" in tool_names %}
- reports: list[ResearchEntry] = Field(default_factory=list)
-{% endif %}
-{% if "analyze" in tool_names %}
- analyses: list[AnalysisEntry] = Field(default_factory=list)
+{% if "execute_code" in tool_names %}
+ executions: list[CodeExecutionEntry] = Field(default_factory=list)
{% endif %}
diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py
index 6ba2ed51..34bd977d 100644
--- a/haiku_rag_slim/haiku/rag/skills/_tools.py
+++ b/haiku_rag_slim/haiku/rag/skills/_tools.py
@@ -7,59 +7,14 @@ from pydantic_ai import RunContext
from haiku.rag.agents.research.models import Citation
from haiku.rag.config.models import AppConfig
from haiku.rag.store.models.chunk import SearchResult
-from haiku.rag.tools.document import DocumentInfo
-from haiku.rag.tools.filters import combine_filters
-from haiku.rag.tools.qa import QAHistoryEntry
from haiku.skills.state import SkillRunDeps
-class ResearchEntry(BaseModel):
- question: str
- title: str
- executive_summary: str
-
-
-class AnalysisEntry(BaseModel):
- question: str
- answer: str
- program: str | None = None
-
-
-async def find_relevant_prior_qa(
- qa_history: list[QAHistoryEntry],
- query: str,
- config: AppConfig,
-) -> list[QAHistoryEntry]:
- from haiku.rag.embeddings import get_embedder
- from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD
- from haiku.rag.utils import cosine_similarity
-
- if not qa_history:
- return []
-
- embedder = get_embedder(config)
- query_embedding = await embedder.embed_query(query)
-
- to_embed = []
- to_embed_indices = []
- for i, qa in enumerate(qa_history):
- if qa.question_embedding is None:
- to_embed.append(qa.question)
- to_embed_indices.append(i)
-
- if to_embed:
- new_embeddings = await embedder.embed_documents(to_embed)
- for i, idx in enumerate(to_embed_indices):
- qa_history[idx].question_embedding = new_embeddings[i]
-
- matches = []
- for qa in qa_history:
- if qa.question_embedding is not None:
- similarity = cosine_similarity(query_embedding, qa.question_embedding)
- if similarity >= PRIOR_ANSWER_RELEVANCE_THRESHOLD:
- matches.append(qa)
-
- return matches
+class CodeExecutionEntry(BaseModel):
+ code: str
+ stdout: str
+ stderr: str = ""
+ success: bool = True
async def skill_search(
@@ -89,14 +44,12 @@ async def skill_search(
async def skill_list_documents(
db_path: Path,
config: AppConfig,
- limit: int | None = None,
- offset: int | None = None,
filter: str | None = None,
) -> list[dict[str, Any]]:
from haiku.rag.client import HaikuRAG
async with HaikuRAG(db_path, config=config, read_only=True) as rag:
- documents = await rag.list_documents(limit, offset, filter=filter)
+ documents = await rag.list_documents(filter=filter)
return [
{
"id": doc.id,
@@ -132,119 +85,26 @@ async def skill_get_document(
}
-async def skill_ask(
- db_path: Path,
- config: AppConfig,
- question: str,
- qa_history: list[QAHistoryEntry] | None = None,
- document_filter: str | None = None,
-) -> tuple[str, list[Citation]]:
- from haiku.rag.client import HaikuRAG
- from haiku.rag.utils import format_citations
-
- ask_question = question
- if qa_history:
- matches = await find_relevant_prior_qa(qa_history, question, config)
- if matches:
- prior_parts = []
- for qa in matches:
- part = f"Q: {qa.question}\nA: {qa.answer}"
- if qa.citations:
- part += "\n" + format_citations(qa.citations)
- prior_parts.append(part)
- ask_question = (
- "Context from prior questions in this session:\n\n"
- + "\n\n---\n\n".join(prior_parts)
- + "\n\n---\n\nCurrent question: "
- + question
- )
-
- async with HaikuRAG(db_path, config=config, read_only=True) as rag:
- answer, citations = await rag.ask(
- ask_question,
- filter=document_filter,
- )
-
- return answer, citations
-
-
-async def skill_research(
- db_path: Path,
- config: AppConfig,
- question: str,
- document_filter: str | None = None,
-) -> tuple[str, str, str]:
- from haiku.rag.client import HaikuRAG
-
- async with HaikuRAG(db_path, config=config, read_only=True) as rag:
- report = await rag.research(question, filter=document_filter)
-
- parts = [
- f"# {report.title}",
- f"\n## Executive Summary\n{report.executive_summary}",
- ]
- if report.main_findings:
- parts.append("\n## Main Findings")
- for finding in report.main_findings:
- parts.append(f"- {finding}")
- if report.conclusions:
- parts.append("\n## Conclusions")
- for conclusion in report.conclusions:
- parts.append(f"- {conclusion}")
- if report.limitations:
- parts.append("\n## Limitations")
- for limitation in report.limitations:
- parts.append(f"- {limitation}")
- if report.recommendations:
- parts.append("\n## Recommendations")
- for rec in report.recommendations:
- parts.append(f"- {rec}")
- parts.append(f"\n## Sources\n{report.sources_summary}")
-
- formatted = "\n".join(parts)
- return formatted, report.title, report.executive_summary
-
-
-async def skill_analyze(
- db_path: Path,
- config: AppConfig,
- question: str,
- document: str | None = None,
- filter: str | None = None,
-) -> tuple[str, str, str | None]:
- from haiku.rag.client import HaikuRAG
-
- async with HaikuRAG(db_path, config=config, read_only=True) as rag:
- documents = [document] if document else None
- result = await rag.rlm(question, documents=documents, filter=filter)
- output = result.answer
- if result.program:
- output += f"\n\nProgram:\n{result.program}"
-
- return output, result.answer, result.program
-
-
-def update_documents_state(
- documents_state: list[DocumentInfo],
- doc_dicts: list[dict[str, Any]],
-) -> None:
- for doc_dict in doc_dicts:
- doc_info = DocumentInfo(
- id=str(doc_dict["id"]),
- title=doc_dict["title"] or "Untitled",
- uri=doc_dict.get("uri") or "",
- created=doc_dict.get("created_at", ""),
- )
- if not any(d.id == doc_info.id for d in documents_state):
- documents_state.append(doc_info)
-
-
def _get_state(ctx: RunContext[SkillRunDeps], state_type: type[BaseModel]) -> Any:
if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, state_type):
return ctx.deps.state
return None
+def _register_citations(state: Any, citations: "list[Citation]") -> None:
+ """Add citations to the index and record the turn's chunk IDs."""
+ chunk_ids = []
+ next_index = len(state.citation_index) + 1
+ for citation in citations:
+ cid = citation.chunk_id
+ if cid not in state.citation_index:
+ citation.index = next_index
+ next_index += 1
+ state.citation_index[cid] = citation
+ chunk_ids.append(cid)
+ state.citations.append(chunk_ids)
+
+
def create_skill_extras(
db_path: Path,
config: AppConfig,
@@ -313,6 +173,8 @@ def create_skill_tools(
tools: dict[str, Any] = {}
if "search" in tool_names:
+ max_searches = config.qa.max_searches
+ search_counts: dict[str, int] = {}
async def search(
ctx: RunContext[SkillRunDeps], query: str, limit: int | None = None
@@ -325,6 +187,14 @@ def create_skill_tools(
query: The search query.
limit: Maximum number of results.
"""
+ rid = ctx.run_id or ""
+ search_counts[rid] = search_counts.get(rid, 0) + 1
+ if search_counts[rid] > max_searches:
+ return (
+ "Search limit reached. Answer the question using "
+ "the results you already have."
+ )
+
state = _get_state(ctx, state_type)
formatted, results = await skill_search(
db_path,
@@ -343,25 +213,14 @@ def create_skill_tools(
async def list_documents(
ctx: RunContext[SkillRunDeps],
- limit: int | None = None,
- offset: int | None = None,
) -> list[dict[str, Any]]:
- """List documents in the knowledge base with optional pagination.
-
- Args:
- limit: Maximum number of documents to return.
- offset: Number of documents to skip.
- """
+ """List all documents in the knowledge base."""
state = _get_state(ctx, state_type)
result = await skill_list_documents(
db_path,
config,
- limit,
- offset,
filter=state.document_filter if state else None,
)
- if state:
- update_documents_state(state.documents, result)
return result
tools["list_documents"] = list_documents
@@ -376,124 +235,86 @@ def create_skill_tools(
Args:
query: Document ID, title, or URI to look up.
"""
- result = await skill_get_document(db_path, config, query)
- if result is not None:
- state = _get_state(ctx, state_type)
- if state:
- update_documents_state(state.documents, [result])
- return result
+ return await skill_get_document(db_path, config, query)
tools["get_document"] = get_document
- if "ask" in tool_names:
+ if "execute_code" in tool_names:
- async def ask(ctx: RunContext[SkillRunDeps], question: str) -> str:
- """Ask a question and get an answer with citations from the knowledge base.
+ async def execute_code(ctx: RunContext[SkillRunDeps], code: str) -> str:
+ """Execute Python code in a sandboxed interpreter.
+
+ The code has access to search(), list_documents(), llm() functions
+ and a virtual filesystem at /documents/ with document content and
+ structure (metadata.json, content.txt, items.jsonl per document).
+
+ Use print() to output results. Each call runs in a fresh
+ interpreter — variables do not persist between calls.
Args:
- question: The question to ask.
+ code: Python code to execute.
"""
- from haiku.rag.utils import format_citations
+ from haiku.rag.agents.analysis.dependencies import AnalysisContext
+ from haiku.rag.agents.analysis.sandbox import Sandbox
state = _get_state(ctx, state_type)
- answer, citations = await skill_ask(
- db_path,
- config,
- question,
- qa_history=state.qa_history if state else None,
- document_filter=state.document_filter if state else None,
- )
+ doc_filter = state.document_filter if state else None
+ context = AnalysisContext(filter=doc_filter)
+ sandbox = Sandbox(db_path=db_path, config=config, context=context)
+ result = await sandbox.execute(code)
+
+ state = _get_state(ctx, state_type)
+ if state and sandbox._search_results:
+ existing = state.searches.get("_sandbox", [])
+ seen = {r.chunk_id for r in existing}
+ for sr in sandbox._search_results:
+ if sr.chunk_id not in seen:
+ existing.append(sr)
+ seen.add(sr.chunk_id)
+ state.searches["_sandbox"] = existing
if state:
- next_index = len(state.citations) + 1
- for citation in citations:
- citation.index = next_index
- next_index += 1
- state.citations.extend(citations)
- state.qa_history.append(
- QAHistoryEntry(
- question=question, answer=answer, citations=citations
+ state.executions.append(
+ CodeExecutionEntry(
+ code=code,
+ stdout=result.stdout,
+ stderr=result.stderr,
+ success=result.success,
)
)
+ if result.success:
+ return result.stdout if result.stdout else "No output."
+ return f"Error: {result.stderr}\n\nOutput: {result.stdout}"
+
+ tools["execute_code"] = execute_code
+
+ if "cite" in tool_names:
+
+ async def cite(ctx: RunContext[SkillRunDeps], chunk_ids: list[str]) -> str:
+ """Register chunk IDs as citations for your answer.
+
+ Call this after searching, with the chunk_id values from search
+ results that support your answer.
+
+ Args:
+ chunk_ids: List of chunk_id values from search results.
+ """
+ from haiku.rag.agents.research.models import resolve_citations
+
+ state = _get_state(ctx, state_type)
+ if not state:
+ return "No state available."
+
+ all_results = []
+ for results_list in state.searches.values():
+ all_results.extend(results_list)
+
+ citations = resolve_citations(chunk_ids, all_results)
if citations:
- answer += "\n\n" + format_citations(citations)
+ _register_citations(state, citations)
+ return f"Registered {len(citations)} citation(s)."
- return answer
-
- tools["ask"] = ask
-
- if "research" in tool_names:
-
- async def research(ctx: RunContext[SkillRunDeps], question: str) -> str:
- """Conduct deep multi-agent research on a question.
-
- Iteratively searches, analyzes, and synthesizes information from the
- knowledge base to produce a comprehensive research report.
- Only use when the user explicitly requests deep research.
-
- Args:
- question: The research question to investigate.
- """
- state = _get_state(ctx, state_type)
- formatted, title, executive_summary = await skill_research(
- db_path,
- config,
- question,
- document_filter=state.document_filter if state else None,
- )
-
- if state:
- state.reports.append(
- ResearchEntry(
- question=question,
- title=title,
- executive_summary=executive_summary,
- )
- )
- state.qa_history.append(
- QAHistoryEntry(question=question, answer=executive_summary)
- )
-
- return formatted
-
- tools["research"] = research
-
- if "analyze" in tool_names:
-
- async def analyze(
- ctx: RunContext[SkillRunDeps],
- question: str,
- document: str | None = None,
- filter: str | None = None,
- ) -> str:
- """Answer complex analytical questions using code execution.
-
- Use this for questions requiring computation, aggregation, or
- data traversal across documents.
-
- Args:
- question: The question to answer.
- document: Optional document ID or title to pre-load for analysis.
- filter: Optional SQL WHERE clause to filter documents.
- """
- state = _get_state(ctx, state_type)
- state_filter = state.document_filter if state else None
- effective_filter = combine_filters(state_filter, filter)
- output, answer, program = await skill_analyze(
- db_path, config, question, document=document, filter=effective_filter
- )
- if state:
- state.analyses.append(
- AnalysisEntry(
- question=question,
- answer=answer,
- program=program,
- )
- )
-
- return output
-
- tools["analyze"] = analyze
+ tools["cite"] = cite
return tools
diff --git a/haiku_rag_slim/haiku/rag/skills/rlm.py b/haiku_rag_slim/haiku/rag/skills/analysis.py
similarity index 70%
rename from haiku_rag_slim/haiku/rag/skills/rlm.py
rename to haiku_rag_slim/haiku/rag/skills/analysis.py
index c913db78..63d2ea2c 100644
--- a/haiku_rag_slim/haiku/rag/skills/rlm.py
+++ b/haiku_rag_slim/haiku/rag/skills/analysis.py
@@ -2,23 +2,28 @@ import os
from functools import cache
from pathlib import Path
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
+from haiku.rag.agents.research.models import Citation
from haiku.rag.config.models import AppConfig
-from haiku.rag.skills._tools import AnalysisEntry
+from haiku.rag.skills._tools import CodeExecutionEntry
+from haiku.rag.store.models.chunk import SearchResult
from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata
from haiku.skills.parser import parse_skill_md
-class RLMState(BaseModel):
+class AnalysisState(BaseModel):
document_filter: str | None = None
- analyses: list[AnalysisEntry] = []
+ executions: list[CodeExecutionEntry] = Field(default_factory=list)
+ citation_index: dict[str, Citation] = Field(default_factory=dict)
+ citations: list[list[str]] = Field(default_factory=list)
+ searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
-STATE_TYPE = RLMState
-STATE_NAMESPACE = "rlm"
+STATE_TYPE = AnalysisState
+STATE_NAMESPACE = "analysis"
-_skill_path = Path(__file__).parent / "rag-rlm"
+_skill_path = Path(__file__).parent / "rag-analysis"
@cache
@@ -45,7 +50,7 @@ def create_skill(
db_path: Path | None = None,
config: AppConfig | None = None,
) -> Skill:
- """Create an RLM analysis skill for computational document analysis.
+ """Create an analysis skill for computational document analysis.
Args:
db_path: Path to the LanceDB database. Resolved from:
@@ -67,7 +72,12 @@ def create_skill(
else:
db_path = config.storage.data_dir / "haiku.rag.lancedb"
- tools = create_skill_tools(db_path, config, RLMState, ["analyze"])
+ tools = create_skill_tools(
+ db_path,
+ config,
+ AnalysisState,
+ ["search", "list_documents", "execute_code", "cite"],
+ )
extras = create_skill_extras(db_path, config)
skill_instructions = instructions()
diff --git a/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md
new file mode 100644
index 00000000..80d9c099
--- /dev/null
+++ b/haiku_rag_slim/haiku/rag/skills/rag-analysis/SKILL.md
@@ -0,0 +1,101 @@
+---
+name: rag-analysis
+description: >
+ Computational analysis of the knowledge base via code execution in a sandboxed Python interpreter.
+ Use for questions requiring counting, aggregation, statistics, data traversal,
+ comparison across documents, or any task best answered by writing Python code.
+ Examples: "how many pages?", "compare table 3 across documents",
+ "calculate average word count", "extract all email addresses".
+---
+
+# Analysis
+
+You solve complex analytical questions by writing and executing Python code against the knowledge base.
+
+## Tools
+
+### execute_code
+Execute Python code in a sandboxed interpreter. Each call runs in a fresh interpreter — write self-contained code. 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
+- `await list_documents()` → list of dicts with keys: id, title, uri, created_at
+- `await llm(prompt)` → string response from an LLM (for classification, summarization, extraction)
+
+Available modules: `json`, `re`, `math`, `pathlib`
+Not supported: class definitions, generators/yield, match statements, decorators, `with` statements
+
+### search
+Search the knowledge base directly (outside code execution). Use for initial exploration before writing code.
+
+### list_documents
+List available documents. Use to discover what's in the knowledge base.
+
+### cite
+Register chunk IDs as citations. Call after your analysis with chunk_id values from search results that support your answer.
+
+## Document Filesystem (inside execute_code)
+
+All documents are mounted as a virtual filesystem at `/documents/`:
+
+```
+/documents/{document_id}/
+ metadata.json # {"id", "title", "uri", "created_at"}
+ content.txt # Full document text
+ items.jsonl # Structured items (one JSON object per line)
+```
+
+### Reading files
+Always use `Path.read_text()` — do NOT use `open()` or `with` statements (they are not supported).
+
+```python
+from pathlib import Path
+import json
+
+# Discover documents
+for doc_dir in Path('/documents').iterdir():
+ meta = json.loads((doc_dir / 'metadata.json').read_text())
+ print(meta['title'])
+
+# Read full text
+content = Path(f'/documents/{doc_id}/content.txt').read_text()
+
+# Read and parse items
+for line in Path(f'/documents/{doc_id}/items.jsonl').read_text().strip().split(chr(10)):
+ item = json.loads(line)
+ if item['label'] == 'table':
+ print(item['text'][:200])
+```
+
+### metadata.json
+Document metadata: `id`, `title`, `uri`, `created_at`.
+
+### content.txt
+Full text content. Use for regex or keyword search across a whole document.
+
+### items.jsonl
+Structured document items. Each line is a JSON object with:
+- `position`: sequential position in the document
+- `self_ref`: item reference (e.g. "#/texts/5", "#/tables/0")
+- `label`: item type — "section_header", "text", "table", "list_item", "caption", "formula", "picture", "code", "footnote"
+- `text`: rendered content (tables are markdown with `|` columns)
+- `page_numbers`: list of page numbers where the item appears
+
+### Cross-referencing search results with items
+Search results include `doc_item_refs` (e.g. `["#/texts/48", "#/tables/0"]`) that correspond to `self_ref` values in items.jsonl.
+
+## Strategy
+
+1. Use `search` tool first to understand what's in the knowledge base
+2. Use `execute_code` to write analysis code
+3. Iterate: run code, examine output, refine approach
+4. Call `cite` with chunk IDs from search results you referenced
+
+## Important
+
+- Each `execute_code` call runs in a fresh interpreter — write self-contained code blocks
+- Use `print()` to output results — the output is your only feedback
+- Always execute code to answer questions — don't just describe what code would do
+- Use `await` for all async functions inside execute_code (search, list_documents, llm)
+- Use `Path.read_text()` to read files — do NOT use `open()`, `with` statements, or `collections` module
+- Do NOT include chunk IDs or UUIDs in your answer text — use the `cite` tool separately
diff --git a/haiku_rag_slim/haiku/rag/skills/rag-rlm/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag-rlm/SKILL.md
deleted file mode 100644
index f59a148f..00000000
--- a/haiku_rag_slim/haiku/rag/skills/rag-rlm/SKILL.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-name: rag-rlm
-description: >
- Computational analysis of the knowledge base via code execution in a sandboxed Python interpreter.
- Use for questions requiring counting, aggregation, statistics, data traversal,
- comparison across documents, or any task best answered by writing Python code.
- Examples: "how many pages?", "compare table 3 across documents",
- "calculate average word count", "extract all email addresses".
----
-
-# RLM Analysis
-
-Use the `analyze` tool for complex analytical questions. It writes and executes Python code against the knowledge base in a sandboxed Python interpreter.
diff --git a/haiku_rag_slim/haiku/rag/skills/rag.py b/haiku_rag_slim/haiku/rag/skills/rag.py
index e9e52772..a660c723 100644
--- a/haiku_rag_slim/haiku/rag/skills/rag.py
+++ b/haiku_rag_slim/haiku/rag/skills/rag.py
@@ -6,10 +6,7 @@ from pydantic import BaseModel, Field
from haiku.rag.agents.research.models import Citation
from haiku.rag.config.models import AppConfig
-from haiku.rag.skills._tools import ResearchEntry
from haiku.rag.store.models.chunk import SearchResult
-from haiku.rag.tools.document import DocumentInfo
-from haiku.rag.tools.qa import QAHistoryEntry
from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata
from haiku.skills.parser import parse_skill_md
@@ -21,7 +18,7 @@ CRITICAL RULES:
3. When a skill returns citations, always include them in your response
"""
-_RAG_TOOLS = ["search", "list_documents", "get_document", "ask", "research"]
+_RAG_TOOLS = ["search", "list_documents", "get_document", "cite"]
def get_agent_preamble(config: AppConfig) -> str:
@@ -32,12 +29,10 @@ def get_agent_preamble(config: AppConfig) -> str:
class RAGState(BaseModel):
- citations: list[Citation] = Field(default_factory=list)
- qa_history: list[QAHistoryEntry] = Field(default_factory=list)
+ citation_index: dict[str, Citation] = Field(default_factory=dict)
+ citations: list[list[str]] = Field(default_factory=list)
document_filter: str | None = None
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
- documents: list[DocumentInfo] = Field(default_factory=list)
- reports: list[ResearchEntry] = Field(default_factory=list)
STATE_TYPE = RAGState
diff --git a/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md
index a1c90206..77cb2939 100644
--- a/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md
+++ b/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md
@@ -5,31 +5,55 @@ description: Search, retrieve and analyze documents using RAG (Retrieval Augment
# RAG
-You are a RAG (Retrieval Augmented Generation) assistant with access to a document knowledge base.
+You are a RAG assistant with access to a document knowledge base.
Use your tools to search and answer questions. Never make up information — always use tools to get facts from the knowledge base.
-## How to decide which tool to use
+## Tools
-**Default rule:** If the user is asking a question, use **ask**. Only use **search** when the user explicitly wants to browse or find passages.
+### search
+Search the knowledge base using hybrid search (vector + full-text). Returns ranked results with context-expanded content.
-- **list_documents** — Use when the user wants to browse or see what documents are available (e.g., "what documents do you have?", "show me the documents", "list available docs").
-- **get_document** — Use when the user wants the full content of a specific document (e.g., "get the paper about X", "show me document Y"). Accepts a document ID, title, or URI — partial matches work.
-- **search** — Use when the user wants to browse, explore, or find specific passages across documents (e.g., "search for embeddings", "find mentions of transformers"). Returns all matching results as sources.
-- **ask** — Use for factual questions that need a synthesized answer (e.g., "what is DocLayNet?", "explain the methodology"). Searches, synthesizes, and returns only the chunks actually used as citations. Always include the citations in your response.
-- **research** — Deep multi-agent research that produces comprehensive reports. **Only use when the user explicitly requests deep research** (e.g., "do a deep research on X", "research this topic thoroughly"). Never call this tool on your own — it is slow and expensive.
+Each result includes:
+- `chunk_id` in brackets and rank position (rank 1 = most relevant)
+- Source: document title and section hierarchy
+- Type: content type (paragraph, table, code, list_item)
+- Content: the actual text
-## When search returns irrelevant results
+### list_documents
+List available documents in the knowledge base. Use when the user wants to browse what's available.
-If your first search returns results that clearly don't match the question, **do not keep searching with variations**. Instead:
-- Use **ask** if the question is factual
-- Report that the knowledge base doesn't contain relevant information
+### get_document
+Retrieve a document by ID, title, or URI. Partial matches work. Use when the user wants the full content of a specific document.
+
+### cite
+Register chunk IDs as citations for your answer. Call this AFTER formulating your answer, with the `chunk_id` values from search results that support it.
+
+## How to answer questions
+
+1. Call `search` with relevant keywords from the question
+2. Review the results — they are ordered by relevance (rank 1 = best match)
+3. If needed, search again with different keywords (you have a limited number of searches)
+4. Synthesize a concise answer based strictly on the retrieved content
+5. Call `cite` with the chunk IDs of search results that informed your answer
+
+## Guidelines
+
+- Base answers strictly on retrieved content — do not use external knowledge
+- Use the Source and Type metadata to understand context
+- If multiple results are relevant, synthesize them coherently
+- Be concise and direct — avoid elaboration unless asked
+- If the search tool tells you the search limit is reached, stop searching and answer with what you have
+- If the retrieved documents do not directly address the question, say: "I cannot find enough information in the knowledge base to answer this question." Do not guess or infer from tangentially related content.
+- Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `cite` tool separately to register citations.
## When the user mentions a specific document
If the user says "search in [doc]", "find in [doc]", or "answer from [doc]":
-- Extract the **topic** as the `query`/`question` parameter
-- Use **get_document** or **list_documents** first to identify the document, then search/ask with a filter
+- Use `get_document` or `list_documents` first to identify the document
+- Then search for the topic
-Examples:
-- "search for embeddings in the ML paper" -> first identify "ML paper", then search for "embeddings"
-- "what does the DocLayNet paper say about annotations?" -> ask with question="what are the annotation methods?"
+## When search returns irrelevant results
+
+If your first search returns results that clearly don't match the question:
+- Try one more search with different keywords
+- If still irrelevant, report that the knowledge base doesn't contain relevant information
diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document.py b/haiku_rag_slim/haiku/rag/store/repositories/document.py
index 0c9de830..4f5e89e1 100644
--- a/haiku_rag_slim/haiku/rag/store/repositories/document.py
+++ b/haiku_rag_slim/haiku/rag/store/repositories/document.py
@@ -100,6 +100,20 @@ class DocumentRepository:
return self._record_to_document(results[0])
+ async def get_content(self, entity_id: str) -> str | None:
+ """Get only the text content of a document (skips docling blobs)."""
+ safe_id = escape_sql_string(entity_id)
+ results = list(
+ self.store.documents_table.search()
+ .select(["content"])
+ .where(f"id = '{safe_id}'")
+ .limit(1)
+ .to_list()
+ )
+ if not results:
+ return None
+ return results[0]["content"]
+
_DOCLING_COLUMNS = ["id", "docling_document", "docling_version"]
async def get_docling_data(self, entity_id: str) -> Document | None:
diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document_item.py b/haiku_rag_slim/haiku/rag/store/repositories/document_item.py
index 9b30a271..1e68c736 100644
--- a/haiku_rag_slim/haiku/rag/store/repositories/document_item.py
+++ b/haiku_rag_slim/haiku/rag/store/repositories/document_item.py
@@ -40,6 +40,44 @@ class DocumentItemRepository:
]
self.store.document_items_table.add(records)
+ async def get_all_items(self, document_id: str) -> list[DocumentItem]:
+ """Get all items for a document, sorted by position."""
+ safe_id = escape_sql_string(document_id)
+ rows = (
+ self.store.document_items_table.search()
+ .where(f"document_id = '{safe_id}'")
+ .to_list()
+ )
+ items = [self._record_to_item(row) for row in rows]
+ items.sort(key=lambda x: x.position)
+ return items
+
+ async def get_all_items_grouped(
+ self, document_ids: list[str] | None = None
+ ) -> dict[str, list[DocumentItem]]:
+ """Get all items grouped by document_id in a single query.
+
+ Args:
+ document_ids: If provided, only fetch items for these documents.
+ If None, fetches all items.
+
+ Returns:
+ Dict mapping document_id to sorted list of DocumentItem.
+ """
+ query = self.store.document_items_table.search()
+ if document_ids is not None:
+ safe_ids = ", ".join(f"'{escape_sql_string(did)}'" for did in document_ids)
+ query = query.where(f"document_id IN ({safe_ids})")
+ rows = query.to_list()
+
+ grouped: dict[str, list[DocumentItem]] = {}
+ for row in rows:
+ item = self._record_to_item(row)
+ grouped.setdefault(item.document_id, []).append(item)
+ for items in grouped.values():
+ items.sort(key=lambda x: x.position)
+ return grouped
+
async def get_items_in_range(
self, document_id: str, start: int, end: int
) -> list[DocumentItem]:
diff --git a/haiku_rag_slim/haiku/rag/tools/__init__.py b/haiku_rag_slim/haiku/rag/tools/__init__.py
index a9237d80..156a003e 100644
--- a/haiku_rag_slim/haiku/rag/tools/__init__.py
+++ b/haiku_rag_slim/haiku/rag/tools/__init__.py
@@ -1,23 +1,11 @@
-from haiku.rag.tools.analysis import AnalysisResult, create_analysis_toolset
from haiku.rag.tools.context import RAGDeps
from haiku.rag.tools.document import create_document_toolset
-from haiku.rag.tools.filters import (
- build_document_filter,
- build_multi_document_filter,
- combine_filters,
-)
-from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry
+from haiku.rag.tools.filters import build_multi_document_filter
from haiku.rag.tools.search import create_search_toolset
__all__ = [
- "AnalysisResult",
- "PRIOR_ANSWER_RELEVANCE_THRESHOLD",
- "QAHistoryEntry",
"RAGDeps",
- "build_document_filter",
"build_multi_document_filter",
- "combine_filters",
- "create_analysis_toolset",
"create_document_toolset",
"create_search_toolset",
]
diff --git a/haiku_rag_slim/haiku/rag/tools/analysis.py b/haiku_rag_slim/haiku/rag/tools/analysis.py
deleted file mode 100644
index fd9b54a5..00000000
--- a/haiku_rag_slim/haiku/rag/tools/analysis.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from pydantic import BaseModel, Field
-from pydantic_ai import FunctionToolset, RunContext
-
-from haiku.rag.agents.rlm.agent import create_rlm_agent
-from haiku.rag.agents.rlm.dependencies import RLMContext, RLMDeps
-from haiku.rag.agents.rlm.sandbox import Sandbox
-from haiku.rag.config.models import AppConfig
-from haiku.rag.tools.context import RAGDeps
-from haiku.rag.tools.filters import (
- build_document_filter,
- combine_filters,
-)
-
-
-class AnalysisResult(BaseModel):
- """Result from the analysis toolset (RLM execution)."""
-
- answer: str = Field(description="The answer produced by analysis")
- code_executed: bool = Field(
- default=True,
- description="Whether code was executed to produce this answer",
- )
-
-
-def create_analysis_toolset(
- config: AppConfig,
- base_filter: str | None = None,
- tool_name: str = "analyze",
-) -> FunctionToolset[RAGDeps]:
- """Create a toolset with code analysis capabilities via RLM agent.
-
- Args:
- config: Application configuration.
- base_filter: Optional base SQL WHERE clause applied to searches.
- tool_name: Name for the analyze tool. Defaults to "analyze".
-
- Returns:
- FunctionToolset with an analyze tool.
- """
-
- async def analyze( # pragma: no cover
- ctx: RunContext[RAGDeps],
- task: str,
- document_name: str | None = None,
- ) -> AnalysisResult:
- """Execute a computational task via code execution.
-
- Uses the RLM (Recursive Language Model) agent to write and execute
- Python code to answer the task.
-
- Args:
- task: A specific, actionable instruction describing what to compute.
- document_name: Optional document name/title to focus on.
-
- Returns:
- AnalysisResult with answer and execution metadata.
- """
- client = ctx.deps.client
-
- doc_filter = build_document_filter(document_name) if document_name else None
- effective_filter = combine_filters(base_filter, doc_filter)
-
- rlm_context = RLMContext(filter=effective_filter)
-
- sandbox = Sandbox(
- client=client,
- config=config,
- context=rlm_context,
- )
- deps = RLMDeps(
- sandbox=sandbox,
- context=rlm_context,
- )
-
- rlm_agent = create_rlm_agent(config)
- result = await rlm_agent.run(task, deps=deps)
-
- program = result.output.program
-
- return AnalysisResult(
- answer=result.output.answer,
- code_executed=bool(program),
- )
-
- toolset: FunctionToolset[RAGDeps] = FunctionToolset()
- toolset.add_function(analyze, name=tool_name)
- return toolset
diff --git a/haiku_rag_slim/haiku/rag/tools/filters.py b/haiku_rag_slim/haiku/rag/tools/filters.py
index b09d14db..66d7f5b9 100644
--- a/haiku_rag_slim/haiku/rag/tools/filters.py
+++ b/haiku_rag_slim/haiku/rag/tools/filters.py
@@ -1,4 +1,4 @@
-def build_document_filter(document_name: str) -> str:
+def _build_document_filter(document_name: str) -> str:
"""Build SQL filter for document name matching.
Matches against both uri and title fields, case-insensitive.
@@ -19,20 +19,7 @@ def build_multi_document_filter(document_names: list[str]) -> str | None:
"""
if not document_names:
return None
- filters = [build_document_filter(name) for name in document_names]
+ filters = [_build_document_filter(name) for name in document_names]
if len(filters) == 1:
return filters[0]
return " OR ".join(f"({f})" for f in filters)
-
-
-def combine_filters(filter1: str | None, filter2: str | None) -> str | None:
- """Combine two SQL filters with AND logic.
-
- Returns None if both filters are None.
- """
- filters = [f for f in [filter1, filter2] if f]
- if not filters:
- return None
- if len(filters) == 1:
- return filters[0]
- return f"({filters[0]}) AND ({filters[1]})"
diff --git a/haiku_rag_slim/haiku/rag/tools/qa.py b/haiku_rag_slim/haiku/rag/tools/qa.py
deleted file mode 100644
index 116377ea..00000000
--- a/haiku_rag_slim/haiku/rag/tools/qa.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from pydantic import BaseModel, Field
-
-from haiku.rag.agents.research.models import Citation, SearchAnswer
-
-PRIOR_ANSWER_RELEVANCE_THRESHOLD = 0.7
-
-
-class QAHistoryEntry(BaseModel):
- """A Q&A pair with optional cached embedding for similarity matching."""
-
- question: str
- answer: str
- confidence: float = 0.9
- citations: list[Citation] = Field(default_factory=list)
- question_embedding: list[float] | None = Field(default=None, exclude=True)
-
- @property
- def sources(self) -> list[str]:
- """Source names for display."""
- return list(
- dict.fromkeys(c.document_title or c.document_uri for c in self.citations)
- )
-
- def to_search_answer(self) -> SearchAnswer:
- """Convert to SearchAnswer for research graph context."""
- return SearchAnswer(
- query=self.question,
- answer=self.answer,
- confidence=self.confidence,
- cited_chunks=[c.chunk_id for c in self.citations],
- citations=self.citations,
- )
diff --git a/haiku_rag_slim/pyproject.toml b/haiku_rag_slim/pyproject.toml
index aa40d7fd..b176939c 100644
--- a/haiku_rag_slim/pyproject.toml
+++ b/haiku_rag_slim/pyproject.toml
@@ -62,7 +62,7 @@ vertexai = ["pydantic-ai-slim[vertexai]"]
[project.entry-points."haiku.skills"]
rag = "haiku.rag.skills.rag:create_skill"
-rag-rlm = "haiku.rag.skills.rlm:create_skill"
+rag-analysis = "haiku.rag.skills.analysis:create_skill"
[project.scripts]
haiku-rag = "haiku.rag.cli:cli"
diff --git a/mkdocs.yml b/mkdocs.yml
index 681e49d0..3a282ca8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -59,7 +59,6 @@ nav:
- index.md
- Getting started: tutorial.md
- Installation: installation.md
- - Architecture: architecture.md
- Configuration:
- configuration/index.md
- Providers: configuration/providers.md
@@ -73,11 +72,11 @@ nav:
- Tuning: tuning.md
- Agents:
- agents/index.md
- - RLM Agent: agents/rlm.md
+ - Analysis Agent: agents/analysis.md
- Skills:
- skills/index.md
- RAG: skills/rag.md
- - RLM: skills/rlm.md
+ - Analysis: skills/analysis.md
- Toolsets: tools.md
- Applications: apps.md
- Server: server.md
diff --git a/tests/agents/rlm/__init__.py b/tests/agents/analysis/__init__.py
similarity index 100%
rename from tests/agents/rlm/__init__.py
rename to tests/agents/analysis/__init__.py
diff --git a/tests/agents/rlm/conftest.py b/tests/agents/analysis/conftest.py
similarity index 51%
rename from tests/agents/rlm/conftest.py
rename to tests/agents/analysis/conftest.py
index b1df0f4c..ca2f3712 100644
--- a/tests/agents/rlm/conftest.py
+++ b/tests/agents/analysis/conftest.py
@@ -1,7 +1,7 @@
import pytest
-from haiku.rag.agents.rlm.dependencies import RLMContext
-from haiku.rag.agents.rlm.sandbox import Sandbox
+from haiku.rag.agents.analysis.dependencies import AnalysisContext
+from haiku.rag.agents.analysis.sandbox import Sandbox
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
@@ -14,8 +14,9 @@ async def empty_client(temp_db_path):
@pytest.fixture
-async def sandbox(empty_client):
+async def sandbox(temp_db_path):
"""Create a Monty sandbox for testing."""
- config = AppConfig()
- context = RLMContext()
- return Sandbox(client=empty_client, config=config, context=context)
+ async with HaikuRAG(temp_db_path, create=True):
+ config = AppConfig()
+ context = AnalysisContext()
+ return Sandbox(db_path=temp_db_path, config=config, context=context)
diff --git a/tests/agents/rlm/test_agent.py b/tests/agents/analysis/test_agent.py
similarity index 82%
rename from tests/agents/rlm/test_agent.py
rename to tests/agents/analysis/test_agent.py
index 60e567c1..3f2ec6ff 100644
--- a/tests/agents/rlm/test_agent.py
+++ b/tests/agents/analysis/test_agent.py
@@ -3,26 +3,26 @@ from pathlib import Path
import pytest
from pydantic_ai import Agent
-from haiku.rag.agents.rlm.agent import create_rlm_agent
-from haiku.rag.agents.rlm.dependencies import RLMDeps
-from haiku.rag.agents.rlm.models import CodeExecution, RLMResult
+from haiku.rag.agents.analysis.agent import create_analysis_agent
+from haiku.rag.agents.analysis.dependencies import AnalysisDeps
+from haiku.rag.agents.analysis.models import CodeExecution, RawAnalysisResult
from haiku.rag.config import AppConfig, Config
@pytest.fixture(scope="module")
def vcr_cassette_dir():
- return str(Path(__file__).parent.parent.parent / "cassettes" / "test_rlm")
+ return str(Path(__file__).parent.parent.parent / "cassettes" / "test_analysis")
-class TestCreateRLMAgent:
+class TestCreateAnalysisAgent:
def test_creates_agent(self):
- agent = create_rlm_agent(Config)
+ agent = create_analysis_agent(Config)
assert isinstance(agent, Agent)
- assert agent.deps_type is RLMDeps
- assert agent.output_type is RLMResult
+ assert agent.deps_type is AnalysisDeps
+ assert agent.output_type is RawAnalysisResult
def test_agent_has_execute_code_tool(self):
- agent = create_rlm_agent(Config)
+ agent = create_analysis_agent(Config)
tool_names = list(agent._function_toolset.tools.keys())
assert "execute_code" in tool_names
@@ -42,13 +42,13 @@ class TestCodeExecutionModel:
assert execution.success is True
-class TestClientRLMIntegration:
- """Integration tests for client.rlm() method."""
+class TestClientAnalysisIntegration:
+ """Integration tests for client.analyze() method."""
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_rlm_count_documents(self, allow_model_requests, temp_db_path):
- """Test RLM agent can count documents.
+ async def test_analyze_count_documents(self, allow_model_requests, temp_db_path):
+ """Test analysis agent can count documents.
Agent program:
docs = list_documents(limit=1000)
@@ -63,14 +63,14 @@ class TestClientRLMIntegration:
await client.create_document("Second document about dogs.", title="Doc 2")
await client.create_document("Third document about birds.", title="Doc 3")
- result = await client.rlm("How many documents are in the database?")
+ result = await client.analyze("How many documents are in the database?")
assert "3" in result.answer
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_rlm_aggregation(self, allow_model_requests, temp_db_path):
- """Test RLM agent can perform aggregation across documents.
+ async def test_analyze_aggregation(self, allow_model_requests, temp_db_path):
+ """Test analysis agent can perform aggregation across documents.
Agent program:
import re
@@ -103,7 +103,7 @@ class TestClientRLMIntegration:
"Sales report Q3: Revenue was $200,000.", title="Q3 Report"
)
- result = await client.rlm(
+ result = await client.analyze(
"What is the total revenue across all quarterly reports?"
)
@@ -111,8 +111,8 @@ class TestClientRLMIntegration:
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_rlm_with_filter(self, allow_model_requests, temp_db_path):
- """Test RLM agent respects filter parameter.
+ async def test_analyze_with_filter(self, allow_model_requests, temp_db_path):
+ """Test analysis agent respects filter parameter.
Agent program:
docs = list_documents(limit=1000)
@@ -130,7 +130,7 @@ class TestClientRLMIntegration:
await client.create_document("Dog document.", title="Dogs")
await client.create_document("Bird document.", title="Birds")
- result = await client.rlm(
+ result = await client.analyze(
"How many documents are available?",
filter="title = 'Cats'",
)
@@ -139,15 +139,10 @@ class TestClientRLMIntegration:
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_rlm_search_and_get_chunk(self, allow_model_requests, temp_db_path):
- """Test RLM agent can search and use get_chunk for citations.
-
- Agent program:
- results = search("content", limit=5)
- for r in results:
- chunk = get_chunk(r['chunk_id'])
- print(chunk['document_title'], chunk['chunk_id'])
- """
+ async def test_analyze_search_and_identify_source(
+ self, allow_model_requests, temp_db_path
+ ):
+ """Test analysis agent can search and identify source documents."""
from haiku.rag.client import HaikuRAG
config = AppConfig()
@@ -158,7 +153,7 @@ class TestClientRLMIntegration:
title="Animal Facts",
)
- result = await client.rlm(
+ result = await client.analyze(
"Search for content about animals and tell me "
"which document it came from."
)
@@ -167,10 +162,10 @@ class TestClientRLMIntegration:
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_rlm_semantic_analysis_with_llm(
+ async def test_analyze_semantic_analysis_with_llm(
self, allow_model_requests, temp_db_path
):
- """Test RLM agent can use llm() for semantic analysis combined with computation.
+ """Test analysis agent can use llm() for semantic analysis combined with computation.
Agent program:
docs = list_documents(limit=100)
@@ -209,7 +204,7 @@ class TestClientRLMIntegration:
title="Q3 Update",
)
- result = await client.rlm(
+ result = await client.analyze(
"Analyze the sentiment of each quarterly update. "
"How many quarters were positive, negative, and mixed?"
)
@@ -220,8 +215,8 @@ class TestClientRLMIntegration:
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_rlm_search_and_extract(self, allow_model_requests, temp_db_path):
- """Test RLM agent can use search() to find content and extract information.
+ async def test_analyze_search_and_extract(self, allow_model_requests, temp_db_path):
+ """Test analysis agent can use search() to find content and extract information.
Agent program:
results = search("document element types", limit=20)
@@ -242,7 +237,7 @@ class TestClientRLMIntegration:
async with HaikuRAG(temp_db_path, config=config, create=True) as client:
await client.create_document_from_source(pdf_path)
- result = await client.rlm(
+ result = await client.analyze(
"Search for content about document element types or labels. "
"What are all the different document element types mentioned? "
"List them all."
@@ -277,10 +272,10 @@ class TestClientRLMIntegration:
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_rlm_with_preloaded_documents(
+ async def test_analyze_with_preloaded_documents(
self, allow_model_requests, temp_db_path
):
- """Test RLM agent can use pre-loaded documents variable.
+ """Test analysis agent can use pre-loaded documents variable.
Agent program:
if 'documents' in dir():
@@ -303,7 +298,7 @@ class TestClientRLMIntegration:
title="Mission Statement",
)
- result = await client.rlm(
+ result = await client.analyze(
"Using the pre-loaded documents variable, "
"tell me when was the company founded and what is their mission?",
documents=["Company History", "Mission Statement"],
diff --git a/tests/agents/rlm/test_models.py b/tests/agents/analysis/test_models.py
similarity index 82%
rename from tests/agents/rlm/test_models.py
rename to tests/agents/analysis/test_models.py
index 8d1ec6c8..41c902ee 100644
--- a/tests/agents/rlm/test_models.py
+++ b/tests/agents/analysis/test_models.py
@@ -1,4 +1,4 @@
-from haiku.rag.agents.rlm.models import CodeExecution, RLMResult
+from haiku.rag.agents.analysis.models import AnalysisResult, CodeExecution
class TestCodeExecution:
@@ -25,8 +25,8 @@ class TestCodeExecution:
assert "ZeroDivisionError" in execution.stderr
-class TestRLMResult:
+class TestAnalysisResult:
def test_create_result(self):
- result = RLMResult(answer="The answer is 42", program="print(42)")
+ result = AnalysisResult(answer="The answer is 42", program="print(42)")
assert result.answer == "The answer is 42"
assert result.program == "print(42)"
diff --git a/tests/agents/rlm/test_sandbox.py b/tests/agents/analysis/test_sandbox.py
similarity index 50%
rename from tests/agents/rlm/test_sandbox.py
rename to tests/agents/analysis/test_sandbox.py
index adc34daa..26bc4679 100644
--- a/tests/agents/rlm/test_sandbox.py
+++ b/tests/agents/analysis/test_sandbox.py
@@ -2,8 +2,8 @@ from pathlib import Path
import pytest
-from haiku.rag.agents.rlm.dependencies import RLMContext
-from haiku.rag.agents.rlm.sandbox import Sandbox, SandboxResult
+from haiku.rag.agents.analysis.dependencies import AnalysisContext
+from haiku.rag.agents.analysis.sandbox import Sandbox, SandboxResult
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
from haiku.rag.store.models import Document
@@ -74,12 +74,12 @@ class TestSandboxErrors:
assert result.stderr != ""
-class TestSandboxHaikuRAG:
- """Test haiku.rag functions in sandbox."""
+class TestSandboxListDocuments:
+ """Test list_documents function in sandbox."""
@pytest.mark.asyncio
async def test_list_documents_empty(self, sandbox):
- """Test list_documents returns empty list for empty database."""
+ """list_documents returns empty list for empty database."""
result = await sandbox.execute(
"docs = await list_documents()\nprint(type(docs).__name__, len(docs))"
)
@@ -89,7 +89,7 @@ class TestSandboxHaikuRAG:
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_list_documents_with_data(self, temp_db_path):
- """Test list_documents returns documents when populated."""
+ """list_documents returns documents when populated."""
config = AppConfig()
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(
@@ -98,8 +98,8 @@ class TestSandboxHaikuRAG:
title="Test Document",
)
- context = RLMContext()
- sb = Sandbox(client=client, config=config, context=context)
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
result = await sb.execute(
"docs = await list_documents()\n"
"print(len(docs))\n"
@@ -109,6 +109,10 @@ class TestSandboxHaikuRAG:
assert "1" in result.stdout
assert "Test Document" in result.stdout
+
+class TestSandboxSearch:
+ """Test search function in sandbox."""
+
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_search_with_data(self, temp_db_path):
@@ -121,8 +125,8 @@ class TestSandboxHaikuRAG:
title="Animals",
)
- context = RLMContext()
- sb = Sandbox(client=client, config=config, context=context)
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
result = await sb.execute(
"results = await search('fox', limit=5)\n"
"print(len(results))\n"
@@ -134,68 +138,52 @@ class TestSandboxHaikuRAG:
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_get_document(self, temp_db_path):
- """Test get_document function."""
- config = AppConfig()
- async with HaikuRAG(temp_db_path, create=True) as client:
- doc = await client.create_document(
- content="Content about foxes and dogs.",
- uri="test://doc",
- title="Fox Document",
- )
-
- context = RLMContext()
- sb = Sandbox(client=client, config=config, context=context)
- result = await sb.execute(
- f"content = await get_document('{doc.id}')\n"
- "print('foxes' in content.lower() if content else 'None')"
- )
- assert result.success
- assert "True" in result.stdout
-
- @pytest.mark.asyncio
- async def test_get_document_not_found(self, sandbox):
- """Test get_document returns None for missing document."""
- result = await sandbox.execute(
- "content = await get_document('nonexistent-id')\nprint(content is None)"
- )
- assert result.success
- assert "True" in result.stdout
-
- @pytest.mark.asyncio
- @pytest.mark.vcr()
- async def test_get_chunk(self, temp_db_path):
- """Test get_chunk function returns chunk with metadata."""
+ async def test_search_returns_doc_item_refs_and_labels(self, temp_db_path):
+ """Search results include doc_item_refs and labels."""
config = AppConfig()
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(
- content="Content about foxes and dogs.",
- uri="test://doc",
- title="Fox Document",
+ content="The quick brown fox jumps over the lazy dog.",
+ uri="test://animals",
+ title="Animals",
)
- context = RLMContext()
- sb = Sandbox(client=client, config=config, context=context)
- # First search to get a chunk_id
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
result = await sb.execute(
- "results = await search('foxes', limit=1)\n"
- "chunk_id = results[0]['chunk_id']\n"
- "chunk = await get_chunk(chunk_id)\n"
- "print(chunk['document_title'])\n"
- "print('content' in chunk)"
+ "results = await search('fox', limit=1)\n"
+ "r = results[0]\n"
+ "print('doc_item_refs' in r)\n"
+ "print('labels' in r)\n"
+ "print(type(r['doc_item_refs']).__name__)\n"
+ "print(type(r['labels']).__name__)"
)
assert result.success
- assert "Fox Document" in result.stdout
- assert "True" in result.stdout
+ assert "True\nTrue" in result.stdout
+ assert "list\nlist" in result.stdout
@pytest.mark.asyncio
- async def test_get_chunk_not_found(self, sandbox):
- """Test get_chunk returns None for missing chunk."""
- result = await sandbox.execute(
- "chunk = await get_chunk('nonexistent-id')\nprint(chunk is None)"
- )
- assert result.success
- assert "True" in result.stdout
+ @pytest.mark.vcr()
+ async def test_search_returns_expanded_content(self, temp_db_path):
+ """search() returns context-expanded results."""
+ config = AppConfig()
+ async with HaikuRAG(temp_db_path, create=True) as client:
+ await client.create_document(
+ content="The quick brown fox jumps over the lazy dog.",
+ uri="test://animals",
+ title="Animals",
+ )
+
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute(
+ "results = await search('fox', limit=1)\n"
+ "print(type(results[0]['content']).__name__)\n"
+ "print('fox' in results[0]['content'].lower())"
+ )
+ assert result.success
+ assert "str" in result.stdout
+ assert "True" in result.stdout
class TestSandboxExternalFunctionEdgeCases:
@@ -254,38 +242,151 @@ class TestSandboxOutputTruncation:
"""Test output truncation behavior."""
@pytest.mark.asyncio
- async def test_truncate_stdout_on_runtime_error(self, empty_client):
+ async def test_truncate_stdout_on_runtime_error(self, temp_db_path):
"""Test stdout is truncated when a runtime error occurs after large output."""
- config = AppConfig()
- config.rlm.max_output_chars = 20
- context = RLMContext()
- sb = Sandbox(client=empty_client, config=config, context=context)
- result = await sb.execute("print('a' * 100)\nx = 1/0")
- assert not result.success
- assert "ZeroDivisionError" in result.stderr
- assert result.stdout.endswith("... (output truncated)")
- assert len(result.stdout) < 100
+ async with HaikuRAG(temp_db_path, create=True):
+ config = AppConfig()
+ config.analysis.max_output_chars = 20
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute("print('a' * 100)\nx = 1/0")
+ assert not result.success
+ assert "ZeroDivisionError" in result.stderr
+ assert result.stdout.endswith("... (output truncated)")
+ assert len(result.stdout) < 100
@pytest.mark.asyncio
- async def test_truncate_successful_output(self, empty_client):
+ async def test_truncate_successful_output(self, temp_db_path):
"""Test output is truncated on successful execution with large output."""
- config = AppConfig()
- config.rlm.max_output_chars = 20
- context = RLMContext()
- sb = Sandbox(client=empty_client, config=config, context=context)
- result = await sb.execute("print('b' * 100)")
+ async with HaikuRAG(temp_db_path, create=True):
+ config = AppConfig()
+ config.analysis.max_output_chars = 20
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute("print('b' * 100)")
+ assert result.success
+ assert result.stdout.endswith("... (output truncated)")
+ assert len(result.stdout) < 100
+
+
+class TestSandboxVFS:
+ """Test virtual filesystem for document access."""
+
+ @pytest.mark.asyncio
+ async def test_empty_database_has_no_documents(self, sandbox):
+ """Empty database has no document directories."""
+ result = await sandbox.execute(
+ "from pathlib import Path\nprint(Path('/documents').exists())"
+ )
assert result.success
- assert result.stdout.endswith("... (output truncated)")
- assert len(result.stdout) < 100
-
-
-class TestSandboxContextFilter:
- """Test context filter is applied."""
+ # /documents dir may or may not exist when empty, both are valid
+ # The key is it doesn't error
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_filter_applied_to_list_documents(self, temp_db_path):
- """Test that context filter is passed to list_documents."""
+ async def test_iterdir_discovers_documents(self, temp_db_path):
+ """Path('/documents').iterdir() lists document directories."""
+ config = AppConfig()
+ async with HaikuRAG(temp_db_path, create=True) as client:
+ await client.create_document(
+ content="Test content",
+ uri="test://doc1",
+ title="Test Document",
+ )
+
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute(
+ "from pathlib import Path\n"
+ "dirs = list(Path('/documents').iterdir())\n"
+ "print(len(dirs))\n"
+ "print(dirs[0].is_dir())"
+ )
+ assert result.success
+ assert "1" in result.stdout
+ assert "True" in result.stdout
+
+ @pytest.mark.asyncio
+ @pytest.mark.vcr()
+ async def test_metadata_json(self, temp_db_path):
+ """metadata.json contains document title and uri."""
+ 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",
+ )
+
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute(
+ "from pathlib import Path\n"
+ "import json\n"
+ f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n"
+ "print(meta['title'])\n"
+ "print(meta['uri'])"
+ )
+ assert result.success
+ assert "Test Document" in result.stdout
+ assert "test://doc1" in result.stdout
+
+ @pytest.mark.asyncio
+ @pytest.mark.vcr()
+ async def test_content_txt(self, temp_db_path):
+ """content.txt returns full document text (lazy loaded)."""
+ config = AppConfig()
+ async with HaikuRAG(temp_db_path, create=True) as client:
+ doc = await client.create_document(
+ content="Content about foxes and dogs.",
+ uri="test://doc",
+ title="Fox Document",
+ )
+
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute(
+ "from pathlib import Path\n"
+ f"content = Path('/documents/{doc.id}/content.txt').read_text()\n"
+ "print('foxes' in content.lower())"
+ )
+ assert result.success
+ assert "True" in result.stdout
+
+ @pytest.mark.asyncio
+ @pytest.mark.vcr()
+ async def test_items_jsonl(self, temp_db_path):
+ """items.jsonl returns document items as JSONL (lazy loaded)."""
+ config = AppConfig()
+ async with HaikuRAG(temp_db_path, create=True) as client:
+ doc = await client.create_document(
+ content="The quick brown fox jumps over the lazy dog.",
+ uri="test://animals",
+ title="Animals",
+ )
+
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute(
+ "from pathlib import Path\n"
+ "import json\n"
+ f"text = Path('/documents/{doc.id}/items.jsonl').read_text()\n"
+ "lines = text.strip().split('\\n')\n"
+ "print(len(lines) > 0)\n"
+ "item = json.loads(lines[0])\n"
+ "print('position' in item)\n"
+ "print('self_ref' in item)\n"
+ "print('label' in item)\n"
+ "print('text' in item)\n"
+ "print('page_numbers' in item)"
+ )
+ assert result.success
+ assert result.stdout.count("True") == 6
+
+ @pytest.mark.asyncio
+ @pytest.mark.vcr()
+ async def test_context_filter_limits_vfs(self, temp_db_path):
+ """Context filter restricts which documents appear in VFS."""
config = AppConfig()
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(
@@ -299,13 +400,15 @@ class TestSandboxContextFilter:
title="Private Doc",
)
- context = RLMContext(filter="uri LIKE 'public://%'")
- sb = Sandbox(client=client, config=config, context=context)
+ context = AnalysisContext(filter="uri LIKE 'public://%'")
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
result = await sb.execute(
- "docs = await list_documents()\n"
- "print(len(docs))\n"
- "if docs:\n"
- " print(docs[0]['title'])"
+ "from pathlib import Path\n"
+ "import json\n"
+ "dirs = list(Path('/documents').iterdir())\n"
+ "print(len(dirs))\n"
+ "meta = json.loads((dirs[0] / 'metadata.json').read_text())\n"
+ "print(meta['title'])"
)
assert result.success
assert "1" in result.stdout
@@ -324,61 +427,25 @@ class TestSandboxPreloadedDocuments:
assert "NameError" in result.stderr
@pytest.mark.asyncio
- async def test_documents_variable_available_with_preload(self, empty_client):
+ async def test_documents_variable_available_with_preload(self, temp_db_path):
"""documents variable is available when context.documents is set."""
- config = AppConfig()
- docs = [
- Document(id="1", content="Content A", title="Doc A", uri="a://1"),
- Document(id="2", content="Content B", title="Doc B", uri="b://2"),
- ]
- context = RLMContext(documents=docs)
- sb = Sandbox(client=empty_client, config=config, context=context)
- result = await sb.execute(
- "print(len(documents))\n"
- "print(documents[0]['title'])\n"
- "print(documents[1]['title'])"
- )
- assert result.success
- assert "2" in result.stdout
- assert "Doc A" in result.stdout
- assert "Doc B" in result.stdout
-
-
-class TestSandboxDoclingDocument:
- """Test get_docling_document() external function."""
-
- @pytest.mark.asyncio
- async def test_returns_none_for_missing_document(self, sandbox):
- """get_docling_document returns None for a non-existent document."""
- result = await sandbox.execute(
- "doc = await get_docling_document('nonexistent-id')\nprint(doc is None)"
- )
- assert result.success
- assert "True" in result.stdout
-
- @pytest.mark.asyncio
- @pytest.mark.vcr()
- async def test_returns_dict_for_document_with_docling_data(self, temp_db_path):
- """get_docling_document returns a dict for a document with docling data."""
- config = AppConfig()
- async with HaikuRAG(temp_db_path, create=True) as client:
- doc = await client.create_document(
- content="Docling processed content",
- uri="test://docling",
- title="Docling Doc",
- )
-
- context = RLMContext()
- sb = Sandbox(client=client, config=config, context=context)
+ async with HaikuRAG(temp_db_path, create=True):
+ config = AppConfig()
+ docs = [
+ Document(id="1", content="Content A", title="Doc A", uri="a://1"),
+ Document(id="2", content="Content B", title="Doc B", uri="b://2"),
+ ]
+ context = AnalysisContext(documents=docs)
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
result = await sb.execute(
- f"doc = await get_docling_document('{doc.id}')\n"
- "print(type(doc).__name__)\n"
- "print(doc['name'])\n"
- "print('texts' in doc)"
+ "print(len(documents))\n"
+ "print(documents[0]['title'])\n"
+ "print(documents[1]['title'])"
)
assert result.success
- assert "dict" in result.stdout
- assert "True" in result.stdout
+ assert "2" in result.stdout
+ assert "Doc A" in result.stdout
+ assert "Doc B" in result.stdout
class TestSandboxLLM:
@@ -386,14 +453,15 @@ class TestSandboxLLM:
@pytest.mark.asyncio
@pytest.mark.vcr()
- async def test_llm_function(self, allow_model_requests, empty_client):
+ async def test_llm_function(self, allow_model_requests, temp_db_path):
"""Test llm() calls the model and returns a string."""
- config = AppConfig()
- context = RLMContext()
- sb = Sandbox(client=empty_client, config=config, context=context)
- result = await sb.execute(
- "answer = await llm('What is 2 + 2? Reply with just the number.')\n"
- "print(answer)"
- )
- assert result.success
- assert "4" in result.stdout
+ async with HaikuRAG(temp_db_path, create=True):
+ config = AppConfig()
+ context = AnalysisContext()
+ sb = Sandbox(db_path=temp_db_path, config=config, context=context)
+ result = await sb.execute(
+ "answer = await llm('What is 2 + 2? Reply with just the number.')\n"
+ "print(answer)"
+ )
+ assert result.success
+ assert "4" in result.stdout
diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_aggregation.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_aggregation.yaml
similarity index 100%
rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_aggregation.yaml
rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_aggregation.yaml
diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_count_documents.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_count_documents.yaml
similarity index 100%
rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_count_documents.yaml
rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_count_documents.yaml
diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_extract.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_extract.yaml
similarity index 100%
rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_extract.yaml
rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_extract.yaml
diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_get_chunk.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_identify_source.yaml
similarity index 100%
rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_search_and_get_chunk.yaml
rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_search_and_identify_source.yaml
diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_semantic_analysis_with_llm.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_semantic_analysis_with_llm.yaml
similarity index 100%
rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_semantic_analysis_with_llm.yaml
rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_semantic_analysis_with_llm.yaml
diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_filter.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_filter.yaml
similarity index 100%
rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_filter.yaml
rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_filter.yaml
diff --git a/tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_preloaded_documents.yaml b/tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_preloaded_documents.yaml
similarity index 100%
rename from tests/cassettes/test_rlm/TestClientRLMIntegration.test_rlm_with_preloaded_documents.yaml
rename to tests/cassettes/test_analysis/TestClientAnalysisIntegration.test_analyze_with_preloaded_documents.yaml
diff --git a/tests/cassettes/test_sandbox/TestSandboxDoclingDocument.test_returns_dict_for_document_with_docling_data.yaml b/tests/cassettes/test_sandbox/TestSandboxDoclingDocument.test_returns_dict_for_document_with_docling_data.yaml
deleted file mode 100644
index 29228880..00000000
--- a/tests/cassettes/test_sandbox/TestSandboxDoclingDocument.test_returns_dict_for_document_with_docling_data.yaml
+++ /dev/null
@@ -1,42 +0,0 @@
-interactions:
-- request:
- headers:
- accept:
- - application/json
- accept-encoding:
- - gzip, deflate, zstd
- connection:
- - keep-alive
- content-length:
- - '95'
- content-type:
- - application/json
- host:
- - localhost:11434
- method: POST
- parsed_body:
- encoding_format: base64
- input:
- - Docling processed content
- model: qwen3-embedding:4b
- uri: http://localhost:11434/v1/embeddings
- response:
- headers:
- content-type:
- - application/json
- transfer-encoding:
- - chunked
- parsed_body:
- data:
- - embedding: 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
- index: 0
- object: embedding
- model: qwen3-embedding:4b
- object: list
- usage:
- prompt_tokens: 5
- total_tokens: 5
- status:
- code: 200
- message: OK
-version: 1
diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_chunk.yaml b/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_chunk.yaml
deleted file mode 100644
index 6977ce26..00000000
--- a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_chunk.yaml
+++ /dev/null
@@ -1,82 +0,0 @@
-interactions:
-- request:
- headers:
- accept:
- - application/json
- accept-encoding:
- - gzip, deflate, zstd
- connection:
- - keep-alive
- content-length:
- - '99'
- content-type:
- - application/json
- host:
- - localhost:11434
- method: POST
- parsed_body:
- encoding_format: base64
- input:
- - Content about foxes and dogs.
- model: qwen3-embedding:4b
- uri: http://localhost:11434/v1/embeddings
- response:
- headers:
- content-type:
- - application/json
- transfer-encoding:
- - chunked
- parsed_body:
- data:
- - embedding: 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
- index: 0
- object: embedding
- model: qwen3-embedding:4b
- object: list
- usage:
- prompt_tokens: 8
- total_tokens: 8
- status:
- code: 200
- message: OK
-- request:
- headers:
- accept:
- - application/json
- accept-encoding:
- - gzip, deflate, zstd
- connection:
- - keep-alive
- content-length:
- - '75'
- content-type:
- - application/json
- host:
- - localhost:11434
- method: POST
- parsed_body:
- encoding_format: base64
- input:
- - foxes
- model: qwen3-embedding:4b
- uri: http://localhost:11434/v1/embeddings
- response:
- headers:
- content-type:
- - application/json
- transfer-encoding:
- - chunked
- parsed_body:
- data:
- - embedding: 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
- index: 0
- object: embedding
- model: qwen3-embedding:4b
- object: list
- usage:
- prompt_tokens: 3
- total_tokens: 3
- status:
- code: 200
- message: OK
-version: 1
diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_list_documents_with_data.yaml b/tests/cassettes/test_sandbox/TestSandboxListDocuments.test_list_documents_with_data.yaml
similarity index 100%
rename from tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_list_documents_with_data.yaml
rename to tests/cassettes/test_sandbox/TestSandboxListDocuments.test_list_documents_with_data.yaml
diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_with_data.yaml b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_doc_item_refs_and_labels.yaml
similarity index 100%
rename from tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_search_with_data.yaml
rename to tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_doc_item_refs_and_labels.yaml
diff --git a/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_expanded_content.yaml b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_expanded_content.yaml
new file mode 100644
index 00000000..4ca2fa50
--- /dev/null
+++ b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_returns_expanded_content.yaml
@@ -0,0 +1,82 @@
+interactions:
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '114'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - The quick brown fox jumps over the lazy dog.
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: 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
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 11
+ total_tokens: 11
+ status:
+ code: 200
+ message: OK
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '73'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - fox
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: 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
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 2
+ total_tokens: 2
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_with_data.yaml b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_with_data.yaml
new file mode 100644
index 00000000..4ca2fa50
--- /dev/null
+++ b/tests/cassettes/test_sandbox/TestSandboxSearch.test_search_with_data.yaml
@@ -0,0 +1,82 @@
+interactions:
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '114'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - The quick brown fox jumps over the lazy dog.
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: 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
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 11
+ total_tokens: 11
+ status:
+ code: 200
+ message: OK
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '73'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - fox
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: 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
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 2
+ total_tokens: 2
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_document.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_content_txt.yaml
similarity index 100%
rename from tests/cassettes/test_sandbox/TestSandboxHaikuRAG.test_get_document.yaml
rename to tests/cassettes/test_sandbox/TestSandboxVFS.test_content_txt.yaml
diff --git a/tests/cassettes/test_sandbox/TestSandboxContextFilter.test_filter_applied_to_list_documents.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_context_filter_limits_vfs.yaml
similarity index 100%
rename from tests/cassettes/test_sandbox/TestSandboxContextFilter.test_filter_applied_to_list_documents.yaml
rename to tests/cassettes/test_sandbox/TestSandboxVFS.test_context_filter_limits_vfs.yaml
diff --git a/tests/cassettes/test_sandbox/TestSandboxVFS.test_items_jsonl.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_items_jsonl.yaml
new file mode 100644
index 00000000..4ca2fa50
--- /dev/null
+++ b/tests/cassettes/test_sandbox/TestSandboxVFS.test_items_jsonl.yaml
@@ -0,0 +1,82 @@
+interactions:
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '114'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - The quick brown fox jumps over the lazy dog.
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: 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
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 11
+ total_tokens: 11
+ status:
+ code: 200
+ message: OK
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '73'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - fox
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: 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
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 2
+ total_tokens: 2
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/test_sandbox/TestSandboxVFS.test_iterdir_discovers_documents.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_iterdir_discovers_documents.yaml
new file mode 100644
index 00000000..b6252ae6
--- /dev/null
+++ b/tests/cassettes/test_sandbox/TestSandboxVFS.test_iterdir_discovers_documents.yaml
@@ -0,0 +1,42 @@
+interactions:
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '82'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - Test content
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: OiAUOdjp5Tx40oi8drbCO/4kaTpb50U9NOSRPQLLj7xVU3M8mVGzPLM7FjzoffM8JQiqOpsCxrz7jAU9mKdqvXmFAL0zGpi7PGA6PeW6xbtdVIW8Fx44PLFdGT0gNR294hmku03VnLwurK28PERUvcPnBT3SrA+9fKY7vA0/ar2f2Kg88IgkPKOQXjsHywK8CMvNu5byWbxDOsk8R9MVvfGgOzxaDsO6NiSQPKJf+jqjd5u89iatO0EqUzvR9Ys8MamHvMQGb7y6mME7rrpnPOA8g7zIxvy8OUzDPNd2izyMTa+8qZkmvCU/6Lv1A/+8XrLDuxz9nzsiPg+9Oi2WvPEZqLq3xF28uIk+OzOlILt657M8+6lNuzR2szydmsg80Q0hPA6cDj0xH9Y2HYzVvMD5qrk+bhA9JttgPKKWsDw8r/Y8yRXRuwadW7zJ1g89Y2KnPNMtMbszWhw8/PZVO6TiPL1BNKg6LyNQPGj6tTzoTUy7bGY7PBSopDquHFm7BLI7vE1bbLw3vZi78vNsPHq077op4Es7MxHsPBNYU7yNExW9oFVlvKQaWjpeDHE8tECCPOLhPLzvTMS7ypMEvJlrlbsCGQK9970lvG6GsrwXbI+8uhAEPT6CuTzScCA9QoX9u+d/7DwQeso7uNqwu0WYcjwnCCI60tZ6vE1sbrz3Zka7lPp+OtlLJ7wjHCC8n0NDPNJslLxYYgG9Uov9u9xguTuMidK7ZwWIvP103TzpzuC7isRmObKXRjylAZ088OpBOxUOd7rIZQu7PJ0VPQprwTu3kxC6tBDwPHzwcLyjIRO8CnksPNhO6Lf0gAs9cbBCvILJmDuUA0w8BK9EPJ9klLwlKEo8nucnvYklhTwKdrg6gStCvE9Mp7wBBtu6zlAGOf0VQrsjk2u8OPa6vPynVTzL8f27i+fAvBHWqLvc9x+9CJVmO5pOx7vvjbQ8xP0TvFK0IzuiXhg9SQ8KvMrUkTzUqZM80gEXu9ZJOTwnZyo8rjVrPKtDsLvhg2U6t5l1vEk3SjvbwY68LQnWvJTRMrwk2lc705tkPF5/2TwxfVU8YCwkuyObLD0GVAA81WlqO/OvILz66jc7fiCbvCPCfDwZ6OG8K/2JPOQnAj3ckxm87Y19vNQOJjsWryQ72+PUvAuPtrlRbHw8ow/ZPFLcBTzJ91O7hyRTvBL6djphhgq9+leePExbr7sy/K+7ce2bPJLXyLslGZA8xkr0PP7XCrxsKtC8XKtLPC8lMLwTsly8BgX0u5VZU7zqgha8BVMFOyW2b7xM14Y8kEiHPNccETta7G28/zGhPG7be7wS32i85j36vD/Sl7zNetC7oQ1XO9EyZbuiVow5m6kFPCws5DoEjRs73RvBO/kjQDvTxHa6ncugPFDcU7xnRsK6sG4AO/rXkTnZiqM7juEzPOpyibvhLOC8HLo6PaPn+Lshf8S7skIlPNYsIr1S/Iq8dZYcPFFcPzyoYoY80ys3PcIMLrtCOCk7D/uevCuxBTxhoB45I/+xuzoorztnOW48uTynvHTHwjnpgjQ8FDe/vAypjjwm5aa77Pfcu1MP/jz/Pp08ANuDPDp9KTsg72q7p/xgvDw8ibw6pI68Y/nsO8nqC7xDU447FZliO6zUgTz0TI480wKwu5m2nDq/NES8tSpjOtlaiLtu3Yq825rYvIVGoztYNi+7QcMcvdbbpbxuLRS8MpC5vLysSrtDaiC8sLyPvKMNArzykKo8VAC2upATljx5Dhq72CCOvB535zxmwwC9joS9u2hmRLzbvD68TaWZPBuNNT0S4BI9aQktPITjd7w6dbW8En/wPJkepLuu8Pi89tQhvATahDzkDxk8qtewvGd8F7vqnmK83IzvvC2+C73XdkW81WvKvEImAj20xxe9cgJNPBOVDj0iEFA7xgPyu6XEhbqTxxw8waEAPHLrAb08qzK7R7GbvF19Gz1PRbE7vC8cvYfEvjitc/872PuKPMsM3TzPA4U82QufvMqbODwDz1c893F9OlEqxjz3uPY8DPoIPLqUbrtLuSg8PP0/vCwtAj26wTe7rJmtvAswQj2wbcM8k9yfvDpOdzyTJps8lx4yPNrw8jzX4vQ8Wq9KPOzGpTxiWD09ZoDZOoej5Du9Bc08ekfFvBXycTzmfIq8PS1EvO2KXDwOXg89JuWtPMkcs7vh4Og7XKCSPMX3ATzlLA+9utVBPFECDjzULTc8RQcIvLY+E71QU0G8cVTRuyjRH7ybuUW8hVyJvPt9yLyYHnc7dwJqunUQsjzYQlc7TPPhPHVzmbvx/KI8BEUVOy+TNT0a4168vWh4PZ0G4Du4nRc6cy9zu0HvFbzesgi9jrkGvZx8CTyBdcE8b2txvKo5fbu7A688JMrgPGEqm7sFqL67SKbouSsIcLwqupe7oRCluE2ZVLyJT1o8+tdePGA77bwWcZ68jIBWvD9Irb1bi4I8mYPHPNmt0rxlHRi8rU6WvCLC5rv8BJe8bfSYOzFMEj2WLYS8mfppvAsXtbxxJ4c8KpqHvP9niDwrqp08e9lcPDYcdLs3zKY8NH5sPd173bsTldo8GnvWPBsQ/DswXUk9O6LPPA4y3zvV8ys8M3RevPhiE7x5i6Q8ieLeO34h37uOgdQ758SSvGVuiDzXaKy8eWcTPMxNhzwU/iy8zSX7PAFR5bvFs5e6pAmtvB1bGrxcXY08DeUavHGMgrxjwXk8J9M7PPvlprtYgp28R58UvUPyHzyMHrK8eGb8vO2jdbz6oYW7rZKuvLpgrbxKkNI8IsunPLQC77tWp+28U1kIPbZIhzsy7rw8Z7KZvHOx/DzMU/o7uVEQPFISND08lbc89rmCvXeQtrzYiD25KSQOPR/MHroyFQi8OGi9vHFYgbs70/I8QsLGvCLU+zsGDmO9XvoYvb3wBTzgJ2G8C/yuPNXULDzcgfu4+JjUPALphLyMPma8+Nc8PVG9jbz4m8q64rOrO+Lj5LtuTO87praKu59gAj0pUPY8FDOpOT09jrx/cGy8QyQfPEOatbwA2R+8sAk7Os8IvzzzWvq6BoKPPMBp0zs6jGu8od59PGi+s7xs/fa8Y8h1vO8Bebx2Hq08VruNvDhwzbzDoYQ8Ym8jPbakTLxmT5O8CEWfPGBICzzToWi8dq2bO8f5zTzjgt+8zUiSPApD8Ly0Vf28q1D9vLIS47yNvU48/AjEPAhL2Lz/GLe840U0PQxb67v7hxC8f7PkPCopUDwun6u8nHWIu00whjyPH2E8XQyAvMXEKDztUC28wELZvCMoILxDxDS9D1BhuxtTw7xxvwC8lUIIPBGY/Lw3oI+8j1OavPQiVL0ge768Z3BvvEY+VjzvXxC8FknQO/Csljy5fQk9vaITvT8pBjxWCbY7hAM8vcR4JT0z4548pYNVvdmgTzxTzcI755ihPDIszzwEB/Y8pLWLPLt8fL23hau8+r5+O+WRdDxV19S8yEtjPGyVDDvJRYm7+gGcPJu6Ir1481E9uSsevHQqFjxCJTM89pxsvJiYB7x7FOM85XbdO2I7y7vFX/e7zdOOvc8QybyIkNc8LfkKPRBGgDulz848MCpiutUukzyvlx497AxPvbPqVTxgXRQ8eXufPNo5qbtBFK082BifPFEX5jw7JeS8XacRPWHHVjwld268jUiiPEqGDT0i3Q09vx/yOp7JsDxTKxE9YwjpPMV8kDvZgpw6uzZeO0DXpbnCMc67PAvfumpkjTywfc87d27KPAFIqjz/HYe7e6xpPIS4pbu6vw+60jNVvD2u4jy+6BI80MyBvMg0IL1ALVe8Lq2CPFFc07zPxAy8vWkPPL3GJr0bgfw79nSsurlTqDxdzNa8vU4qvPYYXbz6/+Y8U+0RPdaCgDyGyUY8H4xePQzbBTxYj6q89VPWvCtKnjwLxQe8cfSJPBazAb1DTF+6M5v2vPS1Sbt/N/68wy1HOZiqxDvMQvE70qZUvFlMujuReae8JlCPvLJ8mjxhZXK8UiiCPIoZIz2Flnc80nVAOY33yzs6kzA9BC0APWJUED2iwQc9fiBHPAnE/zs1EVi7Mw6sPFEVmLt8zVi8geT+ujwzxjtMNDe8KKpiPAM7qjvb26e8/cVcPEQdlTxhqgi8U632PBHRoDu0zuG85LZru0WacDv2CAg7kakMu0S/xrxS5YI8APB4vMUsoryQveg7O1n6vNick7yrQVS8FliYvFEGbzuNwe87EVg5uXUlibueqvI8cPuWukwCD7x4RP+86kIcvW5DD72jqi08xvvwOktLvbxa9is9n97mPPUEOT3tz7k8dOv9u3RUHrsFYGw7PkmRPHq3Er0O6yW7LCjBPIEdIzyfv628pU2GPHHHVLz2rB+8SopQPJ8imjxrN9489ejXvIg+sbx14cq72vKOuwR8tDuexr27sFcPuuMG5ztMbe27m8OnPGUAoTyFybe8szOUvF1dhbsfMrk8fyskvVXMHrySUeK8SdmbO35Ay7zrMRU90KrrvOzo07wANS08U6KGvP3esDyxxKK8EGEYOxpy+DwaUhk998a8PFWeDTwLnmy8iCKgPJz4Mz1b1i87mdylPJ26AbxQigM7ItAWvXnRury4D607S3QQvNYuB701jEq8Flv8u8Sz9DxouFi9r85Ru995GjzbK2M7zHSoOle7MzwMPoM8YeKMvJI0aLvH94G8cnYkvNYcwLmV7+Q8xdssPXSNEzxYMw882JlYvK8YprxEkGE8erUPPEOgAjz6ZIe8H9yRPP6O7ToDnjQ8XvD2O33xKrokMRC9vexIvebmyzzeH7+8gVHkPKQoIzwtE568Mik1POmPwzo6aVy6LWUqveA5vLtJO6271Ke+OxCWjbzhKgS93H2EPIx2eDz2jdi8Os5DuzBB2LuxYHY8ln8UvRXcWryJQus7JUYFPO6BEr0P5y88spHZPLbgxbxrIBG9iCLLO2SslLwIxoK8C81zvNaSErqxY/m8m+MIPPbxDr1Zpu67oCUVvPd04zzAVcA84QITvMfTW7zX/46822WlPFy9/Dyme5O8BgHIO+sfCz1yzi87c30UPPF4gjwhwv67iAp9vAIGpLxKjfy7iMvSuxIdubzVrei7VNQ1PN9Q5Dvze0+7QrUfvNblWbxET/s7dxd0vDIZkTxafmk8lJpGu45wK7rdS1U8ynh5OyvzJ7wcaRC82wievE1hGLq6hFi89ELPO5R3bzuNniA9e8FZPZhu3DuliQS9NT0WvDVuILxb4xu9QMqPPHPgAb2VNoe8MeGlvJJ7Qjq38M+8tCLFu/K+NLz8cUO6ScXSPDHikbrjWOk8B2oTOkVZIjxYvIc8bAnZOwei+7xzN8s8EylEPGlpurxOk8a8702CvRL17TzQ5sA6SuMWPRoGnztegUG8HxLevOuRT7tZWjy8ZWW1vN0DWDzvvmA8wHyqPCbHZLthXMW6cbFXuhiKILyzKf+71dMqvZfRTbyC6IO8w6GwPKR4iTwFlNK6d0X/PAf+4Tt7MuE7i1yVu4vdhTzZ47I82CM5O84oUbx1ZSI97DLPvDd7ELxXFJo8HDNZPGyjczvlzjK83XZRPJnPlDv5NeE86MoXPJNXXTwbSao7eoOAPBJwWDw3TNk8vGKAPHpFIbzzBtU7FHGqOy9207yyEHy6FjGzPA2v+zsug+e8F1onPAyjmbzbzpc80MfMvGn/ODzVdvy7f06ou557qTuK1Je8rHbBPGTLrTxWWiQ8Tq+iPFRAIbyIKwc8llAyvKtN37x66h08tqR3vPHj4Tw3Ima7ETeRu1AD/jz9kfq8dHeOPPJqN7ygBXi7nV/nPIaoUryfPPu85t/XvLyzMruG4Us95NfZuvJ2K73FM5+7CgzyPLiMXDyD00y6CfPxu7DyLzy6oBC8bqOpvA7wY7wbBp08Fc0APYfMuju+c9U84EcYvVJFKbsjcka6HlUGPER0q7ohmfK7a9bTum2CxzxD3946qk7UvDVQfrs2W3a87WTWPFNHADujtAS8m5LBPJMazzxSHpq8Itj6uve3mDxic0C853drPPP66TsqYCe7GSYMPSHfTDyIqBq9XOF3OkZA3zyikCO7bvlJvI/Xt7rLYy28aRqjvHyt9DtbhjA9NdzlvF08vTzalcm6ZdKRO/xlr7zDs/08l4hkvCHbWDzAG5G7fqD7Ow/isTzTaUM9S2YFu9YWnjp0JZa84vlHPNtoyDxWViq8iYonvMP8Gz2LXpi7aKarvBenXbux2VG800zvu461rrzMPUM8X6blvEEuQjunB5K8n+ERPdZHbzwfGLA8ZpGOvP4MtLxB0le8FHbdu+ciwrwISiG8JROZO1PoTzvuvzc84nUdvBqVd7w/YDM91CIgvCYGkjx59Ha8GRU+PPAgcLypAE07YvEWvN+J97wr+hi9+a6PvK1mNb3hYx27C+6RO0e+wLz2IK07i9UbO63oVTl+Xas8BLwiPZSPo7sBIVy6q2+1vFb/VDzhlCq8oV0ZvIDOjDoEzPC8djKAO6g9uDwa1w67nimKPPcKO7zsc2K8PxAzvDZJrjt1gHS8pQYLPbP80zxqa+47Rt4/vNsNuTyR9mQ951jvPBy1uLyrE2084DoNvRwSbrxfym08PoIWvDn5CTwd57M7RnMsu93BljuFzv08+vgaPW9AIb25elQ7fQOBvNssbjxFKB28FDomvI950ruaTWe8BlNSvKN27jwvmSY7HpevvHDxhruYL188ilgqu3IaxjpUPhw88g5ruxSfNruWQHM7LGOePKk2z7wKWs48JomJujumLj2tsrc435wdO06ZxzwoSPW8/1eku/cEmzwFr4i8s686vKFafbwvGps7hfyWPCRZeTzSyAM9vVRVO2SVF7punga9+D6ZuzH9v7s1+yy92uVhO+XfqjxAG9q8r/lOvOY02TyMuwi9Fr0yPIwsd7xSiY679ZZavCOkyzuHagY8JJqxPFUBJjzw2Hg8SRIRvNWPujxizxE83kTBvEtk8LsQvLw7TF/nPNVMz7rYO2Q8elvtPJguijrbF9y8x2oQvUYrmjploUS8vMPxvCaz97ycNiy8e2UCO40buDpbuLa7bp+API1l+ryHfqA7sU6ANqUct7zv2uc8HoMsPKh497sRsQa7re+9vMBIuTzIPAs8z2+7PNmBsLw3Pg68vvtWvLnHYLyM95E8UgziPJWrFTtGLx+9fbRWPHeueTzGITi9P0KLPCX2HrwNYIo8vjJbPLyxZzyen9489hw4u7j+uzp+l5w7Owr+PFBepDz/vm88nUm3PEHCzDrsG4U6eWW1PNw6Oj1hRMa70JL2vIeYCT0r4kS8UebwPNK9FLsG7Gq4YN0bPcUNMDvPF3q8MgmLPL8hmrwbe8q8V94bPFjN7byPQGU7GztxvK8KwDxCMak8v2PvvCHCqDuv5g28x0tXPBK3NDxDKoa53Xn1vJWXxDxskgw8DFkXvP3u3zwDqpK70AuRuhN0nTwiLZk8LTjcPOq647zwhgQ8oBi3u2/wMb169IE8a3bgOnSHc7yVUgO9DyQaO+MPcj3t0JK83jbHvJKcT7zGjIe5DVccPNpyK7y2SbO8o015vEoj3TxzCQC8ETpyvGVpd7xZqb07SBKXvDc8Rz1EdhQ81klgu68dEL3jfY28QJ7BvDM53rygCQG8iBCxPCgcgzw5F648pYgFPMhW/Dwztj88e4mwuxMJYDwANay7DifEu3iPBDx+Vge8D5yivGgnAL0kfrm70G+HvHY6Ib06tQ485hGWvIFJmDs3Elw85rWJO+3vgDsOyhO897tbPIBXuDy3ewq8S73nPCO5ibycgDM8LFMuPVvDY7rajMM6DyTMu7BIgzx4LM+8UsskvSf4Pjsn1CY8nz5Ru6Hi7zzQ9EI8dNuKO7zOt7xP8wg65gpCvFP2lTwvh4A8mU0gPJRLCTtTHRq9PHFmPIVBxLtZ2wa9T7mhusVrGrz+2i+8W5N/vK2rpjyEEZm8v5ZPPZqMNzp09Iu8JbS8vL/5g7yMMzC8Zc/Fus1KiLwiIkQ778qWPMsu7zvZpyG92pbMOwl8XbrCJQC9YawcuXFTnTwFZc46MXLePAsktjteqf28XicMO1wsXLrV2wK8kehvu8LYvTy+wos8ZNtet5BojLvWwaK7m+evum+Embxx7nA8/dBmPEOJ5jzxS1k8OF60PPA3Dr0E8MQ806+hvHJ7qLwe8AG8ff0EvQcOCr0MgLo8N2+9uw8IArkYdB+5d8zlO2/vEDyTaNc8mGZJvIzjVTwvpxw9I4ZEvIeZyrt1S4W8IvFCvNK8GLwGBC08pRQJvS7S5DyGYDc9ij7zueCBGDtJ7g45QDP4u0YkWD0feVy8XGuMPPErUrymQ7u842V3O2cuMjxYE7u8oUneOw84r7ykwcI80sgDPHuyoTwncJu6bDGXvIgWeLv+4y+7svVSur1b4Tuw7Ti73GfivHqEqbxEFoC8ibTruwPPXLvdfrg8rgRsPP3ua7nooCQ7gMaBu4BpPzxfGLU8REpFvLR00bwbc4K8LjhJPBM5zjsv3w0968/du2yTBryBU228T5O3uhiGMD3UcC69KZSPPIdCqbyOltC85g5NvKNfs7ypdWU8hT49vKZF+Lu9gqG8NwksPJmpgDzy3+q8klnFPNs8HjzQnrA8mYgCPCFGRjwYm7W8ozMkvXQyzTsW+go92BtUvFb/JDz/8Uy8cEKEvOK6CD0EZve6QZtYPNJ6Jbz0Z7u8DycYvGflA704xZI82jIHvNmmHDyn4ZQ6oNoeu8J8xTyCiIe8DGlJO4NihTsl+rE7I1zwOzyyjTzR3IA8J1rgPCUcHLzYo0c7issRPBSPnzocUMs6+x1rPJLLojxrbRq8KBI5PJjygzuuP768Xm3iPID1ZbwS4S+9xU9uO2vg4ro5nIW8gGbnuK/87rm4Arg5F6HgvFCtvTw5LYc8sDjvuxfUMLxnh/m8a57kPLXj+DtLWtS7R23Fu0aKDb0+X0K8T2iBPB89g7shQX29vo6gvB7zLrsVpHK7e0U7u4lid7smMA09sqODu+SG8zwphVu8zfHVOzOchzzIRay8jgE/vV10lDqm3Zy8Rs49PIaFDbs9RFI7zVGtPMq6CDyfQNe8BximvI89MTgnC4s8pYsUPPAD5bv35G28NNQvvd4C1LsO4Vg8T1k5vH2UuLrnJII84/EVOyPoCj1UdkE90U0EvWSeX7ss8pG7lpgOvBYV3Dwy9PE8/RXQO4lWqjytEtG8GCMVvJwi2TzmJu+8e/C+OymYebylRk+8AvQRPCv1DLz2UzS8GqmHvP5+/zy4Kfk7Sg4CvJk6TrpTDrC7XkfQO/Lb8DxTclS8oBx0u3FJHLwoGwa7SLotvfMVF7z+lZ275//oPBgm5ru53RM8oK0RN+U/l7qm7987tqIXvcBBgrtlWTI5TJozODXVBbpNvQW9UqrUPNxZWLzaLLy7nGwDvNCEED1KLiW8JPtJPfRoZzybfSM8ud5RvOvPKT1qubq8JTIgvF3jDruH0ou8L202O9dwmjzJfig8t9zPulx0Rb2ClJE8kgq0u2juYzxUtK07PDmMu2VmmbtYTha97nyPPEsI1zxl5Xu8nCZiPBK4qbyTeLi7zzSJvOt8lbw9UcO7+/etvIIjaz2UARk9X0CXvA9r/jl+coo8QJhFO4YZ6Tzglsc8BH5DvASMMDyG2Zo8+i+yPI6nQLzv6Xo7izaWvOmJujxzhT68uuHnvOqH17zAaha8RTimvDqGAr0iRgA8w5zvO0MlHrmIsMs5T5o5vLCoqDyhGfa8TPuwvGjuljzL8sW8UfsvvJcIO73CoXq8C9iUvN0WPjyV5+671QDxvFhWWDwkvDI8lz8yPARDgjyitKs89EhlvLSA1zvmWv875RHQvKPAsLxZHPa7YFiQOsVyT7v8Z3g8+yuuOyN9FjzWEEw6AfFjvE65lbylngm8c8/QO/NfgDwejwg82DfQu7E5JjpZy5C761WNu0b54DyR+Na8RiQkvO2z6jwWpJm8g/OPPDvilzz2iyA7BRhSvBKfcjyrDYg7SkbuO+N4T7pc7Ic8dWCNPLZ5xryvuSM88Fg0PSirwTwlc7K8tnWQvHDxlTymL7a7pbojvBMoSDwwZ0q86xzGPLnCpzxZDSi8JQyNPOAOI7wcZjy9Al03vRkhqbx8Chc8rGfDPNKh/bt4d/C8sQgLvT4lFTuknLQ8rkBWPAdq+zs65/q8mV3/ux4PyzuMgPs7xKQ0PLQuODx2Md07VVwcO+lDiDw+Eo08ZejKuwdOdzywAQs9TZBZvHCxhLuyUtU7yf+tO8gNyDv6RMy6xZAJvDtbbDsK/TW8cFvAujotZTzxNkg8HCGuvPbDjLxlmA88RKoyPeeztbyPyi+6zPHRujcFvrxxFuY7u/xrvDS1tLtzfUE5XAOavDi14LzmrBA7EIj+u/wZqjtjEKU8Or+evDu+mbw/IP+7NPiWO/uVmTwU/8U5vL8rvaHBCLzJIlO7uOLJvAXO2zweqcm8syH7PBUJurtpKYA8SvHBPEWbVrwiK4q8h3kaPeEs2TyuC0k8BKuVuyBHrLsgbma9Ht01PcouFDu8BLa8roSOPBCAwDzsLxQ8gLobu4szjrwHzc28ejW2PKY2/7xJP+q8NKTqOTDC4Twt580857bFvP8QoTv7CgM90vCPPNC9ZTzXvx+6Xf19uxNxljq7hbI83kxUOyCL07qkyQm9rtejuz93/bwR9o28XeRIvEdzAb1Kfsu7NUKMu42cC718Ywa8vvu6O4pwpLwAz9S8anTUOLHlu7veDlm8GyPLu2m2BrwQm688GqpzvAV3+jwWOgO8DUD4vKZosztAL6U840pGvMrlXDtpFsc8vEq/O2gA8LxMTSy9W7emu6esN7qxADU7UCClvFHSyTrX66k7GPhsPEWqibk4eti8UILWuIQP0bxpAVu7gYzyPPhVQLu884y86ceWuqQZeDyN6ga9RkgNvfki8jx2VLo5m0dAvCYkqTyU5l+8u2mJPEzSzTyyMLu80qOgvAlcDrva1Zo8p7EBvHtEpDzcZ7i7+Yn8utLacjvWE6u7Q4M+PISkoTkYY/k712uYvJuc37yw6ty8jvajOzI2v7va3Ji8zIuLO48A67suIQy8Rub1vLnMxTzHeqa7UeBpvFskB71Tp527AbmDPKZWAT1bbbK7ImDFu+fGxjz1LKy8zUP8PCO1f7wV+gc7agVWOwWI1jyfJu27sT7WOy1ElbwtLFC8WtdNPXnuRjsO8ow8faQTPNhI/rzTGLs7UeB2PGM/5jzdQS88YsgKvXiqljx/ORW9z/oQvBCHLj1Ip1a8Ddedu3mB07yC75g6UKgfO68QWTxmh8e7Rmj4O6z05LzW0168FD04vHAdEDzuj+E8meWLO4En2Dz8z168jLxtOmgS+7pI+Jk81UxFO283ZDyWSkW8PcSrvIOYezyz+HQ8qDLtOgACnzzVnq48RAApPLQFLDzfKAe8VRMLPe9vCb3qWz+8qykOOofm/bzba+g8opDlPKeJNrzjZiq8pECrPMLBpjw2Voi7hSZZOvoN07pLMh69YEPKvIEAVrzZEs687rnKvAKcEztzxOO7QBOMPActFLvW3qM8Gy4Uuzg1S7sd5va7+9w0vHv7qLxBWBu8b++kujeDlrpbBoK7UfQevOdpm7yu+Uq8EptZO6FEhD1zfgk9i9XNOpEOxLxaAEs9U3HWvPK4oTxtNZk8/gwWPS8VK7xPA/O856dOO7cvobyuFiA9oqmHvJXM/DyczVI8zqChu/hFTD3kOAu9hO4mvAMPxbr00UO85rqUvHp5Gb2V7x49j9NSPN78Ej3wJ/u6bUafvCdttbrmzgw8VJnpPNbdhLsGYok8xt0uPF87ATwUude79oItPMGPaz0LISc9PFfdPIDzj7xb/WQ7LPzYuxYBqrwBVgE9EIkbPaN4Qbwz/Gg7X43nPEaF8jzOrmU7HDffu0769byv5Qe77AblO+QBdbysw9m7nyNmvNh7O7ydQEw8ljJQvUKU6byqbiA8JFSDPEm+QTxsR+w8XM6EvEVHZTzrDwY9+LqyvFKjVDv3Miu8Y2S/PKoC0TzoCAy8mZMnPOGrGzs6N727UMWmu7S/D71EhCm8sfdbvCDvc7wlaOs8at7sO5r1WD2y5Qg7T2qnPGOxeLnGYKK8Ml8CPRjQGTzPUtc7V2iYu8hXhrwjvG69Y2VfvIyZ2btgcTC7ax1Mu1FL07y2vuo8vThcO/I9NDyoOUw7O1Lou3zmCTskj6E8TSGPvLJKdbsUdj88XjKoOwyVJjuO4Ok7h7RKPJLu8jz32NW8hef6uhuYE73EbBA99vlZvDNi2zzs3Q087QBUvO9X0rxiKaU87GwXvGyxwzybUP87cVsCvThdnbvShgI8AVryO48LxTxPalC8yOWnvPFzwDu5hyW8ufsFvFDwIbxaegu9VWkAvE8WS7zlmsu8q7FUvD8DBD2Xbuc7ErC2u0nYODtod6g8kj0tPMnHCDwovv07+jjpPHvxG7t+umm8hWQdPESzpLzB4N+8TallO6q9L7u1YKq8PxQfOw+LorvFQGi6+vgXvI6QSTzDKS88pZEWvPvx5Duboc+8vR8uPDvk4DwkDiC7pUYmPRXjCrwxNfI6RhL1POlbiryI9iu8X1krvLFbEbxvOfQ7I+aZPAU3x7xsYTE8TrtVPAxx9Lu7jG48m9SZPHsFBD2tqp08U64jPHSTsTuMpay8EkkEvRiO+zwUPEa8DwiCvDnXMDxMRuk7eq8Pu0FYbDxCsbm6fgwDPZNuSTuuW6Q8pxisPL0jkDzykTg6PGtlPBZX/LsSm9U8hIUAu9+Iarx7ogW8ULY2vYu2dbzmQsK713JavNPbmzya5qm8ixh2vN8pQLzaV7m77yBROzMLxDxczso8fogSvW6oyrtXbpe8nFAfPGKFCLxEz+08/LUpvFdsAr0izYy8O1u8vI9EkDyThpC8ghjNO+BTALy0F2U8DIL8vPETyLxt63s730LLPIMxqLzra4u7SnhHPPruEzwDEKe88pT8u0zEIzuvn9M83Zu0POegvrxhCXG88fq/u3GqRj0e39K8h1r/u18XaLmtrTQ8XB0IPObay7w0S5c8axc5vNm++jzeR1E85m5LO5oWBbzT5Uo7TSGSPMxcRjyJMWU8x6gsvFCCfTwE+e07SfWSPGniGzyKZ6g8BuOjuyRAzbySn5K83Cruust4FDuVVGa82gyVPI3VZjwmmgO81qQ1PLKeh7yw/a07Be+OuoDBQ7xmkbK7YAnZvEhZZDtTqny8fSdIPOsneDzwtgc9K9qnPGsvYDwIvSQ7InNwu/gNhDpLWrW8SdiIPICTjrzZ/ts8KMCCvHdVbjxwtgi8C+FmvFYzajwMDP27gtrwO9PML7woL5O5CsBZPBp3WbzGRdS6wMpzvFOmDLuvIJS8+XkDPOlIBzxZXam8ZaCIPDGRETwhR0k8O9t+u2g6hzuYfmA7+xOpO9gfhbyfjAG8sYpJvPictzxD4zm9LX1SvKdpYjw1ziW9nnJ5PHxj0js0qEW801nxvIB4MbzlaYs8us2YPL57W7tlyZM8MS0ePGsIhTyIjLq7z+YHvZRN17yBG0w7e3gNvarqi7y/ml+8Pi4LO8JIwDvqClo8w2rEu7uFHL15xiI8SAWiuw==
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 3
+ total_tokens: 3
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/cassettes/test_sandbox/TestSandboxVFS.test_metadata_json.yaml b/tests/cassettes/test_sandbox/TestSandboxVFS.test_metadata_json.yaml
new file mode 100644
index 00000000..b6252ae6
--- /dev/null
+++ b/tests/cassettes/test_sandbox/TestSandboxVFS.test_metadata_json.yaml
@@ -0,0 +1,42 @@
+interactions:
+- request:
+ headers:
+ accept:
+ - application/json
+ accept-encoding:
+ - gzip, deflate, zstd
+ connection:
+ - keep-alive
+ content-length:
+ - '82'
+ content-type:
+ - application/json
+ host:
+ - localhost:11434
+ method: POST
+ parsed_body:
+ encoding_format: base64
+ input:
+ - Test content
+ model: qwen3-embedding:4b
+ uri: http://localhost:11434/v1/embeddings
+ response:
+ headers:
+ content-type:
+ - application/json
+ transfer-encoding:
+ - chunked
+ parsed_body:
+ data:
+ - embedding: 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
+ index: 0
+ object: embedding
+ model: qwen3-embedding:4b
+ object: list
+ usage:
+ prompt_tokens: 3
+ total_tokens: 3
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/tests/chat/test_chat_app.py b/tests/chat/test_chat_app.py
index dcfd572a..84324bb1 100644
--- a/tests/chat/test_chat_app.py
+++ b/tests/chat/test_chat_app.py
@@ -57,7 +57,7 @@ def _make_app(db_path: Path, mock_client: AsyncMock | None = None):
return ChatApp(
db_path=db_path,
- skill=skill,
+ skills=[skill],
read_only=True,
), mock_client
@@ -80,7 +80,7 @@ def _make_app_with_state(db_path: Path, mock_client: AsyncMock | None = None):
return ChatApp(
db_path=db_path,
- skill=skill,
+ skills=[skill],
read_only=True,
), mock_client
diff --git a/tests/skills/test_analysis.py b/tests/skills/test_analysis.py
new file mode 100644
index 00000000..e2b66799
--- /dev/null
+++ b/tests/skills/test_analysis.py
@@ -0,0 +1,194 @@
+from haiku.rag.config.models import AppConfig
+from haiku.rag.skills.analysis import (
+ STATE_NAMESPACE,
+ STATE_TYPE,
+ AnalysisState,
+ instructions,
+ skill_metadata,
+ state_metadata,
+)
+from haiku.skills.models import SkillMetadata, StateMetadata
+
+from .conftest import _get_tool, _make_ctx
+
+
+class TestAnalysisModuleAPI:
+ def test_state_type_is_analysis_state(self):
+ assert STATE_TYPE is AnalysisState
+
+ def test_state_namespace(self):
+ assert STATE_NAMESPACE == "analysis"
+
+ def test_state_metadata_returns_state_metadata(self):
+ result = state_metadata()
+ assert isinstance(result, StateMetadata)
+ assert result.namespace == "analysis"
+ assert result.type is AnalysisState
+ assert result.schema == AnalysisState.model_json_schema()
+
+ def test_skill_metadata_returns_skill_metadata(self):
+ result = skill_metadata()
+ assert isinstance(result, SkillMetadata)
+ assert result.name == "rag-analysis"
+
+ def test_instructions_returns_string(self):
+ result = instructions()
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+ def test_constants_match_create_skill(self, test_app_config, temp_db_path):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(config=test_app_config, db_path=temp_db_path)
+ assert skill.state_type is STATE_TYPE
+ assert skill.state_namespace == STATE_NAMESPACE
+ assert skill.metadata == skill_metadata()
+ assert skill.instructions == instructions()
+
+
+class TestAnalysisSkillCreation:
+ def test_create_skill_returns_valid_skill(self, test_app_config, temp_db_path):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(config=test_app_config, db_path=temp_db_path)
+ assert skill.metadata.name == "rag-analysis"
+ assert skill.metadata.description
+ assert skill.instructions
+
+ def test_create_skill_has_expected_tools(self, test_app_config, temp_db_path):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(config=test_app_config, db_path=temp_db_path)
+ tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)}
+ assert tool_names == {"search", "list_documents", "execute_code", "cite"}
+
+ def test_create_skill_has_state(self, test_app_config, temp_db_path):
+ from haiku.rag.skills.analysis import AnalysisState, create_skill
+
+ skill = create_skill(config=test_app_config, db_path=temp_db_path)
+ assert skill._state_type is AnalysisState
+ assert skill._state_namespace == "analysis"
+
+ def test_create_skill_has_extras(self, test_app_config, temp_db_path):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(config=test_app_config, db_path=temp_db_path)
+ assert skill.extras["config"] is test_app_config
+ assert skill.extras["db_path"] is temp_db_path
+ assert "visualize_chunk" in skill.extras
+ assert "list_documents" in skill.extras
+
+ def test_create_skill_from_env(self, monkeypatch, temp_db_path):
+ monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path))
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill()
+ assert skill.metadata.name == "rag-analysis"
+
+
+class TestDomainPreambleInAnalysisSkillInstructions:
+ def test_create_skill_without_domain_preamble(self, test_app_config, temp_db_path):
+ from haiku.rag.skills.analysis import create_skill, instructions
+
+ skill = create_skill(config=test_app_config, db_path=temp_db_path)
+ assert skill.instructions == instructions()
+
+ def test_create_skill_with_domain_preamble(self, temp_db_path):
+ from haiku.rag.config.models import PromptsConfig
+ from haiku.rag.skills.analysis import create_skill, instructions
+
+ config = AppConfig(
+ prompts=PromptsConfig(
+ domain_preamble="This knowledge base contains Helios solar panel documentation."
+ )
+ )
+ skill = create_skill(config=config, db_path=temp_db_path)
+ assert skill.instructions is not None
+ assert skill.instructions.startswith(
+ "This knowledge base contains Helios solar panel documentation."
+ )
+ base_instructions = instructions()
+ assert base_instructions is not None
+ assert base_instructions in skill.instructions
+
+
+class TestExecuteCodeTool:
+ async def test_execute_code_returns_output(self, rag_db):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(db_path=rag_db)
+ execute_code = _get_tool(skill, "execute_code")
+ state = AnalysisState()
+ ctx = _make_ctx(state)
+ result = await execute_code(ctx, code="print('hello')")
+ assert "hello" in result
+
+ async def test_execute_code_updates_state(self, rag_db):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(db_path=rag_db)
+ execute_code = _get_tool(skill, "execute_code")
+ state = AnalysisState()
+ ctx = _make_ctx(state)
+ await execute_code(ctx, code="print('hello')")
+ assert len(state.executions) == 1
+ assert state.executions[0].code == "print('hello')"
+ assert state.executions[0].success is True
+ assert "hello" in state.executions[0].stdout
+
+ async def test_execute_code_reports_errors(self, rag_db):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(db_path=rag_db)
+ execute_code = _get_tool(skill, "execute_code")
+ state = AnalysisState()
+ ctx = _make_ctx(state)
+ result = await execute_code(ctx, code="x = 1/0")
+ assert "Error" in result
+ assert "ZeroDivisionError" in result
+ assert state.executions[0].success is False
+
+ async def test_execute_code_applies_document_filter(self, rag_db):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(db_path=rag_db)
+ execute_code = _get_tool(skill, "execute_code")
+ state = AnalysisState(document_filter="title = 'AI Overview'")
+ ctx = _make_ctx(state)
+ result = await execute_code(
+ ctx, code="docs = await list_documents()\nprint(len(docs))"
+ )
+ assert "1" in result
+
+ async def test_execute_code_accumulates_search_results(self, rag_db):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(db_path=rag_db)
+ execute_code = _get_tool(skill, "execute_code")
+ state = AnalysisState()
+ ctx = _make_ctx(state)
+ await execute_code(
+ ctx, code="results = await search('intelligence')\nprint(len(results))"
+ )
+ assert "_sandbox" in state.searches
+ assert len(state.searches["_sandbox"]) > 0
+
+ async def test_execute_code_vfs_write_denied(self, rag_db):
+ from haiku.rag.skills.analysis import create_skill
+
+ skill = create_skill(db_path=rag_db)
+ execute_code = _get_tool(skill, "execute_code")
+ state = AnalysisState()
+ ctx = _make_ctx(state)
+ result = await execute_code(
+ ctx,
+ code=(
+ "from pathlib import Path\n"
+ "import json\n"
+ "dirs = list(Path('/documents').iterdir())\n"
+ "p = dirs[0] / 'content.txt'\n"
+ "p.write_text('hacked')"
+ ),
+ )
+ assert "Error" in result
+ assert "read-only" in result
diff --git a/tests/skills/test_rag.py b/tests/skills/test_rag.py
index 85ea7448..6fee0262 100644
--- a/tests/skills/test_rag.py
+++ b/tests/skills/test_rag.py
@@ -1,7 +1,3 @@
-from unittest.mock import AsyncMock
-
-from haiku.rag.agents.research.models import Citation, ResearchReport
-from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
from haiku.rag.skills.rag import (
STATE_NAMESPACE,
@@ -12,8 +8,6 @@ from haiku.rag.skills.rag import (
state_metadata,
)
from haiku.rag.store.models.chunk import SearchResult
-from haiku.rag.tools.document import DocumentInfo
-from haiku.rag.tools.qa import QAHistoryEntry
from haiku.skills.models import SkillMetadata, StateMetadata
from .conftest import _get_tool, _make_ctx
@@ -116,13 +110,7 @@ class TestRAGSkillCreation:
skill = create_skill(config=test_app_config, db_path=temp_db_path)
tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)}
- assert tool_names == {
- "search",
- "list_documents",
- "get_document",
- "ask",
- "research",
- }
+ assert tool_names == {"search", "list_documents", "get_document", "cite"}
def test_create_skill_has_state(self, test_app_config, temp_db_path):
from haiku.rag.skills.rag import RAGState, create_skill
@@ -139,8 +127,6 @@ class TestRAGSkillCreation:
assert skill.extras["db_path"] is temp_db_path
assert "visualize_chunk" in skill.extras
assert "list_documents" in skill.extras
- assert callable(skill.extras["visualize_chunk"])
- assert callable(skill.extras["list_documents"])
def test_create_skill_from_env(self, monkeypatch, temp_db_path):
monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path))
@@ -169,44 +155,6 @@ class TestSkillExtras:
assert len(results) == 1
assert results[0]["title"] == "AI Overview"
- async def test_visualize_chunk_unknown_returns_empty(
- self,
- test_app_config,
- rag_db,
- ):
- from haiku.rag.skills.rag import create_skill
-
- skill = create_skill(config=test_app_config, db_path=rag_db)
- visualize = skill.extras["visualize_chunk"]
- result = await visualize("nonexistent-chunk-id")
- assert result == []
-
- async def test_visualize_chunk_returns_images(
- self,
- test_app_config,
- rag_db,
- monkeypatch,
- ):
- from haiku.rag.client import HaikuRAG
- from haiku.rag.skills.rag import create_skill
-
- monkeypatch.setattr(
- HaikuRAG, "visualize_chunk", AsyncMock(return_value=["img1"])
- )
-
- skill = create_skill(config=test_app_config, db_path=rag_db)
- visualize = skill.extras["visualize_chunk"]
-
- # Get a real chunk_id from the db
- async with HaikuRAG(rag_db, read_only=True) as rag:
- docs = await rag.list_documents()
- doc = await rag.get_document_by_id(docs[0].id)
- chunks = await rag.chunk_repository.get_by_document_id(doc.id)
- chunk_id = str(chunks[0].id)
-
- result = await visualize(chunk_id)
- assert result == ["img1"]
-
class TestSearchTool:
async def test_search_returns_formatted_string(self, rag_db):
@@ -252,6 +200,23 @@ class TestSearchTool:
result = await search(ctx, query="artificial intelligence")
assert isinstance(result, str)
+ async def test_search_rate_limited(self, rag_db):
+ from haiku.rag.skills.rag import RAGState, create_skill
+
+ config = AppConfig()
+ config.qa.max_searches = 2
+ skill = create_skill(db_path=rag_db, config=config)
+ search = _get_tool(skill, "search")
+ state = RAGState()
+ ctx = _make_ctx(state)
+ ctx.run_id = "test-run"
+
+ await search(ctx, query="first")
+ await search(ctx, query="second")
+ result = await search(ctx, query="third")
+ assert "Search limit reached" in result
+ assert len(state.searches) == 2
+
class TestListDocumentsTool:
async def test_list_documents_returns_results(self, rag_db):
@@ -264,18 +229,6 @@ class TestListDocumentsTool:
assert isinstance(results, list)
assert len(results) == 2
- async def test_list_documents_updates_state(self, rag_db):
- from haiku.rag.skills.rag import RAGState, create_skill
-
- skill = create_skill(db_path=rag_db)
- list_docs = _get_tool(skill, "list_documents")
- state = RAGState()
- ctx = _make_ctx(state)
- await list_docs(ctx)
- assert len(state.documents) == 2
- assert isinstance(state.documents[0], DocumentInfo)
- assert state.documents[0].id is not None
-
async def test_list_documents_applies_document_filter_from_state(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
@@ -287,17 +240,6 @@ class TestListDocumentsTool:
assert len(results) == 1
assert results[0]["title"] == "AI Overview"
- async def test_list_documents_no_duplicates_in_state(self, rag_db):
- from haiku.rag.skills.rag import RAGState, create_skill
-
- skill = create_skill(db_path=rag_db)
- list_docs = _get_tool(skill, "list_documents")
- state = RAGState()
- ctx = _make_ctx(state)
- await list_docs(ctx)
- await list_docs(ctx)
- assert len(state.documents) == 2
-
class TestGetDocumentTool:
async def test_get_document_by_title(self, rag_db):
@@ -310,18 +252,6 @@ class TestGetDocumentTool:
assert result is not None
assert result["title"] == "AI Overview"
- async def test_get_document_updates_state(self, rag_db):
- from haiku.rag.skills.rag import RAGState, create_skill
-
- skill = create_skill(db_path=rag_db)
- get_doc = _get_tool(skill, "get_document")
- state = RAGState()
- ctx = _make_ctx(state)
- await get_doc(ctx, query="AI Overview")
- assert len(state.documents) == 1
- assert isinstance(state.documents[0], DocumentInfo)
- assert state.documents[0].title == "AI Overview"
-
async def test_get_document_not_found(self, rag_db):
from haiku.rag.skills.rag import create_skill
@@ -332,343 +262,57 @@ class TestGetDocumentTool:
assert result is None
-class TestAskTool:
- async def test_ask_returns_answer_with_citations(self, rag_db, monkeypatch):
- from haiku.rag.skills.rag import create_skill
-
- citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="test://ai-overview",
- document_title="AI Overview",
- content="AI is transforming industries.",
- )
- ]
- monkeypatch.setattr(
- HaikuRAG,
- "ask",
- AsyncMock(return_value=("AI transforms industries worldwide.", citations)),
- )
-
- skill = create_skill(db_path=rag_db)
- ask = _get_tool(skill, "ask")
- ctx = _make_ctx()
- result = await ask(ctx, question="What is AI?")
- assert isinstance(result, str)
- assert "AI transforms industries" in result
-
- async def test_ask_updates_state(self, rag_db, monkeypatch):
+class TestCiteTool:
+ async def test_cite_registers_citations(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
- citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="test://ai-overview",
- content="AI content",
- )
- ]
- monkeypatch.setattr(
- HaikuRAG,
- "ask",
- AsyncMock(return_value=("AI transforms industries.", citations)),
- )
-
skill = create_skill(db_path=rag_db)
- ask = _get_tool(skill, "ask")
+ search = _get_tool(skill, "search")
+ cite = _get_tool(skill, "cite")
state = RAGState()
ctx = _make_ctx(state)
- await ask(ctx, question="What is AI?")
+
+ await search(ctx, query="artificial intelligence")
+ chunk_ids = [
+ sr.chunk_id
+ for results in state.searches.values()
+ for sr in results
+ if sr.chunk_id
+ ][:2]
+
+ result = await cite(ctx, chunk_ids=chunk_ids)
+ assert "Registered" in result
assert len(state.citations) == 1
- assert len(state.qa_history) == 1
- assert isinstance(state.qa_history[0], QAHistoryEntry)
- assert state.qa_history[0].question == "What is AI?"
+ assert len(state.citations[0]) == 2
+ assert all(cid in state.citation_index for cid in chunk_ids)
- async def test_ask_assigns_citation_indices(self, rag_db, monkeypatch):
+ async def test_cite_deduplicates_in_index(self, rag_db):
from haiku.rag.skills.rag import RAGState, create_skill
- first_citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="test://doc1",
- content="First.",
- ),
- Citation(
- document_id="d2",
- chunk_id="c2",
- document_uri="test://doc2",
- content="Second.",
- ),
- ]
- second_citations = [
- Citation(
- document_id="d3",
- chunk_id="c3",
- document_uri="test://doc3",
- content="Third.",
- ),
- ]
-
- call_count = 0
-
- async def mock_ask(self, question, **kwargs):
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- return ("Answer 1", first_citations)
- return ("Answer 2", second_citations)
-
- monkeypatch.setattr(HaikuRAG, "ask", mock_ask)
-
skill = create_skill(db_path=rag_db)
- ask = _get_tool(skill, "ask")
+ search = _get_tool(skill, "search")
+ cite = _get_tool(skill, "cite")
state = RAGState()
ctx = _make_ctx(state)
- await ask(ctx, question="First question")
- assert state.citations[0].index == 1
- assert state.citations[1].index == 2
+ await search(ctx, query="artificial intelligence")
+ chunk_ids = [
+ sr.chunk_id
+ for results in state.searches.values()
+ for sr in results
+ if sr.chunk_id
+ ][:1]
- await ask(ctx, question="Second question")
- assert state.citations[2].index == 3
+ await cite(ctx, chunk_ids=chunk_ids)
+ await cite(ctx, chunk_ids=chunk_ids)
+ assert len(state.citation_index) == 1
+ assert len(state.citations) == 2
- async def test_ask_applies_document_filter_from_state(self, rag_db, monkeypatch):
- from haiku.rag.skills.rag import RAGState, create_skill
-
- captured_kwargs = {}
-
- async def mock_ask(self, question, **kwargs):
- captured_kwargs.update(kwargs)
- return ("Answer.", [])
-
- monkeypatch.setattr(HaikuRAG, "ask", mock_ask)
-
- skill = create_skill(db_path=rag_db)
- ask = _get_tool(skill, "ask")
- state = RAGState(document_filter="title = 'AI Overview'")
- ctx = _make_ctx(state)
- await ask(ctx, question="What is AI?")
- assert captured_kwargs.get("filter") == "title = 'AI Overview'"
-
- async def test_ask_includes_prior_qa_context(self, rag_db, monkeypatch):
- import random
-
- from haiku.rag.skills.rag import RAGState, create_skill
- from tests.skills.conftest import VECTOR_DIM
-
- captured_questions = []
-
- async def mock_ask(self, question, **kwargs):
- captured_questions.append(question)
- return ("Answer about AI.", [])
-
- monkeypatch.setattr(HaikuRAG, "ask", mock_ask)
-
- skill = create_skill(db_path=rag_db)
- ask = _get_tool(skill, "ask")
-
- # Pre-compute the embedding the fake embedder will produce for "Tell me about AI"
- query_text = "Tell me about AI"
- random.seed(hash(query_text) % (2**32))
- query_embedding = [random.random() for _ in range(VECTOR_DIM)]
-
- prior_citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="test://ai-overview",
- document_title="AI Overview",
- content="AI content from source.",
- )
- ]
- state = RAGState(
- qa_history=[
- QAHistoryEntry(
- question="What is artificial intelligence?",
- answer="AI is the simulation of human intelligence by machines.",
- question_embedding=query_embedding,
- citations=prior_citations,
- ),
- ]
- )
- ctx = _make_ctx(state)
- await ask(ctx, question=query_text)
-
- # rag.ask() should receive augmented question with prior context
- assert len(captured_questions) == 1
- augmented = captured_questions[0]
- assert "Context from prior questions" in augmented
- assert "What is artificial intelligence?" in augmented
- assert "AI is the simulation" in augmented
- assert "AI Overview" in augmented
- assert query_text in augmented
-
- # State should store the original question, not the augmented one
- assert state.qa_history[-1].question == query_text
-
- async def test_ask_embeds_prior_qa_on_demand(self, rag_db, monkeypatch):
- from haiku.rag.skills.rag import RAGState, create_skill
- from tests.skills.conftest import VECTOR_DIM
-
- captured_questions = []
-
- async def mock_ask(self, question, **kwargs):
- captured_questions.append(question)
- return ("Answer about AI.", [])
-
- monkeypatch.setattr(HaikuRAG, "ask", mock_ask)
-
- skill = create_skill(db_path=rag_db)
- ask = _get_tool(skill, "ask")
-
- # Use the same question text for the prior QA entry and query so
- # their fake embeddings are identical (cosine similarity = 1.0).
- prior_question = "Tell me about AI"
- query_text = prior_question
-
- # Leave question_embedding=None to exercise the lazy embedding path
- state = RAGState(
- qa_history=[
- QAHistoryEntry(
- question=prior_question,
- answer="AI is the simulation of human intelligence by machines.",
- question_embedding=None,
- ),
- ]
- )
- ctx = _make_ctx(state)
- await ask(ctx, question=query_text)
-
- # The lazy embedding should have populated question_embedding
- assert state.qa_history[0].question_embedding is not None
- assert len(state.qa_history[0].question_embedding) == VECTOR_DIM
-
- # The augmented question should include prior context
- assert len(captured_questions) == 1
- assert "Context from prior questions" in captured_questions[0]
- assert prior_question in captured_questions[0]
-
- async def test_ask_no_prior_qa_context_when_irrelevant(self, rag_db, monkeypatch):
- from haiku.rag.skills.rag import RAGState, create_skill
- from tests.skills.conftest import VECTOR_DIM
-
- captured_questions = []
-
- async def mock_ask(self, question, **kwargs):
- captured_questions.append(question)
- return ("Answer.", [])
-
- monkeypatch.setattr(HaikuRAG, "ask", mock_ask)
-
- skill = create_skill(db_path=rag_db)
- ask = _get_tool(skill, "ask")
-
- # Use orthogonal embedding — won't match the fake embedder's output
- orthogonal = [1.0 if i % 2 == 0 else -1.0 for i in range(VECTOR_DIM)]
- state = RAGState(
- qa_history=[
- QAHistoryEntry(
- question="What is the weather?",
- answer="It is sunny today.",
- question_embedding=orthogonal,
- ),
- ]
- )
- ctx = _make_ctx(state)
- await ask(ctx, question="Explain quantum computing")
-
- # rag.ask() should receive the original question unchanged
- assert len(captured_questions) == 1
- assert captured_questions[0] == "Explain quantum computing"
-
-
-class TestResearchTool:
- async def test_research_returns_report(self, rag_db, monkeypatch):
+ async def test_cite_without_state(self, rag_db):
from haiku.rag.skills.rag import create_skill
- report = ResearchReport(
- title="AI Research",
- executive_summary="AI is transforming industries.",
- main_findings=["Finding 1"],
- conclusions=["Conclusion 1"],
- sources_summary="Multiple sources consulted.",
- )
- monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report))
-
skill = create_skill(db_path=rag_db)
- research = _get_tool(skill, "research")
- ctx = _make_ctx()
- result = await research(ctx, question="What is AI?")
- assert isinstance(result, str)
- assert "AI Research" in result
-
- async def test_research_updates_state(self, rag_db, monkeypatch):
- from haiku.rag.skills.rag import RAGState, create_skill
-
- report = ResearchReport(
- title="AI Research",
- executive_summary="AI is transforming industries.",
- main_findings=["Finding 1"],
- conclusions=["Conclusion 1"],
- sources_summary="Multiple sources consulted.",
- )
- monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report))
-
- skill = create_skill(db_path=rag_db)
- research = _get_tool(skill, "research")
- state = RAGState()
- ctx = _make_ctx(state)
- await research(ctx, question="What is AI?")
- assert len(state.reports) == 1
- assert state.reports[0].question == "What is AI?"
- assert len(state.qa_history) == 1
- assert state.qa_history[0].question == "What is AI?"
- assert state.qa_history[0].answer == "AI is transforming industries."
-
- async def test_research_applies_document_filter_from_state(
- self, rag_db, monkeypatch
- ):
- from haiku.rag.skills.rag import RAGState, create_skill
-
- captured_kwargs = {}
-
- report = ResearchReport(
- title="AI Research",
- executive_summary="Summary.",
- main_findings=["Finding"],
- conclusions=["Conclusion"],
- sources_summary="Sources.",
- )
-
- async def mock_research(self, question, **kwargs):
- captured_kwargs.update(kwargs)
- return report
-
- monkeypatch.setattr(HaikuRAG, "research", mock_research)
-
- skill = create_skill(db_path=rag_db)
- research = _get_tool(skill, "research")
- state = RAGState(document_filter="title = 'AI Overview'")
- ctx = _make_ctx(state)
- await research(ctx, question="What is AI?")
- assert captured_kwargs.get("filter") == "title = 'AI Overview'"
-
- async def test_research_without_state(self, rag_db, monkeypatch):
- from haiku.rag.skills.rag import create_skill
-
- report = ResearchReport(
- title="AI Research",
- executive_summary="Summary.",
- main_findings=["Finding"],
- conclusions=["Conclusion"],
- sources_summary="Sources.",
- )
- monkeypatch.setattr(HaikuRAG, "research", AsyncMock(return_value=report))
-
- skill = create_skill(db_path=rag_db)
- research = _get_tool(skill, "research")
+ cite = _get_tool(skill, "cite")
ctx = _make_ctx(state=None)
- result = await research(ctx, question="What is AI?")
- assert isinstance(result, str)
+ result = await cite(ctx, chunk_ids=["nonexistent"])
+ assert "No state" in result
diff --git a/tests/skills/test_rlm.py b/tests/skills/test_rlm.py
deleted file mode 100644
index 82cf41af..00000000
--- a/tests/skills/test_rlm.py
+++ /dev/null
@@ -1,226 +0,0 @@
-from unittest.mock import AsyncMock
-
-from haiku.rag.agents.rlm.models import RLMResult
-from haiku.rag.client import HaikuRAG
-from haiku.rag.config.models import AppConfig
-from haiku.rag.skills.rlm import (
- STATE_NAMESPACE,
- STATE_TYPE,
- RLMState,
- instructions,
- skill_metadata,
- state_metadata,
-)
-from haiku.skills.models import SkillMetadata, StateMetadata
-
-from .conftest import _get_tool, _make_ctx
-
-
-class TestRLMModuleAPI:
- def test_state_type_is_rlm_state(self):
- assert STATE_TYPE is RLMState
-
- def test_state_namespace(self):
- assert STATE_NAMESPACE == "rlm"
-
- def test_state_metadata_returns_state_metadata(self):
- result = state_metadata()
- assert isinstance(result, StateMetadata)
- assert result.namespace == "rlm"
- assert result.type is RLMState
- assert result.schema == RLMState.model_json_schema()
-
- def test_skill_metadata_returns_skill_metadata(self):
- result = skill_metadata()
- assert isinstance(result, SkillMetadata)
- assert result.name == "rag-rlm"
-
- def test_instructions_returns_string(self):
- result = instructions()
- assert isinstance(result, str)
- assert len(result) > 0
-
- def test_constants_match_create_skill(self, test_app_config, temp_db_path):
- from haiku.rag.skills.rlm import create_skill
-
- skill = create_skill(config=test_app_config, db_path=temp_db_path)
- assert skill.state_type is STATE_TYPE
- assert skill.state_namespace == STATE_NAMESPACE
- assert skill.metadata == skill_metadata()
- assert skill.instructions == instructions()
-
-
-class TestRLMSkillCreation:
- def test_create_skill_returns_valid_skill(self, test_app_config, temp_db_path):
- from haiku.rag.skills.rlm import create_skill
-
- skill = create_skill(config=test_app_config, db_path=temp_db_path)
- assert skill.metadata.name == "rag-rlm"
- assert skill.metadata.description
- assert skill.instructions
-
- def test_create_skill_has_expected_tools(self, test_app_config, temp_db_path):
- from haiku.rag.skills.rlm import create_skill
-
- skill = create_skill(config=test_app_config, db_path=temp_db_path)
- tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)}
- assert tool_names == {"analyze"}
-
- def test_create_skill_has_state(self, test_app_config, temp_db_path):
- from haiku.rag.skills.rlm import RLMState, create_skill
-
- skill = create_skill(config=test_app_config, db_path=temp_db_path)
- assert skill._state_type is RLMState
- assert skill._state_namespace == "rlm"
-
- def test_create_skill_has_extras(self, test_app_config, temp_db_path):
- from haiku.rag.skills.rlm import create_skill
-
- skill = create_skill(config=test_app_config, db_path=temp_db_path)
- assert skill.extras["config"] is test_app_config
- assert skill.extras["db_path"] is temp_db_path
- assert "visualize_chunk" in skill.extras
- assert "list_documents" in skill.extras
- assert callable(skill.extras["visualize_chunk"])
- assert callable(skill.extras["list_documents"])
-
- def test_create_skill_from_env(self, monkeypatch, temp_db_path):
- monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path))
- from haiku.rag.skills.rlm import create_skill
-
- skill = create_skill()
- assert skill.metadata.name == "rag-rlm"
-
-
-class TestDomainPreambleInRLMSkillInstructions:
- def test_create_skill_without_domain_preamble(self, test_app_config, temp_db_path):
- from haiku.rag.skills.rlm import create_skill, instructions
-
- skill = create_skill(config=test_app_config, db_path=temp_db_path)
- assert skill.instructions == instructions()
-
- def test_create_skill_with_domain_preamble(self, temp_db_path):
- from haiku.rag.config.models import PromptsConfig
- from haiku.rag.skills.rlm import create_skill, instructions
-
- config = AppConfig(
- prompts=PromptsConfig(
- domain_preamble="This knowledge base contains Helios solar panel documentation."
- )
- )
- skill = create_skill(config=config, db_path=temp_db_path)
- assert skill.instructions is not None
- assert skill.instructions.startswith(
- "This knowledge base contains Helios solar panel documentation."
- )
- base_instructions = instructions()
- assert base_instructions is not None
- assert base_instructions in skill.instructions
-
-
-class TestAnalyzeTool:
- async def test_analyze_returns_result(self, rag_db, monkeypatch):
- from haiku.rag.skills.rlm import create_skill
-
- monkeypatch.setattr(
- HaikuRAG,
- "rlm",
- AsyncMock(return_value=RLMResult(answer="42", program="print(42)")),
- )
-
- skill = create_skill(db_path=rag_db)
- analyze = _get_tool(skill, "analyze")
- ctx = _make_ctx()
- result = await analyze(ctx, question="How many documents?")
- assert isinstance(result, str)
- assert "42" in result
- assert "print(42)" in result
-
- async def test_analyze_updates_state(self, rag_db, monkeypatch):
- from haiku.rag.skills.rlm import RLMState, create_skill
-
- monkeypatch.setattr(
- HaikuRAG,
- "rlm",
- AsyncMock(return_value=RLMResult(answer="42", program="print(42)")),
- )
-
- skill = create_skill(db_path=rag_db)
- analyze = _get_tool(skill, "analyze")
- state = RLMState()
- ctx = _make_ctx(state)
- await analyze(ctx, question="How many documents?")
- assert len(state.analyses) == 1
- assert state.analyses[0].question == "How many documents?"
- assert state.analyses[0].answer == "42"
- assert state.analyses[0].program == "print(42)"
-
- async def test_analyze_applies_document_filter_from_state(
- self, rag_db, monkeypatch
- ):
- from haiku.rag.skills.rlm import RLMState, create_skill
-
- captured_kwargs = {}
-
- async def mock_rlm(self, question, **kwargs):
- captured_kwargs.update(kwargs)
- return RLMResult(answer="42", program="print(42)")
-
- monkeypatch.setattr(HaikuRAG, "rlm", mock_rlm)
-
- skill = create_skill(db_path=rag_db)
- analyze = _get_tool(skill, "analyze")
- state = RLMState(document_filter="title = 'AI Overview'")
- ctx = _make_ctx(state)
- await analyze(ctx, question="How many documents?")
- assert captured_kwargs.get("filter") == "title = 'AI Overview'"
-
- async def test_analyze_combines_state_filter_with_explicit_filter(
- self, rag_db, monkeypatch
- ):
- from haiku.rag.skills.rlm import RLMState, create_skill
-
- captured_kwargs = {}
-
- async def mock_rlm(self, question, **kwargs):
- captured_kwargs.update(kwargs)
- return RLMResult(answer="Result", program="code()")
-
- monkeypatch.setattr(HaikuRAG, "rlm", mock_rlm)
-
- skill = create_skill(db_path=rag_db)
- analyze = _get_tool(skill, "analyze")
- state = RLMState(document_filter="title = 'AI Overview'")
- ctx = _make_ctx(state)
- await analyze(
- ctx,
- question="Count pages",
- filter="uri LIKE '%test%'",
- )
- result_filter = captured_kwargs["filter"]
- assert isinstance(result_filter, str)
- assert "title = 'AI Overview'" in result_filter
- assert "uri LIKE '%test%'" in result_filter
-
- async def test_analyze_with_document_and_filter(self, rag_db, monkeypatch):
- from haiku.rag.skills.rlm import create_skill
-
- captured_kwargs = {}
-
- async def mock_rlm(self, question, **kwargs):
- captured_kwargs.update(kwargs)
- return RLMResult(answer="Result", program="code()")
-
- monkeypatch.setattr(HaikuRAG, "rlm", mock_rlm)
-
- skill = create_skill(db_path=rag_db)
- analyze = _get_tool(skill, "analyze")
- ctx = _make_ctx()
- await analyze(
- ctx,
- question="Count pages",
- document="AI Overview",
- filter="title = 'AI Overview'",
- )
- assert captured_kwargs.get("documents") == ["AI Overview"]
- assert captured_kwargs.get("filter") == "title = 'AI Overview'"
diff --git a/tests/test_context.py b/tests/test_context.py
index 94e062a0..7eb5f1f0 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -49,11 +49,12 @@ class TestMergeRanges:
assert len(merged) == 1
assert merged[0] == (0, 15, [r1, r2])
- def test_adjacent(self):
+ def test_adjacent_stay_separate(self):
r1, r2 = _result(), _result()
merged = _merge_ranges([(0, 5, r1), (6, 10, r2)])
- assert len(merged) == 1
- assert merged[0] == (0, 10, [r1, r2])
+ assert len(merged) == 2
+ assert merged[0] == (0, 5, [r1])
+ assert merged[1] == (6, 10, [r2])
def test_sorts_by_position(self):
r1, r2 = _result(), _result()
@@ -65,7 +66,7 @@ class TestMergeRanges:
class TestExpandOutward:
def test_basic_expansion(self):
items = [_item(i, text=f"{'x' * 100}") for i in range(10)]
- lo, hi = _expand_outward(items, 5, max_items=10, max_chars=500)
+ lo, hi = _expand_outward(items, 5, max_chars=500)
assert lo <= 5
assert hi >= 5
total = sum(
@@ -76,16 +77,9 @@ class TestExpandOutward:
# Should be around 500 chars (may overshoot by one item)
assert total >= 400
- def test_respects_max_items(self):
- items = [_item(i, text="x") for i in range(100)]
- lo, hi = _expand_outward(items, 50, max_items=5, max_chars=999999)
- count = hi - lo + 1
- # May overshoot by 1-2 items due to alternating expansion
- assert count <= 7
-
def test_respects_max_chars(self):
items = [_item(i, text=f"{'x' * 200}") for i in range(20)]
- lo, hi = _expand_outward(items, 10, max_items=999, max_chars=500)
+ lo, hi = _expand_outward(items, 10, max_chars=500)
total = sum(
len(items[i].text)
for i in range(lo, hi + 1)
@@ -96,12 +90,12 @@ class TestExpandOutward:
def test_center_at_start(self):
items = [_item(i) for i in range(10)]
- lo, hi = _expand_outward(items, 0, max_items=5, max_chars=999999)
+ lo, hi = _expand_outward(items, 0, max_chars=999999)
assert lo == 0
def test_center_at_end(self):
items = [_item(i) for i in range(10)]
- lo, hi = _expand_outward(items, 9, max_items=5, max_chars=999999)
+ lo, hi = _expand_outward(items, 9, max_chars=999999)
assert hi == 9
def test_skip_noise_excludes_from_char_count(self):
@@ -113,7 +107,7 @@ class TestExpandOutward:
_item(4, label="footnote", text="f" * 5000),
_item(5, text="d" * 100),
]
- lo, hi = _expand_outward(items, 2, max_items=10, max_chars=500, skip_noise=True)
+ lo, hi = _expand_outward(items, 2, max_chars=500, skip_noise=True)
# Footnotes (5000 chars each) should NOT count toward budget
# So we should expand past them
assert lo <= 0
@@ -125,11 +119,17 @@ class TestExpandOutward:
_item(1, label="document_index", text="x" * 10000),
_item(2, text="b" * 200),
]
- lo, hi = _expand_outward(items, 1, max_items=10, max_chars=500, skip_noise=True)
+ lo, hi = _expand_outward(items, 1, max_chars=500, skip_noise=True)
# Center is noise, should start at 0 chars and expand outward
assert lo == 0
assert hi == 2
+ def test_respects_bounds(self):
+ items = [_item(i, text="x" * 100) for i in range(20)]
+ lo, hi = _expand_outward(items, 10, max_chars=999999, lo_bound=8, hi_bound=12)
+ assert lo == 8
+ assert hi == 12
+
class TestFindExpansionRange:
def _structured_items(self):
@@ -146,36 +146,47 @@ class TestFindExpansionRange:
def test_structured_returns_section(self):
items = self._structured_items()
- lo, hi = _find_expansion_range(
- items, {1}, has_sections=True, max_items=20, max_chars=5000
- )
+ lo, hi = _find_expansion_range(items, {1}, has_sections=True, max_chars=5000)
# Should return the Introduction section (items 0-3)
assert lo == 0
assert hi == 3
def test_structured_different_section(self):
items = self._structured_items()
- lo, hi = _find_expansion_range(
- items, {5}, has_sections=True, max_items=20, max_chars=5000
- )
+ lo, hi = _find_expansion_range(items, {5}, has_sections=True, max_chars=5000)
# Should return the Methods section (items 4-6)
assert lo == 4
assert hi == 6
- def test_structured_large_section_falls_back_to_outward(self):
+ def test_structured_large_section_bounded_by_section(self):
items = [
_item(0, label="section_header", text="Big Section"),
] + [_item(i, text="x" * 1000) for i in range(1, 20)]
# Section has 19 * 1000 = 19000 chars, way over 5000 budget
- lo, hi = _find_expansion_range(
- items, {10}, has_sections=True, max_items=50, max_chars=5000
- )
- # Should NOT return the full section
+ lo, hi = _find_expansion_range(items, {10}, has_sections=True, max_chars=5000)
+ # Should NOT return the full section, but should stay within it
total = sum(
len(items[i].text) for i in range(lo, hi + 1) if items[i].position >= lo
)
assert total < 10000
+ def test_structured_section_with_many_items_returned_whole(self):
+ """A section that fits in char budget is returned even with many items."""
+ items = (
+ [
+ _item(0, label="section_header", text="Section"),
+ ]
+ + [_item(i, text="x" * 200) for i in range(1, 20)]
+ + [
+ _item(20, label="section_header", text="Next"),
+ ]
+ )
+ # Section has 19 * 200 = 3800 chars + header, under 5000 and over min_useful
+ lo, hi = _find_expansion_range(items, {10}, has_sections=True, max_chars=5000)
+ # Should return entire section despite 20 items
+ assert lo == 0
+ assert hi == 19
+
def test_structured_small_section_expands_outward(self):
items = [
_item(0, label="title", text="Paper Title"),
@@ -186,26 +197,20 @@ class TestFindExpansionRange:
_item(5, text="Intro content. " * 50),
]
# Title section (items 0-1) is tiny (~25 chars) < 20% of 5000
- lo, hi = _find_expansion_range(
- items, {0}, has_sections=True, max_items=20, max_chars=5000
- )
+ lo, hi = _find_expansion_range(items, {0}, has_sections=True, max_chars=5000)
# Should expand past the title section into the abstract
assert hi >= 3
def test_unstructured_expands_outward(self):
items = [_item(i, text=f"Paragraph {i}. " * 10) for i in range(10)]
- lo, hi = _find_expansion_range(
- items, {5}, has_sections=False, max_items=20, max_chars=5000
- )
+ lo, hi = _find_expansion_range(items, {5}, has_sections=False, max_chars=5000)
assert lo < 5
assert hi > 5
def test_multiple_matched_positions_uses_center(self):
items = [_item(i, text="x" * 100) for i in range(20)]
- # Match at positions 3 and 7, center should be index for position 5 (median)
- lo, hi = _find_expansion_range(
- items, {3, 7}, has_sections=False, max_items=5, max_chars=999999
- )
+ # Use a char budget that forces partial expansion so center matters
+ lo, hi = _find_expansion_range(items, {3, 7}, has_sections=False, max_chars=500)
center = (lo + hi) // 2
# Center should be around position 5
assert 3 <= center <= 7
@@ -219,9 +224,7 @@ class TestFindExpansionRange:
]
# Section non-noise chars: ~260 chars (items 0,1,3). Under 5000 budget.
# The footnote's 10000 chars should NOT count.
- lo, hi = _find_expansion_range(
- items, {1}, has_sections=True, max_items=20, max_chars=5000
- )
+ lo, hi = _find_expansion_range(items, {1}, has_sections=True, max_chars=5000)
# Should return full section (it fits in budget excluding noise)
assert lo == 0
assert hi == 3
@@ -233,9 +236,7 @@ class TestFindExpansionRange:
_item(2, label="section_header", text="First Section"),
_item(3, text="Section content."),
]
- lo, hi = _find_expansion_range(
- items, {0}, has_sections=True, max_items=20, max_chars=5000
- )
+ lo, hi = _find_expansion_range(items, {0}, has_sections=True, max_chars=5000)
# Match is in preamble section (items 0-1), which is small
# Should expand outward into the first section
assert hi >= 2
@@ -262,7 +263,7 @@ class TestExpandWithItems:
doc_item_refs=["#/texts/999999"],
)
expanded = await expand_with_items(
- rag.document_item_repository, doc.id, [result], 10, 5000
+ rag.document_item_repository, doc.id, [result], 5000
)
assert len(expanded) == 1
assert expanded[0].content == "original"
@@ -312,7 +313,7 @@ class TestExpandWithItems:
doc_item_refs=["#/texts/1"],
)
expanded = await expand_with_items(
- rag.document_item_repository, "doc-1", [result], 10, 5000
+ rag.document_item_repository, "doc-1", [result], 5000
)
assert len(expanded) == 1
# The TOC section's only non-header item is document_index (noise).
@@ -376,7 +377,7 @@ class TestExpandWithItems:
doc_item_refs=["#/texts/1", "#/texts/2", "#/texts/3", "#/texts/4"],
)
expanded = await expand_with_items(
- rag.document_item_repository, "doc-1", [result], 10, 5000
+ rag.document_item_repository, "doc-1", [result], 5000
)
assert len(expanded) == 1
# Expansion produces "Steps\n\nClick\n\n+\n\nAdd a New Service" = 38 chars
diff --git a/tests/test_context_enhancement.py b/tests/test_context_enhancement.py
index 021c8b50..6f59ec7c 100644
--- a/tests/test_context_enhancement.py
+++ b/tests/test_context_enhancement.py
@@ -80,7 +80,6 @@ def small_chunk_config() -> AppConfig:
"""Config with small chunk size to force splitting."""
config = AppConfig()
config.processing.chunk_size = 32
- config.search.max_context_items = 25
config.search.max_context_chars = 10000
return config
@@ -305,33 +304,6 @@ async def test_format_for_agent_output(temp_db_path, small_chunk_config):
assert "Content:" in formatted
-@pytest.mark.vcr()
-async def test_max_items_limit_caps_expansion(temp_db_path):
- """Expansion should respect max_context_items limit."""
- config = AppConfig()
- config.processing.chunk_size = 32
- config.search.max_context_items = 2 # Very restrictive
-
- docling_doc = create_list_document()
-
- async with HaikuRAG(temp_db_path, config=config, create=True) as client:
- doc = await create_document_with_docling(client, docling_doc, "Limit Test")
- assert doc.id is not None
-
- results = await client.search("grapes", limit=1)
- assert len(results) > 0
-
- expanded = await client.expand_context(results)
-
- # With max_items=2, expansion should be limited
- content = expanded[0].content.lower()
- item_count = sum(
- 1 for item in ["apples", "bananas", "oranges", "grapes"] if item in content
- )
- # Should have at most 2 items (the limit)
- assert item_count <= 2, f"Expected at most 2 items, got {item_count}"
-
-
async def test_expand_context_single_item_document(temp_db_path):
"""Test expand_context with a single-item document."""
from haiku.rag.store.models.document import Document
diff --git a/tests/test_skill_generator.py b/tests/test_skill_generator.py
index d76f053b..40fde312 100644
--- a/tests/test_skill_generator.py
+++ b/tests/test_skill_generator.py
@@ -23,9 +23,8 @@ class TestAvailableTools:
"list_documents",
"get_document",
"search",
- "ask",
- "research",
- "analyze",
+ "execute_code",
+ "cite",
}
@@ -43,7 +42,7 @@ class TestValidateTools:
validate_tools(["search"])
def test_valid_multiple_tools(self):
- validate_tools(["list_documents", "get_document", "search", "ask"])
+ validate_tools(["list_documents", "get_document", "search", "cite"])
def test_valid_all_tools(self):
validate_tools(list(AVAILABLE_TOOLS))
@@ -97,7 +96,7 @@ class TestRenderTemplates:
output_dir=tmp_path,
name="recipes",
description="A recipe skill.",
- tool_names=["list_documents", "get_document", "search", "ask"],
+ tool_names=["list_documents", "get_document", "search", "cite"],
)
assert result == tmp_path / "recipes-skill"
assert result.is_dir()
@@ -112,7 +111,7 @@ class TestRenderTemplates:
output_dir=tmp_path,
name="my-recipes",
description="A recipe skill.",
- tool_names=["search", "ask"],
+ tool_names=["search", "cite"],
)
assert result == tmp_path / "my-recipes-skill"
pkg = result / "my_recipes_skill"
@@ -129,11 +128,11 @@ class TestRenderTemplates:
output_dir=tmp_path,
name="docs",
description="A docs skill.",
- tool_names=["search", "ask"],
+ tool_names=["search", "cite"],
)
init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py"
content = init.read_text()
- assert '["search", "ask"]' in content
+ assert '["search", "cite"]' in content
def test_create_skill_tools_called(self, tmp_path):
render_templates(
@@ -151,12 +150,12 @@ class TestRenderTemplates:
output_dir=tmp_path,
name="recipes",
description="A recipe skill.",
- tool_names=["search", "ask"],
+ tool_names=["search", "cite"],
)
init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py"
content = init.read_text()
assert '"search"' in content
- assert '"ask"' in content
+ assert '"cite"' in content
def test_pyproject_toml(self, tmp_path):
render_templates(
@@ -184,24 +183,23 @@ class TestRenderTemplates:
)
skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md"
content = skill_md.read_text()
- assert "**search**" in content
- assert "**ask**" not in content
- assert "**list_documents**" not in content
- assert "**research**" not in content
- assert "**analyze**" not in content
+ assert "### search" in content
+ assert "### cite" not in content
+ assert "### list_documents" not in content
+ assert "### execute_code" not in content
def test_skill_md_includes_all_selected(self, tmp_path):
render_templates(
output_dir=tmp_path,
name="docs",
description="A docs skill.",
- tool_names=["search", "ask", "analyze"],
+ tool_names=["search", "execute_code", "cite"],
)
skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md"
content = skill_md.read_text()
- assert "**search**" in content
- assert "**ask**" in content
- assert "**analyze**" in content
+ assert "search" in content
+ assert "execute_code" in content
+ assert "cite" in content
def test_custom_preamble(self, tmp_path):
render_templates(
@@ -226,23 +224,23 @@ class TestRenderTemplates:
content = init.read_text()
assert 'state_namespace="recipes"' in content
- def test_analyze_state_fields(self, tmp_path):
+ def test_execute_code_state_fields(self, tmp_path):
render_templates(
output_dir=tmp_path,
name="docs",
description="A docs skill.",
- tool_names=["search", "analyze"],
+ tool_names=["search", "execute_code"],
)
init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py"
content = init.read_text()
- assert "analyses" in content
+ assert "executions" in content
def test_imports_from_shared_tools(self, tmp_path):
render_templates(
output_dir=tmp_path,
name="recipes",
description="A recipe skill.",
- tool_names=["search", "ask", "analyze"],
+ tool_names=["search", "execute_code", "cite"],
)
init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py"
content = init.read_text()
@@ -329,7 +327,7 @@ class TestGenerateSkill:
output_dir=tmp_path,
name="recipes",
description="A recipe skill.",
- tool_names=["search", "ask"],
+ tool_names=["search", "cite"],
)
assert result == tmp_path / "recipes-skill"
assets = result / "recipes_skill" / "assets"
@@ -479,7 +477,7 @@ class TestGenerateSkillRemote:
output_dir=tmp_path,
name="recipes",
description="A recipe skill.",
- tool_names=["search", "ask"],
+ tool_names=["search", "cite"],
config_path=config_file,
)
assets = result / "recipes_skill" / "assets"
diff --git a/tests/tools/test_analysis.py b/tests/tools/test_analysis.py
deleted file mode 100644
index 30dd6bbb..00000000
--- a/tests/tools/test_analysis.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import pytest
-
-from haiku.rag.tools.analysis import create_analysis_toolset
-
-
-class TestAnalysisToolset:
- """Tests for create_analysis_toolset."""
-
- def test_create_analysis_toolset_returns_function_toolset(self, analysis_config):
- """create_analysis_toolset returns a FunctionToolset."""
- from pydantic_ai import FunctionToolset
-
- toolset = create_analysis_toolset(analysis_config)
- assert isinstance(toolset, FunctionToolset)
-
- def test_analysis_toolset_has_analyze_tool(self, analysis_config):
- """The toolset includes an 'analyze' tool."""
- toolset = create_analysis_toolset(analysis_config)
- assert "analyze" in toolset.tools
-
- def test_analysis_toolset_custom_tool_name(self, analysis_config):
- """Toolset supports custom tool name."""
- toolset = create_analysis_toolset(analysis_config, tool_name="run_code")
- assert "run_code" in toolset.tools
- assert "analyze" not in toolset.tools
-
-
-@pytest.fixture
-async def analysis_client(temp_db_path):
- """Create a HaikuRAG client for analysis tests."""
- from haiku.rag.client import HaikuRAG
-
- async with HaikuRAG(temp_db_path, create=True) as rag:
- yield rag
-
-
-@pytest.fixture
-def analysis_config():
- """Default AppConfig for analysis tests."""
- from haiku.rag.config import Config
-
- return Config
diff --git a/tests/tools/test_filters.py b/tests/tools/test_filters.py
index de449dd8..85d02290 100644
--- a/tests/tools/test_filters.py
+++ b/tests/tools/test_filters.py
@@ -1,21 +1,16 @@
-from haiku.rag.tools.filters import (
- build_document_filter,
- build_multi_document_filter,
- combine_filters,
-)
+from haiku.rag.tools.filters import _build_document_filter, build_multi_document_filter
def test_build_document_filter_simple():
- """Test build_document_filter with simple name."""
- result = build_document_filter("mytest")
+ """Test _build_document_filter with simple name."""
+ result = _build_document_filter("mytest")
assert "LOWER(uri) LIKE LOWER('%mytest%')" in result
assert "LOWER(title) LIKE LOWER('%mytest%')" in result
def test_build_document_filter_with_spaces():
- """Test build_document_filter handles spaces correctly."""
- result = build_document_filter("TB MED 593")
- # Should include both the original (with spaces) and without spaces
+ """Test _build_document_filter handles spaces correctly."""
+ result = _build_document_filter("TB MED 593")
assert "LOWER(uri) LIKE LOWER('%TB MED 593%')" in result
assert "LOWER(uri) LIKE LOWER('%TBMED593%')" in result
assert "LOWER(title) LIKE LOWER('%TB MED 593%')" in result
@@ -23,9 +18,8 @@ def test_build_document_filter_with_spaces():
def test_build_document_filter_escapes_quotes():
- """Test build_document_filter escapes single quotes."""
- result = build_document_filter("O'Reilly")
- # Single quotes should be doubled for SQL escaping
+ """Test _build_document_filter escapes single quotes."""
+ result = _build_document_filter("O'Reilly")
assert "O''Reilly" in result
@@ -41,7 +35,6 @@ def test_build_multi_document_filter_single():
assert result is not None
assert "LOWER(uri) LIKE LOWER('%mytest%')" in result
assert "LOWER(title) LIKE LOWER('%mytest%')" in result
- # Single document should not have extra wrapping parentheses
assert " OR (" not in result
@@ -49,31 +42,6 @@ def test_build_multi_document_filter_multiple():
"""Test build_multi_document_filter with multiple documents."""
result = build_multi_document_filter(["doc1", "doc2"])
assert result is not None
- # Should have OR-combined filters
assert "doc1" in result
assert "doc2" in result
assert " OR (" in result
-
-
-def test_combine_filters_both_none():
- """Test combine_filters with both None."""
- result = combine_filters(None, None)
- assert result is None
-
-
-def test_combine_filters_first_only():
- """Test combine_filters with only first filter."""
- result = combine_filters("uri = 'test'", None)
- assert result == "uri = 'test'"
-
-
-def test_combine_filters_second_only():
- """Test combine_filters with only second filter."""
- result = combine_filters(None, "title = 'doc'")
- assert result == "title = 'doc'"
-
-
-def test_combine_filters_both():
- """Test combine_filters combines with AND."""
- result = combine_filters("uri = 'test'", "title = 'doc'")
- assert result == "(uri = 'test') AND (title = 'doc')"
diff --git a/tests/tools/test_models.py b/tests/tools/test_models.py
deleted file mode 100644
index 9d055dcc..00000000
--- a/tests/tools/test_models.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from haiku.rag.tools.analysis import AnalysisResult
-
-
-def test_analysis_result_defaults():
- """Test AnalysisResult has sensible defaults."""
- result = AnalysisResult(answer="The result is 42")
- assert result.code_executed is True
-
-
-def test_analysis_result_with_values():
- """Test AnalysisResult with explicit values."""
- result = AnalysisResult(
- answer="The result is 42",
- code_executed=True,
- )
- assert result.answer == "The result is 42"
- assert result.code_executed is True
diff --git a/tests/tools/test_qa.py b/tests/tools/test_qa.py
deleted file mode 100644
index f21aee3a..00000000
--- a/tests/tools/test_qa.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from haiku.rag.agents.research.models import Citation
-from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry
-
-
-class TestQAHistoryEntry:
- """Tests for QAHistoryEntry model."""
-
- def test_defaults(self):
- """QAHistoryEntry has sensible defaults."""
- entry = QAHistoryEntry(question="What is X?", answer="X is Y.")
- assert entry.confidence == 0.9
- assert entry.citations == []
- assert entry.question_embedding is None
-
- def test_sources_property(self):
- """sources returns unique document titles."""
- citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="doc1.md",
- document_title="Document One",
- content="Content 1",
- ),
- Citation(
- document_id="d1",
- chunk_id="c2",
- document_uri="doc1.md",
- document_title="Document One",
- content="Content 2",
- ),
- Citation(
- document_id="d2",
- chunk_id="c3",
- document_uri="doc2.md",
- document_title="Document Two",
- content="Content 3",
- ),
- ]
- entry = QAHistoryEntry(question="Q", answer="A", citations=citations)
- sources = entry.sources
- assert len(sources) == 2
- assert "Document One" in sources
- assert "Document Two" in sources
-
- def test_sources_uses_uri_as_fallback(self):
- """sources uses uri when title is None."""
- citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="test.md",
- document_title=None,
- content="Content",
- ),
- ]
- entry = QAHistoryEntry(question="Q", answer="A", citations=citations)
- assert entry.sources == ["test.md"]
-
- def test_to_search_answer(self):
- """to_search_answer converts to SearchAnswer."""
- citation = Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="doc1.md",
- document_title="Doc",
- content="Content",
- )
- entry = QAHistoryEntry(
- question="What is X?",
- answer="X is Y.",
- confidence=0.85,
- citations=[citation],
- )
- sa = entry.to_search_answer()
- assert sa.query == "What is X?"
- assert sa.answer == "X is Y."
- assert sa.confidence == 0.85
- assert sa.cited_chunks == ["c1"]
- assert len(sa.citations) == 1
-
- def test_question_embedding_excluded_from_serialization(self):
- """question_embedding is excluded from model_dump."""
- entry = QAHistoryEntry(
- question="Q",
- answer="A",
- question_embedding=[0.1, 0.2],
- )
- data = entry.model_dump()
- assert "question_embedding" not in data
-
-
-def test_prior_answer_relevance_threshold():
- """PRIOR_ANSWER_RELEVANCE_THRESHOLD is a sensible value."""
- assert 0 < PRIOR_ANSWER_RELEVANCE_THRESHOLD < 1