diff --git a/examples/templates/strict_pr_reviewer/__init__.py b/examples/templates/strict_pr_reviewer/__init__.py new file mode 100644 index 0000000000..ad418310b7 --- /dev/null +++ b/examples/templates/strict_pr_reviewer/__init__.py @@ -0,0 +1,38 @@ +""" +Strict PR Code Reviewer — read-only GitHub PR review (no code changes, no GitHub posts). + +Fetches PR metadata and file patches via the GitHub API, then delivers a strict written review. +""" + +from __future__ import annotations + +from .agent import ( + StrictPrReviewerAgent, + default_agent, + edges, + entry_node, + entry_points, + goal, + nodes, + pause_nodes, + terminal_nodes, +) +from .config import AgentMetadata, RuntimeConfig, default_config, metadata + +__version__ = "1.0.0" + +__all__ = [ + "AgentMetadata", + "RuntimeConfig", + "StrictPrReviewerAgent", + "default_agent", + "default_config", + "edges", + "entry_node", + "entry_points", + "goal", + "metadata", + "nodes", + "pause_nodes", + "terminal_nodes", +] diff --git a/examples/templates/strict_pr_reviewer/__main__.py b/examples/templates/strict_pr_reviewer/__main__.py new file mode 100644 index 0000000000..e4b35e6921 --- /dev/null +++ b/examples/templates/strict_pr_reviewer/__main__.py @@ -0,0 +1,224 @@ +""" +CLI entry point for Strict PR Code Reviewer. + +Uses AgentRuntime-style setup consistent with other example templates. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys + +import click + +from .agent import StrictPrReviewerAgent, default_agent + + +def setup_logging(verbose: bool = False, debug: bool = False) -> None: + """Configure logging for execution visibility.""" + if debug: + level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s" + elif verbose: + level, fmt = logging.INFO, "%(message)s" + else: + level, fmt = logging.WARNING, "%(levelname)s: %(message)s" + logging.basicConfig(level=level, format=fmt, stream=sys.stderr) + logging.getLogger("framework").setLevel(level) + + +@click.group() +@click.version_option(version="1.0.0") +def cli() -> None: + """Strict PR Code Reviewer — read-only GitHub pull request review.""" + pass + + +@cli.command() +@click.option("--quiet", "-q", is_flag=True, help="Only output result JSON") +@click.option("--verbose", "-v", is_flag=True, help="Show execution details") +@click.option("--debug", is_flag=True, help="Show debug logging") +def run(quiet: bool, verbose: bool, debug: bool) -> None: + """Execute the PR reviewer agent (requires interactive intake for PR details).""" + if not quiet: + setup_logging(verbose=verbose, debug=debug) + + result = asyncio.run(default_agent.run({})) + + output_data = { + "success": result.success, + "steps_executed": result.steps_executed, + "output": result.output, + } + if result.error: + output_data["error"] = result.error + + click.echo(json.dumps(output_data, indent=2, default=str)) + sys.exit(0 if result.success else 1) + + +@cli.command() +@click.option("--verbose", "-v", is_flag=True, help="Show execution details") +@click.option("--debug", is_flag=True, help="Show debug logging") +def tui(verbose: bool, debug: bool) -> None: + """Launch the TUI dashboard for interactive PR review.""" + setup_logging(verbose=verbose, debug=debug) + + try: + from framework.tui.app import AdenTUI + except ImportError: + click.echo( + "TUI requires the 'textual' package. Install with: uv pip install textual" + ) + sys.exit(1) + + from pathlib import Path + + from framework.llm import LiteLLMProvider + from framework.runner.tool_registry import ToolRegistry + from framework.runtime.agent_runtime import create_agent_runtime + from framework.runtime.event_bus import EventBus + from framework.runtime.execution_stream import EntryPointSpec + + async def run_with_tui() -> None: + agent = StrictPrReviewerAgent() + + agent._event_bus = EventBus() + agent._tool_registry = ToolRegistry() + + storage_path = Path.home() / ".hive" / "agents" / "strict_pr_reviewer" + storage_path.mkdir(parents=True, exist_ok=True) + + mcp_config_path = Path(__file__).parent / "mcp_servers.json" + if mcp_config_path.exists(): + agent._tool_registry.load_mcp_config(mcp_config_path) + + llm = LiteLLMProvider( + model=agent.config.model, + api_key=agent.config.api_key, + api_base=agent.config.api_base, + ) + + tools = list(agent._tool_registry.get_tools().values()) + tool_executor = agent._tool_registry.get_executor() + graph = agent._build_graph() + + runtime = create_agent_runtime( + graph=graph, + goal=agent.goal, + storage_path=storage_path, + entry_points=[ + EntryPointSpec( + id="start", + name="Start PR review", + entry_node="intake", + trigger_type="manual", + isolation_level="isolated", + ), + ], + llm=llm, + tools=tools, + tool_executor=tool_executor, + ) + + await runtime.start() + + try: + app = AdenTUI(runtime) + await app.run_async() + finally: + await runtime.stop() + + asyncio.run(run_with_tui()) + + +@cli.command() +@click.option("--json", "output_json", is_flag=True) +def info(output_json: bool) -> None: + """Show agent information.""" + info_data = default_agent.info() + if output_json: + click.echo(json.dumps(info_data, indent=2)) + else: + click.echo(f"Agent: {info_data['name']}") + click.echo(f"Version: {info_data['version']}") + click.echo(f"Description: {info_data['description']}") + click.echo(f"\nNodes: {', '.join(info_data['nodes'])}") + click.echo(f"Client-facing: {', '.join(info_data['client_facing_nodes'])}") + click.echo(f"Entry: {info_data['entry_node']}") + click.echo(f"Terminal: {', '.join(info_data['terminal_nodes'])}") + + +@cli.command() +def validate() -> None: + """Validate agent structure.""" + validation = default_agent.validate() + if validation["valid"]: + click.echo("Agent is valid") + for warning in validation["warnings"]: + click.echo(f" WARNING: {warning}") + else: + click.echo("Agent has errors:") + for error in validation["errors"]: + click.echo(f" ERROR: {error}") + sys.exit(0 if validation["valid"] else 1) + + +@cli.command() +@click.option("--verbose", "-v", is_flag=True) +def shell(verbose: bool) -> None: + """Interactive PR review session (CLI, no TUI).""" + asyncio.run(_interactive_shell(verbose)) + + +async def _interactive_shell(verbose: bool = False) -> None: + """Async interactive shell.""" + setup_logging(verbose=verbose) + + click.echo("=== Strict PR Code Reviewer ===") + click.echo("Provide a PR URL or owner/repo/# when prompted. Type 'quit' to exit.\n") + + agent = StrictPrReviewerAgent() + await agent.start() + + try: + while True: + try: + user_input = await asyncio.get_event_loop().run_in_executor( + None, input, "PR> " + ) + if user_input.lower() in ["quit", "exit", "q"]: + click.echo("Goodbye!") + break + + click.echo("\nRunning review workflow...\n") + + result = await agent.trigger_and_wait("start", {}) + + if result is None: + click.echo("\n[Execution timed out]\n") + continue + + if result.success: + out = result.output + if isinstance(out, dict) and "final_report" in out: + click.echo(out["final_report"]) + click.echo() + else: + click.echo(f"\nFailed: {result.error}\n") + + except KeyboardInterrupt: + click.echo("\nGoodbye!") + break + except Exception as e: + click.echo(f"Error: {e}", err=True) + import traceback + + traceback.print_exc() + finally: + await agent.stop() + + +if __name__ == "__main__": + cli() diff --git a/examples/templates/strict_pr_reviewer/agent.py b/examples/templates/strict_pr_reviewer/agent.py new file mode 100644 index 0000000000..b493d50e2d --- /dev/null +++ b/examples/templates/strict_pr_reviewer/agent.py @@ -0,0 +1,308 @@ +"""Agent graph construction for Strict PR Code Reviewer.""" + +from __future__ import annotations + +from pathlib import Path + +from framework.graph import Constraint, EdgeCondition, EdgeSpec, Goal, SuccessCriterion +from framework.graph.edge import GraphSpec +from framework.graph.executor import ExecutionResult, GraphExecutor +from framework.llm import LiteLLMProvider +from framework.runner.tool_registry import ToolRegistry +from framework.runtime.core import Runtime +from framework.runtime.event_bus import EventBus + +from .config import default_config, metadata +from .nodes import ( + deliver_report_node, + fetch_pr_node, + intake_node, + strict_review_node, +) + +goal = Goal( + id="strict-pr-code-review", + name="Strict PR Code Reviewer", + description=( + "Read-only review of GitHub pull requests: fetch metadata and patches via the API, " + "then produce a strict, balanced tech-lead review (correctness, security, design, " + "performance/ops, style). The agent never modifies code and never posts to GitHub." + ), + success_criteria=[ + SuccessCriterion( + id="sc-evidence-backed", + description="Findings cite file paths and patch evidence where available", + metric="evidence_per_finding", + target="100%", + weight=0.25, + ), + SuccessCriterion( + id="sc-blocking-separated", + description="Blocking vs non-blocking issues are clearly separated", + metric="blocking_section_present", + target="true", + weight=0.2, + ), + SuccessCriterion( + id="sc-remediation", + description="Each blocking issue includes concrete remediation direction", + metric="blocking_with_remediation", + target="100%", + weight=0.25, + ), + SuccessCriterion( + id="sc-read-only", + description="Review states no repository or GitHub-side mutations were performed", + metric="read_only_acknowledged", + target="true", + weight=0.15, + ), + SuccessCriterion( + id="sc-delivered", + description="User receives a structured Markdown final report", + metric="final_report_delivered", + target="true", + weight=0.15, + ), + ], + constraints=[ + Constraint( + id="c-no-code-mutation", + description=( + "Never modify, write, or rewrite repository code — no apply_diff, patches, " + "file writes, or shell commands that change files" + ), + constraint_type="hard", + category="safety", + ), + Constraint( + id="c-no-github-writes", + description=( + "Never post PR comments, reviews, commits, merges, or create/update issues " + "or pull requests on GitHub" + ), + constraint_type="hard", + category="safety", + ), + Constraint( + id="c-no-fabricated-diff", + description="Never invent diff hunks or file contents; only use fetched API data", + constraint_type="hard", + category="quality", + ), + Constraint( + id="c-suggestions-only", + description=( + "Remediation must be prose or pseudo-code guidance only — no full-file " + "drop-in replacements presented as ready-to-apply patches" + ), + constraint_type="hard", + category="quality", + ), + ], +) + +nodes = [ + intake_node, + fetch_pr_node, + strict_review_node, + deliver_report_node, +] + +edges = [ + EdgeSpec( + id="intake-to-fetch-pr", + source="intake", + target="fetch-pr", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), + EdgeSpec( + id="fetch-pr-to-strict-review", + source="fetch-pr", + target="strict-review", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), + EdgeSpec( + id="strict-review-to-deliver-report", + source="strict-review", + target="deliver-report", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), +] + +entry_node = "intake" +entry_points = {"start": "intake"} +pause_nodes = [] +terminal_nodes = ["deliver-report"] + + +class StrictPrReviewerAgent: + """Strict PR Code Reviewer — 4-node pipeline: intake → fetch → review → deliver.""" + + def __init__(self, config=None): + self.config = config or default_config + self.goal = goal + self.nodes = nodes + self.edges = edges + self.entry_node = entry_node + self.entry_points = entry_points + self.pause_nodes = pause_nodes + self.terminal_nodes = terminal_nodes + self._executor: GraphExecutor | None = None + self._graph: GraphSpec | None = None + self._event_bus: EventBus | None = None + self._tool_registry: ToolRegistry | None = None + + def _build_graph(self) -> GraphSpec: + """Build the GraphSpec.""" + return GraphSpec( + id="strict-pr-reviewer-graph", + goal_id=self.goal.id, + version="1.0.0", + entry_node=self.entry_node, + entry_points=self.entry_points, + terminal_nodes=self.terminal_nodes, + pause_nodes=self.pause_nodes, + nodes=self.nodes, + edges=self.edges, + default_model=self.config.model, + max_tokens=self.config.max_tokens, + loop_config={ + "max_iterations": 80, + "max_tool_calls_per_turn": 40, + "max_history_tokens": 32000, + }, + ) + + def _setup(self) -> GraphExecutor: + """Set up the executor with all components.""" + storage_path = Path.home() / ".hive" / "strict_pr_reviewer" + storage_path.mkdir(parents=True, exist_ok=True) + + self._event_bus = EventBus() + self._tool_registry = ToolRegistry() + + mcp_config_path = Path(__file__).parent / "mcp_servers.json" + if mcp_config_path.exists(): + self._tool_registry.load_mcp_config(mcp_config_path) + + llm = LiteLLMProvider( + model=self.config.model, + api_key=self.config.api_key, + api_base=self.config.api_base, + ) + + tool_executor = self._tool_registry.get_executor() + tools = list(self._tool_registry.get_tools().values()) + + self._graph = self._build_graph() + runtime = Runtime(storage_path) + + self._executor = GraphExecutor( + runtime=runtime, + llm=llm, + tools=tools, + tool_executor=tool_executor, + event_bus=self._event_bus, + storage_path=storage_path, + loop_config=self._graph.loop_config, + ) + + return self._executor + + async def start(self) -> None: + """Set up the agent (initialize executor and tools).""" + if self._executor is None: + self._setup() + + async def stop(self) -> None: + """Clean up resources.""" + self._executor = None + self._event_bus = None + + async def trigger_and_wait( + self, + entry_point: str, + input_data: dict, + timeout: float | None = None, + session_state: dict | None = None, + ) -> ExecutionResult | None: + """Execute the graph and wait for completion.""" + if self._executor is None: + raise RuntimeError("Agent not started. Call start() first.") + if self._graph is None: + raise RuntimeError("Graph not built. Call start() first.") + + return await self._executor.execute( + graph=self._graph, + goal=self.goal, + input_data=input_data, + session_state=session_state, + ) + + async def run(self, context: dict, session_state=None) -> ExecutionResult: + """Run the agent (convenience method for single execution).""" + await self.start() + try: + result = await self.trigger_and_wait( + "start", context, session_state=session_state + ) + return result or ExecutionResult(success=False, error="Execution timeout") + finally: + await self.stop() + + def info(self): + """Get agent information.""" + return { + "name": metadata.name, + "version": metadata.version, + "description": metadata.description, + "goal": { + "name": self.goal.name, + "description": self.goal.description, + }, + "nodes": [n.id for n in self.nodes], + "edges": [e.id for e in self.edges], + "entry_node": self.entry_node, + "entry_points": self.entry_points, + "pause_nodes": self.pause_nodes, + "terminal_nodes": self.terminal_nodes, + "client_facing_nodes": [n.id for n in self.nodes if n.client_facing], + } + + def validate(self): + """Validate agent structure.""" + errors = [] + warnings = [] + + node_ids = {node.id for node in self.nodes} + for edge in self.edges: + if edge.source not in node_ids: + errors.append(f"Edge {edge.id}: source '{edge.source}' not found") + if edge.target not in node_ids: + errors.append(f"Edge {edge.id}: target '{edge.target}' not found") + + if self.entry_node not in node_ids: + errors.append(f"Entry node '{self.entry_node}' not found") + + for terminal in self.terminal_nodes: + if terminal not in node_ids: + errors.append(f"Terminal node '{terminal}' not found") + + for ep_id, node_id in self.entry_points.items(): + if node_id not in node_ids: + errors.append( + f"Entry point '{ep_id}' references unknown node '{node_id}'" + ) + + return { + "valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + } + + +default_agent = StrictPrReviewerAgent() diff --git a/examples/templates/strict_pr_reviewer/config.py b/examples/templates/strict_pr_reviewer/config.py new file mode 100644 index 0000000000..543f4f85c7 --- /dev/null +++ b/examples/templates/strict_pr_reviewer/config.py @@ -0,0 +1,30 @@ +"""Runtime configuration for Strict PR Code Reviewer.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from framework.config import RuntimeConfig + +__all__ = ["AgentMetadata", "RuntimeConfig", "default_config", "metadata"] + +default_config = RuntimeConfig() + + +@dataclass +class AgentMetadata: + name: str = "Strict PR Code Reviewer" + version: str = "1.0.0" + description: str = ( + "Read-only automated code review for GitHub pull requests. Acts as a demanding " + "tech lead: reports issues and remediation guidance only — never modifies code, " + "never posts review comments on GitHub." + ) + intro_message: str = ( + "I'm a strict, read-only PR reviewer. Give me a GitHub PR URL or owner, repo, and " + "PR number. I'll fetch the diff via the API and return a written review only — " + "no code changes and no posting to GitHub." + ) + + +metadata = AgentMetadata() diff --git a/examples/templates/strict_pr_reviewer/flowchart.json b/examples/templates/strict_pr_reviewer/flowchart.json new file mode 100644 index 0000000000..9e9d2ad26a --- /dev/null +++ b/examples/templates/strict_pr_reviewer/flowchart.json @@ -0,0 +1,183 @@ +{ + "original_draft": { + "agent_name": "strict_pr_reviewer", + "goal": "Read-only review of GitHub pull requests: fetch metadata and patches via the API, then produce a strict, balanced tech-lead review (correctness, security, design, performance/ops, style). The agent never modifies code and never posts to GitHub.", + "description": "", + "success_criteria": [ + "Findings cite file paths and patch evidence where available", + "Blocking vs non-blocking issues are clearly separated", + "Each blocking issue includes concrete remediation direction", + "Review states no repository or GitHub-side mutations were performed", + "User receives a structured Markdown final report" + ], + "constraints": [ + "Never modify, write, or rewrite repository code \u2014 no apply_diff, patches, file writes, or shell commands that change files", + "Never post PR comments, reviews, commits, merges, or create/update issues or pull requests on GitHub", + "Never invent diff hunks or file contents; only use fetched API data", + "Remediation must be prose or pseudo-code guidance only \u2014 no full-file drop-in replacements presented as ready-to-apply patches" + ], + "nodes": [ + { + "id": "intake", + "name": "Intake", + "description": "Greet the user, accept a GitHub PR URL or owner/repo/pull number, and optional notes (e.g. files or areas to de-emphasize).", + "node_type": "event_loop", + "tools": [], + "input_keys": [], + "output_keys": [ + "owner", + "repo", + "pull_number", + "scope_notes" + ], + "success_criteria": "", + "sub_agents": [], + "flowchart_type": "start", + "flowchart_shape": "stadium", + "flowchart_color": "#8aad3f" + }, + { + "id": "fetch-pr", + "name": "Fetch PR", + "description": "Load pull request metadata and all changed-file patches from GitHub using read-only API tools. Paginates until all files are retrieved.", + "node_type": "event_loop", + "tools": [ + "github_get_pull_request", + "github_list_pull_request_files" + ], + "input_keys": [ + "owner", + "repo", + "pull_number", + "scope_notes" + ], + "output_keys": [ + "pr_context" + ], + "success_criteria": "", + "sub_agents": [], + "flowchart_type": "process", + "flowchart_shape": "rectangle", + "flowchart_color": "#b5a575" + }, + { + "id": "strict-review", + "name": "Strict review", + "description": "Perform a strict, balanced tech-lead review: correctness, security, design, performance/ops, and style. No tools; output structured findings only.", + "node_type": "event_loop", + "tools": [], + "input_keys": [ + "pr_context" + ], + "output_keys": [ + "review_draft" + ], + "success_criteria": "", + "sub_agents": [], + "flowchart_type": "io", + "flowchart_shape": "parallelogram", + "flowchart_color": "#d06818" + }, + { + "id": "deliver-report", + "name": "Deliver report", + "description": "Present the final Markdown review to the user. No tool calls.", + "node_type": "event_loop", + "tools": [], + "input_keys": [ + "review_draft" + ], + "output_keys": [ + "final_report" + ], + "success_criteria": "", + "sub_agents": [], + "flowchart_type": "terminal", + "flowchart_shape": "stadium", + "flowchart_color": "#b5453a" + } + ], + "edges": [ + { + "id": "edge-0", + "source": "intake", + "target": "fetch-pr", + "condition": "on_success", + "description": "", + "label": "" + }, + { + "id": "edge-1", + "source": "fetch-pr", + "target": "strict-review", + "condition": "on_success", + "description": "", + "label": "" + }, + { + "id": "edge-2", + "source": "strict-review", + "target": "deliver-report", + "condition": "on_success", + "description": "", + "label": "" + } + ], + "entry_node": "intake", + "terminal_nodes": [ + "deliver-report" + ], + "flowchart_legend": { + "start": { + "shape": "stadium", + "color": "#8aad3f" + }, + "terminal": { + "shape": "stadium", + "color": "#b5453a" + }, + "process": { + "shape": "rectangle", + "color": "#b5a575" + }, + "decision": { + "shape": "diamond", + "color": "#d89d26" + }, + "io": { + "shape": "parallelogram", + "color": "#d06818" + }, + "document": { + "shape": "document", + "color": "#c4b830" + }, + "database": { + "shape": "cylinder", + "color": "#508878" + }, + "subprocess": { + "shape": "subroutine", + "color": "#887a48" + }, + "browser": { + "shape": "hexagon", + "color": "#cc8850" + } + } + }, + "flowchart_map": { + "intake": [ + "intake" + ], + "fetch-pr": [ + "fetch-pr" + ], + "strict-review": [ + "strict-review" + ], + "deliver-report": [ + "deliver-report" + ] + } +} \ No newline at end of file diff --git a/examples/templates/strict_pr_reviewer/mcp_servers.json b/examples/templates/strict_pr_reviewer/mcp_servers.json new file mode 100644 index 0000000000..ab698bdf49 --- /dev/null +++ b/examples/templates/strict_pr_reviewer/mcp_servers.json @@ -0,0 +1,9 @@ +{ + "hive-tools": { + "transport": "stdio", + "command": "uv", + "args": ["run", "python", "mcp_server.py", "--stdio"], + "cwd": "../../../tools", + "description": "Hive tools MCP server (includes read-only GitHub PR tools)" + } +} diff --git a/examples/templates/strict_pr_reviewer/nodes/__init__.py b/examples/templates/strict_pr_reviewer/nodes/__init__.py new file mode 100644 index 0000000000..d033f87cd9 --- /dev/null +++ b/examples/templates/strict_pr_reviewer/nodes/__init__.py @@ -0,0 +1,201 @@ +"""Node definitions for Strict PR Code Reviewer.""" + +from __future__ import annotations + +from framework.graph import NodeSpec + +# Intake: resolve PR target and optional scope notes from the user. +intake_node = NodeSpec( + id="intake", + name="Intake", + description=( + "Greet the user, accept a GitHub PR URL or owner/repo/pull number, and optional " + "notes (e.g. files or areas to de-emphasize)." + ), + node_type="event_loop", + client_facing=True, + input_keys=["user_request"], + output_keys=["owner", "repo", "pull_number", "scope_notes"], + system_prompt="""\ +You are the intake assistant for a **Strict PR Code Reviewer** agent. + +**Rules (non-negotiable):** +- You do NOT review code in this node. You only collect identifiers. +- Do NOT call tools. Text only. +- **CRITICAL:** Always parse PR from `user_request` in input_data. NEVER reuse pre-existing \ +memory values (owner, repo, pull_number, scope_notes). Each review must start fresh. + +**STEP 1 — Parse PR from user_request (if provided)** +- Check if `user_request` exists in input_data. +- If yes: Extract the GitHub pull request URL from the text (format: \ +`https://github.com/owner/repo/pull/123` or `owner/repo#123`). +- Parse: `owner`, `repo`, and numeric `pull_number` as integers. +- If scope notes are mentioned in user_request, capture them; otherwise empty string. +- Set all four outputs directly. Skip to STEP 4. + +**STEP 2 — Greet briefly** using the tone of a senior tech lead: direct, professional. + +**STEP 3 — Ask the user** for: +- A GitHub pull request URL (`https://github.com/owner/repo/pull/123`), **or** +- `owner`, `repo`, and PR number separately. + +Optionally ask if they want to **scope** the review (e.g. "ignore generated files", +"focus on security", "skip lockfiles"). If they decline, scope is empty. + +**STEP 4 — After the user responds** (or after parsing from user_request), parse the PR target: +- From a URL: extract `owner`, `repo`, and numeric `pull_number`. +- If anything is ambiguous or invalid, ask one clarifying question via ask_user(), then stop \ +this turn. + +**STEP 5 — Call set_output** with all keys in separate logical steps as required by the \ +runtime (you may set each key when ready): +- set_output("owner", "") +- set_output("repo", "") +- set_output("pull_number", ) # integer only +- set_output("scope_notes", "") + +If the user did not provide scope notes, use an empty string for `scope_notes`. +""", + tools=[], +) + +# Fetch PR metadata and per-file patches from the GitHub API (read-only). +fetch_pr_node = NodeSpec( + id="fetch-pr", + name="Fetch PR", + description=( + "Load pull request metadata and all changed-file patches from GitHub using " + "read-only API tools. Paginates until all files are retrieved." + ), + node_type="event_loop", + input_keys=["owner", "repo", "pull_number", "scope_notes"], + output_keys=["pr_context"], + system_prompt="""\You fetch **read-only** context for a strict PR review. This node MUST guarantee that ALL \ +changed files are retrieved. + +**Hard constraints (non-negotiable):** +- Use **only** `github_get_pull_request` and `github_list_pull_request_files`. +- Do **not** use any tool that writes files, runs shell commands, applies diffs, or posts \ +to GitHub (no comments, no merges, new issues/PRs). +- Do **not** invent diff content. If a file has no `patch` field (binary/generated), \ +note that honestly. +- **ALL files must be fetched.** Pagination is mandatory. Do not stop early. + +**Inputs** (already in context): `owner`, `repo`, `pull_number`, `scope_notes`. + +**Procedure — MANDATORY PAGINATION:** + +**STEP 1 — Fetch PR metadata** +- Call `github_get_pull_request(owner, repo, pull_number)` once. +- Extract and preserve: title, body, state, html_url, base/head ref names, author login. + +**STEP 2 — Fetch ALL changed files with enforced pagination** +- Initialize: `page = 1`, `per_page = 100`, `all_files = []`, `file_count = 0`. +- **Loop MUST run at least twice** (once to fetch, once to confirm no more files): + * Call `github_list_pull_request_files(owner=owner, repo=repo, pull_number=pull_number, \ +page=page, per_page=per_page)`. + * Count files returned in this page: `page_file_count = len(response)`. + * Append all files from this page to `all_files`. + * Log page result: e.g., "Page 1: received 15 files (cumulative: 15)". + * If `page_file_count < per_page`, pagination is DONE — break loop. + * Otherwise, increment `page = page + 1` and repeat. + +**STEP 3 — Validate file count** +- After pagination completes, verify you have files. If `len(all_files) == 0`, this is an \ +API error — document it and escalate. +- Log final count: "Total files retrieved: X". + +**STEP 4 — Assemble pr_context text** +- Start with: PR metadata summary (title, state, URL, author) + scope_notes. +- For each file in `all_files`: + * Filename + * Status (added/modified/deleted/renamed) + * Additions/deletions counts + * Full `patch` text when present; if missing, state "no patch available". + +**STEP 5 — Output** +- Call set_output("pr_context", ""). + +**Error handling:** +- If API returns error dict (e.g., 404, 403), set `pr_context` to error summary. Do NOT \ +fabricate patches. +- If pagination loop fails (API error mid-pagination), log all files fetched so far and \ +set error in context so reviewer can see what was incomplete. + +**Critical reminders:** +- Pagination loop MUST check page size each iteration to detect end-of-list. +- Log file counts explicitly so you can verify all files were retrieved. +- Do NOT assume all files fit on one page even if count is small. +- After all pages are fetched, you have ALL files for the review.""", + tools=["github_get_pull_request", "github_list_pull_request_files"], +) + +# Strict balanced review from assembled context only (no tools). +strict_review_node = NodeSpec( + id="strict-review", + name="Strict review", + description=( + "Perform a strict, balanced tech-lead review: correctness, security, design, " + "performance/ops, and style. No tools; output structured findings only." + ), + node_type="event_loop", + input_keys=["pr_context"], + output_keys=["review_draft"], + system_prompt="""\ +You are the **strictest credible tech lead** on the team. You are reviewing a pull request \ +using only the text in `pr_context` (metadata + patches). You **never** modify code and \ +**never** post to GitHub. + +**Tone:** Direct, specific, fair. No padding, no platitudes. Call out real risk. + +**Coverage (balanced):** +- Correctness & edge cases, API contracts, error handling +- Security & privacy (secrets, authz, injection, unsafe defaults) +- Maintainability (boundaries, naming, duplication, testability) +- Performance & operational risk (hot paths, N+1, timeouts, observability) when relevant +- Style & consistency when it affects readability or bugs + +**Rules:** +- Every finding must cite **evidence**: file path and, when possible, a line or hunk from \ +the patch. If `pr_context` shows an API error, produce a short failure explanation instead \ +of fake findings. +- Separate **blocking** (would not merge / must fix) vs **non-blocking** (should fix, \ +nits). +- For each blocking issue: state **why** it is blocking and give **concrete remediation \ +direction** (prose or pseudo-code). Do not output a full rewritten file. +- If patches are missing for some files, say what you could not assess. +- Explicitly state in the draft that **no repository changes were made** as part of this \ +review. + +**Output:** Call set_output("review_draft", ""). + +**NO tool calls** in this node — text reasoning only. +""", + tools=[], +) + +# Final user-facing delivery (no tools). +deliver_report_node = NodeSpec( + id="deliver-report", + name="Deliver report", + description="Present the final Markdown review to the user. No tool calls.", + node_type="event_loop", + client_facing=True, + input_keys=["review_draft"], + output_keys=["final_report"], + system_prompt="""\ +You present the completed PR review to the user. + +**Input:** `review_draft` contains the full Markdown review. + +**Rules:** +- Do NOT call tools. +- Do NOT edit the substance of the review unless fixing obvious formatting (headings/lists). +- Reply with a short intro line (one sentence), then the full content of `review_draft`. +- Call set_output("final_report", ""). + +Keep the intro minimal so the review remains scannable. +""", + tools=[], +) diff --git a/examples/templates/strict_pr_reviewer/tests/conftest.py b/examples/templates/strict_pr_reviewer/tests/conftest.py new file mode 100644 index 0000000000..206b2b6485 --- /dev/null +++ b/examples/templates/strict_pr_reviewer/tests/conftest.py @@ -0,0 +1,36 @@ +"""Test fixtures.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_repo_root = Path(__file__).resolve().parents[4] +for _p in ["examples/templates", "core"]: + _path = str(_repo_root / _p) + if _path not in sys.path: + sys.path.insert(0, _path) + +AGENT_PATH = str(Path(__file__).resolve().parents[1]) + + +@pytest.fixture(scope="session") +def agent_module(): + """Import the agent package for structural validation.""" + import importlib + + return importlib.import_module(Path(AGENT_PATH).name) + + +@pytest.fixture(scope="session") +def runner_loaded(): + """Load the agent through AgentRunner (structural only, no LLM needed).""" + from framework.runner.runner import AgentRunner + + return AgentRunner.load( + AGENT_PATH, + skip_credential_validation=True, + interactive=False, + ) diff --git a/examples/templates/strict_pr_reviewer/tests/test_structure.py b/examples/templates/strict_pr_reviewer/tests/test_structure.py new file mode 100644 index 0000000000..d3964d6202 --- /dev/null +++ b/examples/templates/strict_pr_reviewer/tests/test_structure.py @@ -0,0 +1,81 @@ +"""Structural tests for Strict PR Code Reviewer.""" + +from __future__ import annotations + + +class TestAgentStructure: + """Test agent graph structure.""" + + def test_goal_defined(self, agent_module): + """Goal is properly defined.""" + assert hasattr(agent_module, "goal") + assert agent_module.goal.id == "strict-pr-code-review" + assert len(agent_module.goal.success_criteria) == 5 + + def test_hard_constraints_read_only(self, agent_module): + """Hard constraints forbid code mutation and GitHub writes.""" + ids = {c.id for c in agent_module.goal.constraints} + assert "c-no-code-mutation" in ids + assert "c-no-github-writes" in ids + + def test_nodes_defined(self, agent_module): + """All nodes are defined.""" + assert hasattr(agent_module, "nodes") + node_ids = {n.id for n in agent_module.nodes} + assert node_ids == {"intake", "fetch-pr", "strict-review", "deliver-report"} + + def test_edges_defined(self, agent_module): + """Edges connect the linear pipeline.""" + assert hasattr(agent_module, "edges") + pairs = {(e.source, e.target) for e in agent_module.edges} + assert ("intake", "fetch-pr") in pairs + assert ("fetch-pr", "strict-review") in pairs + assert ("strict-review", "deliver-report") in pairs + assert len(agent_module.edges) == 3 + + def test_entry_points(self, agent_module): + """Entry points configured.""" + assert hasattr(agent_module, "entry_points") + assert agent_module.entry_points["start"] == "intake" + + def test_terminal_nodes(self, agent_module): + """Terminal node is deliver-report.""" + assert hasattr(agent_module, "terminal_nodes") + assert agent_module.terminal_nodes == ["deliver-report"] + + def test_client_facing_nodes(self, agent_module): + """Intake and deliver-report face the user.""" + client_facing = {n.id for n in agent_module.nodes if n.client_facing} + assert client_facing == {"intake", "deliver-report"} + + def test_fetch_node_github_tools_only(self, agent_module): + """Fetch node uses read-only GitHub PR tools.""" + fetch = next(n for n in agent_module.nodes if n.id == "fetch-pr") + assert set(fetch.tools) == { + "github_get_pull_request", + "github_list_pull_request_files", + } + + def test_review_nodes_have_no_tools(self, agent_module): + """Strict review and deliver nodes do not expose tools.""" + for nid in ("strict-review", "deliver-report"): + node = next(n for n in agent_module.nodes if n.id == nid) + assert node.tools == [] + + +class TestRunnerLoad: + """Test AgentRunner can load the agent.""" + + def test_runner_load_succeeds(self, runner_loaded): + """AgentRunner.load() succeeds.""" + assert runner_loaded is not None + + def test_runner_has_goal(self, runner_loaded): + """Runner has goal after load.""" + assert runner_loaded.goal is not None + assert runner_loaded.goal.id == "strict-pr-code-review" + + def test_runner_has_nodes(self, runner_loaded): + """Runner has nodes after load.""" + assert runner_loaded.graph is not None + assert len(runner_loaded.graph.nodes) == 4 diff --git a/tools/src/aden_tools/credentials/github.py b/tools/src/aden_tools/credentials/github.py index 203fc4cc32..4dada8dc63 100644 --- a/tools/src/aden_tools/credentials/github.py +++ b/tools/src/aden_tools/credentials/github.py @@ -19,6 +19,7 @@ "github_update_issue", "github_list_pull_requests", "github_get_pull_request", + "github_list_pull_request_files", "github_create_pull_request", "github_search_code", "github_list_branches", diff --git a/tools/src/aden_tools/credentials/integrations.py b/tools/src/aden_tools/credentials/integrations.py index aa28591388..9b08bc6848 100644 --- a/tools/src/aden_tools/credentials/integrations.py +++ b/tools/src/aden_tools/credentials/integrations.py @@ -19,6 +19,7 @@ "github_update_issue", "github_list_pull_requests", "github_get_pull_request", + "github_list_pull_request_files", "github_create_pull_request", "github_search_code", "github_list_branches", diff --git a/tools/src/aden_tools/tools/github_tool/github_tool.py b/tools/src/aden_tools/tools/github_tool/github_tool.py index b1d99a52be..5777310def 100644 --- a/tools/src/aden_tools/tools/github_tool/github_tool.py +++ b/tools/src/aden_tools/tools/github_tool/github_tool.py @@ -313,6 +313,29 @@ def get_pull_request( ) return self._handle_response(response) + def list_pull_request_files( + self, + owner: str, + repo: str, + pull_number: int, + page: int = 1, + per_page: int = 100, + ) -> dict[str, Any]: + """List files changed in a pull request (includes per-file patch hunks when available).""" + owner = _sanitize_path_param(owner, "owner") + repo = _sanitize_path_param(repo, "repo") + params = { + "per_page": min(max(1, per_page), 100), + "page": max(1, page), + } + response = httpx.get( + f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pull_number}/files", + headers=self._headers, + params=params, + timeout=60.0, + ) + return self._handle_response(response) + def create_pull_request( self, owner: str, @@ -912,6 +935,44 @@ def github_get_pull_request( except httpx.RequestError as e: return {"error": _sanitize_error_message(e)} + @mcp.tool() + def github_list_pull_request_files( + owner: str, + repo: str, + pull_number: int, + page: int = 1, + per_page: int = 100, + account: str = "", + ) -> dict: + """ + List files changed in a pull request with metadata and patch hunks. + + Each file entry may include: filename, status (added/modified/removed/renamed), + additions, deletions, changes, and patch (unified diff; omitted for very large + files). Paginate with page (1-based) if a PR touches many files. + + Args: + owner: Repository owner or organization + repo: Repository name + pull_number: Pull request number + page: Results page (default 1) + per_page: Page size 1-100 (default 100) + + Returns: + Dict with success/data (list of file objects) or error + """ + client = _get_client(account) + if isinstance(client, dict): + return client + try: + return client.list_pull_request_files(owner, repo, pull_number, page, per_page) + except ValueError as e: + return {"error": str(e)} + except httpx.TimeoutException: + return {"error": "Request timed out"} + except httpx.RequestError as e: + return {"error": _sanitize_error_message(e)} + @mcp.tool() def github_create_pull_request( owner: str, diff --git a/tools/tests/tools/test_github_tool.py b/tools/tests/tools/test_github_tool.py index 81b10befae..e5cc7e7deb 100644 --- a/tools/tests/tools/test_github_tool.py +++ b/tools/tests/tools/test_github_tool.py @@ -5,7 +5,7 @@ - _GitHubClient methods (repositories, issues, PRs, search, branches) - Error handling (API errors, timeout, network errors) - Credential retrieval (CredentialStoreAdapter vs env var) -- All 15 MCP tool functions +- All GitHub MCP tool functions (including list PR files) """ from __future__ import annotations @@ -228,6 +228,29 @@ def test_get_pull_request(self, mock_get): assert result["success"] is True assert result["data"]["title"] == "Test PR" + @patch("aden_tools.tools.github_tool.github_tool.httpx.get") + def test_list_pull_request_files(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "filename": "src/a.py", + "status": "modified", + "additions": 2, + "deletions": 1, + "patch": "@@ -1 +1 @@\n-old\n+new", + }, + ] + mock_get.return_value = mock_response + + result = self.client.list_pull_request_files("owner", "repo", 1, page=1, per_page=50) + + assert result["success"] is True + assert len(result["data"]) == 1 + assert result["data"][0]["filename"] == "src/a.py" + called_url = mock_get.call_args[0][0] + assert called_url.endswith("/repos/owner/repo/pulls/1/files") + @patch("aden_tools.tools.github_tool.github_tool.httpx.post") def test_create_pull_request(self, mock_post): mock_response = MagicMock() @@ -545,6 +568,24 @@ def test_get_pull_request_success(self, mock_get, mcp): assert result["success"] is True + @patch("aden_tools.tools.github_tool.github_tool.httpx.get") + def test_list_pull_request_files_success(self, mock_get, mcp): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"filename": "x.py", "status": "added", "patch": "@@ ..."}, + ] + mock_get.return_value = mock_response + + with patch("os.getenv", return_value="ghp_test"): + register_tools(mcp, credentials=None) + list_files = mcp._tool_manager._tools["github_list_pull_request_files"].fn + + result = list_files(owner="owner", repo="repo", pull_number=42) + + assert result["success"] is True + assert len(result["data"]) == 1 + @patch("aden_tools.tools.github_tool.github_tool.httpx.post") def test_create_pull_request_success(self, mock_post, mcp): mock_response = MagicMock()