Sections in this lesson

Bounded Agent Loop

Run model → tool → observation cycles with explicit stop conditions, reservations, deadlines and inspectable outcomes.

Reviewed: · Content owner: Interview Preparator

What you will learn

  • Enforce step, tool-attempt and usage limits.
  • Distinguish completion, deliberate stop and failed execution.
  • Understand why cancellation is not rollback or sandboxing.

Before you start

The flow

  1. 1.Load checkpoint
  2. 2.Reserve budget
  3. 3.Ask model
  4. 4.Authorize tool
  5. 5.Observe or stop

Inspect a bounded agent run

Conceptual simulation using the Java test fixtures. No model or tool is called here.

  1. 1. MODEL
  2. 2. POLICY
  3. 3. TOOL
  4. 4. MODEL

Propose readPlan for PLAN-1.

Bound every amplification path

BoundedAgent persists the model-step reservation before invoking the model. It limits cumulative model steps, tool attempts and configured usage units. A repeated call ID can reuse a receipt, but repeated model decisions still consume step budget and eventually stop.

The example settles fixture usage after a successful response. A real adapter must count input with the selected tokenizer, set provider output limits and reconcile actual usage. A local reservation ledger alone does not guarantee a provider bill cap.

Make terminal outcomes observable

Finish and Stop are explicit decisions. Step exhaustion, token reservation failure, a forbidden tool and a timeout have different statuses. Do not turn all of them into a generic successful answer. Persist enough metadata to identify the cause without logging sensitive prompts by default.

The interactive simulation reads shared fixture cases whose final status, model calls, tool attempts and token accounting are checked against the Java runtime. Intermediate cards explain the flow; they are not a live execution trace.

Cancellation has limits

The runtime waits for each operation within a deadline and requests interruption on timeout. It does not wait indefinitely for an executor to close. Java interruption is cooperative: a misbehaving adapter can keep running, and a committed remote write cannot be undone by cancelling a Future.

For untrusted code, add process/container isolation and external deadlines. For ambiguous writes, inspect the durable target receipt and reconcile the outcome; do not blindly retry a timed-out side effect.

Try it

BoundedAgent.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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
package com.interviewpreparator.agents;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Set;
import java.util.concurrent.*;
import static com.interviewpreparator.agents.AgentContracts.*;
/** Teaching runtime: cooperative cancellation; no untrusted process sandbox. */
public final class BoundedAgent implements AutoCloseable {
public record Budget(int maxSteps, int maxToolAttempts, int maxTokens, long maxCostMicros,
int tokensPerStep, long costPerStepMicros, int maxReadRetries,
Duration runTimeout, Duration operationTimeout) {
public Budget {
if (maxSteps < 1 || maxSteps > 100 || maxToolAttempts < 1 || maxToolAttempts > 100
|| maxTokens < 1 || maxCostMicros < 1 || tokensPerStep < 1 || costPerStepMicros < 1
|| maxReadRetries < 0 || maxReadRetries > 3 || runTimeout == null || operationTimeout == null
|| runTimeout.toMillis() < 1 || runTimeout.toMillis() > 3_600_000
|| operationTimeout.toMillis() < 1 || operationTimeout.compareTo(runTimeout) > 0) {
throw new IllegalArgumentException("Invalid bounded budget");
}
}
}
private final RunStore store;
private final Clock clock;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public BoundedAgent(RunStore store, Clock clock) { this.store = store; this.clock = clock; }
public Snapshot run(Scope scope, Budget budget, Model model, Tools tools, Set<String> approvals) {
Snapshot current = store.createOrLoad(scope, new State(Status.RUNNING, 0, 0, 0, 0,
Math.addExact(clock.millis(), budget.runTimeout().toMillis()), null,
java.util.List.of(), java.util.Map.of(), ""));
if (current.state().status() == Status.WAITING_APPROVAL) {
current = save(scope, current, Status.RUNNING, current.state().pending(), null, null);
}
int readRetries = 0;
while (current.state().status() == Status.RUNNING) {
State state = current.state();
if (clock.millis() >= state.expiresAtMillis()) return terminal(scope, current, Status.TIMED_OUT, "");
if (state.pending() == null) {
if (state.steps() >= budget.maxSteps()) return terminal(scope, current, Status.STEP_LIMIT, "");
if (budget.tokensPerStep() > budget.maxTokens() - state.tokens()) return terminal(scope, current, Status.TOKEN_LIMIT, "");
if (budget.costPerStepMicros() > budget.maxCostMicros() - state.costMicros()) return terminal(scope, current, Status.COST_LIMIT, "");
// Reserve before the call. An interrupted/restarted model step keeps its reservation.
current = store.save(scope, current.version(), new State(Status.RUNNING, state.steps() + 1,
state.toolAttempts(), state.tokens() + budget.tokensPerStep(),
state.costMicros() + budget.costPerStepMicros(), state.expiresAtMillis(),
null, state.observations(), state.receipts(), ""));
ModelReply reply;
try {
State input = current.state();
reply = timed(() -> model.next(input), input, budget);
} catch (TimeoutException exception) { return terminal(scope, current, Status.TIMED_OUT, ""); }
catch (InterruptedException exception) { Thread.currentThread().interrupt(); return terminal(scope, current, Status.STOPPED, ""); }
catch (Exception exception) { return terminal(scope, current, Status.MODEL_ERROR, ""); }
if (clock.millis() >= current.state().expiresAtMillis()) return terminal(scope, current, Status.TIMED_OUT, "");
if (reply == null || reply.decision() == null || reply.tokens() < 0 || reply.costMicros() < 0
|| reply.tokens() > budget.tokensPerStep() || reply.costMicros() > budget.costPerStepMicros()) {
return terminal(scope, current, Status.INVALID_OUTPUT, "");
}
State reserved = current.state();
current = store.save(scope, current.version(), new State(Status.RUNNING, reserved.steps(), reserved.toolAttempts(),
reserved.tokens() - budget.tokensPerStep() + reply.tokens(),
reserved.costMicros() - budget.costPerStepMicros() + reply.costMicros(),
reserved.expiresAtMillis(), null, reserved.observations(), reserved.receipts(), ""));
if (reply.decision() instanceof Finish finish) {
if (!bounded(finish.text())) return terminal(scope, current, Status.INVALID_OUTPUT, "");
return terminal(scope, current, Status.COMPLETED, finish.text());
}
if (reply.decision() instanceof Stop stop) {
if (!bounded(stop.reason())) return terminal(scope, current, Status.INVALID_OUTPUT, "");
return terminal(scope, current, Status.STOPPED, stop.reason());
}
ToolCall call = (ToolCall) reply.decision();
if (!valid(call)) return terminal(scope, current, Status.INVALID_OUTPUT, "");
current = save(scope, current, Status.RUNNING, call, null, null);
readRetries = 0;
}
ToolCall call = current.state().pending();
Permission permission = tools.authorize(scope, call, Set.copyOf(approvals));
if (permission == Permission.DENY) return terminal(scope, current, Status.DENIED, "");
if (permission == Permission.APPROVAL_REQUIRED) return save(scope, current, Status.WAITING_APPROVAL, call, null, null);
Receipt cached = current.state().receipts().get(call.callId());
if (cached != null) {
if (!cached.call().equals(call)) return terminal(scope, current, Status.INVALID_OUTPUT, "");
current = save(scope, current, Status.RUNNING, null, cached, null);
continue;
}
if (current.state().toolAttempts() >= budget.maxToolAttempts()) return terminal(scope, current, Status.TOOL_LIMIT, "");
State before = current.state();
current = store.save(scope, current.version(), new State(before.status(), before.steps(), before.toolAttempts() + 1,
before.tokens(), before.costMicros(), before.expiresAtMillis(), call,
before.observations(), before.receipts(), ""));
String result;
try {
State input = current.state();
result = timed(() -> tools.execute(scope, call, Set.copyOf(approvals)), input, budget);
} catch (TimeoutException exception) { return terminal(scope, current, Status.TIMED_OUT, ""); }
catch (InterruptedException exception) { Thread.currentThread().interrupt(); return terminal(scope, current, Status.STOPPED, ""); }
catch (ExecutionException exception) {
if (exception.getCause() instanceof TransientReadFailure && tools.isReadOnly(call)
&& readRetries++ < budget.maxReadRetries()) continue;
return terminal(scope, current, Status.TOOL_ERROR, "");
} catch (Exception exception) { return terminal(scope, current, Status.TOOL_ERROR, ""); }
if (clock.millis() >= current.state().expiresAtMillis()) return terminal(scope, current, Status.TIMED_OUT, "");
if (!bounded(result)) return terminal(scope, current, Status.INVALID_OUTPUT, "");
current = save(scope, current, Status.RUNNING, null, new Receipt(call, result), null);
}
return current;
}
private <T> T timed(Callable<T> task, State state, Budget budget) throws Exception {
long remaining = Math.min(budget.operationTimeout().toMillis(), state.expiresAtMillis() - clock.millis());
if (remaining <= 0) throw new TimeoutException();
Future<T> future = executor.submit(task);
try { return future.get(remaining, TimeUnit.MILLISECONDS); }
catch (TimeoutException | InterruptedException exception) { future.cancel(true); throw exception; }
}
private Snapshot terminal(Scope scope, Snapshot current, Status status, String answer) {
return save(scope, current, status, null, null, answer);
}
private Snapshot save(Scope scope, Snapshot current, Status status, ToolCall pending, Receipt receipt, String answer) {
State old = current.state();
var observations = new ArrayList<>(old.observations());
var receipts = new HashMap<>(old.receipts());
if (receipt != null) {
observations.add(receipt.result());
receipts.put(receipt.call().callId(), receipt);
if (observations.size() > 6) observations.removeFirst();
}
return store.save(scope, current.version(), new State(status, old.steps(), old.toolAttempts(), old.tokens(),
old.costMicros(), old.expiresAtMillis(), pending, observations, receipts, answer == null ? old.answer() : answer));
}
private static boolean bounded(String text) { return text != null && !text.isBlank() && text.length() <= 2000; }
private static boolean valid(ToolCall call) {
return call.callId() != null && call.callId().matches("[a-zA-Z0-9-]{1,80}")
&& call.tool() != null && call.tool().matches("[a-zA-Z]{1,40}")
&& call.resource() != null && call.resource().matches("PLAN-[0-9]{1,12}");
}
@Override public void close() { executor.shutdownNow(); }
}

examples/spring-ai-lab/src/main/java/com/interviewpreparator/agents/BoundedAgent.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 does a cached tool call still spend steps?

The model continues proposing actions. Without a separate step bound it could loop forever without new tool executions.

Does Future.cancel guarantee no write happened?

No. It requests cancellation; committed or non-cooperative work may require reconciliation.

How do you prevent budget reset after restart?

Persist cumulative counters and the original expiry with the run, then reload them before another action.

Practice

Add a provider adapter with configured output limits and test a response whose reported usage exceeds its reservation.

Check your solution

  • No model call occurs without a reservation.
  • Timeout and explicit Stop are distinguishable.
  • A late side effect is reconciled rather than called impossible.

Official references

Continue learning

Category sequence

Agentic AI

Lesson 2 / 6

Was this lesson useful?

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