DEV Community

Zhengxin
Zhengxin

Posted on

Claude Code Agent Loop Deep Dive (3): From Reading Files to Parallel Scheduling

The first two articles covered the two gates between an LLM requesting a tool and that tool actually running: pre-execution approval and hooks. Once both pass, the tool can finally execute.

But one assistant message may contain several tool_use blocks. Should the harness start all of them at once, or wait for each one to finish? Parallel work is faster, but two edits to the same file can overwrite each other.

This article examines execution scheduling: how Claude Code batches tool calls, decides what may run concurrently, handles failures, and preserves one vital invariant throughout: every tool_use must receive a matching tool_result.

The smallest possible tool call

Start with an LLM deciding to read a file. Its assistant message contains a tool_use block:

{
  role: 'assistant',
  content: [
    { type: 'text', text: 'Let me inspect auth.py first.' },
    {
      type: 'tool_use',
      id: 'toolu_A3f2',
      name: 'Read',
      input: { file_path: 'auth.py' }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A tool-use block has three important fields:

  • id: a unique identifier for this invocation; the result uses it to pair itself with the call.
  • name: the requested tool, such as Read, Edit, or Bash.
  • input: the JSON object containing the tool's arguments.

The harness looks up the implementation by name, executes it with input, then appends the outcome to the message history as a tool_result:

{
  role: 'user',
  content: [
    {
      type: 'tool_result',
      tool_use_id: 'toolu_A3f2',
      content: '<the 200 lines of auth.py>'
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The tool_use_id closes the loop. On the next model call, the model receives that result and can reason over the file contents.

A message can contain many tool calls

The same assistant message can request more than one tool:

{
  role: 'assistant',
  content: [
    { type: 'text', text: 'I will read both files in parallel.' },
    { type: 'tool_use', id: 'toolu_A', name: 'Read', input: { file: 'auth.py' } },
    { type: 'tool_use', id: 'toolu_B', name: 'Read', input: { file: 'login.py' } }
  ]
}
Enter fullscreen mode Exit fullscreen mode

At this point, the harness chooses between two valid implementations:

  • Concurrent: start both reads together. This is fast.
  • Serial: finish one before beginning the other. This is safe but slower.

Claude Code uses a hybrid approach. The model is free to request many tools, while the harness—not the model—decides how they are scheduled.

Each tool declares whether it is safe to parallelize

One design would put a central table in the harness: Read is concurrent, Edit is serial, and Bash depends on its command. Claude Code instead pushes that knowledge down to the tools themselves.

Each tool implements the question, “Is this invocation safe to run concurrently?” Typical answers are:

  • Read: it only reads a file and changes no state, so it is safe.
  • Grep / Glob: they search the filesystem and are safe.
  • Edit / Write: they change files and are not safe; concurrent edits may target the same file.
  • Bash: it depends on the command. A read-only command such as git status can be safe, while a destructive or stateful command must be serialized.

Only a tool knows the full meaning and side effects of its own arguments. The LLM does not make this declaration, and the scheduler does not need to guess. This also makes the architecture extensible: when a tool gains a new command or side effect, the tool can update its own rule without changing the central scheduler.

Turning calls into batches

Given N tool calls, the harness scans them in declaration order. Consecutive calls that all declare themselves safe are collected into one concurrent batch. An unsafe call ends that batch and stands alone.

For example:

1. Read auth.py       → safe
2. Read login.py      → safe
3. Edit auth.py       → unsafe
4. Read session.py    → safe
Enter fullscreen mode Exit fullscreen mode

becomes:

Batch 1: [Read auth.py, Read login.py]  ← run concurrently
Batch 2: [Edit auth.py]                 ← run by itself
Batch 3: [Read session.py]              ← run after the edit
Enter fullscreen mode Exit fullscreen mode

Batches execute serially; calls within a batch execute concurrently. This gives useful ordering guarantees:

  • The two reads do not wait on one another.
  • The edit cannot start until the earlier reads complete.
  • The later read cannot observe the file before the edit has completed.

There is also a cap on concurrency—ten tools by default—so a large model response cannot exhaust local resources by launching everything at once.

Even the safety check can fail

The concurrency check itself is code, and code can throw. For example, a Bash tool might need to parse a command line in order to classify it; unusual quoting can make the parser fail.

Claude Code catches such failures and falls back to unsafe, which means serial execution.

That is a deliberate fail-safe: when the system is uncertain, it becomes slower rather than incorrect. Losing a little parallelism is far better than silently running two conflicting operations at the same time. It is the same conservative-default principle behind not automatically inheriting sensitive permissions to a sub-agent.

Starting a tool while JSON is still streaming

The batching described so far starts after the model has completed its assistant message. But a streaming response arrives frame by frame. A tool-use block may arrive like this:

Frame 1: tool_use begins; name = 'Read'
Frame 2: input JSON fragment: '{"file'
Frame 3: input JSON fragment: '_path":"'
Frame 4: input JSON fragment: 'auth.py"}'
Frame 5: tool_use ends
Enter fullscreen mode Exit fullscreen mode

The obvious implementation waits for frame 5, parses the complete JSON, and then starts Read. Claude Code can do better: it incrementally parses the partial JSON and starts the tool as soon as it can reliably infer the required arguments, such as a complete file_path.

That overlaps tool latency with continued model output. If a read takes 100 ms while the model spends two seconds streaming the next text, a serial path costs about 2.1 seconds; the overlapping path costs roughly the slower of the two operations—about two seconds. Across dozens of tool uses, this can save several seconds of visible waiting time.

The cost is complexity. The harness needs a parser that can extract useful values from incomplete JSON, even before the closing brace arrives.

A tool failure is feedback, not a loop crash

Consider a request to read a nonexistent file, or a Bash command rejected by the permission system. A naïve implementation lets the tool throw and crashes the agent loop.

Claude Code instead catches the failure and turns it into an error result:

{
  role: 'user',
  content: [
    {
      type: 'tool_result',
      tool_use_id: 'toolu_X',
      content: '<tool_use_error>File does not exist</tool_use_error>',
      is_error: true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Tool failure is therefore a state within the loop, not an exception that escapes it. The model gets concrete feedback and can decide what to do next: retry, search for a differently named file, choose another tool, or explain the limitation to the user.

Common sources of is_error: true include:

  • A tool's own exception, such as a missing file, a denied permission, or a non-zero shell exit.
  • An unknown tool name, reported as something like Tool 'XXX' not found.
  • A user interruption, represented by a synthetic Interrupted by user result.
  • A repair result created for an internal failure, for example [Tool result missing due to internal error].

The explicit is_error flag tells the model that the invocation did not succeed while still giving it the information needed to recover.

The strongest guardian of the pairing invariant

Together, these decisions establish a strong promise: once the model has emitted a tool_use, the system creates a corresponding tool_result regardless of what follows.

  • A tool crashes: emit an error result.
  • The tool cannot be found: emit an error result.
  • The user interrupts it: emit an error result.

Tool execution therefore does not leave orphaned tool_use blocks behind. Broader history operations—such as compaction or rewinding—can still damage message structure, but those are handled by repair mechanisms at the context-management layer. The boundaries are clear: the tool system protects the portion of the invariant it owns; outer layers repair damage that crosses those boundaries.

Takeaways

  • A tool call travels from tool_use, through dispatch and execution, into an appended tool_result.
  • One response can contain many tool calls; the harness, not the model, schedules them.
  • Tools declare their own concurrency safety because they understand their own side effects.
  • Consecutive safe calls form a concurrent batch; unsafe calls create serial boundaries.
  • If safety classification fails, the scheduler conservatively falls back to serial execution.
  • Incremental JSON parsing can begin a tool before the model has finished streaming its message.
  • Tool errors become is_error results for the model, rather than exceptions that terminate the loop.
  • The resulting system preserves the essential tool_use / tool_result pairing invariant.

The next article follows the other end of the cycle: after a tool_result is returned and the model is called again, how does the loop decide whether the model is actually finished speaking?


References

Primary source locations (v2.1.220):

  • src/services/tools/toolOrchestration.ts: runTools, runToolsSerially, and runToolsConcurrently.
  • src/services/tools/toolOrchestration.ts: the isConcurrencySafe interface and partitionToolCalls batching logic.
  • src/query.ts: StreamingToolExecutor and early tool start during streamed JSON parsing.
  • src/services/tools/toolExecution.ts: converting execution exceptions into is_error tool results.

Related reading:

Top comments (0)