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.Load checkpoint
- 2.Reserve budget
- 3.Ask model
- 4.Authorize tool
- 5.Observe or stop
Inspect a bounded agent run
Conceptual simulation using the Java test fixtures. No model or tool is called here.
- 1. MODEL
- 2. POLICY
- 3. TOOL
- 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
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.