All questions

Tool-Call Streaming State Machine

Premium

Tool-Call Streaming State Machine

When a language model calls a tool, it does not hand you the call in one piece. It streams events: first that a tool named search is starting, then the argument JSON a few characters at a time, then a signal that the arguments are complete, and finally — after your code runs the tool — a result. Several tool calls can stream at once, interleaved, each tagged with its own id. Your job is to track them: route each event to the right call by id, accumulate its argument text, and move it through a small state machine.

Implement toolCallStreamingStateMachine(). It returns a controller with apply(event), get(id), and list(). Each tool call is { id, name, argsText, args, status, result, error } and moves through the states pending (arguments still streaming) → running (arguments complete, tool executing) → done (result in), with error as an escape hatch.

The events:

  • start { id, name } — announce a new call; it begins pending.
  • delta { id, argsTextDelta } — append a fragment to that call's argsText.
  • ready { id } — arguments are complete; parse argsText and move to running.
  • result { id, result } — the tool returned; move to done.
  • error { id, error } — the call failed.

Signature

type ToolCall = { id: string; name: string; argsText: string; args?: unknown; status: 'pending' | 'running' | 'done' | 'error'; result?: unknown; error?: unknown };

function toolCallStreamingStateMachine(): {
  apply(event: { type: string; id: string; name?: string; argsTextDelta?: string; result?: unknown; error?: unknown }): void;
  get(id: string): ToolCall | undefined;
  list(): ToolCall[];
};

Examples

const m = toolCallStreamingStateMachine();
m.apply({ type: 'start', id: 't1', name: 'search' });
m.apply({ type: 'delta', id: 't1', argsTextDelta: '{"q":' });
m.apply({ type: 'delta', id: 't1', argsTextDelta: '"hi"}' });
m.apply({ type: 'ready', id: 't1' });
m.get('t1'); // { id:'t1', name:'search', argsText:'{"q":"hi"}', args:{ q:'hi' }, status:'running' }
// Out-of-order events are ignored, not fatal.
m.apply({ type: 'start', id: 't2', name: 'now' });
m.apply({ type: 'result', id: 't2', result: 42 }); // too early — still pending
m.get('t2').status; // 'pending'

Notes

  • Route by id — every event carries the id; use it to find the right call. Concurrent calls must not mix.
  • Accumulate then parse — collect argsTextDelta as raw text while pending; only JSON.parse on ready.
  • Guard every transition — a delta after ready, a result while pending, or an error after done should be ignored.
  • Parse failure is an error — if argsText is not valid JSON on ready, the call becomes error.
  • Out of scope — cancelling a call, retries, and partial/streaming JSON parsing (arguments are only read once complete).

FAQ

Why key the tool calls by id?
A model can stream several tool calls at once, interleaving their argument deltas. The id on each event is what lets you route a delta to the right call, so a map keyed by tool-call id keeps concurrent calls from corrupting each other.
Why guard each transition by the current status?
Streams can arrive out of order or repeat. Guarding — accept a delta only while pending, a result only while running — makes the machine robust: an early or duplicate event is ignored instead of corrupting the call.
When are the arguments parsed?
The argument text arrives in fragments and is only valid JSON once complete. You accumulate the raw text while pending and parse it in one shot on the ready event; a truncated or malformed payload moves the call to error.

Unlock the solution & editor

  • Runnable editor + tests

    Solve in the browser with instant Jest feedback.

  • Detailed solutions

    Walkthroughs, edge cases, and complexity notes.

  • Multi-framework variants

    React, Vue, Vanilla, Angular — same question, different stacks.

Upgrade to Premium