Skip to content

Commit a213e76

Browse files
DeanChensjcopybara-github
authored andcommitted
fix(utils): adapt callback keyword arguments to positional parameters
Callbacks typed as Callable[[CallbackContext, ...], ...] may define positional parameters with arbitrary names (such as lambda ctx, resp:). When invoked with keyword arguments, parameter binding fails with unexpected keyword argument. _invoke_callback now inspects the callback signature and adapts keyword arguments to positional arguments when keyword binding fails, while preserving exceptions raised inside the callback body. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 974083312
1 parent 2edfebf commit a213e76

4 files changed

Lines changed: 283 additions & 1 deletion

File tree

src/google/adk/utils/_callback_pipeline.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from collections.abc import Callable
1919
from collections.abc import Sequence
2020
import inspect
21+
from typing import Any
2122
from typing import TypeVar
2223

2324
from typing_extensions import ParamSpec
@@ -27,6 +28,56 @@
2728
_TResult = TypeVar('_TResult')
2829

2930

31+
_CANONICAL_PARAM_ORDER: tuple[str, ...] = (
32+
'tool',
33+
'args',
34+
'tool_context',
35+
'response',
36+
'tool_response',
37+
'callback_context',
38+
'llm_request',
39+
'llm_response',
40+
'error',
41+
)
42+
43+
44+
def _canonical_order_key(name: str) -> int:
45+
try:
46+
return _CANONICAL_PARAM_ORDER.index(name)
47+
except ValueError:
48+
return len(_CANONICAL_PARAM_ORDER)
49+
50+
51+
def _invoke_callback(
52+
_callback: Callable[..., Any], /, *args: Any, **kwargs: Any
53+
) -> Any:
54+
"""Invokes callback, adapting keyword arguments to positional if needed."""
55+
if kwargs:
56+
use_pos = False
57+
pos_args: tuple[Any, ...] = ()
58+
try:
59+
sig = inspect.signature(_callback)
60+
try:
61+
sig.bind(*args, **kwargs)
62+
except TypeError:
63+
sorted_kwarg_values = [
64+
val
65+
for _, val in sorted(
66+
kwargs.items(), key=lambda item: _canonical_order_key(item[0])
67+
)
68+
]
69+
pos_args = args + tuple(sorted_kwarg_values)
70+
sig.bind(*pos_args)
71+
use_pos = True
72+
except (ValueError, TypeError):
73+
pass
74+
75+
if use_pos:
76+
return _callback(*pos_args)
77+
78+
return _callback(*args, **kwargs)
79+
80+
3081
async def _run_callbacks(
3182
callbacks: Sequence[
3283
Callable[
@@ -41,7 +92,7 @@ async def _run_callbacks(
4192
"""Runs callbacks in order while preserving their stop semantics."""
4293
result: _TResult | None = None
4394
for callback in callbacks:
44-
callback_result = callback(*args, **kwargs)
95+
callback_result = _invoke_callback(callback, *args, **kwargs)
4596
if inspect.isawaitable(callback_result):
4697
result = await callback_result
4798
else:

tests/unittests/flows/llm_flows/test_model_callbacks.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,22 @@ async def test_after_model_callback_noop():
159159
) == [('root_agent', 'model_response')]
160160

161161

162+
def test_after_model_callback_lambda_with_arbitrary_param_names():
163+
"""Test that after_model_callback works with lambda having non-matching param names."""
164+
responses = ['model_response']
165+
mock_model = testing_utils.MockModel.create(responses=responses)
166+
agent = Agent(
167+
name='root_agent',
168+
model=mock_model,
169+
after_model_callback=lambda ctx, resp: None,
170+
)
171+
172+
runner = testing_utils.InMemoryRunner(agent)
173+
assert testing_utils.simplify_events(runner.run('test')) == [
174+
('root_agent', 'model_response'),
175+
]
176+
177+
162178
@pytest.mark.asyncio
163179
async def test_on_model_callback_model_error_noop():
164180
"""Test that the on_model_error_callback is a no-op when the model returns an error."""

tests/unittests/flows/llm_flows/test_tool_callbacks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,3 +476,24 @@ async def async_on_tool_error_callback(
476476
),
477477
('root_agent', 'response1'),
478478
]
479+
480+
481+
def test_before_tool_callback_lambda_with_arbitrary_param_names():
482+
"""Test that before_tool_callback works with lambda having non-matching param names."""
483+
captured = []
484+
responses = [
485+
types.Part.from_function_call(name='simple_function', args={}),
486+
'response1',
487+
]
488+
mock_model = testing_utils.MockModel.create(responses=responses)
489+
agent = Agent(
490+
name='root_agent',
491+
model=mock_model,
492+
before_tool_callback=lambda t, a, tc: captured.append((t.name, a)),
493+
tools=[simple_function],
494+
)
495+
496+
runner = testing_utils.InMemoryRunner(agent)
497+
runner.run('test')
498+
assert len(captured) == 1
499+
assert captured[0] == ('simple_function', {})

tests/unittests/utils/test_callback_pipeline.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,197 @@ def empty_callback() -> dict[str, str]:
255255

256256
assert result == {}
257257
assert calls == ['none', 'empty']
258+
259+
260+
@pytest.mark.asyncio
261+
async def test_callback_adapts_keyword_args_to_positional_params():
262+
"""Callbacks with positional param names accept keyword arguments."""
263+
264+
def callback(ctx: str, resp: str) -> str:
265+
return f'{ctx}:{resp}'
266+
267+
result = await _run_callbacks(
268+
[callback],
269+
_stop_on_truthy,
270+
callback_context='context_val',
271+
llm_response='response_val',
272+
)
273+
274+
assert result == 'context_val:response_val'
275+
276+
277+
@pytest.mark.asyncio
278+
async def test_async_callback_adapts_keyword_args_to_positional_params():
279+
"""Async callbacks with positional param names accept keyword arguments."""
280+
281+
async def callback(a: str, b: str) -> str:
282+
return f'{a}:{b}'
283+
284+
result = await _run_callbacks(
285+
[callback],
286+
_stop_on_truthy,
287+
callback_context='arg_a',
288+
llm_response='arg_b',
289+
)
290+
291+
assert result == 'arg_a:arg_b'
292+
293+
294+
@pytest.mark.asyncio
295+
async def test_lambda_callback_adapts_keyword_args():
296+
"""Lambda callbacks with non-matching param names accept keyword arguments."""
297+
callback = lambda c, r: f'lambda_{c}_{r}'
298+
299+
result = await _run_callbacks(
300+
[callback],
301+
_stop_on_truthy,
302+
callback_context='c1',
303+
llm_response='r1',
304+
)
305+
306+
assert result == 'lambda_c1_r1'
307+
308+
309+
@pytest.mark.asyncio
310+
async def test_callback_with_body_type_error_is_not_masked():
311+
"""A TypeError raised inside the callback body is preserved."""
312+
313+
def callback(ctx: str, resp: str) -> None:
314+
raise TypeError('error inside callback body')
315+
316+
with pytest.raises(TypeError, match='error inside callback body'):
317+
await _run_callbacks(
318+
[callback],
319+
_stop_on_truthy,
320+
callback_context='ctx',
321+
llm_response='resp',
322+
)
323+
324+
325+
@pytest.mark.asyncio
326+
async def test_callback_with_reordered_kwargs_maintains_canonical_order():
327+
"""Reordering kwargs at call site still binds positional parameters in canonical order."""
328+
callback = lambda ctx, resp: f'{ctx}:{resp}'
329+
330+
result = await _run_callbacks(
331+
[callback],
332+
_stop_on_truthy,
333+
llm_response='resp_val',
334+
callback_context='ctx_val',
335+
)
336+
337+
assert result == 'ctx_val:resp_val'
338+
339+
340+
@pytest.mark.asyncio
341+
async def test_callback_with_callback_kwarg_does_not_collide():
342+
"""A kwarg named 'callback' does not collide with _callback parameter."""
343+
callback = lambda c, cb: f'{c}:{cb}'
344+
345+
result = await _run_callbacks(
346+
[callback],
347+
_stop_on_truthy,
348+
callback_context='ctx_val',
349+
callback='custom_callback',
350+
)
351+
352+
assert result == 'ctx_val:custom_callback'
353+
354+
355+
@pytest.mark.asyncio
356+
async def test_callback_with_incompatible_signature_raises_type_error():
357+
"""When both keyword and positional binding fail, original TypeError is raised."""
358+
359+
def callback(a: str, b: str, c: str, d: str) -> None:
360+
pass
361+
362+
with pytest.raises(TypeError):
363+
await _run_callbacks(
364+
[callback],
365+
_stop_on_truthy,
366+
callback_context='ctx',
367+
llm_response='resp',
368+
)
369+
370+
371+
@pytest.mark.asyncio
372+
async def test_before_tool_callback_with_reordered_kwargs_maintains_canonical_order():
373+
"""Tool callbacks receive (tool, args, tool_context) in canonical order even if kwargs are reordered."""
374+
callback = lambda t, a, tc: f'{t}:{a}:{tc}'
375+
376+
result = await _run_callbacks(
377+
[callback],
378+
_stop_on_non_none,
379+
tool_context='ctx_val',
380+
args='args_val',
381+
tool='tool_val',
382+
)
383+
384+
assert result == 'tool_val:args_val:ctx_val'
385+
386+
387+
@pytest.mark.asyncio
388+
async def test_after_tool_callback_with_reordered_kwargs_maintains_canonical_order():
389+
"""After-tool callbacks receive (tool, args, tool_context, response) in canonical order."""
390+
callback = lambda t, a, tc, r: f'{t}:{a}:{tc}:{r}'
391+
392+
result = await _run_callbacks(
393+
[callback],
394+
_stop_on_non_none,
395+
response='resp_val',
396+
tool_context='ctx_val',
397+
args='args_val',
398+
tool='tool_val',
399+
)
400+
401+
assert result == 'tool_val:args_val:ctx_val:resp_val'
402+
403+
404+
@pytest.mark.asyncio
405+
async def test_after_tool_callback_with_tool_response_kwarg_maintains_canonical_order():
406+
"""After-tool callbacks with tool_response kwarg receive parameters in canonical order."""
407+
callback = lambda t, a, tc, tr: f'{t}:{a}:{tc}:{tr}'
408+
409+
result = await _run_callbacks(
410+
[callback],
411+
_stop_on_non_none,
412+
tool_response='tr_val',
413+
tool_context='ctx_val',
414+
args='args_val',
415+
tool='tool_val',
416+
)
417+
418+
assert result == 'tool_val:args_val:ctx_val:tr_val'
419+
420+
421+
@pytest.mark.asyncio
422+
async def test_on_tool_error_callback_with_reordered_kwargs_maintains_canonical_order():
423+
"""Tool error callbacks receive (tool, args, tool_context, error) in canonical order."""
424+
callback = lambda t, a, tc, err: f'{t}:{a}:{tc}:{err}'
425+
426+
result = await _run_callbacks(
427+
[callback],
428+
_stop_on_non_none,
429+
error='err_val',
430+
tool_context='ctx_val',
431+
args='args_val',
432+
tool='tool_val',
433+
)
434+
435+
assert result == 'tool_val:args_val:ctx_val:err_val'
436+
437+
438+
@pytest.mark.asyncio
439+
async def test_on_model_error_callback_with_reordered_kwargs_maintains_canonical_order():
440+
"""Model error callbacks receive (callback_context, llm_request, error) in canonical order."""
441+
callback = lambda ctx, req, err: f'{ctx}:{req}:{err}'
442+
443+
result = await _run_callbacks(
444+
[callback],
445+
_stop_on_truthy,
446+
error='err_val',
447+
llm_request='req_val',
448+
callback_context='ctx_val',
449+
)
450+
451+
assert result == 'ctx_val:req_val:err_val'

0 commit comments

Comments
 (0)