When a model emits several tool calls in one turn, firing them all concurrently is the whole point - but only some of them are actually safe to run together. Two calls that read the same file are fine. A read racing a write on that file is not, and neither is a call that needs the output of another.
Write the scheduler that packs a batch of tool calls into the fewest safe waves.
Each call declares the resource it touches (a file path, a table, an API namespace) and its mode, either "read" or "write". Two calls in the same wave conflict when they name the same resource and at least one of them is a write. Read/read never conflicts - that is the parallelism you are trying to harvest.
Calls may also declare depends_on, a list of call ids whose results they consume. A dependency must complete in a strictly earlier wave.
The scheduler is greedy and order-sensitive, which keeps it deterministic:
If a pass places no calls at all, the batch is unschedulable - a dependency cycle, or a dependency on an id that is not in the batch.
Implement:
def plan_parallel_waves(calls):
Return list[list[str]] - one inner list per wave, holding that wave's call ids sorted ascending. Return [] if the batch is unschedulable.
Returns a list of lists of strings. Note the greedy packing uses the original order, but each wave is reported sorted.
calls = [
{"id": "c1", "tool": "read_file", "resource": "cfg", "mode": "read"},
{"id": "c2", "tool": "read_file", "resource": "cfg", "mode": "read"},
{"id": "c3", "tool": "write_file", "resource": "cfg", "mode": "write"},
]
print(plan_parallel_waves(calls))
# [['c1', 'c2'], ['c3']]
c1 and c2 both only read cfg, so they share a wave; c3 writes it and is pushed to the next.
calls = [{'id':'c1','tool':'read_file','resource':'cfg','mode':'read'},{'id':'c2','tool':'read_file','resource':'cfg','mode':'read'},{'id':'c3','tool':'write_file','resource':'cfg','mode':'write'}]
print(plan_parallel_waves(calls))[['c1', 'c2'], ['c3']]
Wave 1 starts with c1 (read cfg). c2 also only reads cfg, so read/read is safe and it joins. c3 writes cfg, which conflicts with both reads already in the wave, so it waits. Wave 2 contains c3 alone.
0 <= len(calls) <= 300; ids are unique stringsmode is "read" or "write"; depends_on defaults to []resource and at least one write[] when no progress is possible (cycle or missing dependency id)[]