Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3ee9403
feat(evals): agent evals harness for before-and-after comparisons
andychoquette Jul 16, 2026
1df9c25
Merge branch 'mainline' into feature/agent-evals
andychoquette Jul 17, 2026
26f6692
fix(evals): fail fast on git errors and refuse to run on a dirty chec…
andychoquette Jul 22, 2026
41b16c7
Merge branch 'mainline' into feature/agent-evals
andychoquette Jul 22, 2026
10a8c7d
fix(evals): safely coerce judge 'passed' field to prevent silent fals…
apcho-amazon Jul 28, 2026
11cb217
Merge branch 'mainline' into feature/agent-evals
andychoquette Jul 28, 2026
bc741b4
fix(evals): never emit proposal.patch when any eval regressed
apcho-amazon Jul 28, 2026
f48fe63
fix(evals): seed subject files into sandbox for read-material A/B
apcho-amazon Jul 28, 2026
8fbc7f6
test(evals): add what_is_openjd eval guarding general-compute framing
apcho-amazon Jul 28, 2026
d123781
fix(evals): scope reset_clean to the pathspec, not the whole tree
andychoquette Jul 29, 2026
cf44c84
fix(evals): harden agent launch/timeout and sandbox file writes
andychoquette Jul 29, 2026
1b13a33
feat(evals): add opt-in real_aws eval that submits a real job
andychoquette Jul 29, 2026
97a0d3d
docs(evals): document env field, placeholders, and full flag reference
andychoquette Jul 29, 2026
0d88d86
feat(evals): log which deadline CLI the agent will drive
andychoquette Jul 29, 2026
021af7c
Merge branch 'mainline' into feature/agent-evals
andychoquette Aug 5, 2026
3f10b30
fix(evals): bound judge/reviser wall-clock and fix three silent-failu…
andychoquette Aug 5, 2026
39a80c1
Merge branch 'mainline' into feature/agent-evals
andychoquette Aug 10, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ src/deadline/client/ui/_translation_keys.py
# Install builder license
license.xml
/THIRD_PARTY_LICENSES

# Agent evals run artifacts
/evals/output/
96 changes: 96 additions & 0 deletions evals/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Agent evals

Measure how well an AI agent achieves goals with Deadline Cloud tooling and
docs — and prove, with a before-and-after comparison, that a change to the CLI,
the docs, or any reference material actually helps.

## How it works

1. An **eval file** (JSON) gives an agent a goal, the tools it may use, optional
reference material, and a **rubric** describing what a passing answer must do.
2. The **runner** launches an isolated headless agent (`claude -p`) per run in a
throwaway sandbox and captures telemetry (tool calls, turns, cost).
3. An **LLM judge** grades the agent's final answer against the rubric.
4. In **A/B mode**, every case runs twice — with this repo at the current ref
(baseline) and at `--revised-ref` (candidate) — and the summary reports the
paired delta plus the diff, which is the PR-ready proposed change.

The material under test can be almost anything the agent relies on:

| Data source | How |
| --- | --- |
| `deadline` CLI | `pip install -e .` this repo; A/B two git refs of `src/` |
| AWS CLI usage | goal + rubric only — no subject needed |
| AWS documentation / blog / web page | fetch it to markdown, pass as `materials`, seed a corpus to revise |
| This repo's docs | A/B with `--pathspec ':(glob)docs/**/*.md'` |

## Setup

```bash
pip install -e . # the agent's `deadline` is this checkout (A/B needs this)
which claude # Claude Code must be on PATH and authenticated
```

That's it — no extra dependencies; the evals use only the Python standard library.

## Run

```bash
cd evals

# score how an agent does today (baseline only)
python -m agent_evals.runner run examples/deadline_cli.json --k 3

# A/B a change: current branch vs a revision of the CLI source
python -m agent_evals.runner run examples/deadline_cli.json --k 3 --revised-ref my-improvement

# A/B a docs change instead of code
python -m agent_evals.runner run my_docs_eval.json --revised-ref docs-fix --pathspec ':(glob)docs/**/*.md'
```

