Skip to content

Commit 4e29502

Browse files
aelisseecopybara-github
authored andcommitted
Refactor function calling to incorporate the tool declaration automatically into the underlying model processors. This avoids duplicating tool declaration in both models and function calling processor which could lead to errors and inconsistencies. This also makes function calling more robust with the ability to define function calling processor on top of other function calling processors, adding capabilities incrementally on other processors.
To that end, we added an "add_tool" method to the model processors and to chain processors and function calling. The tools can then be registered after a model processor is created. The new code is backward compatible: if the model has registered tools in its constructor, the function calling method will not duplicate the tools. PiperOrigin-RevId: 891591218
1 parent 0da39da commit 4e29502

21 files changed

+746
-299
lines changed

documentation/docs/concepts/function-calling.md

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -85,38 +85,21 @@ async def smart_home_state() -> AsyncIterable[str]:
8585

8686
## Configuring the Processor
8787

88-
To enable function calling, you must:
89-
90-
1. Let the model know which tools are available by providing the tool list in
91-
the model config.
92-
93-
2. **Disable** any built-in automatic function calling in the model (e.g., for
94-
`GenaiModel`, set `automatic_function_calling=...disable=True`).
95-
96-
3. Wrap the model processor with `FunctionCalling`, providing the same list of
97-
functions for execution.
88+
To enable function calling, wrap your model with `FunctionCalling` and provide
89+
the list of functions:
9890

9991
```python
10092
from genai_processors.core import function_calling, genai_model
101-
from google.genai import types as genai_types
10293

103-
tools = [get_weather, set_alarm]
104-
105-
# 1 & 2: Configure model and disable its internal AFC.
106-
model = genai_model.GenaiModel(
107-
model_name="gemini-2.0-flash",
108-
generate_content_config=genai_types.GenerateContentConfig(
109-
tools=tools,
110-
automatic_function_calling=genai_types.AutomaticFunctionCallingConfig(
111-
disable=True
112-
),
113-
),
114-
)
94+
model = genai_model.GenaiModel(model_name="gemini-2.0-flash")
11595

116-
# 3. Wrap with FunctionCalling.
117-
agent = function_calling.FunctionCalling(model=model, fns=tools)
96+
agent = function_calling.FunctionCalling(model=model, fns=[get_weather, set_alarm])
11897
```
11998

99+
Tool declarations are **automatically registered** on the model via
100+
`register_tools()`. There is no need to pass `tools=` to the model's config or to
101+
manually disable automatic function calling — `FunctionCalling` handles both.
102+
120103
## Async Tools and Real-Time Interaction
121104

122105
`FunctionCalling` is designed to work with both turn-based and real-time
@@ -203,13 +186,11 @@ additional tools you can add to your model's tool list:
203186
```python
204187
from genai_processors.core import function_calling
205188

206-
# We explicitly add the list_fc and cancel_fc functions from function calling
207-
# to let the model cancel async function calls. If tools contains only synced
208-
# functions, list_fc and cancel_fc can be omitted.
209-
tools = [my_async_tool, function_calling.list_fc, function_calling.cancel_fc]
210-
model = genai_model.GenaiModel(..., tools=tools, ...)
211-
212-
agent = function_calling.FunctionCalling(model=model, fns=tools, is_bidi_model=True)
189+
agent = function_calling.FunctionCalling(
190+
model=model,
191+
fns=[my_async_tool, function_calling.list_fc, function_calling.cancel_fc],
192+
is_bidi_model=True,
193+
)
213194
```
214195

215196
## Using MCP (Model Context Protocol)

documentation/docs/development/built-in-processors.md

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -278,24 +278,14 @@ results back to the model.
278278

