diff --git a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py index 8ad3b6c147..1aaa45d026 100644 --- a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py +++ b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py @@ -8771,13 +8771,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, @@ -8791,7 +8807,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 1e45952da9..dd0297ea77 100644 --- a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py +++ b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py @@ -1180,6 +1180,54 @@ async def test_after_tool_callback_no_agent_response_by_default( event_types = [r["event_type"] for r in rows] assert "AGENT_RESPONSE" not in event_types + @pytest.mark.asyncio + async def test_after_tool_callback_no_agent_response_on_final_tool_error( + self, + mock_write_client, + tool_context, + mock_auth_default, + mock_bq_client, + mock_to_arrow_schema, + dummy_arrow_schema, + mock_asyncio_to_thread, + ): + """Error-bearing final-response tools must not emit AGENT_RESPONSE.""" + _ = mock_auth_default + _ = mock_bq_client + _ = mock_to_arrow_schema + _ = mock_asyncio_to_thread + config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig( + final_response_tool_names=frozenset({"submit_final_response"}) + ) + async with managed_plugin( + PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config + ) as plugin: + await plugin._ensure_started() + mock_write_client.append_rows.reset_mock() + mock_tool = mock.create_autospec( + base_tool_lib.BaseTool, instance=True, spec_set=True + ) + type(mock_tool).name = mock.PropertyMock( + return_value="submit_final_response" + ) + bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context) + await plugin.after_tool_callback( + tool=mock_tool, + tool_args={"answer": "should not become AGENT_RESPONSE"}, + tool_context=tool_context, + result={"error_details": "MCP request failed with code 403"}, + ) + await plugin.flush() + rows = await _get_captured_rows_async( + mock_write_client, dummy_arrow_schema + ) + event_types = [r["event_type"] for r in rows] + assert "TOOL_ERROR" in event_types + assert "TOOL_COMPLETED" not in event_types + assert "AGENT_RESPONSE" not in event_types + tool_err = next(r for r in rows if r["event_type"] == "TOOL_ERROR") + assert tool_err["error_message"] == "MCP request failed with code 403" + @pytest.mark.asyncio async def test_max_content_length_tool_error( self, @@ -2599,6 +2647,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