Artifacts land under `evals/output/<eval>/<timestamp>/`: per-run telemetry and
transcripts, `summary.json`, and — when a revision measurably improved an eval —
`proposal.patch`. Exit code 4 means the revision regressed.

## Write an eval

```json
[
{
"id": "my_case",
"prompt": "The goal, stated imperatively and self-contained.",
"tools": ["Bash", "Read"],
"rubric": "What a passing answer must do, in plain language.",
"materials": {"guide.md": "optional reference text the agent can read"},
"max_turns": 20,
"k": 3
}
]
```

The rubric is the only per-eval authoring step that matters: it should state the
*material's own* success criterion (for a docs page, what the page promises the
reader can do), including what a correct answer looks like when the evidence is
incomplete — a good judge passes an agent that refuses to invent missing details.

## Close the loop automatically

`reviser.revise()` hands a struggling run's transcript to an agent that edits the
subject (code or docs), commits to a scratch ref, and returns it — feed that ref
back to `--revised-ref` to A/B-prove the improvement:

```python
from agent_evals import reviser, subject

subj = subject.repo_subject() # or corpus_subject(markdown)
ref = reviser.revise(subj, run_dir, goal="...", base_ref="mainline")
# python -m agent_evals.runner run my_eval.json --revised-ref <ref>
```

## Notes

- The tested agent always runs isolated — the orchestrating session must never do
the goal itself, or the telemetry measures the wrong thing.
- Runs that talk to real AWS use whatever credentials/config the environment has;
point them at a non-production sandbox account.
- The judge is a single vote per run; for gate-quality decisions increase `k`.
4 changes: 4 additions & 0 deletions evals/agent_evals/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

"""Agent evals: measure how well an AI agent achieves goals with Deadline Cloud
tooling and docs, and A/B-prove improvements to whatever the agent relied on."""
130 changes: 130 additions & 0 deletions evals/agent_evals/harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

"""Run Claude Code headless against a goal and capture telemetry.

The tested agent runs as an ISOLATED subprocess (`claude -p` with stream-json
output) in a sandbox directory. The JSON event stream carries both per-tool-call
events and a final result event with token/cost telemetry, so no extra
instrumentation is needed.
"""

from __future__ import annotations

import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional


@dataclass
class RunResult:
"""One headless agent run: outcome + telemetry, plus the raw event log."""

success: bool # did the CLI complete without error
subtype: Optional[str] # result subtype, e.g. "success" / "error_max_turns"
tool_calls: list = field(default_factory=list)
num_turns: int = 0
total_cost_usd: float = 0.0
duration_ms: int = 0
final_text: str = "" # the agent's final response text
workdir: Optional[Path] = None
raw_events: list = field(default_factory=list)

@property
def tool_call_count(self) -> int:
return len(self.tool_calls)

def telemetry_dict(self) -> dict:
"""Serializable telemetry (excludes the bulky raw event log)."""
return {
"success": self.success,
"subtype": self.subtype,
"tool_calls": self.tool_calls,
"tool_call_count": self.tool_call_count,
"num_turns": self.num_turns,
"total_cost_usd": self.total_cost_usd,
"duration_ms": self.duration_ms,
"final_text": self.final_text,
}


def run_agent(
prompt: str,
workdir: Path,
allowed_tools: list,
*,
max_turns: int = 20,
model: Optional[str] = None,
claude_bin: str = "claude",
) -> RunResult:
"""Run Claude Code headless in `workdir`, restricted to `allowed_tools`."""
cmd = [
claude_bin,
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose", # required for stream-json event detail
"--permission-mode",
"bypassPermissions",
"--max-turns",
str(max_turns),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security note on the trust boundary here: the module docstring calls this an "ISOLATED subprocess ... in a sandbox directory", but --permission-mode bypassPermissions only sets the cwd — it does not sandbox the process. The agent can run any Bash command against the operator’s real machine, credentials, and repo checkout; the temp workdir is a convention, not a boundary.

That matters because the prompt text is not always operator-authored. subject.corpus_subject() is documented as seeding "any fetched material (an AWS docs page, a blog post, a web-search result)", and runner._run_case concatenates case materials / seeded subject/ files into the sandbox for the agent to read. Untrusted fetched prose reaching a bypassPermissions agent with Bash is a prompt-injection path to arbitrary command execution with the operator’s AWS credentials — and real_aws mode is explicitly pointed at live, billable resources.

Worth either (a) tightening the wording so operators understand there is no sandbox and eval files/materials are trusted input, or (b) dropping to a real boundary (a container, or --permission-mode acceptEdits plus a Bash allowlist) for any case whose material was fetched rather than authored.

]
if allowed_tools:
cmd += ["--allowedTools", *allowed_tools]
if model:
cmd += ["--model", model]

