Skip to content
Open
704 changes: 554 additions & 150 deletions core/src/agents/functions.ts

Large diffs are not rendered by default.

57 changes: 48 additions & 9 deletions core/src/agents/llm_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
generateRequestConfirmationEvent,
getLongRunningFunctionCalls,
handleFunctionCallsAsync,
mergeParallelFunctionResponseEvents,
populateClientFunctionCallId,
} from './functions.js';

Expand Down Expand Up @@ -831,23 +832,64 @@ export class LlmAgent extends BaseAgent {
// Call functions
// TODO - b/425992518: bloated funciton input, fix.
// Tool callback passed to get rid of cyclic dependency.
const functionResponseEvent = await handleFunctionCallsAsync({
const functionResponseEvents: Event[] = [];
for await (const functionResponseEvent of handleFunctionCallsAsync({
invocationContext: invocationContext,
functionCallEvent: mergedEvent,
toolsDict: llmRequest.toolsDict,
beforeToolCallbacks: this.canonicalBeforeToolCallbacks,
afterToolCallbacks: this.canonicalAfterToolCallbacks,
});
})) {
functionResponseEvents.push(functionResponseEvent);
yield functionResponseEvent;
}

if (!functionResponseEvent) {
if (!functionResponseEvents.length) {
return;
}

const mergedFunctionResponseEvent = mergeParallelFunctionResponseEvents(
functionResponseEvents,
);

// Persist an internal completion marker for streamed parallel tool batches.
// This allows resumption logic to distinguish complete vs partial batches.
if ((getFunctionCalls(mergedEvent)?.length ?? 0) > 1) {
const functionCallIds = new Set(
getFunctionCalls(mergedEvent)
.map((fc) => fc.id)
.filter(Boolean),
);
const longRunningIds = new Set(mergedEvent.longRunningToolIds ?? []);
const expectedResponseCount = Array.from(functionCallIds).filter(
(id): id is string => !!id && !longRunningIds.has(id),
).length;
const completionEvent = createEvent({
invocationId: invocationContext.invocationId,
author: invocationContext.agent.name,
branch: invocationContext.branch,
actions: createEventActions({
customMetadata: {
parallelToolBatchCompletion: {
functionCallEventId: mergedEvent.id,
expectedResponseCount,
},
},
}),
});
if (invocationContext.sessionService) {
await invocationContext.sessionService.appendEvent({
session: invocationContext.session,
event: completionEvent,
});
}
}

// Yiels an authentication event if any.
// TODO - b/425992518: transaction log session, simplify.
const authEvent = generateAuthEvent(
invocationContext,
functionResponseEvent,
mergedFunctionResponseEvent,
);
if (authEvent) {
yield authEvent;
Expand All @@ -857,19 +899,16 @@ export class LlmAgent extends BaseAgent {
const toolConfirmationEvent = generateRequestConfirmationEvent({
invocationContext: invocationContext,
functionCallEvent: mergedEvent,
functionResponseEvent: functionResponseEvent,
functionResponseEvent: mergedFunctionResponseEvent,
});
if (toolConfirmationEvent) {
yield toolConfirmationEvent;
invocationContext.endInvocation = true;
return;
}

// Yields the function response event.
yield functionResponseEvent;

// If model instruct to transfer to an agent, run the transferred agent.
const nextAgentName = functionResponseEvent.actions.transferToAgent;
const nextAgentName = mergedFunctionResponseEvent.actions.transferToAgent;
if (nextAgentName) {
const nextAgent = this.getAgentByName(invocationContext, nextAgentName);
for await (const event of nextAgent.runAsync(invocationContext)) {
Expand Down
39 changes: 37 additions & 2 deletions core/src/agents/run_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,44 @@ export interface RunConfig {
maxLlmCalls?: number;

/**
* If true, the agent loop will suspend on ANY tool call, allowing the client
* to intercept and execute tools (Client-Side Tool Execution).
* Controls whether multiple tool calls from a single LLM response are
* executed concurrently (true) or sequentially (false).
*
* When true: tool calls run via Promise.allSettled, matching
* adk-python's asyncio.gather pattern. Individual failures don't affect
* other calls.
*
* When false (default): tool calls execute one-by-one in order, preserving
* backward compatibility for tools with interdependencies, shared state
* mutations, or deterministic ordering requirements.
*
* @default false
*/
parallelToolExecution?: boolean;

/**
* When true, execution pauses after receiving tool calls from the model,
* allowing client-side tool execution patterns.
*
* @default false
*/
pauseOnToolCalls?: boolean;

/**
* Maximum number of tool calls to execute concurrently when
* `parallelToolExecution` is true.
*
* When set to a positive integer, tool calls are dispatched in batches of
* this size — each batch runs via Promise.allSettled, and the next batch
* starts only after the current one settles. This provides back-pressure
* for rate-limited APIs or resource-constrained environments.
*
* When undefined or <= 0, all tool calls run concurrently (no limit).
* Ignored when `parallelToolExecution` is false.
*
* @default undefined
*/
maxConcurrentToolCalls?: number;
}

export function createRunConfig(params: Partial<RunConfig> = {}) {
Expand All @@ -108,6 +142,7 @@ export function createRunConfig(params: Partial<RunConfig> = {}) {
enableAffectiveDialog: false,
streamingMode: StreamingMode.NONE,
maxLlmCalls: validateMaxLlmCalls(params.maxLlmCalls || 500),
parallelToolExecution: false,
pauseOnToolCalls: false,
...params,
};
Expand Down
35 changes: 34 additions & 1 deletion core/src/events/event_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {isPlainObject} from 'lodash-es';

import {ToolConfirmation} from '../tools/tool_confirmation.js';

// TODO: b/425992518 - Replace 'any' with a proper AuthConfig.
Expand Down Expand Up @@ -59,6 +61,11 @@ export interface EventActions {
* call id.
*/
requestedToolConfirmations: {[key: string]: ToolConfirmation};

/**
* 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?

}

/**
Expand All @@ -72,6 +79,7 @@ export function createEventActions(
artifactDelta: {},
requestedAuthConfigs: {},
requestedToolConfirmations: {},
customMetadata: {},
...state,
};
}
Expand All @@ -85,6 +93,25 @@ export function createEventActions(
* 2. For other properties (skipSummarization,transferToAgent, escalate), the
* last one wins.
*/
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;
}
}

Comment on lines +96 to +114
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.

export function mergeEventActions(
sources: Array<Partial<EventActions>>,
target?: EventActions,
Expand All @@ -99,7 +126,7 @@ export function mergeEventActions(
if (!source) continue;

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?

}
if (source.artifactDelta) {
Object.assign(result.artifactDelta, source.artifactDelta);
Expand All @@ -113,6 +140,12 @@ export function mergeEventActions(
source.requestedToolConfirmations,
);
}
if (source.customMetadata) {
result.customMetadata = Object.assign(
result.customMetadata ?? {},
source.customMetadata,
);
}
Comment on lines +143 to +148
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.


if (source.skipSummarization !== undefined) {
result.skipSummarization = source.skipSummarization;
Expand Down
Loading
Loading