Feat/tool based coordinator routing (#5)

Co-authored-by: Michael <michael@example.com>
Reviewed-on: #5
This commit is contained in:
2026-06-29 21:17:42 +02:00
parent e38a80532b
commit 7f66d09d9e
10 changed files with 301 additions and 87 deletions
+11
View File
@@ -1,8 +1,11 @@
import asyncio
import logging
import lmstudio as lms
from typing import Any, Callable
from .prompt import CAVEMAN_PROMPT
logger: logging.Logger = logging.getLogger("agent-caveman")
class _ActResponseCapture:
"""Captures the AI response from LMStudio act() callback."""
@@ -60,6 +63,7 @@ class CavemanAgent:
async def initialize(self) -> None:
"""Initialize the LM Studio model."""
logger.info(f"Initializing CavemanAgent with model: {self.model_name}")
self.model = lms.llm(self.model_name)
async def run(self, user_input: str) -> str:
@@ -74,9 +78,12 @@ class CavemanAgent:
]
try:
logger.info(f"Running CavemanAgent interactively (input length: {len(user_input)})")
response = await self.model.respond(user_input, messages=messages)
logger.info(f"CavemanAgent responded successfully (response length: {len(response)})")
return response
except Exception as e:
logger.error(f"CavemanAgent execution error: {e}")
return f"Error in agent execution: {str(e)}"
async def run_with_tools(self, user_input: str, tools: list[Any]) -> str:
@@ -87,10 +94,14 @@ class CavemanAgent:
try:
capture = _ActResponseCapture()
logger.info(f"Calling LMStudio act() on CavemanAgent with {len(tools)} tools...")
result: lms.ActResult = self.model.act(user_input, tools=tools, on_message=capture)
logger.info(f"act() on CavemanAgent 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"CavemanAgent tool execution error: {e}")
return f"Error in agent tool execution: {str(e)}"
+10 -3
View File
@@ -5,6 +5,8 @@ from typing import Any, Callable
from .prompt import CAVEMAN_PROMPT
from .coding_prompt import CODING_AGENT_SYSTEM_PROMPT
logger: logging.Logger = logging.getLogger("agent-coding")
class _ActResponseCapture:
"""Captures the AI response from LMStudio act() callback."""
@@ -62,6 +64,7 @@ class CodingAgent:
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:
@@ -76,9 +79,12 @@ class CodingAgent:
]
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:
@@ -89,13 +95,14 @@ class CodingAgent:
try:
capture = _ActResponseCapture()
logger: logging.Logger = logging.getLogger("agent-coding")
logger.info(f"Calling LMStudio act() with {len(tools)} tools...")
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() returned: {result}")
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)}"
+80
View File
@@ -0,0 +1,80 @@
import logging
from typing import Any
logger: logging.Logger = logging.getLogger("coordinator-tools")
class CoordinatorTools:
"""Tools exposed to the Coordinator Agent for routing decisions."""
def __init__(self) -> None:
self.tool_called: bool = False
self.action: str = "NO_ACTION"
self.arguments: dict[str, Any] = {}
def propose_plan(self, plan: str, issue_number: int) -> str:
"""Propose a step-by-step implementation plan to resolve the issue.
Use this when a code change is needed but no plan has been proposed yet,
or a plan was proposed but the human replied with feedback/changes.
Args:
plan: The detailed implementation plan.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'propose_plan' called for issue #{issue_number}")
self.tool_called = True
self.action = "PROPOSE_PLAN"
self.arguments = {"plan": plan, "issue_number": issue_number}
return "Plan proposal recorded successfully."
def start_implementation(self, approved_plan: str, issue_number: int) -> str:
"""Enqueue/start implementation of the approved plan.
Use this ONLY if a plan was proposed and the human explicitly approved/greenlit it.
Args:
approved_plan: The plan that was approved, including any human feedback.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'start_implementation' called for issue #{issue_number}")
self.tool_called = True
self.action = "EXECUTE_PLAN"
self.arguments = {"approved_plan": approved_plan, "issue_number": issue_number}
return "Implementation start recorded successfully."
def answer_question(self, answer: str, issue_number: int) -> str:
"""Provide a clear, helpful response to a question or information request.
Use this if the issue is just a question (no code changes needed).
Args:
answer: The clear, helpful answer to the question.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'answer_question' called for issue #{issue_number}")
self.tool_called = True
self.action = "ANSWER_QUESTION"
self.arguments = {"answer": answer, "issue_number": issue_number}
return "Answer recorded successfully."
def close_issue(self, comment: str, issue_number: int) -> str:
"""Close the issue.
Use this if the human confirmed they are satisfied or gave approval to close.
Args:
comment: A polite final comment explaining the closing of the issue.
issue_number: The Gitea issue number.
"""
logger.info(f"Coordinator tool 'close_issue' called for issue #{issue_number}")
self.tool_called = True
self.action = "CLOSE_ISSUE"
self.arguments = {"comment": comment, "issue_number": issue_number}
return "Close issue action recorded successfully."
def take_no_action(self) -> str:
"""Take no action on the issue.
Use this if the issue is already resolved or cannot proceed.
"""
logger.info("Coordinator tool 'take_no_action' called")
self.tool_called = True
self.action = "NO_ACTION"
self.arguments = {}
return "No action recorded successfully."
+91 -79
View File
@@ -10,10 +10,11 @@ from gitea.tools.research_tools import ResearchTools
from gitea.tools.gitea_tools import GiteaTools
from gitea.client import GiteaClient
from core.coding_prompt import CODING_AGENT_SYSTEM_PROMPT
from core.coordinator_tools import CoordinatorTools
from core.prompts import COORDINATOR_SYSTEM_PROMPT
from gitea.config import AGENT_MODEL_ID
from gitea.workspace import WorkspaceManager
from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel
logger: logging.Logger = logging.getLogger("agent-dispatcher")
AGENT_USERNAMES: frozenset[str] = frozenset({"meeks-ai", "agent-bot"})
@@ -319,44 +320,6 @@ class AgentDispatcher:
f"PR Reviews:\n{reviews_str}"
)
STATE_ANALYSIS_SYSTEM_PROMPT = (
"You are an AI Coordinator. Your job is to analyze Gitea issues and pull requests, "
"read the conversation history, and determine the next action for the agent.\n\n"
"You must choose one of the following actions:\n"
"1. `PROPOSE_PLAN`: Choose this if code changes are needed to resolve the issue, and either:\n"
" - No plan has been proposed yet by the AI agent.\n"
" - Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.\n"
" You will write a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).\n"
" Your output must include a comment to post, starting with the plan and ending with a question asking if the plan is OK or if they have comments.\n"
" CRITICAL: The comment in the `comment_body` field must contain ONLY the implementation plan and the trailing confirmation question. DO NOT include your thought process, reasoning, research notes, or file analysis in the comment. Keep it concise, professional, and limited strictly to the plan itself. Any internal reasoning should be placed in the `reasoning` field of the JSON instead.\n"
" CRITICAL: The comment must contain the tags `<!-- agent:plan-proposal -->` and `<!-- agent:awaiting-reply -->` on separate lines at the very end of the comment.\n\n"
"2. `ANSWER_QUESTION`: Choose this if the issue is just a question or request for information (no code changes needed), and either:\n"
" - No answer has been provided yet by the AI agent.\n"
" - Or the agent answered, but the human replied with follow-up questions or clarifications.\n"
" You will formulate a clear, helpful answer to the question.\n"
" Your output must include a comment to post, starting with the answer and ending with a question asking if this was a good enough answer.\n"
" CRITICAL: The comment in the `comment_body` field must contain ONLY the actual answer to the question and the trailing confirmation question. DO NOT include your thought process, reasoning, research notes, or file analysis in the comment. Keep it clean, concise, helpful, and limited strictly to the answer itself. Any internal reasoning should be placed in the `reasoning` field of the JSON instead.\n"
" CRITICAL: The comment must contain the tags `<!-- agent:question-response -->` and `<!-- agent:awaiting-reply -->` on separate lines at the very end of the comment.\n\n"
"3. `EXECUTE_PLAN`: Choose this if:\n"
" - A plan was previously proposed (check the comment history) AND the human has clearly replied with approval/greenlight/go-ahead (e.g. 'yes', 'looks good', 'ok', 'go ahead', etc.).\n"
" - OR there is an existing WIP PR or a PR with requested changes, and we need to continue/resume implementing the changes.\n"
" You will extract or summarize the approved plan, incorporating any feedback the human gave in their approval/reviews.\n\n"
"4. `CLOSE_ISSUE`: Choose this if the AI agent previously answered a question (using `<!-- agent:question-response -->`) and the human has replied confirming they are satisfied or giving approval to close (e.g., 'yes', 'looks good', 'thanks', 'close it', etc.).\n"
" You will write a polite final comment to post on the issue.\n\n"
"5. `NO_ACTION`: Choose this if the issue/PR is already resolved, or if we cannot proceed for another reason.\n\n"
"You MUST respond ONLY with a JSON object inside a ```json markdown code block. Do not include other text.\n"
"CRITICAL: The `comment_body` field in the JSON must contain ONLY the implementation plan or the answer itself, and MUST NOT contain any thought process, reasoning, or internal details. Place all thought process and internal reasoning in the `reasoning` field.\n"
"Example:\n"
"```json\n"
"{\n"
" \"action\": \"PROPOSE_PLAN\",\n"
" \"reasoning\": \"No plan has been proposed yet. We need to implement ...\",\n"
" \"comment_body\": \"### Proposed Implementation Plan\\n1. Modify X\\n2. Run Y\\n\\nIs this ok for implementation?\\n<!-- agent:plan-proposal -->\\n<!-- agent:awaiting-reply -->\",\n"
" \"approved_plan\": \"\"\n"
"}\n"
"```"
)
state_analysis_mission = (
f"Analyzing issue #{item.task_number} in '{repo}'.\n\n"
f"Issue Title: {title}\n"
@@ -365,55 +328,103 @@ class AgentDispatcher:
f"Existing PR Details:\n{pr_info_str}\n"
)
# Run Coordinator Agent with CoordinatorTools
coord_tools = CoordinatorTools()
coord_tools_list = [
coord_tools.propose_plan,
coord_tools.start_implementation,
coord_tools.answer_question,
coord_tools.close_issue,
coord_tools.take_no_action,
]
combined_tools = planning_tools + coord_tools_list
logger.info(f"Analyzing conversation state for issue #{item.task_number}...")
planning_agent = CodingAgent(self._model_name)
planning_agent.system_prompt = STATE_ANALYSIS_SYSTEM_PROMPT
plan_response = await planning_agent.run_with_tools(state_analysis_mission, planning_tools)
logger.info(f"State analyzer returned: {plan_response}")
import json
decision = {}
json_match = re.search(r"```json\s*(.*?)\s*```", plan_response, re.DOTALL)
if json_match:
json_str = json_match.group(1).strip()
else:
json_str = plan_response.strip()
try:
decision = json.loads(json_str)
except Exception as e:
logger.error(f"Failed to parse planning agent decision JSON: {e}. Attempting manual extraction.")
planning_agent.system_prompt = COORDINATOR_SYSTEM_PROMPT
# Let the coordinator analyze and route
response_text = await planning_agent.run_with_tools(state_analysis_mission, combined_tools)
# Fallback if no tool was called
if not coord_tools.tool_called:
logger.info(f"Coordinator agent did not call any tools. Falling back to JSON text parsing.")
import json
decision = {}
json_match = re.search(r"```json\s*(.*?)\s*```", response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1).strip()
else:
json_str = response_text.strip()
try:
start_idx = json_str.find('{')
end_idx = json_str.rfind('}')
if start_idx != -1 and end_idx != -1:
decision = json.loads(json_str[start_idx:end_idx+1])
except Exception:
pass
decision = json.loads(json_str)
except Exception as e:
try:
start_idx = json_str.find('{')
end_idx = json_str.rfind('}')
if start_idx != -1 and end_idx != -1:
decision = json.loads(json_str[start_idx:end_idx+1])
except Exception:
pass
if decision and "action" in decision:
coord_tools.action = decision["action"]
if coord_tools.action == "PROPOSE_PLAN":
coord_tools.arguments = {
"comment_body": decision.get("comment_body", "") or decision.get("reasoning", ""),
"issue_number": item.task_number
}
elif coord_tools.action == "ANSWER_QUESTION":
coord_tools.arguments = {
"comment_body": decision.get("comment_body", "") or decision.get("reasoning", ""),
"issue_number": item.task_number
}
elif coord_tools.action == "CLOSE_ISSUE":
coord_tools.arguments = {
"comment": decision.get("comment_body", "Closing the issue as resolved."),
"issue_number": item.task_number
}
elif coord_tools.action == "EXECUTE_PLAN":
coord_tools.arguments = {
"approved_plan": decision.get("approved_plan", ""),
"issue_number": item.task_number
}
if not decision or "action" not in decision:
logger.info("Fallback: assuming PROPOSE_PLAN and using raw plan_response")
decision = {
"action": "PROPOSE_PLAN",
"comment_body": f"### Proposed Implementation Plan\n\n{plan_response}\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->",
"approved_plan": ""
}
action = coord_tools.action
logger.info(f"Coordinator Decided Action: {action} (tool_called={coord_tools.tool_called})")
action = decision.get("action", "NO_ACTION")
reasoning = decision.get("reasoning", "")
logger.info(f"Decided Action: {action}. Reasoning: {reasoning}")
if action in ("PROPOSE_PLAN", "ANSWER_QUESTION"):
comment_body = decision.get("comment_body", "")
if action == "PROPOSE_PLAN":
comment_body = coord_tools.arguments.get("comment_body", "")
if not comment_body:
comment_body = decision.get("reasoning", "No details provided.")
plan = coord_tools.arguments.get("plan", "")
comment_body = (
f"### Proposed Implementation Plan\n\n"
f"{plan}\n\n"
f"Is this plan ok for implementation or do you have any comments/changes?\n"
f"<!-- agent:plan-proposal -->\n"
f"<!-- agent:awaiting-reply -->"
)
self._client.add_comment(owner, repo_name, item.task_number, comment_body)
results.append(f"POSTED_COMMENT: {action} comment posted to issue #{item.task_number}.")
results.append(f"POSTED_COMMENT: PROPOSE_PLAN comment posted to issue #{item.task_number}.")
break
elif action == "ANSWER_QUESTION":
comment_body = coord_tools.arguments.get("comment_body", "")
if not comment_body:
answer = coord_tools.arguments.get("answer", "")
comment_body = (
f"{answer}\n\n"
f"Is this answer satisfactory?\n"
f"<!-- agent:question-response -->\n"
f"<!-- agent:awaiting-reply -->"
)
self._client.add_comment(owner, repo_name, item.task_number, comment_body)
results.append(f"POSTED_COMMENT: ANSWER_QUESTION comment posted to issue #{item.task_number}.")
break
elif action == "CLOSE_ISSUE":
comment_body = decision.get("comment_body", "Closing the issue as resolved.")
self._client.add_comment(owner, repo_name, item.task_number, comment_body)
comment = coord_tools.arguments.get("comment", "Closing the issue as resolved.")
self._client.add_comment(owner, repo_name, item.task_number, comment)
self._client.close_issue(owner, repo_name, item.task_number)
results.append(f"CLOSED_ISSUE: Issue #{item.task_number} closed.")
break
@@ -423,6 +434,7 @@ class AgentDispatcher:
break
elif action == "EXECUTE_PLAN":
approved_plan = coord_tools.arguments.get("approved_plan", "")
pr_to_use = existing_pr
branch_name = ""
@@ -472,7 +484,7 @@ class AgentDispatcher:
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
f"You are implementing changes for issue #{item.task_number} in repository '{repo}'.\n"
f"You are working on the existing Pull Request #{pr_to_use.number} on branch '{branch_name}'.\n\n"
f"--- APPROVED PLAN ---\n{decision.get('approved_plan', '')}\n--- APPROVED PLAN END ---\n\n"
f"--- APPROVED PLAN ---\n{approved_plan}\n--- APPROVED PLAN END ---\n\n"
f"Original Mission details:\n{base_mission}\n\n"
f"DIRECTIONS:\n"
f"1. Checkout the branch '{branch_name}' (it should already be checked out, or run `git checkout {branch_name}`).\n"
+6
View File
@@ -1,3 +1,4 @@
import logging
from gitea.client import GiteaClient
from core.interfaces import (
IssuesClient,
@@ -10,6 +11,8 @@ from core.coding_agent import CodingAgent
from core.agent import CavemanAgent
from gitea.workspace import WorkspaceManager
logger: logging.Logger = logging.getLogger("core-factory")
class GiteaClientFactory:
"""Factory for creating Gitea client components with dependency injection support."""
@@ -54,10 +57,12 @@ class AgentFactory:
@staticmethod
def create_coding_agent(model_name: str) -> CodingAgent:
logger.info(f"Factory creating CodingAgent with model: {model_name}")
return CodingAgent(model_name)
@staticmethod
def create_caveman_agent(model_name: str) -> CavemanAgent:
logger.info(f"Factory creating CavemanAgent with model: {model_name}")
return CavemanAgent(model_name)
@@ -66,4 +71,5 @@ class WorkspaceFactory:
@staticmethod
def create_workspace() -> WorkspaceManager:
logger.info("Factory creating WorkspaceManager")
return WorkspaceManager()
+31
View File
@@ -0,0 +1,31 @@
"""System prompts and configurations for agents."""
COORDINATOR_SYSTEM_PROMPT: str = """
You are an AI Coordinator. Your job is to analyze Gitea issues, read the conversation history, and determine the next action for the agent.
Based on the conversation state, you must choose and call exactly one of the following tools:
1. `propose_plan`: Choose this if code changes are needed to resolve the issue, and either:
- No plan has been proposed yet by the AI agent.
- Or a plan was proposed, but the human replied with feedback, corrections, or requests for changes, so we need to propose a revised plan.
You must provide a detailed, step-by-step implementation plan (listing files to modify/create, specific changes to make, verification/test commands).
2. `start_implementation`: Choose this if:
- A plan was previously proposed AND the human has clearly replied with approval/greenlight/go-ahead (e.g., "yes", "looks good", "ok", "go ahead", etc.).
- Or there is an existing WIP PR or a PR with requested changes, and we need to resume implementing the changes.
You must extract/summarize the approved plan, incorporating any human feedback.
3. `answer_question`: Choose this if the issue is just a question or request for information (no code changes needed), and either:
- No answer has been provided yet by the AI agent.
- Or the agent answered, but the human replied with follow-up questions/clarifications.
Provide a clear, helpful response.
4. `close_issue`: Choose this if the AI agent previously answered a question and the human has replied confirming they are satisfied or giving approval to close (e.g., "thanks", "looks good", "close it").
Provide a polite closing comment.
5. `take_no_action`: Choose this if the issue is already resolved, or if we cannot proceed for another reason.
CRITICAL INSTRUCTIONS:
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
- The `plan`, `answer`, or `comment` argument you pass to the tool will be posted directly to Gitea. DO NOT include your thought process, reasoning, or internal details in those arguments. Keep them concise and professional.
"""
+6
View File
@@ -1,7 +1,10 @@
import logging
from pydantic import BaseModel
from typing import Any
from gitea.models import IssueModel, PullRequestModel
logger: logging.Logger = logging.getLogger("work-queue")
class WorkItem(BaseModel):
repo_full_name: str
@@ -21,6 +24,7 @@ class WorkQueue:
def enqueue(self, item: WorkItem) -> None:
self._queue.append(item)
self._enqueued_repos.add(item.repo_full_name)
logger.info(f"Enqueued work item: {item.task_type} #{item.task_number} for {item.repo_full_name}")
def enqueue_batch(self, items: list[WorkItem]) -> None:
for item in items:
@@ -31,6 +35,7 @@ class WorkQueue:
items: list[WorkItem] = [
item for item in self._queue if item.repo_full_name == repo
]
logger.info(f"Retrieved {len(items)} work items for repository: {repo}")
return items
def remove_repo_work(self, repo: str) -> None:
@@ -39,6 +44,7 @@ class WorkQueue:
item for item in self._queue if item.repo_full_name != repo
]
self._enqueued_repos.discard(repo)
logger.info(f"Removed all work items for repository: {repo}")
def get_next_repo(self) -> str | None:
"""Get the next repo with work, or None if empty."""
+7 -4
View File
@@ -1,9 +1,12 @@
import os
import logging
import subprocess
from pathlib import Path
from urllib.parse import urlparse
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
logger: logging.Logger = logging.getLogger("gitea-workspace")
class WorkspaceManager:
"""Manages local workspace for Gitea repositories."""
@@ -29,7 +32,7 @@ class WorkspaceManager:
capture_output=True
)
except Exception as e:
print(f"Error unsetting global configs: {e}")
logger.error(f"Error unsetting global configs: {e}")
def _configure_repo_user(self, repo_path: Path) -> None:
try:
@@ -64,7 +67,7 @@ class WorkspaceManager:
check=True, capture_output=True
)
except Exception as e:
print(f"Error configuring local git user: {e}")
logger.error(f"Error configuring local git user: {e}")
def get_repo_path(self, repo_full_name: str) -> Path:
parts: list[str] = repo_full_name.split("/")
@@ -112,7 +115,7 @@ class WorkspaceManager:
check=True, capture_output=True,
)
except Exception as e:
print(f"Error during sanitization: {e}")
logger.error(f"Error during sanitization: {e}")
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
repo_path: Path = self.get_repo_path(repo_full_name)
@@ -125,7 +128,7 @@ class WorkspaceManager:
repo_path.rename(new_path)
return repo_path
print(f"Cloning repository {repo_full_name} to {repo_path}...")
logger.info(f"Cloning repository {repo_full_name} to {repo_path}...")
auth_url = self._get_authenticated_url(repo_full_name)
subprocess.run(["git", "clone", auth_url, str(repo_path)], check=True, capture_output=True)
self._configure_repo_user(repo_path)
+2 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import os
import time
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from dotenv import load_dotenv
@@ -33,7 +34,7 @@ class JSONFormatter(logging.Formatter):
return json.dumps(log_data)
file_handler = logging.FileHandler(LOG_FILE)
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=5)
file_handler.setFormatter(JSONFormatter())
stream_handler = logging.StreamHandler()
+57
View File
@@ -511,3 +511,60 @@ async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
assert len(results) == 1
assert "SKIP: Agent is not a requested reviewer" in results[0]
def test_coordinator_tools_registration() -> None:
from core.coordinator_tools import CoordinatorTools
tools: CoordinatorTools = CoordinatorTools()
assert not tools.tool_called
assert tools.action == "NO_ACTION"
tools.propose_plan(plan="my plan", issue_number=42)
assert tools.tool_called
assert tools.action == "PROPOSE_PLAN"
assert tools.arguments == {"plan": "my plan", "issue_number": 42}
tools.start_implementation(approved_plan="my approved plan", issue_number=42)
assert tools.action == "EXECUTE_PLAN"
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:
mock_client: MagicMock = MagicMock(spec=GiteaClient)
mock_tools: MagicMock = MagicMock(spec=GiteaTools)
mock_client.list_repo_pull_requests.return_value = []
mock_client.get_issue_comments.return_value = []
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)
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
dispatcher = AgentDispatcher(client=mock_client, tools=mock_tools)
work_item = WorkItem(
repo_full_name="meeks/repo1",
task_type="issue",
task_number=42,
task_info=IssueModel(number=42, title="add X", body=""),
priority=0
)
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 Implementation Plan\n\nStep 1. Code X\n\nIs this plan ok for implementation or do you have any comments/changes?\n<!-- agent:plan-proposal -->\n<!-- agent:awaiting-reply -->"
)