# stdin=DEVNULL: with -p the CLI still waits on stdin and can exit non-zero on a
# closed pipe; DEVNULL makes the call cleanly non-interactive.
proc = subprocess.run(
Comment thread
andychoquette marked this conversation as resolved.
Outdated
cmd, cwd=str(workdir), capture_output=True, text=True, stdin=subprocess.DEVNULL
)

events = []
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue # non-JSON lines (rare) are ignored

return _parse_events(events, proc.returncode, workdir)


def _parse_events(events: list, returncode: int, workdir: Path) -> RunResult:
tool_calls = []
final = None

for ev in events:
if ev.get("type") == "assistant":
for blk in ev.get("message", {}).get("content", []):
if blk.get("type") == "tool_use":
tool_calls.append(blk["name"])
elif ev.get("type") == "result":
final = ev

if final is None:
# CLI died before emitting a result event.
return RunResult(
success=False,
subtype="no_result_event",
tool_calls=tool_calls,
workdir=workdir,
raw_events=events,
)

return RunResult(
success=(not final.get("is_error", False)) and returncode == 0,
subtype=final.get("subtype"),
tool_calls=tool_calls,
num_turns=final.get("num_turns", 0),
total_cost_usd=final.get("total_cost_usd", 0.0),
duration_ms=final.get("duration_ms", 0),
final_text=final.get("result", "") if isinstance(final.get("result"), str) else "",
workdir=workdir,
raw_events=events,
)
141 changes: 141 additions & 0 deletions evals/agent_evals/judge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

"""Grade an agent's answer against a natural-language rubric (LLM-as-judge).

The judge is a SCORER, not an agent: a headless, tool-less, single-turn model call.
It sees only the rubric, the task prompt, and the agent's final answer, so its
verdict is reproducible from the run artifact alone. The rubric being a plain
string is what makes new eval domains code-free: describing what "success" means
is the only per-eval authoring step.
"""

from __future__ import annotations

import json
import subprocess
from dataclasses import dataclass
from typing import Optional


class JudgeError(RuntimeError):
"""Raised when the judge call fails to produce a usable verdict."""


@dataclass
class Verdict:
passed: bool
reasoning: str


_JUDGE_PROMPT = """\
You are grading an AI agent's answer to a task. Decide ONLY whether the answer
satisfies the rubric below. Judge the substance of the answer, not its wording or
format. A correct conclusion stated in unexpected phrasing still passes; a
confident answer that is wrong or unsupported fails. If the rubric asks the agent
to reach a conclusion the available evidence does not support, then correctly
declining to invent one SATISFIES the rubric.

You have NO tools. Do not attempt to read any file or run any command -- files the
answer mentions are not available to you. Grade purely from the text below.

=== RUBRIC (what a passing answer must do) ===
{rubric}

=== THE TASK THE AGENT WAS GIVEN ===
{prompt}

=== THE AGENT'S FINAL ANSWER ===
{answer}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The graded agent’s final_text is interpolated here with no delimiter or escaping, and the surrounding scaffolding is plain prose the answer can imitate. Because the agent under test produces {answer}, it can emit text that reads as fresh judge instructions — a line resembling one of the === ... === headers, or simply an instruction to respond with a passing verdict. _extract_verdict scans first { to last }, so a compliant-looking injected object is accepted verbatim.

This is more than theoretical for this harness: reviser.revise() closes the loop by having an agent edit the subject so the next agent scores better, and _should_emit_proposal converts a score improvement into a PR-ready proposal.patch. An edit that nudges the agent toward answer phrasing the judge rubber-stamps is a cheaper win than one that genuinely improves the material — reward hacking whose output is a patch aimed at this repo.

Cheap mitigations that preserve the "rubric is just a string" property:

  • Wrap the answer in a per-call random delimiter and tell the judge everything inside it is untrusted data (the agent cannot guess the nonce).
  • State explicitly that text inside the answer block is never an instruction, and that attempting to direct the grading is itself a fail.
  • Bound the answer length so a very long answer cannot crowd out the real instructions.


Respond with ONLY a JSON object on a single line, no prose, no code fence:
{{"passed": true or false, "reasoning": "one or two sentences citing the rubric"}}
"""


