Skip to content

feat(runtime): plan_batch + path-overlap planner for tool dispatch (PR-2/6)#3129

Merged
houko merged 1 commit into
mainfrom
feat/parallel-tool-calls-m2
Apr 25, 2026
Merged

feat(runtime): plan_batch + path-overlap planner for tool dispatch (PR-2/6)#3129
houko merged 1 commit into
mainfrom
feat/parallel-tool-calls-m2

Conversation

@houko

@houko houko commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds on PR-1 (feat/parallel-tool-calls-m1). Adds the planner the agent-loop dispatcher will consume in PR-4/5. Still passive — plan_batch has no callers yet, runtime behaviour is unchanged.

What's new

crates/librefang-runtime/src/parallel_dispatch.rs (new):

  • ParallelPlan { groups: Vec<Vec<usize>> } — output shape: each group's calls may run concurrently, groups themselves run in order. Concatenating groups yields 0..N (order preservation — a hard contract for Anthropic's Messages API tool_result alignment). Helpers: call_count, is_fully_sequential.
  • NormalizedPathReal(PathBuf) for filesystem scopes (component-aware prefix overlap) and Virtual(String) for logical scopes (skill::<name>).
  • lexical_clean — folds . / .. without touching the filesystem (target files for WriteScoped calls may not yet exist, so std::fs::canonicalize is unsafe).
  • paths_overlapReal overlap iff component-prefix; Virtual overlap iff string-equal; mixed kinds never overlap.
  • extract_scope_path — projects a NormalizedPath from a WriteScoped call's input. Handles file_write / file_edit / apply_patch (path field) and skill_evolve_* (virtual skill::<name> from the name field).
  • plan_batch — contiguous-bucket scheduler:
    • Any Exclusive call → every call gets its own group (whole batch serialises).
    • Otherwise walk in original order, accumulating into a current bucket. WriteShared flushes + owns its bucket (acts as a barrier — later ReadOnly calls cannot be reordered before it). ReadOnly appends. WriteScoped checks path overlap with reserved scopes; conflict triggers a flush. Calls without a projectable scope sit alone.

The barrier semantics matter

If the model emits read /a, shell, read /c, dispatching [read /a, read /c] + [shell] would let the second read observe pre-shell state — exactly what the model didn't intend. Contiguous bucketing keeps read /c strictly after the shell. This is why I rewrote the greedy O(N²) algorithm from the design plan as a contiguous-pass; it produced visibly wrong groups for that case (caught by the shell_exec_isolated_in_its_bucket test).

Companion classifier fix

tool_classifier::classify_by_name now matches the skill_evolve_ prefix and projects to Mutating. Without this, PR-1 left skill_evolve_* tools at UnknownWriteShared, defeating the virtual scope projection that plan_batch relies on. The fix is one match arm + 1 test (skill_evolve_prefix_is_mutating covering all 6 concrete names plus the skill_evolve non-match case).

Tests

14 new in parallel_dispatch::tests:

Test Asserts
empty_batch_produces_empty_plan empty input → empty plan, fully sequential, count 0
single_call_is_one_group trivial wrap
three_reads_one_group 3 reads on disjoint paths fan out
read_plus_write_disjoint_one_group ReadOnly + WriteScoped on different paths share a group
two_writes_sibling_files_one_group /a/x and /a/y parallelise
parent_child_overlap_splits /a/b and /a/b/c split
component_aware_prefix_does_not_split /a/b and /a/bc parallelise (component-aware)
trailing_slash_and_parent_dir_normalise /a/b/ and /a/b/c/.. resolve to overlap
shell_exec_isolated_in_its_bucket barrier — later reads cannot be reordered before shell
interactive_forces_full_serial one Exclusive → every call its own group
skill_evolve_virtual_scope same skill splits, different skills share
write_scoped_without_path_is_isolated unprojectable scope → per-call bucket
real_vs_virtual_paths_do_not_overlap namespace independence
lexical_clean_handles_dot_and_double_dot /a/./b/a/b, /a/b/../c/a/c

Plus the assert_plan_covers_all helper that asserts groups.iter().flatten() is a permutation of 0..N — used in every plan-shape test as a structural invariant.

1 new in tool_classifier::tests:

  • skill_evolve_prefix_is_mutating

Test plan

  • cargo test -p librefang-runtime --lib -- parallel_dispatch tool_classifier39 ok (24 baseline + 14 new dispatch + 1 new classifier)
  • cargo check --workspace --lib — clean
  • cargo clippy -p librefang-runtime --all-targets -- -D warnings — clean
  • Live integration — N/A for this PR (planner has no callers). Will be exercised in PR-4/5 when the dispatcher is wired up.

Stack

Base: PR-1 (feat/parallel-tool-calls-m1, PR #3127).

PR-3 will add RuntimeConfig.parallel_tools (feature flag); PR-4 wires plan_batch into agent_loop.rs:3410 (non-streaming); PR-5 wires the streaming path; PR-6 fixes the MCP annotations bug + adds mcp_readonly_allowlist.

See .plans/parallel-tool-calls.md for full design.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

@github-actions github-actions Bot added area/runtime Agent loop, LLM drivers, WASM sandbox ready-for-review PR is ready for maintainer review size/L 250-999 lines changed has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Apr 25, 2026
…R-2/6)

New `parallel_dispatch` module — the planner the agent loop's batch
dispatcher will consume in PR-4/5. Still passive: nothing calls
`plan_batch()` yet, so runtime behaviour is unchanged.

Module contents (`crates/librefang-runtime/src/parallel_dispatch.rs`):
- `ParallelPlan { groups: Vec<Vec<usize>> }` — output shape: each
  group's calls may run concurrently, groups themselves run in
  order. Concatenating groups yields 0..N (order preservation, a
  hard contract for Anthropic Messages API tool_result alignment).
  Helpers: `call_count`, `is_fully_sequential`.
- `NormalizedPath` enum: `Real(PathBuf)` for filesystem scopes
  (component-aware prefix overlap) and `Virtual(String)` for tool
  families whose scope is logical (`skill::<name>`).
- `lexical_clean` — folds `.` and `..` without touching the FS,
  since target files for `WriteScoped` calls may not yet exist.
- `paths_overlap` — `Real` paths overlap when one is a Path-level
  prefix of the other; `Virtual` overlap iff string-equal; mixed
  kinds never overlap.
- `extract_scope_path` — projects a `NormalizedPath` from a
  WriteScoped call's input. Handles `file_write` / `file_edit` /
  `apply_patch` (`path` field) and `skill_evolve_*` (virtual
  `skill::<name>` from the `name` field).
- `plan_batch` — contiguous-bucket scheduler:
  * empty / single → trivial.
  * any Exclusive → every call gets its own group (whole batch
    serialises).
  * walk in original order, accumulating into a `current` bucket.
    `WriteShared` flushes + owns its bucket (acts as a barrier so
    later ReadOnly calls cannot be reordered before it). `ReadOnly`
    appends. `WriteScoped` checks path overlap with the bucket's
    reserved scopes and flushes on conflict; an unprojectable scope
    is treated as a per-call bucket.

The barrier semantics are the key correctness property: if the model
emits `read /a, shell, read /c`, dispatching `[read /a, read /c] +
[shell]` would let the second read observe pre-shell state — exactly
what the model didn't intend. Contiguous bucketing keeps `read /c`
strictly after the shell.

Companion fix: `tool_classifier::classify_by_name` now matches the
`skill_evolve_` prefix and projects to `Mutating`. Without this PR-1
left those tools at `Unknown` -> `WriteShared`, defeating the virtual
scope projection that `plan_batch` relies on.

Tests: 14 new in `parallel_dispatch::tests`:
- order preservation helper `assert_plan_covers_all`
- empty / single-call shapes
- `three_reads_one_group`
- `read_plus_write_disjoint_one_group`
- `two_writes_sibling_files_one_group`
- `parent_child_overlap_splits`
- `component_aware_prefix_does_not_split` (`/a/b` vs `/a/bc`)
- `trailing_slash_and_parent_dir_normalise`
- `shell_exec_isolated_in_its_bucket` (barrier semantics)
- `interactive_forces_full_serial`
- `skill_evolve_virtual_scope` (same skill splits, different skills
  share a group)
- `write_scoped_without_path_is_isolated`
- `real_vs_virtual_paths_do_not_overlap`
- `lexical_clean_handles_dot_and_double_dot`

Plus 1 new in `tool_classifier::tests`:
- `skill_evolve_prefix_is_mutating` (covers all 6 concrete names +
  the bare `skill_evolve` non-match case)

Verification:
- cargo test -p librefang-runtime --lib -- parallel_dispatch
  tool_classifier: 39 ok (24 baseline + 14 new dispatch + 1 new
  classifier)
- cargo check --workspace --lib: clean
- cargo clippy -p librefang-runtime --all-targets -- -D warnings:
  clean

Stack: this PR is based on feat/parallel-tool-calls-m1 (PR #3127).
PR-3 will add `RuntimeConfig.parallel_tools` (feature flag); PR-4
wires `plan_batch` into `agent_loop.rs:3410`.
@houko
houko force-pushed the feat/parallel-tool-calls-m2 branch from e11b4e6 to d427b72 Compare April 25, 2026 13:47
@houko
houko merged commit ea55f9b into main Apr 25, 2026
13 checks passed
@houko
houko deleted the feat/parallel-tool-calls-m2 branch April 25, 2026 13:48
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Agent loop, LLM drivers, WASM sandbox ready-for-review PR is ready for maintainer review size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant