Skip to content

Commit 868a3be

Browse files
committed
agentErrorRecoveryBenchmark implemented
1 parent d444dc5 commit 868a3be

9 files changed

Lines changed: 269 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ For a focused recording, pass a custom `.jfc` file enabling only the `airhacks.z
511511

512512
## Benchmarks
513513

514-
The [`benchmarks/`](benchmarks/) directory holds executable agent benchmarks that score tool-calling behavior against seeded ground truth — no LLM judge — along orthogonal axes whose results can disagree. [`agentLoopBenchmark`](benchmarks/agentLoopBenchmark) drives an agent through a *pointer-chasing* chain (serial loop-following); [`agentParallelismBenchmark`](benchmarks/agentParallelismBenchmark) gives the agent independent lookups and measures whether it *batches* them into one turn or serializes them — the inverse axis. Every run prints one normalized markdown table row (`Benchmark | Model | Size | Calls | Turns | Result`) that pastes directly into the results table in [`benchmarks/README.md`](benchmarks/README.md) — see there for mechanisms and sweep usage.
514+
The [`benchmarks/`](benchmarks/) directory holds executable agent benchmarks that score tool-calling behavior against seeded ground truth — no LLM judge — along orthogonal axes whose results can disagree. [`agentLoopBenchmark`](benchmarks/agentLoopBenchmark) drives an agent through a *pointer-chasing* chain (serial loop-following); [`agentParallelismBenchmark`](benchmarks/agentParallelismBenchmark) gives the agent independent lookups and measures whether it *batches* them into one turn or serializes them — the inverse axis; [`agentErrorRecoveryBenchmark`](benchmarks/agentErrorRecoveryBenchmark) injects transient tool failures into the chain and measures whether the agent *retries* or gives up — the robustness axis. Every run prints one normalized markdown table row (`Benchmark | Model | Size | Calls | Turns | Result`) that pastes directly into the results table in [`benchmarks/README.md`](benchmarks/README.md) — see there for mechanisms and sweep usage.
515515

516516
## Skills
517517

benchmarks/README.md

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@ terminal instead.
1717
|-----------|-------|------|-------|-------|--------|
1818
| loop | claude-opus-4-8 | 50 | 50 || PASS |
1919
| parallelism | claude-opus-4-8 | 8 | 8 | 1 | PASS |
20+
| recovery | claude-opus-4-8 | 3 | 12 || PASS |
2021

21-
- **Benchmark**`loop` (pointer chasing) or `parallelism` (parallel discrimination)
22+
- **Benchmark**`loop` (pointer chasing), `parallelism` (parallel discrimination), or
23+
`recovery` (transient-fault recovery)
2224
- **Model** — the model reported by the LLM responses: the one actually served, which can
2325
differ from the configured one (529 fallback, lightmetal's own config)
24-
- **Size** — the task size knob: chain `depth` for loop, independent `tasks` for parallelism
26+
- **Size** — the task size knob: chain `depth` for loop, independent `tasks` for parallelism,
27+
injected `faults` for recovery
2528
- **Calls** — total tool calls the agent issued
26-
- **Turns** — agent turns that issued tool calls; loop does not track turns (``)
27-
- **Result**`PASS`/`FAIL`: secret match (loop), all values retrieved (parallelism)
29+
- **Turns** — agent turns that issued tool calls; the serial benchmarks do not track turns (``)
30+
- **Result**`PASS`/`FAIL`: secret match (loop, recovery), all values retrieved (parallelism)
2831

2932
A sweep appends ready-to-paste rows:
3033

@@ -74,6 +77,42 @@ Compare `Calls` to `Size`: more calls means the agent wandered or retried; fewer
7477
stopped early (often narrating or hallucinating hops instead of calling the tool). Both are
7578
loop-following failures. On `FAIL`, the expected/actual secret mismatch is printed to stderr.
7679

80+
## agentErrorRecoveryBenchmark — transient-fault recovery (robustness)
81+
82+
The same pointer chase as the loop benchmark — identical system prompt, identical tool
83+
surface — but every third hop fails exactly once with
84+
`ERROR: transient failure for key '…' — call again with the same key.` and succeeds on
85+
retry. The only recovery cue is that error text: nothing in the prompt or the tool
86+
description announces that failures can happen, so the benchmark measures whether the agent
87+
**reads and reacts to tool errors**. Retrying continues the walk; giving up truncates the
88+
secret; a fabricated fragment cannot match the seeded ground truth. `faults` is the size
89+
knob, and the chain is `3 × faults` hops long so recovery is demanded repeatedly, spread
90+
over the whole walk.
91+
92+
```mermaid
93+
flowchart LR
94+
S[start key] --> F[follow_pointer]
95+
F -->|fragment + next key| F
96+
F -->|ERROR: transient| R[retry same key]
97+
R --> F
98+
F -->|next = END| A[assemble secret]
99+
```
100+
101+
```
102+
./agentErrorRecoveryBenchmark # default 3 faults (9 hops)
103+
for f in 2 4 8; do ./agentErrorRecoveryBenchmark $f; done
104+
```
105+
106+
```
107+
| recovery | claude-opus-4-8 | 3 | 12 | – | PASS | # 9 hops + 3 retries — ideal
108+
| recovery | claude-opus-4-8 | 3 | 5 | – | FAIL | # gave up at the second fault
109+
```
110+
111+
Ideal `Calls` = hops + faults: each fault costs exactly one retry. `recovered=X/faults` on
112+
stderr shows how many injected failures were retried past; `Calls` well below hops + faults
113+
means the agent abandoned the walk or hallucinated past an error — the mismatched secret
114+
exposes both.
115+
77116
## agentParallelismBenchmark — parallel discrimination (independence)
78117

79118
The inverse axis. The agent is given `tasks` **independent** `id → value` pairs with every id
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/java --enable-native-access=ALL-UNNAMED -Dtools.permissions.default=allow --class-path=../zsmith/zbo/zsmith.jar:../zsmith/zbo/lightmetal.jar --source 25
2+
3+
import airhacks.zsmith.agent.boundary.Agent;
4+
import airhacks.zsmith.benchmark.boundary.ErrorRecoveryBenchmark;
5+
import airhacks.zsmith.tools.control.WriteClipboardTool;
6+
import airhacks.zsmith.tui.control.CommandLine;
7+
8+
void main(String... args) {
9+
10+
var faults = CommandLine.firstInt(args, 3);
11+
12+
var benchmark = new ErrorRecoveryBenchmark(faults);
13+
14+
// The serial walk needs one turn per hop plus one retry turn per injected fault plus one
15+
// turn to emit the answer. The extra slack absorbs stray turns — a "thinking" turn with
16+
// no tool call, or an extra retry — so an occasional wasted turn does not trip a false FAIL.
17+
var maxIterations = benchmark.depth() + faults + 20;
18+
19+
var agent = new Agent("error-recoverer", benchmark.systemPrompt())
20+
.withTool(benchmark.tool())
21+
.withMaxIterations(maxIterations);
22+
23+
var result = benchmark.score(agent.chat("go"));
24+
25+
// one normalized markdown row on stdout and in the clipboard — ready to paste into the
26+
// summary table in README.md; the recovery count goes to stderr so it never ends up in the table
27+
var row = result.markdownRow(agent.modelName());
28+
IO.println(row);
29+
System.err.println(WriteClipboardTool.write(row));
30+
System.err.println(result.diagnostics());
31+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package airhacks.zsmith.benchmark.boundary;
2+
3+
import java.util.LinkedHashSet;
4+
import java.util.Set;
5+
6+
import airhacks.zsmith.benchmark.control.Chains;
7+
import airhacks.zsmith.benchmark.control.TransientFaultTool;
8+
import airhacks.zsmith.benchmark.entity.Chain;
9+
import airhacks.zsmith.benchmark.entity.Hop;
10+
import airhacks.zsmith.benchmark.entity.RecoveryResult;
11+
import airhacks.zsmith.tools.control.ToolHandler;
12+
13+
/**
14+
* Error-recovery benchmark for agents. The same pointer chase as
15+
* {@link PointerChasingBenchmark} — identical system prompt, identical tool surface — but every
16+
* third hop fails exactly once with a transient error and succeeds on retry. The only recovery
17+
* cue is the error text, so the benchmark measures whether the agent reads and reacts to tool
18+
* errors: retrying continues the walk, giving up truncates the secret, and a fabricated
19+
* fragment cannot match the seeded ground truth.
20+
*
21+
* <p>Compose it into an {@code Agent} and score the reply:
22+
* <pre>{@code
23+
* var benchmark = new ErrorRecoveryBenchmark(3);
24+
* var agent = new Agent("error-recoverer", benchmark.systemPrompt())
25+
* .withTool(benchmark.tool())
26+
* .withMaxIterations(benchmark.depth() + 3 + 20);
27+
* var result = benchmark.score(agent.chat("go"));
28+
* IO.println(result.markdownRow(agent.modelName()));
29+
* }</pre>
30+
*/
31+
public class ErrorRecoveryBenchmark {
32+
33+
static final long DEFAULT_SEED = 0xC0FFEEL;
34+
static final int HOPS_PER_FAULT = 3;
35+
36+
final Chain chain;
37+
final TransientFaultTool tool;
38+
final int faults;
39+
40+
public ErrorRecoveryBenchmark(int faults) {
41+
this(faults, DEFAULT_SEED);
42+
}
43+
44+
public ErrorRecoveryBenchmark(int faults, long seed) {
45+
this.faults = faults;
46+
this.chain = Chains.random(faults * HOPS_PER_FAULT, seed);
47+
this.tool = new TransientFaultTool(this.chain, faultyKeys(this.chain, faults));
48+
}
49+
50+
/**
51+
* Every third hop of the walk fails, spread over the whole chain so recovery is demanded
52+
* repeatedly — not just once at a single point.
53+
*/
54+
static Set<String> faultyKeys(Chain chain, int faults) {
55+
var keys = new LinkedHashSet<String>();
56+
var key = chain.start();
57+
for (var index = 0; !Hop.END.equals(key) && keys.size() < faults; index++) {
58+
if (index % HOPS_PER_FAULT == 1) {
59+
keys.add(key);
60+
}
61+
key = chain.hop(key).next();
62+
}
63+
return keys;
64+
}
65+
66+
public ToolHandler tool() {
67+
return this.tool;
68+
}
69+
70+
public int depth() {
71+
return this.chain.depth();
72+
}
73+
74+
public String systemPrompt() {
75+
return PointerChasingBenchmark.chasePrompt(this.chain.start());
76+
}
77+
78+
public RecoveryResult score(String agentAnswer) {
79+
var actual = agentAnswer == null ? "" : agentAnswer.replaceAll("\\s", "");
80+
var passed = this.chain.secret().equals(actual);
81+
return new RecoveryResult(this.faults, this.chain.depth(), this.tool.calls(),
82+
this.tool.recovered(), passed, this.chain.secret(), actual);
83+
}
84+
}

zsmith/src/main/java/airhacks/zsmith/benchmark/boundary/PointerChasingBenchmark.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ public String startKey() {
4747
}
4848

4949
public String systemPrompt() {
50+
return chasePrompt(this.chain.start());
51+
}
52+
53+
/**
54+
* Shared with {@link ErrorRecoveryBenchmark}: identical instructions, so recovery behavior
55+
* is driven purely by the tool's error text, not by prompt differences.
56+
*/
57+
public static String chasePrompt(String startKey) {
5058
return """
5159
You are chasing a chain of pointers to reconstruct a hidden secret.
5260
@@ -59,7 +67,7 @@ public String systemPrompt() {
5967
6068
When finished, reply with ONLY the concatenated fragments in the exact
6169
order you collected them: no spaces, no separators, no extra words.
62-
""".formatted(this.chain.start());
70+
""".formatted(startKey);
6371
}
6472

6573
public BenchmarkResult score(String agentAnswer) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package airhacks.zsmith.benchmark.control;
2+
3+
import java.util.Map;
4+
import java.util.Set;
5+
import java.util.concurrent.ConcurrentHashMap;
6+
import java.util.concurrent.atomic.AtomicInteger;
7+
8+
import org.json.JSONObject;
9+
10+
import airhacks.zsmith.benchmark.entity.Chain;
11+
import airhacks.zsmith.tools.control.ToolHandler;
12+
13+
/**
14+
* Decorates {@link PointerChasingTool} with seeded transient faults: each faulty key fails
15+
* exactly once, then succeeds on retry. The recovery cue lives only in the error text — the
16+
* tool name, description, and schema are the delegate's, unchanged, so the benchmark measures
17+
* the agent's reaction to an error result, not obedience to an upfront warning.
18+
*/
19+
public class TransientFaultTool implements ToolHandler {
20+
21+
static final int FAILURES_PER_KEY = 1;
22+
23+
final PointerChasingTool delegate;
24+
final Set<String> faultyKeys;
25+
final Map<String, Integer> attempts;
26+
final AtomicInteger calls;
27+
28+
public TransientFaultTool(Chain chain, Set<String> faultyKeys) {
29+
this.delegate = new PointerChasingTool(chain);
30+
this.faultyKeys = faultyKeys;
31+
this.attempts = new ConcurrentHashMap<>();
32+
this.calls = new AtomicInteger();
33+
}
34+
35+
public int calls() {
36+
return this.calls.get();
37+
}
38+
39+
/** Faulty keys the agent called again after the failure — the recovery count. */
40+
public int recovered() {
41+
return (int) this.faultyKeys.stream()
42+
.filter(key -> this.attempts.getOrDefault(key, 0) > FAILURES_PER_KEY)
43+
.count();
44+
}
45+
46+
@Override
47+
public String toolName() {
48+
return this.delegate.toolName();
49+
}
50+
51+
@Override
52+
public String description() {
53+
return this.delegate.description();
54+
}
55+
56+
@Override
57+
public JSONObject inputSchema() {
58+
return this.delegate.inputSchema();
59+
}
60+
61+
@Override
62+
public String execute(JSONObject input) {
63+
this.calls.incrementAndGet();
64+
var key = input.getString(PointerChasingTool.Field.key.name());
65+
var attempt = this.attempts.merge(key, 1, Integer::sum);
66+
if (this.faultyKeys.contains(key) && attempt <= FAILURES_PER_KEY) {
67+
return "ERROR: transient failure for key '" + key + "' — call again with the same key.";
68+
}
69+
return this.delegate.execute(input);
70+
}
71+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package airhacks.zsmith.benchmark.entity;
2+
3+
/**
4+
* Outcome of an error-recovery run. {@code recovered} vs {@code faults} shows how many injected
5+
* transient failures the agent retried past; {@code passed} is the secret match, which a
6+
* fabricated (non-retried) fragment cannot survive. Renders to the shared {@link BenchmarkRow}
7+
* columns; recovery detail is exposed via {@link #diagnostics()} for stderr.
8+
*/
9+
public record RecoveryResult(int faults, int depth, int calls, int recovered, boolean passed,
10+
String expected, String actual) {
11+
12+
public String markdownRow(String model) {
13+
return new BenchmarkRow("recovery", model, this.faults, this.calls, "–", this.passed).markdown();
14+
}
15+
16+
public String diagnostics() {
17+
var recovery = "recovered=%d/%d idealCalls=%d".formatted(
18+
this.recovered, this.faults, this.depth + this.faults);
19+
return this.passed ? recovery
20+
: recovery + " expected=%s actual=%s".formatted(this.expected, this.actual);
21+
}
22+
}

zsmith/src/main/java/airhacks/zsmith/benchmark/package-info.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,12 @@
2828
* serializes them. Its tool runs in parallel and gauges its own concurrency, so the metric is
2929
* efficiency (calls vs turns) rather than a correctness match — a capability that can disagree
3030
* with loop-following, which is what makes it worth measuring separately.
31+
*
32+
* <p>{@link airhacks.zsmith.benchmark.boundary.ErrorRecoveryBenchmark} probes the robustness
33+
* axis: the identical pointer chase, but seeded hops fail transiently exactly once (see
34+
* {@link airhacks.zsmith.benchmark.control.TransientFaultTool}) and succeed on retry. Nothing
35+
* in the prompt or tool description announces the faults — the error text is the only cue —
36+
* so it measures whether the agent reacts to tool errors by retrying rather than giving up or
37+
* fabricating the missing fragment; the seeded secret exposes both failure modes.
3138
*/
3239
package airhacks.zsmith.benchmark;

zsmith/version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2026.07.05.02
1+
2026.07.05.03

0 commit comments

Comments
 (0)