Skip to content

Commit af63567

Browse files
seanzhougooglecopybara-github
authored andcommitted
feat: Support both output_schema and tools at the same time in LlmAgent
1. Allow developers to specify output schema and tools together. 2. If both are specified, do the following: 2.1 Do not set output schema on the model config 2.2 Add a special tool called set_model_response(result) 2.3 `result` has the same schema as the requested output_schema 2.4 Instruct the model to use set_model_response() to output its final result, rather than output text directly. 2.5 When the set_model_response() is called, ADK will extract its content and put it in a text part, so the client would treat it as the model response. PiperOrigin-RevId: 792686011
1 parent b4ce3b1 commit af63567

File tree

10 files changed

+1098
-14
lines changed

10 files changed

+1098
-14
lines changed

src/google/adk/agents/llm_agent.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -499,12 +499,6 @@ def __check_output_schema(self):
499499
' sub_agents must be empty to disable agent transfer.'
500500
)
501501

502-
if self.tools:
503-
raise ValueError(
504-
f'Invalid config for agent {self.name}: if output_schema is set,'
505-
' tools must be empty'
506-
)
507-
508502
@field_validator('generate_content_config', mode='after')
509503
@classmethod
510504
def __validate_generate_content_config(
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Handles output schema when tools are also present."""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
from typing import AsyncGenerator
21+
22+
from typing_extensions import override
23+
24+
from ...agents.invocation_context import InvocationContext
25+
from ...events.event import Event
26+
from ...models.llm_request import LlmRequest
27+
from ...tools.set_model_response_tool import SetModelResponseTool
28+
from ._base_llm_processor import BaseLlmRequestProcessor
29+
30+
31+
class _OutputSchemaRequestProcessor(BaseLlmRequestProcessor):
32+
"""Processor that handles output schema for agents with tools."""
33+
34+
@override
35+
async def run_async(
36+
self, invocation_context: InvocationContext, llm_request: LlmRequest
37+
) -> AsyncGenerator[Event, None]:
38+
from ...agents.llm_agent import LlmAgent
39+
40+
agent = invocation_context.agent
41+
if not isinstance(agent, LlmAgent):
42+
return
43+
44+
# Check if we need the processor: output_schema + tools
45+
if not agent.output_schema or not agent.tools:
46+
return
47+
48+
# Add the set_model_response tool to handle structured output
49+
set_response_tool = SetModelResponseTool(agent.output_schema)
50+
llm_request.append_tools([set_response_tool])
51+
52+
# Add instruction about using the set_model_response tool
53+
instruction = (
54+
'IMPORTANT: You have access to other tools, but you must provide '
55+
'your final response using the set_model_response tool with the '
56+
'required structured format. After using any other tools needed '
57+
'to complete the task, always call set_model_response with your '
58+
'final answer in the specified schema format.'
59+
)
60+
llm_request.append_instructions([instruction])
61+
62+
return
63+
yield # Generator requires yield statement in function body.
64+
65+
66+
def create_final_model_response_event(
67+
invocation_context: InvocationContext, json_response: str
68+
) -> Event:
69+
"""Create a final model response event from set_model_response JSON.
70+
71+
Args:
72+
invocation_context: The invocation context.
73+
json_response: The JSON response from set_model_response tool.
74+
75+
Returns:
76+
A new Event that looks like a normal model response.
77+
"""
78+
from google.genai import types
79+
80+
# Create a proper model response event
81+
final_event = Event(author=invocation_context.agent.name)
82+
final_event.content = types.Content(
83+
role='model', parts=[types.Part(text=json_response)]
84+
)
85+
return final_event
86+
87+
88+
def get_structured_model_response(function_response_event: Event) -> str | None:
89+
"""Check if function response contains set_model_response and extract JSON.
90+
91+
Args:
92+
function_response_event: The function response event to check.
93+
94+
Returns:
95+
JSON response string if set_model_response was called, None otherwise.
96+
"""
97+
if (
98+
not function_response_event
99+
or not function_response_event.get_function_responses()
100+
):
101+
return None
102+
103+
for func_response in function_response_event.get_function_responses():
104+
if func_response.name == 'set_model_response':
105+
# Convert dict to JSON string
106+
return json.dumps(func_response.response)
107+
108+
return None
109+
110+
111+
# Export the processors
112+
request_processor = _OutputSchemaRequestProcessor()

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from websockets.exceptions import ConnectionClosed
2929
from websockets.exceptions import ConnectionClosedOK
3030

31+
from . import _output_schema_processor
3132
from . import functions
3233
from ...agents.base_agent import BaseAgent
3334
from ...agents.callback_context import CallbackContext
@@ -500,8 +501,21 @@ async def _postprocess_live(
500501
function_response_event = await functions.handle_function_calls_live(
501502
invocation_context, model_response_event, llm_request.tools_dict
502503
)
504+
# Always yield the function response event first
503505
yield function_response_event
504506

507+
# Check if this is a set_model_response function response
508+
if json_response := _output_schema_processor.get_structured_model_response(
509+
function_response_event
510+
):
511+
# Create and yield a final model response event
512+
final_event = (
513+
_output_schema_processor.create_final_model_response_event(
514+
invocation_context, json_response
515+
)
516+
)
517+
yield final_event
518+
505519
transfer_to_agent = function_response_event.actions.transfer_to_agent
506520
if transfer_to_agent:
507521
agent_to_run = self._get_agent_to_run(
@@ -532,7 +546,20 @@ async def _postprocess_handle_function_calls_async(
532546
if auth_event:
533547
yield auth_event
534548

549+
# Always yield the function response event first
535550
yield function_response_event
551+
552+
# Check if this is a set_model_response function response
553+
if json_response := _output_schema_processor.get_structured_model_response(
554+
function_response_event
555+
):
556+
# Create and yield a final model response event
557+
final_event = (
558+
_output_schema_processor.create_final_model_response_event(
559+
invocation_context, json_response
560+
)
561+
)
562+
yield final_event
536563
transfer_to_agent = function_response_event.actions.transfer_to_agent
537564
if transfer_to_agent:
538565
agent_to_run = self._get_agent_to_run(

src/google/adk/flows/llm_flows/basic.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ async def run_async(
5050
if agent.generate_content_config
5151
else types.GenerateContentConfig()
5252
)
53-
if agent.output_schema:
53+
# Only set output_schema if no tools are specified. as of now, model don't
54+
# support output_schema and tools together. we have a workaround to support
55+
# both outoput_schema and tools at the same time. see
56+
# _output_schema_processor.py for details
57+
if agent.output_schema and not agent.tools:
5458
llm_request.set_output_schema(agent.output_schema)
5559

5660
llm_request.live_connect_config.response_modalities = (

src/google/adk/flows/llm_flows/single_flow.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414

1515
"""Implementation of single flow."""
1616

17+
from __future__ import annotations
18+
1719
import logging
1820

1921
from . import _code_execution
2022
from . import _nl_planning
23+
from . import _output_schema_processor
2124
from . import basic
2225
from . import contents
2326
from . import identity
@@ -50,6 +53,9 @@ def __init__(self):
5053
# Code execution should be after the contents as it mutates the contents
5154
# to optimize data files.
5255
_code_execution.request_processor,
56+
# Output schema processor add system instruction and set_model_response
57+
# when both output_schema and tools are present.
58+
_output_schema_processor.request_processor,
5359
]
5460
self.response_processors += [
5561
_nl_planning.response_processor,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tool for setting model response when using output_schema with other tools."""
16+
17+
from __future__ import annotations
18+
19+
from typing import Any
20+
from typing import Optional
21+
22+
from google.genai import types
23+
from pydantic import BaseModel
24+
from typing_extensions import override
25+
26+
from ._automatic_function_calling_util import build_function_declaration
27+
from .base_tool import BaseTool
28+
from .tool_context import ToolContext
29+
30+
MODEL_JSON_RESPONSE_KEY = 'temp:__adk_model_response__'
31+
32+
33+
class SetModelResponseTool(BaseTool):
34+
"""Internal tool used for output schema workaround.
35+
36+
This tool allows the model to set its final response when output_schema
37+
is configured alongside other tools. The model should use this tool to
38+
provide its final structured response instead of outputting text directly.
39+
"""
40+
41+
def __init__(self, output_schema: type[BaseModel]):
42+
"""Initialize the tool with the expected output schema.
43+
44+
Args:
45+
output_schema: The pydantic model class defining the expected output
46+
structure.
47+
"""
48+
self.output_schema = output_schema
49+
50+
# Create a function that matches the output schema
51+
def set_model_response() -> str:
52+
"""Set your final response using the required output schema.
53+
54+
Use this tool to provide your final structured answer instead
55+
of outputting text directly.
56+
"""
57+
return 'Response set successfully.'
58+
59+
# Add the schema fields as parameters to the function dynamically
60+
import inspect
61+
62+
schema_fields = output_schema.model_fields
63+
params = []
64+
for field_name, field_info in schema_fields.items():
65+
param = inspect.Parameter(
66+
field_name,
67+
inspect.Parameter.KEYWORD_ONLY,
68+
annotation=field_info.annotation,
69+
)
70+
params.append(param)
71+
72+
# Create new signature with schema parameters
73+
new_sig = inspect.Signature(parameters=params)
74+
setattr(set_model_response, '__signature__', new_sig)
75+
76+
self.func = set_model_response
77+
78+
super().__init__(
79+
name=self.func.__name__,
80+
description=self.func.__doc__.strip() if self.func.__doc__ else '',
81+
)
82+
83+
@override
84+
def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
85+
"""Gets the OpenAPI specification of this tool."""
86+
function_decl = types.FunctionDeclaration.model_validate(
87+
build_function_declaration(
88+
func=self.func,
89+
ignore_params=[],
90+
variant=self._api_variant,
91+
)
92+
)
93+
return function_decl
94+
95+
@override
96+
async def run_async(
97+
self, *, args: dict[str, Any], tool_context: ToolContext # pylint: disable=unused-argument
98+
) -> dict[str, Any]:
99+
"""Process the model's response and return the validated dict.
100+
101+
Args:
102+
args: The structured response data matching the output schema.
103+
tool_context: Tool execution context.
104+
105+
Returns:
106+
The validated response as dict.
107+
"""
108+
# Validate the input matches the expected schema
109+
validated_response = self.output_schema.model_validate(args)
110+
111+
# Return the validated dict directly
112+
return validated_response.model_dump()

tests/unittests/agents/test_llm_agent_fields.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,19 +201,18 @@ class Schema(BaseModel):
201201
)
202202

203203

204-
def test_output_schema_with_tools_will_throw():
204+
def test_output_schema_with_tools_will_not_throw():
205205
class Schema(BaseModel):
206206
pass
207207

208208
def _a_tool():
209209
pass
210210

211-
with pytest.raises(ValueError):
212-
_ = LlmAgent(
213-
name='test_agent',
214-
output_schema=Schema,
215-
tools=[_a_tool],
216-
)
211+
LlmAgent(
212+
name='test_agent',
213+
output_schema=Schema,
214+
tools=[_a_tool],
215+
)
217216

218217

219218
def test_before_model_callback():

0 commit comments

Comments
 (0)