diff --git a/src/google/adk/cli/conformance/cli_record.py b/src/google/adk/cli/conformance/cli_record.py index eb38c994789..b75c8b08251 100644 --- a/src/google/adk/cli/conformance/cli_record.py +++ b/src/google/adk/cli/conformance/cli_record.py @@ -72,23 +72,18 @@ async def _create_conformance_test_files( # long-running tool. Replace the function call ID with the actual # function call ID. This is needed because the function call ID is not # known when writing the test case. - if ( - user_message.content.parts - and user_message.content.parts[0].function_response - and user_message.content.parts[0].function_response.name - ): - if ( - user_message.content.parts[0].function_response.name - not in function_call_name_to_id_map - ): - raise ValueError( - "Function response for" - f" {user_message.content.parts[0].function_response.name} does" - " not match any pending function call." - ) - content.parts[0].function_response.id = function_call_name_to_id_map[ - user_message.content.parts[0].function_response.name - ] + if user_message.content.parts: + for part in content.parts: + if part.function_response and part.function_response.name: + if part.function_response.name not in function_call_name_to_id_map: + raise ValueError( + "Function response for" + f" {part.function_response.name} does" + " not match any pending function call." + ) + part.function_response.id = function_call_name_to_id_map[ + part.function_response.name + ] elif user_message.text is not None: content = types.UserContent(parts=[types.Part(text=user_message.text)]) else: diff --git a/src/google/adk/cli/conformance/cli_test.py b/src/google/adk/cli/conformance/cli_test.py index bc8337cf0a9..d3e4e6bcb11 100644 --- a/src/google/adk/cli/conformance/cli_test.py +++ b/src/google/adk/cli/conformance/cli_test.py @@ -142,23 +142,18 @@ async def _run_user_messages( # long-running tool. Replace the function call ID with the actual # function call ID. This is needed because the function call ID is not # known when writing the test case. - if ( - user_message.content.parts - and user_message.content.parts[0].function_response - and user_message.content.parts[0].function_response.name - ): - if ( - user_message.content.parts[0].function_response.name - not in function_call_name_to_id_map - ): - raise ValueError( - "Function response for" - f" {user_message.content.parts[0].function_response.name} does" - " not match any pending function call." - ) - content.parts[0].function_response.id = function_call_name_to_id_map[ - user_message.content.parts[0].function_response.name - ] + if user_message.content.parts: + for part in content.parts: + if part.function_response and part.function_response.name: + if part.function_response.name not in function_call_name_to_id_map: + raise ValueError( + "Function response for" + f" {part.function_response.name} does" + " not match any pending function call." + ) + part.function_response.id = function_call_name_to_id_map[ + part.function_response.name + ] elif user_message.text is not None: content = types.UserContent(parts=[types.Part(text=user_message.text)]) else: diff --git a/src/google/adk/flows/llm_flows/base_llm_flow.py b/src/google/adk/flows/llm_flows/base_llm_flow.py index a1bedfbe8d2..320c20c4e5c 100644 --- a/src/google/adk/flows/llm_flows/base_llm_flow.py +++ b/src/google/adk/flows/llm_flows/base_llm_flow.py @@ -714,12 +714,10 @@ async def run_live( # the same function response. By handling agent transfer here, # we ensure that only child agent processes its own function # responses after the transfer. - if ( - event.content - and event.content.parts - and event.content.parts[0].function_response - and event.content.parts[0].function_response.name - == 'transfer_to_agent' + if event.content and event.content.parts and any( + part.function_response + and part.function_response.name == 'transfer_to_agent' + for part in event.content.parts ): await asyncio.sleep(DEFAULT_TRANSFER_AGENT_DELAY) # cancel the tasks that belongs to the closed connection. @@ -751,12 +749,10 @@ async def run_live( ) as agen: async for item in agen: yield item - if ( - event.content - and event.content.parts - and event.content.parts[0].function_response - and event.content.parts[0].function_response.name - == 'task_completed' + if event.content and event.content.parts and any( + part.function_response + and part.function_response.name == 'task_completed' + for part in event.content.parts ): # this is used for sequential agent to signal the end of the agent. await asyncio.sleep(DEFAULT_TASK_COMPLETION_DELAY) diff --git a/src/google/adk/integrations/oci/_oci_genai_llm.py b/src/google/adk/integrations/oci/_oci_genai_llm.py index 1e9b00b56fa..db5f1dcb77e 100644 --- a/src/google/adk/integrations/oci/_oci_genai_llm.py +++ b/src/google/adk/integrations/oci/_oci_genai_llm.py @@ -198,12 +198,15 @@ def _content_to_oci_message(content: types.Content) -> Any: # Tool results map to ToolMessage (one per result) if tool_results: - call_id, result_text = tool_results[0] - return oci_models.ToolMessage( - role=oci_models.ToolMessage.ROLE_TOOL, - tool_call_id=call_id, - content=[oci_models.TextContent(type="TEXT", text=result_text)], - ) + tool_messages = [ + oci_models.ToolMessage( + role=oci_models.ToolMessage.ROLE_TOOL, + tool_call_id=call_id, + content=[oci_models.TextContent(type="TEXT", text=result_text)], + ) + for call_id, result_text in tool_results + ] + return tool_messages if len(tool_messages) > 1 else tool_messages[0] if role == "ASSISTANT": oci_content: list[Any] = [] @@ -451,7 +454,13 @@ def _build_chat_details( """Build OCI ChatDetails from an LlmRequest.""" import oci.generative_ai_inference.models as oci_models - messages = [_content_to_oci_message(c) for c in llm_request.contents or []] + messages = [] + for c in llm_request.contents or []: + message_or_list = _content_to_oci_message(c) + if isinstance(message_or_list, list): + messages.extend(message_or_list) + else: + messages.append(message_or_list) # Prepend SystemMessage when a system instruction is present if llm_request.config and llm_request.config.system_instruction: diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 800783cec44..0b372216f02 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -260,8 +260,11 @@ def trace_tool_call( and function_response_event.content is not None and function_response_event.content.parts ): - response_parts = function_response_event.content.parts - function_response = response_parts[0].function_response + function_response = None + for part in function_response_event.content.parts: + if part.function_response and part.function_response.name == tool.name: + function_response = part.function_response + break if function_response is not None: if function_response.id is not None: tool_call_id = function_response.id diff --git a/src/google/adk/tools/load_artifacts_tool.py b/src/google/adk/tools/load_artifacts_tool.py index 99de971a4d0..2962d1ca244 100644 --- a/src/google/adk/tools/load_artifacts_tool.py +++ b/src/google/adk/tools/load_artifacts_tool.py @@ -286,46 +286,47 @@ async def _append_artifacts_to_llm_request( # Attach the content of the artifacts if the model requests them. # This only adds the content to the model request, instead of the session. if llm_request.contents and llm_request.contents[-1].parts: - function_response = llm_request.contents[-1].parts[0].function_response - if function_response and function_response.name == 'load_artifacts': - response = function_response.response or {} - artifact_names = response.get('artifact_names', []) - for artifact_name in artifact_names: - # Try session-scoped first (default behavior) - artifact = await tool_context.load_artifact(artifact_name) - - # If not found and name doesn't already have user: prefix, - # try cross-session artifacts with user: prefix - if artifact is None and not artifact_name.startswith('user:'): - prefixed_name = f'user:{artifact_name}' - artifact = await tool_context.load_artifact(prefixed_name) - - if artifact is None: - logger.warning('Artifact "%s" not found, skipping', artifact_name) - continue - - artifact_part = _as_safe_part_for_llm(artifact, artifact_name) - if artifact_part is not artifact: - mime_type = ( - artifact.inline_data.mime_type if artifact.inline_data else None - ) - logger.debug( - 'Converted artifact "%s" (mime_type=%s) to text Part', - artifact_name, - mime_type, - ) - - llm_request.contents.append( - types.Content( - role='user', - parts=[ - types.Part.from_text( - text=f'Artifact {artifact_name} is:' - ), - artifact_part, - ], + for part in llm_request.contents[-1].parts: + function_response = part.function_response + if function_response and function_response.name == 'load_artifacts': + response = function_response.response or {} + artifact_names = response.get('artifact_names', []) + for artifact_name in artifact_names: + # Try session-scoped first (default behavior) + artifact = await tool_context.load_artifact(artifact_name) + + # If not found and name doesn't already have user: prefix, + # try cross-session artifacts with user: prefix + if artifact is None and not artifact_name.startswith('user:'): + prefixed_name = f'user:{artifact_name}' + artifact = await tool_context.load_artifact(prefixed_name) + + if artifact is None: + logger.warning('Artifact "%s" not found, skipping', artifact_name) + continue + + artifact_part = _as_safe_part_for_llm(artifact, artifact_name) + if artifact_part is not artifact: + mime_type = ( + artifact.inline_data.mime_type if artifact.inline_data else None + ) + logger.debug( + 'Converted artifact "%s" (mime_type=%s) to text Part', + artifact_name, + mime_type, ) - ) + + llm_request.contents.append( + types.Content( + role='user', + parts=[ + types.Part.from_text( + text=f'Artifact {artifact_name} is:' + ), + artifact_part, + ], + ) + ) load_artifacts_tool = LoadArtifactsTool() diff --git a/src/google/adk/tools/load_mcp_resource_tool.py b/src/google/adk/tools/load_mcp_resource_tool.py index 86eff9182cd..f201bdde225 100644 --- a/src/google/adk/tools/load_mcp_resource_tool.py +++ b/src/google/adk/tools/load_mcp_resource_tool.py @@ -124,32 +124,33 @@ async def _append_resources_to_llm_request( # Attach content if llm_request.contents and llm_request.contents[-1].parts: - function_response = llm_request.contents[-1].parts[0].function_response - if function_response and function_response.name == self.name: - response = function_response.response or {} - resource_names = response.get("resource_names", []) - for resource_name in resource_names: - try: - contents = await self._mcp_toolset.read_resource(resource_name) - - for content in contents: - part = self._mcp_content_to_part(content, resource_name) - llm_request.contents.append( - types.Content( - role="user", - parts=[ - types.Part.from_text( - text=f"Resource {resource_name} is:" - ), - part, - ], - ) + for part in llm_request.contents[-1].parts: + function_response = part.function_response + if function_response and function_response.name == self.name: + response = function_response.response or {} + resource_names = response.get("resource_names", []) + for resource_name in resource_names: + try: + contents = await self._mcp_toolset.read_resource(resource_name) + + for content in contents: + part = self._mcp_content_to_part(content, resource_name) + llm_request.contents.append( + types.Content( + role="user", + parts=[ + types.Part.from_text( + text=f"Resource {resource_name} is:" + ), + part, + ], + ) + ) + except Exception as e: + logger.warning( + "Failed to read MCP resource '%s': %s", resource_name, e ) - except Exception as e: - logger.warning( - "Failed to read MCP resource '%s': %s", resource_name, e - ) - continue + continue def _mcp_content_to_part( self, content: Any, resource_name: str diff --git a/tests/unittests/integrations/oci/test_oci_genai_llm.py b/tests/unittests/integrations/oci/test_oci_genai_llm.py index b076f8a2269..a94ab220c9c 100644 --- a/tests/unittests/integrations/oci/test_oci_genai_llm.py +++ b/tests/unittests/integrations/oci/test_oci_genai_llm.py @@ -227,6 +227,60 @@ def test_content_to_oci_message_function_response(): assert msg.content[0].text +def test_content_to_oci_message_multiple_function_responses(): + import oci.generative_ai_inference.models as oci_models + + part1 = Part.from_function_response( + name="get_weather", response={"result": "Sunny, 22°C"} + ) + part1.function_response.id = "call_A" + + part2 = Part.from_function_response( + name="get_price", response={"result": "$150"} + ) + part2.function_response.id = "call_B" + + content = Content(role="user", parts=[part1, part2]) + msgs = _content_to_oci_message(content) + + assert isinstance(msgs, list) + assert len(msgs) == 2 + + assert isinstance(msgs[0], oci_models.ToolMessage) + assert msgs[0].tool_call_id == "call_A" + + assert isinstance(msgs[1], oci_models.ToolMessage) + assert msgs[1].tool_call_id == "call_B" + + +def test_build_chat_details_flattens_multiple_tool_messages(oci_llm): + import oci.generative_ai_inference.models as oci_models + + part1 = Part.from_function_response( + name="get_weather", response={"result": "Sunny, 22°C"} + ) + part1.function_response.id = "call_A" + + part2 = Part.from_function_response( + name="get_price", response={"result": "$150"} + ) + part2.function_response.id = "call_B" + + request = LlmRequest( + model="google.gemini-2.5-flash", + contents=[Content(role="user", parts=[part1, part2])] + ) + + chat_details = oci_llm._build_chat_details(request) + messages = chat_details.chat_request.messages + + assert len(messages) == 2 + assert isinstance(messages[0], oci_models.ToolMessage) + assert messages[0].tool_call_id == "call_A" + assert isinstance(messages[1], oci_models.ToolMessage) + assert messages[1].tool_call_id == "call_B" + + # --------------------------------------------------------------------------- # _oci_response_to_llm_response # --------------------------------------------------------------------------- diff --git a/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index 8039e918224..49676f80fc9 100644 --- a/tests/unittests/telemetry/test_spans.py +++ b/tests/unittests/telemetry/test_spans.py @@ -542,7 +542,7 @@ def test_trace_tool_call_with_scalar_response( types.Part( function_response=types.FunctionResponse( id=test_tool_call_id, - name='test_function_1', + name=mock_tool_fixture.name, response={'result': scalar_function_response}, ) ), @@ -602,7 +602,7 @@ def test_trace_tool_call_with_dict_response( types.Part( function_response=types.FunctionResponse( id=test_tool_call_id, - name='test_function_1', + name=mock_tool_fixture.name, response=dict_function_response, ) ),