Appearance
ADR-0009 — The Agent Host answers server tool calls Mastra leaves open
Status: accepted · 2026-07-30
Context
Found with a live model, not by the test suite, in the device-operations dashboard this template's example domain grew out of. A user asked "disable the offline devices in Turin and filter them in the table"; the model replied with one assistant message containing both a server tool and view tools. The view tools completed, the table filtered correctly — and the server tool's card sat on "running" forever. The turn then stalled.
Reproduced against Mastra directly. When a step contains a server tool call and a client tool call:
- the server tool does execute (the procedure ran, audit recorded);
- the run then suspends for the browser and its result is dropped — it appears in neither
fullStreamnorstream.toolResults(verified: the array is empty).
Two consequences, one of them serious:
- the browser never receives a
tool-resultframe, so the card hangs; - the reconstructed history contains a
tool-callwith no matchingtool-result. Providers reject that shape, which is what stalled the conversation — the visible symptom was a spinner, the real damage was a malformed transcript.
The scripted CI model never batched tool calls, so nothing caught it. Real models batch constantly.
Decision
The Agent Host wraps the domain toolset it hands to Mastra (withResultCapture) and records each result by tool-call id. At step finalization, settleOrphanedServerCalls answers every unresolved server call — emitting the tool-result frame and appending the tool message to the history. A call with no captured result is answered with TOOL_NOT_EXECUTED / retry: "yes" rather than left silent, so the model retries instead of assuming success.
This stays inside the host's remit: it composes the toolset and owns result transport and correlation. Execution, authorization and audit remain entirely inside the orpc-agent runtime — the wrapper only observes.
The scripted model now batches a server tool with a client tool in its first message, so the deterministic e2e exercises this path on every run, and e2e/copilot.spec.ts asserts the domain card reaches data-status="ok".
Consequences
- Every
tool-callgets atool-result, in both planes, always. - The mock is more faithful to real model behavior, which is what let the bug through in the first place.
- If a future Mastra release emits the dropped result itself, the settlement loop simply finds nothing unresolved and does nothing.