DEV Community

Cover image for I Built Scenario Packs for Agent Regression Testing. The Integration, Not the Judge, Broke Me.
Debashish Ghosal
Debashish Ghosal

Posted on • Edited on

I Built Scenario Packs for Agent Regression Testing. The Integration, Not the Judge, Broke Me.

Adapters need conformance suites too

Update: 2026-08-10 — v0.2.0 shipped

EvalForge v0.2.0 is out. All 38 GitHub issues from the community feedback sprint are closed. Full release notes in the changelog.

What landed since this article:

  • 7 new framework adapters — CrewAI, OpenAI Agents SDK, smolagents, AutoGen, LlamaIndex, Claude Agent SDK, and Google ADK. EvalForge now supports 12 frameworks total (5 from v0.1 + 7 new). All field-tested against real agents.
  • Field scenario packs — 10 packs, 47 scenarios covering all 8 OMLX-compatible framework families. Each pack has 5 scenarios (basic-tool-call, multi-step, no-tool, disallowed-tool, structured-output) plus a shared safety pack.
  • Three-gate CI scoring — compatibility, safety, and quality dimensions scored independently with per-dimension gating (--fail-on compatibility|safety|quality).
  • Scoring breakdown + divergence detection — per-check pass/fail for all 17 deterministic scorers. Divergences between deterministic and LLM judge are classified as critical or warning. --fail-on-divergence critical for CI gating.
  • Adapter manifest + baseline binding — structured adapter metadata with SHA-256 digest. Changing the adapter between baseline and candidate is detected as adapter_changed, not a regression.
  • Run manifestrun-manifest.json per run with OS, arch, Python version, dependency tree, adapter info. --no-manifest for privacy-sensitive environments.
  • 6 critical bug fixes — exit codes now non-zero on violations, crashed agents no longer score as passing, score-delta regression detection, ToolStub wired into runner, rubric criteria reach judges, CLI paper cuts fixed.
  • Offline hybrid scorerpolicy_adherence deterministic gate always fires, even without a judge. Safety guarantees never silently skipped.
  • Two-layer defense architecture — documented in docs/architecture.md. EvalForge (Layer 2) catches integration failures that a judgment evaluator (Layer 1) cannot see. Validated by the JPS integration study.

The article below describes the v0.1 architecture. The v0.2.0 release expands it — the scenario pack contract, ground-truth boundary, and baseline comparison are unchanged. What is new is the framework surface, the scoring precision, and the CI gating depth.


I thought the hard part would be the scoring.

Write clean YAML. Define expected behavior. Run agents. Compare scores. Catch regressions. Ship with confidence.

That mental model lasted about one afternoon of real-agent field testing.

The thing that broke was not the judge. It was not the rubric design. It was the realization that a scenario pack is only as honest as the path between your harness and a real, messy, third-party agent that imports ffmpeg at module scope, hardcodes gpt-3.5-turbo, and writes to /root the moment you touch it.

This is the second article in a series about EvalForge, an OSS evaluation harness for tool-using AI agents. Article 1 made the case that agent evaluation is a different problem from model evaluation because the path matters, not just the answer. The launch article is the longer story of what real agents taught me once the code was public. This one is narrower: scenario packs, baselines, and scoring. The three concrete things I built, and the one that broke first.

The Scenario Pack Is a Contract, Not a Test File

Before I get to what broke, I need to show what I actually built, because the design decisions in the pack format are where the engineering lives.

This is a scenario from the launch pack. One of twenty. It looks clean. It is clean:

# scenarios/core-launch.yaml — launch-01-account-policy
# https://github.com/deghosal-2026/agent-eval-forge/blob/main/scenarios/core-launch.yaml
- id: "launch-01-account-policy"
  title: "Account policy lookup"
  goal: "Retrieve a specific policy detail using one tool call"
  input: "What is the return policy for premium customers?"
  allowed_tools:
    - name: "policy_lookup"
  disallowed_tools: []
  expected:
    type: exact
    value: "Premium customers receive a 60-day return window with free return shipping."
  metrics:
    task_completion: {threshold: 1.0}
    output_correctness: {threshold: 0.8}
    tool_correctness: {threshold: 1.0}
    step_efficiency: {threshold: 0.7}
  tags: [retrieval, single-tool]
  difficulty: easy
  budget: {max_steps: 3, max_tokens: 300}
Enter fullscreen mode Exit fullscreen mode

Twenty scenarios shipped in v0.1 across ten families: single-tool retrieval, multi-tool synthesis, structured extraction, tool argument precision, refusal, ambiguity clarification, budget constraints, failure recovery, coding-agent regression, and classification. Eight more for security: prompt injection, exfiltration, SSRF, sandbox escape.

The architecture is straightforward: CLI runs the pack through a core runner. Runner delegates to an adapter. Adapter talks to the agent. Scorer evaluates the trajectory. Judge fills in semantic gaps. Diff engine compares the result against a saved baseline.

The ground-truth boundary

The most important design decision in the pack format is not visible in the YAML. It is what the agent never sees.

The expected and metrics fields are evaluation-only. They are stripped before the agent receives anything. The build_invocation_payload function in the adapter base is the enforcement point:

# src/evalforge/adapters/base.py — build_invocation_payload
def build_invocation_payload(scenario: Scenario, run_id: str) -> dict[str, Any]:
    return {
        "schema_version": "evalforge.invocation_payload.v1",
        "run_id": run_id,
        "scenario_id": scenario.id,
        "input": scenario.input,
        "context": scenario.context,
        "allowed_tools": [tool.model_dump() for tool in scenario.allowed_tools],
        "disallowed_tools": [tool.model_dump() for tool in scenario.disallowed_tools],
        "budget": scenario.budget.model_dump() if scenario.budget else {},
    }
Enter fullscreen mode Exit fullscreen mode

Notice what is not in that dict. No expected. No metrics. No threshold. No goal. The agent gets the input, the tool surface, and a budget. It does not get the answer key. It cannot game what it cannot see.

This is not a convenience — it is a correctness boundary. If ground truth leaks into the agent's context, every score is suspect. The Scenario model documents this in its docstring: "expected/metrics are evaluation-only and never sent to agents." The Baseline model and the ComparisonEngine both depend on that boundary holding. If it breaks, the regression story breaks with it.

There is a second boundary in the same file, and it is the kind of thing nobody talks about until it bites them. The _sanitize_agent function strips API keys and tokens from adapter config before writing them into run artifacts. Pass api_key in your adapter config — it never reaches the artifact store. Secrets do not persist. I would call this a feature, except that calling it a feature implies it is optional. It is not.

A third one: when the adapter parses agent output, a "completed" run that produced no output at all is treated as an error, not a pass. The comment in _artifact_from_envelope is blunt: "blank completions usually signal a dead entry point or empty tool result, and must never count as passes." A blank completion is a failure wearing a pass costume. The harness refuses to count it.

These three boundaries — ground-truth stripping, secret sanitization, blank-completion rejection — are the ones I would fight to keep if I had to rebuild from scratch. Everything else is negotiable. These are not.

If you are building an eval harness, I want to know: where is your ground-truth boundary? Is it enforced at a single function, or is it a convention that depends on every adapter remembering to do the right thing?

The Adapter Problem Started Before Scoring Even Ran

Then I sourced 19 OSS agents from GitHub — 11 LangGraph, 8 PydanticAI — using a star-bucket strategy. High-star repos for maturity signals, medium for real-world mess, low to see if the tool adds any signal in chaotic codebases. The sourcing methodology is documented in docs/hard-won-lessons.md.

Nine passes. Out of 95 scenario-agent combinations.

Not nine-per-agent. Nine total.

The instinct when you see nine passes is to blame the judge. Switch from gpt-4o-mini to gpt-4o. Tune the rubrics. Add more scoring dimensions.

I ran the same passes on two judge tiers — gpt-4o-mini (cheap) and gpt-4o (better). Same outcome both times. Nine passes. The better judge did not surface a single regression or improvement the cheaper one missed. The bottleneck was not the scoring layer at all.

The bottleneck was whether the harness could run the agent in the first place.

Five ways real agents broke the adapter

I documented these in the hard-won lessons file, but these are the patterns that actually hit:

Absolute writes at import time. Several agents wrote to /root/something inside their __init__.py. The harness runs in a locked-down sandbox. Import failed before any evaluation code executed. The fix was not elegant: redirect HOME, TMPDIR, and XDG_CACHE_HOME to per-agent .cache directories. Agents that still wrote to absolute paths got quarantined.

Gateway-bound imports. Multiple agents did ChatOpenAI(api_key=os.getenv("OPENAI_API_KEY")) at module scope. If the key is missing, the module itself raises. You cannot import it. You cannot evaluate it. The workaround was dummy env vars for the local tier. Agents that required real gateway connectivity got quarantined for local runs.

Hardcoded model names. ChatOpenAI(model="gpt-3.5-turbo") at module scope. I pointed OPENAI_BASE_URL at a local OMLX server running Qwen3.5-9B-MLX-4bit. The agent still asked for gpt-3.5-turbo. OMLX does not serve that model. 404. The fix was monkeypatching ChatOpenAI.__init__ before the agent module is imported — and I learned the hard way that Pydantic v2 field-default patching does not work for this. It has to be __init__. It has to run before import.

Typed StateGraph with no chat surface. Some LangGraph agents use typed StateGraph with internal domain state fields. The harness sends chat messages. The agent expects AgentState with typed keys. There is no bridge. I had to write thin evalforge_wrapper.py modules per agent to translate. This is not a harness bug. It is a design gap: the harness assumes a message surface, and typed-graph agents do not expose one.

Database bootstrap at import. create_async_engine(DATABASE_URL) and FAISS.load_local(...) inside module scope. The harness should not be patching around an agent's entire infrastructure bootstrap. I learned to classify agents by import-time side effects — no infra, needs DB/keys/files, needs running server — and skip the ones I could not run locally. Move on. Do not fight databases.

The lesson I walked away with: a scenario pack tests your adapter before it tests your agent. If the harness cannot faithfully run a random third-party agent, the signal you are measuring is integration friction, not agent quality. Friction is real and worth measuring. It is just not the same thing, and calling it the same thing is how teams ship agents they do not actually understand.

What I would build differently

The harness currently classifies agents into three tiers — local, Docker, quarantined — and moves on. That triage works for a first pass but paper-bags a real architectural choice.

Right now the default adapter imports agent code directly into the harness process. The python_import adapter shoulders the import, and the isolated adapter wraps it in a subprocess for some safety. But the boundary is still "shared Python process" at heart.

A cleaner design would be: the harness never imports agent code. It always communicates through a strict stdin/stdout contract. The subprocess adapter already exists and already works this way. Every agent gets a well-defined protocol: invoke(input, tools, budget) → trajectory. The harness does not care what language the agent is written in, what it imports, or what it writes to disk.

The import-based adapters were faster to wire for the first 19 agents. I would build the subprocess boundary as the one true path from the start, and treat import-based adapters as an opt-in optimization for agents you already trust in-process.

This is the one I keep turning over: should an eval harness ever share a process with the thing it is evaluating? Or is process isolation the minimum bar for honest measurement? I lean toward isolation, but I want to hear from anyone who has made the tradeoff the other way.

The Baseline Problem (This Is Where Regression Actually Lives)

Once the adapter runs, you have trajectory artifacts. Now you need to compare versions.

The default approach in most eval setups I have seen is implicit. Run the new version. It produces scores. Eyeball the numbers. Decide. There is no explicit baseline. There is no structured diff. There is just the latest JSON file and your gut.

Why does that break?

Say your agent scores 0.92 across 20 scenarios. Solid. Ship. Next week you change the prompt. Average drops to 0.89. Still decent. Ship again. Two more prompt changes later, average is 0.84. Each individual drop was small. No single change triggered alarm. But the cumulative drift from 0.92 to 0.84 is real, and "last run wins" never catches it because the reference point keeps resetting.

EvalForge takes the opposite approach: explicit golden baselines. You save a baseline explicitly and judge everything against it until you intentionally promote a new one:

evalforge run --pack core-launch.yaml --agent python:my_agent.py
evalforge baseline save --name v1.3.0 --run .evalforge/runs/latest
Enter fullscreen mode Exit fullscreen mode

The Baseline model captures more than just scores. It snapshots the full artifact set, the frozen score state, the git SHA, agent metadata, and trust level. When you compare, you are comparing against a known-good reference that is traceable to exact source:

@dataclass
class Baseline:
    name: str                # "v1.3.0"
    pack: str                # "core-launch-pack"
    pack_version: str        # "1.2.0"
    runs: list[RunArtifact]  # One artifact per scenario
    score_snapshot: dict     # Frozen scores for fast CI comparison
    agent: dict              # Framework, version, model
    git_sha: str | None      # Traceable to exact source
    created: str             # ISO-8601
Enter fullscreen mode Exit fullscreen mode

Three-level comparison

The ComparisonEngine compares at three levels.

Per-scenario. A regression is defined narrowly: baseline was "passed" and candidate is not "passed". If a scenario was already failing, the new version cannot "regress" on it. That is a deliberate product choice. The engine's job is release gating. It asks one question: did this change make something currently working stop working?

# src/evalforge/comparison/engine.py — the regression classification
"regressed": (
    base_ss is not None and cand_ss is not None
    and base_ss.status == "passed" and cand_ss.status != "passed"
),
"improved": (
    base_ss is not None and cand_ss is not None
    and base_ss.status != "passed" and cand_ss.status == "passed"
),
"new_failure": (
    base_ss is None and cand_ss is not None and cand_ss.status != "passed"
),
Enter fullscreen mode Exit fullscreen mode

