refactor: clean up agent architecture and coordination loop

Squash merged refactoring of agent architecture.
This commit is contained in:
2026-06-29 22:44:06 +02:00
parent 7f66d09d9e
commit edc8415571
8 changed files with 760 additions and 812 deletions
+20 -11
View File
@@ -2,9 +2,10 @@ import asyncio
import logging import logging
import lmstudio as lms import lmstudio as lms
from typing import Any, Callable from typing import Any, Callable
from core.interfaces import Agent
from .prompt import CAVEMAN_PROMPT from .prompt import CAVEMAN_PROMPT
logger: logging.Logger = logging.getLogger("agent-caveman") logger: logging.Logger = logging.getLogger("agent-base")
class _ActResponseCapture: class _ActResponseCapture:
@@ -53,17 +54,17 @@ class _ActResponseCapture:
return '\n'.join(self.responses) if self.responses else "No response captured." return '\n'.join(self.responses) if self.responses else "No response captured."
class CavemanAgent: class BaseAgent(Agent):
"""Caveman AI agent - minimal token usage variant.""" """Base AI agent implementing common LMStudio interaction patterns."""
def __init__(self, model_name: str) -> None: def __init__(self, model_name: str) -> None:
self.model_name: str = model_name self.model_name: str = model_name
self.model: Any | None = None self.model: Any | None = None
self.system_prompt: str = CAVEMAN_PROMPT self.system_prompt: str = ""
async def initialize(self) -> None: async def initialize(self) -> None:
"""Initialize the LM Studio model.""" """Initialize the LM Studio model."""
logger.info(f"Initializing CavemanAgent with model: {self.model_name}") logger.info(f"Initializing agent with model: {self.model_name}")
self.model = lms.llm(self.model_name) self.model = lms.llm(self.model_name)
async def run(self, user_input: str) -> str: async def run(self, user_input: str) -> str:
@@ -78,12 +79,12 @@ class CavemanAgent:
] ]
try: try:
logger.info(f"Running CavemanAgent interactively (input length: {len(user_input)})") logger.info(f"Running agent interactively (input length: {len(user_input)})")
response = await self.model.respond(user_input, messages=messages) response = await self.model.respond(user_input, messages=messages)
logger.info(f"CavemanAgent responded successfully (response length: {len(response)})") logger.info(f"Agent responded successfully (response length: {len(response)})")
return response return response
except Exception as e: except Exception as e:
logger.error(f"CavemanAgent execution error: {e}") logger.error(f"Agent execution error: {e}")
return f"Error in agent execution: {str(e)}" return f"Error in agent execution: {str(e)}"
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str: async def run_with_tools(self, user_input: str, tools: list[Any]) -> str:
@@ -94,14 +95,22 @@ class CavemanAgent:
try: try:
capture = _ActResponseCapture() capture = _ActResponseCapture()
logger.info(f"Calling LMStudio act() on CavemanAgent with {len(tools)} tools...") logger.info(f"Calling LMStudio act() on agent with {len(tools)} tools...")
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture) result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
logger.info(f"act() on CavemanAgent returned: {result}") logger.info(f"act() on agent returned: {result}")
response: str = capture.full_response response: str = capture.full_response
if not response or response == "No response captured.": if not response or response == "No response captured.":
logger.warning(f"Act completed with {result.rounds} rounds but no response was captured.") logger.warning(f"Act completed with {result.rounds} rounds but no response was captured.")
return f"Act completed with {result.rounds} rounds but no response captured." return f"Act completed with {result.rounds} rounds but no response captured."
return response return response
except Exception as e: except Exception as e:
logger.error(f"CavemanAgent tool execution error: {e}") logger.error(f"Agent tool execution error: {e}")
return f"Error in agent tool execution: {str(e)}" return f"Error in agent tool execution: {str(e)}"
class CavemanAgent(BaseAgent):
"""Caveman AI agent - minimal token usage variant."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CAVEMAN_PROMPT
+5 -100
View File
@@ -1,108 +1,13 @@
import asyncio
import logging import logging
import lmstudio as lms from core.agent import BaseAgent
from typing import Any, Callable
from .prompt import CAVEMAN_PROMPT
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
logger: logging.Logger = logging.getLogger("agent-coding") logger: logging.Logger = logging.getLogger("agent-coding")
class _ActResponseCapture: class CodingAgent(BaseAgent):
"""Captures the AI response from LMStudio act() callback.""" """AI agent that interacts with LMStudio models and tools for coding tasks."""
def __init__(self) -> None:
self.responses: list[str] = []
def __call__(self, message: Any) -> None:
content: str = ""
if hasattr(message, 'content'):
content = message.content
elif hasattr(message, 'text'):
content = message.text
elif hasattr(message, 'response'):
content = message.response
elif hasattr(message, 'message'):
content = message.message
else:
return
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text: str = item.get('text', '')
if isinstance(text, list):
parts.extend([str(t) for t in text])
else:
parts.append(str(text))
elif isinstance(item, str):
parts.append(item)
elif hasattr(item, 'text'):
parts.append(str(item.text))
elif hasattr(item, 'content'):
parts.append(str(item.content))
content = ''.join(parts)
elif not isinstance(content, str):
content = str(content)
if content.strip():
self.responses.append(content.strip())
@property
def full_response(self) -> str:
return '\n'.join(self.responses) if self.responses else "No response captured."
class CodingAgent:
"""AI agent that interacts with LMStudio models and tools."""
def __init__(self, model_name: str) -> None: def __init__(self, model_name: str) -> None:
self.model_name: str = model_name super().__init__(model_name)
self.model: Any | None = None self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
self.system_prompt: str = CODING_AGENT_SYSTEM_PROMPT
async def initialize(self) -> None:
"""Initialize the LM Studio model."""
logger.info(f"Initializing CodingAgent with model: {self.model_name}")
self.model = lms.llm(self.model_name)
async def run(self, user_input: str) -> str:
"""Run a single interaction with the agent."""
if self.model is None:
await self.initialize()
assert self.model is not None
messages: list[dict[str, str]] = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": user_input},
]
try:
logger.info(f"Running CodingAgent interactively (input length: {len(user_input)})")
response = await self.model.respond(user_input, messages=messages)
logger.info(f"CodingAgent responded successfully (response length: {len(response)})")
return response
except Exception as e:
logger.error(f"CodingAgent execution error: {e}")
return f"Error in agent execution: {str(e)}"
async def run_with_tools(self, user_input: str, tools: list[Callable[..., Any]]) -> str:
"""Run the agent with tool calling capability."""
if self.model is None:
await self.initialize()
assert self.model is not None
try:
capture = _ActResponseCapture()
logger.info(f"Calling LMStudio act() on CodingAgent with {len(tools)} tools...")
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
logger.info(f"act() on CodingAgent returned: {result}")
response: str = capture.full_response
if not response or response == "No response captured.":
logger.warning(f"Act completed with {result.rounds} rounds but no response was captured.")
return f"Act completed with {result.rounds} rounds but no response captured."
return response
except Exception as e:
logger.error(f"CodingAgent tool execution error: {e}")
return f"Error in agent tool execution: {str(e)}"
+47
View File
@@ -0,0 +1,47 @@
import logging
from typing import Any, Callable
from core.agent import BaseAgent
from core.prompts import COORDINATOR_SYSTEM_PROMPT
from core.coordinator_tools import CoordinatorTools
logger: logging.Logger = logging.getLogger("agent-coordinator")
class CoordinatorNoToolCalledError(Exception):
"""Raised when the Coordinator Agent completes execution without calling any routing tool."""
pass
class CoordinatorAgent(BaseAgent):
"""AI agent that coordinates Gitea issues and decides the next action."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = COORDINATOR_SYSTEM_PROMPT
async def decide_action(
self,
mission: str,
planning_tools: list[Callable[..., Any]],
coord_tools: CoordinatorTools,
) -> str:
"""Run the Coordinator Agent and ensure a tool is called."""
coord_tools_list: list[Callable[..., Any]] = [
coord_tools.propose_plan,
coord_tools.start_implementation,
coord_tools.answer_question,
coord_tools.close_issue,
coord_tools.take_no_action,
]
combined_tools: list[Callable[..., Any]] = planning_tools + coord_tools_list
logger.info("Running CoordinatorAgent to decide action...")
response_text: str = await self.run_with_tools(mission, combined_tools)
if not coord_tools.tool_called:
logger.warning("CoordinatorAgent did not call any tools!")
raise CoordinatorNoToolCalledError(
"CoordinatorAgent failed to call a routing tool during execution."
)
return response_text
+557 -570
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -9,6 +9,8 @@ from core.interfaces import (
) )
from core.coding_agent import CodingAgent from core.coding_agent import CodingAgent
from core.agent import CavemanAgent from core.agent import CavemanAgent
from core.coordinator_agent import CoordinatorAgent
from core.planning_agent import PlanningAgent
from gitea.workspace import WorkspaceManager from gitea.workspace import WorkspaceManager
logger: logging.Logger = logging.getLogger("core-factory") logger: logging.Logger = logging.getLogger("core-factory")
@@ -65,6 +67,16 @@ class AgentFactory:
logger.info(f"Factory creating CavemanAgent with model: {model_name}") logger.info(f"Factory creating CavemanAgent with model: {model_name}")
return CavemanAgent(model_name) return CavemanAgent(model_name)
@staticmethod
def create_coordinator_agent(model_name: str) -> CoordinatorAgent:
logger.info(f"Factory creating CoordinatorAgent with model: {model_name}")
return CoordinatorAgent(model_name)
@staticmethod
def create_planning_agent(model_name: str) -> PlanningAgent:
logger.info(f"Factory creating PlanningAgent with model: {model_name}")
return PlanningAgent(model_name)
class WorkspaceFactory: class WorkspaceFactory:
"""Factory for creating workspace manager instances.""" """Factory for creating workspace manager instances."""
+13
View File
@@ -0,0 +1,13 @@
import logging
from core.agent import BaseAgent
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
logger: logging.Logger = logging.getLogger("agent-planning")
class PlanningAgent(BaseAgent):
"""AI agent that analyzes a PR/issue and builds an implementation plan."""
def __init__(self, model_name: str) -> None:
super().__init__(model_name)
self.system_prompt = CODING_AGENT_SYSTEM_PROMPT
+5 -4
View File
@@ -94,7 +94,8 @@ def test_run_verification_failure(tmp_path: Path) -> None:
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CodingAgent")
async def test_dispatch_planning_and_coding_phases(mock_agent_class: MagicMock) -> None: @patch("core.dispatcher.PlanningAgent")
async def test_dispatch_planning_and_coding_phases(mock_planning_class: MagicMock, mock_coding_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -117,14 +118,14 @@ async def test_dispatch_planning_and_coding_phases(mock_agent_class: MagicMock)
mock_client.get_pull_request_files.return_value = [] mock_client.get_pull_request_files.return_value = []
mock_client.get_pr_reviews.return_value = [] mock_client.get_pr_reviews.return_value = []
# Mock CodingAgent instances # Mock agent instances
mock_planning_agent = MagicMock() mock_planning_agent = MagicMock()
mock_planning_agent.run_with_tools = AsyncMock(return_value="Plan: Modify file A") mock_planning_agent.run_with_tools = AsyncMock(return_value="Plan: Modify file A")
mock_planning_class.return_value = mock_planning_agent
mock_coding_agent = MagicMock() mock_coding_agent = MagicMock()
mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created") mock_coding_agent.run_with_tools = AsyncMock(return_value="PR #1 Created")
mock_coding_class.return_value = mock_coding_agent
mock_agent_class.side_effect = [mock_planning_agent, mock_coding_agent]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
+57 -83
View File
@@ -38,8 +38,8 @@ async def test_dispatch_skips_issue_with_existing_pr() -> None:
mock_client.list_repo_pull_requests.assert_called_once_with("meeks", "repo1") mock_client.list_repo_pull_requests.assert_called_once_with("meeks", "repo1")
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_processes_issue_without_pr(mock_agent_class: MagicMock) -> None: async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -52,17 +52,14 @@ async def test_dispatch_processes_issue_without_pr(mock_agent_class: MagicMock)
mock_client.list_repo_pull_requests.return_value = [pr] mock_client.list_repo_pull_requests.return_value = [pr]
mock_client.get_issue_comments.return_value = [] mock_client.get_issue_comments.return_value = []
# Mock CodingAgent run_with_tools # Mock CoordinatorAgent invoking propose_plan tool
mock_agent_instance = MagicMock() async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json coord_tools.propose_plan(plan="- change X", issue_number=42)
{ return "Agent proposed plan."
"action": "PROPOSE_PLAN",
"reasoning": "Plan needs to be proposed first.", mock_coord_instance = MagicMock()
"comment_body": "### Proposed Plan\\n- change X", mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
"approved_plan": "" mock_coord_class.return_value = mock_coord_instance
}
```""")
mock_agent_class.return_value = mock_agent_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
@@ -260,8 +257,8 @@ def test_is_awaiting_reply_no_marker_not_detected() -> None:
assert dispatcher._is_awaiting_reply(comments) is False assert dispatcher._is_awaiting_reply(comments) is False
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_proposes_plan(mock_agent_class: MagicMock) -> None: async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -269,15 +266,12 @@ async def test_dispatch_proposes_plan(mock_agent_class: MagicMock) -> None:
mock_client.get_issue_comments.return_value = [] mock_client.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_agent_instance = MagicMock() async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
{ return "Agent proposed plan."
"action": "PROPOSE_PLAN", mock_coord_instance = MagicMock()
"reasoning": "We need to add a new endpoint.", mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
"comment_body": "### Proposed Plan\\n- Add endpoint\\n<!-- agent:plan-proposal -->\\n<!-- agent:awaiting-reply -->" mock_coord_class.return_value = mock_coord_instance
}
```""")
mock_agent_class.return_value = mock_agent_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem( work_item = WorkItem(
@@ -291,11 +285,11 @@ async def test_dispatch_proposes_plan(mock_agent_class: MagicMock) -> None:
results = await dispatcher.dispatch("meeks/repo1", [work_item]) results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1 assert len(results) == 1
assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0] assert "POSTED_COMMENT: PROPOSE_PLAN" in results[0]
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Plan\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->") mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "### Proposed Implementation Plan\n\n- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->")
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_answers_question(mock_agent_class: MagicMock) -> None: async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -303,15 +297,12 @@ async def test_dispatch_answers_question(mock_agent_class: MagicMock) -> None:
mock_client.get_issue_comments.return_value = [] mock_client.get_issue_comments.return_value = []
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_agent_instance = MagicMock() async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json coord_tools.answer_question(answer="X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->", issue_number=42)
{ return "Agent answered question."
"action": "ANSWER_QUESTION", mock_coord_instance = MagicMock()
"reasoning": "This is a question about how X works.", mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
"comment_body": "X works by doing Y.\\n<!-- agent:question-response -->\\n<!-- agent:awaiting-reply -->" mock_coord_class.return_value = mock_coord_instance
}
```""")
mock_agent_class.return_value = mock_agent_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem( work_item = WorkItem(
@@ -325,11 +316,11 @@ async def test_dispatch_answers_question(mock_agent_class: MagicMock) -> None:
results = await dispatcher.dispatch("meeks/repo1", [work_item]) results = await dispatcher.dispatch("meeks/repo1", [work_item])
assert len(results) == 1 assert len(results) == 1
assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0] assert "POSTED_COMMENT: ANSWER_QUESTION" in results[0]
mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->") mock_client.add_comment.assert_called_once_with("meeks", "repo1", 42, "X works by doing Y.\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->\n\nIs this answer satisfactory?\n<!-- agent:question-response -->\n<!-- agent:awaiting-reply -->")
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_closes_issue_on_satisfaction(mock_agent_class: MagicMock) -> None: async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -342,15 +333,12 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_agent_class: MagicMock
_make_comment("michael", "Yes, thanks! That makes sense.") _make_comment("michael", "Yes, thanks! That makes sense.")
] ]
mock_agent_instance = MagicMock() async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json coord_tools.close_issue(comment="Closing the issue now. Let me know if you need anything else!", issue_number=42)
{ return "Agent closed issue."
"action": "CLOSE_ISSUE", mock_coord_instance = MagicMock()
"reasoning": "User is satisfied.", mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
"comment_body": "Closing the issue now. Let me know if you need anything else!" mock_coord_class.return_value = mock_coord_instance
}
```""")
mock_agent_class.return_value = mock_agent_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem( work_item = WorkItem(
@@ -370,7 +358,8 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_agent_class: MagicMock
@patch("subprocess.run") @patch("subprocess.run")
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CodingAgent")
async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_agent_class: MagicMock, mock_run: MagicMock) -> None: @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -386,20 +375,13 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_agent_cla
mock_client.create_pull_request.return_value = mock_pr mock_client.create_pull_request.return_value = mock_pr
# Mock planning agent deciding EXECUTE_PLAN # Mock planning agent deciding EXECUTE_PLAN
mock_agent_instance1 = MagicMock() async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
mock_agent_instance1.run_with_tools = AsyncMock(return_value="""```json coord_tools.start_implementation(approved_plan="Step 1. Code X", issue_number=42)
{ return "Agent decided execute plan."
"action": "EXECUTE_PLAN", mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
"reasoning": "Plan was approved.",
"approved_plan": "Step 1. Code X"
}
```""")
# Mock coding agent executing plan # Mock coding agent executing plan
mock_agent_instance2 = MagicMock() mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
mock_agent_instance2.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
mock_agent_class.side_effect = [mock_agent_instance1, mock_agent_instance2]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem( work_item = WorkItem(
@@ -427,7 +409,8 @@ async def test_dispatch_executes_approved_plan_and_creates_wip_pr(mock_agent_cla
@patch("subprocess.run") @patch("subprocess.run")
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CodingAgent")
async def test_dispatch_resumes_wip_pr(mock_agent_class: MagicMock, mock_run: MagicMock) -> None: @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_resumes_wip_pr(mock_coord_class: MagicMock, mock_coding_class: MagicMock, mock_run: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -450,20 +433,13 @@ async def test_dispatch_resumes_wip_pr(mock_agent_class: MagicMock, mock_run: Ma
mock_client.get_pull_request_comments.return_value = [] mock_client.get_pull_request_comments.return_value = []
# Mock planning agent deciding EXECUTE_PLAN # Mock planning agent deciding EXECUTE_PLAN
mock_agent_instance1 = MagicMock() async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
mock_agent_instance1.run_with_tools = AsyncMock(return_value="""```json coord_tools.start_implementation(approved_plan="Step 1. Resume coding", issue_number=42)
{ return "Agent decided execute plan."
"action": "EXECUTE_PLAN", mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
"reasoning": "WIP PR exists, resume coding.",
"approved_plan": "Step 1. Resume coding"
}
```""")
# Mock coding agent executing plan # Mock coding agent executing plan
mock_agent_instance2 = MagicMock() mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
mock_agent_instance2.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
mock_agent_class.side_effect = [mock_agent_instance1, mock_agent_instance2]
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem( work_item = WorkItem(
@@ -528,8 +504,8 @@ def test_coordinator_tools_registration() -> None:
assert tools.arguments == {"approved_plan": "my approved plan", "issue_number": 42} assert tools.arguments == {"approved_plan": "my approved plan", "issue_number": 42}
@patch("core.dispatcher.CodingAgent") @patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_uses_coordinator_tool_calling(mock_agent_class: MagicMock) -> None: async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient) mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools) mock_tools: MagicMock = MagicMock(spec=GiteaTools)
@@ -538,15 +514,13 @@ async def test_dispatch_uses_coordinator_tool_calling(mock_agent_class: MagicMoc
mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai") mock_client.get_authenticated_user.return_value = UserModel(login="meeks-ai")
# Mock agent invoking propose_plan tool # Mock agent invoking propose_plan tool
async def mock_run_tools(mission: str, tools: list[any]) -> str: async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
for t in tools: coord_tools.propose_plan(plan="Step 1. Code X", issue_number=42)
if getattr(t, "__name__", "") == "propose_plan":
t(plan="Step 1. Code X", issue_number=42)
return "Agent finished turn after tool calling." return "Agent finished turn after tool calling."
mock_agent_instance = MagicMock() mock_coord_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(side_effect=mock_run_tools) mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_agent_class.return_value = mock_agent_instance mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools) dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem( work_item = WorkItem(