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
+5 -4
View File
@@ -94,7 +94,8 @@ def test_run_verification_failure(tmp_path: Path) -> None:
@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_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_pr_reviews.return_value = []
# Mock CodingAgent instances
# Mock agent instances
mock_planning_agent = MagicMock()
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.run_with_tools = AsyncMock(return_value="PR #1 Created")
mock_agent_class.side_effect = [mock_planning_agent, mock_coding_agent]
mock_coding_class.return_value = mock_coding_agent
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")
@patch("core.dispatcher.CodingAgent")
async def test_dispatch_processes_issue_without_pr(mock_agent_class: MagicMock) -> None:
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_processes_issue_without_pr(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
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.get_issue_comments.return_value = []
# Mock CodingAgent run_with_tools
mock_agent_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json
{
"action": "PROPOSE_PLAN",
"reasoning": "Plan needs to be proposed first.",
"comment_body": "### Proposed Plan\\n- change X",
"approved_plan": ""
}
```""")
mock_agent_class.return_value = mock_agent_instance
# Mock CoordinatorAgent invoking propose_plan tool
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.propose_plan(plan="- change X", issue_number=42)
return "Agent proposed plan."
mock_coord_instance = MagicMock()
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
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
@patch("core.dispatcher.CodingAgent")
async def test_dispatch_proposes_plan(mock_agent_class: MagicMock) -> None:
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_proposes_plan(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
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_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_agent_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json
{
"action": "PROPOSE_PLAN",
"reasoning": "We need to add a new endpoint.",
"comment_body": "### Proposed Plan\\n- Add endpoint\\n<!-- agent:plan-proposal -->\\n<!-- agent:awaiting-reply -->"
}
```""")
mock_agent_class.return_value = mock_agent_instance
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.propose_plan(plan="- Add endpoint\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->", issue_number=42)
return "Agent proposed plan."
mock_coord_instance = MagicMock()
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
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])
assert len(results) == 1
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")
async def test_dispatch_answers_question(mock_agent_class: MagicMock) -> None:
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_answers_question(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
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_authenticated_user.return_value = UserModel(login="meeks-ai")
mock_agent_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json
{
"action": "ANSWER_QUESTION",
"reasoning": "This is a question about how X works.",
"comment_body": "X works by doing Y.\\n<!-- agent:question-response -->\\n<!-- agent:awaiting-reply -->"
}
```""")
mock_agent_class.return_value = mock_agent_instance
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
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."
mock_coord_instance = MagicMock()
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
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])
assert len(results) == 1
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")
async def test_dispatch_closes_issue_on_satisfaction(mock_agent_class: MagicMock) -> None:
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_closes_issue_on_satisfaction(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
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.")
]
mock_agent_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(return_value="""```json
{
"action": "CLOSE_ISSUE",
"reasoning": "User is satisfied.",
"comment_body": "Closing the issue now. Let me know if you need anything else!"
}
```""")
mock_agent_class.return_value = mock_agent_instance
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.close_issue(comment="Closing the issue now. Let me know if you need anything else!", issue_number=42)
return "Agent closed issue."
mock_coord_instance = MagicMock()
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem(
@@ -370,7 +358,8 @@ async def test_dispatch_closes_issue_on_satisfaction(mock_agent_class: MagicMock
@patch("subprocess.run")
@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_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 planning agent deciding EXECUTE_PLAN
mock_agent_instance1 = MagicMock()
mock_agent_instance1.run_with_tools = AsyncMock(return_value="""```json
{
"action": "EXECUTE_PLAN",
"reasoning": "Plan was approved.",
"approved_plan": "Step 1. Code X"
}
```""")
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.start_implementation(approved_plan="Step 1. Code X", issue_number=42)
return "Agent decided execute plan."
mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
# Mock coding agent executing plan
mock_agent_instance2 = MagicMock()
mock_agent_instance2.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
mock_agent_class.side_effect = [mock_agent_instance1, mock_agent_instance2]
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Completed Successfully.")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
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("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_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 planning agent deciding EXECUTE_PLAN
mock_agent_instance1 = MagicMock()
mock_agent_instance1.run_with_tools = AsyncMock(return_value="""```json
{
"action": "EXECUTE_PLAN",
"reasoning": "WIP PR exists, resume coding.",
"approved_plan": "Step 1. Resume coding"
}
```""")
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.start_implementation(approved_plan="Step 1. Resume coding", issue_number=42)
return "Agent decided execute plan."
mock_coord_class.return_value.decide_action = AsyncMock(side_effect=mock_decide)
# Mock coding agent executing plan
mock_agent_instance2 = MagicMock()
mock_agent_instance2.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
mock_agent_class.side_effect = [mock_agent_instance1, mock_agent_instance2]
mock_coding_class.return_value.run_with_tools = AsyncMock(return_value="PR Updated Successfully.")
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem(
@@ -528,8 +504,8 @@ def test_coordinator_tools_registration() -> None:
assert tools.arguments == {"approved_plan": "my approved plan", "issue_number": 42}
@patch("core.dispatcher.CodingAgent")
async def test_dispatch_uses_coordinator_tool_calling(mock_agent_class: MagicMock) -> None:
@patch("core.dispatcher.CoordinatorAgent")
async def test_dispatch_uses_coordinator_tool_calling(mock_coord_class: MagicMock) -> None:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
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 agent invoking propose_plan tool
async def mock_run_tools(mission: str, tools: list[any]) -> str:
for t in tools:
if getattr(t, "__name__", "") == "propose_plan":
t(plan="Step 1. Code X", issue_number=42)
async def mock_decide(mission: str, planning_tools: list, coord_tools) -> str:
coord_tools.propose_plan(plan="Step 1. Code X", issue_number=42)
return "Agent finished turn after tool calling."
mock_agent_instance = MagicMock()
mock_agent_instance.run_with_tools = AsyncMock(side_effect=mock_run_tools)
mock_agent_class.return_value = mock_agent_instance
mock_coord_instance = MagicMock()
mock_coord_instance.decide_action = AsyncMock(side_effect=mock_decide)
mock_coord_class.return_value = mock_coord_instance
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem(