Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions examples/skills_code_review_agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Skills Code Review Agent

This example implements Issue #92: a skills-based automatic code review agent with sandbox execution, SQLite persistence, Filter governance, redaction, deduplication and audit reports.

## Quick Start

This example follows the same layout as `examples/quickstart`: keep the runnable
entrypoint at the example root and put agent construction, prompts, config and
tools under `agent/`.

```text
examples/skills_code_review_agent/
├── README.md
├── run_agent.py
├── agent/
│ ├── __init__.py
│ ├── agent.py
│ ├── config.py
│ ├── prompts.py
│ └── tools.py
├── skills/
│ └── code-review/
│ ├── SKILL.md
│ ├── rules/
│ │ └── README.md
│ └── scripts/
│ ├── parse_diff.py
│ └── static_rules.py
├── fixtures/
├── sample_outputs/
└── schema.sql
```

Run one public fixture in dry-run / fake-model mode:

```bash
python examples/skills_code_review_agent/run_agent.py --fixture security_issue --dry-run --output-dir tmp/code_review_security --db-path tmp/code_review_security/review.sqlite3
```

Run all public fixtures:

```bash
python -m pytest tests/examples/test_skills_code_review_agent.py
```

Query the database by task id:

```bash
python examples/skills_code_review_agent/run_agent.py --db-path tmp/code_review_security/review.sqlite3 --query-task-id <task_id>
```

The CLI accepts:

- `--diff-file`: unified diff or PR patch.
- `--repo-path`: local git worktree, reviewed via `git diff`.
- `--path-list-file`: paths to review inside `--repo-path`.
- `--fixture`: fixture name under `fixtures/`.

Outputs are always:

- `review_report.json`
- `review_report.md`
- SQLite rows for task, sandbox runs, filter intercepts, findings, metrics and report.

The deterministic CLI executes the bundled `skills/code-review` scripts directly so it can run without a model key. `agent/tools.py` also exposes `create_review_skill_tool_set()` for wiring the same Skill into a regular `LlmAgent`.
`agent/agent.py` provides that optional `LlmAgent` wrapper, and `agent/config.py` reads the same `TRPC_AGENT_API_KEY`, `TRPC_AGENT_BASE_URL` and `TRPC_AGENT_MODEL_NAME` environment variables used by the quickstart examples. `run_agent.py` remains the acceptance-test entrypoint because it is deterministic and does not need external model credentials.

## Runtime Modes

Production mode is `--runtime container`, which runs skill scripts in a Docker workspace with network disabled and the same `skills/`, `work/` and `out/` layout used by the framework workspace tools. `--dry-run` and `--fake-model` use a deterministic local workspace fallback so the parsing, sandbox, Filter and database chain can be tested without model credentials.

Sandbox execution has a timeout, output byte limit, environment allowlist and secret redaction. Timeouts and failures are recorded as sandbox runs and manual-review items; they do not crash the review.

## Public Fixtures

- `no_issue`: benign change with tests.
- `security_issue`: `shell=True` and `eval`.
- `async_resource_leak`: unscoped `aiohttp.ClientSession` and unobserved background task.
- `db_lifecycle_issue`: unscoped DB connection and string-built SQL.
- `missing_tests`: production change without tests.
- `duplicate_finding`: repeated secret finding used to verify deduplication.
- `sandbox_failure`: used by tests to force script failure while keeping the task alive.
- `secret_redaction`: API key, token, password and bearer credential redaction.

## 300-500 字方案设计

本示例以 `examples/skills_code_review_agent` 形式交付,避免改动核心 SDK。`code-review` Skill 包含 `SKILL.md`、规则文档和两个脚本:`parse_diff.py` 负责抽取文件、hunk 与增删行统计,`static_rules.py` 负责在沙箱中产出高信号静态检查结果。Agent 入口支持 `--diff-file`、`--repo-path`、`--path-list-file` 和 `--fixture`,先解析 unified diff 得到变更文件、候选行号和上下文,再合并沙箱脚本与内置规则结果。dry-run / fake-model 模式不依赖真实模型 API Key,保证公开样本和 CI 中的链路可重复。

