diff --git a/src/agentdiff/cli.py b/src/agentdiff/cli.py index d8b19c8e..2b51b20d 100644 --- a/src/agentdiff/cli.py +++ b/src/agentdiff/cli.py @@ -8,11 +8,11 @@ from agentdiff.ci.baseline import decide_rotation from agentdiff.ci.github import post_pr_comment -from agentdiff.config import AgentDiffConfig, load_config +from agentdiff.config import AgentDiffConfig, find_config_file, load_config from agentdiff.engine.comparator import compare from agentdiff.engine.explanations import format_explanations, locate_culprit from agentdiff.engine.tree import render_tree -from agentdiff.governance import diff_gate_thresholds +from agentdiff.governance import diff_gate_thresholds, provenance_line from agentdiff.loader import load_trace from agentdiff.models.step import StepStatus from agentdiff.recorder import record_run, save_trace @@ -189,6 +189,7 @@ def diff( ): """Compares baseline and candidate agent trajectories.""" cfg = load_config(config) + config_source = config or (str(find_config_file()) if find_config_file() else None) threshold_changes: list = [] if baseline_config: try: @@ -197,6 +198,7 @@ def diff( typer.echo(f"Baseline config not found: {e}", err=True) sys.exit(2) threshold_changes = diff_gate_thresholds(baseline_cfg, cfg) + gate_provenance = provenance_line(cfg, config_source) values = _resolve_cli( cfg, adapter=adapter, @@ -282,10 +284,11 @@ def diff( # For console printing we write directly, but we can capture it or format differently if output_file is active if output_file: # If writing terminal format to file, output the text summary representation - output_content = report.summary() + output_content = report.summary() + f"\n{gate_provenance}\n" else: - print_report(report) + print_report(report, gate_provenance=gate_provenance) elif format.lower() == "json": + report.gate_provenance = gate_provenance output_content = report.model_dump_json(indent=2) if not output_file: typer.echo(output_content) @@ -301,6 +304,7 @@ def diff( max_cost_delta=max_cost_delta, max_recovery_ratio=max_recovery_ratio, threshold_changes=threshold_changes, + gate_provenance=gate_provenance, ) if not output_file: typer.echo(output_content) @@ -332,6 +336,7 @@ def diff( max_cost_delta=max_cost_delta, max_recovery_ratio=max_recovery_ratio, threshold_changes=threshold_changes, + gate_provenance=gate_provenance, ) comment = post_pr_comment(body, pr) url = comment.get("html_url") diff --git a/src/agentdiff/governance.py b/src/agentdiff/governance.py index 8fee372d..5fad48c3 100644 --- a/src/agentdiff/governance.py +++ b/src/agentdiff/governance.py @@ -62,3 +62,25 @@ def diff_gate_thresholds( for key in _GATED_KEYS if old_gates[key] != new_gates[key] ] + + +def provenance_line(cfg: AgentDiffConfig, config_path: str | None) -> str: + """G7 — one-line, self-describing gate summary for any report. + + Names the active thresholds and where they came from, so every diff + answers "what rules judged me?" without opening the config. + """ + gates = effective_gates(cfg) + source = ( + f"agentdiff.toml ({config_path})" + if config_path + else "defaults (no agentdiff.toml found)" + ) + parts = [ + f"max_divergence={gates['max_divergence']}", + f"max_loops={gates['max_loops']}", + f"max_cost_delta={gates['max_cost_delta']}%", + ] + if gates["max_recovery_ratio"] is not None: + parts.append(f"max_recovery_ratio={gates['max_recovery_ratio']}") + return f"Gate: {', '.join(parts)} — source: {source}" diff --git a/src/agentdiff/models/report.py b/src/agentdiff/models/report.py index dc83ecc5..73673efc 100644 --- a/src/agentdiff/models/report.py +++ b/src/agentdiff/models/report.py @@ -37,6 +37,11 @@ class DiffReport(BaseModel): recovery_step_ratio: float = 0.0 step_diffs: list[StepDiff] = Field(default_factory=list) passed: bool = True + gate_provenance: str | None = Field( + default=None, + description="G7: one-line summary of active gate thresholds and their " + "source (agentdiff.toml path or defaults). Populated by the CLI.", + ) def summary(self) -> str: """Returns a string summarizing the comparison report.""" diff --git a/src/agentdiff/reporters/pr.py b/src/agentdiff/reporters/pr.py index cd8b18e7..f48b68e0 100644 --- a/src/agentdiff/reporters/pr.py +++ b/src/agentdiff/reporters/pr.py @@ -16,6 +16,7 @@ def generate_pr_markdown( max_cost_delta: float = DEFAULT_MAX_COST_DELTA, max_recovery_ratio: float | None = None, threshold_changes: list[ThresholdChange] | None = None, + gate_provenance: str | None = None, ) -> str: """Renders a compact, PR-ready markdown comment. @@ -93,4 +94,7 @@ def generate_pr_markdown( ) lines.append("") + if gate_provenance: + lines.append(f"{gate_provenance}") + return "\n".join(lines) diff --git a/src/agentdiff/reporters/terminal.py b/src/agentdiff/reporters/terminal.py index 3d4315ab..58ad8132 100644 --- a/src/agentdiff/reporters/terminal.py +++ b/src/agentdiff/reporters/terminal.py @@ -63,8 +63,13 @@ def render_diff_table(report: DiffReport) -> Table: return table -def print_report(report: DiffReport): - """Outputs the complete DiffReport to the terminal.""" +def print_report(report: DiffReport, gate_provenance: str | None = None): + """Outputs the complete DiffReport to the terminal. + + ``gate_provenance`` (G7) appends a one-line, self-describing gate summary + — active thresholds and their source — so the report answers "what rules + judged me?" without opening the config. + """ console = Console() status_str = ( @@ -87,6 +92,8 @@ def print_report(report: DiffReport): f" • Token Delta: {report.token_delta_percentage:+.2f}%\n" f" • Cost Delta: {report.cost_delta_percentage:+.2f}%" ) + if gate_provenance: + summary_text += f"\n\n[dim]{gate_provenance}[/dim]" panel = Panel( summary_text, diff --git a/tests/test_governance.py b/tests/test_governance.py index 91a5cb39..810cb2b7 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -205,3 +205,69 @@ def test_missing_baseline_config_file_exits_2(self, tmp_path): ], ) assert result.exit_code == 2 + + +class TestGateProvenance: + """G7 — every report self-describes its active thresholds + source.""" + + def test_provenance_line_defaults(self): + from agentdiff.governance import provenance_line + + line = provenance_line(AgentDiffConfig(), None) + assert "max_divergence=0.3" in line + assert "max_loops=0" in line + assert "max_cost_delta=10.0%" in line + assert "source: defaults (no agentdiff.toml found)" in line + + def test_provenance_line_with_path(self, tmp_path): + from agentdiff.governance import provenance_line + + cfg_file = tmp_path / "agentdiff.toml" + cfg_file.write_text("[cli]\nmax_divergence = 0.4\nmax_recovery_ratio = 1.5\n") + line = provenance_line(load_config(cfg_file), str(cfg_file)) + assert "max_divergence=0.4" in line + assert "max_recovery_ratio=1.5" in line # opt-in gate appears when set + assert f"source: agentdiff.toml ({cfg_file})" in line + + def test_terminal_format_includes_provenance(self, tmp_path): + base, cand = TestCLIBaselineConfig()._traces(tmp_path) + result = runner.invoke( + __import__("agentdiff.cli", fromlist=["app"]).app, + ["diff", str(base), str(cand)], + ) + assert result.exit_code == 0 + assert "Gate: max_divergence=0.3" in result.output + assert ( + "defaults" in result.output + ) # rich wraps long lines; assert words, not phrases + + def test_json_format_includes_provenance(self, tmp_path): + base, cand = TestCLIBaselineConfig()._traces(tmp_path) + result = runner.invoke( + __import__("agentdiff.cli", fromlist=["app"]).app, + ["diff", str(base), str(cand), "--format", "json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "Gate: max_divergence=0.3" in data["gate_provenance"] + + def test_pr_format_includes_provenance_footer(self, tmp_path): + base, cand = TestCLIBaselineConfig()._traces(tmp_path) + result = runner.invoke( + __import__("agentdiff.cli", fromlist=["app"]).app, + ["diff", str(base), str(cand), "--format", "pr"], + ) + assert result.exit_code == 0 + assert "Gate: max_divergence=0.3" in result.output + + def test_explicit_config_path_shown_as_source(self, tmp_path): + base, cand = TestCLIBaselineConfig()._traces(tmp_path) + cfg_file = tmp_path / "agentdiff.toml" + cfg_file.write_text("[cli]\nmax_divergence = 0.45\n") + result = runner.invoke( + __import__("agentdiff.cli", fromlist=["app"]).app, + ["diff", str(base), str(cand), "--config", str(cfg_file)], + ) + assert result.exit_code == 0 + assert "max_divergence=0.45" in result.output + assert "agentdiff.toml" in result.output # path may wrap; filename stays whole diff --git a/website/docs/03-Guides/05-Configuration.md b/website/docs/03-Guides/05-Configuration.md index cb68dcfa..2ce411e5 100644 --- a/website/docs/03-Guides/05-Configuration.md +++ b/website/docs/03-Guides/05-Configuration.md @@ -66,6 +66,11 @@ The PR comment renders a warning block above the gate table: The same summary prints with `--explain`. Loosening a gate is now as visible in review as the code it guards. +Every report also self-describes its rules: a one-line gate provenance +(`Gate: max_divergence=0.3, max_loops=0, ... - source: agentdiff.toml`) +appears in the terminal summary, the JSON (`gate_provenance` field), and +the PR comment footer - so a diff always answers "what rules judged me?". + ## From the SDK You can load config programmatically with `load_config()`, which returns an