Skip to content

Commit 07455ee

Browse files
haiyuan-eng-googlecopybara-github
authored andcommitted
feat: add opt-in final_response_tool_names to BigQueryAgentAnalyticsPlugin
Agents that deliver their final answer through a dedicated tool (e.g. a submit_final_response tool) instead of a plain-text final event do not trigger the on-event AGENT_RESPONSE path, so the response text is not captured. Add an opt-in BigQueryLoggerConfig.final_response_tool_names: when a completed tool's name is in the set, after_tool_callback logs its call args as an AGENT_RESPONSE event. The default (empty) preserves existing behavior. Co-authored-by: Haiyuan Cao <haiyuan@google.com> PiperOrigin-RevId: 953043855
1 parent 9dd4e7b commit 07455ee

2 files changed

Lines changed: 130 additions & 2 deletions

File tree

src/google/adk/plugins/bigquery_agent_analytics_plugin.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,8 +1015,14 @@ class BigQueryLoggerConfig:
10151015
views all stay consistent); views that reference a denied column drop
10161016
the dependent derived columns. NOTE: denying ``attributes`` also
10171017
disables ``attributes.otel`` and ``attributes.custom_metadata``;
1018-
combining it with a non-empty ``custom_metadata_allowlist`` is
1019-
rejected at construction.
1018+
combining it with a non-empty ``custom_metadata_allowlist`` is rejected
1019+
at construction.
1020+
final_response_tool_names: Tool names whose successful completion carries
1021+
the agent's final answer. When a completed tool's name is in this set,
1022+
its call args are logged as an ``AGENT_RESPONSE`` event. For agents that
1023+
emit the final answer via a dedicated tool (e.g.
1024+
``submit_final_response``) rather than a plain-text final event. Empty
1025+
(the default) preserves today's behavior.
10201026
"""
10211027

10221028
enabled: bool = True
@@ -1081,6 +1087,17 @@ class BigQueryLoggerConfig:
10811087
# are protected (see ``_PROJECTABLE_PAYLOAD_COLUMNS``).
10821088
payload_column_denylist: list[str] | None = None
10831089

1090+
# --- final-answer-via-tool capture ---
1091+
# Tool names whose successful completion carries the agent's final
1092+
# response. Some agents deliver their final answer through a dedicated
1093+
# tool (e.g. ``submit_final_response``) instead of a plain-text final
1094+
# event, so the on-event ``AGENT_RESPONSE`` path (which excludes function
1095+
# calls/responses) never fires. When a completed tool's name is in this
1096+
# set, its call args (the final-answer payload) are logged as an
1097+
# ``AGENT_RESPONSE`` event. Empty (the default) preserves today's
1098+
# behavior.
1099+
final_response_tool_names: frozenset[str] = frozenset()
1100+
10841101

10851102
# ==============================================================================
10861103
# HELPER: TRACE MANAGER (Async-Safe with ContextVars)
@@ -5147,6 +5164,25 @@ async def after_tool_callback(
51475164
event_data=event_data,
51485165
)
51495166

5167+
# Some agents deliver their final answer through a dedicated tool
5168+
# (e.g. ``submit_final_response``) rather than a plain-text final event,
5169+
# so the on-event AGENT_RESPONSE path (which excludes function
5170+
# calls/responses) never fires. When such a tool completes, log its call
5171+
# args (the final-answer payload the model supplied) as AGENT_RESPONSE so
5172+
# the visible response text is captured. Opt-in via
5173+
# ``config.final_response_tool_names`` (empty by default).
5174+
if tool.name in self.config.final_response_tool_names:
5175+
args_truncated, args_is_truncated = _recursive_smart_truncate(
5176+
tool_args, self.config.max_content_length
5177+
)
5178+
await self._log_event(
5179+
"AGENT_RESPONSE",
5180+
tool_context,
5181+
raw_content={"response": args_truncated},
5182+
is_truncated=args_is_truncated,
5183+
event_data=EventData(extra_attributes={"source_tool": tool.name}),
5184+
)
5185+
51505186
@_safe_callback
51515187
async def on_tool_error_callback(
51525188
self,

tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,98 @@ async def test_max_content_length_tool_result_no_truncation(
10411041
assert content_dict["tool"] == "MyTool"
10421042
assert content_dict["result"]["res"] == "A" * 100
10431043

1044+
@pytest.mark.asyncio
1045+
async def test_after_tool_callback_logs_agent_response_for_final_tool(
1046+
self,
1047+
mock_write_client,
1048+
tool_context,
1049+
mock_auth_default,
1050+
mock_bq_client,
1051+
mock_to_arrow_schema,
1052+
dummy_arrow_schema,
1053+
mock_asyncio_to_thread,
1054+
):
1055+
"""A configured final-response tool also logs AGENT_RESPONSE from its args."""
1056+
_ = mock_auth_default
1057+
_ = mock_bq_client
1058+
_ = mock_to_arrow_schema
1059+
_ = mock_asyncio_to_thread
1060+
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
1061+
final_response_tool_names=frozenset({"submit_final_response"})
1062+
)
1063+
async with managed_plugin(
1064+
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
1065+
) as plugin:
1066+
await plugin._ensure_started()
1067+
mock_write_client.append_rows.reset_mock()
1068+
mock_tool = mock.create_autospec(
1069+
base_tool_lib.BaseTool, instance=True, spec_set=True
1070+
)
1071+
type(mock_tool).name = mock.PropertyMock(
1072+
return_value="submit_final_response"
1073+
)
1074+
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
1075+
await plugin.after_tool_callback(
1076+
tool=mock_tool,
1077+
tool_args={"answer": "The table has 241 rows."},
1078+
tool_context=tool_context,
1079+
result={"status": "SUCCESS"},
1080+
)
1081+
await plugin.flush()
1082+
rows = await _get_captured_rows_async(
1083+
mock_write_client, dummy_arrow_schema
1084+
)
1085+
event_types = [r["event_type"] for r in rows]
1086+
assert "TOOL_COMPLETED" in event_types
1087+
assert event_types.count("AGENT_RESPONSE") == 1
1088+
agent_resp = next(r for r in rows if r["event_type"] == "AGENT_RESPONSE")
1089+
content_dict = json.loads(agent_resp["content"])
1090+
assert content_dict["response"] == {"answer": "The table has 241 rows."}
1091+
attributes = json.loads(agent_resp["attributes"])
1092+
assert attributes["source_tool"] == "submit_final_response"
1093+
1094+
@pytest.mark.asyncio
1095+
async def test_after_tool_callback_no_agent_response_by_default(
1096+
self,
1097+
mock_write_client,
1098+
tool_context,
1099+
mock_auth_default,
1100+
mock_bq_client,
1101+
mock_to_arrow_schema,
1102+
dummy_arrow_schema,
1103+
mock_asyncio_to_thread,
1104+
):
1105+
"""With the default empty set, a tool never emits AGENT_RESPONSE."""
1106+
_ = mock_auth_default
1107+
_ = mock_bq_client
1108+
_ = mock_to_arrow_schema
1109+
_ = mock_asyncio_to_thread
1110+
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
1111+
async with managed_plugin(
1112+
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
1113+
) as plugin:
1114+
await plugin._ensure_started()
1115+
mock_write_client.append_rows.reset_mock()
1116+
mock_tool = mock.create_autospec(
1117+
base_tool_lib.BaseTool, instance=True, spec_set=True
1118+
)
1119+
type(mock_tool).name = mock.PropertyMock(
1120+
return_value="submit_final_response"
1121+
)
1122+
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
1123+
await plugin.after_tool_callback(
1124+
tool=mock_tool,
1125+
tool_args={"answer": "hi"},
1126+
tool_context=tool_context,
1127+
result={"status": "SUCCESS"},
1128+
)
1129+
await plugin.flush()
1130+
rows = await _get_captured_rows_async(
1131+
mock_write_client, dummy_arrow_schema
1132+
)
1133+
event_types = [r["event_type"] for r in rows]
1134+
assert "AGENT_RESPONSE" not in event_types
1135+
10441136
@pytest.mark.asyncio
10451137
async def test_max_content_length_tool_error(
10461138
self,

0 commit comments

Comments
 (0)