Merge pull request #136 from ggozad/feat/ag-ui

AG-UI support in research / deep ask graphs.
This commit is contained in:
Yiorgis Gozadinos 2025-11-13 13:30:41 +02:00 committed by GitHub
commit cb1001dc29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 16448 additions and 16051 deletions

View file

@ -1,15 +1,59 @@
# Changelog
## [Unreleased]
### Added
- **AG-UI Protocol Support**: Full AG-UI (Agent-UI) protocol implementation for graph execution with event streaming
- New `AGUIEmitter` class for emitting AG-UI events from graphs
- Support for all AG-UI event types: lifecycle events (`RUN_STARTED`, `RUN_FINISHED`, `RUN_ERROR`), step events (`STEP_STARTED`, `STEP_FINISHED`), state updates (`STATE_SNAPSHOT`, `STATE_DELTA`), activity narration (`ACTIVITY_SNAPSHOT`), and text messages (`TEXT_MESSAGE_CHUNK`)
- `AGUIConsoleRenderer` for rendering AG-UI event streams to terminal with Rich formatting
- `stream_graph()` utility function for executing graphs with AG-UI event emission
- State diff computation for efficient state synchronization
- **Delta State Updates**: AG-UI emitter now supports incremental state updates via JSON Patch operations (`STATE_DELTA` events) to reduce bandwidth, configurable via `use_deltas` parameter (enabled by default)
- **AG-UI Server**: Starlette-based HTTP server for serving graphs via AG-UI protocol
- Server-Sent Events (SSE) streaming endpoint at `/v1/agent/stream`
- Health check endpoint at `/health`
- Full CORS support configurable via `agui` config section
- `create_agui_server()` function for programmatic server creation
- **Deep QA AG-UI Support**: Deep QA graph now fully supports AG-UI event streaming
- Integration with `AGUIEmitter` for progress tracking
- Step-by-step execution visibility via AG-UI events
- **CLI AG-UI Flag**: New `--agui` flag for `serve` command to start AG-UI server
- **Graph Module**: New unified `haiku.rag.graph` module containing all graph-related functionality
- **Common Graph Nodes**: New factory functions (`create_plan_node`, `create_search_node`) in `haiku.rag.graph.common.nodes` for reusable graph components
- **AG-UI Research Example**: New full-stack example (`examples/ag-ui-research`) demonstrating agent+graph architecture with CopilotKit frontend
- Pydantic AI agent with research tool that invokes the research graph
- Custom AG-UI streaming endpoint with anyio memory streams
- React/Next.js frontend with split-pane UI showing live research state
- Real-time progress tracking of questions, answers, insights, and gaps
- Docker Compose setup for easy local development
### Changed
- **BREAKING**: Major refactoring of graph-related code into unified `haiku.rag.graph` module structure:
- `haiku.rag.research``haiku.rag.graph.research`
- `haiku.rag.qa.deep``haiku.rag.graph.deep_qa`
- `haiku.rag.agui``haiku.rag.graph.agui`
- `haiku.rag.graph_common``haiku.rag.graph.common`
- **BREAKING**: Research and Deep QA graphs now use AG-UI event protocol instead of direct console logging
- Removed `console` and `stream` parameters from graph dependencies
- All progress updates now emit through `AGUIEmitter`
- **BREAKING**: `ResearchState` converted from dataclass to Pydantic `BaseModel` for JSON serialization and AG-UI compatibility
- Research and Deep QA graphs now emit detailed execution events for better observability
- CLI research command now uses AG-UI event rendering for `--verbose` output
- Improved graph execution visibility with step-by-step progress tracking
- Updated all documentation to reflect new import paths and AG-UI usage
- Updated examples (ag-ui-research, a2a-server) to use new import paths
### Fixed
- **Document Creation**: Optimized `create_document` to skip unnecessary DoclingDocument conversion when chunks are pre-provided
- **FileReader**: Error messages now include both original exception details and file path for easier debugging
### Removed
- **BREAKING**: Removed legacy `ResearchStream` and `ResearchStreamEvent` classes (replaced by AG-UI event protocol)
## [0.15.0] - 2025-11-07
### Added

View file