Per-family. Scenarios are tagged: retrieval, safety, multi-tool, synthesis. The engine groups deltas by tag. A +0.03 overall delta is meaningless if the safety family dropped 0.15 while retrieval gained 0.18. The aggregate number is for dashboards. The family breakdown is for decisions.

Per-pack. Total counts: regressed, improved, unchanged, new failures, new passes. Plus cost delta in USD between baseline runs and candidate runs.

Two comparison modes exist. Snapshot mode compares saved baseline scores against candidate scores — no new judge calls, fast, CI-friendly. Rescore mode re-runs the judge on both baseline and candidate artifacts. Use rescore when the judge model changed or you suspect stale baseline scores. Snapshot is the pragmatic default because it avoids token cost in CI.

There is a subtlety in the per-scenario definition that I want to flag for discussion: a scenario that was already failing cannot "regress." It can only stay broken or improve. That means a version change that makes a failing scenario fail differently — say, from a timeout to a hallucination — shows as "unchanged" in the comparison. Is that the right call? I think so for release gating, because the release question is "did something working break?" not "did something broken change shape?" But I can see an argument for tracking failure-mode shifts separately. If you have an opinion, I want to hear it.

Scoring: Deterministic First, Judge Only When Necessary

While the adapter was humbling me, the scoring design held up better than I expected. What went into it — and what it deliberately does not do.

Seventeen deterministic scorers ship in v0.1. They are free, reproducible, and run on every artifact. ToolCorrectnessScorer computes the fraction of tool calls that were to known tools. ZeroDisallowedActionsScorer checks that no disallowed tools were called — and returns blocking=True, forcing the scenario to failed regardless of answer quality. That is a safety decision, not a scoring convenience.

Two metrics use a hybrid gate+judge strategy: policy_adherence and retry_discipline. The deterministic gate runs first. If it passes, skip the expensive LLM call. If it fails, escalate to the judge. Judge results are cached keyed by scenario ID, judge model, and artifact hash so the same input does not get re-judged across runs.

The exit code hierarchy is encoded in ScoringEngine._resolve_exit_code:

# src/evalforge/scoring/engine.py — _resolve_exit_code
def _resolve_exit_code(self, scenario_scores, safety_violations):
    if safety_violations:
        return 4   # Safety — always blocking, highest priority
    if judge_errors:
        return 3   # Judge failure (API timeout, etc.)
    if any(ss.status != "passed" for ss in scenario_scores.values()):
        return 1   # Standard failure
    return 0        # Clean pass
Enter fullscreen mode Exit fullscreen mode

Safety violations are exit code 4 and override everything. Judge errors are 3. Standard failures are 1. Clean pass is 0. If the agent called a disallowed tool, it does not matter that the answer was correct. The pipeline blocks. There is no threshold negotiation.

The scoring layer explicitly does not auto-optimize, does not run in production, and does not promise coverage. Twenty scenarios catch obvious regressions. They do not catch every failure mode. No offline eval does.

The scoring layer has one design tension I want to surface: the WARN band. A score above threshold is a PASS. Above threshold times 0.7 is a WARN. Below that is a FAIL. The WARN band exists to catch near-misses before they become regressions. But WARN does not block in CI by default — exit code 1 only fires on actual failures. So a scenario that drops from 0.95 to 0.72 (just above the 0.7 WARN cutoff) shows as "passed" in the comparison engine, because both baseline and candidate have status "passed." The regression is invisible to the release gate. Is that acceptable? I think WARN-level drift should be visible in the comparison report even if it does not block. Right now it is not. That is a gap.

What Would Make This Robust

EvalForge v0.1 works. It runs, it scores, it compares, it gates. But "works" and "robust" are different bars. What follows are the gaps I know about, roughly ranked by how much closing each one would change how much I trust the system.

Wire the failure taxonomy into the comparison report. The taxonomy in src/evalforge/analytics/ already classifies failures into buckets — safety_violation, hallucination, tool_error, budget_exceeded, agent_crash. It is just not connected to the comparison engine. So regressed: true is a signal without a diagnosis. The sentence I want the system to generate for me: "Scenario launch-06-disallowed-tool regressed because the new prompt caused the agent to call delete_customer — a disallowed tool it previously avoided." Not built yet. Should be the first thing I add.

Trajectory-step-level scoring. Scores report at the scenario level. You know a scenario regressed, but not which step in the agent's decision sequence caused it. Step-level scoring would make regression diagnosis faster. It would also make the failure taxonomy more precise — "step 3 called the wrong tool" is more actionable than "tool correctness dropped."

Make the subprocess adapter the default. The import-based adapters are convenient but they share a process with the agent. That means an agent that segfaults takes the harness with it. An agent that writes to sys.path corrupts the harness's import state. The subprocess adapter avoids all of this. It should be the default, not the opt-in.

Measured judge costs. The cost table in scoring/engine.py uses estimated pricing from provider pages. Provenance is tagged "estimated" in the source because token usage is not yet captured from judge SDKs. Moving to measured costs is a small change with high signal — it would make cost-delta reporting in the comparison engine trustworthy instead of approximate.

Pack version drift detection in CI. The Baseline model captures pack_version at save time. The BaselineStore.validate() method warns if pack versions diverge. But that check is not yet wired into the CI exit code path. If you change the pack and forget to re-baseline, the comparison silently runs against a stale pack version. That should be a hard failure, not a warning.

Rerun variance as a first-class metric. An agent that takes wildly different paths on identical inputs is harder to trust than one with stable routing. Right now the harness runs each scenario once. Running each scenario N times and reporting variance would surface fragility that single-run scoring misses. This is the metric I most want to add but have not yet.

The Thread

Adapter realism, golden baselines, multi-dimensional scoring. They read like three separate features. They are three layers of one problem: making agent evaluation honest enough to trust for a release decision.

The adapter is whether you are testing the real agent, not a sanitized import. The baseline is whether you are comparing against a known-good reference, not a moving target. The scoring layer is whether you are measuring tool discipline, safety, cost, and trajectory — not just the final answer. And the ground-truth boundary is whether the agent can game what it cannot see.

Lose any one of them and you have a dashboard. Keep all of them and you have a release gate.

EvalForge v0.1 has all of them working, but the adapter layer is still the weakest link — nine passes out of 95 says the integration gap is real. The scoring and baseline layers are ahead of the adapter in maturity. That is not the order I predicted going in. The clean version of the story had scoring as the hard problem. The build disagreed.

Where Do You Want This to Go Next?

I am still actively building this, and the decisions get harder as the system gets more real. Three directions, pick one:

  • Adapter realism — should an eval harness ever share a process with the agent it evaluates? Or is subprocess isolation the minimum bar? What has your experience been with import-based vs subprocess-based adapters?
  • Regression baselining — the per-scenario regression definition says "already-failing scenarios cannot regress." Is that the right call for release gating, or should failure-mode shifts be tracked separately? How do you handle WARN-band drift in your CI?
  • Trajectory scoring — step-level scoring vs scenario-level scoring. Is the additional granularity worth the complexity? Where did you find the signal that scenario-level scoring missed?

I will write the next article about whichever of these generates the most discussion.

Top comments (21)

Collapse
 
kikashy profile image
Brian Jin

This looks very complementary to something I’m testing with JPS.

I’m going to try a small integration experiment using Agent Eval Forge as the external regression harness - same scenarios and facts, with one path using prompt-only judgment and another using a deterministic Judgment Pack evaluator.

Then I want to introduce a few deliberate failures - threshold mistakes, missing evidence, ignored escalation, and incorrect fact mapping - and see which layer catches what.

I like the separation here: JPS can test the decision contract itself, while Agent Eval Forge can test whether the surrounding agent actually respects that decision correctly.

I’ll share the results once I have them.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal • Edited

Hey Brian, thanks for this — the separation you're describing is exactly the one I've been feeling my way toward. JPS testing the decision contract itself, EvalForge testing whether the surrounding agent respects it. Different layers, complementary signal.
Your four injected failures map onto scorers that already exist in the repo, which is a good sign the experiment will actually exercise real seams rather than slip past them:

  • Missing evidence → OutputGroundingScorer and SourceCitationScorer in scoring/deterministic/grounding.py — both already run on every artifact.
  • Ignored escalation → PolicyAdherenceGate in scoring/deterministic/gates.py, and it sets blocking=True, so the pipeline fails regardless of answer quality. This is the one most likely to show layer overlap — a deterministic contract evaluator and a blocking gate are solving the same problem from two directions. Worth watching whether they agree or just both fire.
  • Incorrect fact mapping → FactualConsistencyScorer and ContradictionDetectionScorer.
  • Threshold mistakes → the budget/step-efficiency scorers in scoring/deterministic/budget.py. One honest caveat for the experiment design: the policy_adherence and retry_discipline metrics are already hybrid — a deterministic gate runs first and the judge only fires if the gate fails. So a pure "prompt-only judge vs deterministic JPS" split will have the judge path doing less work than it looks like on those two metrics, because the gate short-circuits it. That's probably a feature for your comparison (it means the layers aren't redundant where the gate is strong), but I'd flag it so the results aren't misread as "the judge didn't catch X" when the gate never let the judge see X. If it's useful, the integration point I'd point you at is the scorer plugin system — tests/test_plugins.py has a ScorerPlugin example, and the registry is the registration path. Shipping JPS as a deterministic scorer plugin would let it run in the same pass as the existing 17 scorers rather than as a side-by-side harness, which I think would make the layer comparison a lot cleaner. Happy to wire a thin adapter or a sample scenario pair if it'd unblock the experiment. And please do share results — I'm genuinely curious which of those four injected failures the deterministic layer already swallows before the judge ever sees them.