def judge_answer(
rubric: str,
prompt: str,
answer: str,
*,
model: Optional[str] = None,
claude_bin: str = "claude",
) -> Verdict:
"""Grade `answer` against `rubric` with a headless, tool-less model call."""
if not (answer or "").strip():
return Verdict(passed=False, reasoning="agent produced no final answer")

full_prompt = _JUDGE_PROMPT.format(rubric=rubric, prompt=prompt or "(none)", answer=answer)
cmd = [
claude_bin,
"-p",
full_prompt,
"--output-format",
"json",
"--permission-mode",
"bypassPermissions",
# The judge must answer from the given text alone, so all tools are denied.
# A denied tool attempt still consumes a turn, so leave headroom for the
# model to recover and answer instead of dying on error_max_turns.
"--max-turns",
"5",
"--disallowedTools",
"Bash",
"Read",
"Write",
"Edit",
"Grep",
"Glob",
"WebFetch",
"WebSearch",
"Agent",
"TodoWrite",
"NotebookEdit",
]
if model:
cmd += ["--model", model]

try:
# stdin=DEVNULL: with -p the CLI still waits on stdin and can exit non-zero
# on a closed pipe.
proc = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The judge subprocess.run has no timeout, unlike run_agent in harness.py (which uses DEFAULT_TIMEOUT_S precisely because "a hung network/auth interaction has no turn cost, so ... one stuck run could block the whole batch indefinitely"). --max-turns 5 does not bound wall-clock time. Since judge_answer is called for every run in the batch, a single hung judge call will hang the entire eval run, defeating the harness timeout. Consider adding a timeout here and catching subprocess.TimeoutExpired as a JudgeError.

except OSError as e:
raise JudgeError(f"could not launch {claude_bin}: {e}") from e

# --output-format json wraps the reply in an envelope whose `result` field is
# the text we asked for.
reply = proc.stdout
try:
envelope = json.loads(proc.stdout)
if isinstance(envelope, dict) and isinstance(envelope.get("result"), str):
reply = envelope["result"]
except json.JSONDecodeError:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
pass

# Parse the verdict from stdout FIRST: the CLI sometimes exits non-zero after
# emitting a valid result (e.g. a model-availability warning). Only surface the
# exit code when stdout carried nothing gradable.
try:
return _extract_verdict(reply)
except JudgeError:
if proc.returncode != 0:
raise JudgeError(
f"judge exited {proc.returncode} with no usable verdict: "
f"{(proc.stderr or reply).strip()[:200]}"
) from None
raise


def _extract_verdict(text: str) -> Verdict:
"""Pull the JSON verdict out of the judge's reply, tolerating code fences or
stray prose around it (scan first '{' to last '}')."""
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end < start:
raise JudgeError(f"judge response had no JSON object: {text[:200]!r}")
try:
obj = json.loads(text[start : end + 1])
except json.JSONDecodeError as e:
raise JudgeError(f"judge JSON did not parse: {e}") from e
if "passed" not in obj:
raise JudgeError(f"judge JSON missing 'passed': {obj!r}")
return Verdict(passed=bool(obj["passed"]), reasoning=str(obj.get("reasoning", "")))
Comment thread
andychoquette marked this conversation as resolved.
Outdated
Loading
Loading