fix: improve prompts, error messages, and workspace concurrency

- Externalize coordinator, notification, and planning prompts to separate files
- Add workspace mutex for concurrent file operations
- Improve error messages across file_tools, issue_tools, and pr_tools
- Add logging to tool modules for better debugging
- Update tests to match new error message strings
This commit is contained in:
meeks
2026-07-19 17:49:33 +02:00
committed by Michael
parent ee01487ce3
commit dfdd8c0931
11 changed files with 300 additions and 107 deletions
+9 -58
View File
@@ -1,64 +1,15 @@
"""System prompts and configurations for agents.""" """System prompts for agents, loaded from external prompt files."""
COORDINATOR_SYSTEM_PROMPT: str = """ from pathlib import Path
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: _PROMPTS_DIR: Path = Path(__file__).resolve().parent.parent / "prompts"
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 ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
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. def _load_prompt(filename: str) -> str:
"""Load a prompt from an external text file in the prompts directory."""
CRITICAL INSTRUCTIONS: return (_PROMPTS_DIR / filename).read_text(encoding="utf-8")
- 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.
"""
NOTIFICATION_READER_SYSTEM_PROMPT: str = """
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
3. `skip_notification`: Choose this if:
- The notification is irrelevant or does not require AI agent intervention.
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
CRITICAL INSTRUCTIONS:
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
"""
PLANNING_AGENT_SYSTEM_PROMPT: str = """
You are an AI Planning Agent. Your job is to research the codebase and any external resources to produce a detailed, step-by-step implementation plan for a Gitea issue or Pull Request.
CRITICAL RULES:
1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.
2. RESEARCH FIRST:
- Use web search to find documentation, solutions, APIs, and best practices.
- Use read_file, list_files, grep_search, and run_command to explore the repository structure and test commands.
3. Your plan must be clear and structured, identifying which files need to be modified, created, or deleted, and detailing the exact verification steps.
"""
COORDINATOR_SYSTEM_PROMPT: str = _load_prompt("coordinator_agent.txt")
NOTIFICATION_READER_SYSTEM_PROMPT: str = _load_prompt("notification_agent.txt")
PLANNING_AGENT_SYSTEM_PROMPT: str = _load_prompt("planning_agent.txt")
+50 -10
View File
@@ -1,8 +1,11 @@
import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from gitea.client import GiteaClient from gitea.client import GiteaClient
logger: logging.Logger = logging.getLogger("gitea-file-tools")
class FileTools: class FileTools:
"""Tools for Gitea file/content operations.""" """Tools for Gitea file/content operations."""
@@ -67,18 +70,28 @@ class FileTools:
local_path: str | None = self._resolve_local_path(owner, repo, path) local_path: str | None = self._resolve_local_path(owner, repo, path)
if local_path and os.path.isfile(local_path): if local_path and os.path.isfile(local_path):
try: try:
with open(local_path, 'r', encoding='utf-8', errors='replace') as f: with open(local_path, "r", encoding="utf-8", errors="replace") as f:
raw: str = f.read() raw: str = f.read()
return self._paginate_lines(raw, offset, limit) return self._paginate_lines(raw, offset, limit)
except Exception: except Exception as exc:
pass logger.debug(
f"Local read failed for {owner}/{repo}/{path}: {exc}",
exc_info=True,
)
try: try:
content = self._client.files.get_file_content(owner, repo, path) content = self._client.files.get_file_content(owner, repo, path)
raw: str = "\n".join(content) if isinstance(content, list) else content raw: str = "\n".join(content) if isinstance(content, list) else content
return self._paginate_lines(raw, offset, limit) return self._paginate_lines(raw, offset, limit)
except Exception as e: except Exception as e:
return f"Error getting file content: {str(e)}" logger.error(
f"Failed to get file content for {owner}/{repo}/{path}: {e}",
exc_info=True,
)
return (
f"Error: Could not retrieve file '{path}' from {owner}/{repo}. "
f"Verify the file path and branch are correct. Details: {e}"
)
def get_file_content_with_ref( def get_file_content_with_ref(
self, self,
@@ -104,21 +117,34 @@ class FileTools:
if os.path.isdir(local_repo): if os.path.isdir(local_repo):
try: try:
import subprocess import subprocess
result = subprocess.run( result = subprocess.run(
["git", "-C", local_repo, "show", f"{ref}:{path}"], ["git", "-C", local_repo, "show", f"{ref}:{path}"],
capture_output=True, text=True, timeout=15, capture_output=True,
text=True,
timeout=15,
) )
if result.returncode == 0: if result.returncode == 0:
return self._paginate_lines(result.stdout, offset, limit) return self._paginate_lines(result.stdout, offset, limit)
except Exception: except Exception as exc:
pass logger.debug(
f"Local git show failed for {owner}/{repo}/{path}@{ref}: {exc}",
exc_info=True,
)
try: try:
content = self._client.files.get_file_content(owner, repo, path, ref) content = self._client.files.get_file_content(owner, repo, path, ref)
raw: str = "\n".join(content) if isinstance(content, list) else content raw: str = "\n".join(content) if isinstance(content, list) else content
return self._paginate_lines(raw, offset, limit) return self._paginate_lines(raw, offset, limit)
except Exception as e: except Exception as e:
return f"Error getting file content: {str(e)}" logger.error(
f"Failed to get file content for {owner}/{repo}/{path}@{ref}: {e}",
exc_info=True,
)
return (
f"Error: Could not retrieve file '{path}' at ref '{ref}' from {owner}/{repo}. "
f"Verify the file path and ref are correct. Details: {e}"
)
def commit_file( def commit_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str self, owner: str, repo: str, path: str, message: str, content: str, branch: str
@@ -127,7 +153,14 @@ class FileTools:
self._client.files.update_file(owner, repo, path, message, content, branch) self._client.files.update_file(owner, repo, path, message, content, branch)
return f"File '{path}' committed successfully to {owner}/{repo}." return f"File '{path}' committed successfully to {owner}/{repo}."
except Exception as e: except Exception as e:
return f"Error committing file: {str(e)}" logger.error(
f"Failed to commit file '{path}' to {owner}/{repo}@{branch}: {e}",
exc_info=True,
)
return (
f"Error: Could not commit file '{path}' to {owner}/{repo} on branch '{branch}'. "
f"Check for conflicts or permission issues. Details: {e}"
)
def update_file( def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str self, owner: str, repo: str, path: str, message: str, content: str, branch: str
@@ -136,4 +169,11 @@ class FileTools:
self._client.files.update_file(owner, repo, path, message, content, branch) self._client.files.update_file(owner, repo, path, message, content, branch)
return f"File '{path}' updated in {owner}/{repo}." return f"File '{path}' updated in {owner}/{repo}."
except Exception as e: except Exception as e:
return f"Error updating file: {str(e)}" logger.error(
f"Failed to update file '{path}' in {owner}/{repo}@{branch}: {e}",
exc_info=True,
)
return (
f"Error: Could not update file '{path}' in {owner}/{repo} on branch '{branch}'. "
f"Check for conflicts or permission issues. Details: {e}"
)
+49 -7
View File
@@ -27,7 +27,14 @@ class IssueTools:
self._client.issues.close_issue(owner, repo, issue_number) self._client.issues.close_issue(owner, repo, issue_number)
return f"Issue #{issue_number} closed successfully." return f"Issue #{issue_number} closed successfully."
except Exception as e: except Exception as e:
return f"Error closing issue: {str(e)}" logger.error(
f"Failed to close issue #{issue_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not close issue #{issue_number} in {owner}/{repo}. "
f"Check permissions or if the issue is already closed. Details: {e}"
)
def get_issue_comments( def get_issue_comments(
self, self,
@@ -58,7 +65,14 @@ class IssueTools:
) )
return result return result
except Exception as e: except Exception as e:
return f"Error getting issue comments: {str(e)}" logger.error(
f"Failed to get comments for issue #{issue_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not retrieve comments for issue #{issue_number} in {owner}/{repo}. "
f"Verify the issue exists and you have access. Details: {e}"
)
def list_assigned_issues(self) -> list[dict[str, Any]]: def list_assigned_issues(self) -> list[dict[str, Any]]:
try: try:
@@ -86,11 +100,18 @@ class IssueTools:
try: try:
issues = self._client.issues.list_repo_issues(owner, repo, state) issues = self._client.issues.list_repo_issues(owner, repo, state)
if not issues: if not issues:
return f"No issues in {owner}/{repo}." return f"No {state} issues in {owner}/{repo}."
summary = [f"#{issue.number}: {issue.title}" for issue in issues] summary = [f"#{issue.number}: {issue.title}" for issue in issues]
return "\n".join(summary) return "\n".join(summary)
except Exception as e: except Exception as e:
return f"Error listing issues: {str(e)}" logger.error(
f"Failed to list {state} issues in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not list issues for {owner}/{repo}. "
f"Verify the repository exists and you have access. Details: {e}"
)
def create_issue( def create_issue(
self, self,
@@ -107,7 +128,14 @@ class IssueTools:
) )
return f"Issue #{issue.number} created successfully in {owner}/{repo}." return f"Issue #{issue.number} created successfully in {owner}/{repo}."
except Exception as e: except Exception as e:
return f"Error creating issue: {str(e)}" logger.error(
f"Failed to create issue '{title}' in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not create issue '{title}' in {owner}/{repo}. "
f"Check repository permissions and label/assignee names. Details: {e}"
)
def add_label_to_issue( def add_label_to_issue(
self, owner: str, repo: str, issue_number: int, label: str self, owner: str, repo: str, issue_number: int, label: str
@@ -116,7 +144,14 @@ class IssueTools:
self._client.issues.add_label(owner, repo, issue_number, label) self._client.issues.add_label(owner, repo, issue_number, label)
return f"Label '{label}' added to issue #{issue_number}." return f"Label '{label}' added to issue #{issue_number}."
except Exception as e: except Exception as e:
return f"Error adding label to issue #{issue_number}: {e}" logger.error(
f"Failed to add label '{label}' to issue #{issue_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not add label '{label}' to issue #{issue_number} in {owner}/{repo}. "
f"Verify the label exists in the repository. Details: {e}"
)
def add_comment_to_issue( def add_comment_to_issue(
self, owner: str, repo: str, issue_number: int, body: str self, owner: str, repo: str, issue_number: int, body: str
@@ -125,4 +160,11 @@ class IssueTools:
self._client.issues.add_comment(owner, repo, issue_number, body) self._client.issues.add_comment(owner, repo, issue_number, body)
return f"Comment added to issue #{issue_number}." return f"Comment added to issue #{issue_number}."
except Exception as e: except Exception as e:
return f"Error adding comment to issue #{issue_number}: {e}" logger.error(
f"Failed to add comment to issue #{issue_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not add comment to issue #{issue_number} in {owner}/{repo}. "
f"Verify the issue exists and you have write access. Details: {e}"
)
+71 -11
View File
@@ -40,11 +40,15 @@ class PRTools:
def __init__(self, client: GiteaClient) -> None: def __init__(self, client: GiteaClient) -> None:
self._client = client self._client = client
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: def get_pull_request(
self, owner: str, repo: str, pull_number: int
) -> PullRequestModel:
try: try:
return self._client.prs.get_pull_request(owner, repo, pull_number) return self._client.prs.get_pull_request(owner, repo, pull_number)
except Exception as e: except Exception as e:
logger.error(f"Error getting pull request #{pull_number}: {e}", exc_info=True) logger.error(
f"Error getting pull request #{pull_number}: {e}", exc_info=True
)
raise raise
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str: def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
@@ -52,7 +56,14 @@ class PRTools:
self._client.prs.close_pull_request(owner, repo, pull_number) self._client.prs.close_pull_request(owner, repo, pull_number)
return f"Pull request #{pull_number} closed successfully." return f"Pull request #{pull_number} closed successfully."
except Exception as e: except Exception as e:
return f"Error closing pull request: {str(e)}" logger.error(
f"Failed to close PR #{pull_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not close PR #{pull_number} in {owner}/{repo}. "
f"Check permissions or if the PR is already closed. Details: {e}"
)
def get_pull_request_comments( def get_pull_request_comments(
self, self,
@@ -83,7 +94,14 @@ class PRTools:
) )
return result return result
except Exception as e: except Exception as e:
return f"Error getting PR comments: {str(e)}" logger.error(
f"Failed to get comments for PR #{pull_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not retrieve comments for PR #{pull_number} in {owner}/{repo}. "
f"Verify the PR exists and you have access. Details: {e}"
)
def list_assigned_pull_requests(self) -> list[dict[str, Any]]: def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
try: try:
@@ -111,11 +129,18 @@ class PRTools:
try: try:
prs = self._client.prs.list_repo_pull_requests(owner, repo, state) prs = self._client.prs.list_repo_pull_requests(owner, repo, state)
if not prs: if not prs:
return f"No PRs in {owner}/{repo}." return f"No {state} PRs in {owner}/{repo}."
summary = [f"#{pr.number}: {pr.title}" for pr in prs] summary = [f"#{pr.number}: {pr.title}" for pr in prs]
return "\n".join(summary) return "\n".join(summary)
except Exception as e: except Exception as e:
return f"Error listing PRs: {str(e)}" logger.error(
f"Failed to list {state} PRs in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not list PRs for {owner}/{repo}. "
f"Verify the repository exists and you have access. Details: {e}"
)
def create_pull_request( def create_pull_request(
self, self,
@@ -156,7 +181,14 @@ class PRTools:
self._client.prs.add_label_pr(owner, repo, pr_number, label) self._client.prs.add_label_pr(owner, repo, pr_number, label)
return f"Label '{label}' added to PR #{pr_number}." return f"Label '{label}' added to PR #{pr_number}."
except Exception as e: except Exception as e:
return f"Error adding label to PR #{pr_number}: {e}" logger.error(
f"Failed to add label '{label}' to PR #{pr_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not add label '{label}' to PR #{pr_number} in {owner}/{repo}. "
f"Verify the label exists in the repository. Details: {e}"
)
def get_pull_request_diff( def get_pull_request_diff(
self, self,
@@ -177,7 +209,14 @@ class PRTools:
diff: str = self._client.prs.get_pull_request_diff(owner, repo, pull_number) diff: str = self._client.prs.get_pull_request_diff(owner, repo, pull_number)
return _truncate_diff(diff, max_chars, char_offset) return _truncate_diff(diff, max_chars, char_offset)
except Exception as e: except Exception as e:
return f"Error getting PR diff: {str(e)}" logger.error(
f"Failed to get diff for PR #{pull_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not retrieve diff for PR #{pull_number} in {owner}/{repo}. "
f"The PR may have no changes or the API is unavailable. Details: {e}"
)
def get_pull_request_patch( def get_pull_request_patch(
self, self,
@@ -200,7 +239,14 @@ class PRTools:
) )
return _truncate_diff(patch, max_chars, char_offset) return _truncate_diff(patch, max_chars, char_offset)
except Exception as e: except Exception as e:
return f"Error getting PR patch: {str(e)}" logger.error(
f"Failed to get patch for PR #{pull_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not retrieve patch for PR #{pull_number} in {owner}/{repo}. "
f"The PR may have no changes or the API is unavailable. Details: {e}"
)
def approve_pull_request( def approve_pull_request(
self, owner: str, repo: str, pull_number: int, comment: str self, owner: str, repo: str, pull_number: int, comment: str
@@ -209,7 +255,14 @@ class PRTools:
self._client.prs.approve_pr(owner, repo, pull_number, comment) self._client.prs.approve_pr(owner, repo, pull_number, comment)
return f"Approved PR #{pull_number}." return f"Approved PR #{pull_number}."
except Exception as e: except Exception as e:
return f"Error approving PR: {str(e)}" logger.error(
f"Failed to approve PR #{pull_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not approve PR #{pull_number} in {owner}/{repo}. "
f"Check that you have review permissions. Details: {e}"
)
def request_changes( def request_changes(
self, owner: str, repo: str, pull_number: int, comment: str self, owner: str, repo: str, pull_number: int, comment: str
@@ -218,4 +271,11 @@ class PRTools:
self._client.prs.request_changes_pr(owner, repo, pull_number, comment) self._client.prs.request_changes_pr(owner, repo, pull_number, comment)
return f"Requested changes on PR #{pull_number}." return f"Requested changes on PR #{pull_number}."
except Exception as e: except Exception as e:
return f"Error requesting changes: {str(e)}" logger.error(
f"Failed to request changes on PR #{pull_number} in {owner}/{repo}: {e}",
exc_info=True,
)
return (
f"Error: Could not request changes on PR #{pull_number} in {owner}/{repo}. "
f"Check that you have review permissions. Details: {e}"
)
+31 -1
View File
@@ -3,6 +3,7 @@ import logging
import subprocess import subprocess
import base64 import base64
import shutil import shutil
import threading
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
@@ -12,12 +13,27 @@ logger: logging.Logger = logging.getLogger("gitea-workspace")
class WorkspaceManager: class WorkspaceManager:
"""Manages local workspace for Gitea repositories.""" """Manages local workspace for Gitea repositories.
Uses per-repo threading locks to prevent concurrent git operations
on the same repository from causing race conditions.
"""
_repo_locks: dict[str, threading.Lock] = {}
_locks_lock: threading.Lock = threading.Lock()
def __init__(self) -> None: def __init__(self) -> None:
self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve() self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve()
self.root_dir.mkdir(parents=True, exist_ok=True) self.root_dir.mkdir(parents=True, exist_ok=True)
@classmethod
def get_repo_lock(cls, repo_full_name: str) -> threading.Lock:
"""Get or create a thread-safe lock for a specific repository."""
with cls._locks_lock:
if repo_full_name not in cls._repo_locks:
cls._repo_locks[repo_full_name] = threading.Lock()
return cls._repo_locks[repo_full_name]
def _configure_repo_user(self, repo_path: Path) -> None: def _configure_repo_user(self, repo_path: Path) -> None:
try: try:
client = GiteaClient() client = GiteaClient()
@@ -70,6 +86,12 @@ class WorkspaceManager:
return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git" return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git"
def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None: def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None:
lock = self.get_repo_lock(repo_full_name)
with lock:
logger.debug(f"Acquired workspace lock for {repo_full_name} (sanitize)")
self._sanitize_repo_inner(repo_full_name, repo_path)
def _sanitize_repo_inner(self, repo_full_name: str, repo_path: Path) -> None:
try: try:
auth_url = self._get_authenticated_url(repo_full_name) auth_url = self._get_authenticated_url(repo_full_name)
subprocess.run( subprocess.run(
@@ -146,6 +168,14 @@ class WorkspaceManager:
) from e ) from 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:
lock = self.get_repo_lock(repo_full_name)
with lock:
logger.debug(f"Acquired workspace lock for {repo_full_name} (clone)")
return self._clone_repo_inner(repo_full_name, clone_url)
def _clone_repo_inner(
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)
if repo_path.exists(): if repo_path.exists():
if not (repo_path / ".git").exists(): if not (repo_path / ".git").exists():
+28
View File
@@ -0,0 +1,28 @@
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 ONLY if the AI agent previously answered a question AND the human has explicitly replied with a message confirming they are fully satisfied or explicitly instructing the agent to close the issue (e.g., "thanks, this answers my question", "looks good, you can close this", "close it"). If the human's response is a follow-up question, is ambiguous, or does not explicitly approve closing, you must NOT call this tool (call `answer_question` or `take_no_action` instead).
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.
+15
View File
@@ -0,0 +1,15 @@
You are a Gitea Notification Reader Agent. Your job is to analyze incoming Gitea notifications and determine how they should be routed.
Based on the notification subject, details, and conversation comments (if retrieved), you must choose and call exactly one of the following tools:
1. `process_issue`: Choose this if the notification refers to a Gitea issue that requires active intervention, planning, implementation, or answering a question by the AI agent.
2. `process_pr`: Choose this if the notification refers to a Gitea Pull Request that requires active intervention, code reviews, updates, or merging by the AI agent.
3. `skip_notification`: Choose this if:
- The notification is irrelevant or does not require AI agent intervention.
- It is a notification about an action taken by the AI agent itself (e.g. self-assigned, self-commented, self-opened).
- The discussion is closed or resolved, or the notification is just informational (e.g. a simple status update that needs no reply).
- You are unsure or think it does not fit the agent's scope. You must provide a clear reason for skipping.
CRITICAL INSTRUCTIONS:
- You must call EXACTLY one tool. Do not guess, and do not output raw text instead of calling a tool.
- You can use the provided inspection tools (like get_issue, get_pull_request, get_issue_comments, get_pull_request_comments) to gather more details if the basic notification metadata is insufficient to make a decision.
+8
View File
@@ -0,0 +1,8 @@
You are an AI Planning Agent. Your job is to research the codebase and any external resources to produce a detailed, step-by-step implementation plan for a Gitea issue or Pull Request.
CRITICAL RULES:
1. You are ONLY generating an implementation plan. DO NOT write files, DO NOT edit files, DO NOT commit, DO NOT push, and DO NOT create branches or PRs.
2. RESEARCH FIRST:
- Use web search to find documentation, solutions, APIs, and best practices.
- Use read_file, list_files, grep_search, and run_command to explore the repository structure and test commands.
3. Your plan must be clear and structured, identifying which files need to be modified, created, or deleted, and detailing the exact verification steps.
+9 -4
View File
@@ -37,7 +37,8 @@ def test_get_file_content_failure() -> None:
file_tools: FileTools = FileTools(mock_client) file_tools: FileTools = FileTools(mock_client)
res: str = file_tools.get_file_content("owner", "repo", "path/to/file") res: str = file_tools.get_file_content("owner", "repo", "path/to/file")
assert "Error getting file content: API Error" in res assert "Could not retrieve file" in res
assert "path/to/file" in res
def test_get_file_content_with_ref_string_success() -> None: def test_get_file_content_with_ref_string_success() -> None:
@@ -73,7 +74,8 @@ def test_get_file_content_with_ref_failure() -> None:
res: str = file_tools.get_file_content_with_ref( res: str = file_tools.get_file_content_with_ref(
"owner", "repo", "path/to/file", "main" "owner", "repo", "path/to/file", "main"
) )
assert "Error getting file content: API Error" in res assert "Could not retrieve file" in res
assert "path/to/file" in res
def test_commit_file_success() -> None: def test_commit_file_success() -> None:
@@ -98,7 +100,8 @@ def test_commit_file_failure() -> None:
res: str = file_tools.commit_file( res: str = file_tools.commit_file(
"owner", "repo", "path/to/file", "msg", "content", "branch" "owner", "repo", "path/to/file", "msg", "content", "branch"
) )
assert "Error committing file: API Error" in res assert "Could not commit file" in res
assert "path/to/file" in res
def test_update_file_success() -> None: def test_update_file_success() -> None:
@@ -123,11 +126,13 @@ def test_update_file_failure() -> None:
res: str = file_tools.update_file( res: str = file_tools.update_file(
"owner", "repo", "path/to/file", "msg", "content", "branch" "owner", "repo", "path/to/file", "msg", "content", "branch"
) )
assert "Error updating file: API Error" in res assert "Could not update file" in res
assert "path/to/file" in res
def test_get_file_content_uses_local_file_when_available(tmp_path: str) -> None: def test_get_file_content_uses_local_file_when_available(tmp_path: str) -> None:
import os import os
mock_client = _create_mock_client() mock_client = _create_mock_client()
repo_dir = tmp_path / "owner" / "repo" repo_dir = tmp_path / "owner" / "repo"
+13 -7
View File
@@ -56,7 +56,8 @@ def test_close_issue_failure() -> None:
issue_tools: IssueTools = IssueTools(mock_client) issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.close_issue("owner", "repo", 1) res: str = issue_tools.close_issue("owner", "repo", 1)
assert "Error closing issue: API Error" in res assert "Could not close issue" in res
assert "owner/repo" in res
def test_get_issue_comments_success() -> None: def test_get_issue_comments_success() -> None:
@@ -77,7 +78,8 @@ def test_get_issue_comments_failure() -> None:
issue_tools: IssueTools = IssueTools(mock_client) issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.get_issue_comments("owner", "repo", 1) res: str = issue_tools.get_issue_comments("owner", "repo", 1)
assert "Error getting issue comments: API Error" in res assert "Could not retrieve comments" in res
assert "owner/repo" in res
def test_list_assigned_issues_success() -> None: def test_list_assigned_issues_success() -> None:
@@ -120,7 +122,7 @@ def test_list_issues_empty() -> None:
issue_tools: IssueTools = IssueTools(mock_client) issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.list_issues("owner", "repo") res: str = issue_tools.list_issues("owner", "repo")
assert res == "No issues in owner/repo." assert res == "No open issues in owner/repo."
def test_list_issues_failure() -> None: def test_list_issues_failure() -> None:
@@ -129,7 +131,8 @@ def test_list_issues_failure() -> None:
issue_tools: IssueTools = IssueTools(mock_client) issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.list_issues("owner", "repo") res: str = issue_tools.list_issues("owner", "repo")
assert "Error listing issues: API Error" in res assert "Could not list issues" in res
assert "owner/repo" in res
def test_create_issue_success() -> None: def test_create_issue_success() -> None:
@@ -153,7 +156,8 @@ def test_create_issue_failure() -> None:
issue_tools: IssueTools = IssueTools(mock_client) issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.create_issue("owner", "repo", "Title", "Body") res: str = issue_tools.create_issue("owner", "repo", "Title", "Body")
assert "Error creating issue: API Error" in res assert "Could not create issue" in res
assert "Title" in res
def test_add_label_to_issue_success() -> None: def test_add_label_to_issue_success() -> None:
@@ -171,7 +175,8 @@ def test_add_label_to_issue_failure() -> None:
issue_tools: IssueTools = IssueTools(mock_client) issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug") res: str = issue_tools.add_label_to_issue("owner", "repo", 1, "bug")
assert "Error adding label to issue #1: API Error" in res assert "Could not add label" in res
assert "bug" in res
def test_add_comment_to_issue_success() -> None: def test_add_comment_to_issue_success() -> None:
@@ -189,4 +194,5 @@ def test_add_comment_to_issue_failure() -> None:
issue_tools: IssueTools = IssueTools(mock_client) issue_tools: IssueTools = IssueTools(mock_client)
res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body") res: str = issue_tools.add_comment_to_issue("owner", "repo", 1, "body")
assert "Error adding comment to issue #1: API Error" in res assert "Could not add comment" in res
assert "owner/repo" in res
+17 -9
View File
@@ -58,7 +58,8 @@ def test_close_pull_request_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.close_pull_request("owner", "repo", 1) res: str = pr_tools.close_pull_request("owner", "repo", 1)
assert "Error closing pull request: API Error" in res assert "Could not close PR" in res
assert "owner/repo" in res
def test_get_pull_request_comments_success() -> None: def test_get_pull_request_comments_success() -> None:
@@ -79,7 +80,8 @@ def test_get_pull_request_comments_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_comments("owner", "repo", 1) res: str = pr_tools.get_pull_request_comments("owner", "repo", 1)
assert "Error getting PR comments: API Error" in res assert "Could not retrieve comments" in res
assert "owner/repo" in res
def test_list_assigned_pull_requests_success() -> None: def test_list_assigned_pull_requests_success() -> None:
@@ -124,7 +126,7 @@ def test_list_pull_requests_empty() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.list_pull_requests("owner", "repo") res: str = pr_tools.list_pull_requests("owner", "repo")
assert res == "No PRs in owner/repo." assert res == "No open PRs in owner/repo."
def test_list_pull_requests_failure() -> None: def test_list_pull_requests_failure() -> None:
@@ -133,7 +135,8 @@ def test_list_pull_requests_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.list_pull_requests("owner", "repo") res: str = pr_tools.list_pull_requests("owner", "repo")
assert "Error listing PRs: API Error" in res assert "Could not list PRs" in res
assert "owner/repo" in res
def test_create_pull_request_success() -> None: def test_create_pull_request_success() -> None:
@@ -179,7 +182,8 @@ def test_add_label_to_pr_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug") res: str = pr_tools.add_label_to_pr("owner", "repo", 1, "bug")
assert "Error adding label to PR #1: API Error" in res assert "Could not add label" in res
assert "bug" in res
def test_get_pull_request_diff_success() -> None: def test_get_pull_request_diff_success() -> None:
@@ -197,7 +201,8 @@ def test_get_pull_request_diff_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_diff("owner", "repo", 1) res: str = pr_tools.get_pull_request_diff("owner", "repo", 1)
assert "Error getting PR diff: API Error" in res assert "Could not retrieve diff" in res
assert "owner/repo" in res
def test_get_pull_request_patch_success() -> None: def test_get_pull_request_patch_success() -> None:
@@ -215,7 +220,8 @@ def test_get_pull_request_patch_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.get_pull_request_patch("owner", "repo", 1) res: str = pr_tools.get_pull_request_patch("owner", "repo", 1)
assert "Error getting PR patch: API Error" in res assert "Could not retrieve patch" in res
assert "owner/repo" in res
def test_approve_pull_request_success() -> None: def test_approve_pull_request_success() -> None:
@@ -233,7 +239,8 @@ def test_approve_pull_request_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good") res: str = pr_tools.approve_pull_request("owner", "repo", 1, "good")
assert "Error approving PR: API Error" in res assert "Could not approve PR" in res
assert "owner/repo" in res
def test_request_changes_success() -> None: def test_request_changes_success() -> None:
@@ -251,4 +258,5 @@ def test_request_changes_failure() -> None:
pr_tools: PRTools = PRTools(mock_client) pr_tools: PRTools = PRTools(mock_client)
res: str = pr_tools.request_changes("owner", "repo", 1, "bad") res: str = pr_tools.request_changes("owner", "repo", 1, "bad")
assert "Error requesting changes: API Error" in res assert "Could not request changes" in res
assert "owner/repo" in res