diff --git a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py index 0db62f1be6..d031a5f13d 100644 --- a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py +++ b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py @@ -8191,13 +8191,29 @@ async def after_tool_callback( span_id, duration = TraceManager.pop_span() parent_span_id, _ = TraceManager.get_current_span_and_parent() + # MCP CallToolResult (isError) and plugin-rewritten failures + # (error_details) reach here as ordinary results. Classify them as + # TOOL_ERROR so status/error_message match on_tool_error_callback; + # otherwise audits treat them as successes. + error_message = None + if isinstance(result, dict): + if result.get("error_details"): + error_message = str(result["error_details"]) + elif result.get("isError") or result.get("is_error"): + content = result.get("content") + first = content[0] if isinstance(content, list) and content else None + text = first.get("text") if isinstance(first, dict) else None + error_message = str(text) if text else "Tool returned an error" + event_data = EventData( latency_ms=duration, span_id_override=span_id, parent_span_id_override=parent_span_id, + status="ERROR" if error_message is not None else "OK", + error_message=error_message, ) await self._log_event( - "TOOL_COMPLETED", + "TOOL_ERROR" if error_message is not None else "TOOL_COMPLETED", tool_context, raw_content=content_dict, is_truncated=is_truncated, @@ -8211,7 +8227,10 @@ async def after_tool_callback( # args (the final-answer payload the model supplied) as AGENT_RESPONSE so # the visible response text is captured. Opt-in via # ``config.final_response_tool_names`` (empty by default). - if tool.name in self.config.final_response_tool_names: + if ( + error_message is None + and tool.name in self.config.final_response_tool_names + ): args_truncated, args_is_truncated = _recursive_smart_truncate( tool_args, self.config.max_content_length ) diff --git a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py index 854d47b031..1257b2e9e1 100644 --- a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py +++ b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py @@ -2119,6 +2119,52 @@ async def test_after_tool_callback_logs_correctly( assert content_dict["tool"] == "MyTool" assert content_dict["result"] == {"res": "success"} + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("result", "expected_error_message"), + [ + ( + { + "content": [ + {"type": "text", "text": "Git reset to remote failed."} + ], + "isError": True, + }, + "Git reset to remote failed.", + ), + ( + {"error_details": "MCP request failed with code 403"}, + "MCP request failed with code 403", + ), + ], + ) + async def test_after_tool_callback_logs_error_bearing_result_as_tool_error( + self, bq_plugin_inst, tool_context, result, expected_error_message + ): + """Error-bearing tool results are recorded as TOOL_ERROR, not TOOL_COMPLETED.""" + mock_tool = mock.create_autospec( + base_tool_lib.BaseTool, instance=True, spec_set=True + ) + type(mock_tool).name = mock.PropertyMock(return_value="MyTool") + type(mock_tool).description = mock.PropertyMock(return_value="Description") + log_event = mock.AsyncMock() + bq_plugin_inst._log_event = log_event + bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context) + + await bq_plugin_inst.after_tool_callback( + tool=mock_tool, + tool_args={"arg1": "val1"}, + tool_context=tool_context, + result=result, + ) + + log_event.assert_awaited_once() + assert log_event.await_args.args[0] == "TOOL_ERROR" + event_data = log_event.await_args.kwargs["event_data"] + assert event_data.status == "ERROR" + assert event_data.error_message == expected_error_message + assert log_event.await_args.kwargs["raw_content"]["tool"] == "MyTool" + @pytest.mark.asyncio async def test_after_tool_callback_no_state_delta_logging( self, bq_plugin_inst, mock_write_client, tool_context, dummy_arrow_schema