diff --git a/devtools/test-skill/README.md b/devtools/test-skill/README.md new file mode 100644 index 00000000..3db1f411 --- /dev/null +++ b/devtools/test-skill/README.md @@ -0,0 +1,158 @@ +# forge test-skill — Local Skill Testing + +Test Forge skills locally without Jira, GitHub, or the hosted beta. +Uses Forge's own deepagents + FilesystemBackend — the same code path +as hosted Forge planning agents. + +## Quick Start + +```bash +# Via forge CLI +forge test-skill run \ + --skill generate-prd \ + --skill-dir skills/myproject/generate-prd \ + --project myproject \ + --input test-case.yaml \ + --output output/ + +# Or directly +python3 devtools/test-skill/run.py \ + --skill generate-prd \ + --skill-dir skills/myproject/generate-prd \ + --project myproject \ + --input test-case.yaml \ + --output output/ +``` + +## What It Reproduces + +Uses Forge's own deepagents library — the same agent, backend, and +middleware as hosted Forge. Skills are discovered via SkillsMiddleware, +not manually injected. + +| Component | How | +|-----------|-----| +| Agent | `create_deep_agent()` — same as `ForgeAgent._create_agent_async()` | +| Backend | `FilesystemBackend(virtual_mode=True)` — file tools match production | +| Skills | SkillsMiddleware auto-discovers from `/opt/forge/skills/{project}/` | +| System prompt | `forge.prompts.load_prompt("system")` — same templates as production | +| User message | `load_prompt("{skill-name}")` — same per-skill templates | +| Model | Configurable in `config.yaml` or `--model` flag | +| References | Injected via `--references` (same format as `forge.references` property) | + +**Not simulated:** shell/command execution (`LocalShellBackend`), MCP tools, +Jira/GitHub integrations, conversation summarization thresholds. + +## Input Format + +```yaml +jira_key: PROJ-1234 +title: "Feature Title" +prompt: | + # PROJ-1234: Feature Title + + ## Description + The full Jira feature description goes here. + Copy it from Jira — no live access needed at runtime. +``` + +If a `gold-prd.md` file exists alongside `input.yaml`, it's automatically +appended to the prompt as an approved PRD (useful for generate-spec). + +## CLI Reference + +### forge test-skill run + +| Flag | Description | +|------|-------------| +| `--skill NAME` | Skill name, e.g., `generate-prd` (required) | +| `--skill-dir PATH` | Path to skill directory containing SKILL.md (required) | +| `--input FILE` | Single input.yaml test case | +| `--dataset DIR` | Directory of test cases (runs all) | +| `--output DIR` | Output directory (required) | +| `--project NAME` | Project name for skill path (overrides config.yaml) | +| `--model MODEL` | Override model (default: `claude-opus-4-6`) | +| `--references FILE` | JSON file with reference docs (same format as `forge.references`) | +| `--repos DIR [DIR...]` | Local repo directories to copy into workspace | +| `--mlflow URI` | MLflow tracking URI for auto-tracing | +| `--mlflow-experiment NAME` | MLflow experiment name (default: `forge-skill-eval`) | + +### forge test-skill eval + +| Flag | Description | +|------|-------------| +| `--criteria FILE` | Path to criteria YAML (required) | +| `--generated FILE` | Path to generated artifact | +| `--gold FILE` | Path to gold standard artifact | +| `--dataset DIR` | Dataset directory (batch mode) | +| `--results-dir DIR` | Runner output directory (batch mode) | +| `--output DIR` | Output directory for reports (required) | +| `--mlflow URI` | MLflow tracking URI | + +## Evaluator + +Judges generated artifacts against gold standards using an LLM judge +(Sonnet by default). Criteria are defined per skill in YAML: + +```yaml +# evaluators/criteria/generate-prd.yaml +skill: generate-prd +judge_model: claude-sonnet-4-6 +gold_standard_file: gold-prd.md + +criteria: + - id: scope-accuracy + name: "Scope Accuracy" + weight: critical + prompt: | + Compare In Scope and Out of Scope items against the gold standard... +``` + +Reports: terminal (colored), JSON (`results.json`), HTML (`report.html`). + +## Adding a New Skill + +1. Verify the prompt template exists at `src/forge/prompts/v1/{skill-name}.md` +2. Create `input.yaml` with pre-fetched Jira content +3. Point `--skill-dir` to the skill directory (must contain `SKILL.md`) +4. Run it — output files and `trace.json` go to `--output` +5. Optionally create `evaluators/criteria/{skill-name}.yaml` for automated grading + +## References + +To inject reference documentation (matching `forge.references` project property): + +```bash +# Export from Forge config +forge get-config MYPROJECT --property forge.references > refs.json + +# Use in test runner +forge test-skill run \ + --skill generate-prd \ + --skill-dir skills/myproject/generate-prd \ + --project myproject \ + --references refs.json \ + --input test-case.yaml \ + --output output/ +``` + +## Configuration + +`devtools/test-skill/config.yaml`: + +```yaml +model: claude-opus-4-6 +max_tokens: 16384 +project: default # override with --project +``` + +## Requirements + +Requires `deepagents`, `langchain-anthropic`, and `langgraph` (all in +Forge's `pyproject.toml`). Uses Vertex AI when `ANTHROPIC_VERTEX_PROJECT_ID` +is set, otherwise direct Anthropic API. + +## Related + +- PR: https://github.com/forge-sdlc/forge/pull/297 +- Issue: https://github.com/forge-sdlc/forge/issues/296 diff --git a/devtools/test-skill/config.yaml b/devtools/test-skill/config.yaml new file mode 100644 index 00000000..01cd548b --- /dev/null +++ b/devtools/test-skill/config.yaml @@ -0,0 +1,11 @@ +# Model used for skill execution (not for judging — judge model is in criteria YAML) +model: claude-opus-4-6 +max_tokens: 16384 + +# Project name — determines skill path: /opt/forge/skills/{project}/{skill-name}/ +# Override per project or use --project CLI flag (e.g., myproject) +project: default + +# Paths inside the temp workspace (match Forge's container layout) +skill_base_path: /opt/forge/skills +workspace_path: /home/user diff --git a/devtools/test-skill/evaluate.py b/devtools/test-skill/evaluate.py new file mode 100644 index 00000000..93b3c994 --- /dev/null +++ b/devtools/test-skill/evaluate.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Forge skill output evaluator — judges generated artifacts against gold standards. + +Usage: + # Evaluate a single generated artifact against its gold standard + python3 devtools/test-skill/evaluate.py \ + --criteria devtools/test-skill/evaluators/criteria/generate-prd.yaml \ + --generated output/enhancements/OSAC-1234/prd.md \ + --gold gold-prd.md \ + --output output/eval/ + + # Evaluate after a runner batch (all cases in a dataset) + python3 devtools/test-skill/evaluate.py \ + --criteria devtools/test-skill/evaluators/criteria/generate-prd.yaml \ + --dataset eval/dataset/cases/ \ + --results-dir output/ \ + --output output/eval/ +""" + +import argparse +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from evaluators.judge import evaluate, load_criteria +from evaluators.reports import print_terminal, save_json, save_html + +try: + import mlflow + import mlflow.anthropic + + HAS_MLFLOW = True +except ImportError: + HAS_MLFLOW = False + + +def find_generated_file(output_dir: Path, criteria_config: dict) -> Path | None: + for pattern in criteria_config.get("generated_file_patterns", []): + matches = list(output_dir.glob(pattern)) + if matches: + return matches[0] + for f in output_dir.rglob("prd.md"): + return f + return None + + +_mlflow_enabled = False + + +def run_single( + criteria_path: Path, + generated_path: Path, + gold_path: Path, + output_dir: Path, +): + case_name = generated_path.parent.name or generated_path.stem + + print(f"Evaluating: {generated_path.name}") + print(f" Generated: {generated_path}") + print(f" Gold: {gold_path}") + + if HAS_MLFLOW and _mlflow_enabled: + with mlflow.start_run(run_name=f"eval — {case_name}"): + report = evaluate(criteria_path, generated_path, gold_path) + + print_terminal(report) + save_json(report, output_dir) + save_html(report, output_dir) + + mlflow.set_tag("case", case_name) + mlflow.set_tag("type", "evaluation") + mlflow.set_tag("skill", report.skill) + mlflow.set_tag("grade", report.grade) + + mlflow.log_metric("total_score", report.total_score) + mlflow.log_metric("max_score", report.max_score) + mlflow.log_metric("score_pct", round(report.total_score / report.max_score * 100, 1)) + mlflow.log_metric("criteria_passed", report.total_passed) + mlflow.log_metric("criteria_total", report.total_criteria) + mlflow.log_metric("critical_failures", len(report.critical_failures)) + + for r in report.results: + mlflow.log_metric(f"c_{r.id}", r.score) + + try: + results_json = output_dir / "results.json" + if results_json.exists(): + mlflow.log_artifact(str(results_json), "eval") + except Exception: + pass + + print(f" MLflow: logged eval for {case_name}") + else: + report = evaluate(criteria_path, generated_path, gold_path) + print_terminal(report) + save_json(report, output_dir) + save_html(report, output_dir) + + return report + + +def run_batch( + criteria_path: Path, + dataset_dir: Path, + results_dir: Path, + output_dir: Path, +): + config = load_criteria(criteria_path) + reports = [] + + for case_dir in sorted(dataset_dir.iterdir()): + if not case_dir.is_dir(): + continue + + gold_file = case_dir / config.get("gold_standard_file", "gold-prd.md") + if not gold_file.exists(): + print(f"Skipping {case_dir.name}: no gold standard") + continue + + case_results = results_dir / case_dir.name + if not case_results.exists(): + print(f"Skipping {case_dir.name}: no run results at {case_results}") + continue + + generated = find_generated_file(case_results, config) + if not generated: + print(f"Skipping {case_dir.name}: no generated artifact found") + continue + + case_output = output_dir / case_dir.name + report = run_single(criteria_path, generated, gold_file, case_output) + reports.append((case_dir.name, report)) + + if reports: + print(f"\n{'='*55}") + print(f"Batch Summary: {len(reports)} cases evaluated") + print(f"{'='*55}") + for name, r in reports: + status = "PASS" if r.overall_pass else "FAIL" + print(f" {name:<30} {r.grade} {status} {r.total_passed}/{r.total_criteria} score {r.total_score}/{r.max_score}") + + +def main(): + global _mlflow_enabled + + parser = argparse.ArgumentParser(description="Forge skill output evaluator") + parser.add_argument("--criteria", required=True, help="Path to criteria YAML file") + parser.add_argument("--generated", help="Path to generated artifact") + parser.add_argument("--gold", help="Path to gold standard artifact") + parser.add_argument("--dataset", help="Path to dataset directory (batch mode)") + parser.add_argument("--results-dir", help="Path to runner output directory (batch mode)") + parser.add_argument("--output", required=True, help="Output directory for reports") + parser.add_argument( + "--mlflow", + metavar="URI", + help="MLflow tracking URI (e.g., http://host:5000). Logs eval scores as MLflow runs.", + ) + parser.add_argument( + "--mlflow-experiment", + default="forge-skill-eval", + help="MLflow experiment name (default: forge-skill-eval)", + ) + args = parser.parse_args() + + if args.mlflow and HAS_MLFLOW: + import logging + logging.getLogger("mlflow.tracing.export").setLevel(logging.ERROR) + mlflow.set_tracking_uri(args.mlflow) + mlflow.set_experiment(args.mlflow_experiment) + mlflow.anthropic.autolog() + _mlflow_enabled = True + print(f"MLflow: tracking to {args.mlflow}, experiment '{args.mlflow_experiment}'") + + criteria_path = Path(args.criteria) + output_dir = Path(args.output) + + if args.generated and args.gold: + run_single(criteria_path, Path(args.generated), Path(args.gold), output_dir) + elif args.dataset and args.results_dir: + run_batch(criteria_path, Path(args.dataset), Path(args.results_dir), output_dir) + else: + print("Error: provide either --generated + --gold, or --dataset + --results-dir") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/devtools/test-skill/evaluators/__init__.py b/devtools/test-skill/evaluators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/devtools/test-skill/evaluators/criteria/generate-prd.yaml b/devtools/test-skill/evaluators/criteria/generate-prd.yaml new file mode 100644 index 00000000..c0ce0398 --- /dev/null +++ b/devtools/test-skill/evaluators/criteria/generate-prd.yaml @@ -0,0 +1,90 @@ +skill: generate-prd +description: "PRD generation quality criteria based on 7 gaps identified in the Forge experiment" + +gold_standard_file: gold-prd.md +generated_file_patterns: + - "enhancements/*/prd.md" + - ".artifacts/prd/*/03-prd.md" + +judge_model: claude-sonnet-4-6 + +criteria: + - id: persona-coverage + name: "Persona Coverage" + weight: critical + prompt: | + Check if all 4 OSAC personas are addressed (Cloud Provider Admin, + Cloud Infrastructure Admin, Tenant Admin, Tenant User). Each must + have user stories or an explicit "Not affected" note. + Pay special attention to Cloud Infrastructure Admin — if the feature + automates a process they currently perform, they MUST have stories. + + - id: persona-alignment + name: "Persona-Story Alignment" + weight: critical + prompt: | + For each user story, verify the capability matches the persona's role: + - Infrastructure ops (sanitization, hardware lifecycle) → Cloud Infrastructure Admin + - Tenant management (quotas, catalogs, cross-tenant visibility) → Cloud Provider Admin + - Self-service provisioning → Tenant User + - Org config, IDP, org users → Tenant Admin + - Internal system stories ("As the CaaS system...") should not exist + A sanitization story under Cloud Provider Admin is a misattribution. + + - id: scope-accuracy + name: "In Scope / Out of Scope Accuracy" + weight: important + prompt: | + Compare In Scope and Out of Scope items against the gold standard. + Flag items in the wrong section (e.g., billing In Scope when gold + has it Out of Scope). Flag missing items that appear in the gold. + Flag items the generated added that the gold doesn't have — are they + accurate or scope creep? + + - id: design-leakage + name: "Design Leakage" + weight: critical + prompt: | + Check for internal implementation details that don't belong in a PRD: + controller names, reconciler logic, finalizer behavior, playbook + parameters, CRD field names, internal conditions, agent/InfraEnv + terminology, AAP job parameters. Platform vocabulary (ClusterOrder, + BareMetalInstance, Hosted Control Planes) is acceptable. + + - id: problem-statement + name: "Problem Statement Quality" + weight: important + prompt: | + The Problem Statement should describe user pain and cost of inaction + only. It should NOT describe the solution, what the feature introduces, + or how it works. Check for solution language — sentences starting with + "This feature introduces...", "The X eliminates...", etc. + + - id: status-visibility + name: "Async Status Visibility" + weight: important + prompt: | + If the feature involves asynchronous resource creation, check that + status/progress visibility is addressed in In Scope or User Stories. + The user should be able to see the current state and failure reasons. + + - id: template-compliance + name: "Template Compliance" + weight: important + prompt: | + The OSAC PRD template has exactly 6 sections: Problem Statement, + In Scope, Out of Scope, User Stories, Assumptions, Dependencies. + Check for extra sections (Risks, Acceptance Criteria, Open Questions, + Terminology, Milestone) or missing required sections. + + - id: completeness + name: "Content Completeness" + weight: important + prompt: | + Compare the generated PRD against the gold standard section by section. + What key requirements are present in the gold but missing in the generated? + What did the generated add that the gold doesn't have? + Focus on substantive content gaps, not wording differences. + +pass_threshold: 6 +fail_on_critical: true diff --git a/devtools/test-skill/evaluators/judge.py b/devtools/test-skill/evaluators/judge.py new file mode 100644 index 00000000..d3402d79 --- /dev/null +++ b/devtools/test-skill/evaluators/judge.py @@ -0,0 +1,197 @@ +"""LLM judge for evaluating Forge skill outputs against gold standards.""" + +import json +import os +import re +from dataclasses import dataclass, field +from pathlib import Path + +import anthropic +import yaml + + +def _create_client(): + vertex_project = os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID") + vertex_region = os.environ.get("ANTHROPIC_VERTEX_REGION", "us-east5") + if vertex_project: + return anthropic.AnthropicVertex(project_id=vertex_project, region=vertex_region) + return anthropic.Anthropic() + + +@dataclass +class CriterionResult: + id: str + name: str + weight: str + passed: bool + score: int + reasoning: str + quotes: list[str] + + +@dataclass +class EvalReport: + skill: str + generated_path: str + gold_path: str + results: list[CriterionResult] + pass_threshold: int = 6 + fail_on_critical: bool = True + + @property + def total_passed(self) -> int: + return sum(1 for r in self.results if r.passed) + + @property + def total_criteria(self) -> int: + return len(self.results) + + @property + def total_score(self) -> int: + return sum(r.score for r in self.results) + + @property + def max_score(self) -> int: + return len(self.results) * 2 + + @property + def critical_failures(self) -> list[CriterionResult]: + return [r for r in self.results if r.weight == "critical" and not r.passed] + + @property + def overall_pass(self) -> bool: + if self.fail_on_critical and self.critical_failures: + return False + return self.total_passed >= self.pass_threshold + + @property + def grade(self) -> str: + pct = self.total_score / self.max_score if self.max_score else 0 + if pct >= 0.9 and not self.critical_failures: + return "A" + if pct >= 0.75 and not self.critical_failures: + return "B" + if pct >= 0.6: + return "C" + return "D" + + +def load_criteria(criteria_path: Path) -> dict: + with open(criteria_path) as f: + return yaml.safe_load(f) + + +def _extract_json(text: str) -> dict | None: + """Try to extract a JSON object from text that may contain preamble.""" + if text.startswith("```"): + text = text.split("\n", 1)[1].rsplit("```", 1)[0] + + try: + return json.loads(text.strip()) + except json.JSONDecodeError: + pass + + match = re.search(r'\{[^{}]*"pass"\s*:', text) + if match: + start = match.start() + depth = 0 + for i in range(start, len(text)): + if text[i] == '{': + depth += 1 + elif text[i] == '}': + depth -= 1 + if depth == 0: + try: + return json.loads(text[start:i + 1]) + except json.JSONDecodeError: + break + + return None + + +def judge_criterion( + client, + model: str, + criterion: dict, + generated: str, + gold: str, +) -> CriterionResult: + system = ( + "You are a document quality judge. Score the generated document against " + "the gold standard for the specific criterion described.\n\n" + "CRITICAL: Return ONLY a JSON object. No thinking, no analysis, no preamble.\n" + "Do NOT explain your reasoning before the JSON. Start your response with {.\n\n" + "JSON schema:\n" + '{"pass": true/false, "score": 0-2, "reasoning": "one sentence", "quotes": ["relevant quote"]}\n\n' + "Scoring: 0 = fails the criterion, 1 = partially meets, 2 = fully meets.\n" + "Keep reasoning to one sentence. Keep quotes short (under 100 chars each, max 3)." + ) + + user = ( + f"## Criterion: {criterion['name']}\n" + f"{criterion['prompt']}\n\n" + f"## Generated Document\n```\n{generated}\n```\n\n" + f"## Gold Standard Document\n```\n{gold}\n```" + ) + + response = client.messages.create( + model=model, + max_tokens=2048, + system=system, + messages=[{"role": "user", "content": user}], + ) + + text = response.content[0].text.strip() + data = _extract_json(text) + + if data is None: + return CriterionResult( + id=criterion["id"], + name=criterion["name"], + weight=criterion.get("weight", "important"), + passed=False, + score=0, + reasoning=f"Judge returned unparseable response: {text[:200]}", + quotes=[], + ) + + return CriterionResult( + id=criterion["id"], + name=criterion["name"], + weight=criterion.get("weight", "important"), + passed=data.get("pass", False), + score=data.get("score", 0), + reasoning=data.get("reasoning", ""), + quotes=data.get("quotes", []), + ) + + +def evaluate( + criteria_path: Path, + generated_path: Path, + gold_path: Path, +) -> EvalReport: + config = load_criteria(criteria_path) + model = config.get("judge_model", "claude-sonnet-4-6") + + generated = generated_path.read_text() + gold = gold_path.read_text() + + client = _create_client() + results = [] + + for criterion in config.get("criteria", []): + print(f" Judging: {criterion['name']}...", end="", flush=True) + result = judge_criterion(client, model, criterion, generated, gold) + status = "PASS" if result.passed else "FAIL" + print(f" {status} {result.score}/2") + results.append(result) + + return EvalReport( + skill=config.get("skill", "unknown"), + generated_path=str(generated_path), + gold_path=str(gold_path), + results=results, + pass_threshold=config.get("pass_threshold", 6), + fail_on_critical=config.get("fail_on_critical", True), + ) diff --git a/devtools/test-skill/evaluators/reports.py b/devtools/test-skill/evaluators/reports.py new file mode 100644 index 00000000..d1721950 --- /dev/null +++ b/devtools/test-skill/evaluators/reports.py @@ -0,0 +1,123 @@ +"""Report generators for evaluation results.""" + +import html as html_mod +import json +from pathlib import Path + +from .judge import EvalReport + + +def print_terminal(report: EvalReport): + GREEN = "\033[92m" + RED = "\033[91m" + YELLOW = "\033[93m" + RESET = "\033[0m" + BOLD = "\033[1m" + + print(f"\n{BOLD}PRD Evaluation: {report.skill}{RESET}") + print("=" * 55) + + for r in report.results: + color = GREEN if r.passed else RED + status = "PASS" if r.passed else "FAIL" + weight_marker = " *" if r.weight == "critical" else "" + reasoning_short = r.reasoning[:60] if r.reasoning else "" + print(f" {r.name:<28} {color}{status}{RESET} {r.score}/2 {reasoning_short}{weight_marker}") + + print("=" * 55) + overall = f"{GREEN}PASS{RESET}" if report.overall_pass else f"{RED}FAIL{RESET}" + grade_colors = {"A": GREEN, "B": GREEN, "C": YELLOW, "D": RED} + gc = grade_colors.get(report.grade, RESET) + print( + f" Total: {report.total_passed}/{report.total_criteria} passed | " + f"Score: {report.total_score}/{report.max_score} | " + f"Grade: {gc}{report.grade}{RESET} | {overall}" + ) + + if report.critical_failures: + print(f"\n {RED}Critical failures:{RESET}") + for r in report.critical_failures: + print(f" - {r.name}: {r.reasoning[:80]}") + + print() + + +def save_json(report: EvalReport, output_dir: Path): + output_dir.mkdir(parents=True, exist_ok=True) + data = { + "skill": report.skill, + "generated_path": report.generated_path, + "gold_path": report.gold_path, + "overall_pass": report.overall_pass, + "grade": report.grade, + "total_passed": report.total_passed, + "total_criteria": report.total_criteria, + "total_score": report.total_score, + "max_score": report.max_score, + "results": [ + { + "id": r.id, + "name": r.name, + "weight": r.weight, + "passed": r.passed, + "score": r.score, + "reasoning": r.reasoning, + "quotes": r.quotes, + } + for r in report.results + ], + } + path = output_dir / "results.json" + with open(path, "w") as f: + json.dump(data, f, indent=2) + print(f"JSON report: {path}") + + +def save_html(report: EvalReport, output_dir: Path): + output_dir.mkdir(parents=True, exist_ok=True) + + rows = [] + for r in report.results: + color = "#4eca8b" if r.passed else "#e85c5c" + status = "PASS" if r.passed else "FAIL" + weight = f' *' if r.weight == "critical" else "" + quotes_html = "" + if r.quotes: + quotes_html = "
".join(f'{html_mod.escape(q[:100])}' for q in r.quotes[:3]) + rows.append( + f'{html_mod.escape(r.name)}{weight}' + f'{status}' + f'{r.score}/2' + f'{html_mod.escape(r.reasoning)}' + f'{quotes_html}' + ) + + overall_color = "#4eca8b" if report.overall_pass else "#e85c5c" + overall_text = "PASS" if report.overall_pass else "FAIL" + + html = f""" + +Eval: {html_mod.escape(report.skill)} + +

