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
92 changes: 70 additions & 22 deletions evals/harbor/.agents/skills/compare_tasks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,23 @@ jq '{task_name, trial_name}' "$TRIAL_A_DIR/result.json"

### 2. Headline facts

Pull these fields from each trial's `result.json`. The actual shape (harbor
0.8 `TrialResult`):
The fastest path is to let `cmd.py task` do it for you — it already prints
status, reward, duration, tokens, turns, cost, error class, and the tail of
the verifier stdout:

```bash
./evals/harbor/cmd.py task "$RUN_A" "$TASK"
./evals/harbor/cmd.py task "$RUN_B" "$TASK"
```

Only drop to raw `jq` against `result.json` if you need a field `cmd.py task`
doesn't print. The actual shape (harbor 0.8 `TrialResult`):

```bash
jq '{
reward: (.verifier_result.rewards.reward // null),
reward: (.verifier_result.rewards.reward
// (.verifier_result.rewards | to_entries | .[0].value)
// null),
rewards_all: .verifier_result.rewards,
duration_seconds: ((.finished_at | fromdateiso8601) - (.started_at | fromdateiso8601)),
input_tokens: .agent_result.n_input_tokens,
Expand All @@ -57,6 +68,10 @@ jq '{
}' "$TRIAL_A_DIR/result.json"
```

The `reward` fallback mirrors `reporter.trial_reward`: if the verifier
didn't use the conventional `reward` key, take the first value in the
`rewards` map.

Derive status from those:

- `pass` if `reward >= 1.0`
Expand All @@ -72,31 +87,62 @@ timed out during teardown, or it timed out after writing the correct answer).
If we got points, count them. See `reporter.trial_status` for the canonical
rule.

Several `agent_result` fields are commonly `null` for older `GooseBinaryAgent`
runs (notably `n_cache_tokens`, `n_output_tokens`, `cost_usd`). Don't treat
that as a failure — just omit those facts from the comparison if missing on
either side. The reporter has fallbacks that read goose's `complete` event
from `agent/goose.txt`; you don't normally need to replicate them here.
Several `agent_result` fields can be `null` depending on the harness
(notably `n_cache_tokens`, `n_output_tokens`, `cost_usd` on some goose
runs). Don't treat that as a failure — just omit those facts from the
comparison if missing on either side. `cmd.py task` already applies
harbor's fallbacks (reading goose's `complete` event from `agent/goose.txt`
when the structured field is null), so its numbers are the right ones to
report.

### 3. Read the task spec

The task definitions are NOT in the harbor Python package. They are plain
text files on disk, in harbor's dataset cache. Do not run `find /` or
text files on disk, in harbor's task cache. Do not run `find /` or
`pip show harbor` — that is the wrong direction.

Find the task directory (works on Linux and macOS):
Harbor caches under `~/.cache/harbor/` on every platform (it uses
`Path("~/.cache/harbor").expanduser()` unconditionally — there is no
`~/Library/Caches/harbor` on macOS, despite what you might expect).

The on-disk layout for package-backed tasks (the common case — everything
in `terminal-bench/terminal-bench-2` lands here) is:

```
~/.cache/harbor/tasks/packages/<org>/<task>/<digest>/
```

Note: no dataset name in the path. Tasks are keyed by org + task name +
content digest, not by which dataset pulled them. The `<digest>` segment
changes when the task is republished, so discover the dir rather than
hardcoding:

```bash
TASK_DIR=$(
ls -d ~/.cache/harbor/datasets/terminal-bench__terminal-bench-2__*/tasks/"$TASK"/ 2>/dev/null \
|| ls -d ~/Library/Caches/harbor/datasets/terminal-bench__terminal-bench-2__*/tasks/"$TASK"/ 2>/dev/null
)
TASK_DIR=$(ls -d ~/.cache/harbor/tasks/packages/terminal-bench/"$TASK"/*/ 2>/dev/null | head -1)
echo "$TASK_DIR"
ls "$TASK_DIR"
```

If both lookups return empty, the dataset hasn't been downloaded yet — bail
out and report that, rather than guessing.
If that's empty, the task could be from a different org or a git source —
broaden the search. `find` returns the parent (one level above the
digest), so descend one more level. Guard against `$PARENT` being empty,
otherwise the glob expands to `/*/` and matches the filesystem root:

```bash
PARENT=$(find ~/.cache/harbor/tasks -type d -name "$TASK" 2>/dev/null | head -1)
if [ -n "$PARENT" ]; then
TASK_DIR=$(ls -d "$PARENT"/*/ 2>/dev/null | head -1)
fi
```

If both lookups come up empty, the task hasn't been downloaded on this
machine — bail out and report that, rather than guessing. (Runs sync via
`cmd.py pull` but the task cache does not, so a machine that only inspects
results may never have the spec locally.)

`~/.cache/harbor/datasets/` exists too but holds dataset-level metadata,
not the per-task `instruction.md` / `tests/` / `solution/` files — not
what you want here.

Inside, you care about three files:

Expand All @@ -115,11 +161,12 @@ Two sources, prefer the first when present:

- `$TRIAL_DIR/agent/trajectory.json` — harbor's ATIF format, one entry per
agent step. `jq '.steps[] | {step_id, source, message, tool_calls: [.tool_calls[]?.function_name]}'`
gives a compact view. Recent goose runs (after the populate_context_post_run
fix) have this; older `GooseBinaryAgent` runs may not.
gives a compact view. Most current runs have it; some older harness
versions may not.
- `$TRIAL_DIR/agent/<harness>.txt` — raw stream-json or log. The filename
matches the harness: `goose.txt`, `pi.txt`, `opencode.txt`,
`claude-code.txt`. `ls "$TRIAL_DIR/agent/"` to find it.
matches the harness (commonly `goose.txt` or `pi.txt`; other harnesses
use their own name). Don't guess — run `ls "$TRIAL_DIR/agent/"` and use
whatever `.txt` file is there.

Skim, don't quote in full. For each agent identify:

Expand Down Expand Up @@ -170,10 +217,11 @@ Output markdown with these sections in order:

## Tools you'll need

- `./evals/harbor/cmd.py task <run> <task>` for the headline numbers
- `ls -d` to discover the `<task>__<suffix>` trial directories
- `jq` for `result.json`
- `jq` for any `result.json` field `cmd.py task` doesn't print
- file reads against `$TRIAL_DIR/agent/` and `$TRIAL_DIR/verifier/`
- file reads against the dataset cache (`~/.cache/harbor/datasets/...`)
- `find ~/.cache/harbor/tasks` to locate the task spec

No Python imports, no `harbor` package required. Everything you need is on
disk as JSON / text files.
188 changes: 188 additions & 0 deletions evals/harbor/recipes/analyze_bench_failure.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
version: 1.0.0
title: analyze a single harbor benchmark failure
description: compare one task across two runs, theorize why the target failed, propose what could change
author:
contact: douwe@block.xyz

parameters:
- key: target
input_type: string
requirement: required
description: "the run we want to improve (typically a goose run)"
- key: reference
input_type: string
requirement: required
description: "the run that succeeded on this task"
- key: task
input_type: string
requirement: required
description: "bare task name, e.g. extract-elf (not terminal-bench/extract-elf)"

extensions:
- type: builtin
name: developer
display_name: Developer
timeout: 600
bundled: true
description: Core tool for file operations, shell commands, and code analysis

instructions: analyze why goose (the target run) failed a task that the reference run passed, and suggest what might change in goose to fix it

prompt: |
you are analyzing a single harbor benchmark task where the reference run
succeeded and the target run (typically goose) failed. the goal is to
form a theory about *why* target failed and suggest what we could change
in goose to fix it. this is analysis, not implementation — no code
changes, no worktrees.

target run (the one that failed): {{ target }}
reference run (the one that passed): {{ reference }}
task: {{ task }}

this recipe assumes it is launched from the root of the goose repo
(the current working directory contains `evals/harbor/`). all paths
below are relative to that.

## step 1: headline facts

cmd.py task prints status, reward, duration, tokens, turns, cost, error,
and a tail of the verifier output. start there for both runs:

```
./evals/harbor/cmd.py task {{ reference }} {{ task }}
./evals/harbor/cmd.py task {{ target }} {{ task }}
```

## step 2: find the trial directories

harbor 0.8 names trial dirs `<task>__<random-suffix>`. discover them
from disk — don't guess the suffix:

```
TARGET_DIR=$(ls -d evals/harbor/runs/{{ target }}/{{ task }}__*/ 2>/dev/null | head -1)
REF_DIR=$(ls -d evals/harbor/runs/{{ reference }}/{{ task }}__*/ 2>/dev/null | head -1)
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match the exact trial before reading artifacts

For runs created with cmd.py run --trials >1, each task has multiple <task>__... directories, but these assignments keep only the first one. The cmd.py task output above can show multiple trials, so the later trajectory and verifier reads may analyze a different attempt than the failure the user is investigating; require/select a specific trial directory instead of using head -1.

Useful? React with 👍 / 👎.

echo "target: $TARGET_DIR"
echo "ref: $REF_DIR"
```

if either is empty the run didn't include this task — stop and report.

## step 3: read the task spec

the task definition lives in harbor's task cache. package-backed tasks
(the common case, including all of terminal-bench-2) land under
`~/.cache/harbor/tasks/packages/<org>/<task>/<digest>/`. the digest is
per task version and changes when the task is republished, so discover
the directory rather than guessing:

```
TASK_DIR=$(ls -d ~/.cache/harbor/tasks/packages/terminal-bench/{{ task }}/*/ 2>/dev/null | head -1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve the cached task digest deterministically

On a machine with more than one cached digest for the same task (for example after a task is republished or after inspecting runs from multiple dataset versions), this lookup silently selects the lexicographically first cache entry rather than the version used by the trial. The analyzer can then quote stale instructions/tests/solution and produce the wrong failure theory; please detect multiple digest directories and resolve the one from trial metadata, or fail closed and ask the user to choose.

Useful? React with 👍 / 👎.

echo "$TASK_DIR"
ls "$TASK_DIR"
```

if that's empty, fall back to a broader search in case the task came
from a git source or a different org. note that `find` returns the
parent (one level above the digest), so descend one more level. guard
against `$PARENT` being empty — otherwise the glob expands to `/*/` and
matches the filesystem root:

```
PARENT=$(find ~/.cache/harbor/tasks -type d -name "{{ task }}" 2>/dev/null | head -1)
if [ -n "$PARENT" ]; then
TASK_DIR=$(ls -d "$PARENT"/*/ 2>/dev/null | head -1)
fi
```

if both come up empty the task isn't cached locally — say so and continue
with what you can learn from the trial dirs alone (the verifier stdout
often reveals what was being checked).

read these three when present:

- `instruction.md` — what the agent was asked to do
- `tests/test_outputs.py` or `run-tests.sh` — what the verifier checks
- `solution/solution.sh` — the reference correct answer

when describing a failure later, **quote the assertion that failed**
rather than paraphrasing — paraphrase is where wrong conclusions sneak in.

## step 4: read each agent's trajectory

two sources per trial, prefer the first:

- `$TRIAL_DIR/agent/trajectory.json` — harbor's ATIF format, one entry
per agent step. compact view:
`jq '.steps[] | {step_id, source, message, tool_calls: [.tool_calls[]?.function_name]}' "$TRIAL_DIR/agent/trajectory.json"`
- `$TRIAL_DIR/agent/<harness>.txt` — raw log. filename varies by harness
(commonly `goose.txt` or `pi.txt`). don't guess; run
`ls "$TRIAL_DIR/agent/"` and use whatever .txt is there.

for each side identify:

- the approach the agent took
- the final artifacts it left in the container (files created / modified)
- for the target (the failure), the failure mode — pick one:
- misread the spec (wrong assumption about input/output)
- right approach, shallow bug (off-by-one, wrong encoding, wrong path)
- ran out of clock — but note whether it was making real progress or
thrashing. a thrashing timeout is really a logic failure.
- diverged into an unproductive thread (debugging a non-issue)
- the verifier expected something the spec didn't telegraph

## step 5: read the verifier output

`$TRIAL_DIR/verifier/test-stdout.txt` is usually the most diagnostic
file — it shows exactly which assertion failed and what the agent's
output looked like at that point.

```
tail -80 "$TARGET_DIR/verifier/test-stdout.txt"
```

## step 6: look at goose source for a theory

the target is (typically) goose. once you have a failure mode, dig into
the goose source (the current working directory) to see if there's
something there that could plausibly be improved. relevant places
depending on what you saw:

- `crates/goose/src/agents/` — agent loop, tool-call handling,
context management
- `crates/goose/src/providers/` — provider-specific quirks (prompt
shape, streaming, tool-call format)
- `crates/goose-mcp/src/developer/` — the developer extension, where
most shell/file tools live
- `crates/goose/src/prompts/` and any system-prompt strings — what
we're telling the model about how to behave
- `crates/goose-cli/src/` — cli-side behavior (less likely to matter
for bench)

use `rg` to search; don't grep the world. if the reference run used a
different harness (e.g. pi, opencode, claude-code), think about what
that harness does differently — sometimes it's just a prompt difference,
sometimes it's a tool-shape difference, sometimes it's a timeout or
retry policy.

## step 7: write up the analysis

produce markdown with these sections:

- **task** — one-line restatement of what the task wanted
- **outcome** — reference vs target headline (status, reward, duration,
turns) and which assertion the target failed on (quote it)
- **what reference did** — 2–4 sentences on the winning approach
- **what target did** — 2–4 sentences on the losing approach, with the
failure mode named
- **theory** — why target failed in mechanism terms, not vibes. "the
developer extension's text_editor truncates files >2MB and the task
output was 3MB" beats "goose got confused".
- **what we might change in goose** — concrete, but open-ended. could be
a prompt tweak, a tool behavior change, a default config, a new
capability, or "this is a one-off task quirk and not worth chasing".
cite the source files you looked at. it's fine to list more than one
candidate, and fine to say "not sure, would want to look at more
failures with this shape first".

stop there. no code changes, no PRs, no issues filed. the user will
triage the suggestions across all the tabs once everything has run.
Loading
Loading