Compare commits

...
5 changed files with 302 additions and 74 deletions

No files matched your search

+54 -48
View File
@@ -1,65 +1,71 @@
framework: "praisonai"
topic: "Autonomous Issue Resolution and PR Creation"
planning: true
process: workflow
topic: "Autonomous Issue Resolution"
planning: false
roles:
issue_analyst:
role: "GitHub Issue Analyst"
goal: "Read the GitHub issue, understand the required changes, and map the problem to the local codebase"
backstory: "You are an expert software analyst who reads bug reports and feature requests. You excel at taking a problem description and finding exactly which files in the codebase need to be modified. Your primary job is discovery."
backstory: "You are an expert software analyst who reads bug reports and feature requests. You excel at taking a problem description and finding exactly which files in the codebase need to be modified. Your primary job is discovery and planning."
tools:
- "execute_command"
software_engineer:
role: "Autonomous Software Engineer"
goal: "Checkout a new branch, rewrite the code to fix the issue, and ensure the changes are correct locally"
backstory: "You are a senior software engineer who writes flawless code. You branch off main, use terminal commands to rewrite files using sed, echo, or other secure tools, and verify that your changes perfectly address the analyst's findings."
role: "Autonomous Software Engineer"
goal: "Implement required code changes on a branch, run tests, commit, push, and open a PR only if changes were made"
backstory: "You are a senior software engineer. You always branch first, implement changes using shell commands, verify with git diff, run tests, then commit and push. You only open a Pull Request when you have actually committed code changes."
tools:
- "execute_command"
- "github_create_branch"
release_manager:
role: "Release and CI Manager"
goal: "Commit the changes, push the branch to remote, and create the Pull Request linking back to the issue"
backstory: "You are a DevOps Release Engineer. Your job is strictly to ensure that any code changes are properly committed, pushed to the GitHub repository, and framed as a clean Pull Request that automatically links to the issue."
tools:
- "github_commit_and_push"
- "github_create_pull_request"
- "github_create_branch"
- "github_commit_and_push"
- "execute_command"
steps:
- name: analyze_issue
- name: Analyze Issue
agent: issue_analyst
action: |
Thoroughly analyze the Issue and the repository:
0. CRITICAL: Your wrapper has already pre-fetched the complete issue description and Pull Request diff securely.
1. Read the pre-fetched context natively using `execute_command` with the `command` argument set to `cat .github/triage-context.md`. Do NOT provide a `cwd` argument.
2. Identify the exact problem or feature request.
3. Use tools like `grep`, `find`, or `cat` to explore the codebase and find the specific files that need modification.
4. Create a specific, file-level plan of what needs to change.
expected_output: "A detailed analysis report including the files that must be changed and exactly what lines or logic must be altered to solve the Issue."
- name: implement_fix
action: >
Thoroughly analyze the issue and the repository. Follow these steps in order.
First, set up git identity: execute_command 'git config user.name PraisonAI' then
execute_command 'git config user.email [email protected]'. Do NOT pass cwd.
Second, read the issue: execute_command 'cat .github/triage-context.md'. Do NOT pass cwd.
Third, read architecture guidelines: execute_command 'cat src/praisonai-agents/AGENTS.md'.
Fourth, decide if this issue requires code changes:
- If YES: identify the exact files and lines that must change. Output a concrete file-level plan.
- If NO (question, discussion, duplicate, invalid): say so clearly. No code changes needed.
Output: routing decision (Core SDK / Wrapper / Tools / No-change), validation result, and exact files/lines to change.
expected_output: A decision on whether code changes are needed, and if so a concrete file-level plan.
- name: Implement Fix
agent: software_engineer
action: |
Implement the changes required to solve the issue:
1. Review the analyst's plan.
2. Check out a dedicated fix branch by using the `github_create_branch` tool with `branch_name` set to `praisonai/issue-{{ISSUE_NUMBER}}`.
3. Carefully modify the necessary files in the repository using bash commands (e.g. `cat`, `sed`, `echo`, or small inline scripts).
4. Double-check your edits by inspecting the file outputs or running `git diff` to ensure you changed what was required.
expected_output: "Confirmation that the git branch was successfully created and the files were reliably modified without syntax errors."
- name: create_pull_request
agent: release_manager
action: |
Push the code and submit the PR:
1. Use the native `github_commit_and_push` tool and provide a commit message like "PraisonAI Automated Fix".
2. Use the native `github_create_pull_request` tool with the arguments:
- title: "Fix Issue {{ISSUE_NUMBER}}"
- body: "Automated triage by PraisonAI Native Issue Triage."
- head_branch: "praisonai/issue-{{ISSUE_NUMBER}}"
- base_branch: "main"
3. Post a comment on the original issue letting the user know: `execute_command`: `gh issue comment {{ISSUE_NUMBER}} -b "I have autonomously analyzed the codebase and written a fix for this issue! I've opened a Pull Request for your review."`
expected_output: "The URL of the successfully created Pull Request."
dependencies: [analyze_issue, implement_fix]
action: >
You are working inside the repository at /Users/praison/praisonai-package.
Do NOT ask for confirmation. Do NOT use placeholder paths. Act immediately.
Read the analyst's output first. If the analyst said NO code changes are needed, stop here and
output a brief explanation — do NOT create a branch or PR.
If code changes ARE needed, follow these steps:
Step 1 — Create branch: github_create_branch with branch_name=praisonai/issue-{{ISSUE_NUMBER}}.
Step 2 — Implement changes using execute_command (no cwd argument).
To insert a line: python3 -c "lines=open('FILE').readlines(); lines.insert(N,'LINE\n'); open('FILE','w').writelines(lines)"
To replace text: python3 -c "t=open('FILE').read(); open('FILE','w').write(t.replace('OLD','NEW'))"
After each edit verify: execute_command 'git diff'
Step 3 — Run tests: execute_command 'python3 -m pytest src/praisonai-agents/tests/ -x -q --timeout=30 -p no:warnings'
Step 4 — Commit and push: github_commit_and_push with a descriptive commit_message.
Step 5 — ONLY if commits were pushed: create PR using github_create_pull_request with
title='fix: resolve issue #{{ISSUE_NUMBER}}',
body='Fixes #{{ISSUE_NUMBER}}.\n\nAutonomously resolved by PraisonAI.',
head_branch=praisonai/issue-{{ISSUE_NUMBER}}, base_branch=main.
expected_output: Either "No code changes needed" or confirmation that branch was created, changes committed, pushed, and PR opened.
dependencies: [Analyze Issue]
+2 -2
View File
@@ -1,3 +1,3 @@
# GitHub Issue #1337: Big Task: Document CLI Router Architecture
# GitHub Issue #1343: Add type hint to github_create_branch return type
Please thoroughly trace how src/praisonai/praisonai/cli/main.py interfaces with typer across all command modules. Produce a detailed architecture document in a new file docs/cli_architecture_test.md.
The github_create_branch function in src/praisonai-agents/praisonaiagents/tools/github_tools.py is missing a return type annotation. Please add -> str to the function signature.
@@ -51,17 +51,32 @@ class ShellTools:
Dictionary with execution results
"""
try:
# Strip wrapping quotes the LLM sometimes adds around the whole command string
if command and len(command) >= 2 and command[0] == command[-1] and command[0] in ("'", '"'):
command = command[1:-1]
# Treat empty-string cwd same as None to avoid subprocess failure
if not cwd:
cwd = None
# Always split command for safety (no shell execution)
# Use shlex.split with appropriate posix flag
if platform.system() == 'Windows':
# Use shlex with posix=False for Windows to handle quotes properly
command = shlex.split(command, posix=False)
cmd_parts = shlex.split(command, posix=False)
else:
command = shlex.split(command)
cmd_parts = shlex.split(command)
# Guard against empty command list (e.g. LLM passed empty string)
if not cmd_parts:
return {
'stdout': '',
'stderr': 'Empty command',
'exit_code': 1,
'success': False,
'execution_time': 0.0,
}
# Expand tilde and environment variables in command arguments
# (shell=False means the shell won't do this for us)
command = [os.path.expanduser(os.path.expandvars(arg)) for arg in command]
cmd_parts = [os.path.expanduser(os.path.expandvars(arg)) for arg in cmd_parts]
# Expand tilde in cwd (subprocess doesn't do this)
if cwd:
@@ -81,7 +96,7 @@ class ShellTools:
# Start process
start_time = time.time()
process = subprocess.Popen(
command,
cmd_parts,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
@@ -0,0 +1,135 @@
"""Unit tests for ShellTools.execute_command input-normalisation improvements.
Covers:
1. Stripping wrapping quotes added by LLMs around the whole command string.
2. Empty-command guard – consistent return-value shape.
3. Empty/None cwd normalisation.
"""
import os
import pytest
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_shell():
"""Return a ShellTools instance with auto-approval so tests can call it."""
from praisonaiagents.tools.shell_tools import ShellTools
return ShellTools()
def _run(shell, command, **kwargs):
"""Call execute_command with PRAISONAI_AUTO_APPROVE set."""
with patch.dict(os.environ, {"PRAISONAI_AUTO_APPROVE": "true"}):
return shell.execute_command(command, **kwargs)
# ---------------------------------------------------------------------------
# Expected result-shape helper
# ---------------------------------------------------------------------------
_REQUIRED_KEYS = {"stdout", "stderr", "exit_code", "success", "execution_time"}
def _assert_shape(result: dict) -> None:
"""Assert the result dict has all required keys."""
missing = _REQUIRED_KEYS - result.keys()
assert not missing, f"Result is missing keys: {missing}; got {result}"
# ---------------------------------------------------------------------------
# Tests: empty command
# ---------------------------------------------------------------------------
class TestEmptyCommandGuard:
"""execute_command should return a consistent dict for empty commands."""
def test_empty_string_returns_required_keys(self):
shell = _make_shell()
result = _run(shell, "")
_assert_shape(result)
def test_empty_string_success_is_false(self):
shell = _make_shell()
result = _run(shell, "")
assert result["success"] is False
def test_empty_string_exit_code_nonzero(self):
shell = _make_shell()
result = _run(shell, "")
assert result["exit_code"] != 0
def test_empty_string_execution_time_present(self):
shell = _make_shell()
result = _run(shell, "")
assert isinstance(result["execution_time"], (int, float))
def test_whitespace_only_returns_required_keys(self):
shell = _make_shell()
result = _run(shell, " ")
_assert_shape(result)
def test_whitespace_only_success_is_false(self):
shell = _make_shell()
result = _run(shell, " ")
assert result["success"] is False
# ---------------------------------------------------------------------------
# Tests: quote stripping
# ---------------------------------------------------------------------------
class TestQuoteNormalisation:
"""execute_command should strip wrapping quotes added by LLMs."""
def test_single_quoted_command_executes(self):
shell = _make_shell()
result = _run(shell, "'echo hello'")
_assert_shape(result)
assert result["success"] is True
assert "hello" in result["stdout"]
def test_double_quoted_command_executes(self):
shell = _make_shell()
result = _run(shell, '"echo world"')
_assert_shape(result)
assert result["success"] is True
assert "world" in result["stdout"]
def test_unquoted_command_executes_normally(self):
shell = _make_shell()
result = _run(shell, "echo normal")
_assert_shape(result)
assert result["success"] is True
assert "normal" in result["stdout"]
def test_mismatched_quotes_not_stripped(self):
"""Mismatched quotes (e.g. 'cmd") are not stripped; shlex raises ValueError which is caught."""
shell = _make_shell()
# The outer try/except in execute_command catches ValueError from shlex.split
result = _run(shell, "'echo bad\"")
_assert_shape(result)
assert result["success"] is False
# ---------------------------------------------------------------------------
# Tests: cwd normalisation
# ---------------------------------------------------------------------------
class TestCwdNormalisation:
"""Empty/None cwd should be treated as None (use current directory)."""
def test_empty_string_cwd_is_treated_as_none(self):
shell = _make_shell()
result = _run(shell, "echo cwd_test", cwd="")
_assert_shape(result)
assert result["success"] is True
def test_none_cwd_works(self):
shell = _make_shell()
result = _run(shell, "echo no_cwd", cwd=None)
_assert_shape(result)
assert result["success"] is True
+92 -20
View File
@@ -1,4 +1,5 @@
import os
import re
import sys
import json
import time
@@ -59,15 +60,23 @@ class StickyComment:
WORKING = "🔄"
def __init__(self, api_base: str, issue: int, token: str,
task_type: str, title: str, run_url: str):
task_type: str, title: str, run_url: str,
repo_name: str = ""):
self._api_base = api_base
self._issue = issue
self._token = token
self._task_type = task_type
self._title = title
self._run_url = run_url
self._repo_name = repo_name
self._comment_id: Optional[int] = None
# Branch and PR tracking (set when tools fire)
self._branch_name: Optional[str] = None
self._pr_url: Optional[str] = None
self._pr_number: Optional[int] = None
self._summary_lines: List[str] = []
# Todo list: list of dicts {content, status} status ∈ pending/in_progress/completed
self._todos: List[Dict[str, Any]] = []
# Free-form activity log shown below the todo list (last N lines)
@@ -154,6 +163,25 @@ class StickyComment:
self._current_tool = None
# no extra log for tool end – keeps comment clean
def set_branch(self, branch_name: str) -> None:
"""Called when a branch is successfully created."""
with self._lock:
self._branch_name = branch_name
self._schedule_push()
def set_pr(self, pr_url: str, pr_number: Optional[int] = None) -> None:
"""Called when a PR is successfully opened."""
with self._lock:
self._pr_url = pr_url
self._pr_number = pr_number
self._schedule_push()
def add_summary_line(self, line: str) -> None:
"""Append a line to the Summary section."""
with self._lock:
self._summary_lines.append(line)
self._schedule_push()
def finalize(self, success: bool, summary: str) -> None:
"""Push the final state synchronously."""
if self._timer:
@@ -164,7 +192,8 @@ class StickyComment:
for t in self._todos:
if t["status"] == "in_progress":
t["status"] = "completed"
self._logs.append(summary)
if summary:
self._summary_lines.append(summary)
self._push_now(final=success)
# ------------------------------------------------------------------
@@ -190,24 +219,40 @@ class StickyComment:
return f"`{tool_name}`"
def _build_body(self, final: Optional[bool] = None) -> str:
"""Render the full Markdown comment body."""
"""Render the full Markdown comment body (Claude Code style)."""
if final is True:
icon = self.DONE
status_icon = self.DONE
verb = "finished"
elif final is False:
icon = self.FAIL
status_icon = self.FAIL
verb = "failed on"
else:
icon = self.SPINNER
status_icon = self.SPINNER
verb = "is working on"
lines: List[str] = []
# Title line
lines.append(f"**{self._task_type} #{self._issue}: {self._title}** {icon}")
lines.append("")
# --- Header line (Claude Code style) ---
# "PraisonAI finished @user's task — View job · branch-name · Create PR →"
header_parts: List[str] = []
header_parts.append(f"**PraisonAI {verb} #{self._issue}** {status_icon}")
# Job link
meta_parts: List[str] = []
if self._run_url:
lines.append(f"[View job run]({self._run_url})")
lines.append("")
meta_parts.append(f"[View job]({self._run_url})")
if self._branch_name:
branch_url = f"https://github.com/{self._repo_name}/tree/{self._branch_name}" if self._repo_name else ""
if branch_url:
meta_parts.append(f"[`{self._branch_name}`]({branch_url})")
else:
meta_parts.append(f"`{self._branch_name}`")
if self._pr_url:
meta_parts.append(f"[Create PR →]({self._pr_url})")
lines.append(" · ".join([header_parts[0]] + meta_parts) if meta_parts else header_parts[0])
lines.append("")
lines.append(f"**{self._task_type} #{self._issue}: {self._title}**")
lines.append("")
# --- Todo list ---
if self._todos:
@@ -218,20 +263,26 @@ class StickyComment:
if s == "completed":
box = "- [x]"
elif s == "in_progress":
box = "- [x]" # GitHub renders in_progress same as done; use ✅ marker
box = "- [ ]"
else:
box = "- [ ]"
content = t["content"]
if s == "in_progress":
content = f"{content} 🔄"
elif s == "completed":
content = f"~~{content}~~" if final else content
lines.append(f"{box} {content}")
lines.append("")
# --- Activity log (last 15 items) ---
recent = self._logs[-15:]
if recent:
# --- Summary section ---
if self._summary_lines:
lines.append("**Summary**")
lines.append("")
for entry in self._summary_lines[-10:]:
lines.append(f"{entry}")
lines.append("")
# --- Activity log (last 8 items, only while running) ---
if final is None and self._logs:
recent = self._logs[-8:]
lines.append("**Activity**")
lines.append("")
for entry in recent:
@@ -308,11 +359,31 @@ def _make_context_sink(sticky: StickyComment):
sticky.set_todos(todos)
else:
sticky.on_tool_start(agent_name, tool_name, tool_args)
# Capture branch name from args immediately
if tool_name == "github_create_branch":
bn = tool_args.get("branch_name", "")
if bn:
sticky.set_branch(bn)
elif et_val == "tool_call_end":
tool_name = data.get("tool_name", "")
tool_result = data.get("tool_result") or {}
if tool_name != "TodoWrite":
sticky.on_tool_end(event.agent_name or "", tool_name)
# Capture PR URL from result
if tool_name == "github_create_pull_request":
result_str = str(tool_result)
m = re.search(r'https://github\.com/[^\s"\)]+/pull/\d+', result_str)
if m:
pr_url = m.group(0)
pr_num_m = re.search(r'/pull/(\d+)', pr_url)
sticky.set_pr(pr_url, int(pr_num_m.group(1)) if pr_num_m else None)
# Also try dict result
if isinstance(tool_result, dict):
url = tool_result.get("url") or tool_result.get("html_url", "")
if url and "/pull/" in url:
pr_num_m = re.search(r'/pull/(\d+)', url)
sticky.set_pr(url, int(pr_num_m.group(1)) if pr_num_m else None)
except Exception:
pass
@@ -444,6 +515,7 @@ def github_triage(
sticky = StickyComment(
api_base=api_base, issue=issue, token=token,
task_type=task_type, title=ctx_title, run_url=run_url,
repo_name=repo_name,
)
sticky._logs.append(f"Context fetched for {task_type} #{issue}")
@@ -505,9 +577,9 @@ def github_triage(
pass
if success:
summary = f"✅ **Completed** – {task_type} #{issue} triaged successfully."
summary = f"✅ Completed – {task_type} #{issue} triaged successfully."
else:
summary = f"❌ **Failed** – {error_msg[:200]}"
summary = f"❌ Failed – {error_msg[:200]}"
sticky.finalize(success=success, summary=summary)
print("[green]✅ Final sticky comment updated.[/green]")