diff --git a/src/agents/AGENT_TEMPLATE.md b/src/agents/AGENT_TEMPLATE.md new file mode 100644 index 00000000..3ca749d8 --- /dev/null +++ b/src/agents/AGENT_TEMPLATE.md @@ -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 diff --git a/src/dialectic/__init__.py b/src/agents/dialectic/__init__.py similarity index 100% rename from src/dialectic/__init__.py rename to src/agents/dialectic/__init__.py diff --git a/src/dialectic/chat.py b/src/agents/dialectic/chat.py similarity index 98% rename from src/dialectic/chat.py rename to src/agents/dialectic/chat.py index e2783db8..ea4ba592 100644 --- a/src/dialectic/chat.py +++ b/src/agents/dialectic/chat.py @@ -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__) diff --git a/src/dialectic/core.py b/src/agents/dialectic/core.py similarity index 99% rename from src/dialectic/core.py rename to src/agents/dialectic/core.py index 6e8826a2..8357f986 100644 --- a/src/dialectic/core.py +++ b/src/agents/dialectic/core.py @@ -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, diff --git a/src/dialectic/prompts.py b/src/agents/dialectic/prompts.py similarity index 100% rename from src/dialectic/prompts.py rename to src/agents/dialectic/prompts.py diff --git a/src/dreamer/__init__.py b/src/agents/dreamer/__init__.py similarity index 100% rename from src/dreamer/__init__.py rename to src/agents/dreamer/__init__.py diff --git a/src/dreamer/dream_scheduler.py b/src/agents/dreamer/dream_scheduler.py similarity index 99% rename from src/dreamer/dream_scheduler.py rename to src/agents/dreamer/dream_scheduler.py index 1a339d21..987635f6 100644 --- a/src/dreamer/dream_scheduler.py +++ b/src/agents/dreamer/dream_scheduler.py @@ -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 diff --git a/src/dreamer/dreamer.py b/src/agents/dreamer/dreamer.py similarity index 96% rename from src/dreamer/dreamer.py rename to src/agents/dreamer/dreamer.py index af3e9689..917bd655 100644 --- a/src/dreamer/dreamer.py +++ b/src/agents/dreamer/dreamer.py @@ -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 diff --git a/src/dreamer/orchestrator.py b/src/agents/dreamer/orchestrator.py similarity index 97% rename from src/dreamer/orchestrator.py rename to src/agents/dreamer/orchestrator.py index d85d346d..f41d9002 100644 --- a/src/dreamer/orchestrator.py +++ b/src/agents/dreamer/orchestrator.py @@ -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, diff --git a/src/dreamer/specialists.py b/src/agents/dreamer/specialists.py similarity index 100% rename from src/dreamer/specialists.py rename to src/agents/dreamer/specialists.py diff --git a/src/dreamer/surprisal.py b/src/agents/dreamer/surprisal.py similarity index 99% rename from src/dreamer/surprisal.py rename to src/agents/dreamer/surprisal.py index faaf7c7e..7cfed627 100644 --- a/src/dreamer/surprisal.py +++ b/src/agents/dreamer/surprisal.py @@ -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__) diff --git a/src/dreamer/trees/__init__.py b/src/agents/dreamer/trees/__init__.py similarity index 100% rename from src/dreamer/trees/__init__.py rename to src/agents/dreamer/trees/__init__.py diff --git a/src/dreamer/trees/base.py b/src/agents/dreamer/trees/base.py similarity index 100% rename from src/dreamer/trees/base.py rename to src/agents/dreamer/trees/base.py diff --git a/src/dreamer/trees/covertree.py b/src/agents/dreamer/trees/covertree.py similarity index 100% rename from src/dreamer/trees/covertree.py rename to src/agents/dreamer/trees/covertree.py diff --git a/src/dreamer/trees/graph.py b/src/agents/dreamer/trees/graph.py similarity index 100% rename from src/dreamer/trees/graph.py rename to src/agents/dreamer/trees/graph.py diff --git a/src/dreamer/trees/lsh.py b/src/agents/dreamer/trees/lsh.py similarity index 100% rename from src/dreamer/trees/lsh.py rename to src/agents/dreamer/trees/lsh.py diff --git a/src/dreamer/trees/prototype.py b/src/agents/dreamer/trees/prototype.py similarity index 100% rename from src/dreamer/trees/prototype.py rename to src/agents/dreamer/trees/prototype.py diff --git a/src/dreamer/trees/rptree.py b/src/agents/dreamer/trees/rptree.py similarity index 100% rename from src/dreamer/trees/rptree.py rename to src/agents/dreamer/trees/rptree.py diff --git a/src/dreamer/trees/sklearn_wrapper.py b/src/agents/dreamer/trees/sklearn_wrapper.py similarity index 100% rename from src/dreamer/trees/sklearn_wrapper.py rename to src/agents/dreamer/trees/sklearn_wrapper.py diff --git a/src/deriver/__init__.py b/src/agents/extractor/__init__.py similarity index 100% rename from src/deriver/__init__.py rename to src/agents/extractor/__init__.py diff --git a/src/deriver/__main__.py b/src/agents/extractor/__main__.py similarity index 100% rename from src/deriver/__main__.py rename to src/agents/extractor/__main__.py diff --git a/src/deriver/consumer.py b/src/agents/extractor/consumer.py similarity index 98% rename from src/deriver/consumer.py rename to src/agents/extractor/consumer.py index 6d66f30e..b6677be2 100644 --- a/src/deriver/consumer.py +++ b/src/agents/extractor/consumer.py @@ -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 diff --git a/src/deriver/deriver.py b/src/agents/extractor/deriver.py similarity index 100% rename from src/deriver/deriver.py rename to src/agents/extractor/deriver.py diff --git a/src/deriver/enqueue.py b/src/agents/extractor/enqueue.py similarity index 99% rename from src/deriver/enqueue.py rename to src/agents/extractor/enqueue.py index b2476ea2..1546c738 100644 --- a/src/deriver/enqueue.py +++ b/src/agents/extractor/enqueue.py @@ -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 diff --git a/src/deriver/prompts.py b/src/agents/extractor/prompts.py similarity index 100% rename from src/deriver/prompts.py rename to src/agents/extractor/prompts.py diff --git a/src/deriver/queue_manager.py b/src/agents/extractor/queue_manager.py similarity index 99% rename from src/deriver/queue_manager.py rename to src/agents/extractor/queue_manager.py index 5288dfbc..623ef222 100644 --- a/src/deriver/queue_manager.py +++ b/src/agents/extractor/queue_manager.py @@ -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, diff --git a/src/agents/shared/__init__.py b/src/agents/shared/__init__.py new file mode 100644 index 00000000..1345f228 --- /dev/null +++ b/src/agents/shared/__init__.py @@ -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", +] diff --git a/src/agents/shared/base_agent.py b/src/agents/shared/base_agent.py new file mode 100644 index 00000000..c6021136 --- /dev/null +++ b/src/agents/shared/base_agent.py @@ -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}>" diff --git a/src/agents/shared/config.py b/src/agents/shared/config.py new file mode 100644 index 00000000..c84a478a --- /dev/null +++ b/src/agents/shared/config.py @@ -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}") diff --git a/src/agents/shared/prompts.py b/src/agents/shared/prompts.py new file mode 100644 index 00000000..677ea5e8 --- /dev/null +++ b/src/agents/shared/prompts.py @@ -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) diff --git a/src/agents/shared/tools.py b/src/agents/shared/tools.py new file mode 100644 index 00000000..4b0214c8 --- /dev/null +++ b/src/agents/shared/tools.py @@ -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, + } diff --git a/src/crud/representation.py b/src/crud/representation.py index f043a2ea..75a681cc 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -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 diff --git a/src/routers/messages.py b/src/routers/messages.py index cfebf424..f25211f1 100644 --- a/src/routers/messages.py +++ b/src/routers/messages.py @@ -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 diff --git a/src/routers/peers.py b/src/routers/peers.py index 4355a087..7419d9b0 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -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 diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 4e141837..593cf9af 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -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, diff --git a/src/routers/workspaces.py b/src/routers/workspaces.py index 535ab886..fbe2fb8f 100644 --- a/src/routers/workspaces.py +++ b/src/routers/workspaces.py @@ -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 diff --git a/tests/bench/obex.py b/tests/bench/obex.py index abd784e8..4fbf1c5e 100644 --- a/tests/bench/obex.py +++ b/tests/bench/obex.py @@ -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 diff --git a/tests/deriver/conftest.py b/tests/deriver/conftest.py index 8f7b1897..d51cbcf3 100644 --- a/tests/deriver/conftest.py +++ b/tests/deriver/conftest.py @@ -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) diff --git a/tests/deriver/test_queue_processing.py b/tests/deriver/test_queue_processing.py index 6cb3dc19..5b88ee62 100644 --- a/tests/deriver/test_queue_processing.py +++ b/tests/deriver/test_queue_processing.py @@ -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 diff --git a/tests/dreamer/test_dream_scheduler.py b/tests/dreamer/test_dream_scheduler.py index 06f904c1..104c900b 100644 --- a/tests/dreamer/test_dream_scheduler.py +++ b/tests/dreamer/test_dream_scheduler.py @@ -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 diff --git a/tests/integration/test_enqueue.py b/tests/integration/test_enqueue.py index bdfd3f5e..f9167603 100644 --- a/tests/integration/test_enqueue.py +++ b/tests/integration/test_enqueue.py @@ -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]] = [ diff --git a/tests/integration/test_token_metrics.py b/tests/integration/test_token_metrics.py index a1908c65..4de75278 100644 --- a/tests/integration/test_token_metrics.py +++ b/tests/integration/test_token_metrics.py @@ -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) diff --git a/tests/test_base_agent.py b/tests/test_base_agent.py new file mode 100644 index 00000000..9b0955b5 --- /dev/null +++ b/tests/test_base_agent.py @@ -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