title: Agent Loop description: How Spring AI Playground governs the agentic tool-calling loop - a decorator on Spring AI’s ToolCallingManager that adds per-turn state, round bounds, and an interceptor chain for approval and interactive tools, without touching the Spring AI loop.
An agent is a model calling tools in a loop until it can answer. In Spring AI Playground that loop is owned by Spring AI - the playground does not reimplement it. What the playground adds is the governance around each round: how many rounds may run, what to do when the model repeats itself or a call is cancelled, and how a tool call can pause for a human or for a file. This page describes that layer.
Pages this layer connects to:
service/agent sits in the runtimeSpring AI’s ToolCallingAdvisor (static tools) and ToolSearchToolCallingAdvisor (dynamic discovery) own the while loop: call the model, if it asked for tools execute them, append the results, repeat until the model answers. Both delegate the execute the tools step to a ToolCallingManager. The playground supplies its own implementation, AgentLoopManager, wired into both advisors as their manager.
So the relationship is: the playground wraps the Spring AI loop from the outside, through the extension point the loop already offers. No Spring AI class is modified or subclassed - AgentLoopManager decorates the default manager. Because the static advisor, the dynamic advisor, and the non-streaming call() path all funnel through this one manager, it is the single place that governs every tool round.
flowchart TB
subgraph SAI["Spring AI (unmodified)"]
ADV["ToolCallingAdvisor / ToolSearchToolCallingAdvisor<br/>· owns the while loop"]
end
subgraph AGENT["service/agent (playground)"]
direction LR
TURN["AgentTurn<br/>· per-turn state"]
MGR["AgentLoopManager<br/>· one round"]
CHAIN["Interceptor<br/>chain"]
MGR --> TURN
MGR --> CHAIN
end
subgraph SAI2["Spring AI (unmodified)"]
DEF["Default ToolCallingManager<br/>· runs the tools"]
end
ADV -->|"each round: executeToolCalls"| MGR
MGR -->|"non-intercepted calls"| SAI2
What this seam can and cannot do shapes everything below. It can count rounds, replace a specific tool call with a synthetic response, and end the loop (by returning returnDirect). It cannot change the loop’s own control flow - so a soft limit is enforced by persuasion (a synthetic “wrap up” tool response) and only the hard limit forces termination.
One AgentTurn is created per user message and placed in the tool context, so every round of that turn shares it. It tracks the round count, a per-fingerprint call count (tool name + arguments), the set of tools declined this turn, the interaction budget for the current round, and a cancelled flag. It is built from atomics/concurrent sets - rounds run on reactive scheduler threads and cancel() arrives from outside the stream.
If no turn is present in the tool context (a caller that uses the manager directly, e.g. a test or an external embedder), AgentTurn.from returns a detached, unbounded turn - the pre-existing behavior, no guards.
At the start of each round the turn returns a verdict:
| Verdict | When | Effect |
|---|---|---|
CONTINUE |
normal | Run the round through the interceptor chain. |
WRAP_UP |
round count past soft-max-rounds |
Every tool call is answered with “the tool budget is exhausted, answer now” - nothing executes. |
HARD_STOP |
round count past hard-max-rounds |
Same, plus returnDirect so the loop ends immediately. |
CANCELLED |
the stream was stopped/failed | Every call is answered without executing, and the loop ends. |
The soft/hard split exists because of the seam: the playground cannot delete tools from the next round, only respond to the calls the model already made. WRAP_UP persuades; HARD_STOP is the backstop for a model that ignores the persuasion. Defaults are 16 and 18 - see the configuration reference. All limits are enforced by LoopGuardInterceptor and counted on the chat.tool.loop meter.
AgentLoopManager.executeToolCalls runs once per round:
turn.beginRound() returns the verdict above.LoopGuard → HitlApproval → FileUpload → ImageReference. Because the guard runs first, a cancelled or budget-exhausted round never opens a dialog.returnDirect; a HARD_STOP/CANCELLED verdict forces returnDirect.flowchart TB
START["executeToolCalls<br/>(one round)"] --> VERDICT{"turn verdict"}
VERDICT -->|"WRAP_UP / HARD_STOP / CANCELLED"| SYNTH["Answer every call<br/>synthetically · no execution"]
VERDICT -->|"CONTINUE"| CHAIN["Interceptor chain<br/>guard → hitl → upload → image"]
CHAIN -->|"claimed calls"| CLAIM["Synthetic response<br/>per claimed call"]
CHAIN -->|"remaining calls"| RUN["Spring AI default manager<br/>runs the tools"]
CLAIM --> MERGE["Merge in call order · truncate ·<br/>returnDirect if terminal / hard-stop"]
RUN --> MERGE
SYNTH --> MERGE
Each interceptor is a small, stateless unit; per-turn memory lives on AgentTurn, UI handlers are pulled from the tool context.
LoopGuardInterceptor - applies the round verdict, short-circuits a tool called with identical arguments too many times, and auto-declines any tool already declined this turn (so a re-request never reopens a dialog).HitlApprovalInterceptor - the human-in-the-loop gate. Raises one approval question per gated call, keyed by tool call id, and batches the round’s questions into a single dialog. Fails closed: if the dialog cannot be shown or errors, the calls are declined.FileUploadInterceptor - handles requestFileUpload by opening the upload dialog (within the round’s interaction budget). A cancel/timeout marks the tool declined for the turn and tells the model to stop retrying and ask the user to upload in a new message.ImageReferenceInterceptor - handles describeImage: resolves an existing image, or opens an upload/pick dialog, then attaches the image as a follow-up message for a vision model. Cancel behaves like the upload case.The upload and image interceptors are why the loop is interactive: a tool call can block on a person mid-loop. The interaction budget (one dialog per round by default) keeps those blocking waits from stacking past the stream’s 300-second inter-signal timeout.
Interactive dialogs block a round thread while they wait. Two things keep that safe:
ChatService marks the turn cancelled and calls cancelPending() on each interactive handler, which completes the blocked wait and closes any open dialog. This prevents an orphaned modal from outliving the response.One consequence worth noting: the upload and approval dialogs are modal, so while one is open the chat’s Stop button is behind the backdrop and cannot be clicked. Aborting an open dialog is done with its own Cancel / Decline button (which ends the tool cleanly and lets the model wrap up); cancelPending covers the error/detach cases, not the Stop button.
Every synthetic response follows one rule: state what happened, then say what to do next. A decline, a cancel, a repeat, a budget stop - each tells the model plainly that the call did not run and not to retry it this turn, so a small local model does not spin on the same call. These are centralized in AgentTurnMessages. The MCP server-side gate carries the same “do not retry” hint for external callers.
chat.tool.loop counter, tagged by outcome (wrap-up, hard-stop, cancelled, repeated, declined-repeat, interaction-budget) - how often each guard fired.mcp.hitl.decision counter, tagged outcome (approved, declined, ask-failed) and side=chat - approval decisions (the server gate reports side=server). See Human-in-the-Loop Approval.AgentLoopHarness.