Skip to content
Merged
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
13 changes: 9 additions & 4 deletions src/agentdiff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions src/agentdiff/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
5 changes: 5 additions & 0 deletions src/agentdiff/models/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
4 changes: 4 additions & 0 deletions src/agentdiff/reporters/pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -93,4 +94,7 @@ def generate_pr_markdown(
)
lines.append("")

if gate_provenance:
lines.append(f"<sub>{gate_provenance}</sub>")

return "\n".join(lines)
11 changes: 9 additions & 2 deletions src/agentdiff/reporters/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/test_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<sub>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
5 changes: 5 additions & 0 deletions website/docs/03-Guides/05-Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading