Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function build({
if (platform === 'browser' && bundle) {
buildOptions.alias = {
'node:async_hooks': './src/utils/async_hooks_shim.ts',
'node:crypto': './src/utils/crypto_shim.ts',
};
}

Expand Down
14 changes: 12 additions & 2 deletions core/src/a2a/a2a_remote_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,12 @@ export class RemoteA2AAgent extends BaseAgent<RemoteA2AAgentConfig> {
}
}

const adkEvent = toAdkEvent(chunk, context.invocationId, this.name);
const adkEvent = toAdkEvent(
chunk,
context.invocationId,
this.name,
context.branch,
);
if (!adkEvent) {
continue;
}
Expand All @@ -242,7 +247,12 @@ export class RemoteA2AAgent extends BaseAgent<RemoteA2AAgentConfig> {
await callback(context, result);
}
}
const adkEvent = toAdkEvent(result, context.invocationId, this.name);
const adkEvent = toAdkEvent(
result,
context.invocationId,
this.name,
context.branch,
);
if (adkEvent) {
processor.updateCustomMetadata(adkEvent, result);
yield adkEvent;
Expand Down
34 changes: 28 additions & 6 deletions core/src/a2a/event_converter_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,30 +75,34 @@ export function toA2AMessage(
* status update).
* @param invocationId - The ADK invocation ID to attach to the resulting event.
* @param agentName - The name of the agent to use as the event author.
* @param branch - The local invocation's branch to attach to the resulting
* event. Must come from the caller's own `InvocationContext`, never from
* the A2A peer: see the comment on `createAdkEventFromMetadata` for why.
* @returns The converted ADK event, or `undefined` if the A2A event type
* produces no content.
*/
export function toAdkEvent(
event: A2AEvent,
invocationId: string,
agentName: string,
branch?: string,
): AdkEvent | undefined {
if (isMessage(event)) {
return messageToAdkEvent(event, invocationId, agentName);
return messageToAdkEvent(event, invocationId, agentName, branch);
}

if (isTask(event)) {
return taskToAdkEvent(event, invocationId, agentName);
return taskToAdkEvent(event, invocationId, agentName, branch);
}

if (isTaskArtifactUpdateEvent(event)) {
return artifactUpdateToAdkEvent(event, invocationId, agentName);
return artifactUpdateToAdkEvent(event, invocationId, agentName, branch);
}

if (isTaskStatusUpdateEvent(event)) {
return event.final
? finalTaskStatusUpdateToAdkEvent(event, invocationId, agentName)
: taskStatusUpdateToAdkEvent(event, invocationId, agentName);
? finalTaskStatusUpdateToAdkEvent(event, invocationId, agentName, branch)
: taskStatusUpdateToAdkEvent(event, invocationId, agentName, branch);
}

return undefined;
Expand All @@ -108,6 +112,7 @@ function messageToAdkEvent(
msg: Message,
invocationId: string,
agentName: string,
branch?: string,
): AdkEvent {
const parts = toGenAIParts(msg.parts);
const content =
Expand All @@ -121,6 +126,7 @@ function messageToAdkEvent(
...createAdkEventFromMetadata(msg),
invocationId,
author: msg.role === MessageRole.USER ? MessageRole.USER : agentName,
branch,
content,
turnComplete: true,
partial: false,
Expand All @@ -131,6 +137,7 @@ function artifactUpdateToAdkEvent(
a2aEvent: TaskArtifactUpdateEvent,
invocationId: string,
agentName: string,
branch?: string,
): AdkEvent | undefined {
const partsToConvert = a2aEvent.artifact?.parts || [];
if (partsToConvert.length === 0) {
Expand All @@ -146,6 +153,7 @@ function artifactUpdateToAdkEvent(
...createAdkEventFromMetadata(a2aEvent),
invocationId,
author: agentName,
branch,
content: createModelContent(toGenAIParts(partsToConvert)),
longRunningToolIds: getLongRunningToolIDs(partsToConvert),
partial,
Expand All @@ -156,6 +164,7 @@ function finalTaskStatusUpdateToAdkEvent(
a2aEvent: TaskStatusUpdateEvent,
invocationId: string,
agentName: string,
branch?: string,
): AdkEvent | undefined {
const partsToConvert = a2aEvent.status.message?.parts || [];
if (partsToConvert.length === 0) {
Expand All @@ -170,6 +179,7 @@ function finalTaskStatusUpdateToAdkEvent(
...createAdkEventFromMetadata(a2aEvent),
invocationId,
author: agentName,
branch,
errorMessage: isFailedTask
? getFailedTaskStatusUpdateEventError(a2aEvent)
: undefined,
Expand All @@ -183,6 +193,7 @@ function taskStatusUpdateToAdkEvent(
a2aEvent: TaskStatusUpdateEvent,
invocationId: string,
agentName: string,
branch?: string,
): AdkEvent | undefined {
const msg = a2aEvent.status.message;
if (!msg) {
Expand All @@ -198,6 +209,7 @@ function taskStatusUpdateToAdkEvent(
...createAdkEventFromMetadata(a2aEvent),
invocationId,
author: agentName,
branch,
content: createModelContent(parts),
turnComplete: false,
partial: true,
Expand All @@ -208,6 +220,7 @@ function taskToAdkEvent(
a2aTask: Task,
invocationId: string,
agentName: string,
branch?: string,
): AdkEvent | undefined {
const parts: GenAIPart[] = [];
const longRunningToolIds: string[] = [];
Expand Down Expand Up @@ -243,6 +256,7 @@ function taskToAdkEvent(
...createAdkEventFromMetadata(a2aTask),
invocationId,
author: agentName,
branch,
content: isFailed ? undefined : createModelContent(parts),
errorMessage: isFailed
? getFailedTaskStatusUpdateEventError(a2aTask)
Expand All @@ -261,7 +275,15 @@ function createAdkEventFromMetadata(a2aEvent: A2AEvent): AdkEvent {
const metadata = a2aEvent.metadata || {};

return createEvent({
branch: metadata[A2AMetadataKeys.BRANCH] as string,
// `branch` is intentionally NOT restored from peer metadata here (unlike
// the other fields below): it is the mechanism getContents() (see
// content_processor_utils.ts) uses to keep sibling sub-agent branches'
// conversation contexts isolated from each other. A remote A2A peer that
// controls its own outgoing metadata could otherwise forge `adk_branch`
// (set it to a shared ancestor branch, or omit it) to leak its content
// into an unrelated sibling agent's LLM context. Every caller of the
// `*ToAdkEvent` functions in this file force-sets `branch` from its own
// local `InvocationContext` instead, the same way `author` is handled.
author: metadata[A2AMetadataKeys.AUTHOR] as string,
partial: metadata[A2AMetadataKeys.PARTIAL] as boolean,
errorCode: metadata[A2AMetadataKeys.ERROR_CODE] as string,
Expand Down
7 changes: 6 additions & 1 deletion core/src/agents/llm_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {BaseTool, isBaseTool} from '../tools/base_tool.js';
import {BaseToolset} from '../tools/base_toolset.js';

import {logger} from '../utils/logger.js';
import {canUseOutputSchemaWithTools} from '../utils/output_schema_utils.js';
import {Context} from './context.js';

import {
Expand Down Expand Up @@ -787,7 +788,11 @@ export class LlmAgent extends BaseAgent<LlmAgentConfig> {
// TODO - b/425992518: check if tool preprocessors can be simplified.
// Run pre-processors for tools.
const allTools = [...this.tools];
if (this.outputSchema && allTools.length > 0) {
if (
this.outputSchema &&
allTools.length > 0 &&
!canUseOutputSchemaWithTools(this.canonicalModel.model)
) {
const setModelResponseTool = new FunctionTool({
name: 'set_model_response',
description:
Expand Down
10 changes: 9 additions & 1 deletion core/src/agents/processors/basic_llm_request_processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import {Event} from '../../events/event.js';
import {LlmRequest, setOutputSchema} from '../../models/llm_request.js';
import {canUseOutputSchemaWithTools} from '../../utils/output_schema_utils.js';
import {InvocationContext} from '../invocation_context.js';
import {isLlmAgent} from '../llm_agent.js';
import {BaseLlmRequestProcessor} from './base_llm_processor.js';
Expand Down Expand Up @@ -37,7 +38,14 @@ export class BasicLlmRequestProcessor extends BaseLlmRequestProcessor {
llmRequest.model = agent.canonicalModel.model;

llmRequest.config = {...(agent.generateContentConfig ?? {})};
if (agent.outputSchema && (!agent.tools || agent.tools.length === 0)) {
// Models that cannot take an output schema alongside tools get the
// prompt-based `set_model_response` workaround instead, injected by
// `LlmAgent.runOneStepAsync` and the instructions processor.
if (
agent.outputSchema &&
(!agent.tools?.length ||
canUseOutputSchemaWithTools(agent.canonicalModel.model))
) {
setOutputSchema(llmRequest, agent.outputSchema);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import {Event} from '../../events/event.js';
import {appendInstructions, LlmRequest} from '../../models/llm_request.js';
import {canUseOutputSchemaWithTools} from '../../utils/output_schema_utils.js';
import {injectSessionState} from '../instructions.js';
import {InvocationContext} from '../invocation_context.js';
import {isLlmAgent} from '../llm_agent.js';
Expand Down Expand Up @@ -62,7 +63,11 @@ export class InstructionsLlmRequestProcessor extends BaseLlmRequestProcessor {
appendInstructions(llmRequest, [instructionWithState]);
}

if (agent.outputSchema && agent.tools && agent.tools.length > 0) {
if (
agent.outputSchema &&
agent.tools?.length &&
!canUseOutputSchemaWithTools(agent.canonicalModel.model)
) {
appendInstructions(llmRequest, [
'To output the final result, you must call the "set_model_response" function with the appropriate values. Do not output anything else.',
]);
Expand Down
17 changes: 16 additions & 1 deletion core/src/code_executors/unsafe_local_code_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ const POWERSHELL_BASE_ARGS = [
*/
const CMD_BASE_ARGS = ['/D', '/c'] as const;

/**
* Whether `commandPath` names Windows PowerShell (`powershell`) or PowerShell
* 7+ (`pwsh`). `path.win32` splits on both separators on every platform.
*/
function isPowerShellCommand(commandPath: string): boolean {
return /^(powershell|pwsh)(\.exe)?$/i.test(path.win32.basename(commandPath));
}

/**
* Options for UnsafeLocalCodeExecutor.
*/
Expand All @@ -56,6 +64,10 @@ export interface UnsafeLocalCodeExecutorOptions {
pythonCommandPath?: string;
/**
* The command to run Shell code. Default is `bash`.
*
* When it names `powershell` or `pwsh` (with or without `.exe`) the script
* is written as `.ps1` and run through PowerShell rather than as a bare
* shell script.
*/
shellCommandPath?: string;
}
Expand Down Expand Up @@ -98,6 +110,9 @@ function getExtensionForLanguage(
}

if (language === CodeExecutionLanguage.SHELL) {
if (shellCommandPath && isPowerShellCommand(shellCommandPath)) {
return '.ps1';
}
if (IS_WINDOWS) {
if (shellCommandPath && shellCommandPath.toLowerCase().includes('cmd')) {
return '.bat';
Expand Down Expand Up @@ -187,7 +202,7 @@ export class UnsafeLocalCodeExecutor extends BaseCodeExecutor {
command = this.pythonCommandPath;
} else if (language === CodeExecutionLanguage.SHELL) {
command = this.shellCommandPath;
if (this.shellCommandPath.toLowerCase().includes('powershell')) {
if (isPowerShellCommand(this.shellCommandPath)) {
args = [...POWERSHELL_BASE_ARGS, filePath];
} else if (this.shellCommandPath.toLowerCase().includes('cmd')) {
args = [...CMD_BASE_ARGS, filePath];
Expand Down
8 changes: 7 additions & 1 deletion core/src/memory/vertex_ai_memory_bank_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {Content, createUserContent} from '@google/genai';
import {Event} from '../events/event.js';
import {Session} from '../sessions/session.js';
import {logger} from '../utils/logger.js';
import {getExpressModeApiKey} from '../utils/vertex_ai_utils.js';
import {
EXPRESS_MODE_UNSUPPORTED_MESSAGE,
getExpressModeApiKey,
} from '../utils/vertex_ai_utils.js';
import {
BaseMemoryService,
SearchMemoryRequest,
Expand Down Expand Up @@ -143,6 +146,9 @@ export class VertexAiMemoryBankService implements BaseMemoryService {
if (options.client) {
this.memories = options.client.agentEnginesInternal.memories;
} else {
if (this.expressModeApiKey && (!this.projectId || !this.location)) {
throw new Error(EXPRESS_MODE_UNSUPPORTED_MESSAGE);
}
const client = new Client({
project: this.projectId,
location: this.location,
Expand Down
6 changes: 4 additions & 2 deletions core/src/plugins/base_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,10 @@ export abstract class BasePlugin {
* @param params.invocationContext The context for the entire invocation.
* @param params.event The event raised by the runner.
* @returns An optional value. A non-`undefined` return may be used by the
* framework to modify or replace the response. Returning `undefined`
* allows the original response to be used.
* framework to modify or replace the response. Copy `params.event` when
* constructing a replacement to preserve fields that are not being
* modified, such as event actions. Returning `undefined` allows the
* original response to be used.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async onEventCallback(params: {
Expand Down
28 changes: 19 additions & 9 deletions core/src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,24 +412,34 @@ export class Runner {
return;
}

if (!event.partial) {
await this.sessionService.appendEvent({session, event});
}
// Step 3: Run the on_event callbacks to optionally modify the event.
// Step 3: Run the on_event callbacks before persisting so callback
// changes are stored in the session and match the streamed event.
const modifiedEvent =
await this.pluginManager.runOnEventCallback({
invocationContext,
event,
});
const outputEvent = modifiedEvent
? {
...modifiedEvent,
id: event.id,
invocationId: event.invocationId,
timestamp: event.timestamp,
author: modifiedEvent.author || event.author,
branch: modifiedEvent.branch ?? event.branch,
}
: event;
if (!event.partial) {
await this.sessionService.appendEvent({
session,
event: outputEvent,
});
}
if (params.abortSignal?.aborted) {
return;
}

if (modifiedEvent) {
yield modifiedEvent;
} else {
yield event;
}
yield outputEvent;
}
// Step 4: Run the after_run callbacks to optionally modify the context.
await this.pluginManager.runAfterRunCallback({invocationContext});
Expand Down
Loading
Loading