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
22 changes: 19 additions & 3 deletions core/src/agents/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import {
getFunctionCalls,
getFunctionResponses,
} from '../events/event.js';
import {mergeEventActions} from '../events/event_actions.js';
import {
isDefaultEventActions,
mergeEventActions,
} from '../events/event_actions.js';
import {BaseTool} from '../tools/base_tool.js';
import {ToolConfirmation} from '../tools/tool_confirmation.js';
import {logger} from '../utils/logger.js';
Expand Down Expand Up @@ -109,7 +112,7 @@ export function generateAuthEvent(
branch: invocationContext.branch,
content: {
parts: parts,
role: functionResponseEvent.content!.role,
role: functionResponseEvent.content?.role ?? 'user',
},
longRunningToolIds: Array.from(longRunningToolIds),
});
Expand Down Expand Up @@ -162,7 +165,7 @@ export function generateRequestConfirmationEvent({
branch: invocationContext.branch,
content: {
parts: parts,
role: functionResponseEvent.content!.role,
role: functionResponseEvent.content?.role ?? 'user',
},
actions: functionResponseEvent.actions,
longRunningToolIds: Array.from(longRunningToolIds),
Expand Down Expand Up @@ -415,6 +418,19 @@ export async function handleFunctionCallList({
// TODO - b/425992518: state event polluting runtime, consider fix.
// Allow long running function to return None as response.
if (tool.isLongRunning && !functionResponse) {
// The tool's response will arrive later, but any actions it recorded on
// the tool context (state/artifact deltas, auth or confirmation
// requests, transfer, escalation, skipSummarization) must not be lost.
if (!isDefaultEventActions(toolContext.actions)) {
functionResponseEvents.push(
createEvent({
invocationId: invocationContext.invocationId,
author: invocationContext.agent.name,
actions: toolContext.actions,
branch: invocationContext.branch,
}),
);
}
continue;
}

Expand Down
4 changes: 3 additions & 1 deletion core/src/agents/llm_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isFinalResponse,
populateClientFunctionCallId,
} from '../events/event.js';
import {isDefaultEventActions} from '../events/event_actions.js';

import {BaseExampleProvider} from '../examples/base_example_provider.js';
import {Example} from '../examples/example.js';
Expand Down Expand Up @@ -701,7 +702,8 @@ export class LlmAgent extends BaseAgent<LlmAgentConfig> {
const isEmptyMetadataEvent =
lastEvent.author === this.name &&
!lastEvent.partial &&
(!lastEvent.content?.parts || lastEvent.content.parts.length === 0);
(!lastEvent.content?.parts || lastEvent.content.parts.length === 0) &&
isDefaultEventActions(lastEvent.actions);

if (
isFinalResponse(lastEvent) &&
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
24 changes: 24 additions & 0 deletions core/src/events/event_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ export function createEventActions(
};
}

/**
* Returns whether the given {@link EventActions} still holds only its default
* values, i.e. the event carries no state, artifact, auth, confirmation,
* transfer, escalation or summarization signal.
*
* An actions object is considered non-default when any dictionary field has at
* least one entry, or when any scalar field has been explicitly set (including
* being set to `false`).
*
* @param actions - The actions to inspect.
* @returns `true` when every field is at its default value.
*/
export function isDefaultEventActions(actions: EventActions): boolean {
return (
Object.keys(actions.stateDelta).length === 0 &&
Object.keys(actions.artifactDelta).length === 0 &&
Object.keys(actions.requestedAuthConfigs).length === 0 &&
Object.keys(actions.requestedToolConfirmations).length === 0 &&
actions.skipSummarization === undefined &&
actions.transferToAgent === undefined &&
actions.escalate === undefined
);
}

/**
* Merges a list of {@link EventActions} objects into a single
* {@link EventActions} object.
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';
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