diff --git a/.github/workflows/check-file-contents.yml b/.github/workflows/check-file-contents.yml index a6c31788fa..42d820ab47 100644 --- a/.github/workflows/check-file-contents.yml +++ b/.github/workflows/check-file-contents.yml @@ -111,7 +111,7 @@ jobs: # 1. Identify files containing any googleapis.com URL. set +e FILES_WITH_ENDPOINTS=$(grep -lE 'https?://[a-zA-Z0-9.-]+\.googleapis\.com' $CHANGED_FILES) - + # 2. From those, identify files that are MISSING the required mTLS version. if [ -n "$FILES_WITH_ENDPOINTS" ]; then FILES_MISSING_MTLS=$(grep -L '.mtls.googleapis.com' $FILES_WITH_ENDPOINTS) diff --git a/contributing/samples/integrations/data_agent/agent.py b/contributing/samples/integrations/data_agent/agent.py index c72667eeff..696430cf06 100644 --- a/contributing/samples/integrations/data_agent/agent.py +++ b/contributing/samples/integrations/data_agent/agent.py @@ -79,6 +79,7 @@ ], ) + # NOTE: The generate_chart tool requires 'altair' and 'vl-convert-python' to be # installed in your environment. You can install them using: # pip install altair vl-convert-python diff --git a/src/google/adk/apps/compaction.py b/src/google/adk/apps/compaction.py index 5a1dd4af8b..a028089859 100644 --- a/src/google/adk/apps/compaction.py +++ b/src/google/adk/apps/compaction.py @@ -306,6 +306,12 @@ def _event_function_call_ids(event: Event) -> set[str]: def _event_function_response_ids(event: Event) -> set[str]: """Returns function response ids found in an event.""" + # Ignore intermediate/pending LRF responses marked on the event. + if event.actions and getattr( + event.actions, 'is_intermediate_long_running_response', False + ): + return set() + function_response_ids: set[str] = set() for function_response in event.get_function_responses(): if function_response.id: diff --git a/src/google/adk/cli/utils/evals.py b/src/google/adk/cli/utils/evals.py index d2ca8878f3..16cf82ee77 100644 --- a/src/google/adk/cli/utils/evals.py +++ b/src/google/adk/cli/utils/evals.py @@ -15,7 +15,8 @@ from __future__ import annotations import os -from typing import Any, TYPE_CHECKING +from typing import Any +from typing import TYPE_CHECKING from pydantic import alias_generators from pydantic import BaseModel @@ -77,9 +78,9 @@ def create_gcs_eval_managers_from_uri( from ...evaluation.gcs_eval_sets_manager import GcsEvalSetsManager except ImportError as e: raise RuntimeError( - 'GCS evaluation managers require Google Cloud optional dependencies.\n' - 'Please install them using: pip install google-adk[gcp]\n' - 'Or: pip install google-cloud-storage>=2.18' + 'GCS evaluation managers require Google Cloud optional' + ' dependencies.\nPlease install them using: pip install' + ' google-adk[gcp]\nOr: pip install google-cloud-storage>=2.18' ) from e gcs_bucket = eval_storage_uri.split('://')[1] diff --git a/src/google/adk/events/event_actions.py b/src/google/adk/events/event_actions.py index 3f52cc5f81..006fc13352 100644 --- a/src/google/adk/events/event_actions.py +++ b/src/google/adk/events/event_actions.py @@ -119,3 +119,10 @@ class EventActions(BaseModel): set_model_response: Optional[Any] = None """The model response structured output.""" + + # Marks a function_response event as an intermediate/pending response + # emitted by a long-running tool. When True, compaction should treat the + # event's function response as non-final and not use it to consider a + # function call resolved. + is_intermediate_long_running_response: Optional[bool] = None + """If true, the function_response is intermediate/pending for an LRF.""" diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index 259d40b6b6..29d39afe68 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -1029,6 +1029,16 @@ async def run_tool_and_update_queue(tool, function_args, tool_context): ' pending.' ) } + # Mark the tool context actions so downstream compaction logic knows + # this function_response is an intermediate/pending update and should + # not be treated as a final response that resolves the corresponding + # function call. + try: + tool_context.actions.is_intermediate_long_running_response = True + except Exception: + # Defensive: do not let instrumentation or missing actions break + # tool execution. + logger.debug('Unable to mark intermediate long-running response') else: # Check if we should run tools in thread pool to avoid blocking event loop thread_pool_config = invocation_context.run_config.tool_thread_pool_config diff --git a/src/google/adk/skills/_utils.py b/src/google/adk/skills/_utils.py index bbe0280f37..a42e531029 100644 --- a/src/google/adk/skills/_utils.py +++ b/src/google/adk/skills/_utils.py @@ -411,7 +411,8 @@ def _list_skills_in_gcs_dir( except ImportError as e: raise ImportError( "google-cloud-storage is required to list skills in GCS. Install it" - " with `pip install google-cloud-storage` or `pip install google-adk[gcp]`." + " with `pip install google-cloud-storage` or `pip install" + " google-adk[gcp]`." ) from e client = storage.Client(project=project_id, credentials=credentials) @@ -478,7 +479,8 @@ def _load_skill_from_gcs_dir( except ImportError as e: raise ImportError( "google-cloud-storage is required to load skills from GCS. Install it" - " with `pip install google-cloud-storage` or `pip install google-adk[gcp]`." + " with `pip install google-cloud-storage` or `pip install" + " google-adk[gcp]`." ) from e client = storage.Client(project=project_id, credentials=credentials) diff --git a/tests/unittests/apps/test_compaction.py b/tests/unittests/apps/test_compaction.py index 5db1443e99..d6f6c425bd 100644 --- a/tests/unittests/apps/test_compaction.py +++ b/tests/unittests/apps/test_compaction.py @@ -19,6 +19,7 @@ from google.adk.agents.base_agent import BaseAgent from google.adk.apps.app import App from google.adk.apps.app import EventsCompactionConfig +from google.adk.apps.app import ResumabilityConfig from google.adk.apps.base_events_summarizer import BaseEventsSummarizer from google.adk.apps.compaction import _run_compaction_for_sliding_window import google.adk.apps.compaction as compaction_module @@ -1198,6 +1199,210 @@ async def test_sliding_window_pending_function_call_remains_in_contents( ) self.assertEqual(result_contents[2].parts[0].text, 'e3') + @pytest.mark.xfail( + reason=( + 'Sliding-window compaction folds unresolved long-running tool calls ' + 'before the resume response arrives.' + ) + ) + async def test_sliding_window_compacts_long_running_function_call_before_resume( + self, + ): + """Long-running tool resumes should survive sliding-window compaction.""" + app = App( + name='test', + root_agent=Mock(spec=BaseAgent), + resumability_config=ResumabilityConfig(is_resumable=True), + events_compaction_config=EventsCompactionConfig( + summarizer=self.mock_compactor, + compaction_interval=2, + overlap_size=0, + ), + ) + function_call_id = 'long-running-call-1' + events = [ + self._create_event(1.0, 'inv1', 'e1'), + Event( + timestamp=2.0, + invocation_id='inv2', + author='agent', + content=Content( + role='model', + parts=[ + Part( + function_call=types.FunctionCall( + id=function_call_id, + name='long_running_tool_func', + args={}, + ) + ) + ], + ), + long_running_tool_ids={function_call_id}, + ), + Event( + timestamp=3.0, + invocation_id='inv2', + author='agent', + content=Content( + role='user', + parts=[ + Part( + function_response=types.FunctionResponse( + id=function_call_id, + name='long_running_tool_func', + response={'status': 'pending'}, + ) + ) + ], + ), + ), + self._create_event(4.0, 'inv3', 'e3'), + ] + session = Session(app_name='test', user_id='u1', id='s1', events=events) + + mock_compacted_event = self._create_compacted_event( + 1.0, 4.0, 'Summary inv1-inv3' + ) + self.mock_compactor.maybe_summarize_events.return_value = ( + mock_compacted_event + ) + + await _run_compaction_for_sliding_window( + app, session, self.mock_session_service + ) + + appended_event = self.mock_session_service.append_event.call_args[1][ + 'event' + ] + resume_event = Event( + timestamp=5.0, + invocation_id='inv4', + author='user', + content=Content( + role='user', + parts=[ + Part( + function_response=types.FunctionResponse( + id=function_call_id, + name='long_running_tool_func', + response={'status': 'confirmed'}, + ) + ) + ], + ), + ) + + _contents._get_contents(None, events + [appended_event, resume_event]) + + async def test_sliding_window_preserves_long_running_call_until_resume( + self, + ): + """Regression: intermediate LRF responses must not resolve the call for compaction.""" + app = App( + name='test', + root_agent=Mock(spec=BaseAgent), + resumability_config=ResumabilityConfig(is_resumable=True), + events_compaction_config=EventsCompactionConfig( + summarizer=self.mock_compactor, + compaction_interval=2, + overlap_size=0, + ), + ) + function_call_id = 'long-running-call-1' + events = [ + self._create_event(1.0, 'inv1', 'e1'), + Event( + timestamp=2.0, + invocation_id='inv2', + author='agent', + content=Content( + role='model', + parts=[ + Part( + function_call=types.FunctionCall( + id=function_call_id, + name='long_running_tool_func', + args={}, + ) + ) + ], + ), + long_running_tool_ids={function_call_id}, + ), + # Intermediate/pending response — mark as intermediate via actions + Event( + timestamp=3.0, + invocation_id='inv2', + author='agent', + content=Content( + role='user', + parts=[ + Part( + function_response=types.FunctionResponse( + id=function_call_id, + name='long_running_tool_func', + response={'status': 'pending'}, + ) + ) + ], + ), + actions=EventActions(is_intermediate_long_running_response=True), + ), + self._create_event(4.0, 'inv3', 'e3'), + ] + session = Session(app_name='test', user_id='u1', id='s1', events=events) + + mock_compacted_event = self._create_compacted_event( + 1.0, 4.0, 'Summary inv1-inv3' + ) + self.mock_compactor.maybe_summarize_events.return_value = ( + mock_compacted_event + ) + + await _run_compaction_for_sliding_window( + app, session, self.mock_session_service + ) + + appended_event = self.mock_session_service.append_event.call_args[1][ + 'event' + ] + + # Now simulate the actual resume arriving later and build contents — this + # should not raise and the long-running call should still be resolvable. + resume_event = Event( + timestamp=5.0, + invocation_id='inv4', + author='user', + content=Content( + role='user', + parts=[ + Part( + function_response=types.FunctionResponse( + id=function_call_id, + name='long_running_tool_func', + response={'status': 'confirmed'}, + ) + ) + ], + ), + ) + + result_contents = _contents._get_contents( + None, events + [appended_event, resume_event] + ) + + # Ensure the long-running function call remains visible (one of the + # content items should be a function_call with the expected name). + assert any( + ( + c.parts + and c.parts[0].function_call + and c.parts[0].function_call.name == 'long_running_tool_func' + ) + for c in result_contents + ) + async def test_token_threshold_excludes_pending_function_call_events(self): """Token-threshold compaction stays contiguous before pending calls.""" app = App( diff --git a/tests/unittests/skills/test__utils.py b/tests/unittests/skills/test__utils.py index 0547c630a5..abae9cd8b8 100644 --- a/tests/unittests/skills/test__utils.py +++ b/tests/unittests/skills/test__utils.py @@ -393,5 +393,3 @@ def mock_import(name, globals=None, locals=None, fromlist=(), level=0): with mock.patch("builtins.__import__", mock_import): with pytest.raises(ImportError, match="google-cloud-storage is required"): _load_skill_from_gcs_dir("my-bucket", "skills/my-skill/") - -