How to preserve result ordering and isolate partial failures in OpenClaw parallel tool calls
The most exciting moment when first setting up a multi-step agent pipeline is watching fan-out work for the first time. Seeing the orchestrator spin up four or five sub-agents at once and watching latency drop sharply gives you a brief sense of satisfaction. But right after that comes this — one failure takes everything down, or results get mixed up and quietly corrupt the session state.
I too initially brushed it off with "who cares about IDs, they'll come back in order" — and paid for it. It wasn't until I properly understood that the OpenClaw runtime maps tool_result to tool_use by ID that I grasped why sessions were silently breaking. In parallel execution, the order of completion may differ from the order the model expects responses in, and this mismatch corrupts the entire pipeline.
In this post I've laid out, in order: result sorting by tool_use ID, partial failure isolation, executionMode contract declaration, and blocking repeated failure loops. This reflects PR #140767 and the v2026.8.1 fix notes as of September 2026.
The Structural Cause of Parallel Execution Corrupting Sessions
The tool_use ID Is a Contract
When a model emits multiple tool_use blocks in a single response, the runtime executes them in parallel. The problem is that results must not be returned in the order they finish. The model can only continue reasoning correctly if the tool_use IDs it issued correspond 1:1 with tool_result IDs.
When an ID mismatch occurs, the model picks up the wrong context and all subsequent reasoning goes completely off the rails. The session log looks fine, but the actual behavior is in a corrupted state — the hardest kind of bug to debug.
What the Steering Queue Decides
When an error occurs after only some of a parallel batch has passed the launch checkpoint, the Steering Queue distinguishes between two cases:
- Calls already started → continue
- Sequential calls not yet started → skip and generate synthetic result pairs
A synthetic result is an empty response generated without actual execution. Because the runtime does not automatically classify these as "failures," unless the orchestrator explicitly detects them they flow through to the next stage indistinguishable from successful responses. The diagram below draws the boundary between what the runtime handles and where the orchestrator's responsibility begins.
Without this check logic explicitly added by the developer, synthetic results are mistaken for successes and the pipeline flows into a broken state.
Practical Patterns: Isolation Implementation in Code
All examples below are conceptual illustrations. Import paths like openclaw.runtime, openclaw.subagents, and openclaw.watchers are names used for explanation, not actual SDK symbols — please map them to the language and SDK you're using.
1. Result ID Sorting Defense Logic
While the OpenClaw runtime handles ID mapping, adding one more layer of defense logic when collecting results in a custom orchestrator layer is much safer.
# Conceptual example - actual class names vary by SDK
import asyncio
async def collect_parallel_results(
tool_use_ids: list[str],
pending_futures: dict[str, asyncio.Future],
) -> list[dict]:
results_by_id: dict[str, dict] = {}
raw = await asyncio.gather(
*[pending_futures[tid] for tid in tool_use_ids],
return_exceptions=True,
)
for tid, outcome in zip(tool_use_ids, raw):
if isinstance(outcome, Exception):
results_by_id[tid] = {
"tool_use_id": tid,
"content": f"[Isolated failure] {type(outcome).__name__}: {outcome}",
"is_error": True,
}
else:
results_by_id[tid] = outcome
return [results_by_id[tid] for tid in tool_use_ids]return_exceptions=True is the key. Without this option, the first exception blows up the entire gather, losing even the results that already succeeded.
2. Partial Failure Isolation in a Fan-out Analysis Pipeline
Imagine a structure where a data collection agent fetches raw data, then fans out to preprocessing, sentiment analysis, topic extraction, and NER sub-agents simultaneously. A common mistake here is spinning up tasks with asyncio.create_task() and then awaiting them one by one with wait_for in a for loop. The tasks run in parallel, but if the first task hits its timeout (60 seconds), the results of tasks that finished in just 1 second are left sitting idle for those 60 seconds. Just as with collect_parallel_results above, unifying on gather + return_exceptions=True is the better approach.
# Conceptual example
import asyncio
ANALYSIS_AGENTS = {
"preprocessing": preprocess_agent_config,
"sentiment": sentiment_agent_config,
"topics": topic_agent_config,
"ner": ner_agent_config,
}
async def run_with_timeout(name, coro, timeout):
try:
return name, await asyncio.wait_for(coro, timeout=timeout)
except Exception as e:
return name, e
async def run_analysis_fanout(raw_data: str) -> dict:
coros = [
run_with_timeout(name, spawn_subagent(cfg, input=raw_data), timeout=60.0)
for name, cfg in ANALYSIS_AGENTS.items()
]
outcomes = await asyncio.gather(*coros) # Individual failures captured in each coroutine
partial_results: dict = {}
failed_agents: list[str] = []
for name, outcome in outcomes:
if isinstance(outcome, Exception):
failed_agents.append(name)
partial_results[name] = None
else:
partial_results[name] = outcome
if failed_agents:
partial_results["_failed"] = failed_agents
return partial_resultsThis way, each sub-agent has its own timeout while preventing one agent's delay from blocking the collection of other results.
Note: Issue #132765 reports a bug where
timeoutSecondsinagents_waitis ignored. Directly controlling timeouts withasyncio.wait_forinside each coroutine is currently more reliable.
3. Preventing Race Conditions with executionMode: "sequential"
MCP tools that share state or tools that modify the filesystem will have race conditions if run in parallel. Declaring executionMode in the tool plugin contract forces the runtime to enforce this.
{
"name": "file_writer",
"description": "Writes results to the local filesystem.",
"executionMode": "sequential",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
}PR #140767 (merged September 2026) fixed a bug where this contract was being ignored in Code Mode. Before that fix, there were cases where declaring executionMode: "sequential" still resulted in parallel execution, so it's worth cross-checking the changelog to confirm whether your current release includes this patch. (The author was unable to find an official basis to pinpoint a specific version number at the time of the PR merge, so no specific version is stated here.)
4. Blocking Repeated Failure Loops with a Watcher
After partial failures are passed to the orchestrator, it sometimes falls into a loop retrying the same error repeatedly. The Watcher component approach from ClawKeeper can prevent this at the system level. Since the interface names and registration methods vary by SDK, think of this as "there needs to be a hook point like this somewhere."
# Conceptual example - check SDK documentation for actual registration method
class RepeatFailureWatcher:
def __init__(self, max_consecutive_failures: int = 3):
self._failure_counts: dict[str, int] = {}
self._threshold = max_consecutive_failures
async def on_upstream_failure(self, event) -> bool:
agent_id = event.agent_id
self._failure_counts[agent_id] = (
self._failure_counts.get(agent_id, 0) + 1
)
# When True is returned, the orchestrator hook that registered this Watcher
# must interpret it to stop retrying the corresponding subtree.
return self._failure_counts[agent_id] >= self._thresholdFor this Watcher to have real effect, (a) the orchestrator's retry loop must call on_upstream_failure, and (b) the orchestrator's flow must be wired to actually stop retrying when the return value is True. Just remember this is not a hook the runtime automatically registers and interprets.
Settings and Limits: Organized with Sources Attached
All figures in the table below are linked to sources the author was able to verify. Items where no reliable source could be found have been omitted or marked "verification needed."
| Item | Value / Condition | Source / How to Verify |
|---|---|---|
| Sub-agent concurrency and recovery policy | Config keys and defaults are updated each release | Sub-agent concurrency, recovery, and stopping |
| Delivery backlog warning and blocking thresholds | Depends on project settings (check defaults) | Steering queue |
| Sub-agent nesting depth | May vary by deployment | Verify in the docs for your current version at Sub-agents |
parallel_tool_calls compatibility |
Causes 400 errors on some OpenAI-compatible providers | Issue #37048 |
| Exec tool timeout handling | Cases exist where the entire run is aborted | Issue #144514 |
Specific figures that were in an earlier draft — such as "maxChildrenPerAgent default 5", "delivery backlog warning 25 / blocking 50", and "max nesting depth 2 levels" — have been removed from this post because the author could not identify source links in the official documentation. Please verify these in the documentation for your version.
The parallel_tool_calls compatibility issue is quite an unexpected pitfall. The issue where v2026.3.2 sent parallel_tool_calls: true to OpenAI-compatible providers, causing 400 error loops, was widely discussed in the community. If you plan to switch providers, be sure to confirm upfront whether the target model supports parallel tool calls.
Tradeoff Summary
Version-dependent issues have been moved to footnotes so that the drawbacks don't appear to remain valid after bugs are fixed.
| Approach | Pros | Conceptual Cons |
|---|---|---|
| Full parallel fan-out | Minimizes latency | Risk of ID mismatch and result collision |
| Partial failure isolation (per-coroutine timeout) | Preserves successful results | Requires isolation contract in orchestrator 1 |
executionMode: "sequential" |
Declaratively prevents race conditions | Reduces tool throughput 2 |
| Parallel Specialist Lanes | No contention for shared capacity | Increases architectural design complexity |
| Watcher loop blocking | Automatic detection of repeated failures | Requires threshold tuning and manual hook wiring |
Practical Notes to Address Now
Issue #108 reported that child results were colliding and being dropped after fan-out completion, and this was fixed in v2026.8.1. Even after the fix, edge cases may remain, so it's good practice to always include an explicit failure marker like the _failed field in orchestrator responses.
One more thing: a deterministic fan-in barrier is not yet built into the runtime. Issue #38433 is registered as a major feature request for 2026, and until then you can implement a fan-in barrier yourself using a third-party library as in this DEV Community example, or achieve the same effect with the asyncio.gather + per-coroutine timeout combination shown above.
In summary, the stability of parallel tool call pipelines comes down to how clearly you understand the boundary between "what the runtime handles" and "where I need to defend." tool_use ID mapping is the runtime's job, but detecting synthetic results and isolating failures is the orchestrator's job, and while executionMode contracts are declared by developers, whether they're honored needs to be re-verified per release. Making this boundary explicit in your code means that when the next release changes something, sessions silently breaking will be far less common.
References
- Agent runtime · OpenClaw official docs
- Sub-agents · OpenClaw official docs
- Sub-agent concurrency, recovery, and stopping · OpenClaw
- Parallel specialist lanes · OpenClaw official docs
- Steering queue · OpenClaw official docs
- Tool plugins · OpenClaw official docs
- PR #140767: fix(agents): honor sequential tools in Code Mode
- Issue #37048: v2026.3.2 sends parallel_tool_calls to unsupported models
- Issue #38433: Feature request — deterministic fan-out/fan-in barrier
- Issue #108: Subagent results lost after fan-out turn-claim collision
- Issue #132765: agents_wait ignores timeoutSeconds
- Issue #144514: Exec tool timeout aborts whole run instead of returning tool error
- v2026.8.1 Other Bug Fixes · OpenClaw
- How I Built a Deterministic Multi-Agent Dev Pipeline Inside OpenClaw — DEV Community
- ClawKeeper: Comprehensive Safety Protection for OpenClaw Agents (arXiv)
- OpenClaw Changelog (September 2026)
Footnotes
-
In certain versions, a bug where
agents_waitignorestimeoutSecondsrequires an additional workaround usingwait_forinside coroutines (#132765). ↩ -
The bug where this contract was ignored in Code Mode was fixed in PR #140767. You need to separately verify whether your current release includes this patch. ↩