Feat/tool based coordinator routing (#5)
Co-authored-by: Michael <michael@example.com> Reviewed-on: #5
This commit is contained in:
@@ -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
@@ -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)}"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
@@ -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,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()
|
||||
|
||||
@@ -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 -->"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user