沙箱由 `SandboxRunner` 封装。生产默认使用 `--runtime container`,Docker 运行时禁用网络;dry-run 只作为开发 fallback,在临时 workspace 中执行同一套 Skill 脚本。每个沙箱请求都先经过 `ReviewExecutionFilter`:禁止敏感路径、路径穿越、非白名单网络访问、超时或输出超预算请求;对 curl 管道、包安装、破坏性命令和提权命令标记 `needs_human_review`,并直接写入报告和数据库,不能继续执行。

SQLite 默认 schema 包含 `review_task`、`sandbox_run`、`finding`、`filter_intercept`、`review_metric` 和 `review_report`,可通过 task id 查询完整任务、执行摘要、拦截记录、监控指标、findings 与最终报告。存储实现集中在 `ReviewStore`,后续可替换为其他 SQL 后端。去重以 `(file, line, category)` 为键,保留置信度和严重级别更高的结果;低置信度或测试缺失等弱信号进入 warnings / needs_human_review。脱敏覆盖输入 diff、沙箱 stdout/stderr、产物、findings、Markdown/JSON 报告和数据库行,避免 API Key、token、password、私钥等明文落盘。最终报告包含 findings 摘要、严重级别统计、人工复核项、Filter 拦截摘要、监控指标、沙箱执行摘要和可执行修复建议。
2 changes: 2 additions & 0 deletions examples/skills_code_review_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Code review agent example package."""

8 changes: 8 additions & 0 deletions examples/skills_code_review_agent/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Deterministic code review agent used by the skills code review example."""

from .review_engine import ReviewConfig
from .review_engine import ReviewResult
from .review_engine import run_review

__all__ = ["ReviewConfig", "ReviewResult", "run_review"]

38 changes: 38 additions & 0 deletions examples/skills_code_review_agent/agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Optional LlmAgent wrapper for the code-review skill.

The tested CLI in ``run_agent.py`` is deterministic and does not require model
credentials. This module mirrors the repository's agent examples for users who
want a normal LlmAgent that can call the bundled SkillToolSet.
"""

from __future__ import annotations

from trpc_agent_sdk.agents import LlmAgent
from trpc_agent_sdk.models import LLMModel
from trpc_agent_sdk.models import OpenAIModel

from .config import get_model_config
from .prompts import INSTRUCTION
from .tools import create_review_skill_tool_set


def _create_model() -> LLMModel:
"""Create a model from the standard example environment variables."""
api_key, url, model_name = get_model_config()
return OpenAIModel(model_name=model_name, api_key=api_key, base_url=url)


def create_agent() -> LlmAgent:
"""Create an LlmAgent wired to the bundled code-review Skill."""
skill_tool_set, skill_repository = create_review_skill_tool_set()
return LlmAgent(
name="skills_code_review_agent",
description="Automatic code review agent using Skills, sandbox scripts and structured reports.",
model=_create_model(),
instruction=INSTRUCTION,
tools=[skill_tool_set],
skill_repository=skill_repository,
)


root_agent = create_agent()
18 changes: 18 additions & 0 deletions examples/skills_code_review_agent/agent/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Agent config module."""

from __future__ import annotations

import os