279279
```python
280280
from genai_processors.core import function_calling
281-
from google.genai import types as genai_types
282281

283282
def get_weather(city: str) -> str:
284283
# ... implementation ...
285284
return f"Weather in {city} is sunny."
286285

287-
tools = [get_weather]
288-
model_with_tools = genai_model.GenaiModel(
289-
...,
290-
generate_content_config=genai_types.GenerateContentConfig(
291-
tools=tools,
292-
automatic_function_calling=genai_types.AutomaticFunctionCallingConfig(
293-
disable=True
294-
),
295-
),
296-
)
286+
model_with_tools = genai_model.GenaiModel(...)
297287

298-
agent = function_calling.FunctionCalling(model=model_with_tools, fns=tools)
288+
agent = function_calling.FunctionCalling(model=model_with_tools, fns=[get_weather])
299289
```
300290

301291
*See: [Function Calling Concept](../concepts/function-calling.md) and

examples/chat.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,6 @@ async def run_chat() -> None:
162162

163163
model = models.turn_based_model(
164164
system_instruction=SYSTEM_INSTRUCTIONS,
165-
disable_automatic_function_calling=True,
166-
tools=tools,
167165
)
168166
model = function_calling.FunctionCalling(
169167
model=realtime.LiveModelProcessor(model),

examples/live_illustrator/illustrator.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,14 +355,6 @@ def create_live_illustrator(
355355
model_name=MODEL_LISTEN,
356356
generate_content_config=genai_types.GenerateContentConfig(
357357
system_instruction=SYSTEM_INSTRUCTION,
358-
# We will be handling tool calls on the client side.
359-
automatic_function_calling=genai_types.AutomaticFunctionCallingConfig(
360-
disable=True
361-
),
362-
tools=[
363-
image_gen.create_image_from_description,
364-
image_gen.create_concept_art,
365-
],
366358
),
367359
)
368360
end_of_turns_scheduler = ScheduleEndOfTurns(period_sec=image_period_sec)

examples/models.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ def turn_based_model(
7878
]
7979
| None
8080
) = None,
81-
disable_automatic_function_calling: bool = False,
8281
) -> processor.Processor:
8382
"""Returns a turn-based model based on command line flags.
8483
@@ -87,9 +86,10 @@ def turn_based_model(
8786
8887
Args:
8988
system_instruction: The system instruction to use for the model.
90-
tools: The tools to use for the model, or google search tool if None.
91-
disable_automatic_function_calling: Whether to disable automatic function
92-
calling.
89+
tools: Server-side tools to use for the model (e.g. google_search).
90+
Client-side function tools should be registered via
91+
``FunctionCalling(fns=...)`` instead — they will be auto-declared on the
92+
model. Defaults to Google Search if None.
9393
9494
Returns:
9595
A turn-based LLM model.
@@ -124,9 +124,6 @@ def turn_based_model(
124124
tools=tools
125125
if tools is not None
126126
else [genai_types.Tool(google_search=genai_types.GoogleSearch())],
127-
automatic_function_calling=genai_types.AutomaticFunctionCallingConfig(
128-
disable=disable_automatic_function_calling
129-
),
130127
)
131128

132129
model_instance = genai_model.GenaiModel(

examples/widgets/widgets.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,6 @@ def create_dr_widget(
183183
model_name=MODEL,
184184
generate_content_config=genai_types.GenerateContentConfig(
185185
system_instruction=SYSTEM_INSTRUCTION,
186-
# We will be handling tool calls on the client side.
187-
automatic_function_calling=genai_types.AutomaticFunctionCallingConfig(
188-
disable=True
189-
),
190-
tools=[
191-
image_gen.create_image_from_description,
192-
plot_gen.create_plot_from_description,
193-
],
194186
),
195187
)
196188
fc_processor = function_calling.FunctionCalling(

genai_processors/core/function_calling.py

Lines changed: 104 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,9 @@
148148
- `pre_processor` is a processor called once on `input` and on all results
149149
obtained by tool invocation. This could for instance be an image tokenizer to
150150
preprocess images and avoid re-tokenizing them on every model invocation.
151-
- `model` is a model processor that generates content. For the model to know
152-
which tools are available, the samefunction/tool set should be given both to the
153-
FunctionCalling and the model constructor. This model can be turn-based or
154-
bidi but this should be specificed in the FunctionCalling ctor.
151+
- `model` is a model processor that generates content. Tool declarations are
152+
automatically registered on the model via ``add_tools()`` — you only need to
153+
pass functions to ``FunctionCalling(fns=...)``.
155154
- `function_call` executes the function calls returned by the model. This is
156155
a private processor in this file. The function response is then fed back to the
157156
model for another iteration. If there is no function call to execute and the
@@ -162,35 +161,27 @@
162161
They are all in the same substream as identified by `substream_name` in the
163162
FunctionCalling ctor.
164163
165-
When used with GenAI models (i.e. Gemini API), the model should be defined as
166-
follows:
164+
When used with GenAI models (i.e. Gemini API), the setup is simply:
167165
168166
```python
169167
genai_processor = genai_model.GenaiModel(
170168
api_key=API_KEY,
171169
model_name='gemini-2.5-flash',
172-
generate_content_config=genai_types.GenerateContentConfig(
173-
tools=[fns],
174-
automatic_function_calling=genai_types.AutomaticFunctionCallingConfig(
175-
disable=True
176-
),
177-
),
178170
)
171+
172+
agent = FunctionCalling(genai_processor, fns=[get_weather, set_alarm])
179173
```
180174
181-
where `fns` are the python functions to be called. Note that we disable the
182-
automatic function calling feature here to avoid duplicate function calls with
183-
the GenAI automatic function calling feature.
175+
Tool declarations and disabling of automatic function calling are handled
176+
automatically via the ``add_tools()`` delegation.
184177
185-
If you want to allow cancelling ongoing async functions add `cancel_fc` and
186-
`list_fc` tool defined in this file:
178+
If you want to allow cancelling ongoing async functions, add ``cancel_fc`` and
179+
``list_fc`` defined in this file:
187180
188181
```python
189-
generate_content_config=genai_types.GenerateContentConfig(
190-
tools=[fns, function_calling.cancel_fc, function_calling.list_fc],
191-
automatic_function_calling=genai_types.AutomaticFunctionCallingConfig(
192-
disable=True
193-
),
182+
agent = FunctionCalling(
183+
genai_processor,
184+
fns=[get_weather, function_calling.cancel_fc, function_calling.list_fc],
194185
)
195186
```
196187
@@ -441,17 +432,15 @@ def __init__(
441432
processor.
442433
pre_processor: An optional pre-processor to pass the model input (prompt,
443434
function responses, model output from previous iterations) through.
444-
fns: The functions to register for function calling. Those functions must
445-
be known to `model`, and will be called only if `model` returns a
446-
function call with the matching name. For Gemini API, this means the
447-
same functions should be passed in the `GenerationConfig(tools=[...])`
448-
to the `model` constructor. If the function name is not found in the
449-
`fns` list, the function calling processor will return the unknown
450-
function call part and will raise a `ValueError`. If the execution of
451-
the function fails, the function calling processor will return a
452-
function response with the error message. MCP tools are automatically
453-
converted to callables when passed in as functions to this processor,
454-
this happens the first time the model is called.
435+
fns: The functions to register for function calling. Tool declarations are
436+
automatically registered on the model via ``add_tools()`` — you do not
437+
need to pass them separately to the model's config. If the function name
438+
is not found in the `fns` list, the function calling processor will
439+
return the unknown function call part and will raise a `ValueError`. If
440+
the execution of the function fails, the function calling processor will
441+
return a function response with the error message. MCP tools are
442+
automatically converted to callables when passed in as functions to this
443+
processor, this happens the first time the model is called.
455444
max_function_calls: maximum number of function calls to make (default set
456445
to 5 of turn-based models and infinity for bidi aka realtime models).
457446
When this limit is reached, the function calling loop will wait for the
@@ -461,8 +450,12 @@ def __init__(
461450
self._model = model
462451
self._substream_name = substream_name
463452
self._is_bidi_model = is_bidi_model
464-
self._fns = fns
453+
self._fns = list(fns) if fns else []
454+
if is_bidi_model:
455+
# Automatically add list_fc and cancel_fc for bidi models.
456+
self._fns.extend([cancel_fc, list_fc])
465457
self._fns_initialized = False
458+
self._called = False
466459
self._pre_processor = (
467460
pre_processor.to_processor()
468461
if pre_processor
@@ -471,6 +464,51 @@ def __init__(
471464
default_max_function_calls = 5 if not is_bidi_model else math.inf
472465
self._max_function_calls = max_function_calls or default_max_function_calls
473466

467+
def add_fns(self, fns: list[Callable[..., Any]]) -> None:
468+
"""Adds functions dynamically before call() is invoked.
469+
470+
This is useful for wrapper processors that need to inject additional
471+
tools without interfering with how the FunctionCalling processor was
472+
originally created. Functions added here are treated identically to
473+
those passed in the constructor.
474+
475+
Must be called before `call()` is invoked.
476+
477+
Args:
478+
fns: The functions to add.
479+
480+
Raises:
481+
RuntimeError: if called after `call()` has been invoked.
482+
"""
483+
if self._called:
484+
raise RuntimeError('Cannot add functions after call() has been invoked.')
485+
if self._fns is None:
486+
self._fns = list(fns)
487+
else:
488+
self._fns = list(self._fns) + list(fns)
489+
490+
def register_tools(
491+
self,
492+
tools: list[Callable[..., Any] | genai_types.McpClientSession],
493+
) -> None:
494+
"""Delegates tool registration to the inner model processor.
495+
496+
When ``FunctionCalling`` is used as the inner model for another
497+
``FunctionCalling`` (nested pattern), the outer processor calls this
498+
method to propagate tool declarations down to the actual model.
499+
500+
Args:
501+
tools: Functions to register. Callables are converted to proto
502+
FunctionDeclarations. McpClientSessions are expected to have been
503+
converted to callables by FunctionCalling before reaching this method.
504+
"""
505+
if hasattr(self._model, 'register_tools'):
506+
self._model.register_tools(tools)
507+
508+
def children(self) -> list[processor.Processor]:
509+
"""Returns the list of sub-processors (children) for this processor."""
510+
return [self._model]
511+
474512
async def _initialize_mcp_tools(self):
475513
"""Initializes the MCP tools."""
476514
if self._fns_initialized:
@@ -486,10 +524,42 @@ async def _initialize_mcp_tools(self):
486524
self._fns_initialized = True
487525
self._fns = fns
488526

527+
def _find_last_tool_supporting_processor(
528+
self, proc: processor.Processor
529+
) -> processor.Processor | None:
530+
"""Recursively finds the last processor supporting register_tools."""
531+
if hasattr(proc, 'register_tools'):
532+
return proc
533+
534+
for child in reversed(proc.children()):
535+
found = self._find_last_tool_supporting_processor(child)
536+
if found:
537+
return found
538+
return None
539+
540+
def _register_tools_on_model(self) -> None:
541+
"""Registers tool declarations on the inner model processor.
542+
543+
Uses a generic tree traversal (via ``children()``) to find the last
544+
processor in the hierarchy that supports ``register_tools()``.
545+
"""
546+
if not self._fns:
547+
return
548+
549+
target = self._find_last_tool_supporting_processor(self._model)
550+
if target:
551+
getattr(target, 'register_tools')(self._fns)
552+
else:
553+
raise ValueError(
554+
f'No processor in the chain supports tool registration: {self._model}'
555+
)
556+
489557
async def call(
490558
self, content: processor.ProcessorStream
491559
) -> AsyncIterable[content_api.ProcessorPartTypes]:
560+
self._called = True
492561
await self._initialize_mcp_tools()
562+
self._register_tools_on_model()
493563
state = _FunctionCallState(fn_call_count_limit=self._max_function_calls)
494564
# To support both bidi and unary models we reduce both cases to bidi.
495565
model = _to_bidi(self._model) if not self._is_bidi_model else self._model

0 commit comments

Comments
 (0)