Evaluation: {html_mod.escape(report.skill)}

+

Generated: {html_mod.escape(report.generated_path)}
Gold: {html_mod.escape(report.gold_path)}

+
{overall_text} — Grade: {report.grade} | {report.total_passed}/{report.total_criteria} passed, score {report.total_score}/{report.max_score}
+ + +{''.join(rows)} +
CriterionResultScoreReasoningEvidence
+""" + + path = output_dir / "report.html" + with open(path, "w") as f: + f.write(html) + print(f"HTML report: {path}") diff --git a/devtools/test-skill/run.py b/devtools/test-skill/run.py new file mode 100644 index 00000000..202ea79b --- /dev/null +++ b/devtools/test-skill/run.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +""" +Forge skill test runner — simulates Forge's agent context locally. + +Uses Forge's own prompt templates (src/forge/prompts/) to reproduce the +exact system prompt and user message format, without needing Jira, GitHub, +or the hosted beta. + +Usage: + python3 devtools/test-skill/run.py \ + --skill generate-prd \ + --skill-dir skills/osac/generate-prd \ + --input test-case.yaml \ + --output output/ + + python3 devtools/test-skill/run.py \ + --skill generate-prd \ + --skill-dir skills/osac/generate-prd \ + --dataset eval/dataset/cases/ \ + --output output/ +""" + +import argparse +import asyncio +import contextlib +import json +import os +import shutil +import sys +import tempfile +import time +import uuid +from datetime import date +from pathlib import Path + +# Add Forge source to path so we can import forge.prompts +FORGE_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(FORGE_ROOT / "src")) + +import yaml + +from forge.prompts import load_prompt + +try: + import mlflow + import mlflow.anthropic + + HAS_MLFLOW = True +except ImportError: + HAS_MLFLOW = False + +from deepagents import create_deep_agent +from deepagents.backends.filesystem import FilesystemBackend +from langchain_anthropic import ChatAnthropic as LCChatAnthropic +from langgraph.checkpoint.memory import MemorySaver + +try: + from langchain_google_vertexai.model_garden import ( + ChatAnthropicVertex as LCChatAnthropicVertex, + ) +except ImportError: + LCChatAnthropicVertex = None + + +SCRIPT_DIR = Path(__file__).parent + + +def load_config(): + with open(SCRIPT_DIR / "config.yaml") as f: + return yaml.safe_load(f) + + +def _format_references(references: list[dict]) -> str: + if not references: + return "" + lines = ["\n\n## Reference Documentation\n"] + for ref in references: + title = ref.get("title", "Untitled") + url = ref.get("url", "") + tags = ref.get("tags", []) + tag_str = f" [{', '.join(tags)}]" if tags else "" + lines.append(f"- [{title}]({url}){tag_str}\n") + return "".join(lines) + + + +def build_user_message( + skill_name: str, + requirements: str, + project_key: str, + summary: str, +) -> str: + prompt_name = skill_name # e.g., "generate-prd" + context_str = str({"project_key": project_key, "summary": summary}) + try: + return load_prompt( + prompt_name, + raw_requirements=requirements, + context=context_str, + ) + except FileNotFoundError: + return f"Please complete the following task:\n\n{requirements}" + + +def setup_workspace( + skill_dir: Path, + skill_name: str, + project: str, + repo_dirs: list[Path] | None = None, +) -> Path: + workspace = Path(tempfile.mkdtemp(prefix="forge-test-")) + skill_target = workspace / "opt" / "forge" / "skills" / project / skill_name + skill_target.mkdir(parents=True) + shutil.copytree(skill_dir, skill_target, dirs_exist_ok=True) + user_dir = workspace / "home" / "user" + user_dir.mkdir(parents=True) + if repo_dirs: + for repo_path in repo_dirs: + repo_path = Path(repo_path).resolve() + if not repo_path.is_dir(): + print(f" Warning: repo dir not found, skipping: {repo_path}") + continue + target = user_dir / repo_path.name + shutil.copytree( + repo_path, target, dirs_exist_ok=True, + ignore=shutil.ignore_patterns( + ".git", "__pycache__", "node_modules", ".venv", "vendor", + ), + ) + print(f" Repo: {repo_path.name} -> {target}") + return workspace + + +def build_system_prompt_text( + ticket_key: str, + project_key: str, + references: list[dict] | None = None, +) -> str: + system_text = load_prompt("system", current_date=str(date.today())) + system_text += f"\n\nContext:\n- ticket_key: {ticket_key}\n- project_key: {project_key}\n" + system_text += _format_references(references or []) + return system_text + + +async def run_agent_deepagents( + system_prompt: str, + user_message: str, + workspace: Path, + config: dict, + _skill_name: str, + project: str, +) -> dict: + vertex_project = os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID") + vertex_region = os.environ.get("ANTHROPIC_VERTEX_REGION", "us-east5") + model_name = config.get("model", "claude-opus-4-6") + max_tokens = config.get("max_tokens", 16384) + + if vertex_project: + if LCChatAnthropicVertex is None: + raise RuntimeError( + "ANTHROPIC_VERTEX_PROJECT_ID is set but langchain-google-vertexai is not installed. " + "Install with: pip install langchain-google-vertexai" + ) + model = LCChatAnthropicVertex( + model_name=model_name, + project=vertex_project, + location=vertex_region, + max_tokens=max_tokens, + ) + else: + model = LCChatAnthropic( + model=model_name, + max_tokens=max_tokens, + ) + + backend = FilesystemBackend(root_dir=str(workspace), virtual_mode=True) + + skill_paths = [f"/opt/forge/skills/{project}/"] + + checkpointer = MemorySaver() + agent = create_deep_agent( + model=model, + backend=backend, + skills=skill_paths, + system_prompt=system_prompt, + checkpointer=checkpointer, + ) + + thread_id = str(uuid.uuid4()) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": user_message}]}, + config={"configurable": {"thread_id": thread_id}}, + ) + + messages = result.get("messages", []) if isinstance(result, dict) else [] + trace = [] + total_input = 0 + total_output = 0 + ai_iteration = 0 + + for msg in messages: + msg_type = type(msg).__name__ + if msg_type not in ("AIMessage", "AIMessageChunk"): + continue + + ai_iteration += 1 + content = msg.content + text_blocks = [] + + if isinstance(content, str): + if content.strip(): + text_blocks.append(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text_blocks.append(block.get("text", "")) + + tool_calls = [ + {"name": tc.get("name", ""), "input": tc.get("args", {})} + for tc in getattr(msg, "tool_calls", []) + ] + + usage = getattr(msg, "usage_metadata", None) or {} + input_tokens = usage.get("input_tokens", 0) if isinstance(usage, dict) else 0 + output_tokens = usage.get("output_tokens", 0) if isinstance(usage, dict) else 0 + total_input += input_tokens + total_output += output_tokens + + resp_meta = getattr(msg, "response_metadata", {}) or {} + stop_reason = resp_meta.get("stop_reason", "") + + trace.append({ + "iteration": ai_iteration, + "stop_reason": stop_reason, + "text": text_blocks, + "tool_calls": tool_calls, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + }) + + if tool_calls: + tc_names = [tc["name"] for tc in tool_calls] + print(f" deepagents: {', '.join(tc_names)}") + + final_text = "" + for entry in trace: + for t in entry.get("text", []): + if t.strip(): + final_text = t + + return { + "trace": trace, + "final_text": final_text, + "total_input_tokens": total_input, + "total_output_tokens": total_output, + "iterations": ai_iteration, + } + + +def collect_output_files(workspace: Path, repo_dirs: list[Path] | None = None) -> dict[str, str]: + repo_names = {Path(r).resolve().name for r in (repo_dirs or [])} + files = {} + for search_root in [workspace / "home" / "user", workspace / "opt" / "forge"]: + if not search_root.exists(): + continue + for fpath in search_root.rglob("*"): + if fpath.is_file() and fpath.suffix != ".pyc": + rel = str(fpath.relative_to(search_root)) + top_dir = rel.split("/")[0] if "/" in rel else "" + if top_dir in repo_names: + continue + if rel not in files: + with contextlib.suppress(UnicodeDecodeError, PermissionError): + files[rel] = fpath.read_text() + return files + + +def _setup_mlflow(tracking_uri: str, experiment_name: str): + """Configure MLflow tracking and Anthropic auto-instrumentation.""" + if not HAS_MLFLOW: + print("Warning: mlflow not installed, skipping MLflow integration") + return False + mlflow.set_tracking_uri(tracking_uri) + mlflow.set_experiment(experiment_name) + mlflow.anthropic.autolog() + print(f"MLflow: tracking to {tracking_uri}, experiment '{experiment_name}'") + return True + + +def _save_outputs(result, workspace, output_dir, config, repo_dirs=None): + """Save output files and trace JSON.""" + output_dir.mkdir(parents=True, exist_ok=True) + + output_files = collect_output_files(workspace, repo_dirs=repo_dirs) + for rel_path, content in output_files.items(): + out_path = output_dir / rel_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(content) + print(f"Output: {out_path}") + + final_text = result.get("final_text", "") + if final_text.strip() and not any(p.endswith(".md") for p in output_files): + skill_name = result.get("_skill_name", "") + filename = "design.md" if "spec" in skill_name else "prd.md" + inline_path = output_dir / filename + inline_path.write_text(final_text) + print(f"Output (inline): {inline_path}") + + trace_path = output_dir / "trace.json" + with open(trace_path, "w") as f: + json.dump( + { + "ticket_key": result["_ticket_key"], + "skill": result["_skill_name"], + "model": config.get("model"), + "elapsed_seconds": result["_elapsed"], + "iterations": result["iterations"], + "total_input_tokens": result["total_input_tokens"], + "total_output_tokens": result["total_output_tokens"], + "trace": result["trace"], + }, + f, + indent=2, + ) + print(f"Trace: {trace_path}") + + +def _log_mlflow_metrics(result, config, output_dir): + """Log metrics and artifacts to the current active MLflow run.""" + mlflow.set_tag("model", config.get("model", "claude-opus-4-6")) + + mlflow.log_metric("elapsed_seconds", result["_elapsed"]) + mlflow.log_metric("iterations", result["iterations"]) + mlflow.log_metric("input_tokens", result["total_input_tokens"]) + mlflow.log_metric("output_tokens", result["total_output_tokens"]) + total = result["total_input_tokens"] + result["total_output_tokens"] + mlflow.log_metric("total_tokens", total) + cost = (result["total_input_tokens"] * 15 + result["total_output_tokens"] * 75) / 1e6 + mlflow.log_metric("cost_usd", round(cost, 2)) + + try: + trace_path = output_dir / "trace.json" + if trace_path.exists(): + mlflow.log_artifact(str(trace_path), "trace") + for f in output_dir.rglob("prd.md"): + if "skills" not in str(f): + mlflow.log_artifact(str(f), "generated") + break + except Exception: + pass + + +def _run_agent( + system_text: str, + user_message: str, + workspace: Path, + config: dict, + skill_name: str, + project: str, +) -> dict: + return asyncio.run( + run_agent_deepagents(system_text, user_message, workspace, config, skill_name, project) + ) + + +def run_single_case( + skill_name: str, + skill_dir: Path, + input_path: Path, + output_dir: Path, + config: dict, + repo_dirs: list[Path] | None = None, +): + with open(input_path) as f: + input_data = yaml.safe_load(f) + + ticket_key = input_data.get("jira_key", input_data.get("ticket_key", "TEST-0000")) + project_key = config.get("project", "default").upper() + project = config.get("project", "default") + summary = input_data.get("title", input_data.get("summary", "")) + requirements = input_data.get("prompt", input_data.get("requirements", "")) + + if not requirements: + print(f"Error: No requirements found in {input_path}") + return + + prd_file = input_path.parent / "gold-prd.md" + if prd_file.exists(): + prd_content = prd_file.read_text() + requirements += f"\n\n## Approved PRD\n\n{prd_content}" + print(f" PRD: loaded {prd_file.name} ({len(prd_content)} chars)") + + print(f"\n{'='*60}") + print(f"Running: {ticket_key} — {summary}") + print(f"Skill: {skill_name} from {skill_dir}") + print(f"{'='*60}") + + workspace = setup_workspace(skill_dir, skill_name, project, repo_dirs=repo_dirs) + print(f"Workspace: {workspace}") + + references = config.get("references", []) + system_text = build_system_prompt_text(ticket_key, project_key, references) + user_message = build_user_message(skill_name, requirements, project_key, summary) + + def _execute(): + return _run_agent( + system_text, user_message, + workspace, config, skill_name, project, + ) + + if HAS_MLFLOW and config.get("mlflow_enabled"): + with mlflow.start_run(run_name=f"{ticket_key} — {summary}"): + mlflow.set_tag("case", ticket_key) + mlflow.set_tag("feature", summary) + mlflow.set_tag("skill", skill_name) + + start = time.time() + result = _execute() + elapsed = round(time.time() - start, 1) + + result["_ticket_key"] = ticket_key + result["_skill_name"] = skill_name + result["_elapsed"] = elapsed + + _save_outputs(result, workspace, output_dir, config, repo_dirs=repo_dirs) + _log_mlflow_metrics(result, config, output_dir) + + print(f"\nDone in {elapsed}s — {result['iterations']} iterations") + print(f"Tokens: {result['total_input_tokens']} input, " + f"{result['total_output_tokens']} output") + print(f"MLflow: logged run for {ticket_key}") + else: + start = time.time() + result = _execute() + elapsed = round(time.time() - start, 1) + + result["_ticket_key"] = ticket_key + result["_skill_name"] = skill_name + result["_elapsed"] = elapsed + + _save_outputs(result, workspace, output_dir, config) + + print(f"\nDone in {elapsed}s — {result['iterations']} iterations") + print(f"Tokens: {result['total_input_tokens']} input, " + f"{result['total_output_tokens']} output") + + shutil.rmtree(workspace) + + +def main(): + parser = argparse.ArgumentParser(description="Forge skill test runner") + parser.add_argument("--skill", required=True, help="Skill name (e.g., generate-prd)") + parser.add_argument("--skill-dir", required=True, help="Path to skill directory") + parser.add_argument("--input", help="Path to a single input.yaml test case") + parser.add_argument("--dataset", help="Path to dataset directory (runs all cases)") + parser.add_argument("--output", required=True, help="Output directory") + parser.add_argument("--model", help="Override model from config") + parser.add_argument( + "--mlflow", + metavar="URI", + help="MLflow tracking URI (e.g., http://host:5000). Enables auto-tracing of all API calls.", + ) + parser.add_argument( + "--mlflow-experiment", + default="forge-skill-eval", + help="MLflow experiment name (default: forge-skill-eval)", + ) + parser.add_argument( + "--repos", + nargs="+", + metavar="DIR", + help="Local repo directories to copy into the workspace (e.g., /path/to/myproject /path/to/enhancement-proposals). " + "Gives the agent codebase access via read/grep tools.", + ) + parser.add_argument( + "--project", + help="Project name for skill path (e.g., osac). Overrides config.yaml project setting.", + ) + parser.add_argument( + "--references", + metavar="FILE", + help="JSON file with reference documentation (same format as forge.references project property).", + ) + args = parser.parse_args() + + config = load_config() + if args.model: + config["model"] = args.model + if args.project: + config["project"] = args.project + if args.references: + refs_path = Path(args.references) + if not refs_path.exists(): + print(f"Error: references file not found: {refs_path}") + sys.exit(1) + with open(refs_path) as f: + config["references"] = json.load(f) + + if args.mlflow: + import logging + logging.getLogger("mlflow.tracing.export").setLevel(logging.ERROR) + config["mlflow_enabled"] = _setup_mlflow(args.mlflow, args.mlflow_experiment) + else: + config["mlflow_enabled"] = False + + skill_dir = Path(args.skill_dir).resolve() + if not (skill_dir / "SKILL.md").exists(): + print(f"Error: No SKILL.md found in {skill_dir}") + sys.exit(1) + + output_dir = Path(args.output).resolve() + + repo_dirs = [Path(r) for r in args.repos] if args.repos else None + + if args.input: + run_single_case(args.skill, skill_dir, Path(args.input), output_dir, config, repo_dirs=repo_dirs) + elif args.dataset: + dataset_dir = Path(args.dataset) + for case_dir in sorted(dataset_dir.iterdir()): + if not case_dir.is_dir(): + continue + input_yaml = case_dir / "input.yaml" + if not input_yaml.exists(): + continue + case_output = output_dir / case_dir.name + run_single_case(args.skill, skill_dir, input_yaml, case_output, config, repo_dirs=repo_dirs) + else: + print("Error: Provide either --input or --dataset") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/forge/cli.py b/src/forge/cli.py index 0e58d1fa..90edd206 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -1196,6 +1196,117 @@ async def cmd_health(_args: argparse.Namespace) -> int: return 0 +def cmd_test_skill_run(args: argparse.Namespace) -> int: + """Run a skill against test cases using deepagents (or legacy mode).""" + import importlib.util + from pathlib import Path + + project_root = Path(__file__).resolve().parent.parent.parent + run_module_path = project_root / "devtools" / "test-skill" / "run.py" + if not run_module_path.exists(): + print(f"Error: test-skill runner not found at {run_module_path}", file=sys.stderr) + return 1 + + spec = importlib.util.spec_from_file_location("test_skill_run", run_module_path) + if spec is None or spec.loader is None: + print(f"Error: cannot load {run_module_path}", file=sys.stderr) + return 1 + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + config = mod.load_config() + if args.model: + config["model"] = args.model + if getattr(args, "project", None): + config["project"] = args.project + if getattr(args, "references", None): + import json as _json + + refs_path = Path(args.references) + if not refs_path.exists(): + print(f"Error: references file not found: {refs_path}", file=sys.stderr) + return 1 + with open(refs_path) as f: + config["references"] = _json.load(f) + + if args.mlflow: + import logging + + logging.getLogger("mlflow.tracing.export").setLevel(logging.ERROR) + config["mlflow_enabled"] = mod._setup_mlflow(args.mlflow, args.mlflow_experiment) + else: + config["mlflow_enabled"] = False + + skill_dir = Path(args.skill_dir).resolve() + if not (skill_dir / "SKILL.md").exists(): + print(f"Error: No SKILL.md found in {skill_dir}", file=sys.stderr) + return 1 + + output_dir = Path(args.output).resolve() + + if args.input: + mod.run_single_case(args.skill, skill_dir, Path(args.input), output_dir, config) + elif args.dataset: + dataset_dir = Path(args.dataset) + for case_dir in sorted(dataset_dir.iterdir()): + if not case_dir.is_dir(): + continue + input_yaml = case_dir / "input.yaml" + if not input_yaml.exists(): + continue + case_output = output_dir / case_dir.name + mod.run_single_case(args.skill, skill_dir, input_yaml, case_output, config) + else: + print("Error: Provide either --input or --dataset", file=sys.stderr) + return 1 + + return 0 + + +def cmd_test_skill_eval(args: argparse.Namespace) -> int: + """Evaluate skill outputs against gold standards.""" + import importlib.util + from pathlib import Path + + project_root = Path(__file__).resolve().parent.parent.parent + eval_module_path = project_root / "devtools" / "test-skill" / "evaluate.py" + if not eval_module_path.exists(): + print(f"Error: test-skill evaluator not found at {eval_module_path}", file=sys.stderr) + return 1 + + spec = importlib.util.spec_from_file_location("test_skill_evaluate", eval_module_path) + if spec is None or spec.loader is None: + print(f"Error: cannot load {eval_module_path}", file=sys.stderr) + return 1 + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + if args.mlflow and mod.HAS_MLFLOW: + import logging + + logging.getLogger("mlflow.tracing.export").setLevel(logging.ERROR) + mod.mlflow.set_tracking_uri(args.mlflow) + mod.mlflow.set_experiment(args.mlflow_experiment) + mod.mlflow.anthropic.autolog() + mod._mlflow_enabled = True + + criteria_path = Path(args.criteria) + output_dir = Path(args.output) + + if args.generated and args.gold: + mod.run_single(criteria_path, Path(args.generated), Path(args.gold), output_dir) + elif args.dataset and args.results_dir: + mod.run_batch(criteria_path, Path(args.dataset), Path(args.results_dir), output_dir) + else: + print( + "Error: provide either --generated + --gold, or --dataset + --results-dir", + file=sys.stderr, + ) + return 1 + + return 0 + + async def cmd_smoke_test(_args: argparse.Namespace) -> int: """Run an end-to-end smoke test to verify Forge runtime connectivity and execution.""" from forge.config import get_settings @@ -1340,6 +1451,71 @@ def main(argv: list[str] | None = None) -> int: help="Print the installed Forge package version", ) + # test-skill subparser group + test_skill_parser = subparsers.add_parser( + "test-skill", + help="Test and evaluate Forge skills locally", + ) + test_skill_subparsers = test_skill_parser.add_subparsers( + dest="test_skill_command", + help="Test-skill commands", + ) + + # test-skill run + ts_run_parser = test_skill_subparsers.add_parser( + "run", + help="Run a skill against test cases", + ) + ts_run_parser.add_argument("--skill", required=True, help="Skill name (e.g., generate-prd)") + ts_run_parser.add_argument("--skill-dir", required=True, help="Path to skill directory") + ts_run_parser.add_argument("--input", help="Path to a single input.yaml test case") + ts_run_parser.add_argument("--dataset", help="Path to dataset directory (runs all cases)") + ts_run_parser.add_argument("--output", required=True, help="Output directory") + ts_run_parser.add_argument("--model", help="Override model from config") + ts_run_parser.add_argument( + "--mlflow", + metavar="URI", + help="MLflow tracking URI (e.g., http://host:5000)", + ) + ts_run_parser.add_argument( + "--mlflow-experiment", + default="forge-skill-eval", + help="MLflow experiment name (default: forge-skill-eval)", + ) + ts_run_parser.add_argument( + "--project", + help="Project name for skill path (e.g., osac). Overrides config.yaml.", + ) + ts_run_parser.add_argument( + "--references", + metavar="FILE", + help="JSON file with reference documentation (same format as forge.references).", + ) + + # test-skill eval + ts_eval_parser = test_skill_subparsers.add_parser( + "eval", + help="Evaluate skill outputs against gold standards", + ) + ts_eval_parser.add_argument("--criteria", required=True, help="Path to criteria YAML") + ts_eval_parser.add_argument("--generated", help="Path to generated artifact") + ts_eval_parser.add_argument("--gold", help="Path to gold standard artifact") + ts_eval_parser.add_argument("--dataset", help="Path to dataset directory (batch mode)") + ts_eval_parser.add_argument( + "--results-dir", help="Path to runner output directory (batch mode)" + ) + ts_eval_parser.add_argument("--output", required=True, help="Output directory for reports") + ts_eval_parser.add_argument( + "--mlflow", + metavar="URI", + help="MLflow tracking URI (e.g., http://host:5000)", + ) + ts_eval_parser.add_argument( + "--mlflow-experiment", + default="forge-skill-eval", + help="MLflow experiment name (default: forge-skill-eval)", + ) + # skills subparser group skills_parser = subparsers.add_parser( "skills", @@ -1589,6 +1765,22 @@ def main(argv: list[str] | None = None) -> int: parser.print_help() return 0 + # Handle test-skill subcommands (sync handlers — no asyncio.run wrapper) + if args.command == "test-skill": + test_skill_handlers = { + "run": cmd_test_skill_run, + "eval": cmd_test_skill_eval, + } + ts_cmd = getattr(args, "test_skill_command", None) + if ts_cmd is None: + test_skill_parser.print_help() + return 0 + ts_handler = test_skill_handlers.get(ts_cmd) + if ts_handler: + return ts_handler(args) + test_skill_parser.print_help() + return 0 + # Handle skills subcommands if args.command == "skills": skills_handlers = {