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
7 changes: 7 additions & 0 deletions .github/actions/agentdiff-check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ inputs:
description: "GitHub pull request number to post the PR-ready report comment to."
required: false
default: ""
baseline-config:
description: "Path to the agentdiff.toml the BASELINE was recorded with. When gate values differ from this run's config, the PR report flags the change (Goodhart guard)."
required: false
default: ""
github-token:
description: "GitHub token used to post the PR comment (e.g. secrets.GITHUB_TOKEN). Required when pr is set."
required: false
Expand Down Expand Up @@ -71,6 +75,9 @@ runs:
if [[ -n "${{ inputs.pr }}" ]]; then
args+=(--pr "${{ inputs.pr }}")
fi
if [[ -n "${{ inputs.baseline-config }}" ]]; then
args+=(--baseline-config "${{ inputs.baseline-config }}")
fi
agentdiff "${{ inputs.baseline }}" "${{ inputs.candidate }}" \
--baseline "${{ inputs.baseline }}" \
--fail-on-regression --format markdown "${args[@]}"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Options:
- `--max-cost-delta`: Maximum cost increase percentage allowed.
- `--baseline, -b PATH`: Compare against a persistent baseline trace file (see [Baseline workflow](#baseline-workflow)).
- `--update-baseline`: Overwrite the persistent baseline with the candidate after a clean diff.
- `--baseline-config PATH`: The `agentdiff.toml` the **baseline** was recorded with. When gate values differ from this run's config, the report flags the change (Goodhart guard — see [Gate governance](https://agentdiff.lostmartian.in/docs/configuration#gate-governance-goodhart-guard)).
- `--config PATH`: Load defaults from an `agentdiff.toml` (auto-discovered if not given).

#### Config-as-code (`agentdiff.toml`)
Expand Down
20 changes: 20 additions & 0 deletions src/agentdiff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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.loader import load_trace
from agentdiff.models.step import StepStatus
from agentdiff.recorder import record_run, save_trace
Expand Down Expand Up @@ -180,9 +181,22 @@ def diff(
max_drift: float = typer.Option(
0.05, help="Max TDI a clean run may have for staged auto-rotation."
),
baseline_config: str | None = typer.Option(
None,
"--baseline-config",
help="Path to the agentdiff.toml the BASELINE was recorded with. When gate values differ from this run's config, the report flags the change (Goodhart guard).",
),
):
"""Compares baseline and candidate agent trajectories."""
cfg = load_config(config)
threshold_changes: list = []
if baseline_config:
try:
baseline_cfg = load_config(baseline_config)
except FileNotFoundError as e:
typer.echo(f"Baseline config not found: {e}", err=True)
sys.exit(2)
threshold_changes = diff_gate_thresholds(baseline_cfg, cfg)
values = _resolve_cli(
cfg,
adapter=adapter,
Expand Down Expand Up @@ -286,6 +300,7 @@ def diff(
max_loops=max_loops,
max_cost_delta=max_cost_delta,
max_recovery_ratio=max_recovery_ratio,
threshold_changes=threshold_changes,
)
if not output_file:
typer.echo(output_content)
Expand All @@ -299,6 +314,10 @@ def diff(
culprit = locate_culprit(report)
if culprit:
typer.echo("\n" + culprit.render())
if threshold_changes:
typer.echo("\nGate thresholds changed vs baseline config:")
for change in threshold_changes:
typer.echo(f" ! {change.render()}")

# Print the collapsed divergence tree when requested
if tree:
Expand All @@ -312,6 +331,7 @@ def diff(
max_loops=max_loops,
max_cost_delta=max_cost_delta,
max_recovery_ratio=max_recovery_ratio,
threshold_changes=threshold_changes,
)
comment = post_pr_comment(body, pr)
url = comment.get("html_url")
Expand Down
64 changes: 64 additions & 0 deletions src/agentdiff/governance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Gate governance — detect when the gate itself changed (G6/G7).

Goodhart guard: a threshold tuned until CI goes green stops being a control.
These helpers make threshold changes visible *next to the diff they let
through*, so loosening the gate is as reviewable as the code it guards.
"""

from __future__ import annotations

from dataclasses import dataclass

from agentdiff.config import AgentDiffConfig

# The gate knobs that decide pass/fail in the CLI diff path, in display order.
_GATED_KEYS: tuple[str, ...] = (
"max_divergence",
"max_loops",
"max_cost_delta",
"max_recovery_ratio",
)

# Defaults used when neither config nor flag provides a value (mirrors cli.py).
_EFFECTIVE_DEFAULTS: dict[str, float | int | None] = {
"max_divergence": 0.3,
"max_loops": 0,
"max_cost_delta": 10.0,
"max_recovery_ratio": None,
}


@dataclass(frozen=True)
class ThresholdChange:
"""One gate knob that differs between the baseline config and this run."""

gate: str
old: float | int | None
new: float | int | None

def render(self) -> str:
return f"{self.gate}: `{self.old}` → `{self.new}`"


def effective_gates(cfg: AgentDiffConfig) -> dict[str, float | int | None]:
"""Resolves the effective gate values from a config (config or defaults)."""
resolved: dict[str, float | int | None] = {}
for key in _GATED_KEYS:
value = getattr(cfg.cli, key, None)
if value is None:
value = _EFFECTIVE_DEFAULTS[key]
resolved[key] = value
return resolved


def diff_gate_thresholds(
baseline_cfg: AgentDiffConfig, candidate_cfg: AgentDiffConfig
) -> list[ThresholdChange]:
"""Returns gate knobs that differ between two configs, display-ordered."""
old_gates = effective_gates(baseline_cfg)
new_gates = effective_gates(candidate_cfg)
return [
ThresholdChange(gate=key, old=old_gates[key], new=new_gates[key])
for key in _GATED_KEYS
if old_gates[key] != new_gates[key]
]
31 changes: 26 additions & 5 deletions src/agentdiff/reporters/pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from agentdiff.engine.explanations import locate_culprit
from agentdiff.engine.tree import render_tree
from agentdiff.governance import ThresholdChange
from agentdiff.models.report import DiffReport

DEFAULT_MAX_DIVERGENCE = 0.3
Expand All @@ -14,12 +15,17 @@ def generate_pr_markdown(
max_loops: int = 0,
max_cost_delta: float = DEFAULT_MAX_COST_DELTA,
max_recovery_ratio: float | None = None,
threshold_changes: list[ThresholdChange] | None = None,
) -> str:
"""Renders a compact, PR-ready markdown comment.

Summary status + gate thresholds, the collapsed divergence tree, and the
root-cause step — everything a reviewer needs without the full diff.
The Recovery Step Ratio row only appears when a threshold is provided.

When ``threshold_changes`` is non-empty (G6), a warning block renders
above the gate table: the gate itself moved in this PR, so the diff
above was judged against looser/tighter rules than the baseline had.
"""
status = "⛔ **FAILED**" if not report.passed else "✅ **PASSED**"

Expand All @@ -28,12 +34,27 @@ def generate_pr_markdown(
"",
f"**Status:** {status}",
"",
"| Gate | Value | Threshold |",
"| :--- | :--- | :--- |",
f"| TDI | `{report.trajectory_divergence_index:.4f}` | ≤ `{max_divergence}` |",
f"| Loops | `{len(report.loops_detected)}` | ≤ `{max_loops}` |",
f"| Cost delta | `{report.cost_delta_percentage:+.2f}%` | ≤ `{max_cost_delta}%` |",
]

if threshold_changes:
lines.append("> [!WARNING]")
lines.append(
"> **Gate thresholds changed in this PR** — the diff below was judged against this PR's rules, not the baseline's."
)
lines.append(">")
for change in threshold_changes:
lines.append(f"> - {change.render()}")
lines.append("")

lines.extend(
[
"| Gate | Value | Threshold |",
"| :--- | :--- | :--- |",
f"| TDI | `{report.trajectory_divergence_index:.4f}` | ≤ `{max_divergence}` |",
f"| Loops | `{len(report.loops_detected)}` | ≤ `{max_loops}` |",
f"| Cost delta | `{report.cost_delta_percentage:+.2f}%` | ≤ `{max_cost_delta}%` |",
]
)
if max_recovery_ratio is not None:
lines.append(
f"| Recovery Step Ratio | `{report.recovery_step_ratio:.2f} "
Expand Down
Loading
Loading