Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ web runtimes.

## 🚀 Installation

> **Prerequisite:** ADK for TypeScript requires a current Node.js LTS release.

```bash
npm install @google/adk
npm install -D @google/adk-devtools
Expand Down
50 changes: 45 additions & 5 deletions core/src/agents/routed_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,10 @@ export interface RoutedAgentConfig extends BaseAgentConfig {
* Routing is strictly limited to the agents passed in the config.
*
* @remarks
* The inherited {@link BaseAgent.clone} does not support `RoutedAgent`: the
* constructor derives its routing targets from `config.agents` (not
* `subAgents`), so a rebuilt clone re-reads the already-parented originals and
* throws. Cloning a `RoutedAgent` is tracked as a follow-up to
* google/adk-js#534.
* Cloning is supported: {@link RoutedAgent.clone} deep-clones the routing
* targets from `config.agents` and re-parents the fresh copies onto the clone,
* so the clone is a detached root that routes identically to the original while
* leaving the original agent tree untouched.
*/
@experimental
export class RoutedAgent extends BaseAgent<RoutedAgentConfig> {
Expand Down Expand Up @@ -94,6 +93,30 @@ export class RoutedAgent extends BaseAgent<RoutedAgentConfig> {
this.router = config.router;
}

/**
* Creates a detached copy of this routed agent.
*
* The inherited {@link BaseAgent.clone} only deep-clones `subAgents`, but a
* `RoutedAgent` derives its routing targets from `config.agents`. Rebuilding
* through the base clone alone would re-read the already-parented original
* agents and throw "already has a parent agent". This override deep-clones the
* routing targets (unless the caller overrides `agents`) so the rebuilt
* constructor re-parents fresh, detached copies. The array-vs-record shape
* and, for records, the keys are preserved so the router keeps selecting the
* same targets.
*
* @param overrides Config fields to override on the clone. Overriding
* `parentAgent` is rejected by the base implementation.
* @returns A new detached `RoutedAgent` of the same concrete class.
*/
override clone(overrides?: Partial<RoutedAgentConfig>): this {
const nextOverrides: Partial<RoutedAgentConfig> = {...overrides};
if (!('agents' in nextOverrides)) {
nextOverrides.agents = cloneRoutingTargets(this.config.agents);
}
return super.clone(nextOverrides);
}

/**
* Runs the selected agent via text-based conversation.
*/
Expand All @@ -116,3 +139,20 @@ export class RoutedAgent extends BaseAgent<RoutedAgentConfig> {
);
}
}

/**
* Deep-clones a RoutedAgent's routing targets, preserving whether they were
* supplied as an array or a keyed record so the rebuilt constructor derives the
* same routing map. Each clone is detached (no parent), so the constructor can
* re-parent it without conflict.
*/
function cloneRoutingTargets(
agents: Readonly<Record<string, BaseAgent>> | BaseAgent[],
): Readonly<Record<string, BaseAgent>> | BaseAgent[] {
if (Array.isArray(agents)) {
return agents.map((agent) => agent.clone());
}
return Object.fromEntries(
Object.entries(agents).map(([key, agent]) => [key, agent.clone()]),
);
}
3 changes: 3 additions & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export type {
export {BaseToolset, isBaseToolset} from './tools/base_toolset.js';
export type {ToolPredicate} from './tools/base_toolset.js';
export {ConsolidateContextTool} from './tools/consolidate_context_tool.js';
export {ExampleTool} from './tools/example_tool.js';
export {EXIT_LOOP, ExitLoopTool} from './tools/exit_loop_tool.js';
export {FunctionTool, isFunctionTool} from './tools/function_tool.js';
export type {
Expand All @@ -254,6 +255,8 @@ export {
LoadArtifactsTool,
} from './tools/load_artifacts_tool.js';
export {LOAD_MEMORY, LoadMemoryTool} from './tools/load_memory_tool.js';
export {LOAD_WEB_PAGE, loadWebPage} from './tools/load_web_page.js';
export type {LoadWebPageOptions} from './tools/load_web_page.js';
export {LongRunningFunctionTool} from './tools/long_running_tool.js';
export {
PRELOAD_MEMORY,
Expand Down
1 change: 1 addition & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js';
export * from './integrations/agent_registry/agent_registry.js';
export * from './telemetry/google_cloud.js';
export * from './telemetry/setup.js';
export * from './tools/mcp/load_mcp_resource_tool.js';
export * from './tools/mcp/mcp_session_manager.js';
export * from './tools/mcp/mcp_tool.js';
export * from './tools/mcp/mcp_toolset.js';
79 changes: 67 additions & 12 deletions core/src/sessions/vertex_ai_session_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import {Client} from '@google-cloud/vertexai/build/src/genai/client.js';
import {Sessions} from '@google-cloud/vertexai/build/src/genai/sessions.js';
import {
EventActions as ApiEventActions,
AppendAgentEngineSessionEventConfig,
AppendAgentEngineSessionEventRequestParameters,
EventMetadata,
Expand Down Expand Up @@ -40,6 +41,7 @@ import {createSession, Session} from './session.js';
const DEFAULT_MAX_ATTEMPTS = 30;
const GRPC_NOT_FOUND = 5;
const HTTP_NOT_FOUND = 404;
const HTTP_BAD_REQUEST = 400;

/**
* Checks if the given URI is a Vertex AI session service URI.
Expand Down Expand Up @@ -392,12 +394,16 @@ export class VertexAiSessionService extends BaseSessionService {
}

const config = partialCopy<AppendAgentEngineSessionEventConfig>(event, [
'content',
'actions',
'errorCode',
'errorMessage',
]);

const content = event.content && dropUnsupportedPartFields(event.content);
config.content = content;
config.actions = event.actions
? toApiEventActions(event.actions)
: undefined;

config.eventMetadata = {
...partialCopy<EventMetadata>(event, [
'partial',
Expand All @@ -411,7 +417,7 @@ export class VertexAiSessionService extends BaseSessionService {
Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
};

config.rawEvent = JSON.parse(JSON.stringify(event)) as Record<
config.rawEvent = JSON.parse(JSON.stringify({...event, content})) as Record<
string,
unknown
>;
Expand All @@ -427,24 +433,67 @@ export class VertexAiSessionService extends BaseSessionService {
try {
await this.sessions.events.append(params);
} catch (error) {
if (!isInvalidArgumentError(error)) {
throw error;
}
logger.warn(
'Failed to append event with rawEvent, falling back...',
'appendEvent was rejected with rawEvent; retrying without it.',
error,
);
delete config.rawEvent;
await this.sessions.events.append({
name: `reasoningEngines/${reasoningEngineId}/sessions/${session.id}`,
author: event.author || 'user',
invocationId: event.invocationId || `inv-${Date.now()}`,
timestamp: new Date(event.timestamp).toISOString(),
config,
});
await this.sessions.events.append(params);
}

return event;
}
}

/**
* Returns a copy of `content` without Part fields the Agent Engine Sessions
* API rejects.
*
* `partMetadata` is a Gemini Developer API-only field; the Sessions API fails
* appendEvent with 400 INVALID_ARGUMENT ("Unknown name \"part_metadata\"").
*/
function dropUnsupportedPartFields(content: Content): Content {
if (!content.parts) {
return content;
}
return {
...content,
parts: content.parts.map((part) => {
const copy = {...part};
delete copy.partMetadata;
return copy;
}),
};
}

/**
* Maps ADK `EventActions` onto the Sessions API wire shape. ADK's
* `transferToAgent` is the API's `transferAgent` (adk-python writes the same
* field as `transfer_agent`); every other field keeps its name, including
* `requestedToolConfirmations`, which the SDK type omits but `_fromApiEvent`
* reads back.
*/
function toApiEventActions(actions: EventActions): ApiEventActions {
const {transferToAgent, ...rest} = actions;
return {...rest, transferAgent: transferToAgent};
}

/**
* True when the service rejected the request payload itself, which is what an
* API that does not know `rawEvent` returns. Any other failure must propagate:
* the event may already be persisted, so retrying would append it twice.
*
* The SDK reports HTTP failures as an `ApiError` carrying `status`, matched
* structurally because `core` and `@google-cloud/vertexai` resolve separate
* `@google/genai` copies, making `instanceof` false at runtime.
*/
function isInvalidArgumentError(error: unknown): boolean {
return (error as {status?: number} | null)?.status === HTTP_BAD_REQUEST;
}

interface ExtendedEventActions extends EventActions {
compaction?: {
startTime: number;
Expand Down Expand Up @@ -515,7 +564,12 @@ function _fromApiEvent(apiEventObj: VertexAiSessionEvent): Event {
'requestedToolConfirmations'
] as Record<string, ToolConfirmation>) || {},
skipSummarization: actions['skipSummarization'] as boolean | undefined,
transferToAgent: actions['transferAgent'] as string | undefined,
// Earlier adk-js versions copied `event.actions` onto the request
// verbatim, so sessions they wrote store ADK's own `transferToAgent` key.
transferToAgent: (actions['transferAgent'] ??
(actions as Record<string, unknown>)['transferToAgent']) as
| string
| undefined,
escalate: actions['escalate'] as boolean | undefined,
compaction: compactionData || undefined,
};
Expand All @@ -535,6 +589,7 @@ function _fromApiEvent(apiEventObj: VertexAiSessionEvent): Event {
turnComplete: eventMetadata['turnComplete'] as boolean | undefined,
interrupted: eventMetadata['interrupted'] as boolean | undefined,
branch: eventMetadata['branch'] as string | undefined,
groundingMetadata: eventMetadata.groundingMetadata,
customMetadata,
longRunningToolIds: eventMetadata['longRunningToolIds'] as
| string[]
Expand Down
52 changes: 52 additions & 0 deletions core/src/tools/example_tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {BaseExampleProvider} from '../examples/base_example_provider.js';
import {Example} from '../examples/example.js';
import {buildExampleSi} from '../examples/example_util.js';
import {appendInstructions} from '../models/llm_request.js';

import {
BaseTool,
RunAsyncToolRequest,
ToolProcessLlmRequest,
} from './base_tool.js';

/**
* A tool that adds (few-shot) examples to the LLM request.
*
* This tool is executed for each LLM request and is never called by the model;
* it only mutates the outgoing request by appending few-shot instructions built
* from the latest user query.
*/
export class ExampleTool extends BaseTool {
constructor(readonly examples: Example[] | BaseExampleProvider) {
super({
// Name and description are not used because this tool only changes
// llmRequest.
name: 'example_tool',
description: 'example tool',
});
}

override async runAsync(_request: RunAsyncToolRequest): Promise<unknown> {
// Should not be called by model because it's not declared in LLM tools list.
throw new Error('ExampleTool should not be called by model');
}

override async processLlmRequest({
toolContext,
llmRequest,
}: ToolProcessLlmRequest): Promise<void> {
const parts = toolContext.userContent?.parts;
if (!parts || !parts[0]?.text) {
return;
}
appendInstructions(llmRequest, [
buildExampleSi(this.examples, parts[0].text, llmRequest.model),
]);
}
}
Loading
Loading