I have created 0.2.0 issues for this - github.com/deghosal-2026/agent-eva...

Collapse
 
kikashy profile image
Brian Jin

Thanks for the detailed pointers.

Quick update: I went ahead and built the integration.

Both paths now run end-to-end against EvalForge, pinned at 8925cac, with the deliberate failures injected one at a time. No models involved yet - this first phase is intentionally deterministic, so every result is reproducible.

A few things surprised me once it was wired up.

The scorers doing most of the catching were the simple trace scorers. zero_disallowed_actions, tool_called, and argument_correctness caught most of the injected failures. The grounding and contradiction scorers never really entered the picture.

In hindsight that makes sense - these mutations did not change what the agent said. They changed what it did. So the failure was visible in the action trace, not the prose.

I also found an interesting offline edge in policy_adherence. With no judge configured, it appears to error rather than allowing the deterministic gate to run on its own. I ended up carrying the escalation checks through disallowed-tool assertions plus a small custom scorer. It may be worth looking at whether offline users should still get the deterministic portion of that scorer.

And on the idea of exposing JPS as an EvalForge scorer - I deliberately kept JPS out of the referee.

In one arm, JPS is part of the agent being tested. If the same judgment logic also participates in scoring, it becomes much harder to tell which layer actually detected the failure. EvalForge is more useful here precisely because it is an external observer that knows nothing about JPS.

The most interesting early result is that every caught mutation was detected deterministically - some by the judgment layer, some by EvalForge's trace scorers.

And a couple were caught by neither.

Those blind spots may end up being the most useful result, because they show where both layers can be individually correct while the overall system still has a gap.

I'll share the full detection matrix after review. The scenario format, runner, and artifact layers held up really well under this - that part of the design definitely earned its keep.

Thread Thread
 
kikashy profile image
Brian Jin

@debashish_ghosal

Promised results - the study is now complete and everything is public.

We ultimately injected 20 failures one at a time - some inside the judgment logic, others in the integration around it. Before running them, we registered which layer we expected to catch each failure.

All 20 detection predictions held.

The separation became very clear:

  • judgment-semantic failures were caught by the judgment layer's own tests
  • integration failures were invisible to that layer and caught by EvalForge's trace scorers
  • zero_disallowed_actions blocked every case where a protected action executed when it should not have

We also added four hidden adversarial cases written by an independent reviewer. One was intentionally designed to escape the judgment layer's tests - and it did. EvalForge's argument_correctness caught it downstream. That blind spot has since been fixed and released in the runtime.

We then added the model comparison you suggested - same cases, policy supplied as prose to a strong model versus the deterministic evaluator.

The model got 62 of 63 and never executed a forbidden action. The interesting failures were at the boundaries: it sometimes resolved an exact-threshold tie that the deterministic evaluator must leave unresolved, and it repeatedly corrupted a routing destination that remains stable when carried as structured data.

So the result was not simply "deterministic beats model."

The more useful finding was that the two layers catch different classes of failure - and an external regression harness can detect integration mistakes that a perfectly correct judgment evaluator cannot see.

Full study, preregistration, hidden cases, artifacts, and detection matrix:

github.com/Judgment-Pack/judgment-...

I also filed the EvalForge findings in #269-#274.

Thanks again - the scenario format, runner, artifact isolation, and blocking trace scorers held up extremely well under adversarial use.

Thread Thread
 
debashish_ghosal profile image
Debashish Ghosal

Thank you so much! I will review.

Thread Thread
 
debashish_ghosal profile image
Debashish Ghosal • Edited

Just released 0.2.0
github.com/deghosal-2026/agent-eva...
It has several of your requested fixes and also expanded support for other frameworks

v0.1:

  1. Subprocess (any language)
  2. Python Import (generic)
  3. HTTP
  4. LangGraph
  5. PydanticAI

