diff --git a/contributing/samples/skip_synthesis_followup/__init__.py b/contributing/samples/skip_synthesis_followup/__init__.py new file mode 100644 index 0000000000..63bd45e6d2 --- /dev/null +++ b/contributing/samples/skip_synthesis_followup/__init__.py @@ -0,0 +1 @@ +from . import agent \ No newline at end of file diff --git a/contributing/samples/skip_synthesis_followup/agent.py b/contributing/samples/skip_synthesis_followup/agent.py new file mode 100644 index 0000000000..e825ecf517 --- /dev/null +++ b/contributing/samples/skip_synthesis_followup/agent.py @@ -0,0 +1,56 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.adk import Agent +from google.adk.tools import AgentTool +from google.adk.agents import LlmAgent +from .prompts import explanation_instruction, followup_instruction +from google.genai import types as genai_types +from pydantic import BaseModel, Field +from typing import List + +class FollowupsPayload(BaseModel): + questions: List[str] = Field( + description="List of 3 short follow-up questions as strings." + ) + +# Follow-up questions agent +followup_agent = LlmAgent( + model="gemini-2.5-flash-lite", + name="followup_agent", + description="Generates 3 follow-up questions to spark curiosity and deepen understanding after explaining concepts. Creates questions covering application, comparison, and exploration. DO NOT call when student is stuck on problems or during practice sessions.", + output_schema=FollowupsPayload, + include_contents="none", + instruction=followup_instruction, + generate_content_config=genai_types.GenerateContentConfig( + temperature=0.0, + ), +) + +# Convert agent to tool +followup_agent_tool = AgentTool( + agent=followup_agent, + skip_synthesis=True +) + +explainer_agent = Agent( + name="explainer_agent", + model="gemini-2.5-flash", + description="An agent that explains topics.", + instruction=explanation_instruction, + tools=[followup_agent_tool] +) + +# Root agent is the explainer +root_agent = explainer_agent \ No newline at end of file diff --git a/contributing/samples/skip_synthesis_followup/prompts.py b/contributing/samples/skip_synthesis_followup/prompts.py new file mode 100644 index 0000000000..7bc439d28a --- /dev/null +++ b/contributing/samples/skip_synthesis_followup/prompts.py @@ -0,0 +1,38 @@ +explanation_instruction = """You are a helpful AI assistant. + +For every user query: + +1. First understand the intent of the question. +2. Give a clear, step-by-step explanation that walks the user through: + - What the problem or question is + - The key concepts involved + - The reasoning or calculations needed + - The final answer or conclusion +3. Use simple, direct language. Avoid jargon unless the user is clearly an expert. +4. When there is more than one way to approach the problem, briefly mention the alternatives and explain which one you chose and why. +5. If something depends on an assumption, state the assumption explicitly. + +At the end of your explanation, generate helpful follow-up questions that the user might want to ask next. +""" + +followup_instruction = """ +Your task: Generate 3 follow-up questions a student should ask next to deepen their understanding of the concept just explained. + +**Context:** +The preceding explanation was conversational and grade-appropriate. Your questions must match that tone and build directly on the content provided. + +**Rules:** +1. **Exactly 3 questions** - no more, no less +2. **Max 8 words each** - concise and actionable +3. **Never reference** the input format or source ("the image", "what you typed") +4. **No generic questions** - avoid "Tell me more" or "Can you simplify it?" +5. **Mandatory structure:** + - **Q1 (Application):** Apply the concept to a new scenario or practical problem + - **Q2 (Comparison):** Compare to related ideas or explain its significance + - **Q3 (Exploration):** Out-of-the-box question about real-world impact, surprising uses, or limitations + +**Output Format:** +JSON array only. No other text. + +Schema: `["question1","question2","question3"]` +""" \ No newline at end of file diff --git a/src/google/adk/events/event.py b/src/google/adk/events/event.py index cca086430b..fee71a58ab 100644 --- a/src/google/adk/events/event.py +++ b/src/google/adk/events/event.py @@ -87,7 +87,7 @@ def is_final_response(self) -> bool: Note that when multiple agents participate in one invocation, there could be one event has `is_final_response()` as True for each participating agent. """ - if self.actions.skip_summarization or self.long_running_tool_ids: + if self.actions.skip_summarization or self.actions.skip_synthesis or self.long_running_tool_ids: return True return ( not self.get_function_calls() diff --git a/src/google/adk/events/event_actions.py b/src/google/adk/events/event_actions.py index d79a71a7b2..1a83bc9613 100644 --- a/src/google/adk/events/event_actions.py +++ b/src/google/adk/events/event_actions.py @@ -63,6 +63,9 @@ class EventActions(BaseModel): Only used for function_response event. """ + skip_synthesis: Optional[bool] = None + """If true, skip LLM synthesis after tool execution.""" + state_delta: dict[str, object] = Field(default_factory=dict) """Indicates that the event is updating the state with the given delta.""" diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index ffe1657be1..9141b11573 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -798,6 +798,10 @@ def __build_response_event( if not isinstance(function_result, dict): function_result = {'result': function_result} + # Check if tool has skip_synthesis flag set + if tool.skip_synthesis: + tool_context.actions.skip_synthesis = True + part_function_response = types.Part.from_function_response( name=tool.name, response=function_result ) diff --git a/src/google/adk/tools/agent_tool.py b/src/google/adk/tools/agent_tool.py index 46d8616619..ac100e65aa 100644 --- a/src/google/adk/tools/agent_tool.py +++ b/src/google/adk/tools/agent_tool.py @@ -55,14 +55,16 @@ def __init__( self, agent: BaseAgent, skip_summarization: bool = False, + skip_synthesis: bool = False, *, include_plugins: bool = True, ): self.agent = agent self.skip_summarization: bool = skip_summarization + self.skip_synthesis: bool = skip_synthesis self.include_plugins = include_plugins - super().__init__(name=agent.name, description=agent.description) + super().__init__(name=agent.name, description=agent.description, skip_synthesis=skip_synthesis) @model_validator(mode='before') @classmethod @@ -123,6 +125,9 @@ async def run_async( if self.skip_summarization: tool_context.actions.skip_summarization = True + if self.skip_synthesis: + tool_context.actions.skip_synthesis = True + if isinstance(self.agent, LlmAgent) and self.agent.input_schema: input_value = self.agent.input_schema.model_validate(args) content = types.Content( diff --git a/src/google/adk/tools/base_tool.py b/src/google/adk/tools/base_tool.py index c714fb11cb..50e7a6b3e4 100644 --- a/src/google/adk/tools/base_tool.py +++ b/src/google/adk/tools/base_tool.py @@ -56,6 +56,17 @@ class BaseTool(ABC): """Whether the tool is a long running operation, which typically returns a resource id first and finishes the operation later.""" + skip_synthesis: bool = False + """Whether to skip LLM synthesis after this tool executes. + + When True, the tool's response will be returned directly without calling + the LLM again to synthesize/format the results. This is useful for tools + that return data meant to be consumed programmatically or when the LLM + has already provided context before calling the tool. + + Default is False (LLM synthesis happens as normal). + """ + custom_metadata: Optional[dict[str, Any]] = None """The custom metadata of the BaseTool. @@ -71,11 +82,13 @@ def __init__( name, description, is_long_running: bool = False, + skip_synthesis: bool = False, custom_metadata: Optional[dict[str, Any]] = None, ): self.name = name self.description = description self.is_long_running = is_long_running + self.skip_synthesis = skip_synthesis self.custom_metadata = custom_metadata def _get_declaration(self) -> Optional[types.FunctionDeclaration]: diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index d957d1c16b..b09d240dc3 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -47,6 +47,7 @@ def __init__( func: Callable[..., Any], *, require_confirmation: Union[bool, Callable[..., bool]] = False, + skip_synthesis: bool = False, ): """Initializes the FunctionTool. Extracts metadata from a callable object. @@ -78,7 +79,7 @@ def __init__( # For callable objects, try to get docstring from __call__ method doc = inspect.cleandoc(func.__call__.__doc__) - super().__init__(name=name, description=doc) + super().__init__(name=name, description=doc, skip_synthesis=skip_synthesis) self.func = func self._ignore_params = ['tool_context', 'input_stream'] self._require_confirmation = require_confirmation diff --git a/tests/unittests/events/test_event.py b/tests/unittests/events/test_event.py new file mode 100644 index 0000000000..fa718e6b20 --- /dev/null +++ b/tests/unittests/events/test_event.py @@ -0,0 +1,45 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.adk.events.event import Event +from google.adk.events.event_actions import EventActions +from google.genai import types + +def test_is_final_response_with_skip_synthesis(): + """Test that is_final_response returns True when skip_synthesis is True.""" + event = Event( + author='agent', + content=types.Content(role='model', parts=[types.Part(text='response')]), + actions=EventActions(skip_synthesis=True), + ) + assert event.is_final_response() is True + +def test_is_final_response_without_skip_synthesis(): + """Test that is_final_response returns False/True correctly without skip_synthesis.""" + # Case 1: Normal text response -> True + event = Event( + author='agent', + content=types.Content(role='model', parts=[types.Part(text='response')]), + ) + assert event.is_final_response() is True + + # Case 2: Function call -> False + event_fc = Event( + author='agent', + content=types.Content( + role='model', + parts=[types.Part(function_call=types.FunctionCall(name='foo', args={}))] + ), + ) + assert event_fc.is_final_response() is False diff --git a/tests/unittests/flows/llm_flows/test_functions_skip_synthesis.py b/tests/unittests/flows/llm_flows/test_functions_skip_synthesis.py new file mode 100644 index 0000000000..2121951b99 --- /dev/null +++ b/tests/unittests/flows/llm_flows/test_functions_skip_synthesis.py @@ -0,0 +1,62 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from google.genai import types +from google.adk.events.event import Event +from google.adk.tools.function_tool import FunctionTool +from google.adk.agents.llm_agent import Agent +from google.adk.flows.llm_flows.functions import handle_function_calls_async +from ... import testing_utils + +@pytest.mark.asyncio +async def test_function_call_with_skip_synthesis(): + """Test that skip_synthesis is propagated to the response event.""" + + def simple_fn(**kwargs) -> dict: + return {'result': 'test'} + + # Create tool with skip_synthesis=True + tool = FunctionTool(simple_fn, skip_synthesis=True) + + model = testing_utils.MockModel.create(responses=[]) + agent = Agent( + name='test_agent', + model=model, + tools=[tool], + ) + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content='' + ) + + function_call = types.FunctionCall(name=tool.name, args={}) + content = types.Content(parts=[types.Part(function_call=function_call)]) + event = Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + content=content, + ) + tools_dict = {tool.name: tool} + + # Execute the function call + result_event = await handle_function_calls_async( + invocation_context, + event, + tools_dict, + ) + + # Verify that the resulting event has SKIP_SYNTHESIS + assert result_event is not None + assert result_event.actions is not None + assert result_event.actions.skip_synthesis is True diff --git a/tests/unittests/tools/test_function_tool.py b/tests/unittests/tools/test_function_tool.py index 78610d330d..1fa8b987ca 100644 --- a/tests/unittests/tools/test_function_tool.py +++ b/tests/unittests/tools/test_function_tool.py @@ -109,6 +109,7 @@ def function_returning_empty_dict() -> dict[str, str]: return {} + def test_init(): """Test that the FunctionTool is initialized correctly.""" tool = FunctionTool(function_for_testing_with_no_args) @@ -117,6 +118,13 @@ def test_init(): assert tool.func == function_for_testing_with_no_args +def test_init_with_skip_synthesis(): + """Test that the FunctionTool is initialized correctly with skip_synthesis.""" + tool = FunctionTool(function_for_testing_with_no_args, skip_synthesis=True) + assert tool.skip_synthesis is True + + + @pytest.mark.asyncio async def test_function_returning_none(): """Test that the function returns with None actually returning None."""