Skip to content

Commit a73f149

Browse files
author
Amaad Martin
committed
Fix: replay the exit_loop side effects in ReplayPlugin
Returning the recorded tool response short-circuits callToolAsync, so a tool whose only observable effect is on EventActions never runs during a replay. The plugin already replicated that effect for transfer_to_agent; do the same for exit_loop so escalate and skipSummarization are set, which is what stops the LoopAgent and ends the LlmAgent step loop.
1 parent c676490 commit a73f149

2 files changed

Lines changed: 148 additions & 5 deletions

File tree

dev/src/integration/replay_plugin.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,20 @@ export class ReplayPlugin extends BasePlugin {
7474
const rec = this.recordings[index];
7575
(rec as unknown as {_consumed: boolean})._consumed = true;
7676

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

8493
// The response from a tool call is a plain object.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
Context,
9+
createEventActions,
10+
EventActions,
11+
EXIT_LOOP,
12+
FunctionTool,
13+
} from '@google/adk';
14+
import {beforeEach, describe, expect, it} from 'vitest';
15+
import {ReplayPlugin} from '../../src/integration/replay_plugin.js';
16+
import {Recording} from '../../src/integration/test_types.js';
17+
18+
const AGENT_NAME = 'refiner_agent';
19+
20+
const TRANSFER_TO_AGENT = new FunctionTool({
21+
name: 'transfer_to_agent',
22+
description: 'Transfers to another agent.',
23+
execute: async () => ({}),
24+
});
25+
26+
const GREET = new FunctionTool({
27+
name: 'greet',
28+
description: 'Greets the user.',
29+
execute: async () => ({}),
30+
});
31+
32+
function toolRecording(
33+
name: string,
34+
response: Record<string, unknown>,
35+
): Recording {
36+
return {
37+
userMessageIndex: 0,
38+
agentName: AGENT_NAME,
39+
toolRecording: {toolCall: {name}, toolResponse: {response}},
40+
};
41+
}
42+
43+
describe('ReplayPlugin', () => {
44+
let actions: EventActions;
45+
let toolContext: Context;
46+
47+
beforeEach(() => {
48+
actions = createEventActions();
49+
toolContext = {
50+
actions,
51+
invocationContext: {agent: {name: AGENT_NAME}},
52+
} as unknown as Context;
53+
});
54+
55+
it('replays the recorded response and escalates for exit_loop', async () => {
56+
const plugin = new ReplayPlugin(
57+
[toolRecording('exit_loop', {result: null})],
58+
{
59+
userMessageIndex: 0,
60+
},
61+
);
62+
63+
const response = await plugin.beforeToolCallback({
64+
tool: EXIT_LOOP,
65+
toolArgs: {},
66+
toolContext,
67+
});
68+
69+
expect(response).toEqual({result: null});
70+
expect(actions.escalate).toBe(true);
71+
expect(actions.skipSummarization).toBe(true);
72+
});
73+
74+
it('replays transfer_to_agent by setting transferToAgent', async () => {
75+
const plugin = new ReplayPlugin(
76+
[toolRecording('transfer_to_agent', {result: null})],
77+
{userMessageIndex: 0},
78+
);
79+
80+
await plugin.beforeToolCallback({
81+
tool: TRANSFER_TO_AGENT,
82+
toolArgs: {agentName: 'writer_agent'},
83+
toolContext,
84+
});
85+
86+
expect(actions.transferToAgent).toBe('writer_agent');
87+
expect(actions.escalate).toBeUndefined();
88+
});
89+
90+
it('replays a plain tool without touching the actions', async () => {
91+
const plugin = new ReplayPlugin(
92+
[toolRecording('greet', {greeting: 'hi'})],
93+
{
94+
userMessageIndex: 0,
95+
},
96+
);
97+
98+
const response = await plugin.beforeToolCallback({
99+
tool: GREET,
100+
toolArgs: {},
101+
toolContext,
102+
});
103+
104+
expect(response).toEqual({greeting: 'hi'});
105+
expect(actions).toEqual(createEventActions());
106+
});
107+
108+
it('throws when no recording matches the tool call', async () => {
109+
const plugin = new ReplayPlugin([], {userMessageIndex: 0});
110+
111+
await expect(
112+
plugin.beforeToolCallback({tool: GREET, toolArgs: {}, toolContext}),
113+
).rejects.toThrow(
114+
`No tool recording found for agent ${AGENT_NAME}, tool greet at turn 0`,
115+
);
116+
});
117+
118+
it('consumes each recording only once', async () => {
119+
const plugin = new ReplayPlugin(
120+
[toolRecording('greet', {greeting: 'hi'})],
121+
{
122+
userMessageIndex: 0,
123+
},
124+
);
125+
const call = () =>
126+
plugin.beforeToolCallback({tool: GREET, toolArgs: {}, toolContext});
127+
128+
await call();
129+
130+
await expect(call()).rejects.toThrow(
131+
`No tool recording found for agent ${AGENT_NAME}, tool greet at turn 0`,
132+
);
133+
});
134+
});

0 commit comments

Comments
 (0)