Remove a2a examples, clean up

This commit is contained in:
Yiorgis Gozadinos 2026-01-19 16:53:40 +02:00
parent e32b48d142
commit ea492248e7
No known key found for this signature in database
23 changed files with 14 additions and 7483 deletions

View file

@ -1,6 +1,11 @@
# Changelog
## [Unreleased]
### Removed
- **A2A Example**: Removed `examples/a2a-server/` A2A protocol server example
- **Stale Example References**: Cleaned up references to removed `ag-ui-research` example from documentation
### Changed
- **Type Checker**: Replaced pyright with [ty](https://github.com/astral-sh/ty), Astral's extremely fast Python type checker

View file

@ -121,7 +121,6 @@ Provides tools for document management, search, QA, and research directly in you
See the [examples directory](examples/) for working examples:
- **[Docker Setup](examples/docker/)** - Complete Docker deployment with file monitoring and MCP server
- **[A2A Server](examples/a2a-server/)** - Self-contained A2A protocol server package with conversational agent interface
- **[Web Application](app/)** - Full-stack conversational RAG with CopilotKit frontend
## Documentation

View file

@ -2,19 +2,6 @@
This directory contains example scripts demonstrating various features of haiku.rag.
## Interactive Research Assistant
**Directory:** `ag-ui-research/`
Full-stack research assistant with interactive UI powered by Pydantic AI and AG-UI:
- Multi-step research workflow with question decomposition
- Human-in-the-loop approval for research plans
- Real-time state synchronization between backend and frontend
- Context expansion and insight extraction
- Structured research reports with citations
See `ag-ui-research/README.md` for setup instructions.
## Docker Example
**Directory:** `docker/`
@ -24,23 +11,3 @@ Complete Docker setup for running haiku.rag with all services:
- MCP server for AI assistant integration
See `docker/README.md` for setup instructions.
## A2A Server
**Directory:** `a2a-server/`
Self-contained A2A (Agent-to-Agent) protocol server package that provides a conversational agent interface with its own CLI and dependencies.
Features:
- Conversational context with multi-turn dialogue support
- Interactive CLI client for testing
- Security examples (API key, OAuth2 with GitHub, enterprise OAuth2)
- Full documentation and installation instructions
See `a2a-server/README.md` for complete setup and usage instructions.
Install locally:
```bash
cd a2a-server
uv sync
```

View file

@ -1,117 +0,0 @@
# haiku-rag-a2a
A2A (Agent-to-Agent) protocol server for haiku.rag. This package provides a conversational agent interface that maintains conversation history and context across multiple turns.
## Features
- **Conversational Context**: Maintains full conversation history including tool calls and results
- **Multi-turn Dialogue**: Supports follow-up questions with pronoun resolution ("he", "it", "that document")
- **Intelligent Search**: Performs single or multiple searches depending on question complexity
- **Source Citations**: Always includes sources with both titles and URIs
- **Full Document Retrieval**: Can fetch complete documents on request
- **Multiple Skills**: Exposes three distinct skills with appropriate artifacts:
- `document-qa`: Conversational question answering (default)
- `document-search`: Semantic search with structured results
- `document-retrieve`: Fetch complete documents by URI
## Installation
This package is not published to PyPI. Install it locally from the haiku.rag repository:
```bash
cd examples/a2a-server
uv sync
```
This will install the package and all its dependencies, including `haiku.rag`.
## Quick Start
### Starting the A2A Server
```bash
# Start server with default database location (uses the same default as haiku-rag)
uv run haiku-rag-a2a serve
# Or specify a custom database path
uv run haiku-rag-a2a serve --db /path/to/database
# Start on custom host/port
uv run haiku-rag-a2a serve --host 0.0.0.0 --port 8080
```
By default, the server uses the same database location as `haiku-rag`:
- Linux: `~/.local/share/haiku.rag/haiku.rag.lancedb`
- macOS: `~/Library/Application Support/haiku.rag/haiku.rag.lancedb`
- Windows: `C:/Users/<USER>/AppData/Roaming/haiku.rag/haiku.rag.lancedb`
### Interactive Client
Test and interact with the A2A server using the built-in interactive client:
```bash
# Connect to local server
uv run haiku-rag-a2a client
# Connect to remote server
uv run haiku-rag-a2a client --url https://example.com:8000
```
The interactive client provides:
- Rich markdown rendering of agent responses
- Conversation context across multiple turns
- Agent card discovery and display
- Compact artifact summaries
## Python Usage
```python
from pathlib import Path
from haiku_rag_a2a.a2a import create_a2a_app
import uvicorn
# Create A2A app
app = create_a2a_app(Path("/path/to/database"))
# Run with uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)
```
## Security Examples
The `security_examples/` directory contains examples for securing the A2A server:
- `apikey_example.py` - Simple API key authentication
- `oauth2_github.py` - GitHub Personal Access Token authentication
- `oauth2_example.py` - Full OAuth2 with JWT verification
## Architecture
The A2A agent uses:
- **FastA2A**: Python framework implementing the A2A protocol
- **Pydantic AI**: Agent framework with tool support
- **In-Memory Storage**: Context and message history storage (persists during server lifetime)
- **Conversation State**: Full pydantic-ai message history serialized in A2A context
## Configuration
The server uses the same configuration as haiku.rag. You can specify a config file:
```bash
uv run haiku-rag-a2a serve --db /path/to/database --config haiku.rag.yaml
```
You can also control the maximum number of conversation contexts via the `--max-contexts` parameter (defaults to 1000).
## Documentation
See [a2a.md](./a2a.md) for detailed documentation including:
- API examples
- Security configuration
- Docker deployment
- Artifact specification
## License
MIT

View file

@ -1,195 +0,0 @@
# Agent-to-Agent (A2A) Protocol
The A2A server exposes `haiku.rag` as a conversational agent using the Agent-to-Agent protocol. Unlike the MCP server which provides stateless tools, the A2A agent maintains conversation history and context across multiple turns.
## Features
- **Conversational Context**: Maintains full conversation history including tool calls and results
- **Multi-turn Dialogue**: Supports follow-up questions with pronoun resolution ("he", "it", "that document")
- **Intelligent Search**: Performs single or multiple searches depending on question complexity
- **Source Citations**: Always includes sources with both titles and URIs
- **Full Document Retrieval**: Can fetch complete documents on request
- **Multiple Skills**: Exposes three distinct skills with appropriate artifacts:
- `document-qa`: Conversational question answering (default)
- `document-search`: Semantic search with structured results
- `document-retrieve`: Fetch complete documents by URI
## Starting A2A Server
```bash
haiku-rag serve --a2a
```
Server options:
- `--a2a-host` - Host to bind to (default: 127.0.0.1)
- `--a2a-port` - Port to bind to (default: 8000)
Example:
```bash
haiku-rag serve --a2a --a2a-host 0.0.0.0 --a2a-port 8080
```
## Interactive A2A Client
!!! note
The interactive A2A client is an excellent way to do conversational research with `haiku.rag`.
Test and interact with haiku.rag's A2A server using the built-in interactive client:
```bash
haiku-rag a2aclient
```
Client options:
- `--url` - Base URL of the A2A server (default: http://localhost:8000)
Example:
```bash
# Connect to local server
haiku-rag a2aclient
# Connect to remote server
haiku-rag a2aclient --url https://example.com:8000
```
The interactive client provides:
- Rich markdown rendering of agent responses
- Conversation context across multiple turns
- Agent card discovery and display
- Compact artifact summaries
## Requirements
A2A support requires the `a2a` extra:
```bash
uv pip install 'haiku.rag[a2a]'
```
## Python Usage
```python
from pathlib import Path
from haiku.rag.a2a import create_a2a_app
import uvicorn
# Create A2A app
app = create_a2a_app(Path("database.lancedb"))
# Run with uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)
```
This installs the `fasta2a` package and its dependencies.
## Architecture
The A2A agent uses:
- **FastA2A**: Python framework implementing the A2A protocol
- **Pydantic AI**: Agent framework with tool support
- **In-Memory Storage**: Context and message history storage (persists during server lifetime)
- **Conversation State**: Full pydantic-ai message history serialized in A2A context
### Message History
The agent stores the complete conversation state including:
- User prompts
- Agent responses
- Tool calls and their arguments
- Tool return values
This enables the agent to:
- Reference previous searches
- Understand pronouns and context
- Maintain coherent multi-turn conversations
### Context Management
Each conversation is identified by a `context_id`. All messages within the same context share conversation history. This allows the agent to:
- Remember what was discussed
- Track which documents were already found
- Provide contextual follow-up answers
### Skills
The agent exposes three skills:
- **document-qa** (default): Conversational question answering including follow-ups and multi-turn dialogue
- **document-search**: Direct semantic search returning formatted results
- **document-retrieve**: Fetch complete document content by URI
### Artifacts
All operations create artifacts for traceability:
- **search_results**: Created for each `search_documents` tool call
- Contains query and formatted search results string
- **document**: Created for each `get_full_document` tool call
- Contains complete document text
- **qa_result**: Created for all document-qa operations
- Contains question, answer, and skill identifier
- Always created for Q&A, even when answering from conversation history without tools
### Memory Management
To prevent memory growth, the server uses LRU (Least Recently Used) eviction:
- Maximum 1000 contexts kept in memory (configurable via `a2a.max_contexts`)
- When limit exceeded, least recently used contexts are automatically evicted
Configure in `haiku.rag.yaml`:
```yaml
a2a:
max_contexts: 1000
```
## Security
By default, the A2A agent runs without authentication. For production deployments, you should add authentication.
### Adding Authentication
The `create_a2a_app()` function accepts optional security parameters that declare authentication requirements in the agent card:
```python
from haiku.rag.a2a import create_a2a_app
app = create_a2a_app(
db_path,
security_schemes={
"apiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API key authentication",
}
},
security=[{"apiKeyAuth": []}],
)
```
This populates the agent card at `/.well-known/agent-card.json` so other agents can discover your authentication requirements.
### Security Examples
Three working examples are provided in `examples/a2a-security/`:
1. **API Key** (`apikey_example.py`) - Simple header-based authentication
2. **OAuth2 GitHub** (`oauth2_github.py`) - GitHub Personal Access Token authentication
3. **OAuth2 Enterprise** (`oauth2_example.py`) - Full OAuth2 with JWT verification
Each example shows:
- How to declare security in the agent card
- How to implement authentication middleware
- How to verify credentials

View file

@ -1,176 +0,0 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
import logfire
from pydantic_ai import Agent, RunContext
from haiku.rag.config import AppConfig, Config
from haiku.rag.utils import get_model
from .context import load_message_history, save_message_history
from .models import A2AConfig, AgentDependencies
from .prompts import A2A_SYSTEM_PROMPT
from .skills import extract_question_from_task, get_agent_skills
from .storage import LRUMemoryStorage
from .worker import ConversationalWorker
try:
from fasta2a import FastA2A # type: ignore
from fasta2a.broker import InMemoryBroker # type: ignore
from fasta2a.storage import InMemoryStorage # type: ignore
except ImportError as e:
raise ImportError(
"A2A support requires the 'a2a' extra. "
"Install with: uv pip install 'haiku.rag[a2a]'"
) from e
logfire.configure(send_to_logfire="if-token-present", service_name="a2a")
logfire.instrument_pydantic_ai()
logger = logging.getLogger(__name__)
__all__ = [
"create_a2a_app",
"load_message_history",
"save_message_history",
"extract_question_from_task",
"get_agent_skills",
"LRUMemoryStorage",
"A2AConfig",
]
def create_a2a_app(
db_path: Path,
config: AppConfig = Config,
max_contexts: int = 1000,
security_schemes: dict | None = None,
security: list[dict[str, list[str]]] | None = None,
):
"""Create an A2A app for the conversational QA agent.
Args:
db_path: Path to the LanceDB database
config: App configuration
max_contexts: Maximum number of conversations to keep in memory
security_schemes: Optional security scheme definitions for the AgentCard
security: Optional security requirements for the AgentCard
Returns:
A FastA2A ASGI application
"""
base_storage = InMemoryStorage()
storage = LRUMemoryStorage(storage=base_storage, max_contexts=max_contexts)
broker = InMemoryBroker()
# Create the agent with native search tool
model = get_model(config.qa.model, config)
agent = Agent(
model=model,
deps_type=AgentDependencies,
system_prompt=A2A_SYSTEM_PROMPT,
retries=3,
)
@agent.tool
async def search_documents(
ctx: RunContext[AgentDependencies],
query: str,
limit: int = 3,
) -> str:
"""Search the knowledge base for relevant documents.
Returns chunks of text with their relevance scores and document URIs.
Use get_full_document if you need to see the complete document content.
"""
search_results = await ctx.deps.client.search(query, limit=limit)
results = await ctx.deps.client.expand_context(search_results)
parts = [r.format_for_agent() for r in results]
return "\n\n".join(parts) if parts else "No results found."
@agent.tool
async def get_full_document(
ctx: RunContext[AgentDependencies],
document_uri: str,
) -> str:
"""Retrieve the complete content of a document by its URI.
Use this when you need more context than what's in a search result chunk.
The document_uri comes from search_documents results.
"""
document = await ctx.deps.client.get_document_by_uri(document_uri)
if document is None:
return f"Document not found: {document_uri}"
return document.content
worker = ConversationalWorker(
storage=storage,
broker=broker,
db_path=db_path,
agent=agent, # type: ignore
config=config,
)
# Create FastA2A app with custom worker lifecycle
@asynccontextmanager
async def lifespan(app):
logger.info(f"Started A2A server (max contexts: {max_contexts})")
async with app.task_manager:
async with worker.run():
yield
app = FastA2A(
storage=storage,
broker=broker,
name="haiku-rag",
description="Conversational question answering agent powered by haiku.rag RAG system",
skills=get_agent_skills(),
lifespan=lifespan,
)
# Add security configuration if provided
if security_schemes or security:
# Monkey-patch the agent card endpoint to include security
async def _agent_card_endpoint_with_security(request):
from fasta2a.schema import ( # type: ignore
AgentCapabilities,
AgentCard,
agent_card_ta,
)
from starlette.responses import Response
if app._agent_card_json_schema is None:
agent_card = AgentCard(
name=app.name,
description=app.description
or "An AI agent exposed as an A2A agent.",
url=app.url,
version=app.version,
protocol_version="0.3.0",
skills=app.skills,
default_input_modes=app.default_input_modes,
default_output_modes=app.default_output_modes,
capabilities=AgentCapabilities(
streaming=False,
push_notifications=False,
state_transition_history=False,
),
)
if app.provider is not None:
agent_card["provider"] = app.provider
if security_schemes:
agent_card["security_schemes"] = security_schemes
if security:
agent_card["security"] = security
app._agent_card_json_schema = agent_card_ta.dump_json(
agent_card, by_alias=True
)
return Response(
content=app._agent_card_json_schema, media_type="application/json"
)
app._agent_card_endpoint = _agent_card_endpoint_with_security
return app

View file

@ -1,269 +0,0 @@
# pyright: reportMissingImports=false
import asyncio
import uuid
from typing import Any
import httpx
from rich.console import Console
from rich.markdown import Markdown
from rich.prompt import Prompt
try:
from fasta2a.client import A2AClient as FastA2AClient
from fasta2a.schema import Message, TextPart
except ImportError as e:
raise ImportError(
"A2A support requires the 'a2a' extra. "
"Install with: uv pip install 'haiku.rag[a2a]'"
) from e
class A2AClient:
"""Interactive A2A protocol client."""
def __init__(self, base_url: str = "http://localhost:8000"):
"""Initialize A2A client.
Args:
base_url: Base URL of the A2A server
"""
self.base_url = base_url.rstrip("/")
http_client = httpx.AsyncClient(timeout=60.0)
self._client = FastA2AClient(base_url=base_url, http_client=http_client)
async def close(self):
"""Close the HTTP client."""
await self._client.http_client.aclose()
async def get_agent_card(self) -> dict[str, Any]:
"""Fetch the agent card from the A2A server.
Returns:
Agent card dictionary with agent capabilities and metadata
"""
response = await self._client.http_client.get(
f"{self.base_url}/.well-known/agent-card.json"
)
response.raise_for_status()
return response.json()
async def send_message(
self,
text: str,
context_id: str | None = None,
skill_id: str | None = None,
) -> dict[str, Any]:
"""Send a message to the A2A agent and wait for completion.
Args:
text: Message text to send
context_id: Optional conversation context ID (creates new if None)
skill_id: Optional skill ID to use (defaults to document-qa)
Returns:
Completed task with response messages and artifacts
"""
if context_id is None:
context_id = str(uuid.uuid4())
message = Message(
kind="message",
role="user",
message_id=str(uuid.uuid4()),
parts=[TextPart(kind="text", text=text)],
)
metadata: dict[str, Any] = {"contextId": context_id}
if skill_id:
metadata["skillId"] = skill_id
response = await self._client.send_message(message, metadata=metadata)
if "error" in response:
return {"error": response["error"]}
result = response.get("result")
if not result:
return {"result": result}
# Result can be either Task or Message - check if it's a Task with an id
if result.get("kind") == "task":
task_id = result.get("id")
if task_id:
# Poll for task completion
return await self.wait_for_task(task_id)
# Return the message directly
return {"result": result}
async def wait_for_task(
self, task_id: str, max_wait: int = 120, poll_interval: float = 0.5
) -> dict[str, Any]:
"""Poll for task completion.
Args:
task_id: Task ID to poll for
max_wait: Maximum time to wait in seconds
poll_interval: Interval between polls in seconds
Returns:
Completed task result
"""
import time
start_time = time.time()
while time.time() - start_time < max_wait:
task_response = await self._client.get_task(task_id)
if "error" in task_response:
return {"error": task_response["error"]}
task = task_response.get("result")
if not task:
raise Exception("No task in response")
state = task.get("status", {}).get("state")
if state == "completed":
return {"result": task}
elif state == "failed":
raise Exception(f"Task failed: {task}")
await asyncio.sleep(poll_interval)
raise TimeoutError(f"Task {task_id} did not complete within {max_wait}s")
def print_agent_card(card: dict[str, Any], console: Console):
"""Pretty print the agent card using Rich."""
console.print()
console.print("[bold]Agent Card[/bold]")
console.rule()
console.print(f" [repr.attrib_name]name[/repr.attrib_name]: {card.get('name')}")
console.print(
f" [repr.attrib_name]description[/repr.attrib_name]: {card.get('description')}"
)
console.print(
f" [repr.attrib_name]version[/repr.attrib_name]: {card.get('version')}"
)
console.print(
f" [repr.attrib_name]protocol version[/repr.attrib_name]: {card.get('protocolVersion')}"
)
skills = card.get("skills", [])
console.print(f"\n[bold cyan]Skills ({len(skills)}):[/bold cyan]")
for skill in skills:
console.print(f"{skill.get('id')}: {skill.get('name')}")
console.print(f" [dim]{skill.get('description')}[/dim]")
examples = skill.get("examples", [])
if examples:
console.print(f" [dim]Examples: {', '.join(examples[:2])}[/dim]")
console.print()
def print_response(response: dict[str, Any], console: Console):
"""Pretty print the A2A response using Rich."""
if "error" in response:
console.print(f"[red]Error: {response['error']}[/red]")
return
result = response.get("result", {})
# Get messages from history and artifacts from completed task
history = result.get("history", [])
artifacts = result.get("artifacts", [])
# Print agent messages from history with markdown rendering
for msg in history:
if msg.get("role") == "agent":
for part in msg.get("parts", []):
if part.get("kind") == "text":
text = part.get("text", "")
# Render as markdown
console.print()
console.print("[bold green]Answer:[/bold green]")
console.print(Markdown(text))
# Print artifacts summary with details
if artifacts:
console.rule("[dim]Artifacts generated[/dim]")
summary_lines = []
for artifact in artifacts:
name = artifact.get("name", "")
parts = artifact.get("parts", [])
if name == "search_results" and parts:
data = parts[0].get("data", {})
query = data.get("query", "")
results = data.get("results", [])
summary_lines.append(f"🔍 search: '{query}' ({len(results)} results)")
elif name == "document" and parts:
part = parts[0]
if part.get("kind") == "text":
text = part.get("text", "")
length = len(text)
summary_lines.append(f"📄 document ({length} chars)")
elif name == "qa_result" and parts:
data = parts[0].get("data", {})
skill = data.get("skill", "unknown")
summary_lines.append(f"💬 {skill}")
if summary_lines:
console.print(f"[dim]{''.join(summary_lines)}[/dim]")
console.print()
async def run_interactive_client(url: str = "http://localhost:8000"):
"""Run the interactive A2A client.
Args:
url: Base URL of the A2A server
"""
console = Console()
client = A2AClient(url)
console.print("[bold]haiku.rag A2A interactive client[/bold]")
console.print()
# Fetch and display agent card
console.print("[dim]Fetching agent card...[/dim]")
try:
card = await client.get_agent_card()
print_agent_card(card, console)
except Exception as e:
console.print(f"[red]Error fetching agent card: {e}[/red]")
await client.close()
return
# Create a conversation context
context_id = str(uuid.uuid4())
console.print(f"[dim]context id: {context_id}[/dim]")
console.print("[dim]Type your questions (or 'quit' to exit)[/dim]\n")
try:
while True:
try:
question = Prompt.ask("[bold blue]Question[/bold blue]").strip()
if not question:
continue
if question.lower() in ("quit", "exit", "q"):
console.print("\n[dim]Goodbye![/dim]")
break
response = await client.send_message(question, context_id=context_id)
print_response(response, console)
except KeyboardInterrupt:
console.print("\n\n[dim]Exiting...[/dim]")
break
except Exception as e:
console.print(f"\n[red]Error: {e}[/red]\n")
finally:
await client.close()

View file

@ -1,68 +0,0 @@
import uuid
from pydantic import TypeAdapter
from pydantic_ai.messages import ModelMessage
from pydantic_core import to_jsonable_python
try:
from fasta2a.schema import DataPart, Message # type: ignore
except ImportError as e:
raise ImportError(
"A2A support requires the 'a2a' extra. "
"Install with: uv pip install 'haiku.rag[a2a]'"
) from e
ModelMessagesTypeAdapter = TypeAdapter(list[ModelMessage])
def load_message_history(context: list[Message]) -> list[ModelMessage]:
"""Load pydantic-ai message history from A2A context.
The context stores serialized pydantic-ai message history directly,
which we deserialize and return.
Args:
context: A2A context messages
Returns:
List of pydantic-ai ModelMessage objects
"""
if not context:
return []
# Context should contain a single "state" message with full history
for msg in context:
parts = msg.get("parts", [])
for part in parts:
if part.get("kind") == "data":
metadata = part.get("metadata", {})
if metadata.get("type") == "conversation_state":
stored_history = part.get("data", {}).get("message_history", [])
if stored_history:
return ModelMessagesTypeAdapter.validate_python(stored_history)
return []
def save_message_history(message_history: list[ModelMessage]) -> Message:
"""Save pydantic-ai message history to A2A context format.
Args:
message_history: Full pydantic-ai message history
Returns:
A2A Message containing the serialized state (stored as agent role)
"""
serialized = to_jsonable_python(message_history)
return Message(
role="agent",
parts=[
DataPart(
kind="data",
data={"message_history": serialized},
metadata={"type": "conversation_state"},
)
],
kind="message",
message_id=str(uuid.uuid4()),
)

View file

@ -1,18 +0,0 @@
from pydantic import BaseModel, Field
from haiku.rag.client import HaikuRAG
class A2AConfig(BaseModel):
"""Configuration for A2A (Agent-to-Agent) protocol server."""
max_contexts: int = Field(
default=1000, description="Maximum number of conversations to keep in memory"
)
class AgentDependencies(BaseModel):
"""Dependencies for the A2A conversational agent."""
model_config = {"arbitrary_types_allowed": True}
client: HaikuRAG

View file

@ -1,79 +0,0 @@
A2A_SYSTEM_PROMPT = """You are Haiku.rag, an AI assistant that helps users find information from a document knowledge base.
IMPORTANT: You are NOT any person mentioned in the documents. You retrieve and present information about them.
Tools available:
- search_documents: Query for relevant text chunks
- get_full_document: Get complete document content by document_uri
The search tool returns results like:
[chunk_abc123] (score: 0.85)
Source: "Document Title" > Section > Subsection
Type: paragraph
Content:
The actual text content here...
[chunk_def456] (score: 0.72)
Source: "Another Document"
Type: table
Content:
| Column 1 | Column 2 |
...
Each result includes:
- chunk_id in brackets and relevance score
- Source: document title and section hierarchy (when available)
- Type: content type like paragraph, table, code, list_item (when available)
- Content: the actual text
Your behavior depends on the operation:
## For direct search requests:
When the user is explicitly searching (e.g., "search for X", "find documents about Y"):
- Use search_documents tool ONLY
- Format results as a numbered list using markdown formatting
- For each result show:
* First line: *Score in italic* | **source in bold** (title if available, otherwise URI)
* Second line: The FULL chunk content (do not summarize or truncate)
- Present results in order of relevance
- Be concise - just present the search results, do not synthesize or add commentary
Example format:
Found 3 relevant results:
1. *Score: 0.95* | **Python Documentation** (/guides/python.md)
Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation.
2. *Score: 0.87* | **/guides/python-basics.md**
Python supports multiple programming paradigms, including structured, object-oriented and functional programming.
## For question-answering:
When the user asks a question (e.g., "What is Python?", "How does X work?"):
- For complex questions, use search_documents MULTIPLE TIMES with DIFFERENT queries to gather comprehensive information
- Example: For "What are the benefits and drawbacks of Python?", search separately for:
* "Python benefits advantages"
* "Python drawbacks disadvantages limitations"
- Synthesize information from all searches into a comprehensive answer
- Include "Sources:" section at the end listing sources used
Sources Format:
List each source with its title/URI and the relevant chunk content (NOT the score).
Format: "- **[title or URI]**: [chunk content]"
Example:
[Your synthesized answer here]
Sources:
- **Python Documentation** (/guides/python.md): Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability.
- **/guides/python-basics.md**: Python supports multiple programming paradigms, including structured, object-oriented and functional programming.
Critical rules:
- ONLY answer based on information found via search_documents
- For comprehensive questions, perform MULTIPLE searches with different query angles
- NEVER fabricate or assume information
- If not found, say: "I cannot find information about this in the knowledge base."
- For follow-ups, understand context (pronouns like "he", "it") but always search for facts
- In Sources, include the actual chunk content from your search results, not summaries
Note: When using get_full_document, always use document_uri (not document_title).
"""

View file

@ -1,75 +0,0 @@
try:
from fasta2a.schema import Message, Skill # type: ignore
except ImportError as e:
raise ImportError(
"A2A support requires the 'a2a' extra. "
"Install with: uv pip install 'haiku.rag[a2a]'"
) from e
def get_agent_skills() -> list[Skill]:
"""Define the skills exposed by the haiku.rag A2A agent.
Returns:
List of skills describing the agent's capabilities
"""
return [
Skill(
id="document-qa",
name="Document Question Answering",
description="Answer questions based on a knowledge base of documents using semantic search and retrieval",
tags=["question-answering", "search", "knowledge-base", "rag"],
input_modes=["application/json"],
output_modes=["application/json"],
examples=[
"What does the documentation say about authentication?",
"Find information about Python best practices",
"Show me the full API documentation",
],
),
Skill(
id="document-search",
name="Document Search",
description="Search for relevant document chunks in the knowledge base using hybrid (semantic and BM25) search",
tags=["search", "retrieval", "semantic-search"],
input_modes=["application/json"],
output_modes=["application/json"],
examples=[
"Search for Python best practices",
"Find documents about authentication",
"Look for API documentation",
],
),
Skill(
id="document-retrieve",
name="Document Retrieval",
description="Retrieve the complete content of a specific document by its URI",
tags=["retrieval", "fetch", "document"],
input_modes=["application/json"],
output_modes=["application/json"],
examples=[
"Get the full content of document X",
"Retrieve document by URI",
"Show me the complete document",
],
),
]
def extract_question_from_task(task_history: list[Message]) -> str | None:
"""Extract the user's question from task history.
Args:
task_history: Task history messages
Returns:
The question text if found, None otherwise
"""
for msg in task_history:
if msg.get("role") == "user":
for part in msg.get("parts", []):
if part.get("kind") == "text":
text = part.get("text", "").strip()
if text:
return text
return None

View file

@ -1,71 +0,0 @@
import logging
from collections import OrderedDict
try:
from fasta2a.schema import Artifact, Message, TaskState # type: ignore
from fasta2a.storage import InMemoryStorage, Storage # type: ignore
except ImportError as e:
raise ImportError(
"A2A support requires the 'a2a' extra. "
"Install with: uv pip install 'haiku.rag[a2a]'"
) from e
logger = logging.getLogger(__name__)
class LRUMemoryStorage(Storage[list[Message]]): # type: ignore
"""Storage wrapper with LRU eviction for contexts.
Enforces a maximum context limit using LRU (Least Recently Used) eviction.
"""
def __init__(self, storage: InMemoryStorage, max_contexts: int):
self.storage = storage
self.max_contexts = max_contexts
# Track context access order (LRU cache)
self.context_order: OrderedDict[str, None] = OrderedDict()
async def load_context(self, context_id: str) -> list[Message] | None:
"""Load context and update access order."""
result = await self.storage.load_context(context_id)
if result is not None:
# Move to end (most recently used)
self.context_order.pop(context_id, None)
self.context_order[context_id] = None
return result
async def update_context(self, context_id: str, context: list[Message]) -> None:
"""Update context and enforce LRU limit."""
await self.storage.update_context(context_id, context)
# Move to end (most recently used)
self.context_order.pop(context_id, None)
self.context_order[context_id] = None
# Enforce max contexts limit (LRU eviction)
while len(self.context_order) > self.max_contexts:
# Remove oldest (first item in OrderedDict)
oldest_context_id = next(iter(self.context_order))
self.context_order.pop(oldest_context_id)
logger.debug(
f"Evicted context {oldest_context_id} (LRU, limit={self.max_contexts})"
)
async def load_task(self, task_id: str, history_length: int | None = None):
"""Delegate to underlying storage."""
return await self.storage.load_task(task_id, history_length)
async def update_task(
self,
task_id: str,
state: TaskState,
new_artifacts: list[Artifact] | None = None,
new_messages: list[Message] | None = None,
):
"""Delegate to underlying storage."""
return await self.storage.update_task(
task_id, state, new_artifacts, new_messages
)
async def submit_task(self, context_id: str, message: Message):
"""Delegate to underlying storage."""
return await self.storage.submit_task(context_id, message)

View file

@ -1,324 +0,0 @@
# pyright: reportMissingImports=false
import json
import logging
import uuid
from pathlib import Path
from pydantic_ai import Agent
from haiku.rag.client import HaikuRAG
from haiku.rag.config import AppConfig, Config
from haiku_rag_a2a.a2a.context import load_message_history, save_message_history
from haiku_rag_a2a.a2a.models import AgentDependencies
from haiku_rag_a2a.a2a.skills import extract_question_from_task
try:
from fasta2a import Worker
from fasta2a.schema import (
Artifact,
Message,
TaskIdParams,
TaskSendParams,
TextPart,
)
except ImportError as e:
raise ImportError(
"A2A support requires the 'a2a' extra. "
"Install with: uv pip install 'haiku.rag[a2a]'"
) from e
logger = logging.getLogger(__name__)
class ConversationalWorker(Worker[list[Message]]):
"""Worker that handles conversational QA tasks."""
def __init__(
self,
storage,
broker,
db_path: Path,
agent: "Agent[AgentDependencies, str]",
config: AppConfig = Config,
):
super().__init__(storage=storage, broker=broker)
self.db_path = db_path
self.agent = agent
self.config = config
async def run_task(self, params: TaskSendParams) -> None:
task = await self.storage.load_task(params["id"])
if task is None:
raise ValueError(f"Task {params['id']} not found")
if task["status"]["state"] != "submitted":
raise ValueError(
f"Task {params['id']} already processed: {task['status']['state']}"
)
await self.storage.update_task(task["id"], state="working")
task_history = task.get("history", [])
question = extract_question_from_task(task_history)
if not question:
await self.storage.update_task(task["id"], state="failed")
return
try:
async with HaikuRAG(self.db_path, config=self.config) as client:
context = await self.storage.load_context(task["context_id"]) or []
message_history = load_message_history(context)
deps = AgentDependencies(client=client)
result = await self.agent.run(
question, deps=deps, message_history=message_history
)
# Detect which skill was used
skill_type = self._detect_skill(result)
# Build messages based on skill type
response_messages = self._build_response_messages(result, skill_type)
# Update context with complete conversation state
updated_history = message_history + result.new_messages()
state_message = save_message_history(updated_history)
await self.storage.update_context(task["context_id"], [state_message])
artifacts = self.build_artifacts(result, skill_type, question)
await self.storage.update_task(
task["id"],
state="completed",
new_messages=response_messages,
new_artifacts=artifacts,
)
except Exception as e:
logger.error(
"Task execution failed: task_id=%s, question=%s, error=%s",
task["id"],
question,
str(e),
exc_info=True,
)
await self.storage.update_task(task["id"], state="failed")
raise
async def cancel_task(self, params: TaskIdParams) -> None:
"""Cancel a task - not implemented for this worker."""
pass
def build_message_history(self, history: list[Message]) -> list[Message]:
"""Required by Worker interface but unused - history stored in context."""
return history
def _detect_skill(self, result) -> str:
"""Detect which skill was used based on tool calls and response pattern.
Returns:
"search", "retrieve", or "qa"
"""
from pydantic_ai.messages import ModelResponse, ToolCallPart
tool_calls = []
for msg in result.new_messages():
if isinstance(msg, ModelResponse):
for part in msg.parts:
if isinstance(part, ToolCallPart):
tool_calls.append(part.tool_name)
# Check if output looks like formatted search results
output_str = str(result.output).strip()
# Check for either format: "Found N relevant results" or "**Search results for"
is_search_format = (
output_str.startswith("Found ") and "relevant results" in output_str[:100]
) or output_str.startswith("**Search results for")
skill_type = "qa"
# If output is in search format and only search tools were used, it's a search
if is_search_format and all(tc == "search_documents" for tc in tool_calls):
skill_type = "search"
elif "get_full_document" in tool_calls and len(tool_calls) == 1:
skill_type = "retrieve"
return skill_type
def _build_response_messages(self, result, skill_type: str) -> list[Message]:
"""Build response messages based on skill type.
All skills return a single text message with LLM's response.
Structured data is provided via artifacts for search/retrieve.
"""
if skill_type == "search":
# Return LLM's formatted response
return [
Message(
role="agent",
parts=[TextPart(kind="text", text=str(result.output))],
kind="message",
message_id=str(uuid.uuid4()),
)
]
elif skill_type == "retrieve":
# Extract document content
from pydantic_ai.messages import ModelRequest, ToolReturnPart
document_content = ""
for msg in result.new_messages():
if isinstance(msg, ModelRequest):
for part in msg.parts:
if (
isinstance(part, ToolReturnPart)
and part.tool_name == "get_full_document"
):
document_content = part.content
break
return [
Message(
role="agent",
parts=[TextPart(kind="text", text=document_content)],
kind="message",
message_id=str(uuid.uuid4()),
)
]
else:
# Conversational Q&A - use agent's answer
return [
Message(
role="agent",
parts=[TextPart(kind="text", text=str(result.output))],
kind="message",
message_id=str(uuid.uuid4()),
)
]
def build_artifacts(
self, result, skill_type: str | None = None, question: str | None = None
) -> list[Artifact]:
"""Build artifacts from agent result based on tool calls.
Creates artifacts for:
- Each tool call (search_documents, get_full_document)
- Q&A operations: additional artifact with question and answer (only if tools were used)
"""
if skill_type is None:
skill_type = self._detect_skill(result)
artifacts = []
# Always create artifacts for all tool calls
tool_artifacts = self._build_all_tool_artifacts(result)
artifacts.extend(tool_artifacts)
# For Q&A, always add a Q&A artifact with question and answer
# This includes follow-up questions, clarifications, and conversational responses
if skill_type == "qa" and question:
from fasta2a.schema import DataPart
artifacts.append(
Artifact(
artifact_id=str(uuid.uuid4()),
name="qa_result",
parts=[
DataPart(
kind="data",
data={
"question": question,
"answer": str(result.output),
"skill": "document-qa",
},
metadata={"skill": "document-qa"},
)
],
)
)
return artifacts
def _build_all_tool_artifacts(self, result) -> list[Artifact]:
"""Build artifacts for all tool calls."""
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
ToolCallPart,
ToolReturnPart,
)
artifacts = []
# Track tool calls and their returns by call_id
tool_returns = {}
for msg in result.new_messages():
if isinstance(msg, ModelRequest):
for part in msg.parts:
if isinstance(part, ToolReturnPart):
result_count = (
len(part.content) if isinstance(part.content, list) else 1
)
logger.info(
"Tool return: tool_call_id=%s, tool_name=%s, result_count=%s",
part.tool_call_id,
part.tool_name,
result_count,
)
tool_returns[part.tool_call_id] = (part.tool_name, part.content)
# Create artifacts for each tool call
for msg in result.new_messages():
if isinstance(msg, ModelResponse):
for part in msg.parts:
if isinstance(part, ToolCallPart):
tool_name, content = tool_returns.get(
part.tool_call_id, (None, None)
)
if tool_name == "search_documents" and content:
from fasta2a.schema import DataPart
# Extract query from tool call arguments
query = ""
if isinstance(part.args, dict):
query = part.args.get("query", "")
elif isinstance(part.args, str):
# Args is a JSON string - parse it
try:
args_dict = json.loads(part.args)
query = args_dict.get("query", "")
except (json.JSONDecodeError, AttributeError):
query = ""
elif hasattr(part.args, "get") and callable(
getattr(part.args, "get", None)
):
# ArgsDict or dict-like object
query = part.args.get("query", "") # type: ignore
elif hasattr(part.args, "query"):
# Object with query attribute
query = str(part.args.query) # type: ignore
artifacts.append(
Artifact(
artifact_id=str(uuid.uuid4()),
name="search_results",
parts=[
DataPart(
kind="data",
data={"results": content, "query": query},
metadata={"query": query},
)
],
)
)
elif tool_name == "get_full_document" and content:
artifacts.append(
Artifact(
artifact_id=str(uuid.uuid4()),
name="document",
parts=[TextPart(kind="text", text=content)],
)
)
return artifacts

View file

@ -1,85 +0,0 @@
import asyncio
import logging
from pathlib import Path
import typer
import uvicorn
from haiku.rag.config import AppConfig, Config, load_yaml_config
from haiku.rag.utils import get_default_data_dir
from haiku_rag_a2a.a2a import create_a2a_app
from haiku_rag_a2a.a2a.client import run_interactive_client
logger = logging.getLogger(__name__)
cli = typer.Typer(name="haiku-rag-a2a", no_args_is_help=True)
@cli.command("serve", help="Start haiku.rag A2A (Agent-to-Agent) server")
def serve(
db: Path | None = typer.Option(
None,
"--db",
help="Path to the database directory",
),
config_file: Path | None = typer.Option(
None,
"--config",
help="Path to the configuration file",
),
host: str = typer.Option(
"127.0.0.1",
"--host",
help="Host to bind A2A server to",
),
port: int = typer.Option(
8000,
"--port",
help="Port to bind A2A server to",
),
max_contexts: int = typer.Option(
1000,
"--max-contexts",
help="Maximum number of conversation contexts to keep in memory",
),
) -> None:
"""Start the A2A server."""
config = Config
if config_file:
yaml_data = load_yaml_config(config_file)
config = AppConfig.model_validate(yaml_data)
if db is None:
db = get_default_data_dir() / "haiku.rag.lancedb"
if not db.exists():
typer.echo(f"Error: Database {db} does not exist")
raise typer.Exit(1)
logger.info(f"Starting A2A server on {host}:{port}")
app = create_a2a_app(db_path=db, config=config, max_contexts=max_contexts)
uvicorn_config = uvicorn.Config(
app,
host=host,
port=port,
log_level="info",
)
server = uvicorn.Server(uvicorn_config)
asyncio.run(server.serve())
@cli.command("client", help="Run interactive client to chat with A2A server")
def client(
url: str = typer.Option(
"http://localhost:8000",
"--url",
help="URL of the A2A server",
),
):
"""Run the interactive A2A client."""
asyncio.run(run_interactive_client(url))
if __name__ == "__main__":
cli()

View file

@ -1,27 +0,0 @@
[project]
name = "haiku-rag-a2a"
description = "A2A protocol server for haiku.rag - Conversational agent interface"
version = "0.1.0"
authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.12"
keywords = ["RAG", "a2a", "agent", "conversational-ai"]
dependencies = [
"haiku.rag>=0.26.6",
"fasta2a>=0.6.0",
"pydantic-ai-slim[a2a]>=1.39.0",
"rich>=14.2.0",
"httpx>=0.28.1",
]
[project.scripts]
haiku-rag-a2a = "haiku_rag_a2a.cli:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["haiku_rag_a2a"]

View file

@ -1,129 +0,0 @@
"""Example: Adding API Key authentication to haiku.rag A2A agent.
Simple header-based authentication suitable for internal services and development.
Perfect for getting started with A2A authentication.
Setup:
# Run with default key
python apikey_example.py /path/to/database.lancedb
# Or use your own key
export API_KEY='your-secret-key'
python apikey_example.py /path/to/database.lancedb
Usage:
# Make authenticated request (default key is demo-key-12345)
curl -H "X-API-Key: demo-key-12345" \
-H "Content-Type: application/json" \
-X POST http://localhost:8000/ \
-d '{"jsonrpc":"2.0","method":"message/send","params":{"contextId":"test","message":{"kind":"message","role":"user","messageId":"msg-1","parts":[{"kind":"text","text":"What is Python?"}]}},"id":1}'
"""
import os
from pathlib import Path
from haiku_rag_a2a.a2a import create_a2a_app
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.status import HTTP_401_UNAUTHORIZED
# API Key Configuration - In production, use environment variables or a secure key store
API_KEY_NAME = "X-API-Key"
VALID_API_KEY = os.getenv("API_KEY", "demo-key-12345")
def verify_api_key(api_key: str | None) -> str:
"""Verify API key from request header.
Args:
api_key: API key from X-API-Key header
Returns:
The verified API key
Raises:
HTTPException: If API key is missing or invalid
"""
if not api_key:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Missing API key",
headers={"WWW-Authenticate": f'ApiKey realm="{API_KEY_NAME}"'},
)
if api_key != VALID_API_KEY:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": f'ApiKey realm="{API_KEY_NAME}"'},
)
return api_key
def create_secure_a2a_app(db_path: Path):
"""Create A2A app with API key authentication.
Args:
db_path: Path to LanceDB database
Returns:
FastA2A application with API key security
"""
# Create app with security declared in AgentCard
app = create_a2a_app(
db_path,
security_schemes={
"apiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": API_KEY_NAME,
"description": "API key authentication",
}
},
security=[{"apiKeyAuth": []}],
)
# Add authentication middleware
@app.middleware("http")
async def authenticate_request(request, call_next):
"""Middleware to verify API key on all requests."""
# Skip authentication for well-known endpoints
if request.url.path in [
"/.well-known/agent-card.json",
"/health",
"/docs",
"/openapi.json",
]:
return await call_next(request)
# Verify API key
api_key = request.headers.get(API_KEY_NAME)
try:
verify_api_key(api_key)
except HTTPException as e:
return JSONResponse(
status_code=e.status_code,
content={"detail": e.detail},
headers=e.headers or {},
)
# Continue with request
return await call_next(request)
return app
if __name__ == "__main__":
import sys
import uvicorn
if len(sys.argv) < 2:
print("Usage: python apikey_example.py <path-to-database.lancedb>")
sys.exit(1)
db_path = Path(sys.argv[1])
app = create_secure_a2a_app(db_path)
uvicorn.run(app, host="127.0.0.1", port=8000)

View file

@ -1,221 +0,0 @@
"""Example: Adding OAuth2 authentication to haiku.rag A2A agent.
This example demonstrates OAuth2 client credentials flow with JWT token verification.
Suitable for enterprise environments with existing OAuth2 infrastructure.
Requirements:
uv pip install python-jose[cryptography]
Setup:
1. Set up an OAuth2 provider (Auth0, Okta, Azure AD, Keycloak, etc.)
2. Create an API and a machine-to-machine application
3. Get the token URL and public key from your provider
4. Set environment variables:
export OAUTH2_TOKEN_URL='https://your-auth.example.com/oauth/token'
export OAUTH2_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----...'
Usage:
python oauth2_example.py /path/to/database.lancedb
# Get access token from your OAuth2 provider:
TOKEN=$(curl -X POST $OAUTH2_TOKEN_URL \
-d "grant_type=client_credentials" \
-d "client_id=your-client-id" \
-d "client_secret=your-client-secret" \
-d "scope=read:documents query:documents" \
| jq -r '.access_token')
# Make authenticated request:
curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-X POST http://localhost:8000/ \
-d '{"jsonrpc":"2.0","method":"message/send","params":{"contextId":"test","message":{"kind":"message","role":"user","messageId":"msg-1","parts":[{"kind":"text","text":"What is Python?"}]}},"id":1}'
"""
import os
from pathlib import Path
from haiku_rag_a2a.a2a import create_a2a_app
from jose import JWTError, jwt # pyright: ignore[reportMissingModuleSource]
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.status import (
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_500_INTERNAL_SERVER_ERROR,
)
# OAuth2 Configuration
OAUTH2_TOKEN_URL = os.getenv(
"OAUTH2_TOKEN_URL", "https://your-auth.example.com/oauth/token"
)
OAUTH2_AUTH_URL = os.getenv(
"OAUTH2_AUTH_URL", "https://your-auth.example.com/oauth/authorize"
)
OAUTH2_PUBLIC_KEY = os.getenv("OAUTH2_PUBLIC_KEY", "")
OAUTH2_ALGORITHM = os.getenv("OAUTH2_ALGORITHM", "RS256")
# Define required scopes for each skill
SKILL_SCOPES = {
"document-qa": ["read:documents", "query:documents"],
}
def verify_token(token: str) -> dict:
"""Verify JWT token from OAuth2 provider.
Args:
token: JWT token from Authorization header
Returns:
Dictionary with user info and scopes
Raises:
HTTPException: If token is invalid or expired
"""
credentials_exception = HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not OAUTH2_PUBLIC_KEY:
raise HTTPException(
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth2 public key not configured",
)
try:
payload = jwt.decode(
token,
OAUTH2_PUBLIC_KEY,
algorithms=[OAUTH2_ALGORITHM],
)
username: str | None = payload.get("sub")
scopes: list[str] = (
payload.get("scope", "").split()
if isinstance(payload.get("scope"), str)
else payload.get("scope", [])
)
if username is None:
raise credentials_exception
return {"username": username, "scopes": scopes}
except JWTError as e:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
) from e
def check_skill_permissions(skill_id: str, credentials: dict) -> None:
"""Verify that user has required scopes for a skill.
Args:
skill_id: The skill being accessed
credentials: User credentials with scopes
Raises:
HTTPException: If user lacks required permissions
"""
required_scopes = SKILL_SCOPES.get(skill_id, [])
user_scopes = credentials.get("scopes", [])
missing_scopes = [scope for scope in required_scopes if scope not in user_scopes]
if missing_scopes:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail=f"Missing required scopes: {', '.join(missing_scopes)} for skill: {skill_id}",
)
def create_secure_a2a_app(db_path: Path):
"""Create A2A app with OAuth2 authentication.
Args:
db_path: Path to LanceDB database
Returns:
FastA2A application with OAuth2 security
"""
# Create app with security declared in AgentCard
app = create_a2a_app(
db_path,
security_schemes={
"oauth2": {
"type": "oauth2",
"flows": {
"clientCredentials": {
"tokenUrl": OAUTH2_TOKEN_URL,
"scopes": {
"read:documents": "Read document content",
"query:documents": "Search and query documents",
},
}
},
"description": "OAuth2 client credentials flow",
}
},
security=[{"oauth2": ["read:documents", "query:documents"]}],
)
# Add authentication middleware
@app.middleware("http")
async def authenticate_request(request, call_next):
"""Middleware to verify OAuth2 token on all requests."""
# Skip authentication for well-known endpoints
if request.url.path in [
"/.well-known/agent-card.json",
"/health",
"/docs",
"/openapi.json",
]:
return await call_next(request)
# Get token from Authorization header
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=HTTP_401_UNAUTHORIZED,
content={"detail": "Missing or invalid Authorization header"},
headers={"WWW-Authenticate": "Bearer"},
)
token = auth_header[7:] # Remove "Bearer " prefix
# Verify token
try:
credentials = verify_token(token)
# Attach credentials to request state for use in handlers
request.state.credentials = credentials
except HTTPException as e:
return JSONResponse(
status_code=e.status_code,
content={"detail": e.detail},
headers=e.headers or {},
)
# Continue with request
return await call_next(request)
return app
if __name__ == "__main__":
import sys
import uvicorn
if len(sys.argv) < 2:
print("Usage: python oauth2_example.py <path-to-database.lancedb>")
sys.exit(1)
db_path = Path(sys.argv[1])
app = create_secure_a2a_app(db_path)
uvicorn.run(app, host="127.0.0.1", port=8000)

View file

@ -1,192 +0,0 @@
"""Example: Using GitHub Personal Access Tokens for authentication.
This is a simplified OAuth2 example that uses GitHub Personal Access Tokens.
It's much easier to set up than full OAuth2 and perfect for testing.
Setup:
1. Go to https://github.com/settings/tokens
2. Click "Generate new token (classic)"
3. Give it a name and select scopes
4. Copy the generated token
Usage:
export GITHUB_TOKENS="your_github_token_here"
python oauth2_github.py /path/to/database.lancedb
# Make authenticated request:
curl -H "Authorization: Bearer ghp_your_token" \
-H "Content-Type: application/json" \
-X POST http://localhost:8000/ \
-d '{"jsonrpc":"2.0","method":"message/send","params":{"contextId":"test","message":{"kind":"message","role":"user","messageId":"msg-1","parts":[{"kind":"text","text":"What is Python?"}]}},"id":1}'
"""
import os
from pathlib import Path
import httpx
from haiku_rag_a2a.a2a import create_a2a_app
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.status import HTTP_401_UNAUTHORIZED
# Configuration
GITHUB_API_URL = "https://api.github.com"
ALLOWED_TOKENS = (
set(os.getenv("GITHUB_TOKENS", "").split(","))
if os.getenv("GITHUB_TOKENS")
else set()
)
async def verify_github_token(token: str) -> dict:
"""Verify GitHub Personal Access Token by calling GitHub API.
Args:
token: GitHub Personal Access Token (starts with ghp_)
Returns:
Dictionary with user info
Raises:
HTTPException: If token is invalid
"""
if not token.startswith("ghp_") and not token.startswith("github_pat_"):
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Invalid GitHub token format",
headers={"WWW-Authenticate": "Bearer"},
)
# If we have a list of allowed tokens, check against it
if ALLOWED_TOKENS and token not in ALLOWED_TOKENS:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Token not in allowed list",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify token with GitHub API
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{GITHUB_API_URL}/user",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=5.0,
)
if response.status_code == 401:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Invalid or expired GitHub token",
headers={"WWW-Authenticate": "Bearer"},
)
if response.status_code != 200:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail=f"GitHub API error: {response.status_code}",
headers={"WWW-Authenticate": "Bearer"},
)
user_data = response.json()
return {
"username": user_data.get("login"),
"email": user_data.get("email"),
"name": user_data.get("name"),
"github_id": user_data.get("id"),
}
except httpx.TimeoutException:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="GitHub API timeout",
headers={"WWW-Authenticate": "Bearer"},
)
except httpx.RequestError as e:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail=f"Failed to verify token with GitHub: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
def create_secure_a2a_app(db_path: Path):
"""Create A2A app with GitHub token authentication.
Args:
db_path: Path to LanceDB database
Returns:
FastA2A application with GitHub authentication
"""
# Create app with security declared in AgentCard
app = create_a2a_app(
db_path,
security_schemes={
"githubAuth": {
"type": "http",
"scheme": "bearer",
"description": "GitHub Personal Access Token authentication",
}
},
security=[{"githubAuth": []}],
)
@app.middleware("http")
async def authenticate_request(request, call_next):
"""Middleware to verify GitHub token on all requests."""
# Skip authentication for well-known endpoints
if request.url.path in [
"/.well-known/agent-card.json",
"/health",
"/docs",
"/openapi.json",
]:
return await call_next(request)
# Get token from Authorization header
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=HTTP_401_UNAUTHORIZED,
content={"detail": "Missing or invalid Authorization header"},
headers={"WWW-Authenticate": 'Bearer realm="GitHub"'},
)
token = auth_header[7:] # Remove "Bearer " prefix
# Verify token
try:
user_data = await verify_github_token(token)
# Attach user data to request state
request.state.user = user_data
except HTTPException as e:
return JSONResponse(
status_code=e.status_code,
content={"detail": e.detail},
headers=e.headers or {},
)
# Continue with request
return await call_next(request)
return app
if __name__ == "__main__":
import sys
import uvicorn
if len(sys.argv) < 2:
print("Usage: python oauth2_github.py <path-to-database.lancedb>")
sys.exit(1)
db_path = Path(sys.argv[1])
app = create_secure_a2a_app(db_path)
uvicorn.run(app, host="127.0.0.1", port=8000)

