Simplify orchestration & subagents

This commit is contained in:
Yiorgis Gozadinos 2025-09-16 14:25:28 +03:00
parent 5327b84fb9
commit 98ca5c4746
No known key found for this signature in database
13 changed files with 374 additions and 561 deletions

View file

@ -6,7 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config from haiku.rag.config import Config
from haiku.rag.qa.prompts import SYSTEM_PROMPT, SYSTEM_PROMPT_WITH_CITATIONS from haiku.rag.qa.prompts import QA_SYSTEM_PROMPT, QA_SYSTEM_PROMPT_WITH_CITATIONS
class SearchResult(BaseModel): class SearchResult(BaseModel):
@ -31,7 +31,9 @@ class QuestionAnswerAgent:
): ):
self._client = client self._client = client
system_prompt = SYSTEM_PROMPT_WITH_CITATIONS if use_citations else SYSTEM_PROMPT system_prompt = (
QA_SYSTEM_PROMPT_WITH_CITATIONS if use_citations else QA_SYSTEM_PROMPT
)
model_obj = self._get_model(provider, model) model_obj = self._get_model(provider, model)
self._agent = Agent( self._agent = Agent(

View file

@ -1,4 +1,4 @@
SYSTEM_PROMPT = """ QA_SYSTEM_PROMPT = """
You are a knowledgeable assistant that helps users find information from a document knowledge base. You are a knowledgeable assistant that helps users find information from a document knowledge base.
Your process: Your process:
@ -21,7 +21,7 @@ Be concise, and always maintain accuracy over completeness. Prefer short, direct
/no_think /no_think
""" """
SYSTEM_PROMPT_WITH_CITATIONS = """ QA_SYSTEM_PROMPT_WITH_CITATIONS = """
You are a knowledgeable assistant that helps users find information from a document knowledge base. You are a knowledgeable assistant that helps users find information from a document knowledge base.
IMPORTANT: You MUST use the search_documents tool for every question. Do not answer any question without first searching the knowledge base. IMPORTANT: You MUST use the search_documents tool for every question. Do not answer any question without first searching the knowledge base.

View file

@ -1,12 +1,11 @@
"""Multi-agent research workflow for advanced RAG queries.""" """Multi-agent research workflow for advanced RAG queries."""
from haiku.rag.research.analysis_agent import AnalysisAgent, AnalysisResult
from haiku.rag.research.base import BaseResearchAgent, ResearchOutput, SearchResult from haiku.rag.research.base import BaseResearchAgent, ResearchOutput, SearchResult
from haiku.rag.research.clarification_agent import (
ClarificationAgent,
ClarificationResult,
)
from haiku.rag.research.dependencies import ResearchContext, ResearchDependencies from haiku.rag.research.dependencies import ResearchContext, ResearchDependencies
from haiku.rag.research.evaluation_agent import (
AnalysisEvaluationAgent,
EvaluationResult,
)
from haiku.rag.research.orchestrator import ResearchOrchestrator, ResearchPlan from haiku.rag.research.orchestrator import ResearchOrchestrator, ResearchPlan
from haiku.rag.research.search_agent import SearchSpecialistAgent from haiku.rag.research.search_agent import SearchSpecialistAgent
from haiku.rag.research.synthesis_agent import ResearchReport, SynthesisAgent from haiku.rag.research.synthesis_agent import ResearchReport, SynthesisAgent
@ -20,10 +19,8 @@ __all__ = [
"ResearchOutput", "ResearchOutput",
# Specialized agents # Specialized agents
"SearchSpecialistAgent", "SearchSpecialistAgent",
"AnalysisAgent", "AnalysisEvaluationAgent",
"AnalysisResult", "EvaluationResult",
"ClarificationAgent",
"ClarificationResult",
"SynthesisAgent", "SynthesisAgent",
"ResearchReport", "ResearchReport",
# Orchestrator # Orchestrator

View file

@ -1,53 +0,0 @@
"""Analysis agent for content processing and insight extraction."""
from pydantic import BaseModel, Field
from haiku.rag.research.base import BaseResearchAgent
class AnalysisResult(BaseModel):
"""Result of content analysis."""
key_insights: list[str] = Field(
description="Main insights extracted from the documents"
)
themes: dict[str, list[str]] = Field(description="Themes and related findings")
summary: str = Field(description="Consolidated summary of findings")
evidence_quality: str = Field(
description="Assessment of evidence quality (strong/moderate/weak)"
)
recommendations: list[str] = Field(
description="Suggested next steps or areas for further research"
)
class AnalysisAgent(BaseResearchAgent):
"""Agent specialized in content analysis and synthesis."""
def __init__(self, provider: str, model: str):
super().__init__(provider, model, output_type=AnalysisResult)
def get_system_prompt(self) -> str:
return """You are an analysis specialist agent focused on extracting deep insights from search results.
Your role is to:
1. Carefully read and analyze all provided documents
2. Extract key insights and important facts
3. Identify common themes and patterns across documents
4. Synthesize information into a coherent understanding
5. Assess the quality and reliability of the evidence
6. Identify areas that need further investigation
Be specific and detailed in your analysis. Focus on:
- What the documents actually say (not assumptions)
- Connections and contradictions between sources
- The strength of the evidence presented
- Gaps in the information that need to be filled
Your analysis should be thorough, critical, and actionable."""
def register_tools(self) -> None:
"""Register analysis-specific tools."""
# The agent will use its LLM capabilities directly for analysis
# No need for hardcoded tools - the structured output will guide the analysis
pass

View file

@ -1,13 +1,12 @@
"""Base class for research agents with common patterns."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, TypeVar from typing import Any, Generic, TypeVar
from pydantic import BaseModel from pydantic import BaseModel
from pydantic_ai import Agent from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.run import AgentRunResult
from haiku.rag.config import Config from haiku.rag.config import Config
from haiku.rag.research.dependencies import ResearchDependencies from haiku.rag.research.dependencies import ResearchDependencies
@ -15,18 +14,18 @@ from haiku.rag.research.dependencies import ResearchDependencies
T = TypeVar("T") T = TypeVar("T")
class BaseResearchAgent(ABC): class BaseResearchAgent(ABC, Generic[T]):
"""Base class for all research agents.""" """Base class for all research agents."""
def __init__( def __init__(
self, self,
provider: str, provider: str,
model: str, model: str,
output_type: type[T] | None = None, output_type: type[T],
): ):
self.provider = provider self.provider = provider
self.model = model self.model = model
self.output_type = output_type or str self.output_type = output_type
model_obj = self._get_model(provider, model) model_obj = self._get_model(provider, model)
@ -69,12 +68,14 @@ class BaseResearchAgent(ABC):
"""Register agent-specific tools.""" """Register agent-specific tools."""
pass pass
async def run(self, prompt: str, deps: ResearchDependencies, **kwargs) -> Any: async def run(
self, prompt: str, deps: ResearchDependencies, **kwargs
) -> AgentRunResult[T]:
"""Execute the agent.""" """Execute the agent."""
return await self._agent.run(prompt, deps=deps, **kwargs) return await self._agent.run(prompt, deps=deps, **kwargs)
@property @property
def agent(self) -> Agent[ResearchDependencies, Any]: def agent(self) -> Agent[ResearchDependencies, T]:
"""Access the underlying Pydantic AI agent.""" """Access the underlying Pydantic AI agent."""
return self._agent return self._agent

View file

@ -1,79 +0,0 @@
"""Clarification agent for gap detection and follow-up question generation."""
from pydantic import BaseModel, Field
from haiku.rag.research.base import BaseResearchAgent
class ClarificationResult(BaseModel):
"""Result of clarification analysis."""
information_gaps: list[str] = Field(
description="Specific missing information identified"
)
follow_up_questions: list[str] = Field(
description="Questions to ask to fill the gaps"
)
suggested_searches: list[str] = Field(
description="Recommended search queries for deeper investigation"
)
completeness_assessment: str = Field(
description="Overall assessment of research completeness"
)
priority_areas: list[str] = Field(
description="Most important areas to investigate next"
)
is_sufficient: bool = Field(
description="Whether the research has gathered sufficient information to answer the question"
)
confidence_score: float = Field(
ge=0.0,
le=1.0,
description="Confidence level (0-1) that the research is complete",
)
reasoning: str = Field(
description="Detailed reasoning for the completeness assessment"
)
class ClarificationAgent(BaseResearchAgent):
"""Agent specialized in identifying gaps and generating follow-up questions."""
def __init__(self, provider: str, model: str):
super().__init__(provider, model, output_type=ClarificationResult)
def get_system_prompt(self) -> str:
return """You are a clarification specialist agent focused on research completeness and quality.
Your role is to:
1. Critically evaluate what information has been gathered
2. Identify what crucial information is still missing
3. Detect contradictions or inconsistencies that need resolution
4. Generate targeted follow-up questions to fill knowledge gaps
5. Suggest specific search queries for deeper investigation
6. Assess the overall completeness of the research
Be thorough and critical in your evaluation. Consider:
- What questions remain unanswered?
- What assumptions need verification?
- What contradictions need resolution?
- What perspectives are missing?
- What details would strengthen the understanding?
IMPORTANT: When setting 'is_sufficient':
- True means: The research has enough information to provide a meaningful, accurate answer
- False means: Critical information is missing that prevents a complete answer
- Consider the nature of the question - simple questions need less, complex ones need more
- Be honest about uncertainty - if you're not confident, set is_sufficient to False
Your 'confidence_score' should reflect:
- 0.9-1.0: Very confident, all major aspects covered
- 0.7-0.9: Good coverage, minor gaps acceptable
- 0.5-0.7: Moderate coverage, some important gaps
- Below 0.5: Significant gaps, much more research needed"""
def register_tools(self) -> None:
"""Register clarification-specific tools."""
# The agent will use its LLM capabilities directly for gap analysis
# The structured output will guide the clarification process
pass

View file

@ -1,5 +1,3 @@
"""Shared dependencies for multi-agent research workflow."""
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -29,9 +27,6 @@ class ResearchContext(BaseModel):
gaps: list[str] = Field( gaps: list[str] = Field(
default_factory=list, description="Identified information gaps" default_factory=list, description="Identified information gaps"
) )
follow_up_questions: list[str] = Field(
default_factory=list, description="Generated follow-up questions"
)
def add_search_result(self, query: str, results: list["SearchResult"]) -> None: def add_search_result(self, query: str, results: list["SearchResult"]) -> None:
"""Add search results to context.""" """Add search results to context."""

View file

@ -0,0 +1,40 @@
from pydantic import BaseModel, Field
from haiku.rag.research.base import BaseResearchAgent
from haiku.rag.research.prompts import EVALUATION_AGENT_PROMPT
class EvaluationResult(BaseModel):
"""Result of analysis and evaluation."""
key_insights: list[str] = Field(
description="Main insights extracted from the research so far"
)
new_questions: list[str] = Field(
description="New sub-questions to add to the research (max 3)", max_length=3
)
confidence_score: float = Field(
description="Confidence level in the completeness of research (0-1)",
ge=0.0,
le=1.0,
)
is_sufficient: bool = Field(
description="Whether the research is sufficient to answer the original question"
)
reasoning: str = Field(
description="Explanation of why the research is or isn't complete"
)
class AnalysisEvaluationAgent(BaseResearchAgent[EvaluationResult]):
"""Agent that analyzes findings and evaluates research completeness."""
def __init__(self, provider: str, model: str) -> None:
super().__init__(provider, model, output_type=EvaluationResult)
def get_system_prompt(self) -> str:
return EVALUATION_AGENT_PROMPT
def register_tools(self) -> None:
"""No additional tools needed - uses LLM capabilities directly."""
pass

View file

@ -1,17 +1,18 @@
from typing import Any from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic_ai import RunContext from pydantic_ai.format_prompt import format_as_xml
from pydantic_ai.run import AgentRunResult
from rich.console import Console from rich.console import Console
from haiku.rag.config import Config from haiku.rag.config import Config
from haiku.rag.research.analysis_agent import AnalysisAgent, AnalysisResult
from haiku.rag.research.base import BaseResearchAgent from haiku.rag.research.base import BaseResearchAgent
from haiku.rag.research.clarification_agent import (
ClarificationAgent,
ClarificationResult,
)
from haiku.rag.research.dependencies import ResearchContext, ResearchDependencies from haiku.rag.research.dependencies import ResearchContext, ResearchDependencies
from haiku.rag.research.evaluation_agent import (
AnalysisEvaluationAgent,
EvaluationResult,
)
from haiku.rag.research.prompts import ORCHESTRATOR_PROMPT
from haiku.rag.research.search_agent import SearchSpecialistAgent from haiku.rag.research.search_agent import SearchSpecialistAgent
from haiku.rag.research.synthesis_agent import ResearchReport, SynthesisAgent from haiku.rag.research.synthesis_agent import ResearchReport, SynthesisAgent
@ -21,11 +22,11 @@ class ResearchPlan(BaseModel):
main_question: str = Field(description="The main research question") main_question: str = Field(description="The main research question")
sub_questions: list[str] = Field( sub_questions: list[str] = Field(
description="Decomposed sub-questions to investigate" description="Decomposed sub-questions to investigate (max 3)", max_length=3
) )
class ResearchOrchestrator(BaseResearchAgent): class ResearchOrchestrator(BaseResearchAgent[ResearchPlan]):
"""Orchestrator agent that coordinates the research workflow.""" """Orchestrator agent that coordinates the research workflow."""
def __init__( def __init__(
@ -37,108 +38,40 @@ class ResearchOrchestrator(BaseResearchAgent):
super().__init__(provider, model, output_type=ResearchPlan) super().__init__(provider, model, output_type=ResearchPlan)
self.search_agent = SearchSpecialistAgent(provider, model) self.search_agent: SearchSpecialistAgent = SearchSpecialistAgent(
self.analysis_agent = AnalysisAgent(provider, model) provider, model
self.clarification_agent = ClarificationAgent(provider, model) )
self.synthesis_agent = SynthesisAgent(provider, model) self.evaluation_agent: AnalysisEvaluationAgent = AnalysisEvaluationAgent(
provider, model
)
self.synthesis_agent: SynthesisAgent = SynthesisAgent(provider, model)
def get_system_prompt(self) -> str: def get_system_prompt(self) -> str:
return """You are a research orchestrator responsible for coordinating a comprehensive research workflow. return ORCHESTRATOR_PROMPT
Your role is to:
1. Understand and decompose the research question
2. Plan a systematic research approach
3. Coordinate specialized agents to gather and analyze information
4. Ensure comprehensive coverage of the topic
5. Iterate based on findings and gaps
Create a research plan that:
- Breaks down complex questions into manageable parts
- Identifies multiple search strategies
- Defines clear success criteria
- Ensures thorough investigation
/no_think"""
def register_tools(self) -> None: def register_tools(self) -> None:
"""Register orchestration tools.""" """Register orchestration tools."""
# Tools are no longer needed - orchestrator directly calls agents
pass
@self.agent.tool def _format_context_for_prompt(self, context: ResearchContext) -> str:
async def delegate_search( """Format the research context as XML for inclusion in prompts."""
ctx: RunContext[ResearchDependencies], queries: list[str], limit: int = 5 context_data = {
) -> list[Any]: "original_question": context.original_question,
"""Delegate search to the search specialist agent for multiple queries.""" "unanswered_questions": context.sub_questions,
all_results = [] "qa_responses": [
{
# Search for each query "question": qa["question"],
# The search agent will automatically store results in context "answer": qa["answer"][:500] + "..."
for query in queries: if len(qa["answer"]) > 500
result = await self.search_agent.run( else qa["answer"],
f"Search for: {query} with limit {limit}", }
deps=ctx.deps, for qa in context.qa_responses
usage=ctx.usage, ],
) "insights": context.insights,
all_results.append(result) "gaps": context.gaps,
}
return all_results return format_as_xml(context_data, root_tag="research_context")
@self.agent.tool
async def delegate_analysis(
ctx: RunContext[ResearchDependencies],
) -> AnalysisResult:
"""Delegate analysis to the analysis agent."""
# Get search results from context
all_documents = []
for search in ctx.deps.context.search_results:
all_documents.extend(search.get("results", []))
# Pass documents for analysis
result = await self.analysis_agent.run(
f"Analyze these {len(all_documents)} documents from our search",
deps=ctx.deps,
usage=ctx.usage,
)
# Store analysis insights in context
if hasattr(result, "output") and isinstance(result.output, AnalysisResult):
for insight in result.output.key_insights:
ctx.deps.context.add_insight(insight)
return result.output if hasattr(result, "output") else result
@self.agent.tool
async def delegate_clarification(
ctx: RunContext[ResearchDependencies],
) -> ClarificationResult:
"""Delegate gap analysis to the clarification agent."""
result = await self.clarification_agent.run(
f"Evaluate the completeness of research on: {ctx.deps.context.original_question}",
deps=ctx.deps,
usage=ctx.usage,
)
# Store identified gaps in context
if hasattr(result, "output") and isinstance(
result.output, ClarificationResult
):
for gap in result.output.information_gaps:
ctx.deps.context.add_gap(gap)
ctx.deps.context.follow_up_questions.extend(
result.output.follow_up_questions
)
return result.output if hasattr(result, "output") else result
@self.agent.tool
async def generate_report(
ctx: RunContext[ResearchDependencies],
) -> ResearchReport:
"""Generate final research report using synthesis agent."""
result = await self.synthesis_agent.run(
f"Create a comprehensive research report for: {ctx.deps.context.original_question}",
deps=ctx.deps,
usage=ctx.usage,
)
return result.output if hasattr(result, "output") else result
async def conduct_research( async def conduct_research(
self, self,
@ -174,11 +107,10 @@ class ResearchOrchestrator(BaseResearchAgent):
if console: if console:
console.print("\n[bold cyan]📋 Creating research plan...[/bold cyan]") console.print("\n[bold cyan]📋 Creating research plan...[/bold cyan]")
plan_result = await self.run( plan_result: AgentRunResult[ResearchPlan] = await self.run(
f"Create a research plan for: {question}", deps=deps f"Create a research plan for: {question}", deps=deps
) )
assert plan_result.output and isinstance(plan_result.output, ResearchPlan)
context.sub_questions = plan_result.output.sub_questions context.sub_questions = plan_result.output.sub_questions
if console: if console:
@ -198,99 +130,101 @@ class ResearchOrchestrator(BaseResearchAgent):
f"[bold yellow]🔄 Iteration {iteration + 1}/{max_iterations}[/bold yellow]" f"[bold yellow]🔄 Iteration {iteration + 1}/{max_iterations}[/bold yellow]"
) )
# Determine what to search for in this iteration # Check if we have questions to search
if context.follow_up_questions: if not context.sub_questions:
# Use follow-up questions from previous clarification # No more questions to explore
search_target = context.follow_up_questions[:3] # Take top 3 follow-ups
search_prompt = ", ".join(search_target)
elif iteration < len(context.sub_questions):
# Use pre-planned sub-questions
search_prompt = context.sub_questions[iteration]
else:
# Fall back to original question
search_prompt = question
# Search phase - directly call the search agent
if console:
console.print(
f"\n[bold cyan]🔍 Searching & Answering:[/bold cyan] {search_prompt}"
)
await self.search_agent.run(search_prompt, deps=deps)
if console:
# Show documents found
if context.search_results:
latest_results = context.search_results[-1]
console.print(
f" Found [green]{len(latest_results.get('results', []))} documents[/green]"
)
# Show the answer generated
if context.qa_responses:
latest_qa = context.qa_responses[-1]
answer_preview = (
latest_qa["answer"][:200] + "..."
if len(latest_qa["answer"]) > 200
else latest_qa["answer"]
)
console.print(f" [bold]Answer:[/bold] {answer_preview}")
# Analysis phase (only if we have results)
if context.search_results:
if console: if console:
console.print( console.print(
"\n[bold cyan]📊 Analyzing gathered information...[/bold cyan]" "[yellow]No more questions to explore. Concluding research.[/yellow]"
) )
break
analysis_result = await self.analysis_agent.run( # Use current sub-questions for this iteration
"Analyze the gathered information", deps=deps questions_to_search = context.sub_questions
)
if console and hasattr(analysis_result, "output"): # Search phase - answer all questions in this iteration
output = analysis_result.output
if hasattr(output, "key_insights") and output.key_insights:
console.print(" [bold]Key insights:[/bold]")
for insight in output.key_insights[:3]:
console.print(f"{insight}")
# Clarification phase - evaluate completeness
if console: if console:
console.print( console.print(
"\n[bold cyan]🔎 Evaluating research completeness...[/bold cyan]" f"\n[bold cyan]🔍 Searching & Answering {len(questions_to_search)} questions:[/bold cyan]"
)
for i, q in enumerate(questions_to_search, 1):
console.print(f" {i}. {q}")
# Run searches for all questions and remove answered ones
answered_questions = []
for search_question in questions_to_search:
await self.search_agent.run(search_question, deps=deps)
# Mark this question as answered
answered_questions.append(search_question)
if console and context.qa_responses:
# Show the last QA response (which should be for this question)
latest_qa = context.qa_responses[-1]
answer_preview = (
latest_qa["answer"][:150] + "..."
if len(latest_qa["answer"]) > 150
else latest_qa["answer"]
)
console.print(
f"\n [green]✓[/green] {search_question[:50]}..."
if len(search_question) > 50
else f"\n [green]✓[/green] {search_question}"
)
console.print(f" {answer_preview}")
# Remove answered questions from the list
for question in answered_questions:
if question in context.sub_questions:
context.sub_questions.remove(question)
# Analysis and Evaluation phase
if console:
console.print(
"\n[bold cyan]📊 Analyzing and evaluating research progress...[/bold cyan]"
) )
clarification_result = await self.clarification_agent.run( # Format context for the evaluation agent
f"Evaluate the completeness of research for: {question}. " context_xml = self._format_context_for_prompt(context)
f"Consider all information gathered so far and determine if we have sufficient " evaluation_prompt = f"""Analyze all gathered information and evaluate the completeness of research.
f"information to provide a comprehensive answer.",
{context_xml}
Evaluate the research progress for the original question and identify any remaining gaps."""
evaluation_result = await self.evaluation_agent.run(
evaluation_prompt,
deps=deps, deps=deps,
) )
if console and hasattr(clarification_result, "output"): if console and evaluation_result.output:
output = clarification_result.output output = evaluation_result.output
if hasattr(output, "confidence_score"): if output.key_insights:
console.print( console.print(" [bold]Key insights:[/bold]")
f" Confidence: [yellow]{output.confidence_score:.1%}[/yellow]" for insight in output.key_insights[:3]:
) console.print(f"{insight}")
if hasattr(output, "is_sufficient"): console.print(
status = ( f" Confidence: [yellow]{output.confidence_score:.1%}[/yellow]"
"[green]Yes[/green]" )
if output.is_sufficient status = (
else "[red]No[/red]" "[green]Yes[/green]" if output.is_sufficient else "[red]No[/red]"
) )
console.print(f" Sufficient: {status}") console.print(f" Sufficient: {status}")
# Check if research is sufficient based on semantic evaluation # Store insights
if self._should_stop_research(clarification_result, confidence_threshold): for insight in evaluation_result.output.key_insights:
# Log the reasoning for stopping context.add_insight(insight)
if (
console # Add new questions to the sub-questions list
and hasattr(clarification_result, "output") for new_q in evaluation_result.output.new_questions:
and isinstance(clarification_result.output, ClarificationResult) if new_q not in context.sub_questions:
): context.sub_questions.append(new_q)
# Check if research is sufficient
if self._should_stop_research(evaluation_result, confidence_threshold):
if console:
console.print( console.print(
f"\n[bold green]✅ Stopping research:[/bold green] {clarification_result.output.reasoning}" f"\n[bold green]✅ Stopping research:[/bold green] {evaluation_result.output.reasoning}"
) )
break break
@ -300,30 +234,31 @@ class ResearchOrchestrator(BaseResearchAgent):
"\n[bold cyan]📝 Generating final research report...[/bold cyan]" "\n[bold cyan]📝 Generating final research report...[/bold cyan]"
) )
report_result = await self.synthesis_agent.run( # Format context for the synthesis agent
"Generate the final research report", deps=deps final_context_xml = self._format_context_for_prompt(context)
synthesis_prompt = f"""Generate a comprehensive research report based on all gathered information.
{final_context_xml}
Create a detailed report that synthesizes all findings into a coherent response."""
report_result: AgentRunResult[ResearchReport] = await self.synthesis_agent.run(
synthesis_prompt, deps=deps
) )
if console: if console:
console.print("[bold green]✅ Research complete![/bold green]") console.print("[bold green]✅ Research complete![/bold green]")
return ( return report_result.output
report_result.output if hasattr(report_result, "output") else report_result
)
def _should_stop_research( def _should_stop_research(
self, clarification_result: Any, confidence_threshold: float self,
evaluation_result: AgentRunResult[EvaluationResult],
confidence_threshold: float,
) -> bool: ) -> bool:
"""Determine if research should stop based on semantic completeness evaluation.""" """Determine if research should stop based on evaluation."""
if not hasattr(clarification_result, "output") or not isinstance( result = evaluation_result.output
clarification_result.output, ClarificationResult
):
# If we can't evaluate, continue researching
return False
result = clarification_result.output
# Use the LLM's semantic evaluation
# Stop if the agent indicates sufficient information AND confidence exceeds threshold # Stop if the agent indicates sufficient information AND confidence exceeds threshold
return result.is_sufficient and result.confidence_score >= confidence_threshold return result.is_sufficient and result.confidence_score >= confidence_threshold

View file

@ -0,0 +1,87 @@
ORCHESTRATOR_PROMPT = """You are a research orchestrator responsible for coordinating a comprehensive research workflow.
Your role is to:
1. Understand and decompose the research question
2. Plan a systematic research approach
3. Coordinate specialized agents to gather and analyze information
4. Ensure comprehensive coverage of the topic
5. Iterate based on findings and gaps
Create a research plan that:
- Breaks down the question into at most 3 focused sub-questions
- Each sub-question should target a specific aspect of the research
- Prioritize the most important aspects to investigate
- Ensure comprehensive coverage within the 3-question limit"""
SEARCH_AGENT_PROMPT = """You are a search and question-answering specialist.
Your role is to:
1. Search the knowledge base for relevant information
2. Analyze the retrieved documents
3. Provide a comprehensive answer to the question
4. Base your answer strictly on the information found
Use the search_and_answer tool to retrieve relevant documents and formulate your response.
Be thorough and specific in your answers, citing relevant information from the sources."""
EVALUATION_AGENT_PROMPT = """You are an analysis and evaluation specialist for research workflows.
You have access to:
- The original research question
- Question-answer pairs from search operations
- Raw search results and source documents
- Previously identified insights
Your dual role is to:
ANALYSIS:
1. Extract key insights from all gathered information
2. Identify patterns and connections across sources
3. Synthesize findings into coherent understanding
4. Focus on the most important discoveries
EVALUATION:
1. Assess if we have sufficient information to answer the original question
2. Calculate a confidence score (0-1) based on:
- Coverage of the main question's aspects
- Quality and consistency of sources
- Depth of information gathered
3. Identify specific gaps that still need investigation
4. Generate up to 3 new sub-questions that haven't been answered yet
Be critical and thorough in your evaluation. Only mark research as sufficient when:
- All major aspects of the question are addressed
- Sources provide consistent, reliable information
- The depth of coverage meets the question's requirements
- No critical gaps remain
Generate new sub-questions that:
- Target specific unexplored aspects not covered by existing questions
- Seek clarification on ambiguities
- Explore important edge cases or exceptions
- Are focused and actionable (max 3)
- Do NOT repeat or rephrase questions that have already been answered (see qa_responses)
- Should be genuinely new areas to explore"""
SYNTHESIS_AGENT_PROMPT = """You are a synthesis specialist agent focused on creating comprehensive research reports.
Your role is to:
1. Synthesize all gathered information into a coherent narrative
2. Present findings in a clear, structured format
3. Draw evidence-based conclusions
4. Acknowledge limitations and uncertainties
5. Provide actionable recommendations
6. Maintain academic rigor and objectivity
Your report should be:
- Comprehensive yet concise
- Well-structured and easy to follow
- Based solely on evidence from the research
- Transparent about limitations
- Professional and objective in tone
Focus on creating a report that provides clear value to the reader by:
- Answering the original research question thoroughly
- Highlighting the most important findings
- Explaining the implications of the research
- Suggesting concrete next steps"""

View file

@ -1,27 +1,27 @@
"""Search specialist agent that answers questions using RAG."""
from typing import Any
from pydantic_ai import RunContext from pydantic_ai import RunContext
from pydantic_ai.run import AgentRunResult
from haiku.rag.research.base import BaseResearchAgent from haiku.rag.research.base import BaseResearchAgent
from haiku.rag.research.dependencies import ResearchDependencies from haiku.rag.research.dependencies import ResearchDependencies
from haiku.rag.research.prompts import SEARCH_AGENT_PROMPT
class SearchSpecialistAgent(BaseResearchAgent): class SearchSpecialistAgent(BaseResearchAgent[str]):
"""Agent specialized in answering questions using RAG search.""" """Agent specialized in answering questions using RAG search."""
def __init__(self, provider: str, model: str): def __init__(self, provider: str, model: str) -> None:
# Output is a string answer, like the QA agent # Output is a string answer, like the QA agent
super().__init__(provider, model, output_type=str) super().__init__(provider, model, output_type=str)
async def run(self, prompt: str, deps: ResearchDependencies, **kwargs) -> Any: async def run(
self, prompt: str, deps: ResearchDependencies, **kwargs
) -> AgentRunResult[str]:
"""Execute the agent and store QA response in context.""" """Execute the agent and store QA response in context."""
# Run the base agent # Run the base agent
result = await super().run(prompt, deps, **kwargs) result = await super().run(prompt, deps, **kwargs)
# Store the QA response if we got an answer # Store the QA response if we got an answer
if hasattr(result, "output") and result.output: if result.output:
# Get the sources from the last search (which the tool just stored) # Get the sources from the last search (which the tool just stored)
if deps.context.search_results: if deps.context.search_results:
last_search = deps.context.search_results[-1] last_search = deps.context.search_results[-1]
@ -31,16 +31,7 @@ class SearchSpecialistAgent(BaseResearchAgent):
return result return result
def get_system_prompt(self) -> str: def get_system_prompt(self) -> str:
return """You are a search and question-answering specialist. return SEARCH_AGENT_PROMPT
Your role is to:
1. Search the knowledge base for relevant information
2. Analyze the retrieved documents
3. Provide a comprehensive answer to the question
4. Base your answer strictly on the information found
Use the search_and_answer tool to retrieve relevant documents and formulate your response.
Be thorough and specific in your answers, citing relevant information from the sources."""
def register_tools(self) -> None: def register_tools(self) -> None:
"""Register search-specific tools.""" """Register search-specific tools."""

View file

@ -1,8 +1,7 @@
"""Synthesis agent for final research report generation."""
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from haiku.rag.research.base import BaseResearchAgent from haiku.rag.research.base import BaseResearchAgent
from haiku.rag.research.prompts import SYNTHESIS_AGENT_PROMPT
class ResearchReport(BaseModel): class ResearchReport(BaseModel):
@ -24,35 +23,14 @@ class ResearchReport(BaseModel):
) )
class SynthesisAgent(BaseResearchAgent): class SynthesisAgent(BaseResearchAgent[ResearchReport]):
"""Agent specialized in synthesizing research into comprehensive reports.""" """Agent specialized in synthesizing research into comprehensive reports."""
def __init__(self, provider: str, model: str): def __init__(self, provider: str, model: str) -> None:
super().__init__(provider, model, output_type=ResearchReport) super().__init__(provider, model, output_type=ResearchReport)
def get_system_prompt(self) -> str: def get_system_prompt(self) -> str:
return """You are a synthesis specialist agent focused on creating comprehensive research reports. return SYNTHESIS_AGENT_PROMPT
Your role is to:
1. Synthesize all gathered information into a coherent narrative
2. Present findings in a clear, structured format
3. Draw evidence-based conclusions
4. Acknowledge limitations and uncertainties
5. Provide actionable recommendations
6. Maintain academic rigor and objectivity
Your report should be:
- Comprehensive yet concise
- Well-structured and easy to follow
- Based solely on evidence from the research
- Transparent about limitations
- Professional and objective in tone
Focus on creating a report that provides clear value to the reader by:
- Answering the original research question thoroughly
- Highlighting the most important findings
- Explaining the implications of the research
- Suggesting concrete next steps"""
def register_tools(self) -> None: def register_tools(self) -> None:
"""Register synthesis-specific tools.""" """Register synthesis-specific tools."""

View file

@ -3,15 +3,10 @@
from unittest.mock import AsyncMock, MagicMock, create_autospec from unittest.mock import AsyncMock, MagicMock, create_autospec
import pytest import pytest
from pydantic_ai import RunContext
from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import RunUsage
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.research.analysis_agent import AnalysisResult
from haiku.rag.research.base import SearchResult
from haiku.rag.research.clarification_agent import ClarificationResult
from haiku.rag.research.dependencies import ResearchContext, ResearchDependencies from haiku.rag.research.dependencies import ResearchContext, ResearchDependencies
from haiku.rag.research.evaluation_agent import EvaluationResult
from haiku.rag.research.orchestrator import ResearchOrchestrator, ResearchPlan from haiku.rag.research.orchestrator import ResearchOrchestrator, ResearchPlan
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
@ -62,8 +57,10 @@ class TestResearchOrchestrator:
# All agents should use the same provider/model # All agents should use the same provider/model
assert orchestrator.search_agent.provider == orchestrator.provider assert orchestrator.search_agent.provider == orchestrator.provider
assert orchestrator.search_agent.model == orchestrator.model assert orchestrator.search_agent.model == orchestrator.model
assert orchestrator.analysis_agent.provider == orchestrator.provider assert orchestrator.evaluation_agent.provider == orchestrator.provider
assert orchestrator.analysis_agent.model == orchestrator.model assert orchestrator.evaluation_agent.model == orchestrator.model
assert orchestrator.synthesis_agent.provider == orchestrator.provider
assert orchestrator.synthesis_agent.model == orchestrator.model
def test_orchestrator_initialization(self): def test_orchestrator_initialization(self):
"""Test that orchestrator initializes all agents correctly.""" """Test that orchestrator initializes all agents correctly."""
@ -71,63 +68,57 @@ class TestResearchOrchestrator:
# Check all agents are initialized # Check all agents are initialized
assert orchestrator.search_agent is not None assert orchestrator.search_agent is not None
assert orchestrator.analysis_agent is not None assert orchestrator.evaluation_agent is not None
assert orchestrator.clarification_agent is not None
assert orchestrator.synthesis_agent is not None assert orchestrator.synthesis_agent is not None
# Check they all use the same provider and model # Check they all use the same provider and model
assert orchestrator.search_agent.provider == "openai" assert orchestrator.search_agent.provider == "openai"
assert orchestrator.search_agent.model == "gpt-4" assert orchestrator.search_agent.model == "gpt-4"
assert orchestrator.analysis_agent.provider == "openai" assert orchestrator.evaluation_agent.provider == "openai"
assert orchestrator.clarification_agent.provider == "openai" assert orchestrator.evaluation_agent.model == "gpt-4"
assert orchestrator.synthesis_agent.provider == "openai" assert orchestrator.synthesis_agent.provider == "openai"
assert orchestrator.synthesis_agent.model == "gpt-4"
def test_orchestrator_has_correct_output_type(self): def test_orchestrator_has_correct_output_type(self):
"""Test that orchestrator's output type is ResearchPlan.""" """Test that orchestrator's output type is ResearchPlan."""
orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4") orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4")
assert orchestrator.output_type == ResearchPlan assert orchestrator.output_type == ResearchPlan
def test_orchestrator_registers_delegation_tools(self): def test_orchestrator_has_no_tools(self):
"""Test that orchestrator registers all delegation tools.""" """Test that orchestrator no longer registers tools (direct agent calls now)."""
orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4") orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4")
# Get the tools from the agent # Get the tools from the agent
tools = orchestrator.agent._function_toolset.tools tools = orchestrator.agent._function_toolset.tools
tool_names = list(tools.keys()) tool_names = list(tools.keys())
# Check all delegation tools are registered # Should have no tools since we call agents directly now
assert "delegate_search" in tool_names assert len(tool_names) == 0
assert "delegate_analysis" in tool_names
assert "delegate_clarification" in tool_names
assert "generate_report" in tool_names
def test_should_stop_research_logic(self): def test_should_stop_research_logic(self):
"""Test the stopping logic based on ClarificationResult.""" """Test the stopping logic based on EvaluationResult."""
orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4") orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4")
# Create mock clarification results # Create mock evaluation results
sufficient_result = MagicMock() sufficient_result = MagicMock()
sufficient_result.output = ClarificationResult( sufficient_result.output = EvaluationResult(
information_gaps=[], key_insights=["Climate is changing", "Human activity is the cause"],
follow_up_questions=[], new_questions=[],
suggested_searches=[],
completeness_assessment="Research is comprehensive",
priority_areas=[],
is_sufficient=True,
confidence_score=0.9, confidence_score=0.9,
reasoning="All aspects covered", is_sufficient=True,
reasoning="All aspects covered comprehensively",
) )
insufficient_result = MagicMock() insufficient_result = MagicMock()
insufficient_result.output = ClarificationResult( insufficient_result.output = EvaluationResult(
information_gaps=["Missing data on impacts"], key_insights=["Some data found"],
follow_up_questions=["What about economic impacts?"], new_questions=[
suggested_searches=["economic impact climate change"], "What about economic impacts?",
completeness_assessment="More research needed", "Regional variations?",
priority_areas=["Economic analysis"], ],
is_sufficient=False,
confidence_score=0.4, confidence_score=0.4,
reasoning="Major gaps remain", is_sufficient=False,
reasoning="Major gaps remain in understanding",
) )
# Test with sufficient research (threshold 0.8) # Test with sufficient research (threshold 0.8)
@ -145,138 +136,66 @@ class TestResearchOrchestrator:
assert not orchestrator._should_stop_research(insufficient_result, 0.8) assert not orchestrator._should_stop_research(insufficient_result, 0.8)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delegate_search_tool(self, research_deps): async def test_conduct_research_workflow(self, mock_client):
"""Test the delegate_search tool function.""" """Test the basic research workflow."""
orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4") orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4")
# Get the delegate_search tool # Mock the agent runs
tools = orchestrator.agent._function_toolset.tools # Mock initial plan
delegate_search = tools["delegate_search"].function plan_mock = MagicMock()
plan_mock.output = ResearchPlan(
# Mock the search agent's run method main_question="What is climate change?",
orchestrator.search_agent.run = AsyncMock( sub_questions=[
return_value=MagicMock(output=["results"]) "What causes climate change?",
"What are the effects?",
"What can be done?",
],
) )
orchestrator.run = AsyncMock(return_value=plan_mock)
# Create context and call the tool # Mock search agent
ctx = RunContext(deps=research_deps, model=TestModel(), usage=RunUsage()) search_mock = MagicMock()
await delegate_search(ctx, queries=["climate change"]) search_mock.output = "Climate change is caused by greenhouse gases."
orchestrator.search_agent.run = AsyncMock(return_value=search_mock)
# Verify the search agent was called # Mock evaluation agent - make it stop after first iteration
orchestrator.search_agent.run.assert_called_once() eval_mock = MagicMock()
assert "climate change" in orchestrator.search_agent.run.call_args[0][0] eval_mock.output = EvaluationResult(
key_insights=["Climate change is real"],
@pytest.mark.asyncio new_questions=[],
async def test_delegate_analysis_tool(self, research_deps): confidence_score=0.9,
"""Test the delegate_analysis tool function.""" is_sufficient=True,
orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4") reasoning="Sufficient information gathered",
# Add some search results to context
research_deps.context.search_results = [
{
"query": "test",
"results": [
SearchResult(
content="Climate data",
score=0.9,
document_uri="doc1.md",
metadata={},
)
],
}
]
# Get the delegate_analysis tool
tools = orchestrator.agent._function_toolset.tools
delegate_analysis = tools["delegate_analysis"].function
# Mock the analysis agent's run method
mock_result = MagicMock()
mock_result.output = AnalysisResult(
key_insights=["Climate is changing"],
themes={"warming": ["temperature rise"]},
summary="Analysis complete",
evidence_quality="strong",
recommendations=["More research needed"],
) )
orchestrator.analysis_agent.run = AsyncMock(return_value=mock_result) orchestrator.evaluation_agent.run = AsyncMock(return_value=eval_mock)
# Create context and call the tool # Mock synthesis agent
ctx = RunContext(deps=research_deps, model=TestModel(), usage=RunUsage())
await delegate_analysis(ctx)
# Verify the analysis agent was called
orchestrator.analysis_agent.run.assert_called_once()
# Verify insights were added to context
assert "Climate is changing" in research_deps.context.insights
@pytest.mark.asyncio
async def test_delegate_clarification_tool(self, research_deps):
"""Test the delegate_clarification tool function."""
orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4")
# Get the delegate_clarification tool
tools = orchestrator.agent._function_toolset.tools
delegate_clarification = tools["delegate_clarification"].function
# Mock the clarification agent's run method
mock_result = MagicMock()
mock_result.output = ClarificationResult(
information_gaps=["Missing economic data"],
follow_up_questions=["What about costs?"],
suggested_searches=["climate change costs"],
completeness_assessment="Needs more data",
priority_areas=["Economics"],
is_sufficient=False,
confidence_score=0.6,
reasoning="Missing key information",
)
orchestrator.clarification_agent.run = AsyncMock(return_value=mock_result)
# Create context and call the tool
ctx = RunContext(deps=research_deps, model=TestModel(), usage=RunUsage())
await delegate_clarification(ctx)
# Verify the clarification agent was called
orchestrator.clarification_agent.run.assert_called_once()
# Verify gaps and questions were added to context
assert "Missing economic data" in research_deps.context.gaps
assert "What about costs?" in research_deps.context.follow_up_questions
@pytest.mark.asyncio
async def test_generate_report_tool(self, research_deps):
"""Test the generate_report tool function."""
orchestrator = ResearchOrchestrator(provider="openai", model="gpt-4")
# Get the generate_report tool
tools = orchestrator.agent._function_toolset.tools
generate_report = tools["generate_report"].function
# Mock the synthesis agent's run method
from haiku.rag.research.synthesis_agent import ResearchReport from haiku.rag.research.synthesis_agent import ResearchReport
mock_result = MagicMock() synthesis_mock = MagicMock()
mock_result.output = ResearchReport( synthesis_mock.output = ResearchReport(
title="Climate Change Research", title="Climate Change Report",
executive_summary="Summary of findings", executive_summary="Summary",
main_findings=["Finding 1", "Finding 2"], main_findings=["Finding 1"],
themes={"warming": "Global temperature rise"}, themes={},
conclusions=["Conclusion 1"], conclusions=[],
limitations=["Limited data"], limitations=[],
recommendations=["More research"], recommendations=[],
sources_summary="Various sources", sources_summary="Sources",
) )
orchestrator.synthesis_agent.run = AsyncMock(return_value=mock_result) orchestrator.synthesis_agent.run = AsyncMock(return_value=synthesis_mock)
# Create context and call the tool # Mock client search and expand
ctx = RunContext(deps=research_deps, model=TestModel(), usage=RunUsage()) mock_client.search.return_value = []
result = await generate_report(ctx) mock_client.expand_context.return_value = []
# Verify the synthesis agent was called # Run the research
orchestrator.synthesis_agent.run.assert_called_once() report = await orchestrator.conduct_research(
"What is climate change?", mock_client, max_iterations=3
)
# Verify we got a ResearchReport # Verify we got a report
assert isinstance(result, ResearchReport) assert report.title == "Climate Change Report"
assert result.title == "Climate Change Research"
# Verify search was called for all 3 sub-questions
assert orchestrator.search_agent.run.call_count == 3