Sections in this lesson

Agent Evaluation & Reliability

Evaluate the full action path: permitted tools, bounded work, recoverable state and justified terminal outcomes.

Reviewed: · Content owner: Interview Preparator

What you will learn

  • Grade intermediate actions as well as final answers.
  • Build deterministic regression cases for failure and recovery.
  • Separate fixture correctness from live task performance.

Before you start

The flow

  1. 1.Define task rubric
  2. 2.Record action sequence
  3. 3.Inject failures
  4. 4.Check invariants
  5. 5.Compare versions

A good answer can hide a bad run

Inspect tool selection, argument validity, resource access, approval, repeated calls, usage and terminal state. A final answer that looks correct cannot excuse a private read or an unapproved write. Denied-action regressions should be hard failures, not small deductions in an average score.

Shared scenarios check completion, repetition limits, access denial, approval waiting, timeout, insufficient token reserve and transient-read recovery. The UI displays the same expected counters that Java tests compare with actual runtime results.

Inject the important gaps

The PostgreSQL suite recreates repositories, rejects stale checkpoint versions and interrupts the worker after a target write but before checkpoint persistence. Resuming uses the durable target receipt to avoid a second domain effect. A separate concurrent-delivery test exercises actual transaction locking.

Read retries are restricted to explicitly classified transient read failures and bounded by the total tool-attempt budget. The small runtime counts retries within an invocation; durable total attempts still bound repeated worker restarts. Writes are not blindly retried after an uncertain error.

Know what the result proves

Synthetic decisions and usage make policy tests repeatable. They do not measure model task success, calibrated confidence, real token spend or adversarial robustness across arbitrary inputs. Keep model, prompt, protocol, policy, dataset and schema versions in a real evaluation record.

Use representative multilingual tasks, human-labeled success criteria and repeated runs for live evaluation. Compare safety failures, completion rates and tail latency separately; a faster agent that skips an approval is a regression.

Try it