def get_model_config() -> tuple[str, str, str]:
"""Get model config from environment variables."""
api_key = os.getenv("TRPC_AGENT_API_KEY", "")
url = os.getenv("TRPC_AGENT_BASE_URL", "")
model_name = os.getenv("TRPC_AGENT_MODEL_NAME", "")
if not api_key or not url or not model_name:
raise ValueError(
"TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, and "
"TRPC_AGENT_MODEL_NAME must be set in environment variables"
)
return api_key, url, model_name
151 changes: 151 additions & 0 deletions examples/skills_code_review_agent/agent/diff_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Unified diff and repository input parsing."""

from __future__ import annotations

import hashlib
import re
import subprocess
from pathlib import Path

from .models import ChangedFile
from .models import ChangedLine
from .models import DiffHunk


HUNK_RE = re.compile(r"@@ -(?P<old>\d+)(?:,(?P<old_count>\d+))? \+(?P<new>\d+)(?:,(?P<new_count>\d+))? @@(?P<section>.*)")


def diff_sha256(diff_text: str) -> str:
"""Return a stable hash for raw diff text."""
return hashlib.sha256(diff_text.encode("utf-8", errors="replace")).hexdigest()


def normalize_diff_path(path: str) -> str:
"""Normalize a path read from diff metadata."""
value = path.strip()
if value in {"/dev/null", "dev/null"}:
return ""
if value.startswith("a/") or value.startswith("b/"):
value = value[2:]
return value


def parse_unified_diff(diff_text: str) -> list[ChangedFile]:
"""Parse a unified diff into changed files, hunks and line numbers."""
files: list[ChangedFile] = []
current_file: ChangedFile | None = None
current_hunk: DiffHunk | None = None
old_line: int | None = None
new_line: int | None = None
pending_old_path = ""

for raw in diff_text.replace("\r\n", "\n").splitlines():
if raw.startswith("diff --git "):
parts = raw.split()
if len(parts) >= 4:
pending_old_path = normalize_diff_path(parts[2])
current_hunk = None
continue

if raw.startswith("--- "):
pending_old_path = normalize_diff_path(raw[4:].split("\t", 1)[0])
current_hunk = None
continue

if raw.startswith("+++ "):
new_path = normalize_diff_path(raw[4:].split("\t", 1)[0])
current_file = ChangedFile(
old_path=pending_old_path,
new_path=new_path,
is_deleted=not new_path,
is_new=not pending_old_path,
)
files.append(current_file)
current_hunk = None
continue

match = HUNK_RE.match(raw)
if match:
if current_file is None:
current_file = ChangedFile(old_path=pending_old_path, new_path=pending_old_path)
files.append(current_file)
old_start = int(match.group("old"))
old_count = int(match.group("old_count") or "1")
new_start = int(match.group("new"))
new_count = int(match.group("new_count") or "1")
old_line = old_start
new_line = new_start
current_hunk = DiffHunk(
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
section=match.group("section").strip(),
)
current_file.hunks.append(current_hunk)
continue

if current_file is None or current_hunk is None:
continue

if raw.startswith("+") and not raw.startswith("+++ "):
current_hunk.lines.append(
ChangedLine(
file=current_file.path,
old_line=None,
new_line=new_line,
kind="+",
content=raw[1:],
))
new_line = (new_line or 0) + 1
elif raw.startswith("-") and not raw.startswith("--- "):
current_hunk.lines.append(
ChangedLine(
file=current_file.path,
old_line=old_line,
new_line=None,
kind="-",
content=raw[1:],
))
old_line = (old_line or 0) + 1
else:
content = raw[1:] if raw.startswith(" ") else raw
current_hunk.lines.append(
ChangedLine(
file=current_file.path,
old_line=old_line,
new_line=new_line,
kind=" ",
content=content,
))
old_line = (old_line or 0) + 1
new_line = (new_line or 0) + 1

return files


def read_diff_file(path: Path) -> str:
"""Read a diff file as UTF-8 text."""
return path.read_text(encoding="utf-8")


def read_repo_diff(repo_path: Path) -> str:
"""Read local git working tree changes as a unified diff."""
command = ["git", "-C", str(repo_path), "diff", "--no-ext-diff", "--unified=80"]
completed = subprocess.run(command, capture_output=True, text=True, check=False, timeout=30)
if completed.returncode != 0:
raise RuntimeError(f"git diff failed: {completed.stderr.strip()}")
return completed.stdout


def read_path_list_diff(repo_path: Path, path_list_file: Path) -> str:
"""Read a path list and return a combined git diff for those paths."""
paths = [line.strip() for line in path_list_file.read_text(encoding="utf-8").splitlines() if line.strip()]
if not paths:
return ""
command = ["git", "-C", str(repo_path), "diff", "--no-ext-diff", "--unified=80", "--", *paths]
completed = subprocess.run(command, capture_output=True, text=True, check=False, timeout=30)
if completed.returncode != 0:
raise RuntimeError(f"git diff for path list failed: {completed.stderr.strip()}")
return completed.stdout

Loading
Loading