v0.2:

  1. CrewAI
  2. OpenAI Agents SDK
  3. smolagents (Hugging Face)
  4. AutoGen
  5. LlamaIndex
  6. Claude Agent SDK
  7. Google ADK
Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The adapter itself needs a conformance suite, not only the agent scenarios. I’d define a small protocol manifest for every adapter: invocation command/image digest, input and output schemas, tool-event stream version, timeout/cancellation semantics, writable paths, network policy, and required secrets. Before a pack runs, the harness should execute adapter fixtures that prove stdout framing, exit-code mapping, artifact capture, cancellation, and isolation. Then classify adapter_failed, agent_crashed, and scenario_failed as different outcomes; otherwise integration noise quietly contaminates regression scores. Subprocess isolation is a strong default, but an ephemeral container or sandboxed worker is a better boundary for agents that import native libraries, mutate global state, or write arbitrary paths. I’d also bind the baseline to the adapter manifest digest, because changing the observation path can change the trajectory even when the agent code and pack did not.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal • Edited

Mads — this is the comment I was hoping someone would write. Going through your points in order of how much the current code already does vs what's actually missing.

Adapter conformance suite. Doesn't exist yet. The contract lives in a docstring in adapters/subprocess.py — "JSON on stdin, clean exit code" — which is a spec in the literary sense, not an enforced one. The protocol manifest you describe (image digest, I/O schemas, tool-event stream version, timeout/cancellation, writable paths, network policy, secrets) doesn't exist either — the only metadata an adapter carries today is its name string. The manifest has to exist before the fixtures can target it.

Three distinct outcomes. This stings because the taxonomy is already there and I failed to wire it through. analytics/taxonomy.py has AGENT_CRASH, TIMEOUT, SAFETY_VIOLATION, TOOL_ERROR with severity maps and a classifier. subprocess_runner.py distinguishes them at the source (AgentTimeoutError vs AdapterError vs OSError). But comparison/engine.py ignores all of it — a timeout and a hallucination both show as "regressed" with no diagnosis. Your adapter_failed / agent_crashed / scenario_failed split is what the taxonomy was built to make. Cheapest win in the repo right now: connect the two.

Container vs subprocess. Half-built. subprocess_runner.py has a container_runtime == "docker" branch calling run_in_container, and security/sandbox.py has DockerConfig. But it's opt-in and undertested against the 19 real agents. You're right that for agents importing native libs or mutating global state, subprocess is still shared-kernel — the ffmpeg-at-import problem isn't solved by a subprocess if the agent segfaults. Container should be the default for untrusted agents; subprocess is the trusted fast path.

Baseline bound to adapter digest. The sharpest point, and one I hadn't considered. baselines/model.py captures agent, git_sha, pack_version, trust — nothing about the adapter. Switch python_import → subprocess, or change timeout_seconds 120 → 60, and the trajectory can change while git_sha and pack_version stay identical. The baseline silently compares against a run produced under a different observation regime. That's a correctness gap, not polish. The manifest gives the digest something to hash; a hard comparison failure on divergence closes it.

Order I'd do this: taxonomy-wiring first (already built, cheapest), then manifest + conformance fixtures together, then baseline digest on top. Thanks for writing this out — it's a better spec for the adapter layer than the one I gave myself.

I have created 0.2.0 issues for this - github.com/deghosal-2026/agent-eva...

Collapse
 
nyx533 profile image
Nyx533

@debashish_ghosal One clarification on the read-back. The eval framework should not write to the agent's memory. It should write to a signal log the agent reads at boot. Direct edits collapse the measurement. You lose the ability to see whether the agent would repeat the same mistake without intervention. The framework publishes findings. The agent merges the ones that survive its own validation. That separation keeps the eval signal measurable and the agent's learning traceable.

Collapse
 
nyx533 profile image
Nyx533

@debashish_ghosal Scenario packs are the right direction. The gap I keep hitting is that regression tests for agents have a shelf life the moment the underlying model updates. A scenario that caught a failure on 4o may pass on Claude 4 and fail on a finetune of the same Claude. The test is not testing your agent logic anymore. It is testing the LLM. Have you found a way to distinguish regressions in the agent layer from regressions in the model layer?

Collapse
 
debashish_ghosal profile image
Debashish Ghosal • Edited

Nyx533 — you've named the gap I keep walking around. The honest answer is: right now the code can't tell them apart, and that's a design hole, not a missing feature.

The Baseline model does capture the agent's model in the agent dict (framework, version, model). But the comparison engine never compares it — ComparisonEngine.compare in comparison/engine.py checks status flips and score deltas, nothing about whether the underlying model changed between baseline and candidate. So a model swap and an agent-logic change both show up as the same kind of "regressed." That's the gap you're pointing at.

The mechanism to fix it actually exists but isn't wired into baselining. There's a VCR layer in testing/vcr.py — LLMVCR records LLM API calls into cassettes and replays them. The right pattern is: record a cassette when you save the baseline, then on comparison runs replay the cassette so the model layer is frozen. The only variable left is the agent code. A regression under replay is the agent; a regression that only appears live is the model. Two runs, two diagnoses.

