Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
27 changes: 22 additions & 5 deletions dev/src/integration/agent_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import {
AgentTool,
BaseAgent,
BaseTool,
EXIT_LOOP,
FunctionTool,
LlmAgent,
LoopAgent,
Expand All @@ -23,8 +25,19 @@ import {
} from './agent_types.js';
import {IntegrationRegistry} from './integration_registry.js';

const BUILTIN_TOOLS = [
'exit_loop',
/**
* Built-in tools that a YAML config can name directly, mirroring adk-python's
* `LlmAgent._resolve_tools`, which resolves a bare built-in name to the real
* tool object.
*/
const BUILTIN_TOOLS = new Map<string, BaseTool>([['exit_loop', EXIT_LOOP]]);

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 a plain Record instead of the map? (Record<string, BaseTool>)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, switched to a plain Record in ca75004 (dev/src/integration/agent_registry.ts:34):

const BUILTIN_TOOLS: Record<string, BaseTool> = {exit_loop: EXIT_LOOP};

One deviation worth flagging so you can object: the lookup key comes straight from user YAML, so I read it through Object.hasOwn (agent_registry.ts:153) rather than a bare if (BUILTIN_TOOLS[toolConfig.name]). With a bare index, tools: [{name: constructor}] resolves to Object.prototype.constructor and gets handed to LlmAgent as a "tool" instead of falling through to findToolOrThrow. The Map did not have that hazard, so the guard is just paying for the shape change. Object.hasOwn is already the idiom used elsewhere in this package (dev/src/integration/test_runner.ts:170).

Added a regression test for it, should not resolve an inherited Object member as a built-in tool (dev/test/integration/agent_registry_test.ts:297), and confirmed it fails without the guard (the config instantiates with Object as a tool and no error is thrown).

Happy to drop the guard and index directly if you would rather keep the lookup bare.


/**
* Server-side built-ins that are dropped instead of resolved: they are executed
* by the Gemini backend and their `processLlmRequest` throws for the replay
* harness' `DummyLlm` model name.
*/
const SKIPPED_BUILTIN_TOOLS = [
'google_search',
'url_context',
'google_maps_grounding',
Expand Down Expand Up @@ -136,8 +149,12 @@ export class AgentRegistry {

const tools = config.tools
?.map((toolConfig) => {
// Built in tools are skipped
if (BUILTIN_TOOLS.includes(toolConfig.name)) {
const builtinTool = BUILTIN_TOOLS.get(toolConfig.name);
if (builtinTool) {
return builtinTool;
}

if (SKIPPED_BUILTIN_TOOLS.includes(toolConfig.name)) {
return undefined;
}

Expand Down Expand Up @@ -175,7 +192,7 @@ export class AgentRegistry {

return this.findToolOrThrow(toolConfig.name);
})
// remove entries for built-in tools
// remove entries for the server-side built-in tools
.filter((tool) => tool !== undefined);

const options = {
Expand Down
19 changes: 14 additions & 5 deletions dev/src/integration/replay_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,20 @@ export class ReplayPlugin extends BasePlugin {
const rec = this.recordings[index];
(rec as unknown as {_consumed: boolean})._consumed = true;

// Handle side effects for built-in tools that modify EventActions
if (toolName === 'transfer_to_agent') {
params.toolContext.actions.transferToAgent = params.toolArgs[
'agentName'
] as string;
// Returning the recorded response short-circuits the real tool call, so
// built-in tools whose only observable effect is on EventActions must have
// that effect replicated here. (adk-python instead runs the real tool in
// its replay plugin before returning the recording.)
switch (toolName) {
case 'transfer_to_agent':
params.toolContext.actions.transferToAgent = params.toolArgs[
'agentName'
] as string;
break;
case 'exit_loop':
params.toolContext.actions.escalate = true;
params.toolContext.actions.skipSummarization = true;
break;
}

// The response from a tool call is a plain object.
Expand Down
1 change: 0 additions & 1 deletion dev/src/integration/test_runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ const SKIPPED_TESTS = [
name: 'tool/example_tool_001',
reason: 'ExampleTool is not implemented yet.',
},
{name: 'workflow/loop_001', reason: 'ExitLoopTool is not implemented yet.'},
{
name: 'core/multi_005',
reason: 'Suspected broken test. Need to re-evaluate.',
Expand Down
24 changes: 22 additions & 2 deletions dev/test/integration/agent_registry_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import {
AgentTool,
EXIT_LOOP,
FunctionTool,
LlmAgent,
MCPToolset,
Expand Down Expand Up @@ -257,7 +258,7 @@ describe('AgentRegistry', () => {
expect(retrieved.tools[0]).toBe(tool);
});

it('should skip built-in tools', () => {
it('should resolve exit_loop to the EXIT_LOOP built-in tool', () => {
const config = {
name: 'builtin_agent',
model: 'model',
Expand All @@ -270,7 +271,26 @@ describe('AgentRegistry', () => {
agentRegistry.registerAgentConfig('builtin_agent', config);
const retrieved = agentRegistry.getAgent('builtin_agent') as LlmAgent;

expect(retrieved).toBeDefined();
expect(retrieved.tools).toEqual([EXIT_LOOP]);
});

it('should skip server-side built-in tools', () => {
const config = {
name: 'builtin_agent',
model: 'model',
description: 'desc',
instruction: 'inst',
agentClass: 'LlmAgent',
tools: [
{name: 'google_search'},
{name: 'url_context'},
{name: 'google_maps_grounding'},
],
} as unknown as YamlAgentConfig;

agentRegistry.registerAgentConfig('builtin_agent', config);
const retrieved = agentRegistry.getAgent('builtin_agent') as LlmAgent;

expect(retrieved.tools.length).toBe(0);
});
});
134 changes: 134 additions & 0 deletions dev/test/integration/replay_plugin_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {
Context,
createEventActions,
EventActions,
EXIT_LOOP,
FunctionTool,
} from '@google/adk';
import {beforeEach, describe, expect, it} from 'vitest';
import {ReplayPlugin} from '../../src/integration/replay_plugin.js';
import {Recording} from '../../src/integration/test_types.js';

const AGENT_NAME = 'refiner_agent';

const TRANSFER_TO_AGENT = new FunctionTool({
name: 'transfer_to_agent',
description: 'Transfers to another agent.',
execute: async () => ({}),
});

const GREET = new FunctionTool({
name: 'greet',
description: 'Greets the user.',
execute: async () => ({}),
});

function toolRecording(
name: string,
response: Record<string, unknown>,
): Recording {
return {
userMessageIndex: 0,
agentName: AGENT_NAME,
toolRecording: {toolCall: {name}, toolResponse: {response}},
};
}

describe('ReplayPlugin', () => {
let actions: EventActions;
let toolContext: Context;

beforeEach(() => {
actions = createEventActions();
toolContext = {
actions,
invocationContext: {agent: {name: AGENT_NAME}},
} as unknown as Context;
});

it('replays the recorded response and escalates for exit_loop', async () => {
const plugin = new ReplayPlugin(
[toolRecording('exit_loop', {result: null})],
{
userMessageIndex: 0,
},
);

const response = await plugin.beforeToolCallback({
tool: EXIT_LOOP,
toolArgs: {},
toolContext,
});

expect(response).toEqual({result: null});
expect(actions.escalate).toBe(true);
expect(actions.skipSummarization).toBe(true);
});

it('replays transfer_to_agent by setting transferToAgent', async () => {
const plugin = new ReplayPlugin(
[toolRecording('transfer_to_agent', {result: null})],
{userMessageIndex: 0},
);

await plugin.beforeToolCallback({
tool: TRANSFER_TO_AGENT,
toolArgs: {agentName: 'writer_agent'},
toolContext,
});

expect(actions.transferToAgent).toBe('writer_agent');
expect(actions.escalate).toBeUndefined();
});

it('replays a plain tool without touching the actions', async () => {
const plugin = new ReplayPlugin(
[toolRecording('greet', {greeting: 'hi'})],
{
userMessageIndex: 0,
},
);

const response = await plugin.beforeToolCallback({
tool: GREET,
toolArgs: {},
toolContext,
});

expect(response).toEqual({greeting: 'hi'});
expect(actions).toEqual(createEventActions());
});

it('throws when no recording matches the tool call', async () => {
const plugin = new ReplayPlugin([], {userMessageIndex: 0});

await expect(
plugin.beforeToolCallback({tool: GREET, toolArgs: {}, toolContext}),
).rejects.toThrow(
`No tool recording found for agent ${AGENT_NAME}, tool greet at turn 0`,
);
});

it('consumes each recording only once', async () => {
const plugin = new ReplayPlugin(
[toolRecording('greet', {greeting: 'hi'})],
{
userMessageIndex: 0,
},
);
const call = () =>
plugin.beforeToolCallback({tool: GREET, toolArgs: {}, toolContext});

await call();

await expect(call()).rejects.toThrow(
`No tool recording found for agent ${AGENT_NAME}, tool greet at turn 0`,
);
});
});
Loading
Loading