From 07d2cf33141b588012a7e0f8f3f7c8e1c3c771b5 Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 20:54:32 +0100 Subject: [PATCH 01/11] feat(code_execution): add _NON_BUILTIN_EXECUTOR_INSTRUCTION constant Steering prompt that tells Gemini 2.x to wrap code in tool_code fences instead of emitting native executable_code parts when no code_execution tool is declared on the request. Co-Authored-By: Claude Sonnet 4.6 --- .../adk/flows/llm_flows/_code_execution.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/google/adk/flows/llm_flows/_code_execution.py b/src/google/adk/flows/llm_flows/_code_execution.py index 19ec4cc219..d6098f97b0 100644 --- a/src/google/adk/flows/llm_flows/_code_execution.py +++ b/src/google/adk/flows/llm_flows/_code_execution.py @@ -74,6 +74,26 @@ class DataFileUtil: ), } +_NON_BUILTIN_EXECUTOR_INSTRUCTION = """\ +# CRITICAL: Code execution format + +You have access to an external Python sandbox managed by the host +application. To run Python code, output it inside a fenced markdown +block exactly like this: + +```tool_code +print("hello") +``` + +DO NOT emit native executable_code parts. +DO NOT attempt to call a code_execution tool — no such tool is +registered for this request and the API will reject the response with +UNEXPECTED_TOOL_CALL or MALFORMED_FUNCTION_CALL. + +Always wrap Python code in the tool_code markdown fence shown +above. +""" + _DATA_FILE_HELPER_LIB = ''' import pandas as pd From d8895d0e76c42824127a30fe88b31aa56a9997f2 Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 20:55:24 +0100 Subject: [PATCH 02/11] feat(code_execution): add _RECOVERABLE_API_ERRORS and _UNEXPECTED_TOOL_CALL_RE constants Frozenset of Gemini 2.x error codes that indicate a native code_execution tool call was rejected, and a regex to extract the code payload from the error message. --- src/google/adk/flows/llm_flows/_code_execution.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/google/adk/flows/llm_flows/_code_execution.py b/src/google/adk/flows/llm_flows/_code_execution.py index d6098f97b0..a67936a50b 100644 --- a/src/google/adk/flows/llm_flows/_code_execution.py +++ b/src/google/adk/flows/llm_flows/_code_execution.py @@ -94,6 +94,16 @@ class DataFileUtil: above. """ +# Recoverable API rejection codes for Gemini 2.x emitting code as a +# native tool call when no code_execution tool was declared. +_RECOVERABLE_API_ERRORS = frozenset( + {'UNEXPECTED_TOOL_CALL', 'MALFORMED_FUNCTION_CALL'} +) + +_UNEXPECTED_TOOL_CALL_RE = re.compile( + r'^\s*Unexpected tool call:\s*(?P.+?)\s*$', re.DOTALL +) + _DATA_FILE_HELPER_LIB = ''' import pandas as pd From 3982724be0dee4a3c67d7ba21cb4c940b76f221d Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 20:55:56 +0100 Subject: [PATCH 03/11] feat(code_execution): add _extract_code_from_error_message helper Parses the code payload out of a Gemini UNEXPECTED_TOOL_CALL rejection error message using _UNEXPECTED_TOOL_CALL_RE. --- src/google/adk/flows/llm_flows/_code_execution.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/google/adk/flows/llm_flows/_code_execution.py b/src/google/adk/flows/llm_flows/_code_execution.py index a67936a50b..48b7fc8497 100644 --- a/src/google/adk/flows/llm_flows/_code_execution.py +++ b/src/google/adk/flows/llm_flows/_code_execution.py @@ -104,6 +104,17 @@ class DataFileUtil: r'^\s*Unexpected tool call:\s*(?P.+?)\s*$', re.DOTALL ) + +def _extract_code_from_error_message(error_message: Optional[str]) -> Optional[str]: + """Best-effort extraction of code from a Gemini API rejection error message.""" + if not error_message: + return None + m = _UNEXPECTED_TOOL_CALL_RE.match(error_message) + if m: + return m.group('code').strip() or None + return None + + _DATA_FILE_HELPER_LIB = ''' import pandas as pd From dad0a565d4443cc8fb52fb6d134bc918e664d428 Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 20:56:16 +0100 Subject: [PATCH 04/11] feat(code_execution): add _maybe_recover_from_api_rejection helper Reconstructs the executable_code part that Gemini 2.x intended to emit when the API rejected the response with UNEXPECTED_TOOL_CALL or MALFORMED_FUNCTION_CALL, allowing the sandbox executor pipeline to proceed normally. --- .../adk/flows/llm_flows/_code_execution.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/google/adk/flows/llm_flows/_code_execution.py b/src/google/adk/flows/llm_flows/_code_execution.py index 48b7fc8497..8e3ebea450 100644 --- a/src/google/adk/flows/llm_flows/_code_execution.py +++ b/src/google/adk/flows/llm_flows/_code_execution.py @@ -115,6 +115,48 @@ def _extract_code_from_error_message(error_message: Optional[str]) -> Optional[s return None +def _maybe_recover_from_api_rejection(llm_response) -> bool: + """Recovers an executable_code part from a Gemini 2.x API rejection. + + When ADK uses a non-built-in code executor (e.g., + AgentEngineSandboxCodeExecutor) with Gemini 2.x, the model may emit a + native code_execution tool call. Because no such tool is declared in + the request, the server rejects the response with UNEXPECTED_TOOL_CALL + (or MALFORMED_FUNCTION_CALL when other tools are present), and + llm_response.content ends up empty. + + This function reconstructs the executable_code part the model intended + to emit so the existing post-processor pipeline can run it through the + configured sandbox executor. + + Returns True if recovery occurred and llm_response was mutated. + """ + error_code = llm_response.error_code + if error_code is None: + return False + error_code_name = getattr(error_code, 'name', str(error_code)) + if error_code_name not in _RECOVERABLE_API_ERRORS: + return False + + code_str = _extract_code_from_error_message(llm_response.error_message) + if not code_str: + return False + + llm_response.content = types.Content( + role='model', + parts=[CodeExecutionUtils.build_executable_code_part(code_str)], + ) + llm_response.error_code = None + llm_response.error_message = None + llm_response.finish_reason = None + logger.info( + 'Recovered code from API %s rejection; routing to configured' + ' code executor.', + error_code_name, + ) + return True + + _DATA_FILE_HELPER_LIB = ''' import pandas as pd From a6b7eab8bce25f644e5eb39eebb495b157b60f6d Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 20:56:39 +0100 Subject: [PATCH 05/11] fix(code_execution): inject tool_code fence instruction for non-built-in executors Appends _NON_BUILTIN_EXECUTOR_INSTRUCTION to every LLM request that uses a non-BuiltInCodeExecutor, steering Gemini 2.x to output code in tool_code markdown fences rather than native executable_code parts which the API rejects as UNEXPECTED_TOOL_CALL. --- src/google/adk/flows/llm_flows/_code_execution.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/google/adk/flows/llm_flows/_code_execution.py b/src/google/adk/flows/llm_flows/_code_execution.py index 8e3ebea450..a1648b25be 100644 --- a/src/google/adk/flows/llm_flows/_code_execution.py +++ b/src/google/adk/flows/llm_flows/_code_execution.py @@ -270,6 +270,13 @@ async def _run_pre_processor( code_executor.process_llm_request(llm_request) return + # Steer Gemini 2.x (and other modern models) away from emitting a + # native `executable_code` / code_execution tool call. When the + # configured executor is *not* the built-in one, no `code_execution` + # tool is declared on the request, and a native emission would be + # rejected by the API as UNEXPECTED_TOOL_CALL / MALFORMED_FUNCTION_CALL. + llm_request.append_instructions([_NON_BUILTIN_EXECUTOR_INSTRUCTION]) + if not code_executor.optimize_data_file: return From d3d9ed45c86dfb6d85dedc9ee2d2cc3ab9368236 Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 20:57:06 +0100 Subject: [PATCH 06/11] fix(code_execution): recover from UNEXPECTED_TOOL_CALL rejection in post-processor When Gemini 2.x emits a native code_execution call and the API rejects it, llm_response.content is empty. For non-built-in executors, attempt to reconstruct the executable_code part from the error message via _maybe_recover_from_api_rejection so the sandbox executor pipeline can still run the code. --- .../adk/flows/llm_flows/_code_execution.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/google/adk/flows/llm_flows/_code_execution.py b/src/google/adk/flows/llm_flows/_code_execution.py index a1648b25be..232a2d41ab 100644 --- a/src/google/adk/flows/llm_flows/_code_execution.py +++ b/src/google/adk/flows/llm_flows/_code_execution.py @@ -363,7 +363,21 @@ async def _run_post_processor( if not code_executor or not isinstance(code_executor, BaseCodeExecutor): return - if not llm_response or not llm_response.content: + if not llm_response: + return + + # When the API rejected the response because the model emitted a native + # code_execution tool call (UNEXPECTED_TOOL_CALL / MALFORMED_FUNCTION_CALL), + # llm_response.content is empty. For non-built-in executors, try to + # recover the intended code from the error message so we can still run + # it in the configured sandbox. + if not llm_response.content and not isinstance( + code_executor, BuiltInCodeExecutor + ): + if not _maybe_recover_from_api_rejection(llm_response): + return + + if not llm_response.content: return if isinstance(code_executor, BuiltInCodeExecutor): From 3eb6564cdcba351d1758c9641812fef3583facbe Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 21:01:53 +0100 Subject: [PATCH 07/11] refactor(code_execution): apply pyink line-wrap to _extract_code_from_error_message --- src/google/adk/flows/llm_flows/_code_execution.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/google/adk/flows/llm_flows/_code_execution.py b/src/google/adk/flows/llm_flows/_code_execution.py index 232a2d41ab..06860c2aac 100644 --- a/src/google/adk/flows/llm_flows/_code_execution.py +++ b/src/google/adk/flows/llm_flows/_code_execution.py @@ -105,7 +105,9 @@ class DataFileUtil: ) -def _extract_code_from_error_message(error_message: Optional[str]) -> Optional[str]: +def _extract_code_from_error_message( + error_message: Optional[str], +) -> Optional[str]: """Best-effort extraction of code from a Gemini API rejection error message.""" if not error_message: return None From 3b63af894a66ff552d7f855b6d0f0a8270c87f7c Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 21:06:39 +0100 Subject: [PATCH 08/11] test(code_execution): add tests for _extract_code_from_error_message --- .../flows/llm_flows/test_code_execution.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unittests/flows/llm_flows/test_code_execution.py b/tests/unittests/flows/llm_flows/test_code_execution.py index 69f2d7832d..a62221438c 100644 --- a/tests/unittests/flows/llm_flows/test_code_execution.py +++ b/tests/unittests/flows/llm_flows/test_code_execution.py @@ -23,6 +23,7 @@ from google.adk.code_executors.base_code_executor import BaseCodeExecutor from google.adk.code_executors.built_in_code_executor import BuiltInCodeExecutor from google.adk.code_executors.code_execution_utils import CodeExecutionResult +from google.adk.flows.llm_flows._code_execution import _extract_code_from_error_message from google.adk.flows.llm_flows._code_execution import response_processor from google.adk.models.llm_response import LlmResponse from google.genai import types @@ -30,6 +31,29 @@ from ... import testing_utils +# --------------------------------------------------------------------------- +# _extract_code_from_error_message +# --------------------------------------------------------------------------- + + +def test_extract_code_from_error_message_valid(): + code = _extract_code_from_error_message('Unexpected tool call: print(1+1)') + assert code == 'print(1+1)' + + +def test_extract_code_from_error_message_multiline(): + msg = 'Unexpected tool call: x = 1\nprint(x)' + code = _extract_code_from_error_message(msg) + assert code == 'x = 1\nprint(x)' + + +def test_extract_code_from_error_message_none(): + assert _extract_code_from_error_message(None) is None + + +def test_extract_code_from_error_message_no_match(): + assert _extract_code_from_error_message('some other error') is None + @pytest.mark.asyncio @patch('google.adk.flows.llm_flows._code_execution.datetime') From 30748197779755486e94b02b90fb9066877143a8 Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 21:08:35 +0100 Subject: [PATCH 09/11] test(code_execution): add tests for _maybe_recover_from_api_rejection --- .../flows/llm_flows/test_code_execution.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/unittests/flows/llm_flows/test_code_execution.py b/tests/unittests/flows/llm_flows/test_code_execution.py index a62221438c..a7085cc4f7 100644 --- a/tests/unittests/flows/llm_flows/test_code_execution.py +++ b/tests/unittests/flows/llm_flows/test_code_execution.py @@ -24,6 +24,7 @@ from google.adk.code_executors.built_in_code_executor import BuiltInCodeExecutor from google.adk.code_executors.code_execution_utils import CodeExecutionResult from google.adk.flows.llm_flows._code_execution import _extract_code_from_error_message +from google.adk.flows.llm_flows._code_execution import _maybe_recover_from_api_rejection from google.adk.flows.llm_flows._code_execution import response_processor from google.adk.models.llm_response import LlmResponse from google.genai import types @@ -174,3 +175,55 @@ async def test_logs_executed_code(mock_logger): mock_logger.debug.assert_called_once_with( 'Executed code:\n```\n%s\n```', 'print("hello")' ) + + +# --------------------------------------------------------------------------- +# _maybe_recover_from_api_rejection +# --------------------------------------------------------------------------- + + +def _make_rejected_response(error_code: str, code_snippet: str) -> LlmResponse: + return LlmResponse( + content=None, + error_code=error_code, + error_message=f'Unexpected tool call: {code_snippet}', + ) + + +def test_maybe_recover_unexpected_tool_call(): + llm_response = _make_rejected_response('UNEXPECTED_TOOL_CALL', 'print(42)') + recovered = _maybe_recover_from_api_rejection(llm_response) + + assert recovered is True + assert llm_response.content is not None + assert len(llm_response.content.parts) == 1 + assert llm_response.content.parts[0].executable_code.code == 'print(42)' + assert llm_response.error_code is None + assert llm_response.error_message is None + assert llm_response.finish_reason is None + + +def test_maybe_recover_malformed_function_call(): + llm_response = _make_rejected_response('MALFORMED_FUNCTION_CALL', 'x=1') + assert _maybe_recover_from_api_rejection(llm_response) is True + assert llm_response.content is not None + + +def test_maybe_recover_unrecognised_error_code(): + llm_response = _make_rejected_response('SAFETY', 'print(42)') + assert _maybe_recover_from_api_rejection(llm_response) is False + assert llm_response.content is None + + +def test_maybe_recover_no_error_code(): + llm_response = LlmResponse(content=None, error_code=None, error_message=None) + assert _maybe_recover_from_api_rejection(llm_response) is False + + +def test_maybe_recover_unparseable_message(): + llm_response = LlmResponse( + content=None, + error_code='UNEXPECTED_TOOL_CALL', + error_message='some completely different message', + ) + assert _maybe_recover_from_api_rejection(llm_response) is False From 79777139fdf698976a67400d31cc0fa3988ebb0c Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 21:09:39 +0100 Subject: [PATCH 10/11] test(code_execution): add tests for pre-processor instruction injection --- .../flows/llm_flows/test_code_execution.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/unittests/flows/llm_flows/test_code_execution.py b/tests/unittests/flows/llm_flows/test_code_execution.py index a7085cc4f7..188a1fe2f8 100644 --- a/tests/unittests/flows/llm_flows/test_code_execution.py +++ b/tests/unittests/flows/llm_flows/test_code_execution.py @@ -25,7 +25,10 @@ from google.adk.code_executors.code_execution_utils import CodeExecutionResult from google.adk.flows.llm_flows._code_execution import _extract_code_from_error_message from google.adk.flows.llm_flows._code_execution import _maybe_recover_from_api_rejection +from google.adk.flows.llm_flows._code_execution import _NON_BUILTIN_EXECUTOR_INSTRUCTION +from google.adk.flows.llm_flows._code_execution import request_processor from google.adk.flows.llm_flows._code_execution import response_processor +from google.adk.models.llm_request import LlmRequest from google.adk.models.llm_response import LlmResponse from google.genai import types import pytest @@ -227,3 +230,52 @@ def test_maybe_recover_unparseable_message(): error_message='some completely different message', ) assert _maybe_recover_from_api_rejection(llm_response) is False + + +# --------------------------------------------------------------------------- +# Pre-processor: instruction injection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pre_processor_injects_instruction_for_non_builtin_executor(): + mock_executor = MagicMock(spec=BaseCodeExecutor) + mock_executor.optimize_data_file = False + + agent = Agent(name='test_agent', code_executor=mock_executor) + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content='run some code' + ) + llm_request = LlmRequest() + + _ = [ + event + async for event in request_processor.run_async( + invocation_context, llm_request + ) + ] + + assert llm_request.config.system_instruction is not None + assert _NON_BUILTIN_EXECUTOR_INSTRUCTION in str( + llm_request.config.system_instruction + ) + + +@pytest.mark.asyncio +async def test_pre_processor_does_not_inject_instruction_for_builtin_executor(): + code_executor = BuiltInCodeExecutor() + agent = Agent(name='test_agent', code_executor=code_executor) + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content='run some code' + ) + llm_request = LlmRequest(model='gemini-2.0-flash') + + _ = [ + event + async for event in request_processor.run_async( + invocation_context, llm_request + ) + ] + + system_instruction = str(llm_request.config.system_instruction or '') + assert _NON_BUILTIN_EXECUTOR_INSTRUCTION not in system_instruction From 91e2aaf5ab83bdd8642bf8674a2c436a17f62b3b Mon Sep 17 00:00:00 2001 From: Jesser Hamdaoui Date: Sun, 26 Apr 2026 21:10:45 +0100 Subject: [PATCH 11/11] test(code_execution): add tests for post-processor API rejection recovery --- .../flows/llm_flows/test_code_execution.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/unittests/flows/llm_flows/test_code_execution.py b/tests/unittests/flows/llm_flows/test_code_execution.py index 188a1fe2f8..dce7cc8f72 100644 --- a/tests/unittests/flows/llm_flows/test_code_execution.py +++ b/tests/unittests/flows/llm_flows/test_code_execution.py @@ -279,3 +279,74 @@ async def test_pre_processor_does_not_inject_instruction_for_builtin_executor(): system_instruction = str(llm_request.config.system_instruction or '') assert _NON_BUILTIN_EXECUTOR_INSTRUCTION not in system_instruction + + +# --------------------------------------------------------------------------- +# Post-processor: API rejection recovery path +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch('google.adk.flows.llm_flows._code_execution.logger') +async def test_post_processor_recovers_from_unexpected_tool_call(mock_logger): + mock_executor = MagicMock(spec=BaseCodeExecutor) + mock_executor.code_block_delimiters = [('```tool_code\n', '\n```')] + mock_executor.error_retry_attempts = 2 + mock_executor.stateful = False + mock_executor.execute_code.return_value = CodeExecutionResult(stdout='42') + + agent = Agent(name='test_agent', code_executor=mock_executor) + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content='run some code' + ) + invocation_context.artifact_service = MagicMock() + invocation_context.artifact_service.save_artifact = AsyncMock( + return_value='v1' + ) + + llm_response = LlmResponse( + content=None, + error_code='UNEXPECTED_TOOL_CALL', + error_message='Unexpected tool call: print(6*7)', + ) + + events = [ + event + async for event in response_processor.run_async( + invocation_context, llm_response + ) + ] + + mock_executor.execute_code.assert_called_once() + call_input = mock_executor.execute_code.call_args[0][1] + assert call_input.code == 'print(6*7)' + assert len(events) == 2 + mock_logger.info.assert_called_once() + + +@pytest.mark.asyncio +async def test_post_processor_skips_recovery_for_builtin_executor(): + code_executor = BuiltInCodeExecutor() + agent = Agent(name='test_agent', code_executor=code_executor) + invocation_context = await testing_utils.create_invocation_context( + agent=agent, user_content='run some code' + ) + invocation_context.artifact_service = MagicMock() + invocation_context.artifact_service.save_artifact = AsyncMock() + + llm_response = LlmResponse( + content=None, + error_code='UNEXPECTED_TOOL_CALL', + error_message='Unexpected tool call: print(1)', + ) + + events = [ + event + async for event in response_processor.run_async( + invocation_context, llm_response + ) + ] + + # BuiltInCodeExecutor path bails out early — no events, no artifact saves. + assert events == [] + invocation_context.artifact_service.save_artifact.assert_not_called()