ai-agent / docs /architecture /agent.md
katospiegel's picture
Deploy develop: FastAPI+React frontend, multi-stage Docker (ai_agent serve)
07c2476 verified
|
Raw
History Blame Contribute Delete
18.5 kB
# Agent & VLM Selection
The second stage of the pipeline uses a vision-language model (VLM) with PydanticAI agents to select and rank the best tools from candidates.
## Overview
**Goal**: Select the most relevant tools using vision + text understanding
**Characteristics**:
- 🧠 Intelligent reasoning with explanations
- 👁️ Vision-aware (analyzes image content)
- 🎯 Comparative ranking of candidates
- 💬 Conversational with context
- 📊 Structured output (Pydantic schemas)
## Architecture
```mermaid
graph TB
A[User Message + Files] --> B[PydanticAI Agent]
B --> C{Agent Router}
C -->|Tool Call| D[Agent Tools]
C -->|LLM Reasoning| E[GPT-4o/4o-mini]
D --> B
E --> F[ToolSelection Schema]
F --> G[Structured Response]
G --> B
B --> H[Formatted Reply]
```
## PydanticAI Agent
### Agent Framework
**Framework**: [PydanticAI](https://ai.pydantic.dev/)
**Benefits**:
- Type-safe with Pydantic models
- Structured output validation
- Built-in tool support
- Async/await support
- Easy testing with dependency injection
### Agent Definition
```python
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.providers.openai import OpenAIProvider
from ai_agent.generator.prompts import get_agent_system_prompt
from ai_agent.generator.schema import ToolSelection
from ai_agent.agent.utils import AgentState
provider = OpenAIProvider(api_key=os.getenv("OPENAI_API_KEY"))
openai_model = OpenAIResponsesModel(model_name="gpt-4o-mini", provider=provider)
agent = Agent(
model=openai_model,
system_prompt=get_agent_system_prompt(num_choices=3),
deps_type=AgentState,
)
```
**Key parameters**:
- `model`: VLM model to use (configurable via `config.yaml`)
- `system_prompt`: Agent role, scoring rules, and output format (from `generator/prompts.py`)
- `deps_type`: `AgentState` — tracks tool calls, quotas, and session overrides
- `output_type`: `ToolSelection` — passed to `agent.run_sync()` to enforce structured JSON output (not set on the `Agent` constructor)
### Conversation State
```python
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any, Set
class AgentState(BaseModel):
"""Holds incremental tool call logs and runtime overrides."""
tool_calls: List[Dict[str, Any]] = Field(default_factory=list)
tool_counts: Dict[str, int] = Field(default_factory=dict)
disabled_tools: Set[str] = Field(default_factory=set)
excluded_tools: List[str] = Field(default_factory=list)
# Runtime overrides (session-only)
override_model: Optional[str] = None
override_base_url: Optional[str] = None
override_top_k: Optional[int] = None
override_num_choices: Optional[int] = None
image_paths: List[str] = Field(default_factory=list)
original_formats: List[str] = Field(default_factory=list)
```
**Passed to every tool call** via dependency injection. Also carries per-tool call counts for quota enforcement.
## Agent Tools
Tools extend agent capabilities beyond chat:
### search_alternative
Request alternative search with different query formulation:
```python
@agent.tool(retries=2, prepare=cap_prepare)
@limit_tool_calls("search_alternative", cap=3)
async def search_alternative(
ctx: RunContext[AgentState],
alternative_query: str,
excluded: List[str] | None = None,
top_k: int = 12,
) -> List[dict]:
"""Search for tools using an alternative query formulation."""
inp = SearchAlternativeInput(
alternative_query=alternative_query,
excluded=excluded or [],
top_k=top_k,
original_formats=ctx.deps.original_formats,
image_paths=ctx.deps.image_paths,
)
out = tool_search_alternative(inp)
return [c.model_dump(mode="python") for c in out.candidates]
```
**Usage**:
- Agent invokes when user asks for alternatives
- Up to 3 calls per conversation
- Formulates semantically different queries
**Example**:
```
User: Show me alternatives
Agent: [Calls search_alternative with "pulmonary segmentation CT"]
```
### repo_info
Fetch GitHub repository details:
```python
@agent.tool(retries=2, prepare=cap_prepare)
@limit_tool_calls("repo_info", cap=12)
async def repo_info(ctx: RunContext[AgentState], url: str, tool_name: str | None = None) -> dict:
"""Fetch a short summary of a GitHub repository."""
# Normalize to canonical GitHub URL
norm_url = coerce_github_url_or_none(url)
# Call tool_repo_summary (tries DeepWiki MCP first, falls back to repocards)
out = await tool_repo_summary(RepoSummaryInput(url=norm_url, tool_name=tool_name))
return out.model_dump(mode="python")
```
**Data sources**:
1. **DeepWiki MCP**: Pre-indexed, fast, no rate limits
2. **Repocards**: Direct fetch, fallback for new repos
**Returns**:
- Repository description
- Stars, language, topics
- Last update date
- License information
**Example**:
```
User: Tell me about TotalSegmentator
Agent: [Calls repo_info("https://github.com/wasserth/TotalSegmentator")]
TotalSegmentator is an automated multi-organ segmentation tool...
⭐ 1.2k stars | Python | Apache-2.0 license
Topics: segmentation, medical-imaging, deep-learning
```
### run_example
Execute Gradio Space demos (optional, experimental):
```python
@agent.tool(retries=0, prepare=cap_prepare)
@limit_tool_calls("run_example", cap=1)
async def run_example(
ctx: RunContext[AgentState],
tool_name: str,
endpoint_url: str | None = None,
extra_text: str | None = None,
) -> dict:
"""Run an example / demo for a given tool via its Gradio space."""
out = tool_run_example(RunExampleInput(
tool_name=tool_name,
endpoint_url=endpoint_url,
extra_text=extra_text,
))
return out.model_dump(mode="python")
```
**Status**: Partially implemented, limited to specific demo formats.
## Selection and Ranking
The PydanticAI agent performs tool selection and ranking directly as part of its LLM reasoning step. There is no separate `VLMToolSelector` class — the agent's system prompt (defined in `generator/prompts.py`) encodes the scoring rules, and the `ToolSelection` Pydantic schema (defined in `generator/schema.py`) enforces structured output.
### System Prompt
The agent system prompt is assembled by `get_agent_system_prompt()` in `generator/prompts.py` and covers:
- **Image analysis**: Instructions to analyze the attached preview image and reference visual observations in explanations
- **Tool call sequence**: When to call `search_tools`, `search_alternative`, `repo_info`, and `run_example`
- **Scoring rules**: Accuracy (0–100) = Task match (40) + Format compatibility (30) + Features (30)
- **Output format**: Single JSON object matching the `ToolSelection` schema
```python
from ai_agent.generator.prompts import get_agent_system_prompt
# Generates a prompt that instructs the agent to return up to N ranked choices
system_prompt = get_agent_system_prompt(num_choices=3)
```
### Selection Process
#### Step 1: Tool Calls (Retrieval)
The agent calls `search_tools` once (and optionally `search_alternative` up to 3 times) to retrieve candidate tools from the vector index:
```
Agent → search_tools(query="segment lungs", top_k=12)
← [TotalSegmentator, MedSAM, nnU-Net, ...]
```
#### Step 2: Verification
For each finalist the agent plans to recommend, it calls `repo_info` to fetch up-to-date GitHub metadata:
```
Agent → repo_info(url="https://github.com/wasserth/TotalSegmentator")
← {stars: 1200, language: "Python", topics: [...], description: "..."}
```
#### Step 3: Structured Output
The agent returns one JSON object (no prose) that is validated against the `ToolSelection` schema:
```python
run_result = agent_instance.run_sync(
user_prompt, # text + optional BinaryContent image
deps=deps, # AgentState with image_paths, excluded_tools, etc.
output_type=ToolSelection,
usage_limits=UsageLimits(tool_calls_limit=20),
)
result = run_result.output # ToolSelection instance
```
**Multimodal input**:
- Text: User task + hidden metadata (format hints, image dimensions)
- Image: PNG preview bytes passed as `BinaryContent(data=image_bytes, media_type="image/png")`
- Context: Conversation history prepended to the prompt
### Structured Response Schema
The `ToolSelection` Pydantic model (in `generator/schema.py`) validates the agent output:
```python
from ai_agent.generator.schema import (
ToolSelection, ToolChoice, Conversation,
ConversationStatus, NoToolReason
)
class ToolChoice(BaseModel):
name: str
rank: int
accuracy: float # 0-100
why: str
demo_link: Optional[str] = None
class Conversation(BaseModel):
status: ConversationStatus
question: Optional[str] = None # required if status=needs_clarification
context: Optional[str] = None # required if status=needs_clarification
options: Optional[List[str]] = None
class ToolSelection(BaseModel):
conversation: Conversation
choices: List[ToolChoice] = []
explanation: Optional[str] = None
reason: Optional[NoToolReason] = None
```
**Example response** (`ToolSelection`):
```json
{
"conversation": {"status": "complete", "question": null, "context": null},
"choices": [
{
"rank": 1,
"name": "TotalSegmentator",
"accuracy": 95.0,
"why": "Specifically designed for automated multi-organ CT segmentation...",
"demo_link": "https://huggingface.co/spaces/..."
},
{
"rank": 2,
"name": "MedSAM",
"accuracy": 85.0,
"why": "Flexible SAM-based segmentation supporting DICOM input...",
"demo_link": "https://huggingface.co/spaces/..."
}
],
"explanation": null,
"reason": null
}
```
### Validation
Pydantic validates:
- All required fields present
- Types correct (`int`, `float`, `str`, enum)
- `accuracy` within 0–100 range
- `ConversationStatus` is one of the allowed enum values
- `NoToolReason` is a valid enum value when `choices` is empty
`ToolSelection.normalize()` also enforces consistency rules automatically (e.g. setting `status=complete` when choices are returned, `status=needs_clarification` when a question is present).
## Conversation States
State machine for conversation flow:
```python
from ai_agent.generator.schema import ConversationStatus
class ConversationStatus(str, Enum):
COMPLETE = "complete" # Recommendations provided (or no tool found)
NEEDS_CLARIFICATION = "needs_clarification" # Agent needs more info
```
### Complete
Normal successful response:
```python
{
"conversation": {"status": "complete", "question": null, "context": null},
"choices": [...],
"explanation": null,
"reason": null
}
```
**Triggers**:
- Query is clear
- Candidates found
- Image/metadata sufficient
### Needs Clarification
Agent requests more information:
```python
{
"conversation": {
"status": "needs_clarification",
"question": "Which specific organ would you like to segment?",
"context": "Several segmentation tools available; target organ narrows choices.",
"options": ["Lungs", "Brain", "Liver", "Other (briefly specify)"]
},
"choices": [],
"explanation": null,
"reason": null
}
```
**Triggers**:
- Ambiguous query
- Multiple valid interpretations
- Missing critical information
**Example flow**:
```
User: Segment this MRI
Agent: [STATUS: needs_clarification] Which organ would you like to segment?
User: The brain
Agent: [STATUS: complete] Here are brain segmentation tools...
```
### No Tool Terminal
No suitable tools in catalog — `status` is still `complete`, but `choices` is empty and a `reason` + `explanation` are provided:
```python
{
"conversation": {"status": "complete", "question": null, "context": null},
"choices": [],
"reason": "no_task_match",
"explanation": "No tools in the catalog handle audio processing. This catalog covers imaging analysis software."
}
```
Available `NoToolReason` values: `no_suitable_tool`, `no_modality_match`, `no_task_match`, `no_dimension_match`, `invalid_files`.
## Ranking Logic
### Scoring Factors
The agent considers:
#### High Priority
1. **Task Match**: Tool designed for this specific task
2. **Format Compatibility**: Supports user's file format
3. **Visual Analysis**: Image content matches tool's domain
#### Medium Priority
4. **Modality Alignment**: CT tool for CT image, MRI for MRI
5. **Dimension Match**: 3D tool for 3D volume
6. **Feature Coverage**: Specific capabilities mentioned
#### Low Priority
7. **License**: Open-source preference (if no preference stated)
8. **Demo Availability**: Has runnable demo
9. **Popularity**: Community adoption
### Explanation Generation
Each recommendation includes explanation:
**Good explanation template**:
```
{Tool} is {specifically designed / well-suited} for {task}
on {modality} images. It supports {format} input {with/without}
preprocessing and provides {key features}. {Caveats if any}.
```
**Example**:
```
TotalSegmentator is specifically designed for automated multi-organ
segmentation on CT scans. It supports DICOM input without preprocessing
and can segment 104 anatomical structures including lungs, air airways,
and vessels. It works best on whole-body CT but also performs well on
thoracic scans.
```
### Rank Assignment
- **Rank 1**: Best overall match (highest accuracy score)
- **Rank 2**: Strong alternative or different approach
- **Rank 3**: Fallback option or specialized capability
**Important**: Ranks are relative to **this specific query**, not absolute tool quality.
## Model Configuration
### Model Selection
Available via `config.yaml`:
```yaml
agent_model:
name: "gpt-4o-mini"
base_url: null
api_key_env: "OPENAI_API_KEY"
```
### Model Comparison
| Model | Vision | Speed | Cost | Best For |
|-------|--------|-------|------|----------|
| gpt-4o-mini | ✅ | ⚡⚡⚡ | $ | Most queries, fast iteration |
| gpt-4o | ✅✅ | ⚡⚡ | $$ | Complex visual analysis |
| gpt-5.1 | ✅✅✅ | ⚡ | $$$ | Maximum accuracy needed |
### Custom Endpoints
Support for OpenAI-compatible APIs:
```yaml
agent_model:
name: "llama-3.2-vision"
base_url: "https://inference.epfl.ch/v1"
api_key_env: "EPFL_API_KEY"
```
## Error Handling
### Agent Errors
**Tool quota exceeded** (handled gracefully in `run_agent`):
```python
except UsageLimitExceeded:
# Returns a ToolSelection with empty choices and an explanation
result = ToolSelection(
conversation=Conversation(status=ConversationStatus.COMPLETE, ...),
choices=[],
explanation="Tool call limit reached. Try a more specific query.",
)
```
**Invalid structured output**:
PydanticAI automatically retries the LLM call (up to `retries=2` per tool) if the model returns output that fails `ToolSelection` validation. The `ToolSelection.normalize()` model validator also auto-corrects minor inconsistencies.
**API Errors**:
```python
except Exception as e:
log.warning(f"Agent execution encountered an error: {e}")
raise # propagated to the UI layer
```
### Graceful Degradation
If the agent fails after all retries:
1. Return empty `choices` with an `explanation` describing what was searched
2. UI surfaces the explanation so users can refine their query
3. Suggest manual exploration of the catalog
<!-- ## Performance
### Latency
Typical VLM call: **2-5 seconds**
Breakdown:
- Prompt construction: <100ms
- API call: 2-4s (network + inference)
- Response parsing: <100ms
- Validation: <50ms
### Optimization
**Prompt optimization**:
- Concise candidate descriptions
- Limit to top-8 candidates
- Structured format for parsing
**Caching**:
- Model endpoint reused
- Agent instance persists across requests
**Batch processing** (for testing):
```python
# Process multiple queries
responses = await asyncio.gather(*[
agent.run(query1),
agent.run(query2),
agent.run(query3)
])
``` -->
## Testing
### Unit Tests
Test agent selection with PydanticAI's built-in test model (your catalog should contain the choice provided below, i.e. the `TotalSegmentator` tool):
```python
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from ai_agent.generator.schema import ToolSelection, Conversation, ConversationStatus, ToolChoice
from ai_agent.agent.utils import AgentState
def test_agent_selection():
test_model = TestModel()
test_agent = Agent(model=test_model, deps_type=AgentState)
mock_output = ToolSelection(
conversation=Conversation(status=ConversationStatus.COMPLETE),
choices=[
ToolChoice(name="TotalSegmentator", rank=1, accuracy=95.0, why="Best CT segmenter")
]
)
with test_agent.override(model=test_model):
result = test_agent.run_sync("segment lungs", deps=AgentState(), output_type=ToolSelection)
assert result.output.conversation.status == ConversationStatus.COMPLETE
assert len(result.output.choices) == 1
assert result.output.choices[0].rank == 1
```
### Integration Tests
Test with real VLM (expensive, slow):
```python
@pytest.mark.integration
def test_real_agent():
from ai_agent.agent.agent import run_agent
with open("tests/data/sample.tif", "rb") as f:
image_bytes = f.read()
result = run_agent(
task="I want to segment the lungs of this CT scan",
image_paths=["tests/data/sample.tif"],
image_bytes=image_bytes,
)
assert result.conversation.status == ConversationStatus.COMPLETE
assert len(result.choices) > 0
assert all(0 <= c.accuracy <= 100 for c in result.choices)
```
## Next Steps
- Learn about [Software Catalog](catalog.md)
- Return to [Architecture Overview](overview.md)
- Explore [Retrieval Pipeline](retrieval.md)