View file

@ -1,638 +0,0 @@
# pyright: reportMissingImports=false
import uuid
import pytest
pytest.importorskip("fasta2a")
from fasta2a.schema import Message, TextPart # noqa: E402
from haiku_rag_a2a.a2a import (
extract_question_from_task,
get_agent_skills,
load_message_history,
save_message_history,
)
from haiku_rag_a2a.a2a.storage import LRUMemoryStorage
from pydantic_ai.messages import ( # noqa: E402
ModelMessage,
ModelRequest,
ModelResponse,
ToolCallPart,
ToolReturnPart,
)
from pydantic_ai.messages import (
TextPart as AITextPart,
)
from haiku.rag.client import HaikuRAG
@pytest.mark.asyncio
async def test_save_and_load_message_history():
"""Test round-trip of saving and loading message history."""
# Create sample message history with proper part_kind for ModelRequest
from pydantic_ai.messages import UserPromptPart
original_history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content="What is Python?")]),
ModelResponse(parts=[AITextPart(content="Python is a programming language")]),
]
# Save to A2A format
saved_message = save_message_history(original_history)
# Verify structure
assert saved_message["role"] == "agent"
assert saved_message["kind"] == "message"
assert len(saved_message["parts"]) == 1
assert saved_message["parts"][0]["kind"] == "data"
metadata = saved_message["parts"][0].get("metadata")
assert metadata is not None
assert metadata.get("type") == "conversation_state"
# Load it back
loaded_history = load_message_history([saved_message])
# Verify it matches
assert len(loaded_history) == len(original_history)
# First message is a request with UserPromptPart
assert isinstance(loaded_history[0], ModelRequest)
first_part = loaded_history[0].parts[0]
assert hasattr(first_part, "content")
assert first_part.content == "What is Python?" # type: ignore
# Second message is a response with TextPart
assert isinstance(loaded_history[1], ModelResponse)
second_part = loaded_history[1].parts[0]
assert hasattr(second_part, "content")
assert second_part.content == "Python is a programming language" # type: ignore
@pytest.mark.asyncio
async def test_save_and_load_message_history_with_tool_calls():
"""Test saving and loading message history that includes tool calls."""
from pydantic_ai.messages import UserPromptPart
original_history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content="Search for Python")]),
ModelResponse(
parts=[
ToolCallPart(
tool_name="search_documents",
args={"query": "Python", "limit": 3},
tool_call_id="call_1",
)
]
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name="search_documents",
content="Python is a high-level programming language",
tool_call_id="call_1",
)
]
),
ModelResponse(
parts=[AITextPart(content="Based on the search, Python is a language")]
),
]
# Save and load
saved_message = save_message_history(original_history)
loaded_history = load_message_history([saved_message])
# Verify tool calls are preserved
assert len(loaded_history) == 4
assert isinstance(loaded_history[1].parts[0], ToolCallPart)
assert loaded_history[1].parts[0].tool_name == "search_documents"
assert isinstance(loaded_history[2].parts[0], ToolReturnPart)
assert loaded_history[2].parts[0].tool_name == "search_documents"
@pytest.mark.asyncio
async def test_extract_question_from_task():
"""Test extracting user question from task history."""
task_history: list[Message] = [
Message(
role="user",
parts=[TextPart(kind="text", text="What is Python?")],
kind="message",
message_id=str(uuid.uuid4()),
)
]
question = extract_question_from_task(task_history)
assert question == "What is Python?"
@pytest.mark.asyncio
async def test_extract_question_from_task_no_text():
"""Test extracting question when no text part exists."""
task_history: list[Message] = [
Message(
role="user",
parts=[],
kind="message",
message_id=str(uuid.uuid4()),
)
]
question = extract_question_from_task(task_history)
assert question is None
@pytest.mark.asyncio
async def test_lru_memory_storage_lru_eviction():
"""Test that LRUMemoryStorage evicts least recently used contexts."""
from fasta2a.storage import InMemoryStorage
base_storage = InMemoryStorage()
storage = LRUMemoryStorage(storage=base_storage, max_contexts=3)
# Add 3 contexts (at limit)
await storage.update_context("ctx1", [])
await storage.update_context("ctx2", [])
await storage.update_context("ctx3", [])
# All 3 should be tracked
assert len(storage.context_order) == 3
assert "ctx1" in storage.context_order
assert "ctx2" in storage.context_order
assert "ctx3" in storage.context_order
# Add 4th context - should evict ctx1 (oldest)
await storage.update_context("ctx4", [])
assert len(storage.context_order) == 3
assert "ctx1" not in storage.context_order
assert "ctx2" in storage.context_order
assert "ctx3" in storage.context_order
assert "ctx4" in storage.context_order
# Access ctx2 (moves it to end)
await storage.load_context("ctx2")
# Add 5th context - should evict ctx3 (now oldest since ctx2 was accessed)
await storage.update_context("ctx5", [])
assert len(storage.context_order) == 3
assert "ctx3" not in storage.context_order
assert "ctx2" in storage.context_order # Still present (was accessed)
assert "ctx4" in storage.context_order
assert "ctx5" in storage.context_order
@pytest.mark.asyncio
async def test_lru_memory_storage_access_order():
"""Test that accessing contexts updates their order."""
from fasta2a.storage import InMemoryStorage
base_storage = InMemoryStorage()
storage = LRUMemoryStorage(storage=base_storage, max_contexts=2)
# Add 2 contexts
await storage.update_context("ctx1", [])
await storage.update_context("ctx2", [])
# Order should be: ctx1, ctx2
assert list(storage.context_order.keys()) == ["ctx1", "ctx2"]
# Load ctx1 (moves to end)
await storage.load_context("ctx1")
# Order should be: ctx2, ctx1
assert list(storage.context_order.keys()) == ["ctx2", "ctx1"]
# Add ctx3 - should evict ctx2 (oldest)
await storage.update_context("ctx3", [])
assert "ctx2" not in storage.context_order
assert "ctx1" in storage.context_order
assert "ctx3" in storage.context_order
@pytest.mark.asyncio
async def test_a2a_app_creation(temp_db_path):
"""Test that A2A app can be created successfully."""
from haiku_rag_a2a.a2a import create_a2a_app
# Create a test database
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(
content="Python is a high-level programming language known for its simplicity.",
uri="python_doc",
)
# Create A2A app
app = create_a2a_app(temp_db_path)
# Verify app properties
assert app.name == "haiku-rag"
assert app.description is not None
assert "conversational" in app.description.lower()
@pytest.mark.asyncio
async def test_a2a_app_has_skills(temp_db_path):
"""Test that A2A app exposes skills describing its capabilities."""
from haiku_rag_a2a.a2a import create_a2a_app
# Create a test database
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(content="Test document", uri="test_doc")
# Create A2A app
app = create_a2a_app(temp_db_path)
# Verify app has skills
assert app.skills is not None
assert len(app.skills) > 0
# Check that at least one skill exists
skill = app.skills[0]
assert "id" in skill
assert "name" in skill
assert "description" in skill
assert "tags" in skill
assert "input_modes" in skill
assert "output_modes" in skill
# Verify the skill describes document search/QA capabilities
skill_text = f"{skill['name']} {skill['description']}".lower()
assert any(
keyword in skill_text
for keyword in ["search", "question", "answer", "document", "knowledge"]
)
def test_get_agent_skills():
"""Test that agent skills include all three skills."""
skills = get_agent_skills()
assert len(skills) == 3
skill_ids = [skill["id"] for skill in skills]
assert "document-qa" in skill_ids
assert "document-search" in skill_ids
assert "document-retrieve" in skill_ids
# Check document-qa skill
doc_qa = next(s for s in skills if s["id"] == "document-qa")
assert "Document Question Answering" in doc_qa["name"]
assert "semantic search" in doc_qa["description"]
assert "question-answering" in doc_qa["tags"]
# Check document-search skill
doc_search = next(s for s in skills if s["id"] == "document-search")
assert "Document Search" in doc_search["name"]
assert "search" in doc_search["tags"]
# Check document-retrieve skill
doc_retrieve = next(s for s in skills if s["id"] == "document-retrieve")
assert "Document Retrieval" in doc_retrieve["name"]
assert "retrieval" in doc_retrieve["tags"]
@pytest.mark.asyncio
async def test_build_artifacts_for_search():
"""Test that search operations produce structured search artifacts."""
from haiku_rag_a2a.a2a.worker import ConversationalWorker
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
ToolCallPart,
ToolReturnPart,
)
class MockResult:
output = "Found 1 relevant results:\n\n1. *Score: 0.9* | **test**\nresult"
def new_messages(self):
return [
ModelResponse(
parts=[
ToolCallPart(
tool_name="search_documents",
args={"query": "test", "limit": 3},
tool_call_id="call_1",
)
]
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name="search_documents",
content=[{"content": "result", "score": 0.9}],
tool_call_id="call_1",
)
]
),
]
from pathlib import Path
from fasta2a.broker import InMemoryBroker
from fasta2a.storage import InMemoryStorage
worker = ConversationalWorker(
storage=InMemoryStorage(),
broker=InMemoryBroker(),
db_path=Path("/tmp/test.db"),
agent=None, # type: ignore
)
artifacts = worker.build_artifacts(MockResult(), "search", "test query")
assert len(artifacts) == 1
assert artifacts[0].get("name") == "search_results"
assert len(artifacts[0]["parts"]) == 1
assert artifacts[0]["parts"][0]["kind"] == "data"
assert "results" in artifacts[0]["parts"][0]["data"]
assert "query" in artifacts[0]["parts"][0]["data"]
@pytest.mark.asyncio
async def test_build_artifacts_for_retrieve():
"""Test that retrieve operations produce document artifacts."""
from haiku_rag_a2a.a2a.worker import ConversationalWorker
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
ToolCallPart,
ToolReturnPart,
)
class MockResult:
output = "Document content"
def new_messages(self):
return [
ModelResponse(
parts=[
ToolCallPart(
tool_name="get_full_document",
args={"document_uri": "test.txt"},
tool_call_id="call_1",
)
]
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name="get_full_document",
content="Full document content here",
tool_call_id="call_1",
)
]
),
]
from pathlib import Path
from fasta2a.broker import InMemoryBroker
from fasta2a.storage import InMemoryStorage
worker = ConversationalWorker(
storage=InMemoryStorage(),
broker=InMemoryBroker(),
db_path=Path("/tmp/test.db"),
agent=None, # type: ignore
)
artifacts = worker.build_artifacts(MockResult(), "retrieve", "test query")
assert len(artifacts) == 1
assert artifacts[0].get("name") == "document"
assert artifacts[0]["parts"][0]["kind"] == "text"
assert artifacts[0]["parts"][0]["text"] == "Full document content here"
@pytest.mark.asyncio
async def test_build_artifacts_for_multiple_searches():
"""Test that multiple searches each get their own artifact with correct results."""
from haiku_rag_a2a.a2a.worker import ConversationalWorker
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
ToolCallPart,
ToolReturnPart,
)
from pydantic_ai.messages import TextPart as AITextPart
class MockResult:
output = "Answer based on multiple searches"
def new_messages(self):
return [
# First search
ModelResponse(
parts=[
ToolCallPart(
tool_name="search_documents",
args={"query": "first query", "limit": 2},
tool_call_id="call_1",
)
]
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name="search_documents",
content=[
{"content": "result 1", "score": 0.9},
{"content": "result 2", "score": 0.8},
],
tool_call_id="call_1",
)
]
),
# Second search
ModelResponse(
parts=[
ToolCallPart(
tool_name="search_documents",
args={"query": "second query", "limit": 2},
tool_call_id="call_2",
)
]
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name="search_documents",
content=[
{"content": "result 3", "score": 0.7},
{"content": "result 4", "score": 0.6},
],
tool_call_id="call_2",
)
]
),
ModelResponse(
parts=[AITextPart(content="Answer based on multiple searches")]
),
]
from pathlib import Path
from fasta2a.broker import InMemoryBroker
from fasta2a.storage import InMemoryStorage
worker = ConversationalWorker(
storage=InMemoryStorage(),
broker=InMemoryBroker(),
db_path=Path("/tmp/test.db"),
agent=None, # type: ignore
)
artifacts = worker.build_artifacts(MockResult(), "qa", "What is the answer?")
# Should have 2 search artifacts + 1 qa_result artifact
assert len(artifacts) == 3
# First search artifact
assert artifacts[0].get("name") == "search_results"
part_0 = artifacts[0]["parts"][0]
assert part_0.get("data", {}).get("query") == "first query"
results_1 = part_0.get("data", {}).get("results", [])
assert len(results_1) == 2
assert results_1[0]["content"] == "result 1"
assert results_1[1]["content"] == "result 2"
# Second search artifact
assert artifacts[1].get("name") == "search_results"
part_1 = artifacts[1]["parts"][0]
assert part_1.get("data", {}).get("query") == "second query"
results_2 = part_1.get("data", {}).get("results", [])
assert len(results_2) == 2
assert results_2[0]["content"] == "result 3"
assert results_2[1]["content"] == "result 4"
# Q&A artifact
assert artifacts[2].get("name") == "qa_result"
@pytest.mark.asyncio
async def test_qa_artifact_for_conversational_messages():
"""Test that conversational Q&A messages always create qa_result artifacts."""
from haiku_rag_a2a.a2a.worker import ConversationalWorker
from pydantic_ai.messages import ModelResponse
from pydantic_ai.messages import TextPart as AITextPart
class MockResult:
output = "Hello! How can I help you?"
def new_messages(self):
# No tool calls, just a conversational response
return [
ModelResponse(parts=[AITextPart(content="Hello! How can I help you?")]),
]
from pathlib import Path
from fasta2a.broker import InMemoryBroker
from fasta2a.storage import InMemoryStorage
worker = ConversationalWorker(
storage=InMemoryStorage(),
broker=InMemoryBroker(),
db_path=Path("/tmp/test.db"),
agent=None, # type: ignore
)
artifacts = worker.build_artifacts(MockResult(), "qa", "Hello")
# Should have qa_result artifact (even without tools, for A2A traceability)
assert len(artifacts) == 1
assert artifacts[0].get("name") == "qa_result"
part = artifacts[0]["parts"][0]
assert part.get("data", {}).get("question") == "Hello"
assert part.get("data", {}).get("answer") == "Hello! How can I help you?"
@pytest.mark.asyncio
async def test_build_artifacts_for_qa():
"""Test that Q&A operations produce artifacts for each tool call."""
from haiku_rag_a2a.a2a.worker import ConversationalWorker
from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
ToolCallPart,
ToolReturnPart,
)
from pydantic_ai.messages import TextPart as AITextPart
class MockResult:
output = "This is the answer"
def new_messages(self):
# Multiple tool calls indicates Q&A workflow
return [
ModelResponse(
parts=[
ToolCallPart(
tool_name="search_documents",
args={"query": "test", "limit": 3},
tool_call_id="call_1",
)
]
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name="search_documents",
content=[{"content": "result", "score": 0.9}],
tool_call_id="call_1",
)
]
),
ModelResponse(
parts=[
ToolCallPart(
tool_name="get_full_document",
args={"document_uri": "test.txt"},
tool_call_id="call_2",
)
]
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name="get_full_document",
content="Full content",
tool_call_id="call_2",
)
]
),
ModelResponse(parts=[AITextPart(content="This is the answer")]),
]
from pathlib import Path
from fasta2a.broker import InMemoryBroker
from fasta2a.storage import InMemoryStorage
worker = ConversationalWorker(
storage=InMemoryStorage(),
broker=InMemoryBroker(),
db_path=Path("/tmp/test.db"),
agent=None, # type: ignore
)
artifacts = worker.build_artifacts(MockResult(), "qa", "What is Python?")
# Q&A should produce artifacts for each tool call (search + retrieve) + final Q&A artifact
assert len(artifacts) == 3
# First artifact is from search_documents
assert artifacts[0].get("name") == "search_results"
assert artifacts[0]["parts"][0]["kind"] == "data"
assert "results" in artifacts[0]["parts"][0]["data"]
assert artifacts[0]["parts"][0]["data"]["query"] == "test"
# Second artifact is from get_full_document
assert artifacts[1].get("name") == "document"
assert artifacts[1]["parts"][0]["kind"] == "text"
# Third artifact is the Q&A result
assert artifacts[2].get("name") == "qa_result"
assert artifacts[2]["parts"][0]["kind"] == "data"
assert artifacts[2]["parts"][0]["data"]["question"] == "What is Python?"
assert artifacts[2]["parts"][0]["data"]["answer"] == "This is the answer"
assert artifacts[2]["parts"][0]["data"]["skill"] == "document-qa"

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,11 @@
# Database directory
DEFAULT_DATA_DIR=/data
# API keys (set as needed)
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# VOYAGE_API_KEY=
# CO_API_KEY=
# File monitoring
MONITOR_DIRECTORIES=/docs
# Ollama on host (if using Ollama for embeddings/QA)
# OLLAMA_BASE_URL=http://host.docker.internal:11434
# Default: Ollama on host
EMBEDDINGS_PROVIDER=ollama
EMBEDDINGS_MODEL=nomic-embed-text
QA_PROVIDER=ollama
QA_MODEL=qwen3
OLLAMA_BASE_URL=http://host.docker.internal:11434
# For other providers, see: https://ggozad.github.io/haiku.rag/configuration/
ENV=production
# All other configuration is done via haiku.rag.yaml
# See: https://ggozad.github.io/haiku.rag/configuration/

View file

@ -120,7 +120,6 @@ def main():
example_pyproject_files = [
root / "app" / "backend" / "pyproject.toml",
root / "examples" / "a2a-server" / "pyproject.toml",
]
changelog_file = root / "CHANGELOG.md"