(refactor) Refactor base structure towards v1 implementation

This commit is contained in:
3un01a 2026-01-16 15:41:53 +09:00
parent b0dc05c909
commit 155112f533
43 changed files with 1450 additions and 37 deletions

View File

@ -0,0 +1,470 @@
# Agent Directory Template
This document defines the standard directory structure for all Honcho agents. Following this template ensures consistency across the codebase and makes agents easier to understand, test, and maintain.
## Standard Directory Structure
```
agent_name/
├── __init__.py # Package initialization and exports
├── agent.py # Main agent class (inherits from BaseAgent)
├── config.py # Agent-specific configuration (optional)
├── prompts.py # Agent-specific prompt templates (optional)
├── tools.py # Agent-specific tool definitions (optional)
└── README.md # Agent documentation (optional)
```
## File Descriptions
### `__init__.py` (Required)
Package initialization that exports the main agent class and any public APIs.
**Template:**
```python
"""
[Agent Name] - [Brief description of agent's purpose]
This module implements [describe what the agent does].
"""
from .agent import AgentNameAgent
__all__ = [
"AgentNameAgent",
]
```
### `agent.py` (Required)
Main agent implementation that inherits from `BaseAgent`.
**Template:**
```python
"""
Main implementation of the [Agent Name] agent.
"""
import logging
from typing import Any, Dict
from sqlalchemy.ext.asyncio import AsyncSession
from src.agents.shared import BaseAgent, AgentConfig
logger = logging.getLogger(__name__)
class AgentNameAgent(BaseAgent):
"""
[Brief description of agent's purpose and functionality]
This agent is responsible for [describe responsibilities].
Attributes:
db: Database session for agent operations
config: Agent configuration
[additional agent-specific attributes]
"""
def __init__(
self,
db: AsyncSession,
config: AgentConfig | None = None,
**kwargs
):
"""
Initialize the [Agent Name] agent.
Args:
db: SQLAlchemy async database session
config: Agent-specific configuration
**kwargs: Additional agent-specific parameters
"""
super().__init__(db, config, **kwargs)
# Initialize agent-specific attributes here
async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute the agent's main task.
Args:
input_data: Dictionary containing:
- [list required input fields]
Returns:
Dictionary containing:
- [list output fields]
Raises:
ValueError: If input data is invalid
RuntimeError: If agent execution fails
"""
# Implement agent logic here
logger.info(f"[{self.agent_type}] Executing with input: {input_data.keys()}")
# Example implementation:
result = await self._process_input(input_data)
return {
"success": True,
"result": result,
}
def validate_input(self, input_data: Dict[str, Any]) -> bool:
"""
Validate the input data before execution.
Args:
input_data: Dictionary containing input data to validate
Returns:
True if input is valid
Raises:
ValueError: If input validation fails with a descriptive error message
"""
# Implement validation logic
required_fields = [] # Define required fields
for field in required_fields:
if field not in input_data:
raise ValueError(f"Missing required field: {field}")
return True
async def _process_input(self, input_data: Dict[str, Any]) -> Any:
"""
Private helper method for processing input.
Args:
input_data: Validated input data
Returns:
Processed result
"""
# Implement processing logic
pass
```
### `config.py` (Optional)
Agent-specific configuration class that extends `AgentConfig`.
**Template:**
```python
"""
Configuration for the [Agent Name] agent.
"""
from pydantic import Field
from src.agents.shared import AgentConfig
class AgentNameConfig(AgentConfig):
"""
Configuration for the [Agent Name] agent.
Extends the base AgentConfig with agent-specific parameters.
"""
# Agent-specific configuration fields
param_name: str = Field(
default="default_value",
description="Description of parameter",
)
another_param: int = Field(
default=10,
ge=1,
le=100,
description="Description with validation constraints",
)
class Config:
"""Pydantic configuration."""
validate_assignment = True
```
### `prompts.py` (Optional)
Agent-specific prompt templates and formatting functions.
**Template:**
```python
"""
Prompt templates for the [Agent Name] agent.
"""
from src.agents.shared import format_system_prompt
def get_agent_system_prompt() -> str:
"""
Get the system prompt for the [Agent Name] agent.
Returns:
Formatted system prompt
"""
return format_system_prompt(
role="[agent role description]",
task_description="[detailed task description]",
guidelines=[
"guideline 1",
"guideline 2",
],
constraints=[
"constraint 1",
"constraint 2",
],
)
def format_agent_specific_prompt(data: dict) -> str:
"""
Format an agent-specific prompt with data.
Args:
data: Data to include in the prompt
Returns:
Formatted prompt string
"""
# Implement agent-specific formatting
return f"Custom prompt with {data}"
```
### `tools.py` (Optional)
Agent-specific tool definitions for LLM tool calling.
**Template:**
```python
"""
Tool definitions for the [Agent Name] agent.
"""
from typing import Any, Dict
from src.agents.shared import create_tool_definition
def get_agent_tools() -> list[Dict[str, Any]]:
"""
Get tool definitions for the [Agent Name] agent.
Returns:
List of tool definitions
"""
return [
create_tool_definition(
name="tool_name",
description="What the tool does",
parameters={
"param1": {
"type": "string",
"description": "Parameter description",
},
},
required=["param1"],
),
]
async def execute_tool(
tool_name: str,
arguments: Dict[str, Any],
context: Dict[str, Any],
) -> Dict[str, Any]:
"""
Execute an agent tool.
Args:
tool_name: Name of the tool to execute
arguments: Tool arguments
context: Execution context (db, config, etc.)
Returns:
Tool execution result
"""
if tool_name == "tool_name":
return await _execute_tool_name(arguments, context)
raise ValueError(f"Unknown tool: {tool_name}")
async def _execute_tool_name(
arguments: Dict[str, Any],
context: Dict[str, Any],
) -> Dict[str, Any]:
"""Execute specific tool."""
# Implement tool logic
return {"result": "success"}
```
## Shared Infrastructure Usage
All agents should leverage the shared infrastructure in `src/agents/shared/`:
### BaseAgent
```python
from src.agents.shared import BaseAgent
class MyAgent(BaseAgent):
async def execute(self, input_data):
# Implementation
pass
def validate_input(self, input_data):
# Validation
return True
```
### Configuration
```python
from src.agents.shared import AgentConfig, ExtractorConfig
# Use base config
config = AgentConfig(model="gpt-4o", temperature=0.7)
# Or use specialized config
config = ExtractorConfig(temperature=0.3)
```
### Prompt Utilities
```python
from src.agents.shared import (
format_system_prompt,
format_context_section,
format_provenance_chain,
truncate_text,
)
prompt = format_system_prompt(
role="data processor",
task_description="Process and analyze data",
)
```
### Tool Utilities
```python
from src.agents.shared import (
create_tool_definition,
validate_tool_call,
extract_tool_arguments,
format_tool_result,
)
tool = create_tool_definition(
name="search",
description="Search for data",
parameters={"query": {"type": "string"}},
)
```
## Testing
Each agent should have corresponding tests in `tests/agents/[agent_name]/`:
```
tests/
└── agents/
└── agent_name/
├── __init__.py
├── test_agent.py # Main agent tests
├── test_config.py # Configuration tests
└── test_integration.py # Integration tests
```
### Test Template
```python
"""Tests for [Agent Name] agent."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.agents.agent_name import AgentNameAgent
class TestAgentName:
"""Test suite for [Agent Name] agent."""
@pytest.fixture
def mock_db(self):
"""Create mock database session."""
return MagicMock()
@pytest.fixture
def agent(self, mock_db):
"""Create agent instance."""
return AgentNameAgent(db=mock_db)
@pytest.mark.asyncio
async def test_execute_success(self, agent):
"""Test successful execution."""
input_data = {"required_field": "value"}
result = await agent.execute(input_data)
assert result["success"] is True
def test_validate_input_valid(self, agent):
"""Test input validation with valid data."""
input_data = {"required_field": "value"}
assert agent.validate_input(input_data) is True
def test_validate_input_invalid(self, agent):
"""Test input validation with invalid data."""
input_data = {}
with pytest.raises(ValueError):
agent.validate_input(input_data)
```
## Best Practices
1. **Inheritance**: Always inherit from `BaseAgent` for consistency
2. **Type Hints**: Use comprehensive type annotations throughout
3. **Logging**: Use structured logging with `logger.info/debug/error`
4. **Error Handling**: Raise specific exceptions with descriptive messages
5. **Documentation**: Include docstrings for all public methods
6. **Configuration**: Use Pydantic models for type-safe configuration
7. **Testing**: Write comprehensive unit and integration tests
8. **Async**: Use async/await for all I/O operations
9. **Shared Utilities**: Leverage shared infrastructure instead of duplicating code
10. **Naming Conventions**: Use snake_case for functions/variables, PascalCase for classes
## Migration Checklist
When converting an existing agent to this template:
- [ ] Create new directory structure
- [ ] Implement BaseAgent inheritance
- [ ] Move configuration to config.py
- [ ] Extract prompts to prompts.py
- [ ] Extract tools to tools.py
- [ ] Update imports in dependent files
- [ ] Write/update tests
- [ ] Update documentation
- [ ] Verify all functionality works
- [ ] Remove old agent files
## Example Agents
See these agents for reference implementations:
- **Extractor** (`src/agents/extractor/`): Premise extraction and memory formation
- **Dialectic** (`src/agents/dialectic/`): Query answering with context retrieval
- **Dreamer** (`src/agents/dreamer/`): Memory consolidation and improvement
## Questions?
For questions or clarifications about the agent template, see:
- `src/agents/shared/base_agent.py` - BaseAgent implementation
- `AGENT_DEVELOPMENT.md` - Detailed development guidelines (Phase 0B.5)
- `TODO.md` - Implementation plan and progress tracking

View File

@ -11,7 +11,7 @@ from collections.abc import AsyncIterator
from src import crud
from src.config import ReasoningLevel
from src.dependencies import tracked_db
from src.dialectic.core import DialecticAgent
from src.agents.dialectic.core import DialecticAgent
from src.utils.config_helpers import get_configuration
logger = logging.getLogger(__name__)

View File

@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, prometheus
from src.config import ReasoningLevel, settings
from src.dialectic import prompts
from src.agents.dialectic import prompts
from src.utils.agent_tools import DIALECTIC_TOOLS, create_tool_executor, search_memory
from src.utils.clients import (
HonchoLLMCallResponse,

View File

@ -163,7 +163,7 @@ class DreamScheduler:
"""Execute the dream by enqueueing it and updating collection metadata."""
# Import here to avoid circular dependency
from src import crud
from src.deriver.enqueue import enqueue_dream
from src.agents.extractor.enqueue import enqueue_dream
from src.utils.config_helpers import get_configuration
# Find the most recent session and get current document count

View File

@ -4,7 +4,7 @@ import sentry_sdk
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.orchestrator import run_dream
from src.agents.dreamer.orchestrator import run_dream
from src.schemas import DreamType
from src.utils.queue_payload import DreamPayload

View File

@ -19,8 +19,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud
from src.config import settings
from src.dreamer.specialists import SPECIALISTS
from src.dreamer.surprisal import SurprisalScore # type: ignore
from src.agents.dreamer.specialists import SPECIALISTS
from src.agents.dreamer.surprisal import SurprisalScore # type: ignore
from src.exceptions import SpecialistExecutionError, SurprisalError
from src.utils.config_helpers import get_configuration
from src.utils.logging import (
@ -102,7 +102,7 @@ async def run_dream(
if settings.DREAM.SURPRISAL.ENABLED:
logger.info(f"[{run_id}] Phase 0: Computing surprisal scores")
try:
from src.dreamer.surprisal import sample_observations_with_surprisal
from src.agents.dreamer.surprisal import sample_observations_with_surprisal
high_surprisal_obs = await sample_observations_with_surprisal(
db=db,

View File

@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.crud.document import get_all_documents
from src.dreamer.trees import SurprisalTree, create_tree
from src.agents.dreamer.trees import SurprisalTree, create_tree
logger = logging.getLogger(__name__)

View File

@ -6,8 +6,8 @@ from sqlalchemy import select
from src import crud, models
from src.dependencies import tracked_db
from src.deriver.deriver import process_representation_tasks_batch
from src.dreamer.dreamer import process_dream
from src.agents.extractor.deriver import process_representation_tasks_batch
from src.agents.dreamer.dreamer import process_dream
from src.exceptions import ResourceNotFoundException
from src.models import Message
from src.schemas import ResolvedConfiguration

View File

@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_scheduler import get_dream_scheduler
from src.agents.dreamer.dream_scheduler import get_dream_scheduler
from src.exceptions import ValidationException
from src.models import QueueItem
from src.schemas import MessageConfiguration, ResolvedConfiguration

View File

@ -19,8 +19,8 @@ from src import models, prometheus
from src.cache.client import close_cache, init_cache
from src.config import settings
from src.dependencies import tracked_db
from src.deriver.consumer import process_item, process_representation_batch
from src.dreamer.dream_scheduler import (
from src.agents.extractor.consumer import process_item, process_representation_batch
from src.agents.dreamer.dream_scheduler import (
DreamScheduler,
get_dream_scheduler,
set_dream_scheduler,

View File

@ -0,0 +1,59 @@
"""
Shared agent infrastructure for Honcho.
This module provides base classes, utilities, and common functionality
shared across all agents in the Honcho system.
"""
from .base_agent import BaseAgent
from .config import (
AbducerConfig,
AgentConfig,
DialecticConfig,
DreamerConfig,
ExtractorConfig,
FalsifierConfig,
InductorConfig,
PredictorConfig,
create_config_from_dict,
)
from .prompts import (
format_context_section,
format_peer_info,
format_provenance_chain,
format_system_prompt,
truncate_text,
)
from .tools import (
create_tool_definition,
extract_tool_arguments,
format_tool_result,
validate_tool_call,
)
__all__ = [
# Base classes
"BaseAgent",
"AgentConfig",
# Agent configs
"ExtractorConfig",
"AbducerConfig",
"PredictorConfig",
"FalsifierConfig",
"InductorConfig",
"DialecticConfig",
"DreamerConfig",
# Config utilities
"create_config_from_dict",
# Prompt utilities
"format_system_prompt",
"format_context_section",
"format_provenance_chain",
"format_peer_info",
"truncate_text",
# Tool utilities
"create_tool_definition",
"validate_tool_call",
"extract_tool_arguments",
"format_tool_result",
]

View File

@ -0,0 +1,202 @@
"""
Base Agent class for Honcho agents.
This module defines the abstract base class that all Honcho agents should inherit from,
providing a consistent interface and common functionality.
"""
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
class BaseAgent(ABC):
"""
Abstract base class for all Honcho agents.
All agents should inherit from this class and implement the required abstract methods.
This ensures a consistent interface across all agents and provides common functionality
for logging, error handling, and provenance tracking.
Attributes:
db: Database session for agent operations
config: Agent-specific configuration
agent_type: String identifier for the agent type (e.g., "abducer", "predictor")
"""
def __init__(self, db: AsyncSession, config: Any = None, **kwargs):
"""
Initialize the base agent.
Args:
db: SQLAlchemy async database session
config: Agent-specific configuration object
**kwargs: Additional agent-specific parameters
"""
self.db = db
self.config = config
self.agent_type = self.__class__.__name__.lower()
# Store additional kwargs for agent-specific parameters
for key, value in kwargs.items():
setattr(self, key, value)
logger.debug(f"Initialized {self.agent_type} agent")
@abstractmethod
async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute the agent's main task.
This is the primary method that performs the agent's work. All agents
must implement this method with their specific logic.
Args:
input_data: Dictionary containing the input data for the agent.
The structure depends on the specific agent type.
Returns:
Dictionary containing the agent's output. The structure depends
on the specific agent type.
Raises:
ValueError: If input data is invalid
RuntimeError: If agent execution fails
"""
pass
@abstractmethod
def validate_input(self, input_data: Dict[str, Any]) -> bool:
"""
Validate the input data before execution.
This method should check that the input_data dictionary contains
all required fields and that values are of the correct type.
Args:
input_data: Dictionary containing input data to validate
Returns:
True if input is valid, False otherwise
Raises:
ValueError: If input validation fails with a descriptive error message
"""
pass
async def trace_execution(
self,
input_data: Dict[str, Any],
output: Dict[str, Any],
metadata: Dict[str, Any] | None = None,
) -> None:
"""
Record execution trace for provenance tracking.
This method records the agent's execution for training data generation
and debugging purposes. The default implementation logs basic information,
but agents can override this to provide more detailed tracing.
Args:
input_data: The input provided to the agent
output: The output produced by the agent
metadata: Optional additional metadata about the execution
(e.g., execution time, model used, tokens consumed)
Note:
This is optional and has a default implementation that logs basic info.
Agents can override this for more detailed provenance tracking.
"""
logger.info(
f"[{self.agent_type}] Execution trace",
extra={
"agent_type": self.agent_type,
"input_keys": list(input_data.keys()),
"output_keys": list(output.keys()),
"metadata": metadata or {},
},
)
async def pre_execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Hook called before execute(). Can be used for setup, validation, etc.
Args:
input_data: The input data that will be passed to execute()
Returns:
Modified input_data (or original if no modifications needed)
Note:
This is optional and has a default implementation that validates input.
Agents can override this for additional pre-processing.
"""
if not self.validate_input(input_data):
raise ValueError(f"Invalid input for {self.agent_type} agent")
return input_data
async def post_execute(
self, input_data: Dict[str, Any], output: Dict[str, Any]
) -> Dict[str, Any]:
"""
Hook called after execute(). Can be used for cleanup, logging, etc.
Args:
input_data: The input data that was passed to execute()
output: The output produced by execute()
Returns:
Modified output (or original if no modifications needed)
Note:
This is optional and has a default implementation that traces execution.
Agents can override this for additional post-processing.
"""
await self.trace_execution(input_data, output)
return output
async def run(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Run the full agent execution pipeline with hooks.
This method orchestrates the full execution flow:
1. pre_execute (validation, setup)
2. execute (main agent logic)
3. post_execute (cleanup, tracing)
Args:
input_data: Dictionary containing input data
Returns:
Dictionary containing agent output
Raises:
ValueError: If input validation fails
RuntimeError: If execution fails
"""
try:
# Pre-execution hook
validated_input = await self.pre_execute(input_data)
# Main execution
output = await self.execute(validated_input)
# Post-execution hook
final_output = await self.post_execute(validated_input, output)
return final_output
except Exception as e:
logger.error(
f"[{self.agent_type}] Execution failed: {str(e)}",
exc_info=True,
)
raise
def __repr__(self) -> str:
"""String representation of the agent."""
return f"<{self.__class__.__name__} type={self.agent_type}>"

194
src/agents/shared/config.py Normal file
View File

@ -0,0 +1,194 @@
"""
Configuration base classes for Honcho agents.
This module provides base configuration classes and utilities
for agent configuration management.
"""
from typing import Any, Dict
from pydantic import BaseModel, Field
class AgentConfig(BaseModel):
"""
Base configuration class for all Honcho agents.
All agent-specific configurations should inherit from this class
to ensure consistent configuration management.
"""
model: str = Field(
default="gpt-4o",
description="LLM model to use for this agent",
)
temperature: float = Field(
default=0.7,
ge=0.0,
le=2.0,
description="Temperature for LLM sampling",
)
max_tokens: int | None = Field(
default=None,
description="Maximum tokens for LLM response (None = no limit)",
)
timeout: int = Field(
default=60,
gt=0,
description="Timeout in seconds for agent execution",
)
retry_attempts: int = Field(
default=3,
ge=0,
description="Number of retry attempts on failure",
)
enable_tracing: bool = Field(
default=True,
description="Enable provenance tracing for this agent",
)
class Config:
"""Pydantic configuration."""
validate_assignment = True
extra = "forbid"
class ExtractorConfig(AgentConfig):
"""Configuration for the Extractor agent."""
model: str = "gpt-4o-mini"
temperature: float = 0.3 # Lower temperature for more consistent extraction
class AbducerConfig(AgentConfig):
"""Configuration for the Abducer agent (hypothesis generation)."""
model: str = "gpt-4o"
temperature: float = 0.7
max_hypotheses: int = Field(
default=5,
ge=1,
le=10,
description="Maximum number of hypotheses to generate",
)
min_hypotheses: int = Field(
default=1,
ge=1,
description="Minimum number of hypotheses to generate",
)
class PredictorConfig(AgentConfig):
"""Configuration for the Predictor agent (blind prediction generation)."""
model: str = "gpt-4o"
temperature: float = 0.8
max_predictions_per_hypothesis: int = Field(
default=5,
ge=2,
le=10,
description="Maximum predictions per hypothesis",
)
min_predictions_per_hypothesis: int = Field(
default=2,
ge=1,
description="Minimum predictions per hypothesis",
)
enforce_blindness: bool = Field(
default=True,
description="Strictly enforce that predictor doesn't see premises",
)
class FalsifierConfig(AgentConfig):
"""Configuration for the Falsifier agent (contradiction search)."""
model: str = "gpt-4o"
temperature: float = 0.5
max_search_iterations: int = Field(
default=7,
ge=1,
le=20,
description="Maximum adversarial search iterations",
)
early_stop_on_contradiction: bool = Field(
default=True,
description="Stop searching once a contradiction is found",
)
search_creativity: float = Field(
default=0.7,
ge=0.0,
le=1.0,
description="Creativity level for adversarial search queries",
)
class InductorConfig(AgentConfig):
"""Configuration for the Inductor agent (pattern extraction)."""
model: str = "gpt-4o"
temperature: float = 0.6
min_sources_per_induction: int = Field(
default=2,
ge=2,
description="Minimum source predictions needed for an induction",
)
clustering_threshold: float = Field(
default=0.6,
ge=0.0,
le=1.0,
description="Similarity threshold for prediction clustering",
)
class DialecticConfig(AgentConfig):
"""Configuration for the Dialectic agent (query answering)."""
model: str = "gpt-4o"
temperature: float = 0.7
max_tool_iterations: int = Field(
default=10,
ge=1,
le=50,
description="Maximum tool calling iterations",
)
class DreamerConfig(AgentConfig):
"""Configuration for the Dreamer agent (orchestration)."""
model: str = "gpt-4o"
temperature: float = 0.7
enable_surprisal: bool = Field(
default=True,
description="Enable surprisal-based sampling",
)
def create_config_from_dict(
config_class: type[AgentConfig],
config_dict: Dict[str, Any],
) -> AgentConfig:
"""
Create an agent configuration from a dictionary.
Args:
config_class: The configuration class to instantiate
config_dict: Dictionary of configuration values
Returns:
Instantiated configuration object
Raises:
ValueError: If configuration is invalid
"""
try:
return config_class(**config_dict)
except Exception as e:
raise ValueError(f"Invalid configuration: {e}")

View File

@ -0,0 +1,166 @@
"""
Shared prompt utilities for Honcho agents.
This module provides common prompt formatting functions and templates
that can be used across multiple agents.
"""
from typing import Any
def format_system_prompt(
role: str,
task_description: str,
guidelines: list[str] | None = None,
constraints: list[str] | None = None,
) -> str:
"""
Format a system prompt for an agent.
Args:
role: The role of the agent (e.g., "hypothesis generator", "falsifier")
task_description: Description of the agent's main task
guidelines: Optional list of guidelines for the agent to follow
constraints: Optional list of constraints the agent must respect
Returns:
Formatted system prompt string
"""
prompt_parts = [
f"You are a {role}.",
"",
task_description,
]
if guidelines:
prompt_parts.extend([
"",
"Guidelines:",
*[f"- {guideline}" for guideline in guidelines],
])
if constraints:
prompt_parts.extend([
"",
"Constraints:",
*[f"- {constraint}" for constraint in constraints],
])
return "\n".join(prompt_parts)
def format_context_section(
title: str,
items: list[str] | list[dict[str, Any]],
item_formatter: Any = None,
) -> str:
"""
Format a context section with a title and items.
Args:
title: Title for the context section
items: List of items to include (strings or dicts)
item_formatter: Optional function to format each item
Returns:
Formatted context section
"""
if not items:
return f"{title}:\n(none)"
formatted_items = []
for i, item in enumerate(items, 1):
if item_formatter:
formatted_items.append(item_formatter(item, i))
elif isinstance(item, str):
formatted_items.append(f"{i}. {item}")
elif isinstance(item, dict):
# Default dict formatting
formatted_items.append(f"{i}. {item.get('content', str(item))}")
else:
formatted_items.append(f"{i}. {str(item)}")
return f"{title}:\n" + "\n".join(formatted_items)
def format_provenance_chain(
entity: str,
sources: list[str],
source_type: str = "premise",
) -> str:
"""
Format provenance information showing the chain of reasoning.
Args:
entity: The entity (hypothesis, prediction, etc.)
sources: List of source IDs or descriptions
source_type: Type of sources (e.g., "premise", "hypothesis", "prediction")
Returns:
Formatted provenance chain
"""
if not sources:
return f"{entity} (no sources)"
source_list = "\n".join([f" - {source}" for source in sources])
return f"{entity}\n Based on {len(sources)} {source_type}(s):\n{source_list}"
def truncate_text(
text: str,
max_length: int = 500,
suffix: str = "...",
) -> str:
"""
Truncate text to a maximum length.
Args:
text: Text to truncate
max_length: Maximum length (including suffix)
suffix: Suffix to add when truncating
Returns:
Truncated text
"""
if len(text) <= max_length:
return text
return text[: max_length - len(suffix)] + suffix
def format_peer_info(
observer: str,
observed: str,
observer_card: list[str] | None = None,
observed_card: list[str] | None = None,
) -> str:
"""
Format peer information for agent context.
Args:
observer: ID of the observing peer
observed: ID of the observed peer
observer_card: Optional biographical info about observer
observed_card: Optional biographical info about observed
Returns:
Formatted peer information
"""
lines = [
f"Observer: {observer}",
f"Observed: {observed}",
]
if observer_card:
lines.append(
"Observer Background:\n "
+ "\n ".join(observer_card)
)
if observed_card:
lines.append(
"Observed Background:\n "
+ "\n ".join(observed_card)
)
return "\n".join(lines)

132
src/agents/shared/tools.py Normal file
View File

@ -0,0 +1,132 @@
"""
Shared tool definitions and utilities for Honcho agents.
This module provides common tool-related functionality that can be
used across multiple agents.
"""
from typing import Any, Dict
def create_tool_definition(
name: str,
description: str,
parameters: Dict[str, Any],
required: list[str] | None = None,
) -> Dict[str, Any]:
"""
Create a standardized tool definition for LLM tool calling.
Args:
name: Tool name (snake_case)
description: Clear description of what the tool does
parameters: Dictionary defining the parameters and their types
required: List of required parameter names
Returns:
Tool definition in the format expected by LLM APIs
Example:
>>> create_tool_definition(
... name="search_premises",
... description="Search for premises matching a query",
... parameters={
... "query": {"type": "string", "description": "Search query"},
... "limit": {"type": "integer", "description": "Max results"},
... },
... required=["query"],
... )
"""
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": parameters,
"required": required or [],
},
},
}
def validate_tool_call(
tool_call: Dict[str, Any],
expected_tools: list[str],
) -> bool:
"""
Validate that a tool call is well-formed and uses a known tool.
Args:
tool_call: The tool call to validate
expected_tools: List of valid tool names
Returns:
True if valid, False otherwise
"""
if not isinstance(tool_call, dict):
return False
if "function" not in tool_call:
return False
function = tool_call["function"]
if not isinstance(function, dict):
return False
if "name" not in function:
return False
return function["name"] in expected_tools
def extract_tool_arguments(
tool_call: Dict[str, Any],
) -> Dict[str, Any]:
"""
Extract arguments from a tool call.
Args:
tool_call: Tool call dictionary
Returns:
Dictionary of arguments
Raises:
ValueError: If tool call is malformed
"""
try:
return tool_call["function"]["arguments"]
except (KeyError, TypeError) as e:
raise ValueError(f"Malformed tool call: {e}")
def format_tool_result(
tool_name: str,
result: Any,
error: str | None = None,
) -> Dict[str, Any]:
"""
Format a tool execution result.
Args:
tool_name: Name of the tool that was called
result: The result from the tool execution
error: Optional error message if execution failed
Returns:
Formatted tool result
"""
if error:
return {
"tool": tool_name,
"success": False,
"error": error,
}
return {
"tool": tool_name,
"success": True,
"result": result,
}

View File

@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, exceptions, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_scheduler import check_and_schedule_dream
from src.agents.dreamer.dream_scheduler import check_and_schedule_dream
from src.embedding_client import embedding_client
from src.schemas import ResolvedConfiguration
from src.utils.formatting import format_datetime_utc

View File

@ -19,7 +19,7 @@ from sqlalchemy.orm.attributes import flag_modified
from src import crud, prometheus, schemas
from src.config import settings
from src.dependencies import db
from src.deriver import enqueue
from src.agents.extractor import enqueue
from src.exceptions import FileTooLargeError, ResourceNotFoundException
from src.security import require_auth
from src.utils.files import process_file_uploads_for_messages

View File

@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, prometheus, schemas
from src.config import settings
from src.dependencies import db, tracked_db
from src.dialectic.chat import agentic_chat, agentic_chat_stream
from src.agents.dialectic.chat import agentic_chat, agentic_chat_stream
from src.exceptions import AuthenticationException, ResourceNotFoundException
from src.security import JWTParams, require_auth
from src.utils.search import search

View File

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import config, crud, schemas
from src.dependencies import db, tracked_db
from src.deriver.enqueue import enqueue_deletion
from src.agents.extractor.enqueue import enqueue_deletion
from src.exceptions import (
AuthenticationException,
ResourceNotFoundException,

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.config import settings
from src.dependencies import db
from src.deriver.enqueue import enqueue_dream
from src.agents.extractor.enqueue import enqueue_dream
from src.exceptions import AuthenticationException
from src.security import JWTParams, require_auth
from src.utils.search import search

View File

@ -31,7 +31,7 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config import settings
from src.deriver.prompts import minimal_deriver_prompt
from src.agents.extractor.prompts import minimal_deriver_prompt
from src.embedding_client import EmbeddingClient
from src.utils.clients import honcho_llm_call
from src.utils.representation import PromptRepresentation

View File

@ -288,7 +288,7 @@ async def create_active_queue_session(db_session: AsyncSession) -> Callable[...,
@pytest.fixture
def mock_queue_manager(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: # pyright: ignore[reportUnusedParameter]
"""Mock the queue manager to avoid actual queue processing"""
from src.deriver.queue_manager import QueueManager
from src.agents.extractor.queue_manager import QueueManager
# Create a mock queue manager
mock_manager = AsyncMock(spec=QueueManager)

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.deriver.queue_manager import QueueManager, WorkerOwnership
from src.agents.extractor.queue_manager import QueueManager, WorkerOwnership
from src.utils.work_unit import construct_work_unit_key

View File

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from src.dreamer.dream_scheduler import DreamScheduler, set_dream_scheduler
from src.agents.dreamer.dream_scheduler import DreamScheduler, set_dream_scheduler
from src.schemas import DreamType
from src.utils.work_unit import construct_work_unit_key

View File

@ -8,8 +8,8 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.deriver import enqueue
from src.deriver.enqueue import generate_queue_records
from src.agents.extractor import enqueue
from src.agents.extractor.enqueue import generate_queue_records
from src.models import Peer, QueueItem, Workspace
@ -962,7 +962,7 @@ class TestGetEffectiveObserveMeFunction:
def test_sender_missing_from_configuration_uses_default(self):
"""Test that missing sender uses default observe_me=True"""
from src.deriver.enqueue import get_effective_observe_me
from src.agents.extractor.enqueue import get_effective_observe_me
# Empty peer configuration dict simulates sender who left after sending message
peers_with_configuration: dict[str, list[dict[str, Any]]] = {}
@ -974,7 +974,7 @@ class TestGetEffectiveObserveMeFunction:
def test_sender_with_empty_configurations_uses_default(self):
"""Test that sender with empty peer and session configs uses default"""
from src.deriver.enqueue import get_effective_observe_me
from src.agents.extractor.enqueue import get_effective_observe_me
# Sender present but with empty configurations
peers_with_configuration: dict[str, list[dict[str, Any]]] = {
@ -988,7 +988,7 @@ class TestGetEffectiveObserveMeFunction:
def test_sender_with_peer_config_observe_me_false(self):
"""Test that peer config observe_me=False is respected"""
from src.deriver.enqueue import get_effective_observe_me
from src.agents.extractor.enqueue import get_effective_observe_me
peers_with_configuration = {
"sender": [{"observe_me": False}, {}] # Peer config with observe_me=False
@ -1000,7 +1000,7 @@ class TestGetEffectiveObserveMeFunction:
def test_session_config_overrides_peer_config(self):
"""Test that session peer config takes precedence over peer config"""
from src.deriver.enqueue import get_effective_observe_me
from src.agents.extractor.enqueue import get_effective_observe_me
peers_with_configuration = {
"sender": [
@ -1015,7 +1015,7 @@ class TestGetEffectiveObserveMeFunction:
def test_session_config_none_falls_back_to_peer_config(self):
"""Test that session config with None observe_me falls back to peer config"""
from src.deriver.enqueue import get_effective_observe_me
from src.agents.extractor.enqueue import get_effective_observe_me
peers_with_configuration = {
"sender": [
@ -1030,7 +1030,7 @@ class TestGetEffectiveObserveMeFunction:
def test_mixed_configurations_with_active_status(self):
"""Test various configuration combinations with active status"""
from src.deriver.enqueue import get_effective_observe_me
from src.agents.extractor.enqueue import get_effective_observe_me
# Test cases: (peer_config, session_config, expected_result)
test_cases: list[tuple[dict[str, Any] | None, dict[str, Any] | None, bool]] = [

View File

@ -199,7 +199,7 @@ class TestDeriverIngestionMetrics:
metric_checker: MetricDeltaChecker,
):
"""Verify OUTPUT_TOTAL tokens match response.output_tokens from LLM."""
from src.deriver.deriver import process_representation_tasks_batch
from src.agents.extractor.deriver import process_representation_tasks_batch
workspace, peer = sample_data
session = await create_test_session_with_peer(db_session, workspace, peer)
@ -255,8 +255,8 @@ class TestDeriverIngestionMetrics:
metric_checker: MetricDeltaChecker,
):
"""Verify PROMPT component is tracked for ingestion input."""
from src.deriver.deriver import process_representation_tasks_batch
from src.deriver.prompts import estimate_minimal_deriver_prompt_tokens
from src.agents.extractor.deriver import process_representation_tasks_batch
from src.agents.extractor.prompts import estimate_minimal_deriver_prompt_tokens
workspace, peer = sample_data
session = await create_test_session_with_peer(db_session, workspace, peer)
@ -309,7 +309,7 @@ class TestDeriverIngestionMetrics:
metric_checker: MetricDeltaChecker,
):
"""Verify MESSAGES component is tracked for ingestion input."""
from src.deriver.deriver import process_representation_tasks_batch
from src.agents.extractor.deriver import process_representation_tasks_batch
workspace, peer = sample_data
session = await create_test_session_with_peer(db_session, workspace, peer)
@ -648,7 +648,7 @@ class TestDialecticTokenMetrics:
metric_checker: MetricDeltaChecker,
):
"""Verify INPUT tokens are tracked from LLM response."""
from src.dialectic.core import DialecticAgent
from src.agents.dialectic.core import DialecticAgent
workspace, peer = sample_data
session = await create_test_session_with_peer(db_session, workspace, peer)
@ -695,7 +695,7 @@ class TestDialecticTokenMetrics:
metric_checker: MetricDeltaChecker,
):
"""Verify OUTPUT tokens are tracked from LLM response."""
from src.dialectic.core import DialecticAgent
from src.agents.dialectic.core import DialecticAgent
workspace, peer = sample_data
session = await create_test_session_with_peer(db_session, workspace, peer)
@ -743,7 +743,7 @@ class TestDialecticTokenMetrics:
monkeypatch: pytest.MonkeyPatch,
):
"""Verify metrics are NOT emitted when METRICS_ENABLED=False."""
from src.dialectic.core import DialecticAgent
from src.agents.dialectic.core import DialecticAgent
# Explicitly disable metrics
monkeypatch.setattr("src.prometheus.METRICS_ENABLED", False)

190
tests/test_base_agent.py Normal file
View File

@ -0,0 +1,190 @@
"""
Tests for BaseAgent implementation.
Test criteria:
- TC-0B.1: BaseAgent can be instantiated (with mock abstract methods)
- TC-0B.2: BaseAgent methods work correctly (run, pre_execute, post_execute, trace_execution)
"""
import pytest
from typing import Any, Dict
from unittest.mock import MagicMock, AsyncMock
from src.agents.shared import BaseAgent
class TestAgent(BaseAgent):
"""Concrete implementation of BaseAgent for testing."""
def __init__(self, db, config=None, **kwargs):
super().__init__(db, config, **kwargs)
self.execution_log = []
async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
"""Mock execute implementation."""
self.execution_log.append("execute")
return {
"result": "success",
"processed": input_data.get("data", ""),
}
def validate_input(self, input_data: Dict[str, Any]) -> bool:
"""Mock validate_input implementation."""
self.execution_log.append("validate_input")
return "data" in input_data
class TestBaseAgent:
"""Test suite for BaseAgent functionality."""
@pytest.fixture
def mock_db(self):
"""Create mock database session."""
db = MagicMock()
return db
@pytest.fixture
def test_agent(self, mock_db):
"""Create TestAgent instance."""
return TestAgent(db=mock_db, config=None)
def test_agent_initialization(self, mock_db):
"""TC-0B.1: BaseAgent can be instantiated."""
agent = TestAgent(db=mock_db, config=None)
assert agent.db == mock_db
assert agent.config is None
assert agent.agent_type == "testagent"
assert hasattr(agent, "execution_log")
def test_agent_initialization_with_config(self, mock_db):
"""TC-0B.1: BaseAgent can be instantiated with config."""
from src.agents.shared import AgentConfig
config = AgentConfig(
model="gpt-4o-mini",
temperature=0.5,
timeout=30,
)
agent = TestAgent(db=mock_db, config=config)
assert agent.config == config
assert agent.config.model == "gpt-4o-mini"
assert agent.config.temperature == 0.5
def test_agent_initialization_with_kwargs(self, mock_db):
"""TC-0B.1: BaseAgent can be instantiated with additional kwargs."""
agent = TestAgent(
db=mock_db,
config=None,
custom_param="test_value",
another_param=42,
)
assert agent.custom_param == "test_value"
assert agent.another_param == 42
@pytest.mark.asyncio
async def test_execute_method(self, test_agent):
"""TC-0B.2: Execute method works correctly."""
input_data = {"data": "test input"}
output = await test_agent.execute(input_data)
assert output["result"] == "success"
assert output["processed"] == "test input"
assert "execute" in test_agent.execution_log
def test_validate_input_method(self, test_agent):
"""TC-0B.2: Validate input method works correctly."""
valid_input = {"data": "test"}
invalid_input = {"wrong_key": "test"}
assert test_agent.validate_input(valid_input) is True
assert test_agent.validate_input(invalid_input) is False
assert test_agent.execution_log.count("validate_input") == 2
@pytest.mark.asyncio
async def test_pre_execute_hook(self, test_agent):
"""TC-0B.2: Pre-execute hook validates input."""
valid_input = {"data": "test"}
result = await test_agent.pre_execute(valid_input)
assert result == valid_input
assert "validate_input" in test_agent.execution_log
@pytest.mark.asyncio
async def test_pre_execute_hook_invalid_input(self, test_agent):
"""TC-0B.2: Pre-execute hook raises error for invalid input."""
invalid_input = {"wrong_key": "test"}
with pytest.raises(ValueError, match="Invalid input for testagent agent"):
await test_agent.pre_execute(invalid_input)
@pytest.mark.asyncio
async def test_post_execute_hook(self, test_agent, mock_db):
"""TC-0B.2: Post-execute hook traces execution."""
input_data = {"data": "test"}
output = {"result": "success"}
result = await test_agent.post_execute(input_data, output)
assert result == output
@pytest.mark.asyncio
async def test_trace_execution(self, test_agent):
"""TC-0B.2: Trace execution logs correctly."""
input_data = {"data": "test"}
output = {"result": "success"}
metadata = {"execution_time": 1.5}
# Should not raise any errors
await test_agent.trace_execution(input_data, output, metadata)
@pytest.mark.asyncio
async def test_run_pipeline_success(self, test_agent):
"""TC-0B.2: Full run pipeline executes correctly."""
input_data = {"data": "test input"}
# Clear execution log
test_agent.execution_log = []
output = await test_agent.run(input_data)
assert output["result"] == "success"
assert output["processed"] == "test input"
# Verify execution order
assert "validate_input" in test_agent.execution_log
assert "execute" in test_agent.execution_log
assert test_agent.execution_log.index("validate_input") < test_agent.execution_log.index("execute")
@pytest.mark.asyncio
async def test_run_pipeline_validation_failure(self, test_agent):
"""TC-0B.2: Run pipeline fails on invalid input."""
invalid_input = {"wrong_key": "test"}
with pytest.raises(ValueError, match="Invalid input for testagent agent"):
await test_agent.run(invalid_input)
@pytest.mark.asyncio
async def test_run_pipeline_execution_failure(self, mock_db):
"""TC-0B.2: Run pipeline handles execution errors."""
class FailingAgent(BaseAgent):
async def execute(self, input_data):
raise RuntimeError("Execution failed")
def validate_input(self, input_data):
return True
agent = FailingAgent(db=mock_db)
with pytest.raises(RuntimeError, match="Execution failed"):
await agent.run({"data": "test"})
def test_agent_repr(self, test_agent):
"""Test agent string representation."""
repr_str = repr(test_agent)
assert "TestAgent" in repr_str
assert "testagent" in repr_str