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 asyncio
|
||||||
|
import logging
|
||||||
import lmstudio as lms
|
import lmstudio as lms
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from .prompt import CAVEMAN_PROMPT
|
from .prompt import CAVEMAN_PROMPT
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("agent-caveman")
|
||||||
|
|
||||||
|
|
||||||
class _ActResponseCapture:
|
class _ActResponseCapture:
|
||||||
"""Captures the AI response from LMStudio act() callback."""
|
"""Captures the AI response from LMStudio act() callback."""
|
||||||
@@ -60,6 +63,7 @@ class CavemanAgent:
|
|||||||
|
|
||||||
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}")
|
||||||
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:
|
||||||
@@ -74,9 +78,12 @@ class CavemanAgent:
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"Running CavemanAgent 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)})")
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"CavemanAgent 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:
|
||||||
@@ -87,10 +94,14 @@ class CavemanAgent:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
capture = _ActResponseCapture()
|
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)
|
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
|
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.")
|
||||||
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}")
|
||||||
return f"Error in agent tool execution: {str(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 .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")
|
||||||
|
|
||||||
|
|
||||||
class _ActResponseCapture:
|
class _ActResponseCapture:
|
||||||
"""Captures the AI response from LMStudio act() callback."""
|
"""Captures the AI response from LMStudio act() callback."""
|
||||||
@@ -62,6 +64,7 @@ class CodingAgent:
|
|||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Initialize the LM Studio model."""
|
"""Initialize the LM Studio model."""
|
||||||
|
logger.info(f"Initializing CodingAgent 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:
|
||||||
@@ -76,9 +79,12 @@ class CodingAgent:
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"Running CodingAgent 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"CodingAgent responded successfully (response length: {len(response)})")
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"CodingAgent 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[Callable[..., Any]]) -> str:
|
async def run_with_tools(self, user_input: str, tools: list[Callable[..., Any]]) -> str:
|
||||||
@@ -89,13 +95,14 @@ class CodingAgent:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
capture = _ActResponseCapture()
|
capture = _ActResponseCapture()
|
||||||
logger: logging.Logger = logging.getLogger("agent-coding")
|
logger.info(f"Calling LMStudio act() on CodingAgent with {len(tools)} tools...")
|
||||||
logger.info(f"Calling LMStudio act() 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() returned: {result}")
|
logger.info(f"act() on CodingAgent 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.")
|
||||||
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"CodingAgent tool execution error: {e}")
|
||||||
return f"Error in agent tool execution: {str(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."
|
||||||
+74
-62
@@ -10,10 +10,11 @@ from gitea.tools.research_tools import ResearchTools
|
|||||||
from gitea.tools.gitea_tools import GiteaTools
|
from gitea.tools.gitea_tools import GiteaTools
|
||||||
from gitea.client import GiteaClient
|
from gitea.client import GiteaClient
|
||||||
from core.coding_prompt import CODING_AGENT_SYSTEM_PROMPT
|
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.config import AGENT_MODEL_ID
|
||||||
from gitea.workspace import WorkspaceManager
|
from gitea.workspace import WorkspaceManager
|
||||||
from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel
|
from gitea.models import CommentModel, PullRequestFileModel, PullRequestModel, IssueModel
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent-dispatcher")
|
logger: logging.Logger = logging.getLogger("agent-dispatcher")
|
||||||
|
|
||||||
AGENT_USERNAMES: frozenset[str] = frozenset({"meeks-ai", "agent-bot"})
|
AGENT_USERNAMES: frozenset[str] = frozenset({"meeks-ai", "agent-bot"})
|
||||||
@@ -319,44 +320,6 @@ class AgentDispatcher:
|
|||||||
f"PR Reviews:\n{reviews_str}"
|
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 = (
|
state_analysis_mission = (
|
||||||
f"Analyzing issue #{item.task_number} in '{repo}'.\n\n"
|
f"Analyzing issue #{item.task_number} in '{repo}'.\n\n"
|
||||||
f"Issue Title: {title}\n"
|
f"Issue Title: {title}\n"
|
||||||
@@ -365,24 +328,37 @@ class AgentDispatcher:
|
|||||||
f"Existing PR Details:\n{pr_info_str}\n"
|
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}...")
|
logger.info(f"Analyzing conversation state for issue #{item.task_number}...")
|
||||||
planning_agent = CodingAgent(self._model_name)
|
planning_agent = CodingAgent(self._model_name)
|
||||||
planning_agent.system_prompt = STATE_ANALYSIS_SYSTEM_PROMPT
|
planning_agent.system_prompt = COORDINATOR_SYSTEM_PROMPT
|
||||||
plan_response = await planning_agent.run_with_tools(state_analysis_mission, planning_tools)
|
|
||||||
logger.info(f"State analyzer returned: {plan_response}")
|
|
||||||
|
|
||||||
|
# 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
|
import json
|
||||||
decision = {}
|
decision = {}
|
||||||
json_match = re.search(r"```json\s*(.*?)\s*```", plan_response, re.DOTALL)
|
json_match = re.search(r"```json\s*(.*?)\s*```", response_text, re.DOTALL)
|
||||||
if json_match:
|
if json_match:
|
||||||
json_str = json_match.group(1).strip()
|
json_str = json_match.group(1).strip()
|
||||||
else:
|
else:
|
||||||
json_str = plan_response.strip()
|
json_str = response_text.strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decision = json.loads(json_str)
|
decision = json.loads(json_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to parse planning agent decision JSON: {e}. Attempting manual extraction.")
|
|
||||||
try:
|
try:
|
||||||
start_idx = json_str.find('{')
|
start_idx = json_str.find('{')
|
||||||
end_idx = json_str.rfind('}')
|
end_idx = json_str.rfind('}')
|
||||||
@@ -391,29 +367,64 @@ class AgentDispatcher:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not decision or "action" not in decision:
|
if decision and "action" in decision:
|
||||||
logger.info("Fallback: assuming PROPOSE_PLAN and using raw plan_response")
|
coord_tools.action = decision["action"]
|
||||||
decision = {
|
if coord_tools.action == "PROPOSE_PLAN":
|
||||||
"action": "PROPOSE_PLAN",
|
coord_tools.arguments = {
|
||||||
"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 -->",
|
"comment_body": decision.get("comment_body", "") or decision.get("reasoning", ""),
|
||||||
"approved_plan": ""
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
action = decision.get("action", "NO_ACTION")
|
action = coord_tools.action
|
||||||
reasoning = decision.get("reasoning", "")
|
logger.info(f"Coordinator Decided Action: {action} (tool_called={coord_tools.tool_called})")
|
||||||
logger.info(f"Decided Action: {action}. Reasoning: {reasoning}")
|
|
||||||
|
|
||||||
if action in ("PROPOSE_PLAN", "ANSWER_QUESTION"):
|
if action == "PROPOSE_PLAN":
|
||||||
comment_body = decision.get("comment_body", "")
|
comment_body = coord_tools.arguments.get("comment_body", "")
|
||||||
if not 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)
|
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
|
break
|
||||||
|
|
||||||
elif action == "CLOSE_ISSUE":
|
elif action == "CLOSE_ISSUE":
|
||||||
comment_body = decision.get("comment_body", "Closing the issue as resolved.")
|
comment = coord_tools.arguments.get("comment", "Closing the issue as resolved.")
|
||||||
self._client.add_comment(owner, repo_name, item.task_number, comment_body)
|
self._client.add_comment(owner, repo_name, item.task_number, comment)
|
||||||
self._client.close_issue(owner, repo_name, item.task_number)
|
self._client.close_issue(owner, repo_name, item.task_number)
|
||||||
results.append(f"CLOSED_ISSUE: Issue #{item.task_number} closed.")
|
results.append(f"CLOSED_ISSUE: Issue #{item.task_number} closed.")
|
||||||
break
|
break
|
||||||
@@ -423,6 +434,7 @@ class AgentDispatcher:
|
|||||||
break
|
break
|
||||||
|
|
||||||
elif action == "EXECUTE_PLAN":
|
elif action == "EXECUTE_PLAN":
|
||||||
|
approved_plan = coord_tools.arguments.get("approved_plan", "")
|
||||||
pr_to_use = existing_pr
|
pr_to_use = existing_pr
|
||||||
branch_name = ""
|
branch_name = ""
|
||||||
|
|
||||||
@@ -472,7 +484,7 @@ class AgentDispatcher:
|
|||||||
f"PHASE 2: EXECUTION/CODING PHASE\n\n"
|
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 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"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"Original Mission details:\n{base_mission}\n\n"
|
||||||
f"DIRECTIONS:\n"
|
f"DIRECTIONS:\n"
|
||||||
f"1. Checkout the branch '{branch_name}' (it should already be checked out, or run `git checkout {branch_name}`).\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 gitea.client import GiteaClient
|
||||||
from core.interfaces import (
|
from core.interfaces import (
|
||||||
IssuesClient,
|
IssuesClient,
|
||||||
@@ -10,6 +11,8 @@ from core.coding_agent import CodingAgent
|
|||||||
from core.agent import CavemanAgent
|
from core.agent import CavemanAgent
|
||||||
from gitea.workspace import WorkspaceManager
|
from gitea.workspace import WorkspaceManager
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("core-factory")
|
||||||
|
|
||||||
|
|
||||||
class GiteaClientFactory:
|
class GiteaClientFactory:
|
||||||
"""Factory for creating Gitea client components with dependency injection support."""
|
"""Factory for creating Gitea client components with dependency injection support."""
|
||||||
@@ -54,10 +57,12 @@ class AgentFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_coding_agent(model_name: str) -> CodingAgent:
|
def create_coding_agent(model_name: str) -> CodingAgent:
|
||||||
|
logger.info(f"Factory creating CodingAgent with model: {model_name}")
|
||||||
return CodingAgent(model_name)
|
return CodingAgent(model_name)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_caveman_agent(model_name: str) -> CavemanAgent:
|
def create_caveman_agent(model_name: str) -> CavemanAgent:
|
||||||
|
logger.info(f"Factory creating CavemanAgent with model: {model_name}")
|
||||||
return CavemanAgent(model_name)
|
return CavemanAgent(model_name)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,4 +71,5 @@ class WorkspaceFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_workspace() -> WorkspaceManager:
|
def create_workspace() -> WorkspaceManager:
|
||||||
|
logger.info("Factory creating WorkspaceManager")
|
||||||
return 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 pydantic import BaseModel
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from gitea.models import IssueModel, PullRequestModel
|
from gitea.models import IssueModel, PullRequestModel
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("work-queue")
|
||||||
|
|
||||||
|
|
||||||
class WorkItem(BaseModel):
|
class WorkItem(BaseModel):
|
||||||
repo_full_name: str
|
repo_full_name: str
|
||||||
@@ -21,6 +24,7 @@ class WorkQueue:
|
|||||||
def enqueue(self, item: WorkItem) -> None:
|
def enqueue(self, item: WorkItem) -> None:
|
||||||
self._queue.append(item)
|
self._queue.append(item)
|
||||||
self._enqueued_repos.add(item.repo_full_name)
|
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:
|
def enqueue_batch(self, items: list[WorkItem]) -> None:
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -31,6 +35,7 @@ class WorkQueue:
|
|||||||
items: list[WorkItem] = [
|
items: list[WorkItem] = [
|
||||||
item for item in self._queue if item.repo_full_name == repo
|
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
|
return items
|
||||||
|
|
||||||
def remove_repo_work(self, repo: str) -> None:
|
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
|
item for item in self._queue if item.repo_full_name != repo
|
||||||
]
|
]
|
||||||
self._enqueued_repos.discard(repo)
|
self._enqueued_repos.discard(repo)
|
||||||
|
logger.info(f"Removed all work items for repository: {repo}")
|
||||||
|
|
||||||
def get_next_repo(self) -> str | None:
|
def get_next_repo(self) -> str | None:
|
||||||
"""Get the next repo with work, or None if empty."""
|
"""Get the next repo with work, or None if empty."""
|
||||||
|
|||||||
+7
-4
@@ -1,9 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
|
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("gitea-workspace")
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceManager:
|
class WorkspaceManager:
|
||||||
"""Manages local workspace for Gitea repositories."""
|
"""Manages local workspace for Gitea repositories."""
|
||||||
@@ -29,7 +32,7 @@ class WorkspaceManager:
|
|||||||
capture_output=True
|
capture_output=True
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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:
|
def _configure_repo_user(self, repo_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -64,7 +67,7 @@ class WorkspaceManager:
|
|||||||
check=True, capture_output=True
|
check=True, capture_output=True
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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:
|
def get_repo_path(self, repo_full_name: str) -> Path:
|
||||||
parts: list[str] = repo_full_name.split("/")
|
parts: list[str] = repo_full_name.split("/")
|
||||||
@@ -112,7 +115,7 @@ class WorkspaceManager:
|
|||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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:
|
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
|
||||||
repo_path: Path = self.get_repo_path(repo_full_name)
|
repo_path: Path = self.get_repo_path(repo_full_name)
|
||||||
@@ -125,7 +128,7 @@ class WorkspaceManager:
|
|||||||
repo_path.rename(new_path)
|
repo_path.rename(new_path)
|
||||||
return repo_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)
|
auth_url = self._get_authenticated_url(repo_full_name)
|
||||||
subprocess.run(["git", "clone", auth_url, str(repo_path)], check=True, capture_output=True)
|
subprocess.run(["git", "clone", auth_url, str(repo_path)], check=True, capture_output=True)
|
||||||
self._configure_repo_user(repo_path)
|
self._configure_repo_user(repo_path)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class JSONFormatter(logging.Formatter):
|
|||||||
return json.dumps(log_data)
|
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())
|
file_handler.setFormatter(JSONFormatter())
|
||||||
|
|
||||||
stream_handler = logging.StreamHandler()
|
stream_handler = logging.StreamHandler()
|
||||||
|
|||||||
@@ -511,3 +511,60 @@ async def test_dispatch_skips_pr_if_not_requested_reviewer() -> None:
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert "SKIP: Agent is not a requested reviewer" in results[0]
|
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