All posts

From Incomplete JSON to Safe Retries: Guarding Streaming Agent Tool Calls

Investigate truncated Agent arguments that became an empty object, and establish observable, retryable boundaries between raw streams, structural validation, and tool execution.

One failed tool call in Agentic EDA initially looked like a tool complaining about missing required arguments. Following the call chain revealed that the tool had received {}, even though the model’s raw output was not empty.

The original arguments contained partial content, but lacked a closing brace. When parsing failed, the adapter replaced the result with an empty object. That conversion erased the most useful error information: the tool could only report “invalid arguments,” without explaining where they had been lost.

The investigation established that the raw arguments were incomplete and that the empty object came from an adapter fallback. It did not prove why the model had not produced complete content, or establish a network interruption or exhausted token budget. The first fix targeted the boundary supported by evidence: prevent damaged arguments from reaching tool execution.

Preserve raw arguments from the stream

A streaming tool call commonly includes a start event, several argument deltas, and an end event. Event names vary by SDK, but the shared requirement is to collect raw text separately by tool-call ID so parallel calls do not become mixed.

Here is a synthetic event sequence; the tool name and arguments are illustrative:

call-a start: inspect_board
call-a delta: {"boardId":"demo",
call-a delta: "checks":["clearance"]
call-a end

The concatenated string lacks its outer closing }. Inspecting only the adapter’s final object cannot distinguish “the model actually produced an empty object” from “a parse failure was replaced with an empty object.”

At this layer, I focus on three things: the call ID, tool name, and whether the raw arguments parsed successfully. Raw text can be retained as controlled diagnostic evidence, but should not be written indiscriminately into long-term logs: tool arguments may contain file contents or business data. Routine monitoring can record length, digest, error position, and a correlated request ID, with raw text available through a restricted diagnostic channel when needed.

Three checks address three different problems

Syntactically valid JSON is not necessarily a business object. null, numbers, and arrays can all be valid JSON. MDN’s JSON.parse reference also distinguishes parsing results from syntax errors.

I therefore split checks before dispatch into three stages:

  1. Syntax: Can the raw string be parsed completely?
  2. Structure: Is it an object, and do required fields and types match the tool schema?
  3. Business rules: Do referenced objects exist, is the action allowed, and does the current state permit execution?

This standalone example demonstrates only the first two layers; a real tool still needs business validation:

class MalformedToolArguments extends Error {}
class InvalidToolInput extends Error {}

function parseToolInput(raw: string): { boardId: string } {
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch {
    throw new MalformedToolArguments('Tool arguments are not complete JSON');
  }
  if (
    typeof value !== 'object' || value === null || Array.isArray(value) ||
    !('boardId' in value) || typeof value.boardId !== 'string' ||
    value.boardId.trim() === ''
  ) {
    throw new InvalidToolInput('boardId must be a nonempty string');
  }
  return { boardId: value.boardId };
}

There is no attempt to append braces automatically. Completing an unfinished nested structure can give it a different meaning. For tools that modify a design, this kind of syntax “repair” is particularly hard to make semantically safe. Asking the model to regenerate explicit arguments is easier to trace than silently guessing.

Retry before side effects

Before arguments pass validation, the generation can be marked failed and requested again with a brief error message. Set an explicit retry limit and return an error to the user after repeated failures, rather than looping indefinitely in the background.

Retry scope is another issue. If one model response includes several tool calls, do not execute the first two, discover broken JSON in the third, and then rerun the entire response. The earlier tools may already have written files. A more reliable boundary collects and validates the arguments for the whole pending batch before dispatch; tools that have already executed need separately recorded results.

These failures must remain distinct:

Situation Handling priority
Incomplete arguments; tool not yet executed Regenerate arguments with a retry limit
Complete arguments rejected by business validation Return the specific reason to planning; avoid an unchanged retry
Tool execution timed out; write status unknown Query the result or use an idempotency identifier before deciding to retry
Upstream explicitly rejected the request Preserve status and reason; stop treating it as success

In particular, “no success response received” must not be equated with “no side effect occurred.” For operations such as creation, deletion, and export, idempotency keys, operation records, and result queries are often more important than the retry count.

Tests must prove the tool was not called incorrectly

Two valuable historical test cases were an incomplete first output followed by a successful one, and repeatedly incomplete output that eventually reached the retry limit. The first checks recovery; the second checks termination.

I also watch tool execution counts. Asserting only the final error type is insufficient: the tool associated with damaged arguments must not execute even once. Multi-tool responses need a case with one valid call and one damaged call, checking whether any write occurs before validation finishes.

Other cases include a valid empty object, valid JSON of the wrong type, upstream error events, and an uncertain response after a tool has executed. They may produce similar user-facing messages, but require entirely different recovery strategies.

This guard improved error localization and execution boundaries. It did not solve every generation failure or explain every upstream interruption. It did preserve original evidence for future diagnosis and keep tools from executing until their inputs were explicit, which makes Agent maintenance easier than reducing every exception to “missing arguments.”