Concrete fix I'm working toward: (1) the comparison engine should surface agent.model mismatch as its own outcome — not a regression, but a "model layer changed, scores not directly comparable" signal; (2) baseline save should optionally capture the LLM cassette so replay comparison is available. The model shelf-life problem you're describing is real, and the only honest answer is to make the layer boundary explicit in the harness instead of pretending every score delta has the same provenance.

Thanks for pushing on this — it's the question the current design most wants to dodge.

I have created 0.2.0 issues for this - github.com/deghosal-2026/agent-eva...

Collapse
 
jkming profile image
jkming

The five adapter failure modes are the real content here. The module-scope ChatOpenAI(api_key=os.getenv(...)) pattern is all over OSS agent repos, and monkeypatching init before import is the kind of fix that rots quietly every time the SDK changes its constructor signature.

I'd also make the blank-completion rejection loud in the report output, not just an error status. A run that produced no output is exactly the one someone eyeballs as a pass when skimming CI.

One question on the regression definition: passed-to-not-passed means a scenario can drift from 0.95 to 0.81 against a 0.8 threshold and still count as unchanged. Do you rely on the per-family delta to catch that, or did you consider a per-scenario score-drift flag alongside the status flip?

Collapse
 
debashish_ghosal profile image
Debashish Ghosal

Great question — and yes, we went with option 2 (per-scenario score-drift flag), not relying on per-family delta. The fix adds a SCORE_DELTA_THRESHOLD (0.05) so a scenario dropping 0.95→0.81 gets flagged as score_delta_regressed right alongside the status-based regression, even though the status is still "passed". The aggregate regressed count also combines both.
It's tracked in issue #271 (also covers snapshot mode and git_sha traceability) and landing in v0.2.0.

Collapse
 
nyx533 profile image
Nyx533

@debashish_ghosal The gap I keep coming back to is that agents don't have a concept of 'same mistake' across sessions. A human who was told the same thing last week will feel the repetition. An agent won't, and there is no ledger column for that. Scenario packs help, but only if the re-run knows it is a re-run.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal

Thanks - as this is eval framework, would it be a fair read-back on what you are saying is - this framework could process multiple traces from same agent and detect what could be fed back to "persisted memory" of an agent as "learn and do not do this"? Since, this is eval framework, it has no means to add persisted memory to an agent. This memory or ledger column would need to be a feature of the agent design that's under evaluation. I can check if there are standardized way to give this feedback back to agents

Collapse
 
nyx533 profile image
Nyx533

@debashish_ghosal That read-back is close but I would narrow one piece. The eval framework does not need to write to the agent's persisted memory. It needs to write to a signal log the agent reads at boot. The distinction matters because if the framework directly edits the agent's memory, you lose the ability to measure whether the agent would have made the same mistake again without intervention. The framework should publish findings. The agent should decide whether to incorporate them. That separation keeps the eval signal measurable and the agent's learning traceable. Think of it as a commit log: the framework proposes corrections, the agent merges the ones that survive its own validation. You keep the failure data either way.

Collapse
 
golen_0 profile image
Golen

On the regression definition, I'd keep already-failing scenarios out of the release gate but give failure-mode shifts their own non-blocking row in the report. A timeout turning into a hallucination is invisible today, and for the safety family that particular shift can matter more than a score drop.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal

Correct, yes, whoever puts the OSS package to use for agent eval must consider. What I have provided in the OSS is a collection of scenario packs which does not on its own eval an agent. User of that will need to decide on the scenarios and what constitutes a gate. Does this make sense?

Collapse
 
kartik-nvjk profile image
Kartik N V J K

The integration breaking before the judge matches my experience exactly. The eval logic is usually the easy part, and the harness around it is where everything falls apart. I started versioning my scenario packs like code so a regression in the environment setup doesn't get blamed on the model. How do you keep the packs from going stale as the agent's tools change?

Collapse
 
debashish_ghosal profile image
Debashish Ghosal

Kartik - thanks for the comments!
I just released 0.2.0 and support for other frameworks.

Scenario packs versioning is a good idea and I was considering putting decoupling the release of scenario packs themselves from the harness, right now they are in the same repo. I haven't put thoughts into scenario packs going stale, but I will need to. In 0.1.0, I supported Langgraph and Pydantic agents, but as I have added support for other frameworks, I realized this is getting out of hand and I need to consider a model where harness may become mature but scenario packs need frequent updates - very much like how virus detection engine is not changing as much but the virus definitions are getting updated. I will have to think about it a bit

Collapse
 
nicola_fiore_89b1628cd6af profile image
Nicola Fiore

Congratss