diff --git a/plugins/automation/agents/__init__.py b/plugins/automation/agents/__init__.py index 6d3724e..360a6b0 100644 --- a/plugins/automation/agents/__init__.py +++ b/plugins/automation/agents/__init__.py @@ -1,13 +1,15 @@ """ AI Agents for Automation -Specialized agents for codebase exploration, code review, and other automated tasks. +Specialized agents for codebase exploration, code review, task decomposition, and other automated tasks. """ from .explore_agent import ExploreAgent, ExploreAgentConfig, explore_codebase +from .decompose_agent import DecomposeAgent __all__ = [ "ExploreAgent", "ExploreAgentConfig", "explore_codebase", + "DecomposeAgent", ] diff --git a/plugins/automation/agents/decompose_agent.py b/plugins/automation/agents/decompose_agent.py new file mode 100644 index 0000000..2cdb714 --- /dev/null +++ b/plugins/automation/agents/decompose_agent.py @@ -0,0 +1,205 @@ +""" +Decompose Agent + +A specialized agent for decomposing complex questions and tasks into parallelizable subtasks. +""" + +import logging +from typing import Optional + +from utils.agent import SpecializedAgent, AgentConfig + +logger = logging.getLogger(__name__) + + +class DecomposeAgent(SpecializedAgent): + """ + Specialized agent for task decomposition. + + This agent is designed to: + - Analyze complex questions and tasks + - Break them down into focused, independent subtopics + - Create parallelizable exploration tasks + - Provide reasoning for decomposition strategy + """ + + def __init__(self, config: AgentConfig): + """ + Initialize the Decompose Agent. + + Args: + config: AgentConfig with decomposition-specific settings + """ + super().__init__(config) + + def get_system_prompt(self) -> str: + """ + Get the system prompt for the Decompose Agent. + + Returns: + System prompt that defines the agent's task decomposition behavior + """ + if self.config.include_session_in_prompt: + return self.get_default_system_prompt( + agent_role="You are an expert task planner specialized in decomposing complex questions into parallelizable subtasks.", + custom_instructions=self._get_decomposition_instructions() + ) + + return """# Task Decomposition Agent + +## Role +You are an expert task planner specialized in decomposing complex questions into parallelizable subtasks. + +Your role is to analyze questions and break them down into focused, independently explorable subtopics that can be investigated in parallel by different agents. + +## Capabilities +You excel at: +1. **Question Analysis**: Understanding the core intent and scope of complex questions +2. **Subtopic Identification**: Breaking questions into logical, independent components +3. **Task Formulation**: Creating clear, actionable exploration tasks for each subtopic +4. **Prioritization**: Ordering subtopics by importance and logical progression +5. **Reasoning**: Explaining decomposition strategy and rationale + +## Decomposition Guidelines + +### 1. Subtopic Quality +- Each subtopic should be **focused** and **independently explorable** +- Avoid overlapping or redundant subtopics +- Ensure subtopics cover different aspects of the main question +- Make subtopics specific enough to be actionable + +### 2. Parallelization +- Design subtopics that can be investigated simultaneously +- Minimize dependencies between subtopics +- Allow different agents to work on different subtopics without conflicts + +### 3. Exploration Tasks +- Write clear, focused questions for each subtopic +- Make tasks directly investigable by exploration agents +- Include context about what information to look for +- Specify expected findings to guide exploration + +### 4. Count and Balance +- Create an appropriate number of subtopics (typically 2-6) +- Balance breadth (covering all aspects) with depth (keeping focus) +- Consider the complexity of the original question +- Don't over-decompose simple questions + +### 5. Importance Levels +- **High**: Critical to answering the main question +- **Medium**: Important but supporting information +- **Low**: Nice to have, provides additional context + +## Response Format +Always respond with a JSON object following this structure: +```json +{ + "reasoning": "Brief explanation of decomposition strategy", + "subtopic_count": , + "subtopics": [ + { + "id": "subtopic_1", + "title": "Brief title", + "exploration_task": "Specific question for exploration", + "importance": "high|medium|low", + "expected_findings": "What this subtopic should reveal" + } + ] +} +``` + +## Quality Criteria +Good decompositions: +- ✓ Cover all aspects of the original question +- ✓ Create independent, parallelizable subtopics +- ✓ Provide clear exploration tasks +- ✓ Balance specificity with scope +- ✓ Order subtopics logically + +Poor decompositions: +- ✗ Overlapping or redundant subtopics +- ✗ Subtopics that depend on each other +- ✗ Vague or ambiguous exploration tasks +- ✗ Too many or too few subtopics +- ✗ Random ordering without logic +""" + + def _get_decomposition_instructions(self) -> str: + """ + Get decomposition-specific instructions for the system prompt. + + Returns: + Custom instructions for task decomposition + """ + return """ +## Decomposition Guidelines +1. Analyze the question's scope and complexity +2. Identify independent, parallelizable aspects +3. Create focused exploration tasks for each subtopic +4. Assign importance levels (high/medium/low) +5. Order subtopics logically by importance + +## Response Format +Always return a JSON object with: +- reasoning: Explanation of your decomposition strategy +- subtopic_count: Number of subtopics created +- subtopics: Array of subtopic objects with id, title, exploration_task, importance, and expected_findings +""" + + async def decompose( + self, + question: str, + min_subtopics: int = 2, + max_subtopics: int = 6, + ) -> dict: + """ + Decompose a question into parallelizable subtopics. + + Args: + question: The main question or task to decompose + min_subtopics: Minimum number of subtopics to create + max_subtopics: Maximum number of subtopics to create + + Returns: + Dictionary with decomposition results including subtopics and reasoning + """ + prompt = f"""Analyze the following question and decompose it into specific subtopics that can be explored in parallel. + +Question: +{question} + +Guidelines: +- Create between {min_subtopics} and {max_subtopics} subtopics +- Each subtopic should be focused and independently explorable +- Subtopics should cover different aspects of the main question +- Avoid overlapping or redundant subtopics +- Order subtopics by importance/logical progression + +Respond with a JSON object: +{{ + "reasoning": "Brief explanation of your decomposition strategy and why you chose this number of subtopics", + "subtopic_count": , + "subtopics": [ + {{ + "id": "subtopic_1", + "title": "Brief title", + "exploration_task": "Specific question or task for the exploration agent", + "importance": "high|medium|low", + "expected_findings": "What kind of information this subtopic should reveal" + }}, + ... + ] +}} + +Make each exploration_task a clear, focused question that an agent can directly investigate.""" + + return await self.invoke(prompt, include_history=False) + + def __repr__(self) -> str: + """String representation of the Decompose Agent""" + return ( + f"DecomposeAgent(" + f"cli_type='{self.config.cli_type.value}', " + f"model='{self._executor.config.get_default_model()}', " + f"session_id='{self._get_session_id()}')" + ) diff --git a/plugins/automation/tests/test_decompose_agent.py b/plugins/automation/tests/test_decompose_agent.py new file mode 100644 index 0000000..efff91e --- /dev/null +++ b/plugins/automation/tests/test_decompose_agent.py @@ -0,0 +1,521 @@ +""" +Tests for DecomposeAgent +""" + +import pytest +import json +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from plugins.automation.agents.decompose_agent import DecomposeAgent +from utils.agent import AgentConfig, CLIType + + +class TestDecomposeAgent: + """Tests for DecomposeAgent""" + + def test_init(self): + """Test decompose agent initialization""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + assert agent.config == config + assert agent._executor is not None + + def test_init_with_session_config(self): + """Test initialization with session configuration""" + config = AgentConfig( + cli_type=CLIType.CLAUDE, + session_id="decompose-session", + session_storage_path=Path("/tmp/sessions"), + include_session_in_prompt=True + ) + agent = DecomposeAgent(config) + + assert agent.config.session_id == "decompose-session" + assert agent.config.session_storage_path == Path("/tmp/sessions") + assert agent.config.include_session_in_prompt is True + + def test_get_system_prompt(self): + """Test getting system prompt""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + prompt = agent.get_system_prompt() + + assert "Task Decomposition Agent" in prompt + assert "Role" in prompt + assert "parallelizable subtasks" in prompt + assert "Capabilities" in prompt + assert "Decomposition Guidelines" in prompt + assert "Response Format" in prompt + assert "JSON" in prompt + + def test_get_system_prompt_with_session(self): + """Test getting system prompt with session context""" + config = AgentConfig( + cli_type=CLIType.CLAUDE, + include_session_in_prompt=True, + session_id="test-session" + ) + agent = DecomposeAgent(config) + + prompt = agent.get_system_prompt() + + # Should use default system prompt with session context + assert "Session" in prompt + assert "test-session" in prompt + assert "Decomposition Guidelines" in prompt + + def test_get_decomposition_instructions(self): + """Test decomposition-specific instructions""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + instructions = agent._get_decomposition_instructions() + + assert "Decomposition Guidelines" in instructions + assert "JSON" in instructions + assert "subtopics" in instructions + assert "importance" in instructions + + @pytest.mark.asyncio + async def test_decompose_basic(self): + """Test basic decompose method""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": "Breaking down into authentication, authorization, and session management", + "subtopic_count": 3, + "subtopics": [ + { + "id": "subtopic_1", + "title": "Authentication Flow", + "exploration_task": "How does user authentication work?", + "importance": "high", + "expected_findings": "Authentication mechanism details" + }, + { + "id": "subtopic_2", + "title": "Authorization", + "exploration_task": "How are permissions checked?", + "importance": "medium", + "expected_findings": "Permission system details" + }, + { + "id": "subtopic_3", + "title": "Session Management", + "exploration_task": "How are sessions managed?", + "importance": "medium", + "expected_findings": "Session storage and lifecycle" + } + ] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + result = await agent.decompose( + question="How does the authentication system work?", + min_subtopics=2, + max_subtopics=5 + ) + + assert result == mock_response + assert mock_execute.called + + # Verify the prompt includes the question and constraints + called_prompt = mock_execute.call_args[0][0] + assert "How does the authentication system work?" in called_prompt + assert "between 2 and 5 subtopics" in called_prompt + assert "JSON" in called_prompt + + @pytest.mark.asyncio + async def test_decompose_with_default_limits(self): + """Test decompose with default min/max subtopics""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": "Test", + "subtopic_count": 3, + "subtopics": [] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + await agent.decompose(question="Test question") + + # Verify default limits + called_prompt = mock_execute.call_args[0][0] + assert "between 2 and 6 subtopics" in called_prompt + + @pytest.mark.asyncio + async def test_decompose_with_custom_limits(self): + """Test decompose with custom subtopic limits""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": "Test", + "subtopic_count": 4, + "subtopics": [] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + await agent.decompose( + question="Complex question", + min_subtopics=3, + max_subtopics=8 + ) + + called_prompt = mock_execute.call_args[0][0] + assert "between 3 and 8 subtopics" in called_prompt + + @pytest.mark.asyncio + async def test_decompose_includes_history_false(self): + """Test that decompose doesn't include history by default""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": "Test", + "subtopic_count": 2, + "subtopics": [] + }) + + with patch.object(agent, 'invoke', new_callable=AsyncMock) as mock_invoke: + mock_invoke.return_value = mock_response + + await agent.decompose(question="Test question") + + # Verify include_history=False was passed to invoke + call_kwargs = mock_invoke.call_args[1] + assert call_kwargs.get('include_history') is False + + @pytest.mark.asyncio + async def test_decompose_complex_question(self): + """Test decomposing a complex multi-faceted question""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + complex_question = """ + How does the MCP workflow system work, including: + - YAML configuration parsing + - Step execution and dependencies + - Agent integration + - Error handling + """ + + mock_response = json.dumps({ + "reasoning": "Breaking down into 4 main components", + "subtopic_count": 4, + "subtopics": [ + { + "id": "subtopic_1", + "title": "YAML Configuration", + "exploration_task": "How are workflow YAML files parsed?", + "importance": "high", + "expected_findings": "Configuration structure and validation" + }, + { + "id": "subtopic_2", + "title": "Step Execution", + "exploration_task": "How are workflow steps executed?", + "importance": "high", + "expected_findings": "Execution engine and dependency resolution" + }, + { + "id": "subtopic_3", + "title": "Agent Integration", + "exploration_task": "How are agents integrated into workflows?", + "importance": "medium", + "expected_findings": "Agent invocation and communication" + }, + { + "id": "subtopic_4", + "title": "Error Handling", + "exploration_task": "How are errors handled in workflows?", + "importance": "medium", + "expected_findings": "Error propagation and recovery" + } + ] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + result = await agent.decompose( + question=complex_question, + min_subtopics=3, + max_subtopics=6 + ) + + assert result == mock_response + assert mock_execute.called + + # Verify the full question is in the prompt + called_prompt = mock_execute.call_args[0][0] + assert "MCP workflow system" in called_prompt + assert "YAML configuration" in called_prompt + + @pytest.mark.asyncio + async def test_decompose_with_session_persistence(self): + """Test decompose with session persistence enabled""" + config = AgentConfig( + cli_type=CLIType.CLAUDE, + session_id="decompose-123", + session_storage_path=Path("/tmp/sessions"), + include_session_in_prompt=True + ) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": "Test", + "subtopic_count": 2, + "subtopics": [] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + await agent.decompose(question="Test question") + + # Session config should be available + assert agent.config.session_id == "decompose-123" + assert agent.config.session_storage_path == Path("/tmp/sessions") + + def test_repr(self): + """Test agent string representation""" + config = AgentConfig( + cli_type=CLIType.CLAUDE, + model="haiku", + session_id="decompose-456" + ) + agent = DecomposeAgent(config) + + repr_str = repr(agent) + + assert "DecomposeAgent" in repr_str + assert "cli_type='claude'" in repr_str + assert "model='haiku'" in repr_str + assert "session_id='decompose-456'" in repr_str + + @pytest.mark.asyncio + async def test_decompose_prompt_structure(self): + """Test that decompose creates well-structured prompts""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": "Test", + "subtopic_count": 2, + "subtopics": [] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + await agent.decompose( + question="Test question", + min_subtopics=2, + max_subtopics=4 + ) + + called_prompt = mock_execute.call_args[0][0] + + # Verify prompt structure + assert "Analyze the following question" in called_prompt + assert "Question:" in called_prompt + assert "Test question" in called_prompt + assert "Guidelines:" in called_prompt + assert "focused and independently explorable" in called_prompt + assert "Respond with a JSON object:" in called_prompt + assert "reasoning" in called_prompt + assert "subtopic_count" in called_prompt + assert "subtopics" in called_prompt + assert "exploration_task" in called_prompt + assert "importance" in called_prompt + assert "expected_findings" in called_prompt + + +class TestDecomposeAgentIntegration: + """Integration tests for DecomposeAgent""" + + @pytest.mark.asyncio + async def test_decompose_multiple_questions(self): + """Test decomposing multiple questions in sequence""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + responses = [ + json.dumps({ + "reasoning": "First decomposition", + "subtopic_count": 2, + "subtopics": [ + {"id": "s1", "title": "T1", "exploration_task": "E1", "importance": "high", "expected_findings": "F1"}, + {"id": "s2", "title": "T2", "exploration_task": "E2", "importance": "medium", "expected_findings": "F2"} + ] + }), + json.dumps({ + "reasoning": "Second decomposition", + "subtopic_count": 3, + "subtopics": [ + {"id": "s1", "title": "T1", "exploration_task": "E1", "importance": "high", "expected_findings": "F1"}, + {"id": "s2", "title": "T2", "exploration_task": "E2", "importance": "medium", "expected_findings": "F2"}, + {"id": "s3", "title": "T3", "exploration_task": "E3", "importance": "low", "expected_findings": "F3"} + ] + }) + ] + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = responses + + # First decomposition + r1 = await agent.decompose("How does authentication work?") + assert "First decomposition" in r1 + + # Second decomposition + r2 = await agent.decompose("How does the database layer work?") + assert "Second decomposition" in r2 + + # Both should have been called + assert mock_execute.call_count == 2 + + @pytest.mark.asyncio + async def test_decompose_with_different_models(self): + """Test decompose with different AI models""" + for cli_type in [CLIType.CLAUDE, CLIType.COPILOT]: + config = AgentConfig(cli_type=cli_type) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": f"Decomposition using {cli_type.value}", + "subtopic_count": 2, + "subtopics": [] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + result = await agent.decompose("Test question") + assert cli_type.value in result + + +class TestDecomposeAgentErrorHandling: + """Tests for error handling in DecomposeAgent""" + + @pytest.mark.asyncio + async def test_decompose_with_executor_error(self): + """Test decompose handles executor errors gracefully""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = Exception("Executor error") + + with pytest.raises(Exception) as exc_info: + await agent.decompose("Test question") + + assert "Executor error" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_decompose_with_empty_question(self): + """Test decompose with empty question string""" + config = AgentConfig(cli_type=CLIType.CLAUDE) + agent = DecomposeAgent(config) + + mock_response = json.dumps({ + "reasoning": "No question provided", + "subtopic_count": 0, + "subtopics": [] + }) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.return_value = mock_response + + result = await agent.decompose("") + + # Should still call executor with empty question + assert mock_execute.called + called_prompt = mock_execute.call_args[0][0] + assert "Question:" in called_prompt + + +class TestDecomposeAgentSessionManagement: + """Tests for session management in DecomposeAgent""" + + @pytest.mark.asyncio + async def test_session_id_propagation(self): + """Test that session_id is properly propagated""" + config = AgentConfig( + cli_type=CLIType.CLAUDE, + session_id="test-session-123" + ) + agent = DecomposeAgent(config) + + assert agent.config.session_id == "test-session-123" + assert agent._get_session_id() == "test-session-123" + + @pytest.mark.asyncio + async def test_session_storage_path_propagation(self): + """Test that session_storage_path is properly propagated""" + with tempfile.TemporaryDirectory() as tmpdir: + storage_path = Path(tmpdir) / "sessions" + config = AgentConfig( + cli_type=CLIType.CLAUDE, + session_storage_path=storage_path + ) + agent = DecomposeAgent(config) + + assert agent.config.session_storage_path == storage_path + + @pytest.mark.asyncio + async def test_include_session_in_prompt_affects_system_prompt(self): + """Test that include_session_in_prompt affects system prompt""" + # Without session in prompt + config_without = AgentConfig( + cli_type=CLIType.CLAUDE, + include_session_in_prompt=False + ) + agent_without = DecomposeAgent(config_without) + prompt_without = agent_without.get_system_prompt() + + # With session in prompt + config_with = AgentConfig( + cli_type=CLIType.CLAUDE, + include_session_in_prompt=True, + session_id="test-session" + ) + agent_with = DecomposeAgent(config_with) + prompt_with = agent_with.get_system_prompt() + + # Prompts should be different + assert prompt_without != prompt_with + # Session-enabled prompt should include session info + assert "Session" in prompt_with or "test-session" in prompt_with + + @pytest.mark.asyncio + async def test_unified_session_config(self): + """Test that all session config parameters work together""" + config = AgentConfig( + cli_type=CLIType.CLAUDE, + session_id="unified-session", + session_storage_path=Path("/tmp/unified"), + include_session_in_prompt=True + ) + agent = DecomposeAgent(config) + + # All session parameters should be set + assert agent.config.session_id == "unified-session" + assert agent.config.session_storage_path == Path("/tmp/unified") + assert agent.config.include_session_in_prompt is True + + # System prompt should reflect session config + prompt = agent.get_system_prompt() + assert "Session" in prompt or "unified-session" in prompt diff --git a/plugins/automation/tests/test_explore_agent.py b/plugins/automation/tests/test_explore_agent.py index 358d3d7..647da22 100644 --- a/plugins/automation/tests/test_explore_agent.py +++ b/plugins/automation/tests/test_explore_agent.py @@ -3,6 +3,7 @@ """ import pytest +import tempfile from unittest.mock import AsyncMock, patch from pathlib import Path @@ -438,3 +439,185 @@ async def test_different_exploration_methods(self): # All should have been executed assert mock_execute.call_count == 4 + + +class TestExploreAgentSessionManagement: + """Tests for session management in ExploreAgent""" + + def test_session_id_propagation(self): + """Test that session_id is properly propagated""" + config = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + session_id="explore-session-123" + ) + agent = ExploreAgent(config) + + assert agent.config.session_id == "explore-session-123" + assert agent._get_session_id() == "explore-session-123" + + def test_session_storage_path_propagation(self): + """Test that session_storage_path is properly propagated""" + with tempfile.TemporaryDirectory() as tmpdir: + storage_path = Path(tmpdir) / "explore" / "sessions" + config = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + session_storage_path=storage_path + ) + agent = ExploreAgent(config) + + assert agent.config.session_storage_path == storage_path + + def test_include_session_in_prompt_false(self): + """Test system prompt without session context""" + config = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + include_session_in_prompt=False + ) + agent = ExploreAgent(config) + + prompt = agent.get_system_prompt() + + # Should use basic system prompt without session context + assert "Codebase Exploration Agent" in prompt + # Should not include session-specific content + assert "Session ID:" not in prompt + + def test_include_session_in_prompt_true(self): + """Test system prompt with session context""" + config = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + include_session_in_prompt=True, + session_id="explore-with-context" + ) + agent = ExploreAgent(config) + + prompt = agent.get_system_prompt() + + # Should include session context + assert "Session" in prompt + assert "explore-with-context" in prompt + + def test_unified_session_config(self): + """Test that all session config parameters work together""" + with tempfile.TemporaryDirectory() as tmpdir: + storage_path = Path(tmpdir) / "unified" / "explore" + config = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + session_id="unified-explore-session", + session_storage_path=storage_path, + include_session_in_prompt=True + ) + agent = ExploreAgent(config) + + # All session parameters should be set + assert agent.config.session_id == "unified-explore-session" + assert agent.config.session_storage_path == storage_path + assert agent.config.include_session_in_prompt is True + + # System prompt should reflect session config + prompt = agent.get_system_prompt() + assert "Session" in prompt + assert "unified-explore-session" in prompt + + @pytest.mark.asyncio + async def test_session_persistence_across_calls(self): + """Test that session data persists across multiple calls""" + config = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + session_id="persistent-session" + ) + agent = ExploreAgent(config) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = ["First answer", "Second answer", "Third answer"] + + # Make multiple calls + await agent.explore("First question") + await agent.explore("Second question") + await agent.find_implementation("feature") + + # Session history should contain all interactions + history = agent.get_session_history() + assert len(history) == 6 # 3 user + 3 assistant + + # Verify session ID remains consistent + assert agent._get_session_id() == "persistent-session" + + @pytest.mark.asyncio + async def test_session_history_included_in_prompts(self): + """Test that session history is included in subsequent prompts""" + config = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + session_id="history-session" + ) + agent = ExploreAgent(config) + + with patch.object(agent._executor, 'execute', new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = ["Found in auth.py", "Uses JWT tokens"] + + # First call + await agent.explore("Where is authentication?") + + # Second call + await agent.explore("How does it work?") + + # The second call should include history from the first + second_call_prompt = mock_execute.call_args_list[1][0][0] + assert "Where is authentication?" in second_call_prompt + assert "Found in auth.py" in second_call_prompt + + def test_session_config_from_base_agent_config(self): + """Test that session config is preserved when converting from base AgentConfig""" + from utils.agent import AgentConfig + + with tempfile.TemporaryDirectory() as tmpdir: + storage_path = Path(tmpdir) / "base" / "storage" + base_config = AgentConfig( + cli_type=CLIType.CLAUDE, + session_id="base-session", + session_storage_path=storage_path, + include_session_in_prompt=True + ) + + agent = ExploreAgent(base_config) + + # Session config should be preserved + assert agent.config.session_id == "base-session" + assert agent.config.session_storage_path == storage_path + assert agent.config.include_session_in_prompt is True + + @pytest.mark.asyncio + async def test_different_sessions_are_isolated(self): + """Test that different session IDs maintain separate histories""" + config1 = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + session_id="session-1" + ) + agent1 = ExploreAgent(config1) + + config2 = ExploreAgentConfig( + cli_type=CLIType.CLAUDE, + session_id="session-2" + ) + agent2 = ExploreAgent(config2) + + with patch.object(agent1._executor, 'execute', new_callable=AsyncMock) as mock1: + with patch.object(agent2._executor, 'execute', new_callable=AsyncMock) as mock2: + mock1.return_value = "Answer from session 1" + mock2.return_value = "Answer from session 2" + + # Agent 1 interaction + await agent1.explore("Question for session 1") + + # Agent 2 interaction + await agent2.explore("Question for session 2") + + # Sessions should be separate + history1 = agent1.get_session_history() + history2 = agent2.get_session_history() + + assert len(history1) == 2 # 1 user + 1 assistant + assert len(history2) == 2 # 1 user + 1 assistant + + assert history1[0]["content"] == "Question for session 1" + assert history2[0]["content"] == "Question for session 2" diff --git a/plugins/automation/workflows/steps/operations/decompose.py b/plugins/automation/workflows/steps/operations/decompose.py index 03c2d6f..c57b931 100644 --- a/plugins/automation/workflows/steps/operations/decompose.py +++ b/plugins/automation/workflows/steps/operations/decompose.py @@ -6,8 +6,9 @@ from typing import Any, Dict, Optional, List from .base import BaseOperation -from utils.agent.agent import SpecializedAgent, AgentConfig +from utils.agent.agent import AgentConfig from utils.agent.cli_executor import CLIType +from plugins.automation.agents.decompose_agent import DecomposeAgent class DecomposeOperation(BaseOperation): @@ -24,6 +25,9 @@ class DecomposeOperation(BaseOperation): - max_subtopics: Maximum number of subtopics (default: 6) - cli_type: CLI type to use - "claude", "codex", or "copilot" (default: "copilot") - model: Optional AI model to use (depends on CLI type) + - session_id: Optional session ID for conversation tracking + - session_storage_path: Optional path to store session data + - include_session_in_prompt: Whether to include session context in prompts (default: False) Inputs: - question: The main question or task to decompose @@ -40,6 +44,8 @@ class DecomposeOperation(BaseOperation): min_subtopics: 2 max_subtopics: 5 cli_type: copilot + session_id: "workflow_123" + session_storage_path: "/path/to/sessions" inputs: question: "How does the MCP workflow system work?" # Result: 3-5 subtopics like: @@ -72,11 +78,15 @@ async def execute(self) -> Dict[str, Any]: max_subtopics = self.config.get("max_subtopics", 6) model = self.config.get("model") cli_type = self.config.get("cli_type", "copilot") + session_id = self.config.get("session_id") + session_storage_path = self.config.get("session_storage_path") + include_session_in_prompt = self.config.get("include_session_in_prompt", False) self.logger.info("=" * 60) self.logger.info("Starting task decomposition operation") self.logger.info(f"Question: {question[:100]}..." if len(question) > 100 else f"Question: {question}") self.logger.info(f"Config: cli_type={cli_type}, model={model}, subtopics={min_subtopics}-{max_subtopics}") + self.logger.info(f"Session: session_id={session_id}, storage_path={session_storage_path}") self.logger.info("=" * 60) # Create a specialized agent for task decomposition @@ -84,56 +94,23 @@ async def execute(self) -> Dict[str, Any]: agent_config = AgentConfig( cli_type=CLIType(cli_type), model=model, - session_id="decompose_operation", + session_id=session_id, + session_storage_path=session_storage_path, skip_permissions=True, + include_session_in_prompt=include_session_in_prompt, ) - # Create anonymous agent class inline - class DecomposeAgent(SpecializedAgent): - def get_system_prompt(self) -> str: - return """You are an expert task planner specialized in decomposing complex questions into parallelizable subtasks. - -Your role is to analyze questions and break them down into focused, independently explorable subtopics that can be investigated in parallel by different agents.""" - agent = DecomposeAgent(agent_config) self.logger.debug("Agent created successfully") - # Construct prompt for AI decomposition - prompt = f"""Analyze the following question and decompose it into specific subtopics that can be explored in parallel. - -Question: -{question} - -Guidelines: -- Create between {min_subtopics} and {max_subtopics} subtopics -- Each subtopic should be focused and independently explorable -- Subtopics should cover different aspects of the main question -- Avoid overlapping or redundant subtopics -- Order subtopics by importance/logical progression - -Respond with a JSON object: -{{ - "reasoning": "Brief explanation of your decomposition strategy and why you chose this number of subtopics", - "subtopic_count": , - "subtopics": [ - {{ - "id": "subtopic_1", - "title": "Brief title", - "exploration_task": "Specific question or task for the exploration agent", - "importance": "high|medium|low", - "expected_findings": "What kind of information this subtopic should reveal" - }}, - ... - ] -}} - -Make each exploration_task a clear, focused question that an agent can directly investigate.""" - self.logger.info(f"Invoking agent for task decomposition (cli_type={cli_type})") - self.logger.debug(f"Full prompt length: {len(prompt)} characters") - # Invoke the agent - response_text = await agent.invoke(prompt, include_history=False) + # Use the agent's decompose method + response_text = await agent.decompose( + question=question, + min_subtopics=min_subtopics, + max_subtopics=max_subtopics, + ) self.logger.info(f"Received response from agent ({len(response_text)} characters)") self.logger.debug(f"Response preview: {response_text[:200]}...") diff --git a/plugins/automation/workflows/steps/operations/exploration.py b/plugins/automation/workflows/steps/operations/exploration.py index 5f9deac..585bd1c 100644 --- a/plugins/automation/workflows/steps/operations/exploration.py +++ b/plugins/automation/workflows/steps/operations/exploration.py @@ -23,7 +23,11 @@ class ExplorationOperation(BaseOperation): - exploration_type: Type of exploration (question, implementation, structure, usage, flow) - session_dir: Directory to store session files (default: .mcp_sessions) - save_to_session: Whether to save findings to session (default: true) - - session_id: Optional session ID to use + - session_id: Optional session ID for conversation tracking + - session_storage_path: Optional path to store agent session data + - include_session_in_prompt: Whether to include session context in prompts (default: False) + - model: Optional model override for the agent + - working_directories: Optional list of working directories Inputs: - task: Task object from split operation (should contain item, question, or exploration details) @@ -164,12 +168,14 @@ async def _perform_exploration( from plugins.automation.agents import ExploreAgent, ExploreAgentConfig from utils.agent import CLIType - # Create agent configuration + # Create agent configuration with unified session management config = ExploreAgentConfig( cli_type=CLIType.CLAUDE, model=self.config.get("model"), # Optional model override cwd=codebase_path, session_id=self.config.get("session_id"), + session_storage_path=self.config.get("session_storage_path"), + include_session_in_prompt=self.config.get("include_session_in_prompt", False), working_directories=self.config.get("working_directories") )