AgentCheckpointIT.java
java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
package com.interviewpreparator.agents;
import java.time.*;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.*;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import static com.interviewpreparator.agents.AgentContracts.*;
import static org.junit.jupiter.api.Assertions.*;
class AgentCheckpointIT {
static final PostgreSQLContainer DATABASE = new PostgreSQLContainer(DockerImageName.parse("pgvector/pgvector:0.8.0-pg17").asCompatibleSubstituteFor("postgres"));
static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-30T00:00:00Z"), ZoneOffset.UTC);
static JdbcTemplate jdbc;
@BeforeAll static void start() {
DATABASE.start();
jdbc = new JdbcTemplate(new DriverManagerDataSource(DATABASE.getJdbcUrl(), DATABASE.getUsername(), DATABASE.getPassword()));
new JdbcRunStore(jdbc).initializeLocalSchema();
new JdbcPlanTools(jdbc).initializeLocalSchema();
}
@AfterAll static void stop() { DATABASE.stop(); }
static Scope scope() { return new Scope("tenant-a", "alpha", "conversation-1", UUID.randomUUID().toString()); }
static State initial() { return new State(Status.RUNNING, 0, 0, 0, 0, CLOCK.millis() + 60_000, null, List.of(), Map.of(), ""); }
static BoundedAgent.Budget budget() { return new BoundedAgent.Budget(4, 4, 100, 1000, 20, 100, 1, Duration.ofSeconds(60), Duration.ofSeconds(5)); }
static Model model() { return model("PLAN-100"); }
static Model model(String plan) { return state -> new ModelReply(state.observations().isEmpty()
? new ToolCall("write-1", "completePlan", plan) : new Finish("Completed."), 10, 50); }
@Test void restoresPendingApprovalAndStateAfterRepositoryRecreation() {
Scope scope = scope();
var tools = new JdbcPlanTools(jdbc);
tools.seedPlan("tenant-a", "PLAN-100");
try (var first = new BoundedAgent(new JdbcRunStore(jdbc), CLOCK)) {
assertEquals(Status.WAITING_APPROVAL, first.run(scope, budget(), model(), tools, Set.of()).state().status());
}
try (var resumed = new BoundedAgent(new JdbcRunStore(jdbc), CLOCK)) {
var result = resumed.run(scope, budget(), model(), new JdbcPlanTools(jdbc), Set.of("PLAN-100"));
assertEquals(Status.COMPLETED, result.state().status());
assertEquals(2, result.state().steps());
assertEquals(1, result.state().toolAttempts());
}
}
@Test void usesOptimisticConcurrencyAndSeparatesEveryScopeDimension() {
Scope scope = scope();
var first = new JdbcRunStore(jdbc);
var snapshot = first.createOrLoad(scope, initial());
State privateState = new State(Status.COMPLETED, 1, 0, 10, 50, initial().expiresAtMillis(), null, List.of("private-note"), Map.of(), "private-result");
first.save(scope, snapshot.version(), privateState);
assertThrows(Conflict.class, () -> new JdbcRunStore(jdbc).save(scope, snapshot.version(), initial()));
for (Scope other : List.of(new Scope("tenant-b", scope.user(), scope.conversation(), scope.runId()),
new Scope(scope.tenant(), "beta", scope.conversation(), scope.runId()),
new Scope(scope.tenant(), scope.user(), "conversation-2", scope.runId()))) {
var isolated = first.createOrLoad(other, initial());
assertEquals(0, isolated.version());
assertTrue(isolated.state().observations().isEmpty());
}
}
@Test void survivesTheGapBetweenToolCommitAndCheckpointSave() {
Scope scope = scope();
var durable = new JdbcRunStore(jdbc);
var tools = new JdbcPlanTools(jdbc);
tools.seedPlan("tenant-a", "PLAN-102");
int before = tools.mutations("tenant-a", "PLAN-102");
RunStore broken = new RunStore() {
public Snapshot createOrLoad(Scope s, State initial) { return durable.createOrLoad(s, initial); }
public Snapshot save(Scope s, long expected, State next) {
if (!next.receipts().isEmpty()) throw new IllegalStateException("Simulated worker crash after tool commit");
return durable.save(s, expected, next);
}
};
try (var first = new BoundedAgent(broken, CLOCK)) {
assertThrows(IllegalStateException.class, () -> first.run(scope, budget(), model("PLAN-102"), tools, Set.of("PLAN-102")));
}
try (var restarted = new BoundedAgent(new JdbcRunStore(jdbc), CLOCK)) {
var result = restarted.run(scope, budget(), model("PLAN-102"), new JdbcPlanTools(jdbc), Set.of("PLAN-102"));
assertEquals(Status.COMPLETED, result.state().status());
assertEquals(2, result.state().toolAttempts());
assertEquals(1, tools.mutations("tenant-a", "PLAN-102") - before);
}
}
@Test void concurrentDeliveryOfTheSameWriteHasOneReceiptAndEffect() throws Exception {
Scope scope = scope();
var tools = new JdbcPlanTools(jdbc);
tools.seedPlan("tenant-a", "PLAN-101");
var call = new ToolCall("same-call", "completePlan", "PLAN-101");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var tasks = List.of(executor.submit(() -> tools.execute(scope, call, Set.of("PLAN-101"))),
executor.submit(() -> new JdbcPlanTools(jdbc).execute(scope, call, Set.of("PLAN-101"))));
for (var task : tasks) assertEquals("COMPLETED", task.get(5, TimeUnit.SECONDS));
}
assertEquals(1, tools.mutations("tenant-a", "PLAN-101"));
assertThrows(SecurityException.class, () -> tools.execute(scope, new ToolCall("same-call", "readPlan", "PLAN-101"), Set.of()));
assertThrows(SecurityException.class, () -> tools.execute(new Scope("tenant-b", "beta", scope.conversation(), scope.runId()), call, Set.of("PLAN-101")));
}
@Test void persistedDeadlineCannotBeExtendedByRestartingAWorker() {
Scope scope = scope();
var store = new JdbcRunStore(jdbc);
store.createOrLoad(scope, new State(Status.RUNNING, 0, 0, 0, 0, CLOCK.millis() - 1, null, List.of(), Map.of(), ""));
try (var agent = new BoundedAgent(new JdbcRunStore(jdbc), CLOCK)) {
var result = agent.run(scope, budget(), state -> { throw new AssertionError("Expired model call"); }, new JdbcPlanTools(jdbc), Set.of());
assertEquals(Status.TIMED_OUT, result.state().status());
}
}
}

examples/spring-ai-lab/src/test/java/com/interviewpreparator/agents/AgentCheckpointIT.java · mvn -f examples/spring-ai-lab/pom.xml -Pintegration verify. Java examples target 21. Models and usage are fixtures; no live inference or billing guarantee is implied.

Interview questions

Why evaluate intermediate actions?

They can violate policy or create side effects even when the final text is acceptable.

What does a crash/replay test prove?

The tested checkpoint and target-idempotency boundary handles that specific interruption; it is not a universal exactly-once guarantee.

Can synthetic usage demonstrate production cost?

No. It checks accounting rules; actual provider usage and pricing need separate measurement.

Practice

Add a tool that returns late after ignoring cancellation and design an explicit reconciliation outcome.

Check your solution

  • No denied action reaches execution.
  • A crash replay has one domain effect.
  • Fixture and live evaluation results stay labeled separately.

Official references

Continue learning

Category sequence

Agentic AI

Lesson 6 / 6

Was this lesson useful?

Change notes: Phase 3: bilingual agent engineering lesson and executable reference.