@ -86,12 +86,12 @@ To customize settings, create a `haiku.rag.yaml` config file (see [Configuration
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.research import (
from haiku.rag.graph.agui import stream_graph
from haiku.rag.graph.research import (
ResearchContext,
ResearchDeps,
ResearchState,
build_research_graph,
stream_research_graph,
)
async with HaikuRAG("database.lancedb") as client:
@ -126,15 +126,17 @@ async with HaikuRAG("database.lancedb") as client:
report = await graph.run(state=state, deps=deps)
print(report.title)
# Streaming progress (log/report/error events)
async for event in stream_research_graph(graph, state, deps):
if event.type == "log":
iteration = event.state.iterations if event.state else state.iterations
print(f"[{iteration}] {event.message}")
elif event.type == "report":
# Streaming progress (AG-UI events)
async for event in stream_graph(graph, state, deps):
if event["type"] == "STEP_STARTED":
print(f"Starting step: {event['stepName']}")
elif event["type"] == "ACTIVITY_SNAPSHOT":
print(f" {event['content']}")
elif event["type"] == "RUN_FINISHED":
print("\nResearch complete!\n")
print(event.report.title)
print(event.report.executive_summary)
result = event["result"]
print(result["title"])
print(result["executive_summary"])
```
## MCP Server

View file

@ -96,9 +96,9 @@ Python usage:
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.qa.deep.graph import build_deep_qa_graph
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
async with HaikuRAG(path_to_db) as client:
# Use global config (recommended)
@ -205,9 +205,9 @@ Python usage (blocking result):
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchDeps, ResearchState
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
async with HaikuRAG(path_to_db) as client:
# Use global config (recommended)
@ -250,15 +250,15 @@ deps = ResearchDeps(client=client)
result = await graph.run(state=state, deps=deps)
```
Python usage (streamed events):
Python usage (streamed AG-UI events):
```python
from haiku.rag.graph.agui import stream_graph
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchDeps, ResearchState
from haiku.rag.research.stream import stream_research_graph
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
async with HaikuRAG(path_to_db) as client:
graph = build_research_graph(config=Config)
@ -267,16 +267,14 @@ async with HaikuRAG(path_to_db) as client:
state = ResearchState.from_config(context=context, config=Config)
deps = ResearchDeps(client=client)
async for event in stream_research_graph(
graph,
state,
deps,
):
if event.type == "log":
iteration = event.state.iterations if event.state else state.iterations
print(f"[{iteration}] {event.message}")
elif event.type == "report":
async for event in stream_graph(graph, state, deps):
if event["type"] == "STEP_STARTED":
print(f"Starting step: {event['stepName']}")
elif event["type"] == "ACTIVITY_SNAPSHOT":
print(f" {event['content']}")
elif event["type"] == "RUN_FINISHED":
print("\nResearch complete!\n")
print(event.report.title)
print(event.report.executive_summary)
result = event["result"]
print(result["title"])
print(result["executive_summary"])
```

View file

@ -146,20 +146,23 @@ When available, citations use the document title; otherwise they fall back to th
Run the multi-step research graph:
```bash
haiku-rag research "How does haiku.rag organize and query documents?" \
--max-iterations 2 \
--confidence-threshold 0.8 \
--max-concurrency 3 \
--verbose
haiku-rag research "How does haiku.rag organize and query documents?"
```
With verbose output to see progress:
```bash
haiku-rag research "How does haiku.rag organize and query documents?" --verbose
```
Flags:
- `--max-iterations, -n`: maximum search/evaluate cycles (default: 3)
- `--confidence-threshold`: stop once evaluation confidence meets/exceeds this (default: 0.8)
- `--max-concurrency`: number of sub-questions searched in parallel each iteration (default: 3)
- `--verbose`: show planning, searching previews, evaluation summary, and stop reason
- `--verbose`: Show planning, searching previews, evaluation summary, and stop reason
When `--verbose` is set the CLI also consumes the internal research stream, printing every `log` event as agents progress through planning, search, evaluation, and synthesis. If you build your own integration, call `stream_research_graph` to access the same `log`, `report`, and `error` events and render them however you like while the graph is running.
Research parameters like `max_iterations`, `confidence_threshold`, and `max_concurrency` are configured in your [configuration file](configuration.md) under the `research` section.
When `--verbose` is set, the CLI consumes the research graph's AG-UI event stream, displaying step events and activity snapshots as agents progress through planning, search, evaluation, and synthesis. Without `--verbose`, only the final research report is displayed.
If you build your own integration, import `stream_graph` from `haiku.rag.graph.agui` to access AG-UI events (`STEP_STARTED`, `ACTIVITY_SNAPSHOT`, `STATE_SNAPSHOT`, `RUN_FINISHED`, etc.) and render them however you like while the graph is running.
## Server
@ -174,10 +177,18 @@ haiku-rag serve --mcp --stdio
# File monitoring only
haiku-rag serve --monitor
# Both services
haiku-rag serve --monitor --mcp
# AG-UI server only
haiku-rag serve --agui
# Custom port
# Multiple services
haiku-rag serve --monitor --mcp
haiku-rag serve --monitor --agui
haiku-rag serve --mcp --agui
# All services
haiku-rag serve --monitor --mcp --agui
# Custom MCP port
haiku-rag serve --mcp --mcp-port 9000
```

View file

@ -83,6 +83,17 @@ qa:
research:
provider: "" # Empty to use qa settings
model: ""
max_iterations: 3
confidence_threshold: 0.8
max_concurrency: 1
agui:
host: "0.0.0.0"
port: 8000
cors_origins: ["*"]
cors_credentials: true
cors_methods: ["GET", "POST", "OPTIONS"]
cors_headers: ["*"]
processing:
chunk_size: 256
@ -460,6 +471,52 @@ providers:
**Note:** vLLM reranking uses the `/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded. Consult the specific model's documentation for proper vLLM serving configuration.
## Research Configuration
Configure the multi-agent research workflow:
```yaml
research:
provider: "" # Empty to use qa settings
model: "" # Empty to use qa model
max_iterations: 3 # Maximum search/evaluate cycles
confidence_threshold: 0.8 # Stop when confidence meets/exceeds this
max_concurrency: 1 # Sub-questions searched in parallel per iteration
```
- **provider/model**: LLM provider and model for research. Leave empty to use the same settings as `qa`.
- **max_iterations**: Maximum number of search/evaluate cycles before stopping (default: 3)
- **confidence_threshold**: Stop research when evaluation confidence score meets or exceeds this threshold (default: 0.8)
- **max_concurrency**: Number of sub-questions to search in parallel during each iteration (default: 1)
The research workflow plans sub-questions, searches in parallel batches, evaluates findings, and iterates until reaching the confidence threshold or max iterations.
## AG-UI Server Configuration
Configure the AG-UI HTTP server for streaming graph execution events:
```yaml
agui:
host: "0.0.0.0"
port: 8000
cors_origins: ["*"]
cors_credentials: true
cors_methods: ["GET", "POST", "OPTIONS"]
cors_headers: ["*"]
```
Start the AG-UI server with:
```bash
haiku-rag serve --agui
```
The server exposes:
- `GET /health` - Health check endpoint
- `POST /v1/agent/stream` - Research graph streaming endpoint (Server-Sent Events)
See [Server Mode](server.md) for more details.
## Other Settings
### Database and Storage

View file

@ -1,10 +1,10 @@
# Server Mode
The server provides automatic file monitoring and MCP functionality.
The server provides automatic file monitoring, MCP functionality, and AG-UI graph streaming.
## Starting the Server
The `serve` command requires at least one service flag. You can enable file monitoring, MCP server, or both:
The `serve` command requires at least one service flag. You can enable file monitoring, MCP server, AG-UI server, or any combination:
### MCP Server Only
@ -93,3 +93,122 @@ The server can parse 40+ file formats including:
- And more...
URLs are also supported for web content.
## AG-UI Server
The AG-UI server provides HTTP streaming of both research and deep ask graph execution using Server-Sent Events (SSE).
### Starting the AG-UI Server
```bash
haiku-rag serve --agui
```
This starts an HTTP server (default: http://0.0.0.0:8000) that exposes:
- `GET /health` - Health check endpoint
- `POST /v1/research/stream` - Research graph streaming endpoint
- `POST /v1/deep-ask/stream` - Deep ask graph streaming endpoint
### Configuration
Configure the AG-UI server in your `haiku.rag.yaml`:
```yaml
agui:
host: "0.0.0.0"
port: 8000
cors_origins: ["*"]
cors_credentials: true
```
See [Configuration](configuration.md#ag-ui-server-configuration) for all available options.
### Using the Streaming Endpoints
Both endpoints accept POST requests with the same AG-UI RunAgentInput format and stream AG-UI events.
**Request format:**
```json
{
"threadId": "optional-thread-id",
"runId": "optional-run-id",
"state": {
"question": "What are the key features of haiku.rag?"
},
"messages": [],
"config": {}
}
```
**Research endpoint example:**
```bash
curl -X POST http://localhost:8000/v1/research/stream \
-H "Content-Type: application/json" \
-d '{
"state": {
"question": "What are the key features of haiku.rag?"
}
}' \
--no-buffer
```
**Deep ask endpoint example:**
```bash
curl -X POST http://localhost:8000/v1/deep-ask/stream \
-H "Content-Type: application/json" \
-d '{
"state": {
"question": "How does haiku.rag handle document chunking?",
"use_citations": true
}
}' \
--no-buffer
```
The `--no-buffer` flag ensures curl displays events as they arrive instead of buffering them.
**Note:** The `state` object can include:
- `question`: The question to answer (required)
- `use_citations`: Enable citations in deep ask responses (optional, deep ask only)
**Response:** Server-Sent Events stream with AG-UI protocol events:
- `RUN_STARTED` - Graph execution started
- `STATE_SNAPSHOT` - Current state snapshot
- `STEP_STARTED` - Node execution started
- `STEP_FINISHED` - Node execution completed
- `ACTIVITY_SNAPSHOT` - Progress update
- `RUN_FINISHED` - Graph execution completed with result
- `RUN_ERROR` - Error during execution
Example event output:
```
data: {"type":"RUN_STARTED","threadId":"abc123","runId":"xyz789"}
data: {"type":"STATE_SNAPSHOT","snapshot":{"context":{"original_question":"What are the key features of haiku.rag?"},"iterations":0}}
data: {"type":"STEP_STARTED","stepName":"plan"}
data: {"type":"ACTIVITY_SNAPSHOT","messageId":"msg-1","activityType":"planning","content":"Creating research plan"}
data: {"type":"STEP_FINISHED","stepName":"plan"}
data: {"type":"RUN_FINISHED","threadId":"abc123","runId":"xyz789","result":{"title":"Research Report","executive_summary":"..."}}
```
The endpoint follows the [AG-UI protocol](https://docs.ag-ui.com/concepts/events) for event streaming.
### Running Multiple Services
You can run any combination of services:
```bash
# File monitoring + AG-UI
haiku-rag serve --monitor --agui
# MCP + AG-UI
haiku-rag serve --mcp --agui
# All services
haiku-rag serve --monitor --mcp --agui
```

View file

@ -6,7 +6,7 @@ import logfire
from pydantic_ai import Agent, RunContext
from haiku.rag.config import AppConfig, Config
from haiku.rag.graph_common import get_model
from haiku.rag.graph.common import get_model
from .context import load_message_history, save_message_history
from .models import A2AConfig, AgentDependencies, SearchResult

View file

@ -3,6 +3,11 @@
# Must be an absolute path to an existing database created with haiku-rag
DB_PATH=/absolute/path/to/your/haiku.rag.lancedb
# Ollama API base URL (if using Ollama for local models)
# If running Ollama on your host machine, use your machine's IP address
# that the Docker container can reach (not localhost)
OLLAMA_BASE_URL=http://host.docker.internal:11434
# API keys (set as needed for your QA provider)
# OPENAI_API_KEY=your-key-here
# ANTHROPIC_API_KEY=your-key-here

View file

@ -1,16 +1,16 @@
# Interactive Research Assistant
Research assistant powered by [haiku.rag](https://ggozad.github.io/haiku.rag/), [Pydantic AI](https://ai.pydantic.dev/), and [AG-UI](https://docs.ag-ui.com/). Ask complex questions and watch the research process unfold in real-time.
Research assistant powered by [haiku.rag](https://ggozad.github.io/haiku.rag/), [Pydantic Graph](https://ai.pydantic.dev/graph/), and [AG-UI](https://docs.ag-ui.com/). Ask complex questions and watch the research process unfold in real-time.
[Watch demo video](https://vimeo.com/1128874386)
## Features
- **Multi-step research workflow**: Question decomposition, search, analysis, and synthesis
- **Human-in-the-loop**: Approve or revise research plans before execution
- **Live state synchronization**: Real-time updates of research progress between backend and frontend
- **Context expansion**: Automatically expands top search results for better context
- **Rich reporting**: Generates structured reports with findings, conclusions, and citations
- **Multi-iteration research graph**: Automated question decomposition, search, insight extraction, and gap analysis
- **Intelligent evaluation**: Confidence-based decision making with automatic iteration until sufficient information is gathered
- **Live state synchronization**: Real-time delta updates of research progress via AG-UI protocol
- **Insight & gap tracking**: Structured insights with provenance and automatic gap identification
- **Rich reporting**: Generates comprehensive research reports with findings, conclusions, and sources
## Quick Start
@ -57,32 +57,67 @@ Research assistant powered by [haiku.rag](https://ggozad.github.io/haiku.rag/),
## How It Works
1. **Ask a question**: Type your research question in the chat
2. **Review the plan**: The agent decomposes your question into 3 sub-questions
3. **Approve or revise**: Choose to approve the plan or request changes
4. **Watch it work**: The agent automatically:
- Searches the knowledge base for each sub-question
- Extracts key insights from search results
- Evaluates overall confidence in findings
5. **Get your report**: Receive a structured research report with citations
2. **Plan phase**: The research graph automatically:
- Decomposes your question into targeted sub-questions
- Gathers initial context about the topic
3. **Research iterations**: The graph autonomously:
- Searches the knowledge base for each sub-question in parallel
- Extracts structured insights with source provenance
- Identifies information gaps and assesses confidence
- Generates new follow-up questions for gaps
- Iterates until confidence threshold is met or max iterations reached
4. **Synthesis**: Generates a comprehensive research report with:
- Executive summary
- Main findings with supporting evidence
- Conclusions and recommendations
- Source citations
## Architecture
- **Backend** (Python): Pydantic AI agent with haiku.rag integration
- Uses published `ghcr.io/ggozad/haiku.rag:latest` Docker image as base
- `agent.py`: Research agent with tool definitions
- `main.py`: Starlette app serving AG-UI protocol
### Agent + Graph Pattern
- **Frontend** (Next.js): CopilotKit/AG-UI interface
- Real-time state synchronization with backend
- Interactive approval workflow
- Collapsible research plan and insights display
This example demonstrates the **agent+graph** architecture pattern:
1. **Conversational Agent** (`agent.py`):
- Pydantic AI agent handles user conversations
- Decides when to invoke the research tool based on user intent
- Responds directly to greetings/casual chat without tools
- Formats research results for the user
2. **Research Graph** (haiku.rag):
- Multi-step research workflow invoked by the agent's tool
- Autonomous execution with plan → search → analyze → decide → synthesize flow
- Emits AG-UI events for real-time progress tracking
3. **Shared Event Stream**:
- `AGUIEmitter` is shared between agent and graph
- Events from both flow through a single stream to the frontend
- Custom streaming endpoint (`main.py`) uses anyio memory streams for proper async handling
### Components
- **Backend** (Python):
- Uses published `ghcr.io/ggozad/haiku.rag:latest` Docker image as base
- `agent.py`: Pydantic AI agent with `run_research` tool
- `main.py`: Custom AG-UI streaming endpoint with anyio memory object streams
- Real-time event forwarding from emitter to SSE stream
- Filters out `ACTIVITY_SNAPSHOT` events (not yet supported by CopilotKit)
- **Frontend** (Next.js/React):
- CopilotKit for AG-UI protocol integration
- Split-pane UI: chat on left, live research state on right
- Real-time state synchronization via Server-Sent Events (SSE)
- `StateDisplay` component with collapsible sections for questions, insights, and gaps
## Configuration
Configuration is done through `haiku.rag.yaml` (see `haiku.rag.yaml.example`):
- `qa.provider`: LLM provider (default: `ollama`)
- `qa.model`: Model name (default: `gpt-oss:latest`)
- `research.provider`: LLM provider (default: `ollama`)
- `research.model`: Model name (default: `gpt-oss:latest`)
- `research.max_iterations`: Maximum research iterations (default: `3`)
- `research.confidence_threshold`: Confidence threshold for completion (default: `0.8`)
- `research.max_concurrency`: Parallel sub-question processing (default: `1`)
- `providers.ollama.base_url`: Ollama endpoint (default: `http://host.docker.internal:11434`)
Environment variables (see `.env.example`):

View file

@ -3,15 +3,13 @@ FROM ghcr.io/ggozad/haiku.rag:latest
WORKDIR /app
# Copy backend application files
COPY agent.py main.py ./
COPY pyproject.toml ./
COPY main.py agent.py ./
# Install backend dependencies
# Note: haiku-rag is already installed in the base image
# Install additional dependencies for the example
# Note: haiku-rag-slim is already installed in the base image
RUN pip install --no-cache-dir \
starlette>=0.45.2 \
uvicorn[standard]>=0.34.2 \
pydantic-ai-slim[ag-ui,openai]>=1.1.0 \
python-dotenv>=1.0.1
EXPOSE 8000

View file

@ -1,6 +1,6 @@
# Haiku.rag Research Assistant Backend
FastAPI backend for the haiku.rag interactive research assistant, using Pydantic AI with AG-UI protocol support.
Starlette backend for the haiku.rag interactive research assistant, using the research graph with AG-UI protocol support.
## Setup
@ -11,7 +11,17 @@ uv run python main.py
The server starts on `http://localhost:8000` and uses [haiku.rag configuration](https://ggozad.github.io/haiku.rag/configuration/).
## Architecture
The backend uses `create_agui_server()` from `haiku.rag.graph.agui.server` which provides:
- **Research graph execution**: Multi-iteration research workflow with insight/gap tracking
- **AG-UI protocol**: Server-Sent Events (SSE) streaming for real-time state updates
- **Delta state updates**: Efficient incremental state synchronization using JSON Patch operations
- **Both research and deep_qa endpoints**: `/agent/research` and `/agent/deep_qa`
## Endpoints
- `GET /health` - Health check
- `POST /agent` - AG-UI protocol endpoint
- `GET /health` - Health check with configuration info
- `POST /agent/research/stream` - Research graph streaming endpoint (AG-UI protocol)
- `POST /agent/deep_qa/stream` - Deep QA graph streaming endpoint (AG-UI protocol)

View file

@ -1,432 +1,111 @@
import json
from dataclasses import dataclass
"""Research assistant agent with graph integration."""
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from ag_ui.core import EventType, StateSnapshotEvent
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
from pydantic_ai.ag_ui import StateDeps
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.graph_common import get_model
from haiku.rag.config import load_yaml_config
from haiku.rag.config.models import AppConfig
from haiku.rag.graph.common import get_model
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
if TYPE_CHECKING:
from haiku.rag.graph.agui.emitter import AGUIEmitter
from haiku.rag.graph.research.models import ResearchReport
class ResearchState(BaseModel):
"""Shared state between research agent and frontend."""
question: str = ""
phase: str = "idle"
status: str = ""
plan: list[dict] = []
current_question_index: int = 0
insights: list[dict] = []
document_registry: dict[str, dict] = {}
current_document: dict | None = None
confidence: float = 0.0
final_report: dict | None = None
# Load config
config_path = Path("/app/haiku.rag.yaml")
Config = (
AppConfig.model_validate(load_yaml_config(config_path))
if config_path.exists()
else AppConfig()
)
@dataclass
class ResearchDeps(StateDeps[ResearchState]):
"""Dependencies for the research agent with HaikuRAG client."""
class AgentDeps:
"""Dependencies for research agent."""
client: HaikuRAG
agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None
def _as_state_snapshot(ctx: RunContext[ResearchDeps]) -> StateSnapshotEvent:
return StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=ctx.deps.state)
model = get_model(Config.research.provider, Config.research.model)
def create_agent(
qa_provider: str = Config.qa.provider, qa_model: str = Config.qa.model
) -> Agent[ResearchDeps, str]:
"""Create and configure the research agent.
Args:
qa_provider: QA provider for the agent (default: from Config.qa.provider)
qa_model: Model name to use (default: from Config.qa.model)
"""
print(f"[AGENT SETUP] Creating agent with provider={qa_provider}, model={qa_model}")
agent = Agent(
model=get_model(qa_provider, qa_model),
deps_type=ResearchDeps,
instructions="""You are a research co-pilot powered by haiku.rag.
Your workflow MUST follow these exact steps in order:
1. Call propose_research_plan with the user's question
2. After propose_research_plan completes, IMMEDIATELY call approve_research_plan (with no arguments)
3. WAIT for approve_research_plan to return:
- If it returns "APPROVED", proceed to step 4
- If it returns "REVISE", ask the user "How would you like me to revise the research plan?" and wait for their response
- Once you receive their revision feedback, revise the plan and go back to step 1
4. Once approved, process questions ONE AT A TIME:
- Call search_question(question_id=0) and WAIT for it to complete
- Then call extract_insights_from_results(question_id=0) and WAIT for it to complete
- Then call search_question(question_id=1) and WAIT for it to complete
- Then call extract_insights_from_results(question_id=1) and WAIT for it to complete
- Then call search_question(question_id=2) and WAIT for it to complete
- Then call extract_insights_from_results(question_id=2) and WAIT for it to complete
5. After all questions are processed, call evaluate_research_confidence
6. Ask user if they want to finalize or continue researching
7. When user approves, call synthesize_final_report
agent = Agent(
model,
deps_type=AgentDeps,
system_prompt="""You are an advanced research assistant powered by haiku.rag.
CRITICAL RULES:
- MANDATORY: Call approve_research_plan immediately after propose_research_plan - NO EXCEPTIONS
- If approve_research_plan returns "REVISE", ask the user for revision feedback naturally in chat
- Call ONE tool at a time - wait for each tool to return before calling the next
- NEVER call extract_insights_from_results until search_question has completed and returned results
- DO NOT explain what you're about to do - just call the tool
- The state updates will show the user what's happening - you don't need to narrate
- Process all 3 questions automatically without asking for approval between them
1. For greetings (hi, hello, hey, etc) or casual chat: respond directly WITHOUT using any tools
2. For questions about yourself or the system: respond directly WITHOUT using any tools
3. For substantive questions requiring information: ALWAYS use the run_research tool
4. NEVER answer substantive questions from your own knowledge - always use the tool
Document Viewing:
- When user asks to "show document X", call get_full_document with the document_uri
How to decide:
- "Hi" / "Hello" / "How are you?" Respond directly, NO tools
- "What can you do?" Respond directly, NO tools
- "How does X work in the codebase?" Use run_research tool
- "Tell me about Y" Use run_research tool
Remember: Call tools ONE AT A TIME in sequence. Each tool must complete before calling the next.
""",
When you use run_research, the graph will decompose questions, search the knowledge base,
extract insights, and generate a comprehensive report.
Be friendly and conversational in all responses.""",
)
@agent.tool
async def run_research(ctx: RunContext[AgentDeps], question: str) -> str:
"""Execute research graph on a substantive question.
Use for questions requiring knowledge base search.
DO NOT use for greetings or casual conversation.
"""
if ctx.deps.agui_emitter:
ctx.deps.agui_emitter.log(f"🔍 Starting research on: {question}")
graph = build_research_graph(Config)
context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=Config)
graph_deps = ResearchDeps(
client=ctx.deps.client,
agui_emitter=ctx.deps.agui_emitter,
)
@agent.tool
async def propose_research_plan(
ctx: RunContext[ResearchDeps], question: str
) -> StateSnapshotEvent:
"""Propose a research plan by decomposing the question into sub-questions."""
ctx.deps.state.question = question
ctx.deps.state.phase = "planning"
ctx.deps.state.status = "Decomposing question into sub-questions..."
try:
result = await graph.run(state=state, deps=graph_deps)
decompose_prompt = f"""Break down this research question into exactly 3 specific sub-questions that would help answer it comprehensively.
if ctx.deps.agui_emitter:
ctx.deps.agui_emitter.log("✅ Research complete!")
Research Question: {question}
return f"""Research completed successfully!
Return ONLY a JSON array of sub-questions, like: ["Question 1?", "Question 2?", "Question 3?"]"""
Question: {question}
response = await ctx.deps.client.ask(decompose_prompt)
Executive Summary: {result.executive_summary}
try:
sub_questions = json.loads(response)
except json.JSONDecodeError:
sub_questions = [
q.strip().lstrip("0123456789.-) ")
for q in response.split("\n")
if q.strip()
][:3]
Main Findings:
{chr(10).join(f"- {finding}" for finding in result.main_findings[:3])}
plan = [
{"id": i, "question": q, "status": "pending"}
for i, q in enumerate(sub_questions)
]
Conclusions:
{chr(10).join(f"- {conclusion}" for conclusion in result.conclusions[:2])}
ctx.deps.state.plan = plan
ctx.deps.state.current_question_index = 0
ctx.deps.state.status = f"Proposed plan with {len(plan)} sub-questions"
Total insights gathered: {len(state.context.insights)}
Confidence: {f"{state.last_eval.confidence_score:.0%}" if state.last_eval else "N/A"}
Iterations completed: {state.iterations}
return _as_state_snapshot(ctx)
The full research report with all citations has been provided to the user.
"""
@agent.tool
async def search_question(
ctx: RunContext[ResearchDeps],
question_id: int,
search_type: str = "hybrid",
) -> StateSnapshotEvent:
"""Execute search for a specific sub-question."""
plan = ctx.deps.state.plan
if question_id >= len(plan):
raise ValueError(f"Question ID {question_id} not found in plan")
question = plan[question_id]["question"]
ctx.deps.state.phase = "searching"
ctx.deps.state.current_question_index = question_id
ctx.deps.state.status = f"Searching: {question}"
plan[question_id]["status"] = "searching"
search_results = await ctx.deps.client.search(
question, limit=5, search_type=search_type
)
expanded_map = {}
if search_results:
expanded_results = await ctx.deps.client.expand_context(
search_results[:3], radius=2
)
expanded_map = {
chunk.id: (chunk, score) for chunk, score in expanded_results
}
results = []
for chunk, score in search_results:
doc_uri = chunk.document_uri or "unknown"
doc_title = chunk.document_title or chunk.document_uri or "Unknown"
if doc_uri not in ctx.deps.state.document_registry:
ctx.deps.state.document_registry[doc_uri] = {
"title": doc_title,
"chunks_referenced": [],
}
if (
chunk.id
not in ctx.deps.state.document_registry[doc_uri]["chunks_referenced"]
):
ctx.deps.state.document_registry[doc_uri]["chunks_referenced"].append(
chunk.id
)
expanded_chunk, _ = (
expanded_map[chunk.id] if chunk.id in expanded_map else (chunk, score)
)
result_data = {
"chunk": expanded_chunk.content[:500],
"chunk_id": chunk.id,
"document_uri": doc_uri,
"document_title": doc_title,
"chunk_position": chunk.order,
"full_chunk_content": expanded_chunk.content,
"score": round(score, 3),
"expanded": chunk.id in expanded_map,
}
results.append(result_data)
plan[question_id]["search_results"] = {
"type": search_type,
"results": results,
}
plan[question_id]["status"] = "searched"
ctx.deps.state.status = f"Found {len(results)} results"
return _as_state_snapshot(ctx)
@agent.tool
async def extract_insights_from_results(
ctx: RunContext[ResearchDeps],
question_id: int,
) -> StateSnapshotEvent:
"""Extract key insights from search results for a specific question."""
plan = ctx.deps.state.plan
if question_id >= len(plan):
raise ValueError(f"Question ID {question_id} not found in plan")
question_item = plan[question_id]
if "search_results" not in question_item:
raise ValueError(
f"No search results found for question ID {question_id}. "
f"You must call search_question(question_id={question_id}) first."
)
search_results = question_item["search_results"]
ctx.deps.state.phase = "analyzing"
ctx.deps.state.status = "Extracting insights from results..."
context_parts = [
f"[Result {idx}] [Source: {r['document_title']}] {r['full_chunk_content']}"
for idx, r in enumerate(search_results["results"])
]
context = "\n\n".join(context_parts)
class InsightResult(BaseModel):
summary: str
confidence: float
result_indices: list[int]
class InsightsList(BaseModel):
insights: list[InsightResult]
question_text = question_item["question"]
extract_prompt = f"""Analyze these search results and extract 1-3 key insights that help answer the question: "{question_text}"
Search Results:
{context}
For each insight, reference which result numbers (0, 1, 2, etc.) support it."""
insight_agent: Agent[None, InsightsList] = Agent(
ctx.model,
output_type=InsightsList,
retries=3,
)
result = await insight_agent.run(extract_prompt)
raw_insights = [
{
"summary": insight.summary,
"confidence": insight.confidence,
"result_indices": insight.result_indices,
}
for insight in result.output.insights
]
new_insights = []
for insight in raw_insights:
source_refs = []
for idx in insight.get("result_indices", []):
if 0 <= idx < len(search_results["results"]):
result = search_results["results"][idx]
source_refs.append(
{
"chunk_id": result["chunk_id"],
"document_uri": result["document_uri"],
"document_title": result["document_title"],
"chunk_position": result["chunk_position"],
}
)
new_insights.append(
{
"summary": insight["summary"],
"confidence": insight.get("confidence", 0.7),
"source_refs": source_refs,
}
)
ctx.deps.state.insights.extend(new_insights)
plan[question_id]["status"] = "done"
ctx.deps.state.status = f"Extracted {len(new_insights)} insights"
return _as_state_snapshot(ctx)
@agent.tool
async def evaluate_research_confidence(
ctx: RunContext[ResearchDeps],
) -> StateSnapshotEvent:
"""Evaluate overall confidence in the research findings."""
insights = ctx.deps.state.insights
if not insights:
raise ValueError("No insights collected yet")
ctx.deps.state.phase = "evaluating"
ctx.deps.state.status = "Evaluating research confidence..."
confidences = [i.get("confidence", 0.5) for i in insights]
overall_confidence = sum(confidences) / len(confidences) if confidences else 0
eval_prompt = f"""Evaluate if these insights provide a confident answer to: "{ctx.deps.state.question}"
Insights collected:
{chr(10).join([f"- {i['summary']}" for i in insights])}
Assess:
1. Do we have enough information to answer the question?
2. What gaps remain?
3. Overall confidence (0.0-1.0)
Return JSON: {{"confidence": 0.0-1.0, "gaps": ["gap1", "gap2"], "recommendation": "continue" or "finalize"}}"""
response = await ctx.deps.client.ask(eval_prompt)
try:
evaluation = json.loads(response)
overall_confidence = evaluation.get("confidence", overall_confidence)
except json.JSONDecodeError:
pass
ctx.deps.state.confidence = overall_confidence
ctx.deps.state.status = f"Confidence: {overall_confidence:.0%}"
return _as_state_snapshot(ctx)
@agent.tool
async def synthesize_final_report(
ctx: RunContext[ResearchDeps],
) -> StateSnapshotEvent:
"""Generate final research report with citations."""
insights = ctx.deps.state.insights
if not insights:
raise ValueError("No insights to synthesize")
ctx.deps.state.phase = "synthesizing"
ctx.deps.state.status = "Generating final report..."
insights_summary = []
for i in insights:
source_titles = [ref["document_title"] for ref in i.get("source_refs", [])]
unique_sources = list(dict.fromkeys(source_titles))
insights_summary.append(
f"- {i['summary']} (sources: {', '.join(unique_sources[:2])})"
)
report_prompt = f"""Generate a comprehensive research report answering: "{ctx.deps.state.question}"
Based on these insights:
{chr(10).join(insights_summary)}
Create a structured report with:
- Executive Summary (2-3 sentences)
- Main Findings (bullet points)
- Conclusions
- Sources (list the document titles mentioned above)
Return JSON with format:
{{
"title": "...",
"summary": "...",
"findings": ["finding1", "finding2", ...],
"conclusions": ["conclusion1", ...],
"sources": ["source1", "source2", ...]
}}"""
response = await ctx.deps.client.ask(report_prompt)
try:
report = json.loads(response)
except json.JSONDecodeError:
report = {
"title": ctx.deps.state.question,
"summary": response[:300],
"findings": [i["summary"] for i in insights],
"conclusions": ["See findings above"],
"sources": [],
}
citations = [
{
"document_uri": doc_uri,
"document_title": doc_info["title"],
"chunk_ids": doc_info["chunks_referenced"],
}
for doc_uri, doc_info in ctx.deps.state.document_registry.items()
]
report["citations"] = citations
ctx.deps.state.final_report = report
ctx.deps.state.phase = "done"
ctx.deps.state.status = "Research complete"
return _as_state_snapshot(ctx)
@agent.tool
async def get_full_document(
ctx: RunContext[ResearchDeps],
document_uri: str,
) -> StateSnapshotEvent:
"""Retrieve and display the full content of a document by its URI."""
ctx.deps.state.status = f"Retrieving document: {document_uri}"
document = await ctx.deps.client.get_document_by_uri(document_uri)
if document is None:
ctx.deps.state.status = f"Document not found: {document_uri}"
ctx.deps.state.current_document = {
"uri": document_uri,
"title": "Not Found",
"content": f"Document with URI '{document_uri}' was not found.",
"total_chunks": 0,
}
else:
all_chunks = await ctx.deps.client.search(
query="", limit=1000, search_type="fts"
)
chunks_for_doc = [
c for c, _ in all_chunks if c.document_uri == document_uri
]
ctx.deps.state.current_document = {
"uri": document.uri or document_uri,
"title": document.title or "Untitled",
"content": document.content,
"total_chunks": len(chunks_for_doc),
"metadata": document.metadata,
}
ctx.deps.state.status = f"Retrieved: {document.title or document_uri}"
return _as_state_snapshot(ctx)
return agent
except Exception as e:
if ctx.deps.agui_emitter:
ctx.deps.agui_emitter.log(f"❌ Research error: {str(e)}")
return f"I encountered an error while researching: {str(e)}"

View file

@ -1,87 +1,171 @@
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from agent import ResearchDeps, ResearchState, create_agent
from agent import AgentDeps, agent
from anyio import create_memory_object_stream, create_task_group
from anyio.streams.memory import MemoryObjectSendStream
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from starlette.requests import Request
from starlette.responses import JSONResponse, StreamingResponse
from starlette.routing import Route
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.config import load_yaml_config
from haiku.rag.config.models import AppConfig
from haiku.rag.graph.agui.emitter import AGUIEmitter
from haiku.rag.graph.agui.server import RunAgentInput, format_sse_event
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.models import ResearchReport
from haiku.rag.graph.research.state import ResearchState
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
client: HaikuRAG | None = None
ag_ui_app = None
# Load config from mounted haiku.rag.yaml
config_path = Path("/app/haiku.rag.yaml")
if config_path.exists():
yaml_data = load_yaml_config(config_path)
Config = AppConfig.model_validate(yaml_data)
else:
# Fallback to default config
Config = AppConfig()
@asynccontextmanager
async def lifespan(app):
global client
db_path_str = os.getenv("DB_PATH", "haiku_rag.lancedb")
db_path = Path(db_path_str)
# Get DB path from environment
db_path_str = os.getenv("DB_PATH", "haiku_rag.lancedb")
db_path = Path(db_path_str)
if not db_path.exists():
logger.error(f"Database not found at {db_path}")
logger.error("Run: haiku-rag add <path-to-documents>")
raise RuntimeError(f"Database not found: {db_path}")
if not db_path.exists():
logger.error(f"Database not found at {db_path}")
logger.error("Run: haiku-rag add <path-to-documents>")
raise RuntimeError(f"Database not found: {db_path}")
logger.info(f"Initializing HaikuRAG client with database: {db_path}")
client = HaikuRAG(db_path)
logger.info("Research assistant backend ready")
logger.info(f"QA Provider: {Config.qa.provider}, Model: {Config.qa.model}")
logger.info(f"Initializing research assistant with database: {db_path}")
logger.info(
f"Research Provider: {Config.research.provider}, Model: {Config.research.model}"
)
yield
if client:
logger.info("Closing HaikuRAG client")
client.close()
# Store client reference for proper lifecycle management
_client_cache: dict[str, HaikuRAG] = {}
agent = create_agent()
def get_client(effective_db_path: Path) -> HaikuRAG:
"""Get or create cached client."""
path_key = str(effective_db_path)
if path_key not in _client_cache:
_client_cache[path_key] = HaikuRAG(db_path=effective_db_path, config=Config)
return _client_cache[path_key]
async def health(request):
db_path_str = os.getenv("DB_PATH", "haiku_rag.lancedb")
async def stream_research_agent(request: Request) -> StreamingResponse:
"""Agent streaming endpoint with research graph integration."""
body = await request.json()
input_data = RunAgentInput(**body)
user_message = ""
if input_data.messages:
user_message = input_data.messages[-1].get("content", "")
send_stream, receive_stream = create_memory_object_stream[str]()
async def run_agent_with_streaming(
send_stream: MemoryObjectSendStream[str],
) -> None:
"""Execute agent and forward emitter events to memory stream."""
async with send_stream:
try:
# Create shared emitter
emitter: AGUIEmitter[ResearchState, ResearchReport] = AGUIEmitter(
thread_id=input_data.thread_id,
run_id=input_data.run_id,
use_deltas=False,
)
# Get client
effective_db_path = input_data.config.get("db_path") or db_path
if isinstance(effective_db_path, str):
effective_db_path = Path(effective_db_path)
client = get_client(effective_db_path)
# Create agent dependencies with shared emitter
agent_deps = AgentDeps(client=client, agui_emitter=emitter)
# Start run with empty initial state
emitter.start_run(
initial_state=ResearchState.from_config(
context=ResearchContext(original_question=""),
config=Config,
)
)
# Forward emitter events to stream
async def forward_events():
async for event in emitter:
# Filter out ACTIVITY_SNAPSHOT - not supported by CopilotKit
if event.get("type") == "ACTIVITY_SNAPSHOT":
continue
await send_stream.send(format_sse_event(event))
# Run agent and event forwarding concurrently
async with create_task_group() as tg:
tg.start_soon(forward_events)
result = await agent.run(user_message, deps=agent_deps)
emitter.log(result.output)
await emitter.close()
except Exception as e:
logger.exception("Error executing agent")
try:
await send_stream.send(
format_sse_event({"type": "error", "error": str(e)})
)
except Exception:
pass
async def event_generator():
"""Generate SSE events from memory stream."""
async with create_task_group() as tg:
tg.start_soon(run_agent_with_streaming, send_stream)
async with receive_stream:
async for event_str in receive_stream:
yield event_str
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_check(_: Request) -> JSONResponse:
"""Health check endpoint with configuration info."""
return JSONResponse(
{
"status": "healthy",
"agent_model": str(agent.model),
"qa_provider": Config.qa.provider,
"qa_model": Config.qa.model,
"ollama_base_url": Config.providers.ollama.base_url,
"db_path": db_path_str,
"db_exists": Path(db_path_str).exists(),
"research_provider": Config.research.provider,
"research_model": Config.research.model,
"db_path": str(db_path),
"db_exists": db_path.exists(),
}
)
def get_ag_ui_app():
global ag_ui_app
if ag_ui_app is None and client is not None:
research_deps = ResearchDeps(client=client, state=ResearchState())
logger.info("Creating AG-UI app")
ag_ui_app = agent.to_ag_ui(deps=research_deps)
return ag_ui_app
async def agent_endpoint(scope, receive, send):
app = get_ag_ui_app()
if app is None:
response = JSONResponse({"error": "Client not initialized"}, status_code=503)
await response(scope, receive, send)
return
await app(scope, receive, send)
# Create Starlette app
app = Starlette(
routes=[
Route("/health", health),
Mount("/agent", agent_endpoint),
Route("/v1/research/stream", stream_research_agent, methods=["POST"]),
Route("/health", health_check, methods=["GET"]),
],
middleware=[
Middleware(
@ -92,17 +176,11 @@ app = Starlette(
allow_headers=["*"],
)
],
lifespan=lifespan,
)
if __name__ == "__main__":
import uvicorn
print("Starting haiku.rag research assistant backend...")
print(f"Agent model: {agent.model}")
print(f"QA provider: {Config.qa.provider}")
print(f"QA model: {Config.qa.model}")
uvicorn.run(
"main:app",
host="0.0.0.0",

View file

@ -9,7 +9,7 @@ dependencies = [
"uvicorn[standard]>=0.34.2",
"pydantic-ai-slim[ag-ui,openai]>=1.1.0",
"python-dotenv>=1.0.1",
"haiku-rag>=0.12.1",
"haiku-rag-slim @ file:///Users/ggozad/dev/open-source/haiku.rag-agui/haiku_rag_slim",
]
[dependency-groups]
@ -18,6 +18,9 @@ dev = [
"ruff>=0.13.0",
]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["."]

File diff suppressed because it is too large Load diff

View file

@ -10,11 +10,16 @@ services:
# API keys (set these in your shell or .env file)
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
# Ollama connection (use value from .env)
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL}
# Prevent Python bytecode caching for development
- PYTHONDONTWRITEBYTECODE=1
volumes:
- ./backend:/app
- /app/.venv
- ${DB_PATH}:/app/data/haiku.rag.lancedb
- ./haiku.rag.yaml:/app/haiku.rag.yaml:ro
- ./backend/main.py:/app/main.py
- ./backend/agent.py:/app/agent.py
- ../../haiku_rag_slim/haiku:/app/.venv/lib/python3.13/site-packages/haiku
networks:
- ag-ui-network
extra_hosts:

View file

@ -13,7 +13,7 @@ const runtime = new CopilotRuntime({
agents: {
// "research_agent" maps to the agent name used in useCoAgent() on the frontend
research_agent: new HttpAgent({
url: `${process.env.BACKEND_URL || "http://backend:8000"}/agent`,
url: `${process.env.BACKEND_URL || "http://backend:8000"}/v1/research/stream`,
}),
},
});

View file

@ -1,208 +1,94 @@
"use client";
import {
CopilotKit,
useCoAgent,
useCoAgentStateRender,
useCopilotAction,
} from "@copilotkit/react-core";
import { CopilotKit, useCoAgent } from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import StateDisplay from "./StateDisplay";
interface SourceRef {
chunk_id: string;
document_uri: string;
document_title: string;
chunk_position: number;
interface InsightRecord {
id: string;
summary: string;
status: string;
notes?: string;
supporting_sources: string[];
originating_questions: string[];
}
interface GapRecord {
id: string;
description: string;
severity: string;
blocking: boolean;
resolved: boolean;
notes?: string;
supporting_sources: string[];
resolved_by: string[];
}
interface SearchAnswer {
query: string;
answer: string;
confidence: number;
context: string[];
sources: string[];
}
interface ResearchContext {
original_question: string;
sub_questions: string[];
qa_responses: SearchAnswer[];
insights: InsightRecord[];
gaps: GapRecord[];
}
interface EvaluationResult {
confidence: number;
reasoning: string;
should_continue: boolean;
gaps_identified: string[];
follow_up_questions: string[];
}
interface ResearchReport {
question: string;
summary: string;
findings: string[];
conclusions: string[];
insights_used: string[];
methodology: string;
}
interface ResearchState {
question: string;
phase: string;
status: string;
plan: Array<{
id: number;
question: string;
status: string;
search_results?: {
type: string;
results: Array<{
chunk: string;
chunk_id: string;
document_uri: string;
document_title: string;
chunk_position: number;
full_chunk_content: string;
score: number;
expanded: boolean;
}>;
};
}>;
current_question_index: number;
insights: Array<{
summary: string;
confidence: number;
source_refs: SourceRef[];
}>;
document_registry: Record<
string,
{
title: string;
chunks_referenced: string[];
}
>;
current_document: {
uri: string;
title: string;
content: string;
total_chunks: number;
metadata?: Record<string, unknown>;
} | null;
confidence: number;
final_report: {
title: string;
summary: string;
findings: string[];
conclusions: string[];
sources: string[];
citations: Array<{
document_uri: string;
document_title: string;
chunk_ids: string[];
}>;
context: ResearchContext;
iterations: number;
max_iterations: number;
confidence_threshold: number;
max_concurrency: number;
last_eval: EvaluationResult | null;
last_analysis: {
insights_extracted: InsightRecord[];
gaps_identified: GapRecord[];
} | null;
result?: ResearchReport;
}
function AgentContent() {
const { state } = useCoAgent<ResearchState>({
name: "research_agent",
initialState: {
question: "",
phase: "idle",
status: "",
plan: [],
current_question_index: 0,
insights: [],
document_registry: {},
current_document: null,
confidence: 0.0,
final_report: null,
},
});
useCopilotAction({
name: "approve_research_plan",
description:
"Request user approval for the research plan. Returns 'APPROVED' if approved or 'REVISE' if user wants to revise.",
parameters: [],
renderAndWaitForResponse: ({ respond, status }) => (
<div
style={{
padding: "1.5rem",
background: "white",
borderRadius: "8px",
border: "2px solid #4299e1",
marginBottom: "1rem",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<h3
style={{
fontSize: "1.25rem",
fontWeight: "bold",
marginBottom: "1rem",
color: "#2d3748",
}}
>
Research Plan Approval
</h3>
<p
style={{
fontSize: "0.875rem",
color: "#4a5568",
marginBottom: "1rem",
}}
>
Please review the research plan in the right pane.
</p>
<div
style={{
display: "flex",
gap: "1rem",
}}
className={status !== "executing" ? "hidden" : ""}
>
<button
type="button"
onClick={() => respond?.("REVISE")}
disabled={status !== "executing"}
style={{
flex: 1,
padding: "0.75rem",
background: "white",
border: "2px solid #e2e8f0",
borderRadius: "6px",
fontSize: "0.875rem",
fontWeight: "600",
cursor: status === "executing" ? "pointer" : "not-allowed",
opacity: status === "executing" ? 1 : 0.5,
}}
>
Revise Plan
</button>
<button
type="button"
onClick={() => respond?.("APPROVED")}
disabled={status !== "executing"}
style={{
flex: 1,
padding: "0.75rem",
background: "#4299e1",
color: "white",
border: "none",
borderRadius: "6px",
fontSize: "0.875rem",
fontWeight: "600",
cursor: status === "executing" ? "pointer" : "not-allowed",
opacity: status === "executing" ? 1 : 0.5,
}}
>
Approve & Start Research
</button>
</div>
</div>
),
});
useCoAgentStateRender<ResearchState>({
name: "research_agent",
render: ({ state: newState }) => {
const phaseMessages: Record<string, string> = {
planning: "Planning research...",
searching: "Searching...",
analyzing: "Extracting insights...",
evaluating: `Evaluating confidence: ${(newState.confidence * 100).toFixed(0)}%`,
synthesizing: "Generating final report...",
done: "Research complete!",
};
const phaseMessage =
phaseMessages[newState.phase] || newState.status || "Ready";
return (
<div
style={{
padding: "1rem",
background: "#e6f7ff",
borderRadius: "4px",
marginBottom: "0.5rem",
border: "1px solid #91d5ff",
}}
>
<strong>Research Update:</strong> {phaseMessage}
</div>
);
context: {
original_question: "",
sub_questions: [],
qa_responses: [],
insights: [],
gaps: [],
},
iterations: 0,
max_iterations: 3,
confidence_threshold: 0.8,
max_concurrency: 1,
last_eval: null,
last_analysis: null,
},
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,23 @@
# haiku.rag configuration for ag-ui-research example
# Copy to haiku.rag.yaml and customize
qa:
research:
provider: ollama
model: gpt-oss:latest
max_iterations: 3
confidence_threshold: 0.8
max_concurrency: 1
providers:
ollama:
base_url: http://host.docker.internal:11434
# For OpenAI:
# qa:
# research:
# provider: openai
# model: gpt-4o-mini
# For Anthropic:
# qa:
# research:
# provider: anthropic
# model: claude-3-5-haiku-20241022

View file

@ -10,12 +10,12 @@ from rich.progress import Progress
from haiku.rag.client import HaikuRAG
from haiku.rag.config import AppConfig, Config
from haiku.rag.graph.agui import AGUIConsoleRenderer, stream_graph
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
from haiku.rag.mcp import create_mcp_server
from haiku.rag.monitor import FileWatcher
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchDeps, ResearchState
from haiku.rag.research.stream import stream_research_graph
from haiku.rag.store.models.chunk import Chunk
from haiku.rag.store.models.document import Document
@ -216,23 +216,31 @@ class HaikuRAGApp:
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
try:
if deep:
from rich.console import Console
from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.qa.deep.graph import build_deep_qa_graph
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
graph = build_deep_qa_graph(config=self.config)
context = DeepQAContext(
original_question=question, use_citations=cite
)
state = DeepQAState.from_config(context=context, config=self.config)
deps = DeepQADeps(
client=self.client, console=Console() if verbose else None
)
deps = DeepQADeps(client=self.client)
result = await graph.run(state=state, deps=deps)
answer = result.answer
if verbose:
# Use AG-UI renderer to process and display events
from haiku.rag.graph.agui import AGUIConsoleRenderer
renderer = AGUIConsoleRenderer(self.console)
result_dict = await renderer.render(
stream_graph(graph, state, deps)
)
# Result should be a dict with 'answer' key
answer = result_dict.get("answer", "") if result_dict else ""
else:
# Run without rendering events, just get the result
result = await graph.run(state=state, deps=deps)
answer = result.answer
else:
answer = await self.client.ask(question, cite=cite)
@ -243,46 +251,46 @@ class HaikuRAGApp:
except Exception as e:
self.console.print(f"[red]Error: {e}[/red]")
async def research(
self,
question: str,
verbose: bool = False,
):
async def research(self, question: str, verbose: bool = False):
"""Run research via the pydantic-graph pipeline.
Args:
question: The research question
verbose: Show verbose output
verbose: Show AG-UI event stream during execution
"""
async with HaikuRAG(db_path=self.db_path, config=self.config) as client:
try:
if verbose:
self.console.print("[bold cyan]Starting research[/bold cyan]")
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
self.console.print()
self.console.print("[bold cyan]Starting research[/bold cyan]")
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
self.console.print()
graph = build_research_graph(config=self.config)
context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=self.config)
deps = ResearchDeps(
client=client, console=self.console if verbose else None
)
deps = ResearchDeps(client=client)
report = None
async for event in stream_research_graph(graph, state, deps):
if event.type == "report":
report = event.report
break
if event.type == "error":
self.console.print(
f"[red]Error during research: {event.message}[/red]"
)
return
if verbose:
# Use AG-UI renderer to process and display events
renderer = AGUIConsoleRenderer(self.console)
report_dict = await renderer.render(
stream_graph(graph, state, deps)
)
else:
# Run without rendering events, just get the result
report = await graph.run(state=state, deps=deps)
report_dict = (
report.model_dump() if hasattr(report, "model_dump") else report
)
if report is None:
if report_dict is None:
self.console.print("[red]Research did not produce a report.[/red]")
return
# Convert dict to ResearchReport model
from haiku.rag.graph.research.models import ResearchReport
report = ResearchReport.model_validate(report_dict)
# Display the report
self.console.print("[bold green]Research Report[/bold green]")
self.console.rule()
@ -455,6 +463,7 @@ class HaikuRAGApp:
enable_mcp: bool = True,
mcp_transport: str | None = None,
mcp_port: int = 8001,
enable_agui: bool = False,
):
"""Start the server with selected services."""
async with HaikuRAG(self.db_path, config=self.config) as client:
@ -482,6 +491,30 @@ class HaikuRAGApp:
mcp_task = asyncio.create_task(run_mcp())
tasks.append(mcp_task)
# Start AG-UI server if enabled
if enable_agui:
async def run_agui():
import uvicorn
from haiku.rag.graph.agui import create_agui_server
logger.info(
f"Starting AG-UI server on {self.config.agui.host}:{self.config.agui.port}"
)
app = create_agui_server(self.config, db_path=self.db_path)
config = uvicorn.Config(
app=app,
host=self.config.agui.host,
port=self.config.agui.port,
log_level="info",
)
server = uvicorn.Server(config)
await server.serve()
agui_task = asyncio.create_task(run_agui())
tasks.append(agui_task)
if not tasks:
logger.warning("No services enabled")
return

View file

@ -302,7 +302,7 @@ def research(
verbose: bool = typer.Option(
False,
"--verbose",
help="Show verbose progress output",
help="Show planning, searching previews, evaluation summary, and stop reason",
),
):
app = create_app(db)
@ -401,7 +401,7 @@ def download_models_cmd():
@cli.command(
"serve",
help="Start haiku.rag server. Use --monitor and/or --mcp to enable services.",
help="Start haiku.rag server. Use --monitor, --mcp, and/or --agui to enable services.",
)
def serve(
db: Path | None = typer.Option(
@ -429,12 +429,17 @@ def serve(
"--mcp-port",
help="Port to bind MCP server to (ignored with --stdio)",
),
agui: bool = typer.Option(
False,
"--agui",
help="Enable AG-UI HTTP server for graph streaming",
),
) -> None:
"""Start the server with selected services."""
# Require at least one service flag
if not (monitor or mcp):
if not (monitor or mcp or agui):
typer.echo(
"Error: At least one service flag (--monitor or --mcp) must be specified"
"Error: At least one service flag (--monitor, --mcp, or --agui) must be specified"
)
raise typer.Exit(1)
@ -452,6 +457,7 @@ def serve(
enable_mcp=mcp,
mcp_transport=transport,
mcp_port=mcp_port,
enable_agui=agui,
)
)

View file

@ -6,6 +6,7 @@ from haiku.rag.config.loader import (
load_yaml_config,
)
from haiku.rag.config.models import (
AGUIConfig,
AppConfig,
EmbeddingsConfig,
LanceDBConfig,
@ -22,6 +23,7 @@ from haiku.rag.config.models import (
__all__ = [
"Config",
"AGUIConfig",
"AppConfig",
"StorageConfig",
"MonitorConfig",

View file

@ -84,4 +84,12 @@ def generate_default_config() -> dict:
"research_base_url": "",
},
},
"agui": {
"host": "0.0.0.0",
"port": 8000,
"cors_origins": ["*"],
"cors_credentials": True,
"cors_methods": ["GET", "POST", "OPTIONS"],
"cors_headers": ["*"],
},
}

View file

@ -77,6 +77,15 @@ class ProvidersConfig(BaseModel):
vllm: VLLMConfig = Field(default_factory=VLLMConfig)
class AGUIConfig(BaseModel):
host: str = "0.0.0.0"
port: int = 8000
cors_origins: list[str] = ["*"]
cors_credentials: bool = True
cors_methods: list[str] = ["GET", "POST", "OPTIONS"]
cors_headers: list[str] = ["*"]
class AppConfig(BaseModel):
environment: str = "production"
storage: StorageConfig = Field(default_factory=StorageConfig)
@ -88,3 +97,4 @@ class AppConfig(BaseModel):
research: ResearchConfig = Field(default_factory=ResearchConfig)
processing: ProcessingConfig = Field(default_factory=ProcessingConfig)
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
agui: AGUIConfig = Field(default_factory=AGUIConfig)

View file

@ -0,0 +1,26 @@
"""Graph module for haiku.rag.
This module contains all graph-related functionality including:
- AG-UI protocol for graph streaming
- Common graph utilities and models
- Research graph implementation
- Deep QA graph implementation
"""
from haiku.rag.graph.agui import (
AGUIConsoleRenderer,
AGUIEmitter,
create_agui_server,
stream_graph,
)
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
from haiku.rag.graph.research.graph import build_research_graph
__all__ = [
"AGUIConsoleRenderer",
"AGUIEmitter",
"build_deep_qa_graph",
"build_research_graph",
"create_agui_server",
"stream_graph",
]

View file

@ -0,0 +1,53 @@
"""Generic AG-UI protocol support for haiku.rag graphs."""
from haiku.rag.graph.agui.cli_renderer import AGUIConsoleRenderer
from haiku.rag.graph.agui.emitter import AGUIEmitter
from haiku.rag.graph.agui.events import (
AGUIEvent,
emit_activity,
emit_activity_delta,
emit_run_error,
emit_run_finished,
emit_run_started,
emit_state_delta,
emit_state_snapshot,
emit_step_finished,
emit_step_started,
emit_text_message,
emit_text_message_content,
emit_text_message_end,
emit_text_message_start,
)
from haiku.rag.graph.agui.server import (
RunAgentInput,
create_agui_app,
create_agui_server,
format_sse_event,
)
from haiku.rag.graph.agui.state import compute_state_delta
from haiku.rag.graph.agui.stream import stream_graph
__all__ = [
"AGUIConsoleRenderer",
"AGUIEmitter",
"AGUIEvent",
"RunAgentInput",
"compute_state_delta",
"create_agui_app",
"create_agui_server",
"emit_activity",
"emit_activity_delta",
"emit_run_error",
"emit_run_finished",
"emit_run_started",
"emit_state_delta",
"emit_state_snapshot",
"emit_step_finished",
"emit_step_started",
"emit_text_message",
"emit_text_message_content",
"emit_text_message_end",
"emit_text_message_start",
"format_sse_event",
"stream_graph",
]

View file

@ -0,0 +1,135 @@
"""Generic CLI renderer for AG-UI events with Rich console output."""
from collections.abc import AsyncIterator
from typing import Any
from rich.console import Console
from haiku.rag.graph.agui.events import AGUIEvent
class AGUIConsoleRenderer:
"""Renders AG-UI events to Rich console with formatted output.
Generic renderer that processes AG-UI protocol events and renders them
with Rich formatting. Works with any graph that emits AG-UI events.
"""
def __init__(self, console: Console | None = None):
"""Initialize the renderer.
Args:
console: Optional Rich console instance (creates new one if not provided)
"""
self.console = console or Console()
async def render(self, events: AsyncIterator[AGUIEvent]) -> Any | None:
"""Process events and render to console, return final result.
Args:
events: Async iterator of AG-UI events
Returns:
The final result from RunFinished event, or None
"""
result = None
async for event in events:
event_type = event.get("type")
if event_type == "RUN_STARTED":
self._render_run_started(event)
elif event_type == "RUN_FINISHED":
result = event.get("result")
self._render_run_finished()
elif event_type == "RUN_ERROR":
self._render_error(event)
elif event_type == "STEP_STARTED":
self._render_step_started(event)
elif event_type == "STEP_FINISHED":
self._render_step_finished(event)
elif event_type == "TEXT_MESSAGE_CHUNK":
self._render_text_message(event)
elif event_type == "TEXT_MESSAGE_START":
pass # Start of streaming message, no output needed
elif event_type == "TEXT_MESSAGE_CONTENT":
self._render_text_content(event)
elif event_type == "TEXT_MESSAGE_END":
pass # End of streaming message, no output needed
elif event_type == "STATE_SNAPSHOT":
self._render_state_snapshot(event)
elif event_type == "STATE_DELTA":
self._render_state_delta(event)
elif event_type == "ACTIVITY_SNAPSHOT":
self._render_activity(event)
elif event_type == "ACTIVITY_DELTA":
pass # Activity deltas don't need separate rendering
return result
def _render_run_started(self, event: AGUIEvent) -> None:
"""Render run start event."""
run_id = event.get("runId", "")
if run_id:
# Show shortened run ID (first 8 chars like our UUIDs)
short_id = run_id[:8] if len(run_id) > 8 else run_id
self.console.print(f"[bold green][RUN_STARTED][/bold green] Run {short_id}")
def _render_run_finished(self) -> None:
"""Render run completion."""
self.console.print("[bold green][RUN_FINISHED][/bold green] Completed")
def _render_error(self, event: AGUIEvent) -> None:
"""Render error event."""
message = event.get("message", "Unknown error")
self.console.print(f"[bold red][RUN_ERROR][/bold red] {message}")
def _render_step_started(self, event: AGUIEvent) -> None:
"""Render step start event."""
step_name = event.get("stepName", "")
if step_name:
display_name = step_name.replace("_", " ").title()
self.console.print(
f"\n[bold cyan][STEP_STARTED][/bold cyan] {display_name}"
)
def _render_step_finished(self, event: AGUIEvent) -> None:
"""Render step finish event."""
step_name = event.get("stepName", "")
if step_name:
display_name = step_name.replace("_", " ").title()
self.console.print(f"[cyan][STEP_FINISHED][/cyan] {display_name}")
def _render_text_message(self, event: AGUIEvent) -> None:
"""Render complete text message."""
delta = event.get("delta", "")
self.console.print(f"[magenta][TEXT_MESSAGE][/magenta] {delta}")
def _render_text_content(self, event: AGUIEvent) -> None:
"""Render streaming text content delta."""
delta = event.get("delta", "")
self.console.print(delta, end="")
def _render_activity(self, event: AGUIEvent) -> None:
"""Render activity update."""
content = event.get("content", "")
if content:
self.console.print(f"[yellow][ACTIVITY][/yellow] {content}")
def _render_state_snapshot(self, event: AGUIEvent) -> None:
"""Render full state snapshot."""
snapshot = event.get("snapshot")
if not snapshot:
return
self.console.print("[blue][STATE_SNAPSHOT][/blue]")
self.console.print(snapshot, style="dim")
def _render_state_delta(self, event: AGUIEvent) -> None:
"""Render state delta operations."""
delta = event.get("delta", [])
if not delta:
return
self.console.print("[blue][STATE_DELTA][/blue]")
self.console.print(delta, style="dim")

View file

@ -0,0 +1,197 @@
"""Generic AG-UI event emitter for any graph execution."""
import asyncio
import hashlib
from collections.abc import AsyncIterator
from uuid import uuid4
from pydantic import BaseModel
from haiku.rag.graph.agui.events import (
AGUIEvent,
emit_activity,
emit_run_error,
emit_run_finished,
emit_run_started,
emit_state_delta,
emit_state_snapshot,
emit_step_finished,
emit_step_started,
emit_text_message,
)
class AGUIEmitter[StateT: BaseModel, ResultT]:
"""Generic queue-backed AG-UI event emitter for any graph.
Manages the lifecycle of AG-UI events including:
- Run lifecycle (start, finish, error)
- Step lifecycle (start, finish)
- Text messages
- State synchronization (snapshots and deltas)
- Activity updates
Type parameters:
StateT: The Pydantic BaseModel type for graph state
ResultT: The result type returned by the graph
"""
def __init__(
self,
thread_id: str | None = None,
run_id: str | None = None,
use_deltas: bool = True,
):
"""Initialize the emitter.
Args:
thread_id: Optional thread ID (generated from input hash if not provided)
run_id: Optional run ID (random UUID if not provided)
use_deltas: Whether to emit state deltas instead of full snapshots (default: True)
"""
self._queue: asyncio.Queue[AGUIEvent | None] = asyncio.Queue()
self._closed = False
self._thread_id = thread_id or str(uuid4())
self._run_id = run_id or str(uuid4())
self._last_state: StateT | None = None
self._current_step: str | None = None
self._use_deltas = use_deltas
@property
def thread_id(self) -> str:
"""Get the thread ID for this emitter."""
return self._thread_id
@property
def run_id(self) -> str:
"""Get the run ID for this emitter."""
return self._run_id
def start_run(self, initial_state: StateT) -> None:
"""Emit RunStarted and initial StateSnapshot.
Args:
initial_state: The initial state of the graph
"""
# If thread_id wasn't provided, generate from state hash
if not self._thread_id or self._thread_id == str(uuid4()):
state_json = initial_state.model_dump_json()
self._thread_id = self._generate_thread_id(state_json)
# RunStarted (state snapshot follows immediately with full state)
self._emit(emit_run_started(self._thread_id, self._run_id))
self._emit(emit_state_snapshot(initial_state))
# Store a deep copy to detect future changes
self._last_state = initial_state.model_copy(deep=True)
def start_step(self, step_name: str) -> None:
"""Emit StepStarted event.
Args:
step_name: Name of the step being started
"""
self._current_step = step_name
self._emit(emit_step_started(step_name))
def finish_step(self) -> None:
"""Emit StepFinished event for the current step."""
if self._current_step:
self._emit(emit_step_finished(self._current_step))
self._current_step = None
def log(self, message: str, role: str = "assistant") -> None:
"""Emit a text message event.
Args:
message: The message content
role: The role of the sender (default: assistant)
"""
self._emit(emit_text_message(message, role))
def update_state(self, new_state: StateT) -> None:
"""Emit StateDelta or StateSnapshot for state change.
Args:
new_state: The updated state
"""
if self._use_deltas and self._last_state is not None:
# Emit delta for incremental updates
self._emit(emit_state_delta(self._last_state, new_state))
else:
# Emit full snapshot for initial state or when deltas disabled
self._emit(emit_state_snapshot(new_state))
# Store a deep copy to detect future changes
self._last_state = new_state.model_copy(deep=True)
def update_activity(
self, activity_type: str, content: str, message_id: str | None = None
) -> None:
"""Emit ActivitySnapshot event.
Args:
activity_type: Type of activity (e.g., "planning", "searching")
content: Description of the activity
message_id: Optional message ID to associate activity with (auto-generated if None)
"""
if message_id is None:
message_id = str(uuid4())
self._emit(emit_activity(message_id, activity_type, content))
def finish_run(self, result: ResultT) -> None:
"""Emit RunFinished event.
Args:
result: The final result from the graph
"""
self._emit(emit_run_finished(self._thread_id, self._run_id, result))
def error(self, error: Exception, code: str | None = None) -> None:
"""Emit RunError event.
Args:
error: The exception that occurred
code: Optional error code
"""
self._emit(emit_run_error(str(error), code))
def _emit(self, event: AGUIEvent) -> None:
"""Put event in queue.
Args:
event: The event to emit
"""
if not self._closed:
self._queue.put_nowait(event)
async def close(self) -> None:
"""Close the emitter and stop event iteration."""
if self._closed:
return
self._closed = True
await self._queue.put(None)
def __aiter__(self) -> AsyncIterator[AGUIEvent]:
"""Enable async iteration over events."""
return self._iter_events()
async def _iter_events(self) -> AsyncIterator[AGUIEvent]:
"""Iterate over events from the queue."""
while True:
event = await self._queue.get()
if event is None:
break
yield event
@staticmethod
def _generate_thread_id(input_data: str) -> str:
"""Generate a deterministic thread ID from input data.
Args:
input_data: The input data (e.g., question, prompt)
Returns:
A stable thread ID based on input hash
"""
# Use hash of input for deterministic thread ID
hash_obj = hashlib.sha256(input_data.encode("utf-8"))
return hash_obj.hexdigest()[:16]

View file

@ -0,0 +1,254 @@
"""Generic AG-UI event creation utilities for any graph."""
from typing import Any
from uuid import uuid4
from pydantic import BaseModel
from haiku.rag.graph.agui.state import compute_state_delta
# Type aliases for AG-UI events (actual types from ag_ui.core will be used at runtime)
AGUIEvent = dict[str, Any]
def emit_run_started(
thread_id: str, run_id: str, input_data: str | None = None
) -> dict[str, Any]:
"""Create a RunStarted event.
Args:
thread_id: Unique identifier for the conversation thread
run_id: Unique identifier for this run
input_data: Optional input that started the run
Returns:
RunStarted event dict
"""
event: dict[str, Any] = {
"type": "RUN_STARTED",
"threadId": thread_id,
"runId": run_id,
}
if input_data:
event["input"] = input_data
return event
def emit_run_finished(thread_id: str, run_id: str, result: Any) -> dict[str, Any]:
"""Create a RunFinished event.
Args:
thread_id: Unique identifier for the conversation thread
run_id: Unique identifier for this run
result: The final result of the run
Returns:
RunFinished event dict
"""
# Convert result to dict if it's a Pydantic model
if hasattr(result, "model_dump"):
result = result.model_dump()
return {
"type": "RUN_FINISHED",
"threadId": thread_id,
"runId": run_id,
"result": result,
}
def emit_run_error(message: str, code: str | None = None) -> dict[str, Any]:
"""Create a RunError event.
Args:
message: Error message
code: Optional error code
Returns:
RunError event dict
"""
event: dict[str, Any] = {
"type": "RUN_ERROR",
"message": message,
}
if code:
event["code"] = code
return event
def emit_step_started(step_name: str) -> dict[str, Any]:
"""Create a StepStarted event.
Args:
step_name: Name of the step being started
Returns:
StepStarted event dict
"""
return {
"type": "STEP_STARTED",
"stepName": step_name,
}
def emit_step_finished(step_name: str) -> dict[str, Any]:
"""Create a StepFinished event.
Args:
step_name: Name of the step that finished
Returns:
StepFinished event dict
"""
return {
"type": "STEP_FINISHED",
"stepName": step_name,
}
def emit_text_message(content: str, role: str = "assistant") -> dict[str, Any]:
"""Create a TextMessageChunk event (convenience wrapper).
This creates a complete text message in one event.
Args:
content: The message content
role: The role of the sender (default: assistant)
Returns:
TextMessageChunk event dict
"""
message_id = str(uuid4())
return {
"type": "TEXT_MESSAGE_CHUNK",
"messageId": message_id,
"role": role,
"delta": content,
}
def emit_text_message_start(message_id: str, role: str = "assistant") -> dict[str, Any]:
"""Create a TextMessageStart event.
Args:
message_id: Unique identifier for this message
role: The role of the sender
Returns:
TextMessageStart event dict
"""
return {
"type": "TEXT_MESSAGE_START",
"messageId": message_id,
"role": role,
}
def emit_text_message_content(message_id: str, delta: str) -> dict[str, Any]:
"""Create a TextMessageContent event.
Args:
message_id: Identifier for the message being streamed
delta: Content chunk to append
Returns:
TextMessageContent event dict
"""
return {
"type": "TEXT_MESSAGE_CONTENT",
"messageId": message_id,
"delta": delta,
}
def emit_text_message_end(message_id: str) -> dict[str, Any]:
"""Create a TextMessageEnd event.
Args:
message_id: Identifier for the message being completed
Returns:
TextMessageEnd event dict
"""
return {
"type": "TEXT_MESSAGE_END",
"messageId": message_id,
}
def emit_state_snapshot(state: BaseModel) -> dict[str, Any]:
"""Create a StateSnapshot event.
Args:
state: The complete state to snapshot (any Pydantic BaseModel)
Returns:
StateSnapshot event dict
"""
return {
"type": "STATE_SNAPSHOT",
"snapshot": state.model_dump(),
}
def emit_state_delta(old_state: BaseModel, new_state: BaseModel) -> dict[str, Any]:
"""Create a StateDelta event with JSON Patch operations.
Args:
old_state: Previous state (any Pydantic BaseModel)
new_state: Current state (same type as old_state)
Returns:
StateDelta event dict
"""
delta = compute_state_delta(old_state, new_state)
return {
"type": "STATE_DELTA",
"delta": delta,
}
def emit_activity(
message_id: str,
activity_type: str,
content: str,
) -> dict[str, Any]:
"""Create an ActivitySnapshot event.
Args:
message_id: Message ID to associate activity with (required)
activity_type: Type of activity (e.g., "planning", "searching")
content: Description of the activity
Returns:
ActivitySnapshot event dict
"""
return {
"type": "ACTIVITY_SNAPSHOT",
"messageId": message_id,
"activityType": activity_type,
"content": content,
}
def emit_activity_delta(
message_id: str,
activity_type: str,
patch: list[dict[str, Any]],
) -> dict[str, Any]:
"""Create an ActivityDelta event with JSON Patch operations.
Args:
message_id: Message ID of the activity being updated
activity_type: Type of activity being updated
patch: JSON Patch operations to apply
Returns:
ActivityDelta event dict
"""
return {
"type": "ACTIVITY_DELTA",
"messageId": message_id,
"activityType": activity_type,
"patch": patch,
}

View file

@ -0,0 +1,310 @@
"""AG-UI HTTP server implementation for graph execution."""
import json
from collections.abc import AsyncIterator, Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from haiku.rag.config.models import AppConfig
from pydantic import BaseModel, Field
from pydantic_graph.beta import Graph
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, StreamingResponse
from starlette.routing import Route
from haiku.rag.config.models import AGUIConfig
from haiku.rag.graph.agui.emitter import AGUIEmitter
from haiku.rag.graph.agui.events import AGUIEvent
from haiku.rag.graph.agui.stream import stream_graph
class GraphDeps(Protocol):
"""Protocol for graph dependencies that support AG-UI emission."""
agui_emitter: AGUIEmitter[Any, Any] | None
class RunAgentInput(BaseModel):
"""AG-UI protocol run agent input.
See: https://docs.ag-ui.com/concepts/agents#runagentinput
"""
thread_id: str | None = Field(None, alias="threadId")
run_id: str | None = Field(None, alias="runId")
state: dict[str, Any] = Field(default_factory=dict)
messages: list[dict[str, Any]] = Field(default_factory=list)
config: dict[str, Any] = Field(default_factory=dict)
def create_agui_app(
graph_factory: Callable[[], Graph],
state_factory: Callable[[dict[str, Any]], BaseModel],
deps_factory: Callable[[dict[str, Any]], GraphDeps],
config: AGUIConfig,
) -> Starlette:
"""Create Starlette app with AG-UI endpoint.
Args:
graph_factory: Factory function to create graph instance
state_factory: Factory to create initial state from input
deps_factory: Factory to create graph dependencies
config: AG-UI server configuration
Returns:
Starlette application with AG-UI endpoints
"""
async def event_stream(
input_data: RunAgentInput,
) -> AsyncIterator[str]:
"""Generate SSE event stream from graph execution.
Yields:
Server-Sent Events formatted strings
"""
# Create graph, state, and dependencies
graph = graph_factory()
# Create initial state from input
initial_state = state_factory(input_data.state)
# Create dependencies (may use config from input)
deps = deps_factory(input_data.config)
# Execute graph and stream events
async for event in stream_graph(graph, initial_state, deps):
# Format as SSE event
event_data = format_sse_event(event)
yield event_data
async def stream_agent(request: Request) -> StreamingResponse:
"""AG-UI agent stream endpoint.
Accepts AG-UI RunAgentInput and streams events via SSE.
"""
# Parse request body
body = await request.json()
input_data = RunAgentInput(**body)
# Return SSE stream
return StreamingResponse(
event_stream(input_data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable buffering in nginx
},
)
async def health_check(_: Request) -> JSONResponse:
"""Health check endpoint."""
return JSONResponse({"status": "healthy"})
# Define routes
routes = [
Route("/v1/agent/stream", stream_agent, methods=["POST"]),
Route("/health", health_check, methods=["GET"]),
]
# Configure CORS middleware
middleware = [
Middleware(
CORSMiddleware,
allow_origins=config.cors_origins,
allow_credentials=config.cors_credentials,
allow_methods=config.cors_methods,
allow_headers=config.cors_headers,
)
]
# Create Starlette app
app = Starlette(
routes=routes,
middleware=middleware,
debug=False,
)
return app
def format_sse_event(event: AGUIEvent) -> str:
"""Format AG-UI event as Server-Sent Event.
Args:
event: AG-UI event dictionary
Returns:
SSE formatted string with event data
"""
# Convert event to JSON
event_json = json.dumps(event, ensure_ascii=False)
# Format as SSE
# Each event is: data: <json>\n\n
return f"data: {event_json}\n\n"
def create_agui_server(config: "AppConfig", db_path: Path | None = None) -> Starlette:
"""Create AG-UI server with both research and deep ask endpoints.
Args:
config: Application config with research and qa settings
db_path: Optional database path override
Returns:
Starlette app with research and deep ask endpoints
"""
from haiku.rag.client import HaikuRAG
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
# Store client reference for proper lifecycle management
_client_cache: dict[str, HaikuRAG] = {}
def get_client(effective_db_path: Path) -> HaikuRAG:
"""Get or create cached client."""
path_key = str(effective_db_path)
if path_key not in _client_cache:
_client_cache[path_key] = HaikuRAG(db_path=effective_db_path, config=config)
return _client_cache[path_key]
# Research graph factories
def research_graph_factory() -> Graph:
return build_research_graph(config)
def research_state_factory(input_state: dict[str, Any]) -> ResearchState:
question = input_state.get("question", "")
if not question:
messages = input_state.get("messages", [])
if messages:
question = messages[0].get("content", "")
context = ResearchContext(original_question=question)
return ResearchState.from_config(context=context, config=config)
def research_deps_factory(input_config: dict[str, Any]) -> ResearchDeps:
effective_db_path = (
db_path
or input_config.get("db_path")
or config.storage.data_dir / "haiku.rag.lancedb"
)
return ResearchDeps(client=get_client(effective_db_path))
# Deep ask graph factories
def deep_ask_graph_factory() -> Graph:
return build_deep_qa_graph(config)
def deep_ask_state_factory(input_state: dict[str, Any]) -> DeepQAState:
question = input_state.get("question", "")
if not question:
messages = input_state.get("messages", [])
if messages:
question = messages[0].get("content", "")
use_citations = input_state.get("use_citations", False)
context = DeepQAContext(original_question=question, use_citations=use_citations)
return DeepQAState.from_config(context=context, config=config)
def deep_ask_deps_factory(input_config: dict[str, Any]) -> DeepQADeps:
effective_db_path = (
db_path
or input_config.get("db_path")
or config.storage.data_dir / "haiku.rag.lancedb"
)
return DeepQADeps(client=get_client(effective_db_path))
# Create event stream functions for each graph type
async def research_event_stream(
input_data: RunAgentInput,
) -> AsyncIterator[str]:
"""Generate SSE event stream from research graph execution."""
graph = research_graph_factory()
initial_state = research_state_factory(input_data.state)
deps = research_deps_factory(input_data.config)
async for event in stream_graph(graph, initial_state, deps):
event_data = format_sse_event(event)
yield event_data
async def deep_ask_event_stream(
input_data: RunAgentInput,
) -> AsyncIterator[str]:
"""Generate SSE event stream from deep ask graph execution."""
graph = deep_ask_graph_factory()
initial_state = deep_ask_state_factory(input_data.state)
deps = deep_ask_deps_factory(input_data.config)
async for event in stream_graph(graph, initial_state, deps):
event_data = format_sse_event(event)
yield event_data
# Endpoint handlers
async def stream_research(request: Request) -> StreamingResponse:
"""Research graph streaming endpoint."""
body = await request.json()
input_data = RunAgentInput(**body)
return StreamingResponse(
research_event_stream(input_data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def stream_deep_ask(request: Request) -> StreamingResponse:
"""Deep ask graph streaming endpoint."""
body = await request.json()
input_data = RunAgentInput(**body)
return StreamingResponse(
deep_ask_event_stream(input_data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_check(_: Request) -> JSONResponse:
"""Health check endpoint."""
return JSONResponse({"status": "healthy"})
# Define routes
routes = [
Route("/v1/research/stream", stream_research, methods=["POST"]),
Route("/v1/deep-ask/stream", stream_deep_ask, methods=["POST"]),
Route("/health", health_check, methods=["GET"]),
]
# Configure CORS middleware
middleware = [
Middleware(
CORSMiddleware,
allow_origins=config.agui.cors_origins,
allow_credentials=config.agui.cors_credentials,
allow_methods=config.agui.cors_methods,
allow_headers=config.agui.cors_headers,
)
]
# Create Starlette app
app = Starlette(
routes=routes,
middleware=middleware,
debug=False,
)
return app

View file

@ -0,0 +1,34 @@
"""Generic AG-UI state utilities for any Pydantic BaseModel."""
from typing import Any
from pydantic import BaseModel
def compute_state_delta(
old_state: BaseModel, new_state: BaseModel
) -> list[dict[str, Any]]:
"""Compute JSON Patch (RFC 6902) operations from old state to new state.
Args:
old_state: Previous state (any Pydantic BaseModel)
new_state: Current state (same type as old_state)
Returns:
List of JSON Patch operations
"""
operations: list[dict[str, Any]] = []
# Convert states to dicts for comparison
old_dict = old_state.model_dump()
new_dict = new_state.model_dump()
# Compare each field and generate patches
for key, new_value in new_dict.items():
old_value = old_dict.get(key)
if old_value != new_value:
# Simple replace operation
operations.append({"op": "replace", "path": f"/{key}", "value": new_value})
return operations

View file

@ -0,0 +1,86 @@
"""Generic graph streaming with AG-UI events."""
import asyncio
from collections.abc import AsyncIterator
from contextlib import suppress
from typing import Protocol, TypeVar
from pydantic import BaseModel
from pydantic_graph.beta import Graph
from haiku.rag.graph.agui.emitter import AGUIEmitter
from haiku.rag.graph.agui.events import AGUIEvent
StateT = TypeVar("StateT", bound=BaseModel)
ResultT = TypeVar("ResultT")
class GraphDeps[StateT: BaseModel, ResultT](Protocol):
"""Protocol for graph dependencies that support AG-UI emission."""
agui_emitter: AGUIEmitter[StateT, ResultT] | None
async def stream_graph[StateT: BaseModel, DepsT: GraphDeps, ResultT](
graph: Graph[StateT, DepsT, None, ResultT],
state: StateT,
deps: DepsT,
use_deltas: bool = True,
) -> AsyncIterator[AGUIEvent]:
"""Run a graph and yield AG-UI events as they occur.
This is a generic streaming function that works with any pydantic-graph
that follows the AG-UI pattern:
- State must be a Pydantic BaseModel
- Deps must have an optional agui_emitter attribute
- Graph must be a pydantic-graph Graph instance
Args:
graph: The pydantic-graph Graph to execute
state: Initial state (Pydantic BaseModel)
deps: Graph dependencies with agui_emitter support
use_deltas: Whether to emit state deltas instead of full snapshots (default: True)
Yields:
AG-UI event dictionaries
Raises:
TypeError: If deps doesn't support agui_emitter
RuntimeError: If graph doesn't produce a result
"""
if not hasattr(deps, "agui_emitter"):
raise TypeError("deps must have an 'agui_emitter' attribute")
# Create AG-UI emitter
emitter: AGUIEmitter[StateT, ResultT] = AGUIEmitter(use_deltas=use_deltas)
deps.agui_emitter = emitter # type: ignore[assignment]
async def _execute() -> None:
try:
# Start the run with initial state
emitter.start_run(initial_state=state)
# Execute the graph
result = await graph.run(state=state, deps=deps)
if result is None:
raise RuntimeError("Graph did not produce a result")
# Finish the run with the result
emitter.finish_run(result)
except Exception as exc:
# Emit error event
emitter.error(exc)
finally:
await emitter.close()
runner = asyncio.create_task(_execute())
try:
async for event in emitter:
yield event
finally:
if not runner.done():
runner.cancel()
with suppress(asyncio.CancelledError):
await runner

View file

@ -0,0 +1,5 @@
"""Common utilities for graph implementations."""
from haiku.rag.graph.common.utils import get_model
__all__ = ["get_model"]

View file

@ -0,0 +1,265 @@
"""Common node implementations for graph workflows."""
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
from pydantic_ai import Agent, RunContext
from pydantic_ai.format_prompt import format_as_xml
from pydantic_ai.output import ToolOutput
from pydantic_graph.beta import StepContext
from haiku.rag.client import HaikuRAG
from haiku.rag.graph.agui.emitter import AGUIEmitter
from haiku.rag.graph.common import get_model
from haiku.rag.graph.common.models import ResearchPlan, SearchAnswer
from haiku.rag.graph.common.prompts import PLAN_PROMPT, SEARCH_AGENT_PROMPT
class GraphContext(Protocol):
"""Protocol for graph context objects."""
original_question: str
sub_questions: list[str]
def add_qa_response(self, qa: SearchAnswer) -> None:
"""Add a QA response to context."""
...
class GraphState(Protocol):
"""Protocol for graph state objects."""
context: GraphContext
max_concurrency: int
class GraphDeps(Protocol):
"""Protocol for graph dependencies."""
client: HaikuRAG
agui_emitter: AGUIEmitter[Any, Any] | None
semaphore: asyncio.Semaphore | None
class GraphAgentDeps(Protocol):
"""Protocol for agent dependencies."""
client: HaikuRAG
context: GraphContext
def create_plan_node[AgentDepsT: GraphAgentDeps](
provider: str,
model: str,
deps_type: type[AgentDepsT],
activity_message: str = "Creating plan",
output_retries: int | None = None,
) -> Callable[[StepContext[Any, Any, None]], Awaitable[None]]:
"""Create a plan node for any graph.
Args:
provider: Model provider (e.g., 'openai', 'anthropic')
model: Model name
deps_type: Type of dependencies for the agent (e.g., ResearchDependencies, DeepQADependencies)
activity_message: Message to show during planning activity
output_retries: Number of output retries for the agent (optional)
Returns:
Async function that can be used as a graph step
"""
async def plan(ctx: StepContext[Any, Any, None], /) -> None:
state: GraphState = ctx.state # type: ignore[assignment]
deps: GraphDeps = ctx.deps # type: ignore[assignment]
if deps.agui_emitter:
deps.agui_emitter.start_step("plan")
deps.agui_emitter.update_activity("planning", activity_message)
try:
# Build agent configuration
agent_config = {
"model": get_model(provider, model),
"output_type": ResearchPlan,
"instructions": (
PLAN_PROMPT
+ "\n\nUse the gather_context tool once on the main question before planning."
),
"retries": 3,
"deps_type": deps_type,
}
if output_retries is not None:
agent_config["output_retries"] = output_retries
plan_agent = Agent(**agent_config)
@plan_agent.tool
async def gather_context(
ctx2: RunContext[AgentDepsT], query: str, limit: int = 6
) -> str:
results = await ctx2.deps.client.search(query, limit=limit)
expanded = await ctx2.deps.client.expand_context(results)
return "\n\n".join(chunk.content for chunk, _ in expanded)
# Tool is registered via decorator above
_ = gather_context
prompt = (
"Plan a focused approach for the main question.\n\n"
f"Main question: {state.context.original_question}"
)
# Create agent dependencies
agent_deps = deps_type(client=deps.client, context=state.context) # type: ignore[call-arg]
plan_result = await plan_agent.run(prompt, deps=agent_deps)
state.context.sub_questions = list(plan_result.output.sub_questions)
# State now contains the plan - emit state update and narrate
if deps.agui_emitter:
deps.agui_emitter.update_state(state)
count = len(state.context.sub_questions)
deps.agui_emitter.update_activity(
"planning", f"Created plan with {count} sub-questions"
)
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
return plan
def create_search_node[AgentDepsT: GraphAgentDeps](
provider: str,
model: str,
deps_type: type[AgentDepsT],
with_step_wrapper: bool = True,
success_message_format: str = "Answered: {sub_q}",
handle_exceptions: bool = False,
) -> Callable[[StepContext[Any, Any, str]], Awaitable[SearchAnswer]]:
"""Create a search_one node for any graph.
Args:
provider: Model provider
model: Model name
deps_type: Type of dependencies for the agent
with_step_wrapper: Whether to wrap with agui_emitter start/finish step
success_message_format: Format string for success activity message
handle_exceptions: Whether to handle exceptions with fallback answer
Returns:
Async function that can be used as a graph step
"""
async def search_one(ctx: StepContext[Any, Any, str], /) -> SearchAnswer:
state: GraphState = ctx.state # type: ignore[assignment]
deps: GraphDeps = ctx.deps # type: ignore[assignment]
sub_q = ctx.inputs
# Create unique step name from question text
step_name = f"search: {sub_q}"
if deps.agui_emitter and with_step_wrapper:
deps.agui_emitter.start_step(step_name)
try:
# Create semaphore if not already provided
if deps.semaphore is None:
deps.semaphore = asyncio.Semaphore(state.max_concurrency)
# Use semaphore to control concurrency
async with deps.semaphore:
return await _do_search(
state,
deps,
sub_q,
provider,
model,
deps_type,
success_message_format,
handle_exceptions,
)
finally:
if deps.agui_emitter and with_step_wrapper:
deps.agui_emitter.finish_step()
return search_one
async def _do_search[AgentDepsT: GraphAgentDeps](
state: GraphState,
deps: GraphDeps,
sub_q: str,
provider: str,
model: str,
deps_type: type[AgentDepsT],
success_message_format: str,
handle_exceptions: bool,
) -> SearchAnswer:
"""Internal search implementation."""
if deps.agui_emitter:
deps.agui_emitter.update_activity("searching", f"Searching: {sub_q}")
agent = Agent(
model=get_model(provider, model),
output_type=ToolOutput(SearchAnswer, max_retries=3),
instructions=SEARCH_AGENT_PROMPT,
retries=3,
deps_type=deps_type,
)
@agent.tool
async def search_and_answer(
ctx2: RunContext[AgentDepsT], query: str, limit: int = 5
) -> str:
search_results = await ctx2.deps.client.search(query, limit=limit)
expanded = await ctx2.deps.client.expand_context(search_results)
entries: list[dict[str, Any]] = [
{
"text": chunk.content,
"score": score,
"document_uri": (chunk.document_title or chunk.document_uri or ""),
}
for chunk, score in expanded
]
if not entries:
return f"No relevant information found in the knowledge base for: {query}"
return format_as_xml(entries, root_tag="snippets")
# Tool is registered via decorator above
_ = search_and_answer
agent_deps = deps_type(client=deps.client, context=state.context) # type: ignore[call-arg]
try:
result = await agent.run(sub_q, deps=agent_deps)
answer = result.output
if answer:
state.context.add_qa_response(answer)
# State updated with new answer - emit state update and narrate
if deps.agui_emitter:
deps.agui_emitter.update_state(state)
# Format the success message
if "{confidence" in success_message_format:
message = success_message_format.format(
sub_q=sub_q, confidence=answer.confidence
)
else:
message = success_message_format.format(sub_q=sub_q)
deps.agui_emitter.update_activity("searching", message)
return answer
except Exception as e:
if handle_exceptions:
# Narrate the error
if deps.agui_emitter:
deps.agui_emitter.update_activity("searching", f"Search failed: {e}")
failure_answer = SearchAnswer(
query=sub_q,
answer=f"Search failed after retries: {str(e)}",
confidence=0.0,
)
return failure_answer
else:
raise

View file

@ -1,7 +1,5 @@
"""Common utilities for all graph implementations."""
from typing import Any, Protocol
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
@ -9,12 +7,6 @@ from pydantic_ai.providers.openai import OpenAIProvider
from haiku.rag.config import Config
class HasEmitLog(Protocol):
"""Protocol for objects that can emit log messages."""
def emit_log(self, message: str, state: Any = None) -> None: ...
def get_model(provider: str, model: str) -> OpenAIChatModel | str:
"""
Get a model instance for the specified provider and model name.
@ -50,15 +42,3 @@ def get_model(provider: str, model: str) -> OpenAIChatModel | str:
f"Unknown model provider: {provider}. "
f"Supported providers: ollama, vllm, openai, anthropic, gemini, groq, bedrock"
)
def log(deps: HasEmitLog, state: Any, message: str) -> None:
"""
Emit a log message through the dependencies.
Args:
deps: Dependencies object with emit_log method
state: Current state (passed to emit_log)
message: The message to log
"""
deps.emit_log(message, state)

View file

@ -0,0 +1 @@
from haiku.rag.graph.deep_qa.models import DeepQAAnswer

View file

@ -1,8 +1,7 @@
from pydantic import BaseModel, Field
from rich.console import Console
from haiku.rag.client import HaikuRAG
from haiku.rag.graph_common.models import SearchAnswer
from haiku.rag.graph.common.models import SearchAnswer
class DeepQAContext(BaseModel):
@ -26,4 +25,3 @@ class DeepQADependencies(BaseModel):
client: HaikuRAG = Field(description="RAG client for document operations")
context: DeepQAContext = Field(description="Shared QA context")
console: Console | None = None

View file

@ -0,0 +1,243 @@
from pydantic_ai import Agent
from pydantic_ai.format_prompt import format_as_xml
from pydantic_graph.beta import Graph, GraphBuilder, StepContext
from pydantic_graph.beta.join import reduce_list_append
from haiku.rag.config import Config
from haiku.rag.config.models import AppConfig
from haiku.rag.graph.common import get_model
from haiku.rag.graph.common.models import SearchAnswer
from haiku.rag.graph.common.nodes import create_plan_node, create_search_node
from haiku.rag.graph.deep_qa.dependencies import DeepQADependencies
from haiku.rag.graph.deep_qa.models import DeepQAAnswer, DeepQAEvaluation
from haiku.rag.graph.deep_qa.prompts import (
DECISION_PROMPT,
SYNTHESIS_PROMPT,
SYNTHESIS_PROMPT_WITH_CITATIONS,
)
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
def build_deep_qa_graph(
config: AppConfig = Config,
) -> Graph[DeepQAState, DeepQADeps, None, DeepQAAnswer]:
"""Build the Deep QA graph.
Args:
config: AppConfig object (uses config.qa for provider, model, and graph parameters)
Returns:
Configured Deep QA graph
"""
provider = config.qa.provider
model = config.qa.model
g = GraphBuilder(
state_type=DeepQAState,
deps_type=DeepQADeps,
output_type=DeepQAAnswer,
)
# Create and register the plan node using the factory
plan = g.step(
create_plan_node(
provider=provider,
model=model,
deps_type=DeepQADependencies, # type: ignore[arg-type]
activity_message="Planning approach",
output_retries=None, # Deep QA doesn't use output_retries
)
) # type: ignore[arg-type]
# Create and register the search_one node using the factory
search_one = g.step(
create_search_node(
provider=provider,
model=model,
deps_type=DeepQADependencies, # type: ignore[arg-type]
with_step_wrapper=False, # Deep QA doesn't wrap with agui_emitter step
success_message_format="Answered: {sub_q}",
handle_exceptions=True,
)
) # type: ignore[arg-type]
@g.step
async def get_batch(
ctx: StepContext[DeepQAState, DeepQADeps, None | bool],
) -> list[str] | None:
"""Get all remaining questions for this iteration."""
state = ctx.state
if not state.context.sub_questions:
return None
# Take ALL remaining questions - max_concurrency controls parallel execution within .map()
batch = list(state.context.sub_questions)
state.context.sub_questions.clear()
return batch
@g.step
async def decide(
ctx: StepContext[DeepQAState, DeepQADeps, list[SearchAnswer]],
) -> bool:
state = ctx.state
deps = ctx.deps
if deps.agui_emitter:
deps.agui_emitter.start_step("decide")
deps.agui_emitter.update_activity(
"evaluating", "Evaluating information sufficiency"
)
try:
agent = Agent(
model=get_model(provider, model),
output_type=DeepQAEvaluation,
instructions=DECISION_PROMPT,
retries=3,
deps_type=DeepQADependencies,
)
context_data = {
"original_question": state.context.original_question,
"gathered_answers": [
{
"question": qa.query,
"answer": qa.answer,
"sources": qa.sources,
}
for qa in state.context.qa_responses
],
}
context_xml = format_as_xml(context_data, root_tag="gathered_information")
prompt = (
"Evaluate whether we have sufficient information to answer the question.\n\n"
f"{context_xml}"
)
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
)
result = await agent.run(prompt, deps=agent_deps)
evaluation = result.output
state.iterations += 1
for new_q in evaluation.new_questions:
if new_q not in state.context.sub_questions:
state.context.sub_questions.append(new_q)
if deps.agui_emitter:
deps.agui_emitter.update_state(state)
status = "sufficient" if evaluation.is_sufficient else "insufficient"
deps.agui_emitter.update_activity(
"evaluating",
f"Information {status} after {state.iterations} iteration(s)",
)
should_continue = (
not evaluation.is_sufficient and state.iterations < state.max_iterations
)
return should_continue
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
@g.step
async def synthesize(
ctx: StepContext[DeepQAState, DeepQADeps, None | bool],
) -> DeepQAAnswer:
state = ctx.state
deps = ctx.deps
if deps.agui_emitter:
deps.agui_emitter.start_step("synthesize")
deps.agui_emitter.update_activity(
"synthesizing", "Synthesizing final answer"
)
try:
prompt_template = (
SYNTHESIS_PROMPT_WITH_CITATIONS
if state.context.use_citations
else SYNTHESIS_PROMPT
)
agent = Agent(
model=get_model(provider, model),
output_type=DeepQAAnswer,
instructions=prompt_template,
retries=3,
deps_type=DeepQADependencies,
)
context_data = {
"original_question": state.context.original_question,
"sub_answers": [
{
"question": qa.query,
"answer": qa.answer,
"sources": qa.sources,
}
for qa in state.context.qa_responses
],
}
context_xml = format_as_xml(context_data, root_tag="gathered_information")
prompt = f"Synthesize a comprehensive answer to the original question.\n\n{context_xml}"
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
)
result = await agent.run(prompt, deps=agent_deps)
if deps.agui_emitter:
deps.agui_emitter.update_activity("synthesizing", "Answer complete")
return result.output
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
# Build the graph structure
collect_answers = g.join(
reduce_list_append,
initial_factory=list[SearchAnswer],
)
g.add(
g.edge_from(g.start_node).to(plan),
g.edge_from(plan).to(get_batch),
)
# Branch based on whether we have questions
g.add(
g.edge_from(get_batch).to(
g.decision()
.branch(g.match(list).label("Has questions").map().to(search_one))
.branch(g.match(type(None)).label("No questions").to(synthesize))
),
g.edge_from(search_one).to(collect_answers),
g.edge_from(collect_answers).to(decide),
)
# Branch based on decision
g.add(
g.edge_from(decide).to(
g.decision()
.branch(
g.match(bool, matches=lambda x: x).label("Continue QA").to(get_batch)
)
.branch(
g.match(bool, matches=lambda x: not x)
.label("Done with QA")
.to(synthesize)
)
),
g.edge_from(synthesize).to(g.end_node),
)
return g.build()

View file

@ -2,33 +2,40 @@ import asyncio
from dataclasses import dataclass
from typing import TYPE_CHECKING
from rich.console import Console
from pydantic import BaseModel, Field
from haiku.rag.client import HaikuRAG
from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
from haiku.rag.graph.deep_qa.models import DeepQAAnswer
if TYPE_CHECKING:
from haiku.rag.config.models import AppConfig
from haiku.rag.graph.agui.emitter import AGUIEmitter
@dataclass
class DeepQADeps:
client: HaikuRAG
console: Console | None = None
agui_emitter: "AGUIEmitter[DeepQAState, DeepQAAnswer] | None" = None
semaphore: asyncio.Semaphore | None = None
def emit_log(self, message: str, state: "DeepQAState | None" = None) -> None:
if self.console:
self.console.print(message)
class DeepQAState(BaseModel):
"""Deep QA state for multi-agent question answering."""
@dataclass
class DeepQAState:
context: DeepQAContext
max_sub_questions: int = 3
max_iterations: int = 2
max_concurrency: int = 1
iterations: int = 0
model_config = {"arbitrary_types_allowed": True}
context: DeepQAContext = Field(description="Shared QA context")
max_sub_questions: int = Field(
default=3, description="Maximum number of sub-questions"
)
max_iterations: int = Field(
default=2, description="Maximum number of QA iterations"
)
max_concurrency: int = Field(
default=1, description="Maximum parallel sub-question searches"
)
iterations: int = Field(default=0, description="Current iteration number")
@classmethod
def from_config(cls, context: DeepQAContext, config: "AppConfig") -> "DeepQAState":

View file

@ -0,0 +1,3 @@
from haiku.rag.graph.common.models import SearchAnswer
from haiku.rag.graph.research.dependencies import ResearchContext, ResearchDependencies
from haiku.rag.graph.research.models import EvaluationResult, ResearchReport

View file

@ -1,7 +1,7 @@
from pydantic_ai import format_as_xml
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.models import InsightAnalysis
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.models import InsightAnalysis
def format_context_for_prompt(context: ResearchContext) -> str:

View file

@ -1,16 +1,14 @@
from collections.abc import Iterable
from pydantic import BaseModel, Field
from rich.console import Console
from pydantic import BaseModel, Field, PrivateAttr
from haiku.rag.client import HaikuRAG
from haiku.rag.graph_common.models import SearchAnswer
from haiku.rag.research.models import (
from haiku.rag.graph.common.models import SearchAnswer
from haiku.rag.graph.research.models import (
GapRecord,
InsightAnalysis,
InsightRecord,
)
from haiku.rag.research.stream import ResearchStream
class ResearchContext(BaseModel):
@ -30,26 +28,29 @@ class ResearchContext(BaseModel):
default_factory=list, description="Identified information gaps"
)
# Private dict indexes for O(1) lookups
_insights_by_id: dict[str, InsightRecord] = PrivateAttr(default_factory=dict)
_gaps_by_id: dict[str, GapRecord] = PrivateAttr(default_factory=dict)
def model_post_init(self, __context: object) -> None:
"""Build indexes after initialization."""
self._insights_by_id = {ins.id: ins for ins in self.insights}
self._gaps_by_id = {gap.id: gap for gap in self.gaps}
def add_qa_response(self, qa: SearchAnswer) -> None:
"""Add a structured QA response (minimal context already included)."""
self.qa_responses.append(qa)
def upsert_insights(self, records: Iterable[InsightRecord]) -> list[InsightRecord]:
"""Merge one or more insights into the shared context with deduplication."""
merged: list[InsightRecord] = []
for record in records:
candidate = InsightRecord.model_validate(record)
existing = next(
(ins for ins in self.insights if ins.id == candidate.id), None
)
if not existing:
existing = next(
(ins for ins in self.insights if ins.summary == candidate.summary),
None,
)
existing = self._insights_by_id.get(candidate.id)
if existing:
# Update existing insight
existing.summary = candidate.summary
existing.status = candidate.status
if candidate.notes:
@ -62,36 +63,24 @@ class ResearchContext(BaseModel):
)
merged.append(existing)
else:
candidate = candidate.model_copy(deep=True)
if candidate.id is None: # pragma: no cover - defensive
raise ValueError(
"InsightRecord.id must be populated after validation"
)
candidate_id: str = candidate.id
candidate.id = self._allocate_insight_id(candidate_id)
self.insights.append(candidate)
merged.append(candidate)
# Add new insight
new_insight = candidate.model_copy(deep=True)
self.insights.append(new_insight)
self._insights_by_id[new_insight.id] = new_insight
merged.append(new_insight)
return merged
def upsert_gaps(self, records: Iterable[GapRecord]) -> list[GapRecord]:
"""Merge one or more gap records into the shared context with deduplication."""
merged: list[GapRecord] = []
for record in records:
candidate = GapRecord.model_validate(record)
existing = next((gap for gap in self.gaps if gap.id == candidate.id), None)
if not existing:
existing = next(
(
gap
for gap in self.gaps
if gap.description == candidate.description
),
None,
)
existing = self._gaps_by_id.get(candidate.id)
if existing:
# Update existing gap
existing.description = candidate.description
existing.severity = candidate.severity
existing.blocking = candidate.blocking
@ -106,22 +95,19 @@ class ResearchContext(BaseModel):
)
merged.append(existing)
else:
candidate = candidate.model_copy(deep=True)
if candidate.id is None: # pragma: no cover - defensive
raise ValueError("GapRecord.id must be populated after validation")
candidate_id: str = candidate.id
candidate.id = self._allocate_gap_id(candidate_id)
self.gaps.append(candidate)
merged.append(candidate)
# Add new gap
new_gap = candidate.model_copy(deep=True)
self.gaps.append(new_gap)
self._gaps_by_id[new_gap.id] = new_gap
merged.append(new_gap)
return merged
def mark_gap_resolved(
self, identifier: str, resolved_by: Iterable[str] | None = None
) -> GapRecord | None:
"""Mark a gap as resolved by identifier (id or description)."""
gap = self._find_gap(identifier)
"""Mark a gap as resolved by identifier."""
gap = self._gaps_by_id.get(identifier)
if gap is None:
return None
@ -133,7 +119,6 @@ class ResearchContext(BaseModel):
def integrate_analysis(self, analysis: InsightAnalysis) -> None:
"""Apply an analysis result to the shared context."""
merged_insights: list[InsightRecord] = []
if analysis.highlights:
merged_insights = self.upsert_insights(analysis.highlights)
@ -143,9 +128,7 @@ class ResearchContext(BaseModel):
analysis.gap_assessments = merged_gaps
if analysis.resolved_gaps:
resolved_by_list = (
[ins.id for ins in merged_insights if ins.id is not None]
if merged_insights
else None
[ins.id for ins in merged_insights] if merged_insights else None
)
for resolved in analysis.resolved_gaps:
self.mark_gap_resolved(resolved, resolved_by=resolved_by_list)
@ -153,29 +136,6 @@ class ResearchContext(BaseModel):
if question not in self.sub_questions:
self.sub_questions.append(question)
def _allocate_insight_id(self, candidate_id: str) -> str:
taken: set[str] = set()
for ins in self.insights:
if ins.id is not None:
taken.add(ins.id)
return _allocate_sequential_id(candidate_id, taken)
def _allocate_gap_id(self, candidate_id: str) -> str:
taken: set[str] = set()
for gap in self.gaps:
if gap.id is not None:
taken.add(gap.id)
return _allocate_sequential_id(candidate_id, taken)
def _find_gap(self, identifier: str) -> GapRecord | None:
normalized = identifier.lower().strip()
for gap in self.gaps:
if gap.id is not None and gap.id == normalized:
return gap
if gap.description.lower().strip() == normalized:
return gap
return None
class ResearchDependencies(BaseModel):
"""Dependencies for research agents with multi-agent context."""
@ -184,32 +144,8 @@ class ResearchDependencies(BaseModel):
client: HaikuRAG = Field(description="RAG client for document operations")
context: ResearchContext = Field(description="Shared research context")
console: Console | None = None
stream: ResearchStream | None = Field(
default=None, description="Optional research event stream"
)
def _merge_unique(existing: list[str], incoming: Iterable[str]) -> list[str]:
"""Merge two iterables preserving order while removing duplicates."""
merged = list(existing)
seen = {item for item in existing if item}
for item in incoming:
if item and item not in seen:
merged.append(item)
seen.add(item)
return merged
def _allocate_sequential_id(candidate: str, taken: set[str]) -> str:
slug = candidate
if slug not in taken:
return slug
base = slug
counter = 2
while True:
slug = f"{base}-{counter}"
if slug not in taken:
return slug
counter += 1
return [k for k in dict.fromkeys([*existing, *incoming]) if k]

View file

@ -0,0 +1,295 @@
from pydantic_ai import Agent
from pydantic_graph.beta import Graph, GraphBuilder, StepContext
from pydantic_graph.beta.join import reduce_list_append
from haiku.rag.config import Config
from haiku.rag.config.models import AppConfig
from haiku.rag.graph.common import get_model
from haiku.rag.graph.common.models import SearchAnswer
from haiku.rag.graph.common.nodes import create_plan_node, create_search_node
from haiku.rag.graph.research.common import (
format_analysis_for_prompt,
format_context_for_prompt,
)
from haiku.rag.graph.research.dependencies import ResearchDependencies
from haiku.rag.graph.research.models import (
EvaluationResult,
InsightAnalysis,
ResearchReport,
)
from haiku.rag.graph.research.prompts import (
DECISION_AGENT_PROMPT,
INSIGHT_AGENT_PROMPT,
SYNTHESIS_AGENT_PROMPT,
)
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
def build_research_graph(
config: AppConfig = Config,
) -> Graph[ResearchState, ResearchDeps, None, ResearchReport]:
"""Build the Research graph.
Args:
config: AppConfig object (uses config.research for provider, model, and graph parameters)
Returns:
Configured Research graph
"""
provider = config.research.provider
model = config.research.model
g = GraphBuilder(
state_type=ResearchState,
deps_type=ResearchDeps,
output_type=ResearchReport,
)
# Create and register the plan node using the factory
plan = g.step(
create_plan_node(
provider=provider,
model=model,
deps_type=ResearchDependencies, # type: ignore[arg-type]
activity_message="Creating research plan",
output_retries=3,
)
) # type: ignore[arg-type]
# Create and register the search_one node using the factory
search_one = g.step(
create_search_node(
provider=provider,
model=model,
deps_type=ResearchDependencies, # type: ignore[arg-type]
with_step_wrapper=True,
success_message_format="Found answer with {confidence:.0%} confidence",
handle_exceptions=True,
)
) # type: ignore[arg-type]
@g.step
async def get_batch(
ctx: StepContext[ResearchState, ResearchDeps, None | bool],
) -> list[str] | None:
"""Get all remaining questions for this iteration."""
state = ctx.state
if not state.context.sub_questions:
return None
# Take ALL remaining questions and process them in parallel
batch = list(state.context.sub_questions)
state.context.sub_questions.clear()
return batch
@g.step
async def analyze_insights(
ctx: StepContext[ResearchState, ResearchDeps, list[SearchAnswer]],
) -> None:
state = ctx.state
deps = ctx.deps
if deps.agui_emitter:
deps.agui_emitter.start_step("analyze_insights")
deps.agui_emitter.update_activity(
"analyzing", "Synthesizing insights and gaps"
)
try:
agent = Agent(
model=get_model(provider, model),
output_type=InsightAnalysis,
instructions=INSIGHT_AGENT_PROMPT,
retries=3,
output_retries=3,
deps_type=ResearchDependencies,
)
context_xml = format_context_for_prompt(state.context)
prompt = (
"Review the latest research context and update the shared ledger of insights, gaps,"
" and follow-up questions.\n\n"
f"{context_xml}"
)
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
)
result = await agent.run(prompt, deps=agent_deps)
analysis: InsightAnalysis = result.output
state.context.integrate_analysis(analysis)
state.last_analysis = analysis
# State updated with insights/gaps - emit state update and narrate
if deps.agui_emitter:
deps.agui_emitter.update_state(state)
highlights = len(analysis.highlights) if analysis.highlights else 0
gaps = len(analysis.gap_assessments) if analysis.gap_assessments else 0
resolved = len(analysis.resolved_gaps) if analysis.resolved_gaps else 0
parts = []
if highlights:
parts.append(f"{highlights} insights")
if gaps:
parts.append(f"{gaps} gaps")
if resolved:
parts.append(f"{resolved} resolved")
summary = ", ".join(parts) if parts else "No updates"
deps.agui_emitter.update_activity("analyzing", f"Analysis: {summary}")
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
@g.step
async def decide(ctx: StepContext[ResearchState, ResearchDeps, None]) -> bool:
state = ctx.state
deps = ctx.deps
if deps.agui_emitter:
deps.agui_emitter.start_step("decide")
deps.agui_emitter.update_activity(
"evaluating", "Evaluating research sufficiency"
)
try:
agent = Agent(
model=get_model(provider, model),
output_type=EvaluationResult,
instructions=DECISION_AGENT_PROMPT,
retries=3,
output_retries=3,
deps_type=ResearchDependencies,
)
context_xml = format_context_for_prompt(state.context)
analysis_xml = format_analysis_for_prompt(state.last_analysis)
prompt_parts = [
"Assess whether the research now answers the original question with adequate confidence.",
context_xml,
analysis_xml,
]
if state.last_eval is not None:
prev = state.last_eval
prompt_parts.append(
"<previous_evaluation>"
f"<confidence>{prev.confidence_score:.2f}</confidence>"
f"<is_sufficient>{str(prev.is_sufficient).lower()}</is_sufficient>"
f"<reasoning>{prev.reasoning}</reasoning>"
"</previous_evaluation>"
)
prompt = "\n\n".join(part for part in prompt_parts if part)
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
)
decision_result = await agent.run(prompt, deps=agent_deps)
output = decision_result.output
state.last_eval = output
state.iterations += 1
for new_q in output.new_questions:
if new_q not in state.context.sub_questions:
state.context.sub_questions.append(new_q)
# State updated with evaluation - emit state update and narrate
if deps.agui_emitter:
deps.agui_emitter.update_state(state)
sufficient = "Yes" if output.is_sufficient else "No"
deps.agui_emitter.update_activity(
"evaluating",
f"Confidence: {output.confidence_score:.0%}, Sufficient: {sufficient}",
)
should_continue = (
not output.is_sufficient
or output.confidence_score < state.confidence_threshold
) and state.iterations < state.max_iterations
return should_continue
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
@g.step
async def synthesize(
ctx: StepContext[ResearchState, ResearchDeps, None | bool],
) -> ResearchReport:
state = ctx.state
deps = ctx.deps
if deps.agui_emitter:
deps.agui_emitter.start_step("synthesize")
deps.agui_emitter.update_activity(
"synthesizing", "Generating final research report"
)
try:
agent = Agent(
model=get_model(provider, model),
output_type=ResearchReport,
instructions=SYNTHESIS_AGENT_PROMPT,
retries=3,
output_retries=3,
deps_type=ResearchDependencies,
)
context_xml = format_context_for_prompt(state.context)
prompt = (
"Generate a comprehensive research report based on all gathered information.\n\n"
f"{context_xml}\n\n"
"Create a detailed report that synthesizes all findings into a coherent response."
)
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
)
result = await agent.run(prompt, deps=agent_deps)
return result.output
finally:
if deps.agui_emitter:
deps.agui_emitter.finish_step()
# Build the graph structure
collect_answers = g.join(
reduce_list_append,
initial_factory=list[SearchAnswer],
)
g.add(
g.edge_from(g.start_node).to(plan),
g.edge_from(plan).to(get_batch),
)
# Branch based on whether we have questions
g.add(
g.edge_from(get_batch).to(
g.decision()
.branch(g.match(list).label("Has questions").map().to(search_one))
.branch(g.match(type(None)).label("No questions").to(synthesize))
),
g.edge_from(search_one).to(collect_answers),
g.edge_from(collect_answers).to(analyze_insights),
g.edge_from(analyze_insights).to(decide),
)
# Branch based on decision
g.add(
g.edge_from(decide).to(
g.decision()
.branch(
g.match(bool, matches=lambda x: x)
.label("Continue research")
.to(get_batch)
)
.branch(
g.match(bool, matches=lambda x: not x)
.label("Done researching")
.to(synthesize)
)
),
g.edge_from(synthesize).to(g.end_node),
)
return g.build()

View file

@ -1,19 +1,12 @@
import re
import uuid
from enum import Enum
from pydantic import BaseModel, Field, model_validator
_SLUG_RE = re.compile(r"[^a-z0-9]+")
from pydantic import BaseModel, Field, field_validator
def _make_slug(text: str, prefix: str) -> str:
"""Generate a lowercase slug with the given prefix as fallback."""
base = _SLUG_RE.sub("-", text.lower()).strip("-")
if not base:
base = prefix
# Trim overly long slugs but keep enough entropy for readability
return base[:48]
def _deduplicate_list(items: list[str]) -> list[str]:
"""Remove duplicates while preserving order."""
return list(dict.fromkeys(items))
class InsightStatus(str, Enum):
@ -28,48 +21,54 @@ class GapSeverity(str, Enum):
HIGH = "high"
class InsightRecord(BaseModel):
class TrackedRecord(BaseModel):
"""Base model for tracked entities with sources and metadata."""
model_config = {"validate_assignment": True}
id: str = Field(
default_factory=lambda: str(uuid.uuid4())[:8],
description="Unique identifier for the record",
)
supporting_sources: list[str] = Field(
default_factory=list,
description="Source identifiers backing this record",
)
notes: str | None = Field(
default=None,
description="Optional elaboration or caveats",
)
@field_validator("supporting_sources", mode="before")
@classmethod
def deduplicate_sources(cls, v: list[str]) -> list[str]:
"""Ensure supporting_sources has no duplicates."""
return _deduplicate_list(v) if v else []
class InsightRecord(TrackedRecord):
"""Structured insight with provenance and lifecycle metadata."""
id: str | None = Field(
default=None,
description="Stable slug identifier for the insight (auto-generated if omitted)",
)
summary: str = Field(description="Concise description of the insight")
status: InsightStatus = Field(
default=InsightStatus.OPEN,
description="Lifecycle status for the insight",
)
supporting_sources: list[str] = Field(
default_factory=list,
description="Source identifiers backing the insight",
)
originating_questions: list[str] = Field(
default_factory=list,
description="Research sub-questions that produced this insight",
)
notes: str | None = Field(
default=None,
description="Optional elaboration or caveats for the insight",
)
@model_validator(mode="after")
def _set_defaults(self) -> "InsightRecord":
if not self.id:
self.id = _make_slug(self.summary, "insight")
self.id = self.id.lower()
self.supporting_sources = list(dict.fromkeys(self.supporting_sources))
self.originating_questions = list(dict.fromkeys(self.originating_questions))
return self
@field_validator("originating_questions", mode="before")
@classmethod
def deduplicate_questions(cls, v: list[str]) -> list[str]:
"""Ensure originating_questions has no duplicates."""
return _deduplicate_list(v) if v else []
class GapRecord(BaseModel):
class GapRecord(TrackedRecord):
"""Structured representation of an identified research gap."""
id: str | None = Field(
default=None,
description="Stable slug identifier for the gap (auto-generated if omitted)",
)
description: str = Field(description="Concrete statement of what is missing")
severity: GapSeverity = Field(
default=GapSeverity.MEDIUM,
@ -87,23 +86,12 @@ class GapRecord(BaseModel):
default_factory=list,
description="Insight IDs or notes explaining how the gap was closed",
)
supporting_sources: list[str] = Field(
default_factory=list,
description="Sources confirming the gap status (e.g., evidence of absence)",
)
notes: str | None = Field(
default=None,
description="Optional clarification about the gap or follow-up actions",
)
@model_validator(mode="after")
def _set_defaults(self) -> "GapRecord":
if not self.id:
self.id = _make_slug(self.description, "gap")
self.id = self.id.lower()
self.resolved_by = list(dict.fromkeys(self.resolved_by))
self.supporting_sources = list(dict.fromkeys(self.supporting_sources))
return self
@field_validator("resolved_by", mode="before")
@classmethod
def deduplicate_resolved_by(cls, v: list[str]) -> list[str]:
"""Ensure resolved_by has no duplicates."""
return _deduplicate_list(v) if v else []
class InsightAnalysis(BaseModel):

View file

@ -0,0 +1,85 @@
import asyncio
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from haiku.rag.client import HaikuRAG
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.models import (
EvaluationResult,
InsightAnalysis,
ResearchReport,
)
if TYPE_CHECKING:
from haiku.rag.config.models import AppConfig
from haiku.rag.graph.agui.emitter import AGUIEmitter
@dataclass
class ResearchDeps:
"""Dependencies for research graph execution."""
client: HaikuRAG
agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None
semaphore: asyncio.Semaphore | None = None
def emit_log(self, message: str, state: "ResearchState | None" = None) -> None:
"""Emit a log message through AG-UI events.
Args:
message: The message to log
state: Optional state to include in state update
"""
if self.agui_emitter:
self.agui_emitter.log(message)
if state:
self.agui_emitter.update_state(state)
class ResearchState(BaseModel):
"""Research graph state model.
Fully JSON-serializable Pydantic model suitable for AG-UI state synchronization.
"""
model_config = {"arbitrary_types_allowed": True}
context: ResearchContext = Field(
description="Shared research context with questions, insights, and gaps"
)
iterations: int = Field(default=0, description="Current iteration number")
max_iterations: int = Field(default=3, description="Maximum allowed iterations")
confidence_threshold: float = Field(
default=0.8, description="Confidence threshold for completion", ge=0.0, le=1.0
)
max_concurrency: int = Field(
default=1, description="Maximum concurrent search operations", ge=1
)
last_eval: EvaluationResult | None = Field(
default=None, description="Last evaluation result"
)
last_analysis: InsightAnalysis | None = Field(
default=None, description="Last insight analysis"
)
@classmethod
def from_config(
cls, context: ResearchContext, config: "AppConfig"
) -> "ResearchState":
"""Create a ResearchState from an AppConfig.
Args:
context: The ResearchContext containing the question and settings
config: The AppConfig object (uses config.research for state parameters)
Returns:
A configured ResearchState instance
"""
return cls(
context=context,
max_iterations=config.research.max_iterations,
confidence_threshold=config.research.confidence_threshold,
max_concurrency=config.research.max_concurrency,
)

View file

@ -1,5 +0,0 @@
"""Common utilities for graph implementations."""
from haiku.rag.graph_common.utils import get_model, log
__all__ = ["get_model", "log"]

View file

@ -6,7 +6,7 @@ from pydantic import BaseModel
from haiku.rag.client import HaikuRAG
from haiku.rag.config import AppConfig, Config
from haiku.rag.research.models import ResearchReport
from haiku.rag.graph.research.models import ResearchReport
class SearchResult(BaseModel):
@ -191,9 +191,9 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
try:
async with HaikuRAG(db_path, config=config) as rag:
if deep:
from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.qa.deep.graph import build_deep_qa_graph
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
graph = build_deep_qa_graph(config=config)
context = DeepQAContext(
@ -226,9 +226,9 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
A research report with findings, or None if an error occurred.
"""
try:
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchDeps, ResearchState
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
async with HaikuRAG(db_path, config=config) as rag:
graph = build_research_graph(config=config)

View file

@ -1 +0,0 @@
from haiku.rag.qa.deep.models import DeepQAAnswer

View file

@ -1,363 +0,0 @@
from typing import Any
from pydantic_ai import Agent, RunContext
from pydantic_ai.format_prompt import format_as_xml
from pydantic_ai.output import ToolOutput
from pydantic_graph.beta import Graph, GraphBuilder, StepContext
from pydantic_graph.beta.join import reduce_list_append
from haiku.rag.config import Config
from haiku.rag.config.models import AppConfig
from haiku.rag.graph_common import get_model, log
from haiku.rag.graph_common.models import ResearchPlan, SearchAnswer
from haiku.rag.graph_common.prompts import PLAN_PROMPT, SEARCH_AGENT_PROMPT
from haiku.rag.qa.deep.dependencies import DeepQADependencies
from haiku.rag.qa.deep.models import DeepQAAnswer, DeepQAEvaluation
from haiku.rag.qa.deep.prompts import (
DECISION_PROMPT,
SYNTHESIS_PROMPT,
SYNTHESIS_PROMPT_WITH_CITATIONS,
)
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
def build_deep_qa_graph(
config: AppConfig = Config,
) -> Graph[DeepQAState, DeepQADeps, None, DeepQAAnswer]:
"""Build the Deep QA graph.
Args:
config: AppConfig object (uses config.qa for provider, model, and graph parameters)
Returns:
Configured Deep QA graph
"""
provider = config.qa.provider
model = config.qa.model
g = GraphBuilder(
state_type=DeepQAState,
deps_type=DeepQADeps,
output_type=DeepQAAnswer,
)
@g.step
async def plan(ctx: StepContext[DeepQAState, DeepQADeps, None]) -> None:
state = ctx.state
deps = ctx.deps
log(deps, state, "\n[bold cyan]📋 Planning approach...[/bold cyan]")
plan_agent = Agent(
model=get_model(provider, model),
output_type=ResearchPlan,
instructions=(
PLAN_PROMPT
+ "\n\nUse the gather_context tool once on the main question before planning."
),
retries=3,
deps_type=DeepQADependencies,
)
@plan_agent.tool
async def gather_context(
ctx2: RunContext[DeepQADependencies], query: str, limit: int = 6
) -> str:
results = await ctx2.deps.client.search(query, limit=limit)
expanded = await ctx2.deps.client.expand_context(results)
return "\n\n".join(chunk.content for chunk, _ in expanded)
prompt = (
"Plan a focused approach for the main question.\n\n"
f"Main question: {state.context.original_question}"
)
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
console=deps.console,
)
plan_result = await plan_agent.run(prompt, deps=agent_deps)
state.context.sub_questions = list(plan_result.output.sub_questions)
log(deps, state, "\n[bold green]✅ Plan Created:[/bold green]")
log(
deps,
state,
f" [bold]Main Question:[/bold] {state.context.original_question}",
)
log(deps, state, " [bold]Sub-questions:[/bold]")
for i, sq in enumerate(state.context.sub_questions, 1):
log(deps, state, f" {i}. {sq}")
@g.step
async def search_one(
ctx: StepContext[DeepQAState, DeepQADeps, str],
) -> SearchAnswer:
state = ctx.state
deps = ctx.deps
sub_q = ctx.inputs
# Create semaphore if not already provided
if deps.semaphore is None:
import asyncio
deps.semaphore = asyncio.Semaphore(state.max_concurrency)
# Use semaphore to control concurrency
async with deps.semaphore:
return await _do_search(state, deps, sub_q)
async def _do_search(
state: DeepQAState,
deps: DeepQADeps,
sub_q: str,
) -> SearchAnswer:
log(
deps,
state,
f"\n[bold cyan]🔍 Searching & Answering:[/bold cyan] {sub_q}",
)
agent = Agent(
model=get_model(provider, model),
output_type=ToolOutput(SearchAnswer, max_retries=3),
instructions=SEARCH_AGENT_PROMPT,
retries=3,
deps_type=DeepQADependencies,
)
@agent.tool
async def search_and_answer(
ctx2: RunContext[DeepQADependencies], query: str, limit: int = 5
) -> str:
search_results = await ctx2.deps.client.search(query, limit=limit)
expanded = await ctx2.deps.client.expand_context(search_results)
entries: list[dict[str, Any]] = [
{
"text": chunk.content,
"score": score,
"document_uri": (chunk.document_title or chunk.document_uri or ""),
}
for chunk, score in expanded
]
if not entries:
return (
f"No relevant information found in the knowledge base for: {query}"
)
return format_as_xml(entries, root_tag="snippets")
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
console=deps.console,
)
try:
result = await agent.run(sub_q, deps=agent_deps)
answer = result.output
if answer:
state.context.add_qa_response(answer)
preview = answer.answer[:150] + (
"" if len(answer.answer) > 150 else ""
)
log(deps, state, f" [green]✓[/green] {preview}")
return answer
except Exception as e:
log(deps, state, f"[red]Search failed:[/red] {e}")
failure_answer = SearchAnswer(
query=sub_q,
answer=f"Search failed after retries: {str(e)}",
confidence=0.0,
)
return failure_answer
@g.step
async def get_batch(
ctx: StepContext[DeepQAState, DeepQADeps, None | bool],
) -> list[str] | None:
"""Get all remaining questions for this iteration."""
state = ctx.state
if not state.context.sub_questions:
return None
# Take ALL remaining questions - max_concurrency controls parallel execution within .map()
batch = list(state.context.sub_questions)
state.context.sub_questions.clear()
return batch
@g.step
async def decide(
ctx: StepContext[DeepQAState, DeepQADeps, list[SearchAnswer]],
) -> bool:
state = ctx.state
deps = ctx.deps
log(
deps,
state,
"\n[bold cyan]📊 Evaluating information sufficiency...[/bold cyan]",
)
agent = Agent(
model=get_model(provider, model),
output_type=DeepQAEvaluation,
instructions=DECISION_PROMPT,
retries=3,
deps_type=DeepQADependencies,
)
context_data = {
"original_question": state.context.original_question,
"gathered_answers": [
{
"question": qa.query,
"answer": qa.answer,
"sources": qa.sources,
}
for qa in state.context.qa_responses
],
}
context_xml = format_as_xml(context_data, root_tag="gathered_information")
prompt = (
"Evaluate whether we have sufficient information to answer the question.\n\n"
f"{context_xml}"
)
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
console=deps.console,
)
result = await agent.run(prompt, deps=agent_deps)
evaluation = result.output
state.iterations += 1
log(deps, state, f" [bold]Assessment:[/bold] {evaluation.reasoning}")
status = "[green]Yes[/green]" if evaluation.is_sufficient else "[red]No[/red]"
log(deps, state, f" Sufficient: {status}")
for new_q in evaluation.new_questions:
if new_q not in state.context.sub_questions:
state.context.sub_questions.append(new_q)
if evaluation.new_questions:
log(deps, state, " [cyan]New questions:[/cyan]")
for question in evaluation.new_questions:
log(deps, state, f"{question}")
should_continue = (
not evaluation.is_sufficient and state.iterations < state.max_iterations
)
if not should_continue:
if state.iterations >= state.max_iterations:
log(
deps,
state,
f"\n[bold yellow]⚠️ Reached max iterations ({state.max_iterations})[/bold yellow]",
)
log(deps, state, "\n[bold green]✅ Moving to synthesis.[/bold green]")
else:
log(
deps,
state,
f"\n[bold cyan]🔄 Starting iteration {state.iterations + 1}...[/bold cyan]",
)
return should_continue
@g.step
async def synthesize(
ctx: StepContext[DeepQAState, DeepQADeps, None | bool],
) -> DeepQAAnswer:
state = ctx.state
deps = ctx.deps
log(
deps,
state,
"\n[bold cyan]📝 Synthesizing final answer...[/bold cyan]",
)
prompt_template = (
SYNTHESIS_PROMPT_WITH_CITATIONS
if state.context.use_citations
else SYNTHESIS_PROMPT
)
agent = Agent(
model=get_model(provider, model),
output_type=DeepQAAnswer,
instructions=prompt_template,
retries=3,
deps_type=DeepQADependencies,
)
context_data = {
"original_question": state.context.original_question,
"sub_answers": [
{
"question": qa.query,
"answer": qa.answer,
"sources": qa.sources,
}
for qa in state.context.qa_responses
],
}
context_xml = format_as_xml(context_data, root_tag="gathered_information")
prompt = f"Synthesize a comprehensive answer to the original question.\n\n{context_xml}"
agent_deps = DeepQADependencies(
client=deps.client,
context=state.context,
console=deps.console,
)
result = await agent.run(prompt, deps=agent_deps)
log(deps, state, "[bold green]✅ Answer complete![/bold green]")
return result.output
# Build the graph structure
collect_answers = g.join(
reduce_list_append,
initial_factory=list[SearchAnswer],
)
g.add(
g.edge_from(g.start_node).to(plan),
g.edge_from(plan).to(get_batch),
)
# Branch based on whether we have questions
g.add(
g.edge_from(get_batch).to(
g.decision()
.branch(g.match(list).label("Has questions").map().to(search_one))
.branch(g.match(type(None)).label("No questions").to(synthesize))
),
g.edge_from(search_one).to(collect_answers),
g.edge_from(collect_answers).to(decide),
)
# Branch based on decision
g.add(
g.edge_from(decide).to(
g.decision()
.branch(
g.match(bool, matches=lambda x: x).label("Continue QA").to(get_batch)
)
.branch(
g.match(bool, matches=lambda x: not x)
.label("Done with QA")
.to(synthesize)
)
),
g.edge_from(synthesize).to(g.end_node),
)
return g.build()

View file

@ -1,3 +0,0 @@
from haiku.rag.graph_common.models import SearchAnswer
from haiku.rag.research.dependencies import ResearchContext, ResearchDependencies
from haiku.rag.research.models import EvaluationResult, ResearchReport

View file

@ -1,429 +0,0 @@
from typing import Any
from pydantic_ai import Agent, RunContext
from pydantic_ai.format_prompt import format_as_xml
from pydantic_ai.output import ToolOutput
from pydantic_graph.beta import Graph, GraphBuilder, StepContext
from pydantic_graph.beta.join import reduce_list_append
from haiku.rag.config import Config
from haiku.rag.config.models import AppConfig
from haiku.rag.graph_common import get_model, log
from haiku.rag.graph_common.models import ResearchPlan, SearchAnswer
from haiku.rag.graph_common.prompts import PLAN_PROMPT, SEARCH_AGENT_PROMPT
from haiku.rag.research.common import (
format_analysis_for_prompt,
format_context_for_prompt,
)
from haiku.rag.research.dependencies import ResearchDependencies
from haiku.rag.research.models import (
EvaluationResult,
InsightAnalysis,
ResearchReport,
)
from haiku.rag.research.prompts import (
DECISION_AGENT_PROMPT,
INSIGHT_AGENT_PROMPT,
SYNTHESIS_AGENT_PROMPT,
)
from haiku.rag.research.state import ResearchDeps, ResearchState
def build_research_graph(
config: AppConfig = Config,
) -> Graph[ResearchState, ResearchDeps, None, ResearchReport]:
"""Build the Research graph.
Args:
config: AppConfig object (uses config.research for provider, model, and graph parameters)
Returns:
Configured Research graph
"""
provider = config.research.provider
model = config.research.model
g = GraphBuilder(
state_type=ResearchState,
deps_type=ResearchDeps,
output_type=ResearchReport,
)
@g.step
async def plan(ctx: StepContext[ResearchState, ResearchDeps, None]) -> None:
state = ctx.state
deps = ctx.deps
log(deps, state, "\n[bold cyan]📋 Creating research plan...[/bold cyan]")
plan_agent = Agent(
model=get_model(provider, model),
output_type=ResearchPlan,
instructions=(
PLAN_PROMPT
+ "\n\nUse the gather_context tool once on the main question before planning."
),
retries=3,
deps_type=ResearchDependencies,
)
@plan_agent.tool
async def gather_context(
ctx2: RunContext[ResearchDependencies], query: str, limit: int = 6
) -> str:
results = await ctx2.deps.client.search(query, limit=limit)
expanded = await ctx2.deps.client.expand_context(results)
return "\n\n".join(chunk.content for chunk, _ in expanded)
prompt = (
"Plan a focused approach for the main question.\n\n"
f"Main question: {state.context.original_question}"
)
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
)
plan_result = await plan_agent.run(prompt, deps=agent_deps)
state.context.sub_questions = list(plan_result.output.sub_questions)
log(deps, state, "\n[bold green]✅ Plan Created:[/bold green]")
log(
deps,
state,
f" [bold]Main Question:[/bold] {state.context.original_question}",
)
log(deps, state, " [bold]Sub-questions:[/bold]")
for i, sq in enumerate(state.context.sub_questions, 1):
log(deps, state, f" {i}. {sq}")
@g.step
async def search_one(
ctx: StepContext[ResearchState, ResearchDeps, str],
) -> SearchAnswer:
state = ctx.state
deps = ctx.deps
sub_q = ctx.inputs
# Create semaphore if not already provided
if deps.semaphore is None:
import asyncio
deps.semaphore = asyncio.Semaphore(state.max_concurrency)
# Use semaphore to control concurrency
async with deps.semaphore:
return await _do_search(state, deps, sub_q)
async def _do_search(
state: ResearchState,
deps: ResearchDeps,
sub_q: str,
) -> SearchAnswer:
log(
deps,
state,
f"\n[bold cyan]🔍 Searching & Answering:[/bold cyan] {sub_q}",
)
agent = Agent(
model=get_model(provider, model),
output_type=ToolOutput(SearchAnswer, max_retries=3),
instructions=SEARCH_AGENT_PROMPT,
retries=3,
deps_type=ResearchDependencies,
)
@agent.tool
async def search_and_answer(
ctx2: RunContext[ResearchDependencies], query: str, limit: int = 5
) -> str:
search_results = await ctx2.deps.client.search(query, limit=limit)
expanded = await ctx2.deps.client.expand_context(search_results)
entries: list[dict[str, Any]] = [
{
"text": chunk.content,
"score": score,
"document_uri": (chunk.document_title or chunk.document_uri or ""),
}
for chunk, score in expanded
]
if not entries:
return (
f"No relevant information found in the knowledge base for: {query}"
)
return format_as_xml(entries, root_tag="snippets")
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
)
try:
result = await agent.run(sub_q, deps=agent_deps)
answer = result.output
if answer:
state.context.add_qa_response(answer)
preview = answer.answer[:150] + (
"" if len(answer.answer) > 150 else ""
)
log(deps, state, f" [green]✓[/green] {preview}")
return answer
except Exception as e:
log(deps, state, f"[red]Search failed:[/red] {e}")
failure_answer = SearchAnswer(
query=sub_q,
answer=f"Search failed after retries: {str(e)}",
confidence=0.0,
)
return failure_answer
@g.step
async def get_batch(
ctx: StepContext[ResearchState, ResearchDeps, None | bool],
) -> list[str] | None:
"""Get all remaining questions for this iteration."""
state = ctx.state
if not state.context.sub_questions:
return None
# Take ALL remaining questions and process them in parallel
batch = list(state.context.sub_questions)
state.context.sub_questions.clear()
return batch
@g.step
async def analyze_insights(
ctx: StepContext[ResearchState, ResearchDeps, list[SearchAnswer]],
) -> None:
state = ctx.state
deps = ctx.deps
log(
deps,
state,
"\n[bold cyan]🧭 Synthesizing new insights and gap status...[/bold cyan]",
)
agent = Agent(
model=get_model(provider, model),
output_type=InsightAnalysis,
instructions=INSIGHT_AGENT_PROMPT,
retries=3,
deps_type=ResearchDependencies,
)
context_xml = format_context_for_prompt(state.context)
prompt = (
"Review the latest research context and update the shared ledger of insights, gaps,"
" and follow-up questions.\n\n"
f"{context_xml}"
)
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
)
result = await agent.run(prompt, deps=agent_deps)
analysis: InsightAnalysis = result.output
state.context.integrate_analysis(analysis)
state.last_analysis = analysis
if analysis.commentary:
log(deps, state, f" Summary: {analysis.commentary}")
if analysis.highlights:
log(deps, state, " [bold]Updated insights:[/bold]")
for insight in analysis.highlights:
label = insight.status.value
log(
deps,
state,
f" • ({label}) {insight.summary}",
)
if analysis.gap_assessments:
log(deps, state, " [bold yellow]Gap updates:[/bold yellow]")
for gap in analysis.gap_assessments:
status = "resolved" if gap.resolved else "open"
severity = gap.severity.value
log(
deps,
state,
f" • ({severity}/{status}) {gap.description}",
)
if analysis.resolved_gaps:
log(deps, state, " [green]Resolved gaps:[/green]")
for resolved in analysis.resolved_gaps:
log(deps, state, f"{resolved}")
if analysis.new_questions:
log(deps, state, " [cyan]Proposed follow-ups:[/cyan]")
for question in analysis.new_questions:
log(deps, state, f"{question}")
@g.step
async def decide(ctx: StepContext[ResearchState, ResearchDeps, None]) -> bool:
state = ctx.state
deps = ctx.deps
log(
deps,
state,
"\n[bold cyan]📊 Evaluating research sufficiency...[/bold cyan]",
)
agent = Agent(
model=get_model(provider, model),
output_type=EvaluationResult,
instructions=DECISION_AGENT_PROMPT,
retries=3,
deps_type=ResearchDependencies,
)
context_xml = format_context_for_prompt(state.context)
analysis_xml = format_analysis_for_prompt(state.last_analysis)
prompt_parts = [
"Assess whether the research now answers the original question with adequate confidence.",
context_xml,
analysis_xml,
]
if state.last_eval is not None:
prev = state.last_eval
prompt_parts.append(
"<previous_evaluation>"
f"<confidence>{prev.confidence_score:.2f}</confidence>"
f"<is_sufficient>{str(prev.is_sufficient).lower()}</is_sufficient>"
f"<reasoning>{prev.reasoning}</reasoning>"
"</previous_evaluation>"
)
prompt = "\n\n".join(part for part in prompt_parts if part)
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
)
decision_result = await agent.run(prompt, deps=agent_deps)
output = decision_result.output
state.last_eval = output
state.iterations += 1
for new_q in output.new_questions:
if new_q not in state.context.sub_questions:
state.context.sub_questions.append(new_q)
if output.key_insights:
log(deps, state, " [bold]Key insights:[/bold]")
for insight in output.key_insights:
log(deps, state, f"{insight}")
if output.gaps:
log(deps, state, " [bold yellow]Remaining gaps:[/bold yellow]")
for gap in output.gaps:
log(deps, state, f"{gap}")
log(
deps,
state,
f" Confidence: [yellow]{output.confidence_score:.1%}[/yellow]",
)
status = "[green]Yes[/green]" if output.is_sufficient else "[red]No[/red]"
log(deps, state, f" Sufficient: {status}")
should_continue = (
not output.is_sufficient
or output.confidence_score < state.confidence_threshold
) and state.iterations < state.max_iterations
if not should_continue:
log(deps, state, "\n[bold green]✅ Stopping research.[/bold green]")
return should_continue
@g.step
async def synthesize(
ctx: StepContext[ResearchState, ResearchDeps, None | bool],
) -> ResearchReport:
state = ctx.state
deps = ctx.deps
log(
deps,
state,
"\n[bold cyan]📝 Generating final research report...[/bold cyan]",
)
agent = Agent(
model=get_model(provider, model),
output_type=ResearchReport,
instructions=SYNTHESIS_AGENT_PROMPT,
retries=3,
deps_type=ResearchDependencies,
)
context_xml = format_context_for_prompt(state.context)
prompt = (
"Generate a comprehensive research report based on all gathered information.\n\n"
f"{context_xml}\n\n"
"Create a detailed report that synthesizes all findings into a coherent response."
)
agent_deps = ResearchDependencies(
client=deps.client,
context=state.context,
console=deps.console,
stream=deps.stream,
)
result = await agent.run(prompt, deps=agent_deps)
log(deps, state, "[bold green]✅ Research complete![/bold green]")
return result.output
# Build the graph structure
collect_answers = g.join(
reduce_list_append,
initial_factory=list[SearchAnswer],
)
g.add(
g.edge_from(g.start_node).to(plan),
g.edge_from(plan).to(get_batch),
)
# Branch based on whether we have questions
g.add(
g.edge_from(get_batch).to(
g.decision()
.branch(g.match(list).label("Has questions").map().to(search_one))
.branch(g.match(type(None)).label("No questions").to(synthesize))
),
g.edge_from(search_one).to(collect_answers),
g.edge_from(collect_answers).to(analyze_insights),
g.edge_from(analyze_insights).to(decide),
)
# Branch based on decision
g.add(
g.edge_from(decide).to(
g.decision()
.branch(
g.match(bool, matches=lambda x: x)
.label("Continue research")
.to(get_batch)
)
.branch(
g.match(bool, matches=lambda x: not x)
.label("Done researching")
.to(synthesize)
)
),
g.edge_from(synthesize).to(g.end_node),
)
return g.build()

View file

@ -1,58 +0,0 @@
import asyncio
from dataclasses import dataclass
from typing import TYPE_CHECKING
from rich.console import Console
from haiku.rag.client import HaikuRAG
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.models import EvaluationResult, InsightAnalysis
from haiku.rag.research.stream import ResearchStream
if TYPE_CHECKING:
from haiku.rag.config.models import AppConfig
@dataclass
class ResearchDeps:
client: HaikuRAG
console: Console | None = None
stream: ResearchStream | None = None
semaphore: asyncio.Semaphore | None = None
def emit_log(self, message: str, state: "ResearchState | None" = None) -> None:
if self.console:
self.console.print(message)
if self.stream:
self.stream.log(message, state)
@dataclass
class ResearchState:
context: ResearchContext
iterations: int = 0
max_iterations: int = 3
confidence_threshold: float = 0.8
max_concurrency: int = 1
last_eval: EvaluationResult | None = None
last_analysis: InsightAnalysis | None = None
@classmethod
def from_config(
cls, context: ResearchContext, config: "AppConfig"
) -> "ResearchState":
"""Create a ResearchState from an AppConfig.
Args:
context: The ResearchContext containing the question and settings
config: The AppConfig object (uses config.research for state parameters)
Returns:
A configured ResearchState instance
"""
return cls(
context=context,
max_iterations=config.research.max_iterations,
confidence_threshold=config.research.confidence_threshold,
max_concurrency=config.research.max_concurrency,
)

View file

@ -1,162 +0,0 @@
import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from haiku.rag.research.models import ResearchReport
if TYPE_CHECKING: # pragma: no cover
from haiku.rag.research.state import ResearchState
@dataclass(slots=True)
class ResearchStateSnapshot:
question: str
sub_questions: list[str]
iterations: int
max_iterations: int
confidence_threshold: float
pending_sub_questions: int
answered_questions: int
insights: list[str]
gaps: list[str]
last_confidence: float | None
last_sufficient: bool | None
@classmethod
def from_state(cls, state: "ResearchState") -> "ResearchStateSnapshot":
context = state.context
last_confidence: float | None = None
last_sufficient: bool | None = None
if state.last_eval:
last_confidence = state.last_eval.confidence_score
last_sufficient = state.last_eval.is_sufficient
return cls(
question=context.original_question,
sub_questions=list(context.sub_questions),
iterations=state.iterations,
max_iterations=state.max_iterations,
confidence_threshold=state.confidence_threshold,
pending_sub_questions=len(context.sub_questions),
answered_questions=len(context.qa_responses),
insights=[
f"{insight.status.value}:{insight.summary}"
for insight in context.insights
],
gaps=[
f"{gap.severity.value}/{'resolved' if gap.resolved else 'open'}:{gap.description}"
for gap in context.gaps
],
last_confidence=last_confidence,
last_sufficient=last_sufficient,
)
@dataclass(slots=True)
class ResearchStreamEvent:
type: Literal["log", "report", "error"]
message: str | None = None
state: ResearchStateSnapshot | None = None
report: ResearchReport | None = None
error: str | None = None
class ResearchStream:
"""Queue-backed stream for research graph events."""
def __init__(self) -> None:
self._queue: asyncio.Queue[ResearchStreamEvent | None] = asyncio.Queue()
self._closed = False
def _snapshot(self, state: "ResearchState | None") -> ResearchStateSnapshot | None:
if state is None:
return None
return ResearchStateSnapshot.from_state(state)
def log(self, message: str, state: "ResearchState | None" = None) -> None:
if self._closed:
return
event = ResearchStreamEvent(
type="log", message=message, state=self._snapshot(state)
)
self._queue.put_nowait(event)
def report(self, report: ResearchReport, state: "ResearchState") -> None:
if self._closed:
return
event = ResearchStreamEvent(
type="report",
report=report,
state=self._snapshot(state),
)
self._queue.put_nowait(event)
def error(self, error: Exception, state: "ResearchState | None" = None) -> None:
if self._closed:
return
event = ResearchStreamEvent(
type="error",
message=str(error),
error=str(error),
state=self._snapshot(state),
)
self._queue.put_nowait(event)
async def close(self) -> None:
if self._closed:
return
self._closed = True
await self._queue.put(None)
def __aiter__(self) -> AsyncIterator[ResearchStreamEvent]:
return self._iter_events()
async def _iter_events(self) -> AsyncIterator[ResearchStreamEvent]:
while True:
event = await self._queue.get()
if event is None:
break
yield event
async def stream_research_graph(
graph,
state: "ResearchState",
deps,
) -> AsyncIterator[ResearchStreamEvent]:
"""Run the research graph and yield streaming events as they occur."""
from contextlib import suppress
from haiku.rag.research.state import ResearchDeps
if not isinstance(deps, ResearchDeps):
raise TypeError("deps must be an instance of ResearchDeps")
stream = ResearchStream()
deps.stream = stream
async def _execute() -> None:
try:
report = await graph.run(state=state, deps=deps)
if report is None:
raise RuntimeError("Graph did not produce a report")
stream.report(report, state)
except Exception as exc:
stream.error(exc, state)
finally:
await stream.close()
runner = asyncio.create_task(_execute())
try:
async for event in stream:
yield event
finally:
if not runner.done():
runner.cancel()
with suppress(asyncio.CancelledError):
await runner

View file

@ -27,7 +27,7 @@ dependencies = [
"lancedb==0.25.2",
"pathspec>=0.12.1",
"pydantic>=2.12.3",
"pydantic-ai-slim[openai,fastmcp,logfire]>=1.11.1",
"pydantic-ai-slim[openai,fastmcp,logfire,ag-ui]>=1.11.1",
"python-dotenv>=1.2.1",
"pyyaml>=6.0.3",
"rich>=14.2.0",

View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Test script for AG-UI server functionality."""
import asyncio
import json
import httpx
async def test_agui_server():
"""Test the AG-UI server endpoint."""
base_url = "http://localhost:8000"
# Test health check
async with httpx.AsyncClient() as client:
print("Testing health check...")
response = await client.get(f"{base_url}/health")
print(f"Health check response: {response.status_code}")
print(f"Health check data: {response.json()}")
# Test AG-UI streaming endpoint
print("\nTesting AG-UI stream endpoint...")
request_data = {
"threadId": "test-thread-1",
"runId": "test-run-1",
"state": {"question": "What is pydantic-graph?"},
"messages": [],
"config": {},
}
print(f"Request data: {json.dumps(request_data, indent=2)}")
# Send request and stream response
async with client.stream(
"POST",
f"{base_url}/v1/agent/stream",
json=request_data,
timeout=120.0,
) as response:
print(f"Response status: {response.status_code}")
print("Streaming events...\n")
event_count = 0
async for line in response.aiter_lines():
if line.startswith("data: "):
event_data = line[6:] # Remove "data: " prefix
try:
event = json.loads(event_data)
event_type = event.get("type", "UNKNOWN")
print(f"Event {event_count}: {event_type}")
# Show specific event details
if event_type == "RUN_STARTED":
print(f" Thread ID: {event.get('threadId')}")
print(f" Run ID: {event.get('runId')}")
elif event_type == "STEP_STARTED":
print(f" Step: {event.get('stepName')}")
elif event_type == "ACTIVITY_SNAPSHOT":
print(f" Activity: {event.get('content')}")
elif event_type == "STATE_SNAPSHOT":
state = event.get("snapshot", {})
if "context" in state:
context = state["context"]
if "sub_questions" in context:
num_questions = len(context["sub_questions"])
print(f" Sub-questions: {num_questions}")
elif event_type == "RUN_FINISHED":
result = event.get("result", {})
if "title" in result:
print(f" Report Title: {result['title']}")
elif event_type == "RUN_ERROR":
print(f" Error: {event.get('message')}")
event_count += 1
except json.JSONDecodeError as e:
print(f"Failed to parse event: {e}")
print(f"Raw line: {line}")
print(f"\nTotal events received: {event_count}")
if __name__ == "__main__":
print("AG-UI Server Test")
print("=" * 50)
print("Make sure to start the server first with:")
print(" haiku-rag serve --agui --agui-port 8000")
print("=" * 50)
print()
try:
asyncio.run(test_agui_server())
except httpx.ConnectError:
print("ERROR: Could not connect to server at http://localhost:8000")
print("Make sure the AG-UI server is running.")
except KeyboardInterrupt:
print("\nTest interrupted by user")
except Exception as e:
print(f"Test failed with error: {e}")
import traceback
traceback.print_exc()

1
tests/graph/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Tests for haiku.rag.graph module."""

View file

@ -0,0 +1 @@
"""Tests for AG-UI implementation."""

View file

@ -0,0 +1,204 @@
"""Tests for AGUIConsoleRenderer."""
import pytest
from pydantic import BaseModel
from rich.console import Console
from haiku.rag.graph.agui.cli_renderer import AGUIConsoleRenderer
from haiku.rag.graph.agui.events import (
emit_activity,
emit_run_error,
emit_run_finished,
emit_run_started,
emit_state_delta,
emit_state_snapshot,
emit_step_finished,
emit_step_started,
emit_text_message,
)
class SimpleState(BaseModel):
"""Simple state for testing."""
value: int
async def async_gen(items):
"""Helper to create async generator from list."""
for item in items:
yield item
@pytest.mark.asyncio
async def test_renderer_basic_flow():
"""Test basic event rendering flow."""
console = Console(file=None, force_terminal=False) # Don't actually print
renderer = AGUIConsoleRenderer(console)
events = [
emit_run_started("t1", "r1-test-run-id"),
emit_state_snapshot(SimpleState(value=1)),
emit_step_started("plan"),
emit_step_finished("plan"),
emit_activity("m1", "planning", "Planning research"),
emit_run_finished("t1", "r1", {"status": "complete"}),
]
result = await renderer.render(async_gen(events))
assert result == {"status": "complete"}
@pytest.mark.asyncio
async def test_renderer_multiple_snapshots():
"""Test that renderer handles multiple snapshots without errors."""
renderer = AGUIConsoleRenderer()
events = [
emit_state_snapshot(SimpleState(value=1)),
emit_state_snapshot(SimpleState(value=2)),
]
# Should render both snapshots without error
result = await renderer.render(async_gen(events))
assert result is None # No run finished event
@pytest.mark.asyncio
async def test_renderer_handles_all_event_types():
"""Test that renderer handles all event types without errors."""
renderer = AGUIConsoleRenderer()
events = [
emit_run_started("t1", "r1"),
emit_state_snapshot(SimpleState(value=1)),
emit_step_started("step1"),
emit_step_finished("step1"),
emit_text_message("message"),
emit_activity("m1", "work", "Working"),
emit_run_error("error occurred"),
emit_run_finished("t1", "r1", {"result": "done"}),
]
# Should not raise any exceptions
result = await renderer.render(async_gen(events))
assert result == {"result": "done"}
@pytest.mark.asyncio
async def test_renderer_state_snapshots():
"""Test that state snapshots are rendered."""
renderer = AGUIConsoleRenderer()
events = [
emit_state_snapshot(SimpleState(value=1)),
emit_state_snapshot(SimpleState(value=2)),
emit_run_finished("t1", "r1", {"done": True}),
]
result = await renderer.render(async_gen(events))
assert result == {"done": True}
@pytest.mark.asyncio
async def test_renderer_no_result():
"""Test renderer when no RUN_FINISHED event."""
renderer = AGUIConsoleRenderer()
events = [
emit_run_started("t1", "r1"),
emit_step_started("step1"),
]
result = await renderer.render(async_gen(events))
assert result is None
@pytest.mark.asyncio
async def test_renderer_snapshot_then_delta():
"""Test that renderer handles snapshot followed by deltas."""
renderer = AGUIConsoleRenderer()
state1 = SimpleState(value=1)
state2 = SimpleState(value=2)
state3 = SimpleState(value=3)
events = [
emit_state_snapshot(state1), # Initial snapshot
emit_state_delta(state1, state2), # Delta to value=2
emit_state_delta(state2, state3), # Delta to value=3
emit_run_finished("t1", "r1", {"complete": True}),
]
result = await renderer.render(async_gen(events))
assert result == {"complete": True}
@pytest.mark.asyncio
async def test_renderer_with_empty_state():
"""Test renderer handles empty state gracefully."""
renderer = AGUIConsoleRenderer()
# Create a state with no changes
events = [
emit_step_started("step1"), # No state snapshot
emit_run_finished("t1", "r1", {"result": "ok"}),
]
result = await renderer.render(async_gen(events))
assert result == {"result": "ok"}
@pytest.mark.asyncio
async def test_renderer_state_delta():
"""Test that renderer renders state deltas."""
renderer = AGUIConsoleRenderer()
state1 = SimpleState(value=1)
state2 = SimpleState(value=2)
events = [
emit_state_snapshot(state1), # Initial state
emit_state_delta(state1, state2), # Delta update
emit_run_finished("t1", "r1", None),
]
result = await renderer.render(async_gen(events))
assert result is None
@pytest.mark.asyncio
async def test_renderer_state_delta_without_initial():
"""Test that renderer handles delta without initial state gracefully."""
renderer = AGUIConsoleRenderer()
state1 = SimpleState(value=1)
state2 = SimpleState(value=2)
# Send delta without initial snapshot - should still render it
events = [
emit_state_delta(state1, state2),
emit_run_finished("t1", "r1", {"ok": True}),
]
result = await renderer.render(async_gen(events))
assert result == {"ok": True}
@pytest.mark.asyncio
async def test_renderer_lifecycle_events():
"""Test that lifecycle events are rendered properly."""
renderer = AGUIConsoleRenderer()
events = [
emit_run_started("thread-123", "run-abc-def-ghi"), # Long run ID
emit_step_started("search_one"),
emit_step_finished("search_one"),
emit_step_started("analyze_insights"),
emit_step_finished("analyze_insights"),
emit_run_finished("thread-123", "run-abc-def-ghi", {"complete": True}),
]
result = await renderer.render(async_gen(events))
assert result == {"complete": True}

View file

@ -0,0 +1,263 @@
"""Tests for AGUIEmitter."""
import asyncio
import pytest
from pydantic import BaseModel
from haiku.rag.graph.agui.emitter import AGUIEmitter
class TestState(BaseModel):
"""Test state model."""
value: int
text: str
class TestResult(BaseModel):
"""Test result model."""
status: str
@pytest.mark.asyncio
async def test_emitter_lifecycle():
"""Test emitter lifecycle events."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
initial_state = TestState(value=1, text="initial")
emitter.start_run(initial_state)
events = []
async for event in emitter:
events.append(event)
if event["type"] == "RUN_STARTED":
# Close after getting run started
result = TestResult(status="complete")
emitter.finish_run(result)
await emitter.close()
assert len(events) >= 3 # RUN_STARTED, STATE_SNAPSHOT, RUN_FINISHED
assert events[0]["type"] == "RUN_STARTED"
assert events[-1]["type"] == "RUN_FINISHED"
assert events[-1]["result"] == {"status": "complete"}
@pytest.mark.asyncio
async def test_emitter_step_events():
"""Test step lifecycle events."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
emitter.start_step("test_step")
emitter.finish_step()
await emitter.close()
events = []
async for event in emitter:
events.append(event)
step_events = [e for e in events if e["type"] in ("STEP_STARTED", "STEP_FINISHED")]
assert len(step_events) == 2
assert step_events[0]["type"] == "STEP_STARTED"
assert step_events[0]["stepName"] == "test_step"
assert step_events[1]["type"] == "STEP_FINISHED"
assert step_events[1]["stepName"] == "test_step"
@pytest.mark.asyncio
async def test_emitter_state_updates():
"""Test state update events with snapshots."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter(use_deltas=False)
state1 = TestState(value=1, text="first")
state2 = TestState(value=2, text="second")
emitter.update_state(state1)
emitter.update_state(state2)
await emitter.close()
events = []
async for event in emitter:
events.append(event)
state_events = [e for e in events if e["type"] == "STATE_SNAPSHOT"]
assert len(state_events) == 2
assert state_events[0]["snapshot"] == {"value": 1, "text": "first"}
assert state_events[1]["snapshot"] == {"value": 2, "text": "second"}
@pytest.mark.asyncio
async def test_emitter_state_deltas():
"""Test state update events with deltas."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter(use_deltas=True)
state1 = TestState(value=1, text="first")
state2 = TestState(value=2, text="second")
emitter.update_state(state1)
emitter.update_state(state2)
await emitter.close()
events = []
async for event in emitter:
events.append(event)
# First update should be a snapshot (no previous state)
snapshot_events = [e for e in events if e["type"] == "STATE_SNAPSHOT"]
assert len(snapshot_events) == 1
assert snapshot_events[0]["snapshot"] == {"value": 1, "text": "first"}
# Second update should be a delta
delta_events = [e for e in events if e["type"] == "STATE_DELTA"]
assert len(delta_events) == 1
# Delta should contain replace operations for changed fields
delta = delta_events[0]["delta"]
assert isinstance(delta, list)
assert len(delta) == 2 # Two fields changed
assert any(op["path"] == "/value" and op["value"] == 2 for op in delta)
assert any(op["path"] == "/text" and op["value"] == "second" for op in delta)
@pytest.mark.asyncio
async def test_emitter_activity_events():
"""Test activity events."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
emitter.update_activity("processing", "Processing data")
emitter.update_activity("done", "Completed", message_id="msg-1")
await emitter.close()
events = []
async for event in emitter:
events.append(event)
activity_events = [e for e in events if e["type"] == "ACTIVITY_SNAPSHOT"]
assert len(activity_events) == 2
assert activity_events[0]["activityType"] == "processing"
assert activity_events[0]["content"] == "Processing data"
assert activity_events[1]["messageId"] == "msg-1"
assert activity_events[1]["activityType"] == "done"
@pytest.mark.asyncio
async def test_emitter_text_messages():
"""Test text message events."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
emitter.log("Test message", role="assistant")
emitter.log("Another message", role="user")
await emitter.close()
events = []
async for event in emitter:
events.append(event)
text_events = [e for e in events if e["type"] == "TEXT_MESSAGE_CHUNK"]
assert len(text_events) == 2
assert text_events[0]["delta"] == "Test message"
assert text_events[0]["role"] == "assistant"
assert text_events[1]["delta"] == "Another message"
assert text_events[1]["role"] == "user"
@pytest.mark.asyncio
async def test_emitter_error():
"""Test error event emission."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
error = ValueError("Test error")
emitter.error(error, code="TEST_ERROR")
await emitter.close()
events = []
async for event in emitter:
events.append(event)
error_events = [e for e in events if e["type"] == "RUN_ERROR"]
assert len(error_events) == 1
assert error_events[0]["message"] == "Test error"
assert error_events[0]["code"] == "TEST_ERROR"
@pytest.mark.asyncio
async def test_emitter_thread_and_run_ids():
"""Test thread and run ID management."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter(
thread_id="thread-1", run_id="run-1"
)
assert emitter.thread_id == "thread-1"
assert emitter.run_id == "run-1"
initial_state = TestState(value=1, text="test")
emitter.start_run(initial_state)
await emitter.close()
events = []
async for event in emitter:
events.append(event)
run_started = [e for e in events if e["type"] == "RUN_STARTED"][0]
assert run_started["threadId"] == "thread-1"
assert run_started["runId"] == "run-1"
@pytest.mark.asyncio
async def test_emitter_generates_thread_id():
"""Test that thread ID is generated from state hash when not provided."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
initial_state = TestState(value=42, text="test")
emitter.start_run(initial_state)
# Thread ID should be generated deterministically from state
assert emitter.thread_id is not None
assert len(emitter.thread_id) > 0
await emitter.close()
async for _ in emitter:
pass
@pytest.mark.asyncio
async def test_emitter_closes_properly():
"""Test that emitter closes and stops iteration."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
emitter.log("Message 1")
await emitter.close()
# Attempting to iterate after close should work and stop
events = []
async for event in emitter:
events.append(event)
# Should have received the message and then stopped
assert len(events) == 1
@pytest.mark.asyncio
async def test_emitter_concurrent_emission():
"""Test that multiple events can be emitted concurrently."""
emitter: AGUIEmitter[TestState, TestResult] = AGUIEmitter()
async def emit_many():
for i in range(10):
emitter.log(f"Message {i}")
await asyncio.sleep(0.001) # Simulate some work
await emitter.close()
# Start emission in background
emit_task = asyncio.create_task(emit_many())
# Collect events
events = []
async for event in emitter:
events.append(event)
await emit_task
# Should have all 10 messages
text_events = [e for e in events if e["type"] == "TEXT_MESSAGE_CHUNK"]
assert len(text_events) == 10

View file

@ -0,0 +1,155 @@
"""Tests for AG-UI event creation utilities."""
from pydantic import BaseModel
from haiku.rag.graph.agui.events import (
emit_activity,
emit_run_error,
emit_run_finished,
emit_run_started,
emit_state_snapshot,
emit_step_finished,
emit_step_started,
emit_text_message,
)
class TestState(BaseModel):
"""Test state model."""
value: int
class TestResult(BaseModel):
"""Test result model."""
status: str
def test_emit_run_started():
"""Test RUN_STARTED event creation."""
event = emit_run_started("thread-1", "run-1")
assert event["type"] == "RUN_STARTED"
assert event["threadId"] == "thread-1"
assert event["runId"] == "run-1"
assert "input" not in event
def test_emit_run_started_with_input():
"""Test RUN_STARTED event with input data."""
event = emit_run_started("thread-1", "run-1", input_data="test input")
assert event["type"] == "RUN_STARTED"
assert event["input"] == "test input"
def test_emit_run_finished():
"""Test RUN_FINISHED event creation."""
result = TestResult(status="complete")
event = emit_run_finished("thread-1", "run-1", result)
assert event["type"] == "RUN_FINISHED"
assert event["threadId"] == "thread-1"
assert event["runId"] == "run-1"
assert event["result"] == {"status": "complete"}
def test_emit_run_finished_with_dict():
"""Test RUN_FINISHED event with dict result."""
result = {"status": "complete", "count": 42}
event = emit_run_finished("thread-1", "run-1", result)
assert event["type"] == "RUN_FINISHED"
assert event["result"] == result
def test_emit_run_error():
"""Test RUN_ERROR event creation."""
event = emit_run_error("Something went wrong")
assert event["type"] == "RUN_ERROR"
assert event["message"] == "Something went wrong"
assert "code" not in event
def test_emit_run_error_with_code():
"""Test RUN_ERROR event with error code."""
event = emit_run_error("Something went wrong", code="ERR_001")
assert event["type"] == "RUN_ERROR"
assert event["message"] == "Something went wrong"
assert event["code"] == "ERR_001"
def test_emit_step_started():
"""Test STEP_STARTED event creation."""
event = emit_step_started("plan")
assert event["type"] == "STEP_STARTED"
assert event["stepName"] == "plan"
def test_emit_step_finished():
"""Test STEP_FINISHED event creation."""
event = emit_step_finished("plan")
assert event["type"] == "STEP_FINISHED"
assert event["stepName"] == "plan"
def test_emit_text_message():
"""Test TEXT_MESSAGE_CHUNK event creation."""
event = emit_text_message("Hello world")
assert event["type"] == "TEXT_MESSAGE_CHUNK"
assert event["delta"] == "Hello world"
assert event["role"] == "assistant"
assert "messageId" in event
def test_emit_text_message_with_role():
"""Test TEXT_MESSAGE_CHUNK event with custom role."""
event = emit_text_message("Hello", role="user")
assert event["type"] == "TEXT_MESSAGE_CHUNK"
assert event["role"] == "user"
def test_emit_state_snapshot():
"""Test STATE_SNAPSHOT event creation."""
state = TestState(value=42)
event = emit_state_snapshot(state)
assert event["type"] == "STATE_SNAPSHOT"
assert event["snapshot"] == {"value": 42}
def test_emit_activity():
"""Test ACTIVITY_SNAPSHOT event creation."""
event = emit_activity("msg-1", "processing", "Working on task")
assert event["type"] == "ACTIVITY_SNAPSHOT"
assert event["messageId"] == "msg-1"
assert event["activityType"] == "processing"
assert event["content"] == "Working on task"
def test_event_structure_consistency():
"""Test that all events have consistent structure."""
events = [
emit_run_started("t1", "r1"),
emit_run_finished("t1", "r1", {"result": "done"}),
emit_run_error("error"),
emit_step_started("step1"),
emit_step_finished("step1"),
emit_text_message("text"),
emit_state_snapshot(TestState(value=1)),
emit_activity("m1", "type", "content"),
]
for event in events:
assert isinstance(event, dict)
assert "type" in event
assert isinstance(event["type"], str)
assert event["type"].isupper() # Event types are uppercase

View file

@ -0,0 +1,250 @@
"""Tests for AG-UI server."""
import pytest
from pydantic import BaseModel
from starlette.testclient import TestClient
from haiku.rag.config.models import AGUIConfig
from haiku.rag.graph.agui.server import RunAgentInput, create_agui_app, format_sse_event
class SimpleState(BaseModel):
"""Simple state for testing."""
question: str
class SimpleResult(BaseModel):
"""Simple result for testing."""
answer: str
class MockGraph:
"""Mock graph that returns immediately."""
async def run(self, state, deps): # type: ignore[no-untyped-def]
"""Return a simple result."""
return SimpleResult(answer=f"Answer to: {state.question}")
def test_run_agent_input_parsing():
"""Test RunAgentInput model parsing."""
data = {
"threadId": "thread-1",
"runId": "run-1",
"state": {"question": "What is AI?"},
"messages": [],
"config": {},
}
input_data = RunAgentInput(**data)
assert input_data.thread_id == "thread-1"
assert input_data.run_id == "run-1"
assert input_data.state == {"question": "What is AI?"}
def test_run_agent_input_defaults():
"""Test RunAgentInput with defaults."""
input_data = RunAgentInput() # type: ignore[call-arg]
assert input_data.thread_id is None
assert input_data.run_id is None
assert input_data.state == {}
assert input_data.messages == []
assert input_data.config == {}
def test_format_sse_event():
"""Test SSE event formatting."""
event = {"type": "TEST_EVENT", "data": "test"}
sse = format_sse_event(event)
assert sse.startswith("data: ")
assert sse.endswith("\n\n")
assert '{"type": "TEST_EVENT"' in sse
def test_create_agui_app_basic():
"""Test basic app creation."""
config = AGUIConfig(
host="localhost",
port=8000,
cors_origins=["http://localhost"],
)
def graph_factory():
return MockGraph()
def state_factory(input_state):
return SimpleState(question=input_state.get("question", ""))
def deps_factory(input_config):
from dataclasses import dataclass
@dataclass
class SimpleDeps:
agui_emitter: None = None
return SimpleDeps()
app = create_agui_app(
graph_factory=graph_factory, # type: ignore[arg-type]
state_factory=state_factory,
deps_factory=deps_factory,
config=config,
)
# Should return a Starlette app
assert app is not None
assert hasattr(app, "routes")
def test_server_health_endpoint():
"""Test health check endpoint."""
config = AGUIConfig()
def graph_factory():
return MockGraph()
def state_factory(input_state):
return SimpleState(question="")
def deps_factory(input_config):
from dataclasses import dataclass
@dataclass
class SimpleDeps:
agui_emitter: None = None
return SimpleDeps()
app = create_agui_app(
graph_factory=graph_factory, # type: ignore[arg-type]
state_factory=state_factory,
deps_factory=deps_factory,
config=config,
)
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
@pytest.mark.asyncio
async def test_server_stream_endpoint():
"""Test AG-UI streaming endpoint."""
config = AGUIConfig()
def graph_factory():
return MockGraph()
def state_factory(input_state):
question = input_state.get("question", "")
return SimpleState(question=question)
def deps_factory(input_config):
from dataclasses import dataclass
@dataclass
class SimpleDeps:
agui_emitter: None = None
return SimpleDeps()
app = create_agui_app(
graph_factory=graph_factory, # type: ignore[arg-type]
state_factory=state_factory,
deps_factory=deps_factory,
config=config,
)
client = TestClient(app)
request_data = {
"threadId": "test-1",
"runId": "run-1",
"state": {"question": "What is pydantic-graph?"},
"messages": [],
"config": {},
}
response = client.post("/v1/agent/stream", json=request_data)
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
# Read the streamed events
events = []
for line in response.iter_lines():
if line.startswith("data: "):
import json
event_data = line[6:] # Remove "data: " prefix
event = json.loads(event_data)
events.append(event)
# Should have received multiple events
assert len(events) > 0
# Should have RUN_STARTED and RUN_FINISHED
event_types = [e["type"] for e in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
def test_server_cors_headers():
"""Test CORS middleware is configured."""
config = AGUIConfig(
cors_origins=["http://example.com"],
cors_credentials=True,
)
def graph_factory():
return MockGraph()
def state_factory(input_state):
return SimpleState(question="")
def deps_factory(input_config):
from dataclasses import dataclass
@dataclass
class SimpleDeps:
agui_emitter: None = None
return SimpleDeps()
app = create_agui_app(
graph_factory=graph_factory, # type: ignore[arg-type]
state_factory=state_factory,
deps_factory=deps_factory,
config=config,
)
client = TestClient(app)
# GET request with Origin header should get CORS headers
response = client.get("/health", headers={"Origin": "http://example.com"})
assert response.status_code == 200
# CORS middleware should add access-control headers
assert (
"access-control-allow-origin" in response.headers or response.status_code == 200
)
def test_agui_config_defaults():
"""Test AGUIConfig default values."""
config = AGUIConfig()
assert config.host == "0.0.0.0"
assert config.port == 8000
assert config.cors_origins == ["*"]
assert config.cors_credentials is True
assert "GET" in config.cors_methods
assert "POST" in config.cors_methods

View file

@ -0,0 +1,199 @@
"""Tests for stream_graph function."""
from dataclasses import dataclass
import pytest
from pydantic import BaseModel
from haiku.rag.graph.agui.emitter import AGUIEmitter
from haiku.rag.graph.agui.stream import stream_graph
class TestState(BaseModel):
"""Test state model."""
value: int
@dataclass
class TestDeps:
"""Test dependencies."""
agui_emitter: AGUIEmitter | None = None
class MockGraph:
"""Mock graph for testing."""
def __init__(self, result: str | dict[str, str | int] = "done"):
self.result = result
self.run_called = False
async def run(self, state, deps): # type: ignore[no-untyped-def]
"""Mock run method."""
self.run_called = True
# Emit some events through the emitter
if deps.agui_emitter:
deps.agui_emitter.start_step("mock_step")
deps.agui_emitter.update_activity("working", "Doing work")
deps.agui_emitter.finish_step()
return self.result
@pytest.mark.asyncio
async def test_stream_graph_basic():
"""Test basic graph streaming."""
graph = MockGraph(result="success")
state = TestState(value=1)
deps = TestDeps()
events = []
async for event in stream_graph(graph, state, deps):
events.append(event)
# Should have collected events
assert len(events) > 0
# Should have RUN_STARTED and RUN_FINISHED
event_types = [e["type"] for e in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
# Graph should have been executed
assert graph.run_called
@pytest.mark.asyncio
async def test_stream_graph_emits_initial_state():
"""Test that initial state is emitted."""
graph = MockGraph()
state = TestState(value=42)
deps = TestDeps()
events = []
async for event in stream_graph(graph, state, deps):
events.append(event)
# Should have initial state snapshot
state_snapshots = [e for e in events if e["type"] == "STATE_SNAPSHOT"]
assert len(state_snapshots) > 0
# First snapshot should be initial state
assert state_snapshots[0]["snapshot"] == {"value": 42}
@pytest.mark.asyncio
async def test_stream_graph_emits_step_events():
"""Test that step events from graph are emitted."""
graph = MockGraph()
state = TestState(value=1)
deps = TestDeps()
events = []
async for event in stream_graph(graph, state, deps):
events.append(event)
# Should have step events from MockGraph
step_started = [e for e in events if e["type"] == "STEP_STARTED"]
assert len(step_started) > 0
assert step_started[0]["stepName"] == "mock_step"
@pytest.mark.asyncio
async def test_stream_graph_emits_activity():
"""Test that activity events from graph are emitted."""
graph = MockGraph()
state = TestState(value=1)
deps = TestDeps()
events = []
async for event in stream_graph(graph, state, deps):
events.append(event)
# Should have activity events from MockGraph
activities = [e for e in events if e["type"] == "ACTIVITY_SNAPSHOT"]
assert len(activities) > 0
assert activities[0]["content"] == "Doing work"
@pytest.mark.asyncio
async def test_stream_graph_handles_error():
"""Test that graph errors are captured and emitted."""
class ErrorGraph:
async def run(self, state, deps):
raise ValueError("Test error")
graph = ErrorGraph()
state = TestState(value=1)
deps = TestDeps()
events = []
async for event in stream_graph(graph, state, deps):
events.append(event)
# Should have error event
errors = [e for e in events if e["type"] == "RUN_ERROR"]
assert len(errors) > 0
assert "Test error" in errors[0]["message"]
@pytest.mark.asyncio
async def test_stream_graph_closes_emitter():
"""Test that emitter is properly closed."""
class NeverReturnsGraph:
async def run(self, state, deps):
# Don't return anything
pass
graph = NeverReturnsGraph()
state = TestState(value=1)
deps = TestDeps()
events = []
try:
async for event in stream_graph(graph, state, deps):
events.append(event)
except RuntimeError:
# Expected - graph didn't return a result
pass
# Should have error event about no result
errors = [e for e in events if e["type"] == "RUN_ERROR"]
assert len(errors) > 0
@pytest.mark.asyncio
async def test_stream_graph_without_emitter_support():
"""Test error when deps doesn't support agui_emitter."""
@dataclass
class BadDeps:
pass
graph = MockGraph()
state = TestState(value=1)
deps = BadDeps()
with pytest.raises(TypeError, match="agui_emitter"):
async for _ in stream_graph(graph, state, deps): # type: ignore[arg-type]
pass
@pytest.mark.asyncio
async def test_stream_graph_result_in_finish_event():
"""Test that graph result is included in RUN_FINISHED event."""
graph = MockGraph(result={"status": "complete", "count": 42})
state = TestState(value=1)
deps = TestDeps()
events = []
async for event in stream_graph(graph, state, deps):
events.append(event)
# Find RUN_FINISHED event
finished = [e for e in events if e["type"] == "RUN_FINISHED"]
assert len(finished) == 1
assert finished[0]["result"] == {"status": "complete", "count": 42}

View file

@ -2,10 +2,10 @@ import pytest
from pydantic_ai.models.test import TestModel
from haiku.rag.client import HaikuRAG
from haiku.rag.graph_common.models import SearchAnswer
from haiku.rag.qa.deep.dependencies import DeepQAContext
from haiku.rag.qa.deep.graph import build_deep_qa_graph
from haiku.rag.qa.deep.state import DeepQADeps, DeepQAState
from haiku.rag.graph.common.models import SearchAnswer
from haiku.rag.graph.deep_qa.dependencies import DeepQAContext
from haiku.rag.graph.deep_qa.graph import build_deep_qa_graph
from haiku.rag.graph.deep_qa.state import DeepQADeps, DeepQAState
@pytest.mark.asyncio
@ -16,8 +16,8 @@ async def test_deep_qa_graph_end_to_end(monkeypatch, temp_db_path):
def test_model_factory(provider, model):
return TestModel()
monkeypatch.setattr("haiku.rag.graph_common.utils.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.qa.deep.graph.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.graph.common.utils.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.graph.deep_qa.graph.get_model", test_model_factory)
graph = build_deep_qa_graph()
@ -30,7 +30,7 @@ async def test_deep_qa_graph_end_to_end(monkeypatch, temp_db_path):
# Use real client but with TestModel for LLM calls
client = HaikuRAG(temp_db_path)
deps = DeepQADeps(client=client, console=None)
deps = DeepQADeps(client=client)
result = await graph.run(state=state, deps=deps)
@ -50,8 +50,8 @@ async def test_deep_qa_with_citations(monkeypatch, temp_db_path):
def test_model_factory(provider, model):
return TestModel()
monkeypatch.setattr("haiku.rag.graph_common.utils.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.qa.deep.graph.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.graph.common.utils.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.graph.deep_qa.graph.get_model", test_model_factory)
graph = build_deep_qa_graph()
@ -62,7 +62,7 @@ async def test_deep_qa_with_citations(monkeypatch, temp_db_path):
# Use real client but with TestModel for LLM calls
client = HaikuRAG(temp_db_path)
deps = DeepQADeps(client=client, console=None)
deps = DeepQADeps(client=client)
result = await graph.run(state=state, deps=deps)

View file

@ -0,0 +1,85 @@
import asyncio
import pytest
from pydantic_ai.models.test import TestModel
from haiku.rag.client import HaikuRAG
from haiku.rag.graph.agui.stream import stream_graph
from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
def test_build_graph_and_state():
graph = build_research_graph()
assert graph is not None
state = ResearchState(
context=ResearchContext(
original_question="What are the key features of haiku.rag?"
),
max_iterations=1,
confidence_threshold=0.8,
)
assert state.iterations == 0
assert state.context.sub_questions == []
def test_async_loop_available():
# Ensure an event loop can be created in test env
loop = asyncio.new_event_loop()
loop.close()
@pytest.mark.asyncio
async def test_graph_end_to_end_with_test_model(monkeypatch, temp_db_path):
"""Test research graph with mocked LLM using AG-UI events."""
# Mock get_model to return TestModel which generates valid schema-compliant data
def test_model_factory(_provider, _model):
return TestModel()
monkeypatch.setattr("haiku.rag.graph.common.utils.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.graph.research.graph.get_model", test_model_factory)
graph = build_research_graph()
state = ResearchState(
context=ResearchContext(original_question="What is haiku.rag?"),
max_iterations=1,
confidence_threshold=0.5,
max_concurrency=2,
)
# Use real client but with TestModel for LLM calls
client = HaikuRAG(temp_db_path)
deps = ResearchDeps(client=client)
events = []
result = None
async for event in stream_graph(graph, state, deps):
events.append(event)
if event["type"] == "RUN_FINISHED":
result = event["result"]
elif event["type"] == "RUN_ERROR":
pytest.fail(f"Graph execution failed: {event['message']}")
# TestModel will generate valid structured output for each node
assert result is not None, (
f"No result. Events collected: {[e['type'] for e in events]}"
)
# Result is serialized as dict in AG-UI events
assert isinstance(result, dict)
assert "title" in result
assert isinstance(result["title"], str)
assert "executive_summary" in result
assert "main_findings" in result
# Verify AG-UI events were emitted
event_types = [e["type"] for e in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
assert "STATE_SNAPSHOT" in event_types
assert "STEP_STARTED" in event_types
client.close()

View file

@ -343,7 +343,7 @@ async def test_ask_with_verbose(app: HaikuRAGApp, monkeypatch):
@pytest.mark.asyncio
async def test_ask_with_deep(app: HaikuRAGApp, monkeypatch):
"""Test asking a question with deep QA."""
from haiku.rag.qa.deep.models import DeepQAAnswer
from haiku.rag.graph.deep_qa.models import DeepQAAnswer
mock_output = DeepQAAnswer(answer="Deep QA answer", sources=["test.md"])
@ -358,7 +358,7 @@ async def test_ask_with_deep(app: HaikuRAGApp, monkeypatch):
with patch("haiku.rag.app.HaikuRAG", return_value=mock_client):
with patch(
"haiku.rag.qa.deep.graph.build_deep_qa_graph", return_value=mock_graph
"haiku.rag.graph.deep_qa.graph.build_deep_qa_graph", return_value=mock_graph
):
await app.ask("test question", deep=True)
@ -371,7 +371,7 @@ async def test_ask_with_deep(app: HaikuRAGApp, monkeypatch):
@pytest.mark.asyncio
async def test_ask_with_deep_and_cite(app: HaikuRAGApp, monkeypatch):
"""Test asking a question with deep QA and citations."""
from haiku.rag.qa.deep.models import DeepQAAnswer
from haiku.rag.graph.deep_qa.models import DeepQAAnswer
mock_output = DeepQAAnswer(
answer="Deep QA answer with citations [test.md]", sources=["test.md"]
@ -388,7 +388,7 @@ async def test_ask_with_deep_and_cite(app: HaikuRAGApp, monkeypatch):
with patch("haiku.rag.app.HaikuRAG", return_value=mock_client):
with patch(
"haiku.rag.qa.deep.graph.build_deep_qa_graph", return_value=mock_graph
"haiku.rag.graph.deep_qa.graph.build_deep_qa_graph", return_value=mock_graph
):
await app.ask("test question", deep=True, cite=True)
@ -401,12 +401,13 @@ async def test_ask_with_deep_and_cite(app: HaikuRAGApp, monkeypatch):
@pytest.mark.asyncio
async def test_ask_with_deep_and_verbose(app: HaikuRAGApp, monkeypatch):
"""Test asking a question with deep QA and verbose output."""
from haiku.rag.qa.deep.models import DeepQAAnswer
mock_output = DeepQAAnswer(answer="Deep QA answer", sources=["test.md"])
mock_output = {"answer": "Deep QA answer", "sources": ["test.md"]}
mock_renderer = AsyncMock()
mock_renderer.render.return_value = mock_output
mock_graph = AsyncMock()
mock_graph.run.return_value = mock_output
mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client
@ -416,10 +417,13 @@ async def test_ask_with_deep_and_verbose(app: HaikuRAGApp, monkeypatch):
with patch("haiku.rag.app.HaikuRAG", return_value=mock_client):
with patch(
"haiku.rag.qa.deep.graph.build_deep_qa_graph", return_value=mock_graph
"haiku.rag.graph.deep_qa.graph.build_deep_qa_graph", return_value=mock_graph
):
await app.ask("test question", deep=True, verbose=True)
with patch(
"haiku.rag.graph.agui.AGUIConsoleRenderer", return_value=mock_renderer
):
await app.ask("test question", deep=True, verbose=True)
mock_graph.run.assert_called_once()
call_kwargs = mock_graph.run.call_args[1]
assert call_kwargs["deps"].console is not None
# With verbose, it should use AGUIConsoleRenderer.render, not graph.run
mock_renderer.render.assert_called_once()
mock_graph.run.assert_not_called()

View file

@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, patch
import pytest
from haiku.rag.graph.research.models import ResearchReport
from haiku.rag.mcp import create_mcp_server
from haiku.rag.research.models import ResearchReport
from haiku.rag.store.models.document import Document
@ -249,7 +249,9 @@ async def test_mcp_ask_question_deep():
with (
patch("haiku.rag.mcp.HaikuRAG") as mock_rag_class,
patch("haiku.rag.qa.deep.graph.build_deep_qa_graph") as mock_graph_builder,
patch(
"haiku.rag.graph.deep_qa.graph.build_deep_qa_graph"
) as mock_graph_builder,
):
mock_rag = AsyncMock()
mock_rag_class.return_value.__aenter__ = AsyncMock(return_value=mock_rag)
@ -291,7 +293,7 @@ async def test_mcp_research_question():
with (
patch("haiku.rag.mcp.HaikuRAG") as mock_rag_class,
patch(
"haiku.rag.research.graph.build_research_graph"
"haiku.rag.graph.research.graph.build_research_graph"
) as mock_graph_builder,
):
mock_rag = AsyncMock()

View file

@ -1,26 +0,0 @@
import asyncio
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.state import ResearchState
def test_build_graph_and_state():
graph = build_research_graph()
assert graph is not None
state = ResearchState(
context=ResearchContext(
original_question="What are the key features of haiku.rag?"
),
max_iterations=1,
confidence_threshold=0.8,
)
assert state.iterations == 0
assert state.context.sub_questions == []
def test_async_loop_available():
# Ensure an event loop can be created in test env
loop = asyncio.new_event_loop()
loop.close()

View file

@ -1,55 +0,0 @@
import pytest
from pydantic_ai.models.test import TestModel
from haiku.rag.client import HaikuRAG
from haiku.rag.research.dependencies import ResearchContext
from haiku.rag.research.graph import build_research_graph
from haiku.rag.research.models import ResearchReport
from haiku.rag.research.state import ResearchDeps, ResearchState
from haiku.rag.research.stream import stream_research_graph
@pytest.mark.asyncio
async def test_graph_end_to_end_with_test_model(monkeypatch, temp_db_path):
"""Test research graph with mocked LLM using TestModel."""
# Mock get_model to return TestModel which generates valid schema-compliant data
def test_model_factory(provider, model):
return TestModel()
monkeypatch.setattr("haiku.rag.graph_common.utils.get_model", test_model_factory)
monkeypatch.setattr("haiku.rag.research.graph.get_model", test_model_factory)
graph = build_research_graph()
state = ResearchState(
context=ResearchContext(original_question="What is haiku.rag?"),
max_iterations=1,
confidence_threshold=0.5,
max_concurrency=2,
)
# Use real client but with TestModel for LLM calls
client = HaikuRAG(temp_db_path)
deps = ResearchDeps(client=client, console=None)
collected = []
report = None
async for event in stream_research_graph(graph, state, deps):
collected.append(event)
if event.type == "report":
report = event.report
break
elif event.type == "error":
pytest.fail(f"Graph execution failed: {event.error}")
# TestModel will generate valid structured output for each node
assert report is not None, (
f"No report generated. Events collected: {[e.type for e in collected]}"
)
assert isinstance(report, ResearchReport)
assert report.title is not None
assert isinstance(report.title, str)
assert any(evt.type == "log" for evt in collected)
client.close()

20
uv.lock
View file

@ -38,6 +38,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/a0/d9ef19f780f319c21ee90ecfef4431cbeeca95bec7f14071785c17b6029b/accelerate-1.10.1-py3-none-any.whl", hash = "sha256:3621cff60b9a27ce798857ece05e2b9f56fcc71631cfb31ccf71f0359c311f11", size = 374909, upload-time = "2025-08-25T13:57:04.55Z" },
]
[[package]]
name = "ag-ui-protocol"
version = "0.1.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/bb/5a5ec893eea5805fb9a3db76a9888c3429710dfb6f24bbb37568f2cf7320/ag_ui_protocol-0.1.10.tar.gz", hash = "sha256:3213991c6b2eb24bb1a8c362ee270c16705a07a4c5962267a083d0959ed894f4", size = 6945, upload-time = "2025-11-06T15:17:17.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889, upload-time = "2025-11-06T15:17:15.325Z" },
]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
@ -1188,7 +1200,7 @@ dependencies = [
{ name = "lancedb" },
{ name = "pathspec" },
{ name = "pydantic" },
{ name = "pydantic-ai-slim", extra = ["fastmcp", "logfire", "openai"] },
{ name = "pydantic-ai-slim", extra = ["ag-ui", "fastmcp", "logfire", "openai"] },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
@ -1247,7 +1259,7 @@ requires-dist = [
{ name = "pydantic-ai-slim", extras = ["google"], marker = "extra == 'google'" },
{ name = "pydantic-ai-slim", extras = ["groq"], marker = "extra == 'groq'" },
{ name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" },
{ name = "pydantic-ai-slim", extras = ["openai", "fastmcp", "logfire"], specifier = ">=1.11.1" },
{ name = "pydantic-ai-slim", extras = ["openai", "fastmcp", "logfire", "ag-ui"], specifier = ">=1.11.1" },
{ name = "pydantic-ai-slim", extras = ["vertexai"], marker = "extra == 'vertexai'" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "pyyaml", specifier = ">=6.0.3" },
@ -3019,6 +3031,10 @@ wheels = [
]
[package.optional-dependencies]
ag-ui = [
{ name = "ag-ui-protocol" },
{ name = "starlette" },
]
anthropic = [
{ name = "anthropic" },
]