Remove a2a examples, clean up
This commit is contained in:
parent
e32b48d142
commit
ea492248e7
23 changed files with 14 additions and 7483 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue