Skip to content

feat: add parallel tool orchestration with bounded concurrency#167

Open
bgauryy wants to merge 10 commits intogoogle:mainfrom
bgauryy:adding-parallel-tools-execution
Open

feat: add parallel tool orchestration with bounded concurrency#167
bgauryy wants to merge 10 commits intogoogle:mainfrom
bgauryy:adding-parallel-tools-execution

Conversation

@bgauryy
Copy link
Copy Markdown

@bgauryy bgauryy commented Feb 27, 2026

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

  • Closes: #issue_number
  • Related: #issue_number

2. Or, if no issue exists, describe the change:

If applicable, please follow the issue templates to provide as much detail as
possible.

Problem:
A clear and concise description of what the problem is.

Solution:
A clear and concise description of what you want to happen and why you choose
this solution.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required
for all PRs that are not small documentation or typo fixes.

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Please include a summary of passed npm test results.

Manual End-to-End (E2E) Tests:

Please provide instructions on how to manually test your changes, including any
necessary setup or configuration. Please provide logs or screenshots to help
reviewers better understand the fix.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Add any other context or screenshots about the feature request here.

@bgauryy
Copy link
Copy Markdown
Author

bgauryy commented Feb 27, 2026

Parallel Tool Orchestration in ADK-JS

  • Date: 2026-02-22

Overview

This change adds optional parallel tool execution with bounded concurrency to ADK-JS, while keeping sequential mode as the default.

  • Faster multi-tool turns when tools are independent
  • Per-tool error isolation — one failing tool does not crash the turn
  • Deep merge for nested stateDelta across tool responses
  • Configurable concurrency cap via maxConcurrentToolCalls

How It Works

Enabling parallel mode

runner.runAsync({
  runConfig: {
    parallelToolExecution: true,       // opt-in; default is false
    maxConcurrentToolCalls: 4,         // optional; omit for no limit
  },
});

The config flows through createRunConfigInvocationContexthandleFunctionCallList, which reads runConfig.parallelToolExecution to select the dispatch strategy.

Dispatch flow

  1. The LLM agent detects function calls in the model response.
  2. handleFunctionCallList dispatches them:
    • Parallel mode (parallelToolExecution: true): all calls run via Promise.allSettled, optionally batched by maxConcurrentToolCalls.
    • Sequential mode (parallelToolExecution: false, default): calls run one-by-one with per-call error isolation.
  3. Each call runs through executeSingleFunctionCall:
    • Deep-copies args via structuredClone
    • Resolves tool + creates ToolContext
    • Runs plugin and canonical before/after callbacks
    • Executes the tool
    • Builds a response event
  4. All response events are merged into a single event via mergeParallelFunctionResponseEvents.

Result ordering

Promise.allSettled returns results in input order, not completion order. Batched mode concatenates batches sequentially. The final event order always matches the original FunctionCall[] order regardless of individual tool latency.


What Changed

New RunConfig fields (added — not in Python ADK)

Field Type Default Location
parallelToolExecution boolean? false run_config.ts interface + createRunConfig defaults
maxConcurrentToolCalls number? undefined (no limit) run_config.ts interface only (no default in createRunConfig)
  • parallelToolExecution controls whether tools run concurrently or sequentially.
  • maxConcurrentToolCalls caps how many parallel tools fire at once. When undefined or <= 0, all tools dispatch simultaneously. Fractional values are floored via Math.floor. Only applies when parallelToolExecution is true.

core/src/agents/functions.ts (modified)

  • Extracted executeSingleFunctionCall — canonical single-call pipeline shared by both dispatch modes.
  • Added dispatchParallel — runs tools via Promise.allSettled, optionally batched by maxConcurrentToolCalls.
  • Added dispatchSequential — runs tools one-by-one with per-call try/catch error isolation.
  • Added executeInBatches — dispatches tool call tasks in fixed-size batches when maxConcurrentToolCalls is set.
  • Added executeSingleFunctionCall error recovery — if a tool is not in toolsDict, the plugin onToolErrorCallback is invoked before throwing.
  • Added structuredClone for args — deep-copies functionCall.args before execution, preventing callback/tool mutations from leaking back to the original FunctionCall.
  • Added detectStateDeltaConflicts — warns when parallel tools write overlapping stateDelta keys, labeling each as (deep-merged) or (last-write-wins). Uses the same lodash isPlainObject check as deepMergeStateDelta to ensure warning labels match actual merge behavior.
  • Added createErrorResponseEvent — builds error response events for failed tool calls (used by both dispatch modes).
  • Modified mergeParallelFunctionResponseEvents — already existed, but now merges actions via mergeEventActions which uses the new deep merge for stateDelta.

core/src/events/event_actions.ts (modified)

  • Added deepMergeStateDelta — replaced shallow Object.assign for stateDelta with a recursive merge using lodash isPlainObject guard:
    • Plain objects → recursive deep merge (creates a shallow copy at each level, then recurses)
    • Arrays, primitives, undefined → last-write-wins via direct assignment

When to Use Each Mode

Use parallel mode when:

  • Tools are independent (no shared side effects)
  • Latency matters (e.g., multiple API calls)
  • You want failure isolation by default

Use sequential mode when:

  • Tools depend on side effects from earlier tools
  • Callback logic assumes strict execution order
  • You need deterministic ordering of state mutations

Caveats

Sequential mode behavior change

Previously, a tool failure in sequential mode would throw and abort the entire turn. Now, failures produce error response events and execution continues to the next tool. Callers that relied on thrown exceptions from handleFunctionCallsAsync should be aware of this change.

structuredClone limitations

structuredClone is used on functionCall.args before execution. If you pass non-cloneable values (functions, Symbols, WeakMaps) as args programmatically, a DataCloneError will be thrown. LLM-generated args are always JSON-serializable and unaffected.

stateDelta conflict warnings

In parallel mode, a warning fires when multiple tools write to the same top-level stateDelta key. Two tools writing different sub-keys of the same parent object (e.g., {user: {name}} and {user: {age}}) will trigger the warning but merge correctly via deep merge. Treat the warning as informational.

Concurrent callbacks

In parallel mode, beforeToolCallback and afterToolCallback fire concurrently across tool calls. Callbacks must not depend on cross-call execution order or shared mutable state.

stateDelta merge semantics

  • Nested plain objects are recursively deep-merged
  • Arrays are not element-merged — last write wins
  • Explicit undefined writes are preserved (clearing a key by setting it to undefined works)

Comparison with Python ADK

Feature Python ADK JS ADK
Default mode Always parallel (asyncio.gather) Sequential; opt-in parallel
Error isolation asyncio.gather propagates first error Promise.allSettled isolates per-tool
Concurrency cap Not available maxConcurrentToolCalls batching
Args protection copy.deepcopy structuredClone
stateDelta merge deep_merge_dicts (mutates in place) deepMergeStateDelta (immutable copies per level)
Conflict detection None Warning with resolution labels
Sequential fallback Not available Full sequential mode with error isolation

mergeParallelFunctionResponseEvents was calling createEvent without
passing invocationId, causing the merged event to default to ''.
Single-tool responses were unaffected (returned as-is), but any
multi-tool parallel turn produced an event with an empty invocationId,
breaking session history, tracing, and downstream invocation filtering.

Added a regression test that asserts the merged event carries the
correct invocationId across a two-tool parallel execution.
} catch (e) {
toolContext = new ToolContext({
invocationContext,
functionCallId: functionCall.id || undefined,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it be just functionCallId: functionCall.id
Why do we need || undefined?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behind type - fixed

parts: parts,
role: functionResponseEvent.content!.role,
},
actions: functionResponseEvent.actions,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to remove this?

Copy link
Copy Markdown
Author

@bgauryy bgauryy Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See main comment

Actions on confirmation event

Before: actions: functionResponseEvent.actions copied the full merged actions onto the confirmation event.

Problem: In the streaming path each individual tool-response event is yielded and appended one by one. appendEvent applies stateDelta for every event. Keeping the full actions on the confirmation event double-applied stateDelta.

Why we can't just remove it entirely: requestedToolConfirmations is not a state mutation and is not applied by appendEvent, but it is read by toStructuredEvents to emit the TOOL_CONFIRMATION signal. Without it consumers never receive the confirmation pause signal.

Fix: actions: createEventActions({ requestedToolConfirmations: functionResponseEvent.actions.requestedToolConfirmations }) carries only what consumers need, nothing that mutates state.

Test: "carries only requestedToolConfirmations stateDelta/artifactDelta/transferToAgent are not copied"
Builds a functionResponseEvent with non-empty stateDelta, artifactDelta, and transferToAgent alongside requestedToolConfirmations, then asserts the confirmation event has empty deltas and no transferToAgent.

@bgauryy
Copy link
Copy Markdown
Author

bgauryy commented Mar 3, 2026

@kalenkevich

I fixed all issues and run real test on my side
Agents now can run parallel tools (and return partial results)
Much more efficient now! (need it for a project I'm working on)

Review Fixes PR #167

1. structuredClonecloneDeep

Replaced structuredClone(functionCall.args) with cloneDeep from lodash-es.

Test: "args containing non-serializable values do not throw (cloneDeep, not structuredClone)"
Passes a {fn: () => ...} value in args — structuredClone would throw DataCloneError; cloneDeep copies it without throwing and preserves the function reference.

2. functionCall.id || undefinedfunctionCall.id

Removed the redundant || undefined guard functionCall.id is already typed

Test: "functionCall.id is preserved verbatim in functionResponse.id"
Asserts that the id set on the input FunctionCall round-trips unchanged into functionResponse.id on the resulting event.

3. actions on confirmation event

Before: actions: functionResponseEvent.actions copied the full merged actions onto the confirmation event.

Problem: In the streaming path each individual tool-response event is yielded and appended one by one. appendEvent applies stateDelta for every event. Keeping the full actions on the confirmation event double-applied stateDelta.

Why we can't just remove it entirely: requestedToolConfirmations is not a state mutation and is not applied by appendEvent, but it is read by toStructuredEvents to emit the TOOL_CONFIRMATION signal. Without it consumers never receive the confirmation pause signal.

Fix: actions: createEventActions({ requestedToolConfirmations: functionResponseEvent.actions.requestedToolConfirmations }) carries only what consumers need, nothing that mutates state.

Test: "carries only requestedToolConfirmations — stateDelta/artifactDelta/transferToAgent are not copied"
Builds a functionResponseEvent with non-empty stateDelta, artifactDelta, and transferToAgent alongside requestedToolConfirmations, then asserts the confirmation event has empty deltas and no transferToAgent.

@bgauryy
Copy link
Copy Markdown
Author

bgauryy commented Mar 9, 2026

@kalenkevich
What do you think?
This fix will allow adk to call several tools, which is essential for none simple flows (works perfectly for in my local implementation).

/**
* Optional metadata for framework-level runtime coordination.
*/
customMetadata?: {[key: string]: unknown};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this field in eventActions?


if (source.stateDelta) {
Object.assign(result.stateDelta, source.stateDelta);
deepMergeStateDelta(result.stateDelta, source.stateDelta);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use cloneDeep from 'lodash-es' instead?

Comment on lines +143 to +145
if (source.customMetadata) {
Object.assign(result.customMetadata ?? {}, source.customMetadata);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure that we need this at all.

Comment on lines +96 to +114
function deepMergeStateDelta(
target: Record<string, unknown>,
source: Record<string, unknown>,
): void {
for (const [key, srcValue] of Object.entries(source)) {
const targetValue = target[key];

if (isPlainObject(targetValue) && isPlainObject(srcValue)) {
const nestedTarget = {...(targetValue as Record<string, unknown>)};
deepMergeStateDelta(nestedTarget, srcValue as Record<string, unknown>);
target[key] = nestedTarget;
continue;
}

// Preserve explicit undefined writes as last-write-wins for clear semantics.
target[key] = srcValue;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants