fix(plugins): record error-bearing tool results as TOOL_ERROR in BigQuery analytics - #15
Conversation
…uery analytics BigQueryAgentAnalyticsPlugin wrote TOOL_ERROR only from on_tool_error_callback. A tool that failed without its exception reaching that callback was therefore recorded as TOOL_COMPLETED with status OK: an MCP tool returning a CallToolResult with isError set, and a raising tool whose error ReflectAndRetryToolPlugin answered first. after_tool_callback now records such a result as TOOL_ERROR, with the content, status, error message, span and latency of the row on_tool_error_callback writes, and leaves the result the model receives unchanged. The built-in rules match the ReflectAndRetryToolPlugin response type and an MCP isError of exactly True. A new BigQueryLoggerConfig.tool_result_classifier lets an application classify its own result shapes first. The MCP error text and the result stay out of the TOOL_ERROR row: error_message bypasses content_formatter and payload_column_denylist, and a formatter written for TOOL_COMPLETED rows would not scrub a result copied into TOOL_ERROR content. When the analytics plugin runs before the retry plugin, on_tool_error_callback has already recorded the failure. after_tool_callback now skips that call instead of writing a spurious TOOL_COMPLETED row that popped a span it did not own. Tools from ToolboxToolset and SkillToolset's own tools now get the TOOLBOX and SKILL tool_origin instead of UNKNOWN. Both are matched through sys.modules, so classification never imports toolbox_adk or skill_toolset. Fixes google#7112 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
caohy1988
left a comment
There was a problem hiding this comment.
Independent blind review by Astra on Haiyuan's behalf, at 3fc3c951c6a130ce2a5d4ba4103d7ca4ba3a1352, compared with e4c0d946f6d602b0ac23804328473b8d9e597b3e. I read the PR description, full diff, implementation commit, repository code, and issues google#7112 and google#485. I did not read another reviewer's material. No tracked files were modified, and no branch was pushed or merged.
Rejection criteria, written before reading the diff
- Error results produce missing, duplicate, or incorrectly classified rows in either plugin order or supported tool execution paths.
- Hostile results or classifiers break the callback, drop a row, or bypass the existing formatting and redaction contract.
- Origin detection changes unrelated tools, or verification contradicts the PR's correctness claims.
P0 findings: None.
P1 findings — changes required
-
Built-in classification introduces a row-loss and exception-text disclosure path.
src/google/adk/plugins/bigquery_agent_analytics_plugin.py:804-805calls payload-controlledget, equality, and truthiness before sanitizing the result. These operations can raise, afterafter_tool_callbackhas already popped the span. The outer_safe_callbackthen logs the exception and skips the outcome row; it does not provide the promised fallback.Reproductions against the actual serialized-row fixture:
class BadGet(dict): def get(self, *args, **kwargs): raise RuntimeError("PROBE_PRIVATE_PAYLOAD_get") result = BadGet({"isError": True}) # Or an ordinary dict: result = {"isError": True, "response_type": numpy.array(["a", "b"])}
Both produce only
TOOL_STARTING, with no outcome row. The first also puts the injected private-payload marker into the process log through the traceback. A matching Reflect response whoseerror_details.__bool__raises has the same failure. All three inputs retainedTOOL_STARTING+TOOL_COMPLETEDon the base plugin, without that exception-text disclosure, so this is a regression rather than an existing sanitizer limitation.Suggested fix: guard the entire built-in inspection; avoid overridden dictionary methods and arbitrary equality/truthiness where possible; validate the response-type/message primitives before interpreting them. A malformed field must not prevent checking a valid
isError=Trueor emitting one safely formatted outcome. Any diagnostic must exclude exception text/tracebacks. Add regression tests that assert both row count and absence of payload markers in logs. -
The classifier's return-value handling is outside its exception boundary.
src/google/adk/plugins/bigquery_agent_analytics_plugin.py:8493-8496accessesstatus/error_messageand evaluateserror_messagetruthiness in theelseblock, which is not covered by thetryaround the classifier call.ToolResultClassification.__post_init__validates only status (:2203-2205), so malformed messages are accepted.class BadMessage: def __bool__(self): raise RuntimeError("PROBE_PRIVATE_PAYLOAD_bool") def classify(tool, result): return ToolResultClassification("ERROR", error_message=BadMessage())
With an otherwise ordinary
{"isError": True}result, this drops the outcome row and logs the injected exception text. Returning a classification with a multi-element NumPy array as its message also drops the row; a classification subclass with raising attribute access does likewise. Ordinary classifiers that raise during the call, return an unrelated object, or returnNonedo fall back correctly. The PR's claim that classifier failures cannot drop rows therefore holds only for the call itself, not interpretation of its return value.Suggested fix: validate and interpret the returned classification inside the guarded boundary, including its fields. Reject malformed statuses/messages or use the fixed fallback message without evaluating arbitrary truthiness. Fall back to built-ins with a constant warning on any interpretation failure. Cover these cases alongside the existing raising/unsupported-return tests.
P2 finding
- A direct MCP
CallToolResultstill reports success.src/google/adk/plugins/bigquery_agent_analytics_plugin.py:802-803rejects every non-dict result. With installed MCP 2.2.0,CallToolResult(content=[], isError=True)recordsTOOL_COMPLETED/OK. I reproduced this both through the callback/Arrow-row fixture and through a real Runner using aFunctionToolwrapper returning that object. The nativeMcpToolpath is covered because it dumps the model into a dict; this is a remaining result-shape gap, not a regression in that native path. Suggested fix: safely recognize supported MCP result models as well as their dict dumps, and add a Runner regression for a direct model result. Do not add unguarded duck-typed attribute access while doing so.
What I tried and verified
- Own uv environment in this worktree, Python 3.11.13.
PYTHONPATH=$PWD/src python -m pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8 -q: 580 passed. - Reflect/retry tool, utility, and model suites; MCP tool suite; workflow ToolNode suite: 183 passed. Together these include the PR's five-file 741-test selection, plus 22 ToolNode tests.
- Pinned
pyink 25.12.0 --checkandisort 8.0.1 --check-onlyon both changed files: passed.git diff --check: passed. - New classification tests loaded with the base plugin through an isolated import hook: 19 failed, 6 passed. This verifies detection of the original issue without replacing any tracked file. The three hostile-result baseline comparisons separately passed.
- Own adversarial suite: 8 failed, 13 passed. Six failures demonstrate the two P1 findings; two demonstrate the direct-model P2 finding. Truthy non-bool flags, raising ordinary object attributes, raising classifiers, and unrelated classifier return objects retained their rows safely in the passing cases.
- Four 32-call concurrent Runner probes mixed raised errors, MCP error results, and successes, with both plugin orders and both retry guidance and exhausted-retry responses. Each run produced 32 starts and exactly 32 outcomes (22 errors), with matching spans and no duplicate completion rows. Workflow ToolNode probes also passed in both orders for raised and returned errors.
- Another 17 path/privacy probes passed: live sync/async raising tools and live dict results in both plugin orders; nested AgentTool outcome counts; content formatter, failing formatter, and payload denylist behavior; allowlist filtering; and explicit characterization of existing callback bypasses. No live BigQuery service or external model was used.
I agree with omitting result content from the new error row and using a fixed MCP message. The normal rows honor the existing formatter/redaction boundary, and the Reflect response's error_details matches what the exception callback records. The six Skill origins and installed Toolbox class pass the provenance tests. The shared context marker survived the tested parallel, workflow, live, and nested call paths. An allowlist containing only TOOL_COMPLETED does intentionally exclude the new error rows, as the PR documents.
Coverage limits remain: live streaming chunks bypass the outcome callback and still leave TOOL_COMPLETED/OK; a resumed long-running isError=True response also remains TOOL_COMPLETED/OK; an earlier plugin returning from after_tool_callback prevents analytics from observing the result. The repository's unchanged dispatch paths explain these limits; I am not treating them as regressions introduced here. The nested test checked outcome counts, not a repair of the documented outer-span issue.
Environment/evidence limits: all-extras sync could not install google-antigravity on macOS x86_64. I omitted it, onnxruntime, and lancedb, and used a no-deps requirements install with wheel-compatible cryptography 48.0.1 / pyOpenSSL 25.3.0 after the latest cryptography source build failed. This is a targeted review environment, not proof of a coherent all-extras installation. I did not independently reproduce the full 16k-test run, all pre-commit hooks, mypy, or the exact eight mutation experiments claimed in the PR. The broader evidence is therefore unverified here; the concrete classifier robustness claim is disproved above.
Reproducers and logs are under /tmp/adk-pr15-dual/astra-probes/: test_adversarial.py, test_paths.py, test_base_safety.py, run_base.py, focused-tests.log, adjacent-tests.log, adversarial-final.log, adversarial.log (tracebacks), paths.log, base-new-tests.log, and base-safety.log. Rejection criterion 2 is met by both P1 findings.
VERDICT: REQUEST_CHANGES @ 3fc3c95
caohy1988
left a comment
There was a problem hiding this comment.
Blind review: Fable (Claude Opus 5.5), head 3fc3c951
Scope: the implementation commit is f187aef2. The head 3fc3c951 only merges upstream main: git diff f187aef2 3fc3c951 on the two files is empty, and the head's src/ differs from base only in the plugin file. I read only the PR description, the diff and code, google#7112, and GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485. I read no other review or comment on this PR.
Rejection criteria (written before I read the diff)
- R1, row integrity. An error-bearing result (MCP
isError=True, or a ReflectAndRetry response matched byresponse_type) produces exactly oneTOOL_ERROR/ERRORrow per tool call. That holds with analytics ahead of or behind the retry plugin, and with parallel calls. Zero rows, two rows, or aTOOL_COMPLETED/OKrow for such a result rejects. - R2, no crash, no drop, no regression. A raising or odd-returning
tool_result_classifier, or a hostile result (truthy non-boolisError, an attribute or==that raises, dict vs pydantic shapes), must never raise out ofafter_tool_callback, drop the row, or change the result the model receives. Successful results stayTOOL_COMPLETED/OK, and a raising tool still gets exactly oneTOOL_ERROR. Any violation rejects. - R3, data contract. The new
TOOL_ERRORrows carry no payload beyond whatTOOL_ERRORalready records.content_formatter, redaction and truncation still apply to them.tool_originchanges only for Toolbox and Skill tools. The new tests fail on the unfixed plugin.
Outcome: R1 holds for the supported single-instance setup. R2 is violated (P1 below). R3 holds.
What I tried
Environment: my own .venv in a detached worktree at the head. Python 3.11.13 (x86_64 under Rosetta), mcp 2.2.0, toolbox-adk 1.4.0, pyink 25.12.0 and isort 8.0.1 as pinned. I skipped google-antigravity, onnxruntime and lancedb, which have no wheels for this platform. My probes live outside the repo.
-
pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8: 580 passed. -
The PR's 5-file set: 741 passed, which matches the claim.
tests/unittests/pluginsplustest_tool_node.pyandtest_optional_dependencies.py: 902 passed, 6 skipped. -
New tests against the base plugin (base
srcextracted withgit archive e4c0d946and put onPYTHONPATH):- 21 of the 28 new test cases (counting parametrizations) fail on base, including every headline test.
- The 7 that pass on base are negative controls or guards: the no-flag cases, the unrelated
error_detailskey, "next call not skipped", and "toolbox_adk not imported".
-
Mutation spot-check, on a scratch copy of
src. All 8 of my own mutations are caught by at least one new test:- no marker skip
- marker never set
- truthy
isError is_errorrule dropped- result copied into
TOOL_ERRORcontent - classifier ignored
SKILLorigin dropped- reflect message fixed
-
pyink --checkandisort --check-onlyon both files pass.mypyon the plugin reports no issues. -
End-to-end probes with real
InMemoryRunnerruns, a scriptedBaseLlmand rows captured from_log_event, run in both plugin orders unless noted, at the head and on base:- two raising tools called in parallel
- three parallel calls: one raising, one returning an MCP
isErrorresult, one succeeding - no retry plugin; the agent's own
on_tool_error_callbackanswers the error ReflectAndRetryToolPluginretry-exceeded final response, both with and withoutthrow_exception_if_retry_exceeded- a nested
AgentToolwith shared plugins - a workflow
ToolNodewith a raising tool, and one returning an MCP result - a tool name the model made up
- errors and successes alternating across turns
- a
ReflectAndRetryToolPluginsubclass usingextract_error_from_result - two analytics plugin instances in one runner
At the head, every single-instance case writes exactly one outcome row per call, and its span pairs with its
TOOL_STARTING. On base the same runs writeTOOL_ERRORplus a spuriousTOOL_COMPLETED, orTOOL_COMPLETED/OKfor the failure, so the fix is real. -
Unit probes through the real
_log_eventpipeline:- result shapes:
isErrorof1,"true"or1.0;None; a str; a list; reflect dicts without details, with dict details, or with both fields empty - a real MCP 2.2.0
CallToolResult: dumped byMcpTool's own_dump_mcp_model, dumped unaliased (is_error), and as a raw model content_formatterobservation, and a formatter that raises onTOOL_ERRORevent_allowlist,payload_column_denylist=["content"], and a 5000-char reflecterror_detailsagainstmax_content_length=200- classifiers returning an int, a dict, bytes, or a string holding a secret; an
asyncclassifier - hostile values: an
==that raises, numpy, pandas, adictsubclass whosegetraises, and a__bool__that raises
All pass except the P1 cases.
- result shapes:
-
Parallel calls get independent markers.
_start_execute_taskcreates each call's task from a copy of the prepare-phase context (_batch_executor.py:186), and a workflow retry builds a freshContextfor each attempt (_node_runner.py:128). Live mode goes through the same_start_execute_taskand_execute_single_prepared_call, so the same reasoning applies. I checked that by reading the code, not by running it.
P1: must fix
P1-1. Classifying a result has no fail-closed boundary, so a hostile result or an odd classifier return drops the outcome row, and a payload-bearing traceback reaches the logs. This is a regression from base.
after_tool_callback pops the tool span (bigquery_agent_analytics_plugin.py:8397) and then calls self._tool_result_error_message(tool, result) (:8405) with no try. Two places inside it can raise:
- The built-in rule compares an arbitrary value with
==and tests the truth of the outcome (:804). - The classifier's return value is used after the
tryhas ended, inclassification.error_message or ...(:8496).
When either raises, _safe_callback swallows the exception and writes the full traceback through logger.exception. No outcome row is written, even though the span was already popped. The plugin's own formatter and parser boundaries avoid exactly this: they fail closed and log constant messages.
Results of before_tool_callback then after_tool_callback, with no classifier unless stated:
| Result or classifier | Base e4c0d946 |
Head 3fc3c951 |
|---|---|---|
{"response_type": HostileEq()} (__eq__ raises RuntimeError("SECRET…")) |
STARTING, COMPLETED; secret not logged | STARTING only; secret in the traceback log |
{"response_type": np.array([1, 2])} |
STARTING, COMPLETED | STARTING only |
{"response_type": pd.Series(["a", "b"])}, for example df.to_dict("series") on a table with a response_type column |
STARTING, COMPLETED | STARTING only |
dict subclass whose get raises |
STARTING, COMPLETED | STARTING only |
Classifier returns ToolResultClassification("ERROR", error_message=<__bool__ raises>) |
n/a | STARTING only; payload in the log |
This breaks the contract's "a raising/odd-returning classifier must never drop the row", and my R2. These shapes are unlikely in practice, but base recorded every one of them. Suggested fix, which I prototyped on a scratch copy: with it all 580 tests in the file and all 46 of my probes pass, and the rows come back without the traceback.
# _builtin_tool_result_error_message: compare a str, never an arbitrary object.
response_type = result.get("response_type")
if isinstance(response_type, str) and response_type == REFLECT_AND_RETRY_RESPONSE_TYPE:
...
# after_tool_callback: a fail-closed boundary, like the formatter and parser ones.
try:
error_message = self._tool_result_error_message(tool, result)
except Exception: # pylint: disable=broad-except
# Constant message: the exception can carry the result.
logger.warning(
"Could not classify the result of tool %s; recording it as"
" TOOL_COMPLETED.", tool.name)
error_message = None
# _tool_result_error_message: never take the truth value of an arbitrary object.
message = classification.error_message
if isinstance(message, str) and message:
return message
return _CLASSIFIED_TOOL_ERROR_MESSAGEAdd a parametrized regression test over those hostile results. It should assert [TOOL_STARTING, TOOL_COMPLETED] and that the payload text is absent from caplog.
P2: should fix or discuss
- The marker is module-global, not per plugin instance. See
_tool_error_recorded_ctx(:2418), set at:8534and cleared at:8393. With two analytics instances in one runner plus the retry plugin (the second renamed soPluginManageraccepts it), the first instance'safter_tool_callbackconsumes the marker. The second then pops a span it doesn't own and writes a secondTOOL_ERROR. Base wrote a spuriousTOOL_COMPLETEDin the same spot, so this isn't a regression, but that instance now counts each error twice.- Fix: key the marker by instance with copy-on-write,
{**(ctx.get() or {}), id(self): weakref.ref(tool_context)}, and remove only your own key inafter_tool_callback. I validated this on a scratch copy.
- Fix: key the marker by instance with copy-on-write,
- An
asyncclassifier is silently ignored. It passes thecallablecheck at:1576, returns a coroutine, is warned about as an unsupported value, and produces a "coroutine was never awaited"RuntimeWarning; the row staysTOOL_COMPLETED.- Fix: reject coroutine functions at construction, as you already do for non-callables.
- Also settle the public signature now.
(tool, result)has notool_argsortool_context, and adding them later is a breaking change. Keyword-only(*, tool, tool_args, tool_context, result)would be the safer choice.
- The built-in rules diverge from ADK's own error detection. ADK already has per-tool hooks,
_detect_error_in_response, which_tool_error_handler.detect_error_type_for_telemetrycalls.FunctionTool's own{"error": ...}results count as errors in OTel but stayTOOL_COMPLETED/OKin BigQuery. These cover validation errors, missing mandatory arguments and signature-lessTypeErrors (function_tool.py:261,:389,:430).- The same goes for the other tools that define a detector (SkillToolset tools,
BashTool,RestApiTool, and others): the plugin never consults them. I found this by reading the code, not by running it. McpTool's hook testsisErrorfor truth (mcp_tool.py:763-764), while this PR requiresis True(:809).- Consider reusing
detect_error_type_for_telemetryas a built-in rule. It already skips pending auth and confirmation. Otherwise, document the divergence so OTel and BigQuery error counts can be reconciled.
- Plugin-order gaps the PR doesn't mention. Both are pre-existing and identical on base.
ReflectAndRetryToolPlugin's defaultthrow_exception_if_retry_exceeded=True(reflect_retry_tool_plugin.py:106), with the retry plugin ahead of analytics: once retries run out it re-raises, analytics never sees the error, and the call has no outcome row.- A retry-plugin subclass using the documented
extract_error_from_result, registered ahead of analytics: it answersafter_tool_callbackfirst, and again there is no outcome row. - With analytics first, both cases write exactly one
TOOL_ERROR. Document "register BigQueryAgentAnalyticsPlugin before plugins that answer or re-raise tool callbacks", and qualify the "either plugin order" claim.
- Minor message consistency.
- A
raise RuntimeError()recordserror_message=''with analytics first (fromon_tool_error_callback) but'RuntimeError'with the retry plugin first. - A reflect dict whose
error_detailsanderror_typeare both empty records''(:805). - Suggest
or _REFLECT_AND_RETRY_TOOL_ERROR_MESSAGEfor the empty case.
- A
- Release communication. The PR adds public API (
ToolResultClassification,BigQueryLoggerConfig.tool_result_classifier) and changes which event types the plugin emits. I confirmed thatevent_allowlist=["TOOL_STARTING", "TOOL_COMPLETED"]now drops these failures entirely. That fitsfeat(plugins):better thanfix:for release-please, and it deserves a release note for dashboards and allowlists.
The design points the implementer flagged
- Result left out of
TOOL_ERRORcontent. Agreed, for privacy: the formatter sees{args, tool, tool_origin}forTOOL_ERROR, and the MCP text appears nowhere in the row. The claim that the result "is still recorded in the next LLM_REQUEST row" holds only throughcontent_parts[].part_attributes.function_response, with the defaultlog_multi_modal_content=True. It is not in thecontentcolumn, which reads"Function response: t". It is also missing whenlog_multi_modal_content=False, whencontent_partsis denied, or when no further model call follows. Please say this precisely in the docstring and PR body. - Reflect
error_messagetaken fromerror_details. Fine. It equalsstr(error), so both plugin orders record the same text. It is redacted (api_key=[REDACTED]) and truncated (5000 chars becomes 214, withis_truncated). - Toolbox and Skill detection. Correct:
- Checked against toolbox-adk 1.4.0, which exports
toolbox_adk.ToolboxTool(BaseTool)at the top level. - Checked against all six classes in
skill_toolset; the test enumerates them, so drift gets caught. - The detection never imports
toolbox_adk. - Importing
skill_toolsetcost 0.16 s here, against ~0.4 s claimed; the difference depends on the environment and doesn't matter.
- Checked against toolbox-adk 1.4.0, which exports
- Per-call marker. Holds for parallel calls, nested
AgentTool, workflowToolNode, sequential calls, the unknown-tool path, and the agent-level error callback. Only the multi-instance case breaks (P2-1). event_allowlistand dashboard change. See P2-6.
Not verified: live and streaming tools (reasoned only); long-running tools resumed through on_user_message_callback, which the PR acknowledges it doesn't classify; the full tests/unittests suite, which I didn't run because the change touches one module; and real BigQuery writes (the write client was mocked).
VERDICT: REQUEST_CHANGES @ 3fc3c95
Merge google#7293 Fixes google#7292 PiperOrigin-RevId: 990023711
after_tool_callback classified a tool result after it had closed the tool span, with no boundary of its own. Any of these dropped the call's outcome row: - a result whose ==, truth test or get() raised; - a tool_result_classifier verdict whose fields could not be read. The traceback _safe_callback then logged could carry the result. The built-in rules now read entries with dict.get, and compare and test only exact str and bool values, so none of the result's own code runs. The classifier's verdict is read inside the boundary around the classifier call. A second boundary around classification falls back to the TOOL_COMPLETED row with a constant warning. BaseException subclasses that are not Exceptions still propagate. Also from review: - recognize an MCP CallToolResult model, not only its dict dump, including one from the MCP SDK 2.x mcp_types package; - key the recorded-error marker by plugin instance, so two analytics plugins in one runner each record an error once; - call the classifier with the keyword arguments tool, tool_args, tool_context and result; - reject asynchronous or incompatible classifiers when the plugin is created, and close a coroutine a classifier returns; - record an exception without a message as its type name, and give an empty retry response a fixed message; - document registering the plugin before plugins that answer or re-raise tool callbacks, and why the per-tool error hooks are not consulted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
caohy1988
left a comment
There was a problem hiding this comment.
Blind review by Astra on Haiyuan's behalf, scoped to def458b609c2811d137b0332b2fc7b201dddd5c0..fa56c26f717dc0e1a3c15b50fd703983937191ca. I read the PR description, both implementation commit messages, the complete two-file diff, issues google#7112/google#485, and relevant repository code. I did not read another reviewer's review or artifacts.
Rejection criteria, written before reading the diff
- Reject a reproducible lost, duplicated, or misclassified outcome row within the promised callback coverage, including MCP
isError=True, ReflectAndRetry responses, and parallel/multiple-plugin execution. - Reject hostile results or classifier failures escaping the callback or leaking payload-derived diagnostics; require the advertised classifier construction and return-value handling.
- Reject regressions in successful/raising tools, content formatting/redaction, or tool origins, and unsupported guarantees that conceal a coverage limit.
What I tried
- Created a separate uv environment in this worktree, Python 3.11.13, from the frozen lock. The all-extras install required excluding
google-antigravity,onnxruntime, andlancedb; lockedcryptography==50.0.1also failed its local Rust/OpenSSL build, so this environment uses 48.0.1. Repository dependency files were unchanged. - Ran the requested
PYTHONPATH=$PWD/src python -m pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8 -q: 613 passed. - Ran all plugin tests plus workflow ToolNode, MCP tool, and streaming-tool-event tests: 1,064 passed. This includes the related suites named in the PR.
- Pinned pyink 25.12.0 and isort 8.0.1 checks passed on both changed files. Mypy passed on the plugin.
- Wrote 41 independent probe cases outside the repository: 35 passed, 6 failed. These exercised actual serialized rows for hostile results, direct MCP models, exact/non-bool flags, formatter/denylist behavior, and real Runner/Workflow callback ordering. The failures support findings 1 and 2 below.
- Two separately named analytics instances in one real Runner each emitted exactly one error per call across 36 parallel calls, three plugin orders (both before retry, both after retry, and sandwich), and both ordinary/message-less exceptions. No outcome rows were lost or duplicated in these probes.
- Workflow ToolNode passed for MCP errors, raised errors, and retry-exhausted returned guidance in both orders. Nested AgentTool outcome counts passed. Probed non-streaming live dispatch, streaming chunks, resumed long-running responses, retry re-raising, and plugins short-circuiting before/after callbacks. The documented existing exclusions remain real.
- Ran the issue google#7112 reproduction unchanged on the head and with the pinned base plugin loaded in an isolated process. Also ran three assertion-based regressions: all 3 pass on head and fail on base; the base exhibits both false successes and the analytics-first duplicate outcome. No tracked file was replaced for this comparison.
Probe sources/logs: /tmp/adk-pr15-dual/r2/astra-probes/ (test_adversarial.py, test_paths.py, adversarial-tests.log, path-tests.log, regression-{head,base}.log). BigQuery writes and model responses were mocked; I did not exercise a live BigQuery service or remote MCP server, or rerun the historical full-unit-suite totals.
P1 — findings requiring changes
1. A hostile sibling field can turn a valid MCP failure back into TOOL_COMPLETED/OK.
Locations: src/google/adk/plugins/bigquery_agent_analytics_plugin.py:788, :839, :843, and the fallback at :8585.
For example:
class Unreadable:
@property
def __class__(self):
raise RuntimeError("protected payload")
result = {"isError": True, "response_type": Unreadable()}_exact_str calls isinstance(value, str), which accesses the object's __class__. That raises before the known isError=True flag is examined. The outer handler then emits TOOL_COMPLETED, status OK, null error_message. My full row-writing test test_valid_error_survives_hostile_fields[raising_class_field] fails on precisely that outcome. The row survives and the warning stays constant; the error classification does not survive.
Two further full-row reproductions fail the same way: a dictionary containing a key whose hash collides with "response_type" and whose __eq__ raises (dict.get still invokes key equality), and a CallToolResult subclass whose __getattribute__("__dict__") raises (vars(result) dispatches to it). These also disprove the code/commit/PR assertion that classification runs none of the result's own code. The existing test at tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py:6958 explicitly expects a success row after classification failure, so it does not protect a valid error flag from this loss.
Suggested fix: read recognized fields without dispatching to payload-controlled instance methods, normalize actual string keys before lookup, and isolate malformed fields so one cannot suppress another valid error indicator. Use type-based string checks and bypass model instance attribute overrides where appropriate. Add all three full-row regressions, asserting TOOL_ERROR/ERROR and constant diagnostics. This is a missing part of the requested hostile-result hardening, not a claim that base already classified these results correctly.
P2 — additional findings
2. Classifier validation and awaitable cleanup do not meet the advertised contract.
Locations: src/google/adk/plugins/bigquery_agent_analytics_plugin.py:1678, :1687, :8691.
Three independent probes fail:
- An
async def classifier(**kwargs): yield Noneis accepted at construction: only coroutine functions are checked, not async-generator functions/callable objects. operator.itemgetter("status")is accepted, although it cannot receive the four keyword arguments.inspect.signatureraisesValueErrorand validation simply returns. Consequently this configuration can never classify a call and only warns at runtime.- A generator-based coroutine produced with
types.coroutineis recognized as awaitable but is not closed, becauseinspect.iscoroutineis false. Itsgi_frameremains live after classification. The fallback outcome row is correct; the promised cleanup is not.
Suggested fix: reject async-generator callables too; require an inspectable compatible signature (or an explicit synchronous adapter) when construction-time validation is promised; close supported generator-based coroutines as well as native coroutines. Document a precise policy for other awaitable kinds instead of promising that every awaitable is closed. Add constructor and cleanup tests beyond the current coroutine-only cases.
3. Registering analytics first does not guarantee the documented TOOL_ERROR outcome.
Location: src/google/adk/plugins/bigquery_agent_analytics_plugin.py:4996.
The docstring says that registered first, this plugin records each failure as TOOL_ERROR whatever later plugins do. I tested a tool returning {"failed": True} and a later ReflectAndRetry subclass whose extract_error_from_result recognizes that result. Analytics first records TOOL_COMPLETED before the retry plugin produces its reflection response. Retry first records no outcome, as already documented. test_extractor_plugin_order_documentation confirms both outcomes.
Suggested fix: qualify the first-position guarantee to raised exceptions and result shapes recognized by the analytics built-ins/classifier. Explicitly tell users of extract_error_from_result subclasses to supply equivalent tool_result_classifier logic when they need matching counts. This is a documentation correction for an existing architectural limit, not a request to redesign PluginManager in this PR.
4. The exact 19-guard mutation claim has no reproducible evidence in the permitted review inputs.
Location: PR description, Testing Plan → Mutation check. The description reports 19 killed mutations but supplies neither the mutations nor a command/per-mutation report; the two changed files contain the tests, not that mutation runner. I independently verified the normal suites and before/after regressions above, but those do not establish the exact reported mutation tally. Please attach the mutation patches/script and per-mutation failing-test output, or narrow the validation claim. This is an evidence gap, not an assertion that the author's mutation run failed.
Design judgments
Omitting result payloads from TOOL_ERROR, using ReflectAndRetry's error_details with existing error redaction, keyword invocation of the hook, and the current Toolbox/Skill class detection are reasonable for this scope. Conservative built-ins that deliberately differ from _detect_error_in_response are acceptable with the opt-in classifier and accurate coverage documentation. The event-allowlist/dashboard migration is explicitly disclosed. The per-instance/task-local marker passed the concurrency and row-accounting probes; the previously documented nested span-stack and bypass-path limitations should not be presented as newly fixed.
Finding 1 meets rejection criterion 1; findings 2–3 also contradict explicit contract/coverage claims. Request changes on this head. This is intentionally posted with review event COMMENT only.
VERDICT: REQUEST_CHANGES @ fa56c26
caohy1988
left a comment
There was a problem hiding this comment.
Fable blind review (round 2) of PR #15 @ fa56c26
This review is blind: I read no other review, review comment or PR comment. My sources were the PR description, the diff def458b6..fa56c26f, issues google#7112 and BigQuery-Agent-Analytics-SDK#485, and the repository code. The merge commits 3fc3c95 and fa56c26 touch only Redis files, so the plugin changes are entirely f187aef and 563d32c.
Rejection criteria (written before I read the diff)
- R1 Row integrity. For one tool call, a reachable path writes zero outcome rows, or more than one (TOOL_COMPLETED + TOOL_ERROR, or two TOOL_ERRORs), or lets an exception escape into the agent run. The paths: MCP
isErroras a dict and as a model, ReflectAndRetry answers in either plugin order, parallel calls, two analytics plugin instances, raising tools, and classifiers that raise or return junk. Results that succeed must stay TOOL_COMPLETED/OK. - R2 Fail-closed and leakage. A hostile result or classifier drops the outcome row or crashes the callback. Or payload-derived text reaches the logs, or reaches the TOOL_ERROR row beyond what TOOL_ERROR already recorded. Or the new rows bypass
content_formatteror redaction. - R3 Unsupported claims. A claimed round-2 item fails under a probe, the new tests pass on the unfixed plugin, or existing tests regress.
I found no evidence that meets any of the three.
What I tried
Setup: Python 3.11.13, the repo's venv, PYTHONPATH=src. All probes lived outside the repo, and the worktree was never modified.
-
The PR's stated results.
- Test counts, all reproduced exactly: the plugin test file gives 613 passed;
plugins/plusworkflow/test_tool_node.pygives 925 passed; the plugin file plus the reflect-retry andmcp_toolsuites gives 774 passed. - Wider runs:
plugins/plus the tool node and MCP tool suites, 1034 passed.test_optional_dependencies.pypluscli/test_fast_api.py, which also reference the plugin: 152 passed, 11 skipped, 1 xfailed. - Static checks:
mypyon the plugin is clean.pyink --check25.12.0 andisort --check-only8.0.1 (the pre-commit pins) are clean on both files. Both files compile on Python 3.10.16. - The google#7112 reproduction script, run unchanged, prints exactly the "Before" output on def458b and exactly the "After" output on fa56c26.
- Test counts, all reproduced exactly: the plugin test file gives 613 passed;
-
Do the tests fail on unfixed code? The test module subclasses
ToolResultClassificationat import, so a plain base run fails at collection. I ran the tests instead against a shim: the base plugin plus only the new public names (the dataclass, the Protocol and the config field), with none of the behavior.- 43 of the 73
TestToolResultErrorClassification/TestToolProvenancetests fail on the shim. - The 30 that pass are the 12 existing provenance tests and the intended "stays TOOL_COMPLETED" and validation guards.
- 43 of the 73
-
Mutations, applied to a
/tmpcopy of head. At least one test catches each one:- drop the marker skip: 3 fail
- key the marker by a constant instead of per instance: 1 fails
- use a truthy
isErrorinstead ofis True: 1 fails - remove the outer classification boundary: 1 fails
- drop the fallback for exceptions without a message: 1 fails
- run the built-in rules before the classifier: 9 fail
- remove the async-classifier validation: 3 fail
- remove the SKILL origin: 1 fails
-
End to end through a real
Runner, head against base. Rows were captured by replacing_log_event, and each outcome row was paired with its TOOL_STARTING by span.- Four parallel calls in one turn (raise, MCP
isError, OK, raise), with the retry plugin in either order.- Head: exactly one correct outcome per call, and every span pairs.
- Base, retry plugin first: all four are TOOL_COMPLETED.
- Base, analytics first: duplicate TOOL_COMPLETED rows that carry the wrong span.
- Two renamed analytics instances, arranged as
[bq1,bq2,retry],[bq1,retry,bq2]and[retry,bq1,bq2]: each instance writes one outcome per call. - Retries exhausted with
throw_exception_if_retry_exceeded=False: two TOOL_ERRORs in both orders. - A message-less
RuntimeError():error_message == "RuntimeError"in both orders. - Workflow
ToolNode.- A raising tool with the retry plugin in either order: one TOOL_ERROR.
- An MCP error result: TOOL_ERROR.
- A node
retry_configwhose first attempt raises: TOOL_ERROR, then TOOL_COMPLETED. Each attempt gets a freshContext, so the marker does not skip the retry.
- A nested
AgentToolwhose inner tool returns an MCP error: one outcome per call. - Calls that get no outcome row behave exactly as on base. With the retry plugin first, that happens on a re-raise, with an
extract_error_from_resultsubclass, and with an unknown tool name. Two span issues are also unchanged from base: the outer AgentTool's span mismatch, and the span swap between two plugin instances.
- Four parallel calls in one turn (raise, MCP
-
Hostile results. On head, each of these keeps its outcome row, and no log record contains the payload:
- dict keys that share a lookup key's hash and whose
==raises or always returns True - objects whose
__class__claimsdictorstr numpy.bool_,numpy.str_and int flags- a
MappingProxyType - a
model_constructresult with a non-bool flag - a real
CallToolResult(isError=True)
The one exception is a result whose
__class__itself raises. It loses its row and logs the payload through_safe_callback. Base behaves the same, and the PR discloses it. - dict keys that share a lookup key's hash and whose
-
Hostile classifiers. Each of these keeps its row, logs only the constant warning, and triggers no "never awaited" warning:
- one that raises an exception whose
__str__is the payload - ones that return an async generator or a
Future - verdicts whose
statusorerror_messagewas replaced viaobject.__setattr__with an object whose__class__claimsstr - a verdict with a fake
__class__whosestatusraises - a 100 KB
error_message - an async classifier assigned to
configafter construction
The construction check:
- accepts keyword-only, positional-or-keyword and
**kwfunctions; - rejects a missing or extra required argument, coroutine functions, an async
__call__,partial(async),AsyncMock,printand non-callables; - accepts a sync wrapper that returns a coroutine, which the documented runtime path then closes.
- one that raises an exception whose
-
The real write path, using the repo's fixtures.
content_formatterruns on the new TOOL_ERROR rows.- A raising formatter writes the sentinel, and the payload stays out of the logs.
- A classifier's
error_messageis redacted (api_key=sk-live-123) and truncated. - An
event_allowlistwithout TOOL_ERROR drops the outcome row, as the release note says.
P0
None.
P1
None.
P2 (suggestions, none blocking)
P2-1: A classifier can make a raised exception's row depend on plugin order.
- Where:
src/google/adk/plugins/bigquery_agent_analytics_plugin.py:8680-8706runs the classifier before the built-in rules. With the retry plugin first, the classifier therefore sees theReflectAndRetryToolPluginanswer. - Probe: a classifier that returns
ToolResultClassification(status="OK")for everything. A raising tool recordsTOOL_COMPLETED/OKwith[retry, bq]butTOOL_ERRORwith[bq, retry]. - Scope: any classifier that ends in "else OK" has the same problem. An ERROR verdict likewise changes
error_messagein one order only. - Fix: check the
response_type == REFLECT_AND_RETRY_RESPONSE_TYPErule before calling the classifier. That answer stands in foron_tool_error_callback, which the classifier never sees in the other order.test_classifier_ok_overrides_the_builtin_rules(test file line 6736) uses an MCP result, so it is unaffected. If you keep the current order, thetool_result_classifierdocs should say the classifier also sees retry-plugin answers and that an OK verdict hides a raised exception.
P2-2: The plugin-order docs claim more than holds.
- Where: the docstring at
…plugin.py:4996("Registered first, this plugin records each failure once, as TOOL_ERROR, whatever later plugins do with it"), and the PR body ("With the analytics plugin first, a failure answered by ReflectAndRetryToolPlugin is recorded exactly once, as TOOL_ERROR"). - Counterexample: a
ReflectAndRetryToolPluginsubclass whoseextract_error_from_resultflags{"status": "error"}, registered as[bq, retry]. The row isTOOL_COMPLETED/OK, while the model receives the retry plugin's error response. - Fix: reword to "…records each raised error once, as TOOL_ERROR; a failure that only a later plugin detects in the result is recorded as TOOL_COMPLETED unless
tool_result_classifierflags it."
P2-3: The list of plugin-order limits leaves out unknown tool names.
- What happens: with the retry plugin first, a hallucinated tool name is answered in
on_tool_error_callback. ADK then skips the after-tool callbacks for lookup errors (src/google/adk/flows/llm_flows/tools/_caller.py:820-838), so the call gets TOOL_STARTING and no outcome row. Base does the same. - Why it matters: this is a realistic case, since the retry plugin's own guidance covers "Wrong Function Name".
- Fix: list it next to re-raise and
extract_error_from_resultin the### Plugin orderdocstring (:4988).
P2-4: Declining the _detect_error_in_response hooks is acceptable for this PR, but the stated reason is broader than it should be.
- "A tool may also return those as data" holds for
FunctionTool's truthyerror. It does not hold for ADK's own error envelopes. GoogleTool.run_async(src/google/adk/tools/google_tool.py:103) catches every exception and returns{"status": "ERROR", "error_details": str(ex)}.GoogleToolunderliesBigQueryTooland the Spanner, Bigtable, Pub/Sub, GCS, Eventarc and Data Agent toolsets.- Probe: a
GoogleToolwhose function raises writesTOOL_COMPLETED/OK, while the same call's OTelerror.typeisTOOL_ERROR. Base does the same. - So a BigQuery analytics plugin still undercounts failures of ADK's own BigQuery tools.
- Suggested follow-up, either of:
- a built-in rule for the
GoogleToolenvelope that takeserror_messagefromerror_details, as the retry-answer rule does (it isstr(ex)); - or an opt-in classifier that mirrors the telemetry hooks, with the
GoogleToolcase named in the release note.
- a built-in rule for the
P2-5: The release note leaves out a data change. For reclassified calls, the result is no longer in content, and that includes the MCP error text. Queries that read JSON_VALUE(content, '$.result…') for those calls will stop finding it. The Privacy section of the PR body says this, but the release note does not. Keeping the result out by default is defensible, and you might offer an opt-in to keep it for debugging MCP failures.
P2-6: Two claims need rewording.
:818-821and the PR body say classification "runs none of the result's own code". Butdict.getcalls a stored key's__eq__on a hash collision. My colliding key that always compares equal turned{key: True}into a TOOL_ERROR row. A colliding key that raises is caught by the outer boundary, so the effect is harmless. Suggested caveat: "except dict keys' own__hash__/__eq__".:8690and the PR body say a returned awaitable is closed. Only coroutines are closed. Other awaitables, such as aFuture, are ignored with a warning.
P2-7: Optional hardening of the marker.
- Where:
on_tool_error_callbackcreatesweakref.ref(tool_context)(:8736-8739) after popping the span and before_log_event. - Failure: a context that can't take a weak reference, such as a
SimpleNamespacefrom a custom caller or a test, raises inside_safe_callback, and the TOOL_ERROR row is lost. - Reachability: no ADK flow reaches this, since
ToolContext = Contextsupports weak references. - Fix: catch
TypeErroraround the ref and skip the marker; the row is then kept.
P2-8: One test gap.
- Dependency: the marker is correct because each parallel call runs in its own copied context (
src/google/adk/flows/llm_flows/tools/_batch_executor.py:171). If two errors interleaved in one shared context, the single slot per instance would be overwritten and a duplicate row written. - Gap: no test pins this.
- Suggested test: a Runner test with two parallel raising calls and
[bq, retry], expecting two TOOL_ERROR rows and no TOOL_COMPLETED, next totest_raised_error_answered_by_retry_plugin_is_recorded_once(test file line 7587). It would guard both the marker and the span stack. AToolNodecase would be cheap to add as well.
P2-9: Title. The PR title and the commits say fix(plugins), but the body recommends feat(plugins). Use the feat title for the upstream squash, so release-please lists the new API and the TOOL_COMPLETED→TOOL_ERROR change under Features.
The design points you flagged
- Leaving the result out of TOOL_ERROR content: acceptable. It matches the rows
on_tool_error_callbackwrites and closes the formatter gap; see P2-5. - Taking the retry-answer message from
error_details: correct. It equalsstr(error), and both plugin orders agree in my runs, including exceptions with no message. - The classifier signature (four keyword-only arguments): good.
- Toolbox and Skill detection through
sys.modulesand class names: correct for toolbox-adk 1.4.0. It exportsToolboxToolat the top level, andToolboxTool's MRO has noFunctionTool. The enumeration test guards the skill tool names. - The per-call marker: sound on every ADK path I exercised: the LLM flow, the live path (which shares
_execute_single_prepared_call),ToolNode, node retry and nestedAgentTool. - The
event_allowlistand dashboard change: documented.
VERDICT: APPROVE @ fa56c26
A hostile or malformed sibling field could still hide a well-formed error signal. Three reads could raise: - checking a field's type with isinstance() consulted the value's __class__; - looking an entry up hashed and compared the result's own keys; - vars() dispatched to a model subclass's __getattribute__. Any of them raising sent the whole result to the TOOL_COMPLETED fallback, so an MCP isError=True or a ReflectAndRetryToolPlugin answer was recorded as a success. Fields are now read without running any code of the result, its keys or its values: - types are checked with type.__subclasscheck__ on the actual type; - a dict result is scanned for exact-str keys instead of being looked up; - a model's fields come through the SDK class's own __dict__ descriptor. Each rule then runs on its own, so a failing one cannot hide another's signal. Every boundary logs its constant warning after leaving its except block; inside it, a failing log handler would print the caught exception. Also from review: - check a ReflectAndRetryToolPlugin answer before the classifier, so a classifier cannot hide a raised error in one plugin order only; - reject async generator classifiers and classifiers whose signature inspect cannot read, and close generator-based coroutines too; - keep the TOOL_ERROR row for a tool context that cannot take a weak reference; - state the plugin-order limits precisely, including unknown tool names and failures only a later plugin detects. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
caohy1988
left a comment
There was a problem hiding this comment.
Independent Astra review of head 92ff4bb2233c8fae540bca37dcb09cd204f53ee9 against def458b609c2811d137b0332b2fc7b201dddd5c0. Submitted as COMMENT as requested.
Rejection criteria, written before reading the diff
- A valid error signal is missed, or hostile result/classifier values drop or duplicate an outcome row.
- New error rows leak payload text or bypass formatting/redaction.
- Plugin ordering or execution paths violate the stated contract without a documented, acceptable limit.
P1 — The completion fallback still drops hostile outcomes and logs their payload-bearing exceptions
Location: src/google/adk/plugins/bigquery_agent_analytics_plugin.py:8693 (completion serialization), with the new unconditional row guarantee at :8647; the escaping type check is at :1890, and _safe_callback logs its traceback at :568.
The classification boundary ends before _recursive_smart_truncate(result, ...). A result whose __class__ access raises passes the new classification safely, then raises from the sanitizer's unguarded isinstance before its own try. _safe_callback logs the payload-bearing exception and returns without an outcome row. The span has already been popped.
I reproduced all three cases through the real mocked BigQuery row-writing fixtures:
- An opaque result with a raising
__class__property. - A valid
CallToolResult(isError=False)subclass whose attribute access raises after construction. - A
CallToolResult(isError=True)of that subclass with the new classifier returningToolResultClassification(status="OK").
For each, the actual assertion result is (['TOOL_STARTING'], True), where the boolean means the synthetic payload text appeared in captured logs. Required: (['TOOL_STARTING', 'TOOL_COMPLETED'], False). The third case exercises the new classifier's documented override path. The error-flagged model without that override correctly produces TOOL_ERROR.
Evidence: /tmp/adk-pr15-dual/r3/astra-probes/test_completion_boundary.py and completion-boundary.log — 3 failures. Reproduce with this worktree's .venv-astra/bin/python, PYTHONPATH=$PWD/src:$PWD:/tmp/adk-pr15-dual/r3/astra-probes, and -m pytest /tmp/adk-pr15-dual/r3/astra-probes/test_completion_boundary.py -q -p no:cacheprovider.
Scope distinction: I also reproduced the underlying opaque-result serializer defect with the base plugin loaded separately. It is inherited, not a newly introduced serializer regression, and the PR body acknowledges part of it. However, the requested round-2/round-3 acceptance contract explicitly requires hostile results to retain their outcome row without payload logging. Documentation alone does not meet that requirement; this is why I am treating it as blocking for this review.
Suggested fix: contain completion-payload preparation in a fail-closed boundary, substitute a constant sentinel and mark truncation on failure, then continue through the normal formatter/redaction and TOOL_COMPLETED writer. Log only constant text after leaving the exception handler. Add the successful hostile MCP and classifier-override cases alongside the existing error-flagged hostile model tests.
P0 / other P1 / P2 findings: None.
What I tried and verified
- Read the complete two-file diff, the three implementation commit messages, the PR description, issues google#7112/google#485, and relevant tool, workflow, plugin-manager, streaming, and AgentTool code. No branch modifications.
- Fresh uv environment in this worktree, Python 3.11.13. Full frozen all-extras setup hit the anticipated unsupported
google-antigravitywheel and a lockedcryptographysource-build failure; used a scoped dependency install with BigQuery, MCP, Toolbox, A2A and test dependencies. This was not a full locked all-extras environment. - Required plugin suite: 641 passed with
-n 8 -q, no skips after installing A2A. Broader plugins + ToolNode + McpTool + AgentTool run: 1,115 passed (includes that suite). Separate live/long-running/parallel/streaming-flow selection: 65 passed. - Own adversarial matrix: 106 passed under MCP 2.2.0, and 106 passed with MCP 1.30.0 selected through an isolated package directory. It covers dicts, dict subclasses, Pydantic dumps, actual MCP/Pydantic model subclasses, raising attribute/equality/truth hooks, colliding keys, numpy/pandas values, malformed classifier returns, awaitables, and successful controls. Valid built-in error signals survived all tested hostile sibling fields.
- The same probes verified actual row content, formatter success/failure, payload-column denial, and the documented
TOOL_COMPLETED-only allowlist behavior. Classifier exceptions and malformed verdicts used content-free warnings. Native returned coroutines were closed. - Two distinctly named analytics instances in a real runner, 24 mixed asynchronous calls per run, three plugin orders, retry guidance and final responses: each instance recorded exactly one outcome per call; message-less exceptions agreed. Six additional live terminal-result probes passed.
- Separate base/head comparisons confirmed the MCP dict/model fix, retry/final-response fix in both orders, removal of the duplicate completion, and nested AgentTool outcome counts. They also reproduced the documented inherited limits for retry re-raise, unknown tool names, short-circuiting after-tool plugins, and resumed long-running results.
- PR-body mutation script: 40/40 mutations caught; no survivors, rerun against temporary source copies. This confirms the reported mutation result, but does not cover the three additional failing completion cases above.
- Pinned
pyink 25.12.0 --checkandisort 8.0.1 --check-onlyboth passed for both changed files. No live BigQuery service test, full all-extras suite, mypy, or complete pre-commit rerun is claimed.
Design judgments
Leaving the result out of TOOL_ERROR is appropriate for the existing formatter/privacy contract. Reflect retry messages use the same exception text as the raised-error path; redaction remains in the writer. Keyword arguments and construction-time classifier validation are appropriate. Toolbox and the six current Skill tool classes were correctly recognized without importing optional modules during classification.
The explicitly documented plugin-order limitations are acceptable framework constraints for this change. Keep analytics first. The strict boolean MCP rule and not invoking arbitrary per-tool detection hooks are defensible: broad truthy error rules would change successful application data and output retention. A built-in GoogleTool envelope rule and an opt-in to retain results are useful follow-ups, neither is a blocker here. Event-allowlist/dashboard migration is clearly described. The hostile completion fallback above is a separate, explicit acceptance-contract failure.
Blindness disclosure: no peer review text, review results, forbidden files, or PR review/comment bodies were read. An incidental process-status diagnostic exposed a peer launch command containing task instructions and another pytest command of unknown provenance; no review conclusions or test results were exposed.
VERDICT: REQUEST_CHANGES @ 92ff4bb
caohy1988
left a comment
There was a problem hiding this comment.
Fable blind review, round 3: PR #15 @ 92ff4bb
Independent blind review (Claude Fable) of head 92ff4bb2233c8fae540bca37dcb09cd204f53ee9 against base def458b609c2811d137b0332b2fc7b201dddd5c0. Code changes reviewed: f187aef, 563d32c, 92ff4bb (the two merges touch no plugin files). I read only the PR description, the diff, issues google#7112 and GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485, the repository code, and CI check logs. I read no other review or comment.
Outcome: one narrow P1: a row-loss regression in the new per-call marker, with a five-line fix that I prototyped and tested. Everything else I threw at the change held up. Details below.
Rejection criteria (written before I read the diff or the PR body)
- R1 Row integrity. Each tool call gets exactly one outcome row (
TOOL_COMPLETEDorTOOL_ERROR). Reject on a lost row, a duplicate (TOOL_ERROR+TOOL_COMPLETED, or twoTOOL_ERRORs), or an exception escaping a callback. This applies in either order relative toReflectAndRetryToolPlugin, with two analytics instances, with parallel calls, and with hostile results or classifiers. - R2 Classification correctness. Reject if a well-formed error signal is recorded as
TOOL_COMPLETED/OK, particularly next to a hostile or malformed sibling field. The signals are MCPisError=Trueas a dict, a dict subclass or aCallToolResultmodel, and a reflect-and-retry answer. Also reject if a successful result becomesTOOL_ERROR, or if a Toolbox or Skilltool_originis wrong. - R3 Payload safety, API contract, evidence. Reject if a new row or warning carries payload-derived text, or if the new rows bypass
content_formatteror redaction. Also reject if the classifier contract isn't what ships, or if the core claims have no test that fails on unfixed code.
Result. R1 is met, narrowly, by P1-1 below. R2 and R3 are not met.
What I tried
Environment: Python 3.11.13 (macOS x86_64), pydantic 2.13.5, mcp 2.2.0 (SDK 2.x, where mcp.types.CallToolResult is mcp_types.CallToolResult and the field is is_error), toolbox-adk 1.4.0.
pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8: 641 passed.tests/unittests/plugins,workflow/test_tool_node.pyandtools/mcp_tool/test_mcp_tool.py: 1062 passed. Wider set: every test file that references the plugin (cli/test_fast_api.py,test_optional_dependencies.py), plusplugins/,workflow/,tools/mcp_tool/,flows/llm_flows/,tools/test_agent_tool.pyandtools/test_skill_toolset.py: 3689 passed, 12 skipped, 6 xfailed.- pyink 25.12.0 and isort 8.0.1 (the pre-commit pins) report both files clean. ruff is clean, and mypy on the plugin reports no issues.
- Do the tests fail on unfixed code? The test module subclasses
ToolResultClassificationat import time, so I ran it against an API-only shim instead of reverting in place: basedef458b6plus only the new public names, with no behavior. 70 of the 89 new tests fail and no pre-existing test fails. The 19 that pass are guards that should pass on base: results without a failure signal stayTOOL_COMPLETED, the dataclass validates itself,toolbox_adkisn't imported, a context without weak references keeps its row, and the next call isn't skipped. - Mutation check. I reran the PR's
mutate.py(0–39) from a temporary copy. All 40 mutations are caught, with the same per-mutation failure counts as the PR body. - CI. All five
Unit Testsjobs on this head were canceled at the workflow's 10-minutetimeout-minutes, around 94%, each showing oneFin the progress dots and no summary. The fork'smainpushe4c0d946shows the identical pattern (oneFper job, then canceled), so this isn't attributable to the PR. It does mean CI gives no evidence here, and upstream CI will need to be green. Mypy, pre-commit, MCP v2 and A2A jobs pass. - My probes (739 cases in
/tmp/adk-pr15-dual/r3/fable-probes/test_fable_r3_probes.py, run under-p no:loggingwith a stockStreamHandlerand a swappedsys.stderr, and hostile hooks armed only during the callback). Result at head: 732 passed, 3 failed (P1-1), 4 xfailed (identical on base, P2-1). With my P1-1 fix prototype: 735 passed, 4 xfailed.-
Hostile-sibling matrix, 692 cases.
- Result shapes:
- a plain dict;
- a dict subclass where every method raises, including
__getattribute__; - an MCP
CallToolResult; - a
CallToolResultsubclass with raising__getattribute__,__eq__,__bool__and__iter__; - a
CallToolResultsubclass under a pydantic metaclass subclass with raising__instancecheck__,__subclasscheck__and__getattribute__.
- Signals:
isError,is_error, a reflect answer, and a reflect answer with hostileerror_details. - Hostile values in every other wanted slot and in unrelated slots (
content,structured_content,meta):- an object whose every dunder raises;
strsubclasses whose every hook raises;- metaclass-hostile classes, plain and
str-derived; - a raising
__class__property; - a numpy array,
np.True_,pd.NA, a pandas Series, NaN.
- Keys that hash like each wanted name and raise on comparison.
- Result: the valid signal always wins, with the correct message.
strsubclasses count by value, so one holding the retry marker reads as a retry answer. No payload reaches the log, stderr or the rows, and no rule-failure or "Could not classify" warning fires.
- Result shapes:
-
End-to-end ordering with real runners. Plugin orders:
[bq, reflect],[reflect, bq],[bq1, reflect, bq2],[reflect, bq1, bq2]and[bq1, bq2, reflect]. One model turn makes 5 parallel calls: two raising, an MCP dict withisError, an MCP model withisError, and one success. Every analytics instance writes exactly 5 outcomes, with the right types and messages. These cases also passed:- retry exceeded with
max_retries=0, throw_exception_if_retry_exceeded=False, in both orders; - an agent-level
on_tool_error_callbackanswering; - the same tool raising and succeeding in parallel, in both orders;
- a message-less exception in both orders (
RuntimeErroreither way); - a workflow
ToolNodewithRetryConfig: it fails then succeeds, givingTOOL_ERRORthenTOOL_COMPLETED, and the failed attempt's mark doesn't suppress the retry because each attempt gets a newContext; - a nested
AgentToolwith shared plugins in both orders: the innerTOOL_ERRORis written once and the outerTOOL_COMPLETEDonce.
The documented "unknown tool name with reflect ahead" gap reproduces as stated.
- retry exceeded with
-
Classifier:
- swapped to
asyncafter construction: the row is kept, a warning is logged, and there's no "never awaited"; KeyboardInterrupt,SystemExit,CancelledErrorandGeneratorExitpropagate, as documented;- signature variants behave as documented;
- the verdict message is redacted.
- swapped to
-
New rows versus formatter, denylist and allowlist:
content_formatterreceivesTOOL_ERRORwith{tool, args, tool_origin}and no result, and its output is used;- a raising formatter gives the
[FORMATTER_FAILED]sentinel, keeps the row, and leaks nothing; payload_column_denylist=["content"]nulls the content;- an allowlist without
TOOL_ERRORdrops just that row, with no error.
-
A real
_dump_mcp_model(CallToolResult(isError=True))is recorded asTOOL_ERROR, and the MCP text is absent.
-
Findings
P1-1: a raised error whose __str__ raises now leaves the call with no outcome row
src/google/adk/plugins/bigquery_agent_analytics_plugin.py:8845-8869. on_tool_error_callback pops the span (8845) and marks the call (8848, 8855). Only afterwards does it evaluate str(error) or type(error).__name__ (8869), inside the arguments of _log_event. If str(error) raises, _safe_callback swallows it and no TOOL_ERROR row is written, but the mark stays. When a later handler answers the error, after_tool_callback (8628-8636) sees the mark and skips. The call ends with TOOL_STARTING and no outcome, in the plugin order the PR recommends. This is the same class of hazard 92ff4bb already fixed for contexts that can't take a weak reference.
Analytics registered first; tool raises ValueError(obj) where obj.__str__ raises |
base def458b6 |
head 92ff4bb2 |
head + fix below |
|---|---|---|---|
| no handler answers | TOOL_STARTING |
TOOL_STARTING |
TOOL_STARTING, TOOL_ERROR |
| a later handler answers (unit) | TOOL_STARTING, TOOL_COMPLETED/OK |
TOOL_STARTING only |
TOOL_STARTING, TOOL_ERROR |
E2E: LlmAgent(on_tool_error_callback=...) answers |
TOOL_STARTING, TOOL_COMPLETED/OK |
TOOL_STARTING only |
TOOL_STARTING, TOOL_ERROR ("ValueError") |
ReflectAndRetryToolPlugin itself calls str(error), so with it the run raises RuntimeError and the call keeps only TOOL_STARTING, at base and head alike (probed). The regression shows with any other answerer, such as an agent callback or a custom plugin. This is narrow, but it breaks the PR's own invariants ("exactly one row per tool call", "nothing hostile ever drops the row") in code this PR changed. It also meets R1.
Suggested fix, prototyped in a copy of src. It passes my three probes and the PR's 641 tests. Compute the message before marking the call:
span_id, duration = TraceManager.pop_span()
parent_span_id, _ = TraceManager.get_current_span_and_parent()
# Computed before the call is marked: once it is marked,
# after_tool_callback skips the call, so nothing that can fail may run
# between the mark and the row.
try:
error_message = str(error) or type(error).__name__
except Exception: # pylint: disable=broad-except
error_message = type(error).__name__
try:
recorded_call = weakref.ref(tool_context)
...
event_data=EventData(
status="ERROR",
error_message=error_message,Please add a regression test (unit, plus E2E with an agent-level on_tool_error_callback) asserting exactly [TOOL_STARTING, TOOL_ERROR] with error_message == "ValueError".
P2-1 (pre-existing, identical on base): hostile results without an error signal still lose their row and log the payload
Four shapes lose the TOOL_COMPLETED row at both head and base:
- a dict subclass with raising methods and no flag, e.g.
_RaisingMethodsDict({"rows": 1}); - an object with a raising
__getattribute__; - an object with a raising
__class__; - a
CallToolResultsubclass with a raising__getattribute__andisError=False.
In each case _recursive_smart_truncate(result, ...) (8693) raises, and _safe_callback's logger.exception (568) logs a traceback that carries the payload text. The TOOL_ERROR path is safe because it never serializes the result. This isn't a regression. But the PR body's "Results with a raising ==, raising get or other methods … keep their row. No payload reaches the log" holds only when the result carries an error signal, and the "Pre-existing and unchanged" list names only the __class__ case. Please either narrow that wording or, as a follow-up, put the TOOL_COMPLETED serialization behind the same constant-warning boundary with a sentinel result.
P2-2: gaps in construction-time classifier validation (the runtime boundary still copes)
functools.partial(obj)whereobj.__call__isasync defpasses_validate_tool_result_classifier(1732-1739). Neitherinspect.iscoroutinefunction(partial)nor the partial's own__call__detects it. At run time the coroutine is closed with a warning.- A classifier whose
__signature__raises something other thanTypeError/ValueError(e.g. a property raisingRuntimeError) escapesinspect.signature(1742-1744) as that exception instead of the documentedValueError.
Suggested fixes: unwrap partials before the __call__ check, and catch Exception around inspect.signature so it re-raises as ValueError.
P2-3: design notes (non-blocking)
The MCP server's error text no longer appears anywhere in the analytics row. The message is fixed and the result is omitted, so MCP users lose some debuggability compared with main. The privacy rationale is sound, and the release note and the "keep-result opt-in" follow-up cover it.
ADK-generated envelopes still record TOOL_COMPLETED/OK: GoogleTool's {"status": "ERROR", "error_details": ...} and FunctionTool's own {"error": "Invoking X() failed ..."} for missing mandatory args. I'd list the FunctionTool case next to the GoogleTool follow-up.
On the design points the implementer flagged
-
Result omitted from
TOOL_ERROR: agree, with the P2-3 caveat. -
Reflect message from
error_details: agree.ReflectAndRetryToolPluginsetserror_details=str(error)on both the retry and the retry-exceeded answers, which matcheson_tool_error_callback, and redaction applies. -
Keyword-only classifier signature: agree, apart from the P2-2 nits.
-
Toolbox/Skill detection through
sys.modulesand class names: acceptable.toolbox_adk.ToolboxToolsubclassesBaseTooldirectly, so placing the check afterFunctionToolis safe. The skill test enumerates everyBaseToolsubclass inskill_toolset, so a new tool class fails loudly. -
Marker relying on a shared context: sound across these flows:
_caller.py:on_tool_errorandafter_toolrun in one_run_with_tracecoroutine, and each parallel call is a task holding a copied context (_batch_executor.py:186);- live mode uses the same pipeline;
_tool_node.pyuses one coroutine, with a newContextper retry attempt;- nested
AgentTool.
The one exception is P1-1's ordering.
-
Dashboard/
event_allowlistchange: acceptable as afeatwith the release note. Probes behave as the release note says. -
Not aligning with
_detect_error_in_response: acceptable.FunctionTool's truthy-errorrule would flag data and confirmation envelopes. For realMcpTooldumps,isErroris a JSON bool, sois Trueequals McpTool's truthiness. -
Deferred
GoogleToolrule and keep-result opt-in: not blockers. Both are outside google#7112's stated scope, and a classifier covers the envelope today. -
BaseExceptionfrom a classifier propagates: acceptable deviation from "never crash the callback". The span has already been popped, so the stack stays consistent.
Independence
I read no reviews or comments on this PR. My local session memory holds general test-harness notes from earlier sessions: the hook-free-read and probe techniques, plus a one-line mention of earlier rounds of this review. I read no earlier-round results, probes or peer material.
VERDICT: REQUEST_CHANGES @ 92ff4bb
… results Two paths could still lose a tool call's outcome row and log an exception that can carry the payload through _safe_callback's traceback: - after_tool_callback serialized a TOOL_COMPLETED result outside any boundary. Classification runs none of the result's code, so a result whose attribute reads raise got past it and then raised from the serializer: a dict subclass or object whose __getattribute__ raises, a raising __class__, or a CallToolResult subclass that succeeded or whose error the classifier recorded as OK. - on_tool_error_callback marked the call as recorded before reading str(error). An error whose __str__ raised lost its TOOL_ERROR row, and once a later handler answered the error, after_tool_callback skipped the marked call, which was left with no outcome row at all. The result is now serialized inside a boundary that records the serializer's own sentinel and logs a constant warning after leaving the except block. The error message is read before the call is marked and falls back to the error's type name. Also from review: the classifier check unwraps functools.partial, so a partial of an object with an async __call__ is rejected, and any failure to read the classifier's signature raises ValueError. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
caohy1988
left a comment
There was a problem hiding this comment.
Blind Astra review of d5b8ecbb1f846b5d920d6d151c693abf18433434, against def458b609c2811d137b0332b2fc7b201dddd5c0. This is a COMMENT review, as requested. No other review or earlier-round artifact was consulted.
Rejection criteria, set before reading the diff
- A supported tool outcome reaching the callbacks is lost, duplicated, or assigned the wrong outcome/status; successful results or raised-error handling regress.
- A new result/classifier/serialization path exposes payload text beyond the existing TOOL_ERROR privacy contract, including through process logs.
- A claimed fail-closed or lifecycle guarantee fails a targeted probe. Under the final-round cap, hostile-class-only residuals are P2 follow-ups; REQUEST_CHANGES requires P0/P1.
P0 findings: None.
P1 — Consume failures from returned Future/Task objects before discarding them.
Location: src/google/adk/plugins/bigquery_agent_analytics_plugin.py:8819–8828.
Classification (1): a real payload leak using ordinary Python functions, dictionaries, ValueError, asyncio.Future and asyncio.Task; no hostile/adversarial class is involved. It requires an incorrectly implemented synchronous classifier returning an awaitable, which is precisely a malformed return this new boundary attempts to contain.
Construction accepts the following synchronous classifier with the required keyword signature:
def classify(*, tool, tool_args, tool_context, result):
future = asyncio.get_running_loop().create_future()
future.set_exception(ValueError(result["private_text"]))
return futureWith result {"isError": True, "private_text": "ASTRA_R4_PRIVATE_PAYLOAD_2391"}, the callback correctly writes one TOOL_ERROR, but drops the Future without retrieving its exception. Python then emits:
Future exception was never retrieved
future: <Future finished exception=ValueError('ASTRA_R4_PRIVATE_PAYLOAD_2391')>
ValueError: ASTRA_R4_PRIVATE_PAYLOAD_2391
A sync wrapper returning asyncio.create_task(...), whose async computation raises ValueError(result["private_text"]) after yielding, similarly logs Task exception was never retrieved plus the payload and traceback. I reproduced both through the actual row pipeline with a scrubbing content_formatter and payload_column_denylist=["content", "content_parts"]: the rows contain no canary, but the process log does. A constant warning from this plugin does not contain the later asyncio diagnostic. The PR body's explicit decision to ignore Futures as-is leaves the malformed-classifier privacy guarantee unmet.
Suggested fix: explicitly handle standard asyncio Future/Task returns: retrieve a completed failure without logging its exception, and attach a cancellation-aware, exception-consuming completion callback for pending ones. Preserve the built-in classification fallback and constant warning; do not arbitrarily cancel caller-owned work. Add regression tests for both already-failed Futures and Tasks that fail after the callback returns, checking the asyncio log as well as the plugin log and row. This is one finding with two reproductions, not two separate blockers.
Reproduction artifacts: /tmp/adk-pr15-dual/r4/astra-probes/test_independent.py, tests test_failed_standard_awaitable_does_not_leak[future] and [task]; output in independent.log. A standalone reproduction is future_probe.py in the same directory.
P2 findings: No additional finding retained. The disclosed pre-existing hostile-class residuals are not promoted to blockers.
What I checked
- Read the PR description, all four implementation commit messages, the complete two-file base-to-head diff, issues google#7112 and BigQuery-Agent-Analytics-SDK#485 including their allowed issue comments, and the relevant runner, callback, workflow, tool, and logging code.
- Requested command:
PYTHONPATH="$PWD/src" .venv/bin/python -m pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8 -q: 654 passed. tests/unittests/pluginsplustests/unittests/workflow/test_tool_node.py,-n 8 -q: 966 passed, including the previous 654.- MCP tool tests plus
test_live_tool_callbacks.pyandtest_plugin_tool_callbacks.py,-n 8 -q: 150 passed. - Independent suite: 86 passed, 2 failed, both failures being the P1 above. This includes a 48-case sibling-field matrix over dicts, hostile dict subclasses, Pydantic dumps, and MCP model subclasses; raising attribute/class/comparison/truth hooks; numpy/pandas fields; colliding keys; strict non-bool flags; unreadable success/error MCP models; malformed/raising classifiers; coroutine closure; partial async/async-generator callable rejection; signature failures; formatter failures, redaction, and payload denylists.
- Independent actual-runner probes exercised 12 async calls held at a barrier, mixing successful results, MCP errors, and message-less raised errors. Both plugin orders and one/two distinctly named analytics instances retained exactly one outcome per call per instance.
max_retries=0, throw_exception_if_retry_exceeded=Falsealso exercised final retry responses. The shipped tests additionally cover ToolNode, unreadable exception text answered by an agent callback, classifier-forced OK, span pairing, and final-response tools. - Independent live terminal tools classified MCP errors correctly. Probes confirmed the documented limits for streaming chunks, resumed long-running responses, and a retry subclass short-circuiting after-tool callbacks. Nested AgentTool runs retained their inner/outer outcome rows in both orders; the separately documented shared span-stack limitation remains out of scope.
- An independent runner reproduction passed in both orders at head and failed in both with only the plugin replaced by the base version in a temporary source copy: base mislabels MCP/retry results and, with analytics first, emits the duplicate TOOL_COMPLETED after TOOL_ERROR. No tracked worktree file was replaced.
- Pinned pyink 25.12.0 and isort 8.0.1 checks passed on both changed files.
mypypassed on the plugin.
Design/scope assessment
Omitting the result from TOOL_ERROR is acceptable given existing event-specific formatters; a keep-result opt-in is not a blocker. Reflect error_details matches the existing exception message path, including the tested message-less fallback. Keyword invocation, instance-specific weak markers, and lazy Toolbox/Skill type detection are reasonable for the current code; existing origin tests passed with the real installed Toolbox package. The documented recommendation to register analytics first is necessary because PluginManager stops callback dispatch; the limitations are not silently presented as universal order independence.
Keeping built-in rules narrower than _detect_error_in_response is acceptable for this scoped fix: arbitrary truthy error fields need not be failures, and the MCP contract uses a boolean flag. A GoogleTool envelope rule would be useful, but its pre-existing undercount and the keep-result opt-in can remain follow-ups. The dashboard/event_allowlist migration and lost result column are material behavior changes and are explicitly disclosed in the proposed release note.
Validation limits and workspace
Python 3.11.13; this worktree's uv environment. All-extras sync excluded the three permitted unavailable packages. Pinned cryptography 50.0.1 also failed its native build (missing x86_64 Rust target/OpenSSL); validation retained installed cryptography 48.0.1, so this was not a fully locked dependency environment. No live BigQuery, external MCP server, or remote model was contacted. I inspected the supplied mutation report/script but did not independently rerun all 51 mutations. No tracked files were modified; HEAD and the tracked worktree remain unchanged. No push or merge was performed.
Criterion 2 is met by the standard Future/Task leak; the other exercised behavior passed within the documented scope.
VERDICT: REQUEST_CHANGES @ d5b8ecb
caohy1988
left a comment
There was a problem hiding this comment.
Fable blind review: PR #15 round 4 @ d5b8ecb
This is a blind review. I read the PR description, the diff against def458b6, issues google#7112 and google#485, and the repository code. I read no other review or comment on this PR.
Rejection criteria (written before reading the diff)
- R1, row integrity. Reject if any tool call reachable with ordinary tools, results or errors ends with zero outcome rows (
TOOL_COMPLETED/TOOL_ERROR), or with more than one, for any analytics plugin instance. That covers MCPisErrorresults,ReflectAndRetryToolPluginanswers in either plugin order, raising tools, parallel calls,AgentToolnesting, and a failing or odd classifier. Also reject if the new code can raise out of a plugin callback into the agent run. - R2, classification. Reject if a successful result becomes
TOOL_ERROR. Also reject if an error the PR claims to handle staysTOOL_COMPLETED/OK, including when a sibling field is malformed. The claimed errors are an MCP result withisErrorTrue, as a dict or aCallToolResultmodel, and a retry answer. Reject too if raising-tool behavior changes beyond what is documented. - R3, leak or false claim. Reject if a
TOOL_ERRORrow or a warning carries payload-derived text beyond whatTOOL_ERRORalready records, or ifcontent_formatterand redaction stop holding for the new rows. Also reject if a concrete claim in the PR body, a docstring or a commit message is false or has no test behind it.
None of the three is met. Evidence follows.
What I ran
Environment: Python 3.11.13 (worktree venv) and 3.10.16 (a fresh dependencies-only venv in /tmp), MCP SDK 2.2.0, pydantic 2.13.5.
tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8: 654 passed on 3.11 and 654 passed on 3.10. The PR body's other two counts reproduce exactly: 815 with the reflect-retry and MCP tool files, and 966 fortests/unittests/pluginsplusworkflow/test_tool_node.py.- The new tests fail on unfixed code. I ran this head's test file against base
def458b6with only the new public names added:ToolResultClassification,ToolResultClassifierand the config field, with no behavior. 82 of the 114TestToolResultErrorClassification/TestToolProvenancecases fail. The 32 that pass are the existing provenance tests, the dataclass validation tests, and the intended "unchanged" guards. - The PR's mutation check (
mutate.py, rerun from the PR body): 51 of 51 mutations caught, with the same failure counts the body reports. - Static checks: pyink 25.12.0
--check, isort 8.0.1--check-only, ruff 0.15.17, codespell 2.4.2 and mypy are clean on both files. The worktree was never modified. - My own probes: 101, kept outside the repo. All pass at this head on 3.10 and 3.11. Against base, 16 of the 20 end-to-end probes fail, so they discriminate.
What I tried to break
-
Hostile sibling fields beside a valid error signal (19 cases).
- Results: dicts and dict subclasses with
isError/is_errorTrue. Some subclasses have raisingget,items,__iter__or__getitem__, a raising__getattribute__, or a raising__class__. - Siblings: fields whose
__class__,__eq__,__bool__,__str__or metaclass hooks raise; numpy and pandas values; keys that collide withisError,response_typeorerror_detailsand raise when compared. - Retry answers whose
response_typeorerror_detailsis hostile or astrsubclass. - MCP 2.x
CallToolResultbuilt withmodel_constructaround hostile content. CallToolResultsubclasses with a raising__getattribute__, a raising__class__, or aModelMetaclasssubclass whose__instancecheck__,__subclasscheck__and__getattribute__raise.
Every case gave exactly
TOOL_STARTINGthenTOOL_ERROR, with the expected message, a paired span and{tool, args, tool_origin}content. The marker text never reached the rows, a realStreamHandler(run with-p no:logging), or stderr. - Results: dicts and dict subclasses with
-
Successful and odd results (25 shapes).
isErrorFalse beside the same hostile siblings.isErrorset tonp.True_,1or"true", and a(str, Enum)key.- Hostile dicts and objects that cannot be serialized.
- An MCP model with
is_errorFalse, both plain and as a hostile subclass. - A non-MCP pydantic model with an
isErrorfield. - DataFrame, ndarray, set, generator, bytes, datetime, Decimal and None.
- A list of MCP dumps,
{"error": …}and{"error_details": …}.
All kept one
TOOL_COMPLETED/OKrow. The unserializable ones got the sentinel and the constant warning, and nothing payload-derived was logged. By design, the flag must be exactlyTrueunder an exact-strkey;McpTool's JSON dump always produces abool. -
Plugin orders, end to end. One model turn made six parallel calls: raising, raising without a message, MCP dict error, MCP model error, success, and MCP success.
- Orders:
[bq, reflect],[reflect, bq],[bq1, reflect, bq2],[reflect, bq1, bq2],[bq1, bq2, reflect], and[bq]and[bq1, bq2]with an agenton_tool_error_callbackthat answers. The second instance is renamed, becausePluginManagerrejects duplicate names. - Wrapping each instance's
_log_eventshowed exactly one outcome row per call per instance. - The messages were the same in every order:
boom,ValueErrorand the fixed MCP text. - Spans were paired in single-instance runs, and the model received the MCP result unchanged.
- On base, the same probe shows
TOOL_ERRORplus a spuriousTOOL_COMPLETEDwith the analytics plugin first, whether the retry plugin or an agent callback answers.
- Orders:
-
Classifier in both orders. Always-
OKand always-ERRORclassifiers still leave raised errors asTOOL_ERROR, and the verdict decides every other call. -
Retry cap.
- With
throw_exception_if_retry_exceeded=False, three calls give threeTOOL_ERRORrows in either order. - With the analytics plugin first, a re-raise still leaves one
TOOL_ERROR. - With the retry plugin first, a re-raise leaves no outcome row, which is the documented limitation.
- With
-
Nesting and workflows.
AgentToolwith shared plugins, in both orders.- A fan-out of three parallel
ToolNodes, two of them failing, in both orders. - A
ToolNodewithretry_configwhose first attempt fails unanswered and whose second succeeds. It recordsTOOL_ERRORthenTOOL_COMPLETED: the leftover entry never skips the new attempt, which gets a freshContext.
-
Real
McpTool. With a mocked session returningCallToolResult(is_error=True), the row isTOOL_ERRORwithtool_originMCP, and the MCP text is absent from it. -
The
LLM_REQUESTprivacy claim. By default the MCP text appears only in the laterLLM_REQUEST'scontent_parts. Withlog_multi_modal_content=False, or withcontent_partsdenied, it appears nowhere. -
Classifier validation, at construction and at run time.
-
Rejected with
ValueError(16 shapes):- asynchronous callables: a coroutine function, a partial or nested partial of an object with an async
__call__, an async-generator__call__, a bound async method,AsyncMock, and apartialmethodasync__call__; - wrong parameters: positional-only, missing or extra required keywords;
- no readable signature: a raising
__signature__, andmax; - other: a non-callable, and the
ToolResultClassificationclass itself.
The same holds when the classifier is passed as a plugin keyword argument.
- asynchronous callables: a coroutine function, a partial or nested partial of an object with an async
-
Accepted (6 shapes): a keyword-only function, a bound method, a
**kwargslambda, a partial that binds an extra keyword,MagicMock, and None. -
At run time:
- returned coroutines and generator-based coroutines are closed, with no "never awaited" warning;
str, dict andMagicMockverdicts and a raising classifier fall back to the built-in rules with the constant warning;- the classifier's
error_messageis recorded.
-
-
Marker and concurrency, from reading the flows.
- Each LLM-flow call runs in its own task, created from a copy of its prepare snapshot (
_batch_executor._start_execute_task). PluginManagerawaits callbacks inline.- Workflow retries build a new
Contextfor each attempt (_node_runner._create_child_context).
So a marker entry lives only in the failing call's task and matches only that call's context. I found no ADK flow in which a second failure in the same task could overwrite it before the first call's
after_tool_callback. - Each LLM-flow call runs in its own task, created from a copy of its prepare snapshot (
-
Unreadable error messages. An exception whose
__str__returns astrsubclass with raising__bool__/__len__still records exactly oneTOOL_ERROR, with its type name.
Findings
P0: none. P1: none.
P2 follow-ups (none blocks this PR):
- Python 3.14 rejects a correctly typed classifier at construction (
src/google/adk/plugins/bigquery_agent_analytics_plugin.py:1752).- Category: compatibility. It is neither a leak nor a hostile class, and it fails loudly at startup rather than losing rows.
- Cause: on 3.14, annotations are evaluated lazily, so a classifier can annotate
tool: BaseToolwithBaseToolimported only underTYPE_CHECKING.inspect.signature()then raisesNameError, which the check turns into "must have a signature that inspect can read". - Evidence: I ran
_validate_tool_result_classifier, copied verbatim from this head, on CPython 3.14.0a6. It rejects such a classifier with causeNameError: name 'BaseTool' is not defined.inspect.signature(..., annotation_format=annotationlib.Format.FORWARDREF)reads the same function. - Scope: on 3.10–3.13 such a function cannot even be defined, so only 3.14 is affected. Worth confirming on 3.14 final.
- Fix: on 3.14 and later, call
inspect.signature(classifier, annotation_format=annotationlib.Format.FORWARDREF).
McpTool's default error envelope is stillTOOL_COMPLETED/OK.- Category: an undercount that predates this PR and lies outside its done-when list.
- Cause:
_MCP_GRACEFUL_ERROR_HANDLINGis on by default (src/google/adk/features/_feature_registry.py:191). SoMcpTool.run_asyncreturns{"error": "MCP tool execution failed: …"}or{"error": "Unexpected error during MCP tool execution: …"}(src/google/adk/tools/mcp_tool/mcp_tool.py:606,:611) instead of raising, forMcpErrorand transport failures such as a gateway 403. - Evidence: a real
McpToolwhose session raisesMcpError(-32000, "403 denied")recordsTOOL_STARTING, thenTOOL_COMPLETED/OK.McpTool._detect_error_in_responsedoesn't flag this envelope either, so the PR is consistent with the tool's own hook. - Why it matters: this is probably the most common MCP failure in production, yet the PR body's follow-up list names only the
GoogleToolandFunctionToolenvelopes. - Suggestion: add it to that list. A low-risk rule: tool origin
MCP, and a plain dict whose only key is"error"with an exact-strvalue, becomesTOOL_ERRORwith a fixed message.
- Doc nit: the dict rule is not limited to MCP tools (
bigquery_agent_analytics_plugin.py:873-883,:8782).- Any tool's dict whose top-level
isError/is_erroris exactlyTrueis recorded asTOOL_ERRORwith "Tool returned an MCP result with isError=true." - That is intended: the google#7112 reproduction uses a
FunctionToolreturning that shape. - The docstrings, though, call it "an MCP
CallToolResult… as a dict dump". Suggest "any dict result whose top-levelisError/is_errorisTrue".
- Any tool's dict whose top-level
Not counted: google#485's ADK-side item is untouched here and is not in this PR's done-when list. That item is a content_formatter failure writing [FORMATTER_FAILED] with a null error_message.
Design points the implementer flagged
- Result left out of
TOOL_ERROR: agree. A formatter written forTOOL_COMPLETEDrows would not scrub it. The text is still in the nextLLM_REQUEST'scontent_partsby default (probed). - Retry-answer message taken from
error_details: agree. It is the samestr(error)thaton_tool_error_callbackrecords, and the type-name fallback makes both orders record the same text (probed in both orders). - Classifier signature: fine. The keyword-only
(tool, tool_args, tool_context, result)mirrorsafter_tool_callback. - Toolbox and Skill detection through
sys.modules: fine. It avoids importing an optional package, andtest_skill_toolset_tools_return_skillenumerates everyBaseToolsubclass inskill_toolset, so a new skill tool cannot silently becomeUNKNOWN. - Per-call marker in a contextvar: sound for every current flow (item 10 above).
event_allowlistand dashboard change: documented in the release note, and the suggestedfeatsquash title fits.- Not reusing the
_detect_error_in_responsehooks: acceptable.FunctionTool's hook (function_tool.py:437) flags any truthy"error"key. That includes the HITL confirmation pause,{"error": "This tool call requires confirmation, …"}(function_tool.py:411), which is not a failure. - Deferred
GoogleToolenvelope rule and keep-result opt-in: not blockers. Both lie outside this contract, a classifier covers the first today, and by default the MCP text is still inLLM_REQUESTcontent_parts.
VERDICT: APPROVE @ d5b8ecb
A synchronous function tool blocks the event loop while it runs, and RunConfig.tool_thread_pool_config, the setting that moves tools off the loop, only applied in live mode. With it set, run_async now runs a synchronous function tool's function on the tool thread pool when an LlmAgent calls the tool, while async tools and tools used directly as Workflow nodes stay on the event loop, and without it nothing changes. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 990376105
A synchronous tool_result_classifier that returned a failed asyncio Future, or a Task that failed later, had it dropped as it was. asyncio then logged the unretrieved exception, which can carry the tool result, with its traceback when the future was collected. That went past the plugin's constant warning, a content_formatter and the column denylist. A returned awaitable is now released without running its code. A coroutine is closed as before. An asyncio future or task that is still pending is cancelled, and the exception it holds, or ends with, is marked as retrieved. The built-in rule still records the call, and the warning stays constant. An async generator or any other awaitable runs nothing until it is iterated or awaited, so it is dropped as before. Also from review: - read the classifier's signature without evaluating its annotations, so Python 3.14 no longer rejects a classifier annotated with names imported only for type checking; - describe the error-flag rule as it works: any dict result whose top-level isError or is_error is True, not only an MCP dump. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cancelling a pending task that a tool_result_classifier returned stops it before it runs only if it has not started yet. A task that is already running gets the cancellation delivered at its next await, and its own handlers then run on the loop. The docstring said the release ran none of the awaitable's code; it now says the awaitable is released without being awaited. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge google#6752 Fixes google#6751 PiperOrigin-RevId: 990390762
caohy1988
left a comment
There was a problem hiding this comment.
Blind Astra review of head 86d62bf0367ac68fe18570757dce46bce63971ad (round 5). I read the PR description, the complete specified base-to-head diff, the two source issues, and repository code; I did not read peer reviews or earlier-round artifacts.
Rejection criteria, written before reading the diff
- A supported tool outcome is lost, duplicated, or misclassified, including MCP errors, retry responses, and parallel calls.
- Ordinary results or classifier awaitables leak payload text through logging/warnings or bypass the redaction boundary.
- A claimed final-round fix is false or lacks meaningful test evidence.
What I tried
- Required plugin suite, Python 3.11.13: 661 passed, 1 skipped. The skipped case requires Python 3.14.
- Adjacent reflect/retry, MCP, and workflow ToolNode suites: 183 passed.
- Independent probes: 101 passed, 3 failed; all three failures reproduce the single P1 below. Probes are under
/tmp/adk-pr15-dual/r5/astra-probes/. - Awaitable lifecycle: failed/pending futures; already-failed, newly-created, already-started, and cancellation-swallowing tasks; gather, shield, wrap_future, run_in_executor; coroutines, async generators, and custom awaitables. Tested successful and error-bearing tool results, yielded to the loop, collected garbage, and checked rows, captured logs, warnings, stderr, and asyncio ERROR records. These passed. A task created inside the classifier was cancelled before its body ran.
- Hostile sibling fields across dicts, dict subclasses, Pydantic dumps, direct MCP models, and MCP subclasses, including numpy/pandas values and raising attribute/comparison/truth hooks. Valid error signals survived, non-boolean flags remained successes, malformed/raising classifiers fell back, and hostile results explicitly classified OK retained a sentinel row.
- Real-runner probes with two distinctly named analytics instances and eight parallel asynchronous failures, with retry before/between/after them; both retry guidance and final responses; message-less exceptions. Each instance recorded one error per call. Also tested 24 interleaved callback calls, AgentTool nesting with inherited plugins, and unary live tools with/without the thread pool. Repository tests additionally cover workflow nodes, unreadable exception messages answered at agent level, provenance, span pairing, and classifier validation.
- Formatter, formatter-failure, payload-denylist, and event-allowlist behavior on the new TOOL_ERROR rows. Result omission and the fixed MCP error message preserved the intended row privacy.
- Independently extracted the unchanged validator functions from this head and ran them on available CPython 3.14.0a6: TYPE_CHECKING-only annotations were accepted without evaluation; the old signature read raised NameError; incompatible and partial-async classifiers were rejected. This is a validator test, not a full plugin run on stable 3.14.
- Pinned Pyink 25.12.0 and isort 8.0.1 checks passed for both PR files. The fresh uv environment omitted the three permitted unsupported packages; Cryptography 50.0.1 failed its native build, so testing used the compatible 46.0.5 wheel. Pydantic/MCP were the locked 2.13.5/2.2.0 versions.
- A separate source copy with the plugin reverted to the specified base reproduced MCP misclassification; head recorded TOOL_ERROR. No tracked worktree file was changed.
streaming_utils.pyhas no base-to-head diff, so the reported CI Mypy issue is outside this PR. The four other changed files in the full diff come from the upstream merge.
I checked the callback short-circuit, streaming-chunk, resumed long-running-tool, and nested span-stack paths. Their documented limits are acceptable for this scope. The deferred GoogleTool/McpTool/FunctionTool envelopes and OTel-hook classifier need separate semantics; the existing classifier provides an extension point. Deferring keep-result is also reasonable given formatter compatibility. I did not rerun the entire reported 60-mutation campaign or perform a live BigQuery write.
P0
None.
P1 — category (1): ordinary Pydantic serializer warnings still expose tool payload before redaction
Location: src/google/adk/plugins/bigquery_agent_analytics_plugin.py:8758, calling the existing Pydantic dumping path at line 2142.
The new serialization boundary catches exceptions, but Pydantic can successfully return a dump while emitting a UserWarning containing the original field value. That warning escapes before _log_event applies content_formatter. No hostile class, overridden method, or custom awaitable is needed. For example, return this ordinary mutable SDK model from a FunctionTool:
result = CallToolResult(content=[], isError=False)
result.content.append({"type": "text", "text": "ASTRA_R5_PRIVATE_71fd9"})
return resultThe list mutation is not revalidated. model_dump_fn() emits PydanticSerializationUnexpectedValue(... input_value={'type': 'text', 'text': 'ASTRA_R5_PRIVATE_71fd9'}, input_type=dict) and still returns normally. A normal application BaseModel with a subsequently assigned mismatching field value reproduces the same leak.
Evidence: test_plain_model_serialization_warning_privacy[mcp] and [plain_pydantic] both fail even though a formatter replaces the entire row content and the row contains no private marker. test_warning_leak_in_actual_runner fails through an actual tool invocation. The standalone warning_stderr.py confirms payload_in_default_stderr: True and payload_in_py.warnings: True when standard logging.captureWarnings(True) is enabled. Outcome rows remain present.
Classification: category (1), a reproducible payload leak using ordinary SDK/Pydantic classes and built-in containers. I also reproduced it on base main: this is a pre-existing serializer leak left outside the new boundary, not a newly introduced regression. It nevertheless violates the requested final-round warning/privacy guarantee; it is not one of the deferred hostile-class cases. Rejection criterion 2 is met.
Suggested fix: suppress payload-bearing diagnostics at the recognized Pydantic serialization call (for example, native model_dump(warnings=False)), while preserving sanitized output or the sentinel and using only a constant diagnostic if needed. Add a regression using ordinary mutable MCP/Pydantic models that checks Python warnings/default stderr and py.warnings, alongside the row assertion. Do not rely solely on catching serialization exceptions or inspecting the plugin logger.
P2
No additional findings retained.
VERDICT: REQUEST_CHANGES @ 86d62bf
When a resumed model turn had parallel calls and only some had a response, the resume decision treated one answer as covering the whole turn, so it continued to the model and the call that never ran was dropped. The decision now replays just the calls with no response, and only when the agent has written nothing since the call, so answered calls do not run a second time. Close google#7108 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 990408003
The new-file check read added files and commit messages from several version control systems. Contributors and CI use git through pre-commit, and jj's default colocated repositories already take the git path, so the rest only added code to maintain. Outside a git work tree the check now reports that it could not determine the added files. A `//`-prefixed .py path with no file behind it, the form Perforce depot paths take, is now refused. Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 990408500
…t validation errors
NodeTool swallowed every exception raised while running its node and
returned a plain string, which the flow wrapped as {'result': '<string>'}.
As a result:
- on_tool_error callbacks (agent and plugin) never saw node failures, unlike
every other BaseTool, and the original exception was lost behind the
generic "Dynamic node <name> failed" wrapper.
- Input validation errors reached the model as {'result': ...} instead of the
{'error': ...} shape FunctionTool uses for argument validation errors.
This change aligns NodeTool with FunctionTool:
- Input schema validation errors return {'error': ...} with the same wording
as FunctionTool, so the model can correct its arguments and retry.
- When the node fails, the node's original exception is re-raised (chained
from DynamicNodeFailError). The tool pipeline then runs on_tool_error
callbacks with the real cause; if none handles it, the error propagates,
as it does for FunctionTool. Node-level retry_config still applies first,
inside run_node.
Behavior change: an agent using a node as a tool without an on_tool_error
callback now fails the run when the node fails, instead of passing an error
string to the model. This matches FunctionTool.
Co-authored-by: Shangjie Chen <deanchen@google.com>
PiperOrigin-RevId: 990410996
ADK always brought a session up with `initialize()`, which pins the connection to the 2025 wire for its whole life even on SDK 2.x: the server issues an `Mcp-Session-Id` to route later requests back to one instance, and `clientInfo` is stated once rather than on each request. Setting `ADK_ENABLE_MCP_MODERN_PROTOCOL=1` probes `server/discover` first and falls back to the handshake on anything that is not a modern server, so a 2025-era server is unaffected. Off by default, and a no-op on SDK 1.x. Co-authored-by: Kathy Wu <wukathy@google.com> PiperOrigin-RevId: 990414661
caohy1988
left a comment
There was a problem hiding this comment.
Fable blind review — PR #15, round 5 (final) @ 86d62bf
Blind review by Claude Fable on caohy1988's behalf. Sources: the PR description, the diff, issues google#7112 and GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485, and the repository code. I did not read any other review, review comment or PR comment, and I changed nothing on the branch.
Rejection criteria (written before reading the diff)
- R1: lost or duplicated rows. Fail if a reachable path, needing no hostile classes, leaves a tool call with zero outcome rows or with two (TOOL_ERROR plus TOOL_COMPLETED, or two TOOL_ERRORs). The paths to cover: either order with
ReflectAndRetryToolPlugin, parallel calls, two analytics instances, an agent-level error callback, and a raising or odd classifier. An outcome row that pops a span it doesn't own also fails. - R2: leaked payload. Fail if result, argument or exception text reaches a BigQuery row outside the
content_formatter/redaction contract, or reaches logs (warnings, tracebacks, asyncio "never retrieved" records,sys.unraisablehook). - R3: wrong classification, or a false or untested claim. Fail if a well-formed error signal (MCP
isError=True, a reflect-and-retry answer) is recorded as TOOL_COMPLETED, if a plain success is recorded as TOOL_ERROR, if a PR-body or docstring claim is false, or if a fix has no test that fails without it.
Outcome: none of R1–R3 is met at 86d62bf. There are no P0 or P1 findings. Below are three non-blocking P2 notes, each classified, and two NOT VERIFIED items (Python 3.14 final; real BigQuery and MCP servers).
What I ran (this session, on this head)
Environment: this worktree on Python 3.11.13 (x86_64, uv venv, pinned tools); dependency-only venvs for 3.10.16 and 3.13.7; CPython 3.14.0a6 for stdlib-only checks.
| Check | Result |
|---|---|
pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8 |
661 passed, 1 skipped (the 3.14-only test) |
tests/unittests/plugins + tests/unittests/workflow/test_tool_node.py |
973 passed, 1 skipped, as the body says |
the same plus tests/unittests/tools/mcp_tool/test_mcp_tool.py |
1082 passed, 1 skipped |
-k "TestToolResultErrorClassification or TestToolProvenance" on 3.10.16 and 3.13.7 |
121 passed, 1 skipped on each |
pyink 25.12.0 --check, isort 8.0.1 --check-only, ruff 0.15.17 on both files |
clean |
mypy src/google/adk/plugins/bigquery_agent_analytics_plugin.py |
no issues |
my 94 out-of-repo probes (described below) on 3.10, 3.11 and 3.13; on 3.11 both with pytest's log capture and with -p no:logging |
94 passed on each |
my 22 end-to-end probes against base def458b6 |
18 fail; the 4 that pass are intended "unchanged" guards |
the head's classifier-return tests against the round-4 plugin (d5b8ecbb) |
5 failed, 4 passed, 1 skipped. The failures are exactly the round-5 cases: failed future, failing task, task failing once cancelled, failing gather, pending future cancelled. |
my classifier-return probes against d5b8ecbb |
16 fail (8 kinds × 2: failed future, unstarted tasks, shield, both gathers, run_in_executor, wrap_future), 14 pass |
the PR's own mutate.py, mutations 0–8 (round 5; 6–8 run with PY314 set to 3.14.0a6) |
9 of 9 caught; the worktree stayed untouched |
_signature_without_annotations + _validate_tool_result_classifier, AST-extracted and run on 3.14.0a6 |
Accepts TYPE_CHECKING-only annotations, including through functools.partial and a callable instance, and evaluates none of them. Rejects a missing keyword, async, positional-only, max and a raising __signature__ with ValueError. The pre-change inspect.signature wrongly rejects the 3 valid classifiers with NameError. |
| CI | Mypy (3.11 job): 858 errors on main and 858 on the branch. The one "new" error is in src/google/adk/utils/streaming_utils.py, which has no diff against def458b6 or against fork main e4c0d946, so it's out of scope. Unit Tests: all 5 jobs were cancelled at the 10-minute limit; the two I counted show one F in the progress dots. Fork main's run 36517507151 shows the same pattern (5 jobs cancelled, one F each), so CI gives no signal either way. |
| scope | Since d5b8ecbb, only 01ff6041 (the fix) and 86d62bf0 (a 4-line docstring change) touch the two PR files. The merge aa456a78 touches neither. |
What I tried to break
The probes live in /tmp, not in the repo.
End to end. A real Runner with a scripted model; I read the rows the plugin actually writes to the mocked write client.
- Orders and parallel calls. A raising tool × {analytics first, retry first} × {1, 3 parallel calls}. Each call gets exactly one TOOL_ERROR with
error_message == str(error). Every outcome row carries the span its TOOL_STARTING opened (samespan_idandparent_span_id). The model still receives the retry plugin's answer. - Stale markers. One batch holding an unknown tool name, a raising call and a successful call, followed by more turns. Successes are always TOOL_COMPLETED. With analytics first, the lookup error gets its TOOL_ERROR; with retry first it gets none, which is the documented limit.
- Two analytics instances in the orders
bq1,retry,bq2,bq1,bq2,retryandretry,bq1,bq2, with 2 parallel raising calls. Each instance writes exactly 2 TOOL_STARTING and 2 TOOL_ERROR rows. - AgentTool nesting with shared plugins, in both orders. The inner raising call gets one TOOL_ERROR and the outer AgentTool call one TOOL_COMPLETED.
- Workflow ToolNode with
RetryConfig(max_attempts=2): attempt 1 raises and nothing answers it; attempt 2 succeeds. Rows: STARTING, ERROR, STARTING, COMPLETED, with spans paired. - Agent-level
on_tool_error_callbackanswering, with analytics as the only plugin, over 2 calls: one TOOL_ERROR each. - A real
MCPTool(mocked session) whose server returnsisError=True. The row is TOOL_ERROR with the fixed message andtool_originMCP, and carries no server text. The model receives the dump withisErrorand the server text. WithisError=Falsethe row stays TOOL_COMPLETED and carries the text. - A sync tool on
RunConfig.tool_thread_pool_config(upstreamc50ade39), in both orders. The tool runs on worker threads, and each call gets one TOOL_ERROR. - Where the omitted MCP error text still lands. With
log_multi_modal_contenton, it appears in exactly one place across all rows and columns:LLM_REQUEST.content_parts. With it off, it appears nowhere. This confirms theafter_tool_callbackdocstring and the body's Privacy section. - A plugin ahead of analytics that answers
before_tool_callback. Head and base behave identically apart from the new classification. Neither writes a TOOL_STARTING, and the outcome row borrows the agent's span. That span handling predates this PR and is unchanged, so I don't count it as a finding. A possible follow-up outside google#7112:pop_span(expected_kind="tool")in the tool callbacks.
Unit level. Every case checks the rows and the root and plugin logger output (formatted with tracebacks), plus stderr, sys.unraisablehook and warnings, for a per-case payload marker. Checks run after gc.collect() and several loop turns.
-
Classifier return values, each tried with a plain result and with an
isErrorresult: failed Future; unstarted failing Task (its body never ran); started Task that raises on cancel; Task that swallows the cancel and raises later;gatherof a started task;shieldof a task that fails on its own;gatherof a failed future;gatherof unstarted coroutines (they never ran);run_in_executorthat raises;wrap_futureof a failed concurrent future; a failedconcurrent.futures.Future(not awaitable); a coroutine whose__qualname__is the payload; an async generator. Every case gives the right row and a constant warning. No payload appears anywhere, and asyncio logs no ERROR record. -
Hostile siblings next to a valid signal, each tried with no classifier, a raising classifier and an
OKclassifier. The shapes:- a dict whose values raise from
__eq__,__bool__or__str__; - a value whose
__class__raises; str-subclass values with raising hooks;- a key that hash-collides with a wanted key and raises when compared;
- a dict subclass whose every method and
__getattribute__raise; - a
CallToolResultsubclass whose__getattribute__raises; model_constructwith hostile content;- a hostile extra field;
- a retry answer with hostile
error_details.
The valid signal always wins, and the retry answer wins even over
OK. OtherwiseOKgives TOOL_COMPLETED. No payload reaches the logs or any TOOL_ERROR row. - a dict whose values raise from
-
Near misses stay TOOL_COMPLETED with no boundary warning:
isErrorset to False,"true"or 1;is_errorset to"True"; numpyTrue_;{"error": …};{"status": "ERROR", "error_details": …}; anotherresponse_type; a nested flag; a list; a str; None. Separately, a non-MCP pydantic model withisError=True, run end to end, stays TOOL_COMPLETED as documented. -
Unserializable results (raising
__getattribute__, raising__class__, hostile dict subclass) keep their TOOL_COMPLETED row with[UNPARSEABLE_JSON_BLOB]and a constant warning, and leak no payload.
The code the marker relies on.
- Every call, single or parallel, async or live, runs in its own task, created from a copy of its prepare-phase context (
flows/llm_flows/tools/_batch_executor.py:171-186). - ToolNode runs both callbacks in one coroutine (
workflow/_tool_node.py). - Each workflow retry gets a fresh child context (
workflow/_node_runner.py:128). - The marker check compares a weak reference's target by identity.
I found no path where one call's mark can skip another call's row.
Findings
P0: none. P1: none.
P2 (non-blocking)
-
A docstring overstates what releasing an awaitable guarantees.
src/google/adk/plugins/bigquery_agent_analytics_plugin.py:8825-8828says that after_release_awaitable, "neither Python nor asyncio later logs anything about it".- A started task that swallows the cancellation and keeps running is still reported by asyncio ("Task was destroyed but it is pending!"); the PR body lists that case itself.
- A variant for the body's hostile-class list: a custom awaitable that wraps a failed future (its
__await__returnsfut.__await__()). It's left alone, as designed, and asyncio then logs "Future exception was never retrieved", with the payload, when it's collected. My probecustom_awaitable_holding_failed_futureshows the row is correct and confirms the leak.
Classification: (2). It needs a classifier that builds such objects, and code can't fix it without running the wrapper's own code. Fix: reword to "so that asyncio does not later log an exception the returned future or task holds", and add the wrapper case to the body's hostile-class list.
-
The release note is narrower than the rule. The suggested note says "MCP results with
isErrorset". But_mcp_error_message(plugin.py:873) applies to any tool's dict whose top-levelisError/is_erroris exactlyTrue, and the docstrings now say so. A function tool that returns, say,{"is_error": True, "line": …}as data moves from TOOL_COMPLETED (with the result) to TOOL_ERROR (without it). Classification: not (1). The docstrings document it and anOKverdict overrides it; the release note is incomplete, not false. Fix: say "any tool's dict result whose top-levelisErrororis_erroris exactlyTrue, such as an MCPCallToolResult" in the release note. Optionally, accept the snake_case spelling only from MCP models or MCP-origin tools, since ADK'sMcpToolalways restores camelCase (tools/mcp_tool/mcp_tool.py:125-150). -
The release note should say where the exception text moves. With
ReflectAndRetryToolPluginahead of analytics,str(error)used to land inTOOL_COMPLETED.content.result.error_details, whichcontent_formatterandpayload_column_denylistgovern. It now lands inTOOL_ERROR.error_message, which only_sanitize_sensitive_textgoverns. TOOL_ERROR already records that same text for the same exception in the other order, so the contract holds, and the body's Privacy section covers it. But a user who relied on a formatter or acontentdenylist to scrub exception text in that order would only learn of the change from the release note. Classification: not (1), since nothing leaks beyond what TOOL_ERROR already records. Fix: add one release-note bullet.
Judgment on the flagged design points and deferrals
- Result left out of TOOL_ERROR: agree. The probe above shows where the text still lands.
- Reflect
error_messagetaken fromerror_details: agree. It isstr(error), witherror_type(the type name) as the fallback (plugins/reflect_retry_tool_plugin.py:320-325, 358-363), so both plugin orders record the same text, message-less errors included. - Keyword-only, four-argument classifier signature: fine.
- Toolbox and Skill detection via
sys.modulesand class names: fine. Both tests use the real classes, and the Skill test enumerates the module, so a new skill tool would fail it. - Per-call marker: sound, per the flow analysis above.
- Allowlist and dashboard change: documented.
- Not reusing
_detect_error_in_response: acceptable, and the stated reason is accurate. TheFunctionTool,BashTool,RestApiTooland SkillToolset hooks flag any truthyerrorkey (tools/function_tool.py:439,tools/bash_tool.py:293,tools/openapi_tool/openapi_spec_parser/rest_api_tool.py:742,tools/skill_toolset.py:723). - Deferred items: none is a blocker; none loses a row or leaks. I'd take the McpTool graceful-error envelope first.
_MCP_GRACEFUL_ERROR_HANDLINGis on by default (features/_feature_registry.py:191-192), so an MCP transport failure is still recorded as TOOL_COMPLETED/OK by default. A rule scoped toMcpTooland its two fixed message prefixes would sidestep the concern about{"error": …}being data.
NOT VERIFIED
- Python 3.14 final. The 3.14-only test is skipped on 3.10–3.13, CI's 3.14 unit job was cancelled before reaching a result, and this machine only has 3.14.0a6, where I checked the validator by extraction (above). I suggest running
test_classifier_annotated_with_names_it_imports_for_type_checkingonce on 3.14 final before the upstream PR. - Real BigQuery writes and a live MCP server. Every row came from the mocked write client, and the MCP session was mocked.
- Issue google#485 item A (
[FORMATTER_FAILED]with a nullerror_message): the PR defers it and the done-when contract doesn't include it, so I didn't review it as a gap.
VERDICT: APPROVE @ 86d62bf
…response Propagate `ctx.actions.skip_summarization` from dynamically executed child nodes to the parent context in `run_node_internal`, and include `NodeTool` alongside `AgentTool` when attaching displayable tool output to `function_response` events with `skip_summarization=True`. This allows `@node` and `Workflow` tools to set `ctx.actions.skip_summarization = True` inside the node so their terminal output is emitted directly as the final user-visible text response without triggering a follow-up LLM summarization turn, while keeping `NodeTool` internal. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 990434765
Pydantic still dumps a model whose field no longer matches its type, such as an MCP CallToolResult whose content list was appended to after validation, or an application model given a value of the wrong type. But by default it first emits a UserWarning that quotes the value. The plugin's dump of a tool result for the TOOL_COMPLETED row, and of the function response for the next LLM_REQUEST row, therefore printed the result to stderr or the py.warnings logger. That happened before content_formatter, the column denylist or log_multi_modal_content could apply. Both dumps now pass warnings=False, a per-call flag that changes no process-wide warning filter. A model_dump override that does not take the keyword is still called, without it, because it may be what leaves out fields that must not be reported. Also from review: - say that asyncio still reports a task that ignores its cancellation, and that an awaitable of another kind is left as it is, along with any future it wraps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
caohy1988
left a comment
There was a problem hiding this comment.
Independent blind review of head 8b05d3810f8493d63a86621f09ae9b9d45b339ac, against def458b609c2811d137b0332b2fc7b201dddd5c0. I read the permitted PR description, issues, diff and repository code; no other review or earlier-round artifact was read.
Rejection criteria (written before reading the diff)
- Normal tool-result serialization or classifier awaitable cleanup leaks payload text into stderr or logging.
- Supported MCP/reflect error results or raising tools lose or duplicate outcome rows, including either plugin order and separate analytics instances.
- A claimed safety fix fails executable validation. Hostile-class-only edge cases are P2 under the final-round cap.
What I tried
- Required plugin suite: 668 passed, 1 skipped (Python 3.14-only test), using Python 3.11.13 and
PYTHONPATH=src. Pinned pyink 25.12.0 and isort 8.0.1 checks pass on both files; tracked worktree remains clean. - Environment:
uv sync --all-extrascould not install google-antigravity on macOS x86_64. Excluded the three permitted packages; locked cryptography 50.0.1 also failed to build, so used the available cryptography 48.0.1 wheel. Pydantic is 2.13.5. This is a validation limitation, not a PR finding. - Independent probes, with repository fixtures and row-capture helpers but independent inputs/assertions: 44 passed and 2 failed; four additional full-turn reproductions fail on the same leak below. Successful probes cover mutable MCP and application models, nested models, both default-style stderr and
logging.captureWarnings(True), multimodal logging on/off, a safe no-keyword dump override, and unrelated warnings remaining visible. - Field isolation: valid MCP and retry signals survive raising sibling values in plain dicts, dict subclasses, MCP models and hostile MCP subclasses; non-boolean flags remain successes and retain their rows.
- Awaitable cleanup after loop turns and
gc.collect(): failed Future, completed failing Task, newly created Task, cancellation-swallowing Task that then raises, gather, shield, wrap_future, run_in_executor, async generator and an inert custom awaitable. Outcome rows survive; no payload canary or asyncio ERROR record appears; the new task does not start. - Real runners: four parallel async message-less failures in each plugin order; two analytics instances (distinct plugin names) each record the raised error once; AgentTool nesting retains the inner error and outer completion. The required suite also exercises workflow ToolNode, hostile result serialization, malformed classifiers, unreadable exception strings, agent error handlers, formatter/redaction behavior, and Toolbox/Skill origins.
- Extracted the actual signature-validation functions and ran them on the available CPython 3.14.0a6: undefined annotation names raise with default
inspect.signature, but pass this implementation, including a partial. Full plugin execution on 3.14 was not run. - Separate baseline source copy: the MCP classification control records TOOL_COMPLETED on base and TOOL_ERROR on head. A direct standalone subprocess using Python's untouched default warning display reproduces the remaining override leak on head and base. Simply reverting the plugin under all new tests fails collection because the new API is absent; I do not count that as a regression-test result.
- Inspected live/streaming, long-running resume, short-circuit callback, nested AgentTool and workflow call paths. Live chunks and resumed long-running outcomes bypass this classifier as documented; no real provider/live-service run was performed. The documented callback-order limits agree with dispatch code. Not mirroring OTel error hooks, deferring GoogleTool envelopes, and omitting results from TOOL_ERROR are acceptable scope/privacy choices here; event_allowlist/dashboard changes and the retry-first exception-text relocation are disclosed.
git diff def458b6 HEAD -- src/google/adk/utils/streaming_utils.pyis empty. The merge after 8688539 changes neither PR file. The reported unrelated CI typing failure is outside this review; I did not infer a green full CI run.
P0
None.
P1 — category (1): the compatibility fallback still leaks ordinary Pydantic tool results
Location: src/google/adk/plugins/bigquery_agent_analytics_plugin.py:1891-1894, invoked for completion payloads at line 2161.
The TypeError fallback calls a legacy model_dump() override without warnings=False. A normal application override that delegates to Pydantic to exclude a field therefore reinstates the payload-quoting warning:
class Result(BaseModel):
number: int = 1
excluded: str = "omit this field"
def model_dump(self):
return super().model_dump(exclude={"excluded"})
result = Result()
result.number = "PRIVATE_PAYLOAD"
# Return result (or {"nested": result}) from an ordinary FunctionTool.With content_formatter=lambda content, event: "scrubbed" and log_multi_modal_content=False, the plugin still emits PydanticSerializationUnexpectedValue(... input_value='PRIVATE_PAYLOAD' ...) before the formatter. I reproduced it through the actual TOOL_COMPLETED row capture and a full scripted runner turn, for both a top-level and nested result, to stderr and py.warnings. All affected turns still record one TOOL_COMPLETED and two LLM_REQUEST rows: this is a privacy leak, not a lost-row finding.
Evidence: /tmp/adk-pr15-dual/r6/astra-probes/test_independent.py (test_plain_override_leak_probe, test_override_leak_real_turn), independent.log, leak-real-turn.log, and default-head.stderr. The standalone default_warnings.py subprocess uses Python's actual default warning display, without pytest's warning recorder.
Classification: category (1), a real payload leak in the plugin's normal tool-result serialization path. No raising hooks, adversarial objects, custom warnings or asyncio misuse are involved. The PR body explicitly acknowledges this fallback as deferred, and it also exists on base; the final-round contract expressly permits retaining pre-existing normal tool-result payload leaks. Documentation does not close this privacy boundary.
Suggested fix: avoid the unchecked no-keyword retry for Pydantic overrides. Require an override to accept and forward warnings=False; otherwise retain the outcome row with a safe sentinel and constant diagnostic (or provide an explicit safe serialization adapter). Preserve field exclusions rather than bypassing the override, and add this delegating-override regression in both warning displays. A process-wide warning filter is unnecessary for the sentinel approach.
P2 (category 2)
No additional findings retained. Documented hostile-class-only and asynchronous cleanup limitations are not escalated.
The first rejection criterion is met by the P1 above. This is a COMMENT review; the verdict does not merge or modify the branch.
VERDICT: REQUEST_CHANGES @ 8b05d38
Invocations now keep the final event with its content removed so the efficiency metrics can read its token usage, and the Vertex multi-turn facade sent that event as an empty agent message in every turn. Skips intermediate events without content when mapping a turn. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 990461145
MCP SDK 2.x moved the transport off the client session and behind a dispatcher, so ADK's liveness check read attributes that no longer exist and a pooled session whose server had died looked healthy forever, failing every later call. A session with no streams of its own is now checked through its dispatcher's closed flag, which restores the existing reconnect path on both SDK majors. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 990472674
caohy1988
left a comment
There was a problem hiding this comment.
Fable (Claude) blind review — round 6 — PR #15 @ 8b05d38
Blind: I read no review, review comment or PR #15 comment before posting this. Disclosure: my local auto-memory was loaded at session start. It holds technique notes from earlier rounds of this PR, some written during the implementer's google#7112 sessions. No peer review text was read.
Rejection criteria (written before reading the diff)
- R1: lost or duplicate rows. Any non-hostile path where one tool call gets zero outcome rows (TOOL_COMPLETED / TOOL_ERROR), or more than one. Cases:
- either order with
ReflectAndRetryToolPlugin; - raised errors, including message-less ones;
- agent-level
on_tool_error_callbackanswers; - two analytics instances;
- parallel calls;
- workflow and AgentTool nesting.
- either order with
- R2: payload leak. Any non-hostile path where tool result, argument or error payload reaches stderr,
py.warningsor the logger. Or reaches a row beyond the existing TOOL_ERROR / TOOL_COMPLETED contract, or bypassescontent_formatteror redaction for the new rows. For round 6 this includes:- a payload-quoting Pydantic warning still emitted by the plugin's tool-result serialization: the TOOL_COMPLETED row, the next LLM_REQUEST function_response, nested models,
log_multi_modal_contentoff; - the fix hiding unrelated warnings process-wide.
- a payload-quoting Pydantic warning still emitted by the plugin's tool-result serialization: the TOOL_COMPLETED row, the next LLM_REQUEST function_response, nested models,
- R3: misclassification, a false claim, or an untested fix.
- A valid MCP
isError=True(dict orCallToolResult) or a retry answer that isn't TOOL_ERROR / ERROR. - A success that isn't TOOL_COMPLETED / OK.
- A wrong Toolbox or Skill origin, or a changed origin for other tools.
- A false claim in the PR body, a docstring or the release notes.
- A fix whose tests pass on unfixed code.
- A valid MCP
Result: R3 is met. There is one false PR-body claim, and two new tests fail under the repo's own CI command (P1-1). R1 and R2 are not met. The plugin code held up under everything below.
Findings
P1-1 (category 1: false PR-body claim; new tests fail under the repo's CI command): the round-6 turn test isn't hermetic
tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py:7271, test_result_field_that_no_longer_fits_is_not_quoted_in_a_turn[stderr] and [py.warnings], fail at their asserts (:7303 / :7304). They fail whenever an earlier test in the same process has installed a recording OpenTelemetry tracer provider.
Evidence
-
Repro, no xdist:
pytest tests/unittests/cli/test_fast_api.py 'tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py::TestToolResultErrorClassification::test_result_field_that_no_longer_fits_is_not_quoted_in_a_turn'→ 2 failed. A sequential run ofcli/test_fast_api.py,telemetry/test_setup.py,flows/llm_flows/test_llm_callback_span_consistency.pyand the plugin file gives the same 2 failures. -
Full suite:
pytest tests/unittests -n 8on this head gives 16410 passed, 8 failed.- Six are env-only on this Mac: 4 antigravity samples and 2
test_import_loadingallowlist cases. - The other two are these tests.
- Six are env-only on this Mac: 4 antigravity samples and 2
-
CI matches. Every
Unit Tests (3.10–3.14)job on this head shows 3 F in its progress lines. Each baseline shows 1 F:- upstream main 5a0421c (run 36613592212), all 5 jobs;
- fork main e4c0d94, all 5 jobs;
- the previous PR head 86d62bf (3.11 job).
git diff --stat 5a0421cb 8b05d381lists only this PR's two files. The jobs are still cancelled at the 10-minute timeout, which predates this PR. -
The cause is not the plugin. A stack recorder on
warnings._showwarnmsgshows the payload warnings come from ADK core span content capture (ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, default on):telemetry/tracing.py:344:trace_tool_call→_serialization.safe_json_serialize→model_dump();tracing.py:885:_build_llm_request_for_trace→Content.model_dump(...).
A standalone app turn after
trace.set_tracer_provider(TracerProvider())prints the payload-quoting warning to stderr both with and without the analytics plugin.
Claims this falsifies
- The checklist's "[x] All unit tests pass locally" and "[x] New and existing unit tests pass locally".
- Testing Plan: "The same holds across a real turn with
log_multi_modal_contentoff". That holds only for the plugin's own dumps. Any app with a recording tracer (adk web,adk api_server, Cloud Trace) still prints the payload in that turn.
Fix (verified on a /tmp copy of tests/)
- In that test, add
monkeypatch.setenv("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "false"), with a one-line comment that ADK's span content capture dumps the same response with Pydantic warnings on.- The patched copy passes after the three tracer-installing files (823 passed).
- It still fails both parametrizations against the pre-round-6 plugin (86d62bf), so it still pins the fix.
- Alternative: a stack-scoped recorder that counts only warnings raised under
bigquery_agent_analytics_pluginframes. - In the PR body, scope the "real turn" sentence to the plugin's dumps, and add the ADK core sources in P2-1 to the follow-ups.
P2-1 (not category 1 under the rubric: pre-existing and outside the plugin's paths): ADK core still quotes the same payload in a real turn
- Telemetry, as above. By reading,
tracing.py:545does the same for merged tool calls. SqliteSessionServiceatsessions/sqlite_session_service.py:499(event.model_dump_json(exclude_none=True)). A turn with no analytics plugin printed two payload-quoting warnings to stderr.- Other session services, by reading:
sessions/schemas/v0.py,v1.pyandvertex_ai_session_service.pydump events the same way. - The PR body's follow-ups name only google-genai. Suggest listing these too, and filing an upstream issue to pass
warnings=Falseat those sites, asflows/llm_flows/tools/_caller.py:601already does.
P2-2 (category 2, pre-existing on def458b): a result whose own model_dump raises falls back to __dict__
_model_dump_quietly retries only the missing-keyword case. When a present model_dump raises for any other reason, including a TypeError from its body, _recursive_smart_truncate (plugin :2161 branch) falls through to the instance-__dict__ walk. The row then records fields the override would have left out.
- Probe: an override that always raises TypeError recorded
{'s': 'ok'}, identical on def458b. - Nothing reaches the log, and
content_formatterstill applies. - One-line fix: when
model_dump_fnexists and raises, return("[UNSUPPORTED_OBJECT]", True)instead of falling through.
P2-3 (category 2, already documented): a custom awaitable wrapping a failed future
It still leaks through asyncio's "Future exception was never retrieved" at GC. I reproduced it. It matches the PR body's hostile-class list. No action.
Scope note (no severity)
google#485's ADK-side item A is deferred and listed under follow-ups: a content_formatter failure writes [FORMATTER_FAILED] with a null error_message. The done-when list doesn't require it; I'm flagging it only so the deferral is a deliberate call.
What I tried
Everything ran on 8b05d38 unless noted. Probes live in /tmp; the worktree was not modified.
Environment and suites
- Worktree venv: Python 3.11.13, pydantic 2.13.5 / pydantic-core 2.46.5, mcp 2.2.0, google-genai 2.25.0. These are the versions CI's Unit Tests job lists.
- PR test file with
-n 8: 668 passed, 1 skipped. - The two new classes: 128 passed, plus 1 skipped (3.14-only).
- The PR body's
tests/unittests/plugins tests/unittests/workflow/test_tool_node.py -n 8: 980 passed, 1 skipped. pyink --check25.12.0,isort --check-only8.0.1 and ruff are clean. mypy on the plugin reports no issues.
Round 6, run like a real app. A standalone script with default warning filters and stderr redirected to a file, plus a logging.captureWarnings(True) variant. Real Runner turns used a FunctionTool returning:
- a mutated MCP
CallToolResult(content appended after validation); - the same with
isError=True; - an app model with a wrong-typed field;
- that model nested in a dict, a list field or a dataclass.
Each case ran with log_multi_modal_content on and off.
- Head: 0 payload hits in stderr and
py.warnings, and TOOL_COMPLETED rows keep the dumped value. - 86d62bf and def458b both leak via
after_tool_callback→_recursive_smart_truncate, so the fix closes a pre-existing plugin-path leak.
Round 6, in process
- No payload warning for models nested in a dict, list, tuple, dataclass, list field or
Anyfield, aRootModel, or a**kwpass-through override. warnings.filtersis unchanged.- An unrelated
warnings.warninside afield_serializeris still shown. - Overrides that take no keywords or fixed keywords get their own output recorded.
- Five own mutations, all caught by the new tests:
- each dump site made loud;
warnings=Falsedropped;- the TypeError fallback removed;
- the fallback returning None.
The PR's mutate.py, rerun: 64 of 64 caught, with mutations 10–12 checked on CPython 3.14.0a6.
Runner end to end. 34 probes, all passing on head; 19 of them fail on def458b.
- Raising tool with 1 and 3 parallel calls, with and without a message, in both plugin orders: exactly one TOOL_ERROR per call, with the expected
error_message, and its span ids pair with TOOL_STARTING. - A dict
isErrorof True / "true" / 1 / False / None, in 3 orders: onlyTruebecomes TOOL_ERROR, and the model still receives the result unchanged. - A
CallToolResultmodel withisErrorTrue and False. - Two analytics instances through a Runner in [bq, retry, bq], [bq, bq, retry] and [retry, bq, bq]: 2 TOOL_ERROR rows each.
- A classifier returning OK, returning ERROR, or raising: no payload in the log.
content_formatterapplies to the new TOOL_ERROR rows.- AgentTool nesting in both orders.
Round 5. Awaitables a classifier returns, checked after gc.collect() for payload in caplog, stderr and warnings, and for asyncio ERROR records:
- a failed Future;
- an unstarted failing Task;
- a started task that raises on cancel;
- a task that swallows the cancel, then raises;
- a failing
gather; shield,wrap_future,run_in_executor;- an async generator and a coroutine.
All clean; only the documented custom awaitable leaks.
Round 4
- An error whose
__str__raises gets exactly one TOOL_ERROR, carrying its type name. - Classifier validation:
- accepted: a
functools.partial,lambda **kw; - rejected: a nested partial of an object with an async
__call__, a positional-only function,max, a non-callable.
- accepted: a
Round 3, hostile siblings. The valid signal wins, a hostile-only result stays TOOL_COMPLETED, and nothing reaches the log. Cases:
- a raising
__eq__value; - a colliding key;
- a dict subclass whose
__getattribute__raises; - a
CallToolResultsubclass whose__getattribute__raises, with and without an error; - a retry answer next to a raising value.
Python 3.14 (3.14.0a6, validator extracted)
- A classifier annotated with names imported only under TYPE_CHECKING is accepted, with nothing evaluated.
- With
Format.VALUEthe same classifier is rejected with NameError. - 3.10–3.13 keep the unchanged path.
Marker. In _caller.py the error and after-tool callbacks run in the same coroutine; the thread pool runs only the tool body. The same holds in workflow/_tool_node.py. So the ContextVar marker is visible where it's consumed.
Origins. ToolboxTool (toolbox_adk 1.4.0) and all six Skill tools subclass BaseTool directly, so the new checks are reachable after the FunctionTool check.
CI Mypy. The "new" errors are in dependencies/_mcp.py, from upstream e738c26 merged by 8b05d38, and in utils/streaming_utils.py, which has no diff against def458b. Both are out of scope.
Design points (no change requested)
- Leaving the result out of TOOL_ERROR content is fine.
- Taking the retry answer's
error_messagefromerror_detailsis fine. It equalsstr(error), which is whaton_tool_error_callbackrecords. - The keyword-only classifier signature is fine.
- The deferred GoogleTool envelope rule and keep-result opt-in are not blockers; a classifier covers both today.
- Toolbox and Skill detection through
sys.modulesand hardcoded class names is accurate today. It will silently drift if skill_toolset adds a tool class; checking the class's module would be sturdier.
VERDICT: REQUEST_CHANGES @ 8b05d38
The server-wide avatar configuration added to `adk web` and `adk api_server` was attached to every /run_live session, including the default audio-only ones. Avatars are rendered as video, so only set `RunConfig.avatar_config` when the client requests the VIDEO modality, and say so in the `--avatar_config` help text. Also adds a test for the unreadable avatar configuration file path. Co-authored-by: Liang Wu <wuliang@google.com> PiperOrigin-RevId: 990498163
Merge google#6824 PiperOrigin-RevId: 990507320
When a model_dump did not take warnings=False, the plugin called it again without the flag. An ordinary override that calls Pydantic's own with other arguments, such as super().model_dump(exclude=...), then let Pydantic quote a field that no longer fits its type in a UserWarning, before content_formatter ran. That happened whether the model was the result or was nested in it. A Pydantic model whose model_dump is an override is now not called. Whatever its signature, it may not pass the flag on, so it is recorded as the [UNSUPPORTED_OBJECT] sentinel. So is a model_dump that fails or does not take the flag. In those cases the serializer used to read the fields instead, which recorded what the dump leaves out. Pydantic's own model_dump, and an object that only looks like a model, are still called with the flag. An LLM_REQUEST row's function response part is dumped by Pydantic itself, which serializes nested models without calling their overrides. Also from review: the real-turn tests switch ADK's span content capture off. Once any test has installed a recording tracer, that capture dumps the same response with Pydantic's warnings on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
caohy1988
left a comment
There was a problem hiding this comment.
Astra R7 independent blind review
Reviewed head 380335e48c0bc2369d9054d5fe9ace8a29ee1684 against def458b609c2811d137b0332b2fc7b201dddd5c0, focusing on the two PR files and distinguishing upstream merge changes. No earlier-round or peer review material was read. No tracked files were modified.
Rejection criteria (written before reading the diff)
- A supported error result or raised error loses or duplicates its outcome row, or ordinary successful results change classification.
- Plugin serialization or classifier cleanup emits payload text through warnings/logging, or exposes excluded model fields, in a reachable ordinary model/async case.
- A final-head contract/testing claim is false or a substantive fix lacks passing evidence, including real-turn isolation and model override handling.
What I tried
- Read the PR description, the two-file diff, issue google#7112 including its reproduction, and SDK issue google#485. Reviewed callback dispatch, parallel context copying, workflow ToolNode, AgentTool nesting, long-running handling, and live/streaming dispatch. Live and streaming paths were inspected, not exercised against an external service. The documented short-circuit limitations match PluginManager and the caller: analytics cannot record callbacks an earlier answering/re-raising plugin prevents it from seeing.
PYTHONPATH=$PWD/src .venv/bin/python -m pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8 -q: 685 passed, 1 skipped. This exercises both retry orders, parallel raised calls, instance-specific markers, direct MCP models, message-less/unprintable errors, classifier validation/fallbacks, formatter/redaction, origins, and hostile serialization.- Independent probes outside the repository: 77 passed. Used dict/dict-subclass and MCP model/model-subclass results with hostile sibling fields beside valid MCP/retry signals, plus successful controls. Verified exactly one outcome row per call and no payload-bearing boundary logs.
- Probed model overrides top-level and nested: delegating with no keywords, swallowing keywords, returning a private dict, raising overrides, mixins,
functools.wraps, and instance/class assignments. Probed a failing Pydantic serializer with an excluded field. Overrides/failing dumps retain a completed row with the sentinel and no excluded field leakage. RootModel, generic models, MCP CallToolResult/TextContent, genai Content/Part/FunctionResponse, ADK Event, a non-Pydantic look-alike, and MagicMock retain rows; common real models are not incorrectly treated as overrides. - Ran real runner turns for the override variants with
log_multi_modal_content=False, both default stderr warning display andpy.warnings, and span content capture disabled. Each had one TOOL_COMPLETED and two LLM_REQUEST rows, with no payload warning from either plugin serialization path. - Returned failed Future, failed Task, cancellation-swallowing-then-failing Task, gather, shield, wrap_future, run_in_executor future, async generator, and custom awaitable from synchronous classifiers. Yielded and called
gc.collect()repeatedly, checking rows, captured logging/stderr and the loop exception handler: no leaked payload or asyncio error. The repository tests additionally verify cancellation before an unstarted task runs, pending-future cancellation, and native/generator coroutine closing. - Installed an actual recording SDK OpenTelemetry tracer first, then ran a FastAPI telemetry test and the entire plugin file in the same process: 687 passed, 1 skipped. The new turn tests remain hermetic. The initially attempted full FastAPI-file ordering run was interrupted after 50 passing tests when it stalled; I do not count it as passing.
- Entire plugins directory plus workflow ToolNode tests,
-n 8: 997 passed, 1 skipped. - Python 3.14.0a6: extracted the two validator functions from this head and verified an unresolved type-only annotation fails ordinary
inspect.signaturebut passes this validator. This is validator-only evidence, not a full 3.14 plugin run. - Negative control in an isolated source copy using pre-R7 plugin
86885394: the 23 focused warning/override/failing-dump tests gave 17 failed, 6 passed, including the real-turn override tests. Trying the base plugin with the head test file cannot collect because the new classification API is absent; tracked source was never reverted. - Pinned pyink 25.12.0 and isort 8.0.1 checks passed on both files;
git diff --checkpassed. The final merge changes neither PR file.git diff BASE HEAD -- src/google/adk/utils/streaming_utils.pyis empty.
Findings
- P0/P1 — category (1): none. No ordinary reachable leak/data-loss regression, false final-head claim, or untested substantive fix found in the reviewed scope.
- P2 — category (2): none retained. The disclosed hostile-class/custom-awaitable limitations and pre-existing core/session/OTel warning paths are not new findings in this review.
Design judgment and verification limits
The keyword-call classifier contract is practical; invalid signatures/async callables are rejected and unusable returns fall back safely. Strict literal-True flags and response_type matching avoid treating ordinary error data as failure. Not aligning all built-ins with OTel error hooks, and deferring GoogleTool/graceful-MCP/missing-argument envelopes and keep-result, are acceptable scoped choices with the application classifier available.
Replacing overriding Pydantic model payloads with a sentinel is an acceptable disclosed privacy trade-off: row/classification and the model's actual result survive, and excluded fields are not recovered through __dict__. The TOOL_ERROR result omission and error_details-to-error_message handling follow the existing raised-error row contract. Dashboard/event_allowlist migration and plugin-order limits are explicitly documented.
The relevant reported test totals were reproduced. I did not independently run all 16k unit tests, validate every reported environment-only failure, or reproduce hosted CI timeouts/Mypy. Full extras sync hit the disclosed google-antigravity macOS x86_64 wheel limitation; the usable Python 3.11 worktree environment ran the checks above. No live BigQuery/external MCP service was used.
VERDICT: APPROVE @ 380335e
caohy1988
left a comment
There was a problem hiding this comment.
Fable (Claude) blind review: PR #15 round 7 (final) @ 380335e
Reviewed blind. I read only the PR description, the diff and files at 380335e48c0bc2369d9054d5fe9ace8a29ee1684 (base def458b609c2811d137b0332b2fc7b201dddd5c0), and repo code. I read no review or comment on this PR.
Disclosure: my local Claude Code auto-memory was loaded into this session. It is a notes directory shared by Claude Code sessions on this machine, and it holds probing and env techniques. Some of those notes date from earlier rounds of this PR, including a one-paragraph note of the implementer's round-7 rule (only call model_dump when __func__ is BaseModel.model_dump). No peer review text was in it.
Rejection criteria (written before reading the diff)
-
R1, row integrity. Every tool call gets exactly one outcome row (
TOOL_COMPLETEDorTOOL_ERROR). This covers success, raised errors, MCPisError, ReflectAndRetry answers, both plugin orders, parallel calls, two analytics instances, and any non-hostile result type: real Pydantic models (including ones that overridemodel_dump), MCP/genai/ADK types, MagicMock, RootModel and generics. A dropped or duplicated row reachable without adversarial classes means REQUEST_CHANGES. -
R2, no payload leak through the plugin's own paths. No result or field text may reach default stderr,
py.warningsor plugin logs through the plugin's serialization: theTOOL_COMPLETEDrow, or the nextLLM_REQUESTfunction_responsepart. This must hold in a real runner turn, withlog_multi_modal_contenton or off, without hostile classes. A leak means REQUEST_CHANGES. -
R3, claims hold and are tested. Each round-7 claim must be true and pinned by a test that fails when the fix is reverted:
- an override is never called and becomes the sentinel;
- BaseModel and RootModel dumps use
warnings=False; - a raising dump becomes the sentinel with no
__dict__fallback; - the real-turn tests are hermetic and fail on pre-fix code;
- common types are not misclassified as overrides.
A false claim, a common type regressed to the sentinel, or an untested fix means REQUEST_CHANGES.
Outcome: no criterion is met. The one judgement call, on R3's "common types", is spelled out under P2-1.
What I ran
Environment: Python 3.11.13, pydantic 2.13.5, mcp 2.2.0, google-genai 2.25.0. Probes live in /tmp and not in the repo. The worktree has no tracked changes.
Suites and hygiene
-
Plugin file:
pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8gives 685 passed, 1 skipped. The same holds on Python 3.10.16, using a dependency-only venv. -
Full suite:
pytest tests/unittests -n 8 --timeout=300, run in four parts, gives 16,443 passed. The only failures are environment-only:- 4
labs/antigravitycollection errors and 4 antigravity samples (no x86_64 wheel); - 2
test_import_loadingallowlist cases (sitecustomize); - 1 LiveKit hang.
So the PR body's "all unit tests pass apart from the environment-only failures" is true.
- 4
-
Formatters: pinned pyink 25.12.0
--check, isort 8.0.1--check-only, ruff 0.15.17 and codespell 2.4.2 are all clean on both files.mypyon the plugin file finds no issues. -
Mutation check: I reran the PR body's
mutate.py(it writes only to a scratch copy) for all 66 mutations, withPY314set to 3.14.0a6. None survive, and the per-mutation failure counts are identical to the report.
Claim 3: hermetic real-turn tests
cli/test_fast_api.pythenTestToolResultErrorClassification, in one process: pass.- Negative control: the same run from a
/tmpcopy of the tests with the twomonkeypatch.setenv("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "false")lines removed. All 6 turn tests fail. So the tracer-installing file really does break them, and the fix is what makes them hermetic. test_fast_api.py,telemetry/test_setup.py,test_llm_callback_span_consistency.py, then the whole plugin file: 840 passed, as the PR body says.
Fail-before
- Round-7 tests on the round-6 plugin (
86885394): 17 fail. The 7 passes are the round-6 tests and the look-alike test. - The round-6 and round-7 dump tests on base
def458b6, with an API-only shim so the module imports: 24 of 24 fail. - Both new classes on the base shim: 113 of 145 fail.
Claim 1: overrides are never called, and nothing leaks
-
Subprocess, Python's default warning display (no pytest). I ran
_recursive_smart_truncateand_serialize_part_modelover 20 shapes, each with a mistyped field carrying a unique marker. The shapes:- plain models top-level, and in a dict or list;
- delegating, kw-swallowing,
**kw-forwarding, own-dict andfunctools.wrapsoverrides, top-level and in a dict; - an override inside a plain model and inside an
Anyfield; RootModel[Plain], a generic, acomputed_field;- a dataclass, an object
__dict__andto_dictholders; - MCP results appended to after validation.
Head puts 0 markers on stderr and 0 in
py.warnings. The round-6 plugin leaks the override markers, and base leaks all 20. -
Real
Runnerturn in a subprocess (no pytest). Tools return a kw-swallowing override, an appended MCP result, and a mistyped model nested in a dict. I ran stderr andpy.warningsdisplays,log_multi_modal_contenton and off, and span capture both off and at its default. Head puts 0 markers anywhere, with exactly 1TOOL_STARTING, 1TOOL_COMPLETEDand 2LLM_REQUESTper turn. The base shim leaks all 3 markers. -
pytest real-turn matrix. 3 overrides × top/dict/list × multimodal on/off × 2 displays, plus 3 parallel calls with mixed results (override, MCP
isErrorappended, mistyped nested model): one outcome row per call and no marker. A spy shows the override is called 0 times during a whole turn, including theLLM_REQUESTpart dump. -
Trying to break the override check.
- Dumped as expected: subclass, mixin,
Gen[T],RootModel[str]andRootModel[list],model_dump = BaseModel.model_dump. - Sentinel as expected:
**kw-forwarding override,functools.wrapsoverride, instance attribute set viaobject.__setattr__. - Not Pydantic models, so called with
warnings=Falseand the row kept: MagicMock,MagicMock(spec=Model), pydantic dataclass,pydantic.v1model. - Not misclassified: MCP
CallToolResultandTextContent, genaiContent,Part,FunctionResponseandGenerateContentResponse, ADKEventandLlmResponse. An MCP model withisError=Trueis stillTOOL_ERROR.
- Dumped as expected: subclass, mixin,
Claim 2. A raising field_serializer, a raising computed_field and a raising model_serializer all record the sentinel. The Field(exclude=True) value is absent, and nothing reaches the logs.
Earlier-round claims, re-probed fresh
- Hostile sibling fields: raising
__class__, raising__eq__/__bool__, a dict subclass whose__getattribute__raises, a hostileCallToolResultsubclass. Also a numpyTrue_flag and a top-level raising__class__. Each got the correct row, and no payload appeared in rows, logs or stderr. - What a classifier returns: a failed Future, a started failing Task, a task that swallows cancellation then raises, a failing
gather,wrap_future,run_in_executor. After GC there was no asyncio ERROR record and no payload. The exception isshield(P2-4). - Classifier validation: async,
partial(async),partial(partial(async __call__)), a raising__signature__, positional-only andmaxare all rejected. Keyword and partial classifiers are accepted. On 3.14.0a6, a TYPE_CHECKING-annotated classifier is accepted with nothing evaluated. - Exactly once:
[bq, retry],[retry, bq],[bq1, retry, bq2]and[bq1, bq2, retry], each with 1 and 2 parallel raising calls, give oneTOOL_ERRORper call per instance.AgentToolnesting with the retry plugin, in both orders: one row per call. An error whose__str__raises, answered by an agenton_tool_error_callback: oneTOOL_ERROR. - Upstream merges since round 4: NodeTool now raises into
on_tool_error, and the tool thread pool runs only the tool body off-loop. Both use the same callback path the marker relies on. The newer upstream09601045touches neither PR file, and doesn't changeMcpTool'sisErrordump.
CI (out of scope, verified)
- Mypy's "new" errors are in
src/google/adk/dependencies/_mcp.py(from upstreame738c26f) andsrc/google/adk/utils/streaming_utils.py. No PR commit touches either file,git diff def458b6 380335e4 -- src/google/adk/utils/streaming_utils.pyis empty, and local mypy on the plugin is clean. - The Unit Tests jobs are cancelled at 10 min with F/E counts 1,1,1,1,2 (3.10 to 3.14). Upstream's own run for
fd14aec2(this tree minus the two PR files) shows 1,1,2,2,1, so the difference is noise.
Findings
P0: none. P1: none.
P2-1: the sentinel for override models hits popular library models whose override does forward warnings, and it applies plugin-wide.
Classification: non-hostile, but this is the disclosed trade-off, not a misclassification. No leak and no row loss; the payload is replaced by a sentinel. Recommended before the upstream PR.
bigquery_agent_analytics_plugin.py:2153-2157 records the sentinel for any Pydantic model whose model_dump.__func__ isn't pydantic.BaseModel.model_dump. In the installed dependencies that includes:
sqlmodel.SQLModel(0.0.47);- google-genai 2.25's public
google.genai.interactionsmodels (170 classes, e.g.Interaction, viagoogle.genai._gaos.types.basemodel.BaseModel); - LiteLLM
ModelResponse; - llama-index instrumentation events.
SQLModel and google-genai both pass warnings= on to Pydantic's own dump, so for them the sentinel prevents nothing.
Evidence:
- A SQLModel
Herotable row returned by a tool: head records['[UNSUPPORTED_OBJECT]'], where base recorded[{'id': 1, 'name': 'Deadpond', 'secret_name': 'Dive Wilson'}]. Interaction(status="completed", id=...): head records[UNSUPPORTED_OBJECT]; base recorded{'status': 'completed', 'id': ..., 'output_text': ''}.- The same happens for session state, since
_recursive_smart_truncateis shared by state, workflow node outputs, agent state, custom metadata, A2A payloads and attributes. The release note only says "A tool result model".
Suggested fix:
-
Trust audited forwarding overrides by identity, looked up through
sys.moduleslike_imported_types:_FORWARDING_MODEL_DUMP_OWNERS = (("sqlmodel.main", "SQLModel"), ("google.genai._gaos.types.basemodel", "BaseModel")) trusted = (pydantic.BaseModel.model_dump, *(c.__dict__["model_dump"] for m, n in _FORWARDING_MODEL_DUMP_OWNERS for c in _imported_types(m, n)))
and test
__func__against that tuple withis. -
I prototyped this in a
/tmpcopy. The plugin file still passes (685), all my override probes still pass, SQLModel rows andInteractionget their payload back, and a SQLModel row with a mistyped id puts 0 markers on stderr where base leaks it. -
At minimum, the release note should name SQLModel and google-genai Interactions models, and say the rule applies to every value the plugin serializes, not only tool results.
P2-2: Pydantic's own model_dump returning a non-container still falls through to __dict__ and records Field(exclude=True) values.
Classification: no hostile class needed (an ordinary @model_serializer returning Decimal, UUID, datetime, an Enum or a set), but pre-existing and byte-identical on base def458b6, and not claimed fixed. Claim 2 is scoped to raising dumps, and it holds. So this is not blocking under the final-round cap.
At :2158-2167 the branch returns only for containers and scalars. Anything else drops to the __dict__ fallback at :2225, which records every public field, with is_truncated=False. Round 7's own rationale ("reading the fields instead would record what it leaves out", :2150-2151) applies here too.
Evidence: a model with amount: Decimal, internal_note: str = Field(exclude=True) and @model_serializer returning self.amount. The TOOL_COMPLETED row records {"amount": "9.99", "internal_note": "EXCL-SCALAR-NOTE"} on head and on base. The same happens with UUID, datetime and frozenset serializers.
Fix: after the scalar check, add:
if _is_instance(obj, pydantic.BaseModel):
return _recursive_smart_truncate(dumped, max_len, seen, depth + 1, budget)I prototyped it in /tmp: 685 plugin tests pass, all 103 of my probes pass, and the rows become '9.99', the UUID string and so on.
P2-3: "Recorded as the sentinel whether it is the result or is nested in it" holds only for nesting in a dict, list or plain object.
Classification: doc precision; non-hostile, pre-existing and consistent with Pydantic.
An override model that is a field of another Pydantic model, or wrapped in RootModel[...], is dumped by Pydantic's schema serializer. The override isn't called, which is true as claimed, but what it leaves out is recorded:
Outer(inner=Delegating())gives{'inner': {'number': ..., 'excluded': 'OVERRIDE-OMITS'}}, on head and on base.- The next
LLM_REQUESTcontent_parts(withlog_multi_modal_contenton, the default) also carries the full schema dump of a top-level override model, includingexcluded. That is what the model receives, so the sentinel's privacy benefit only applies toTOOL_COMPLETEDrows.
Fix: in the PR body's round-7 bullets (lines 60 and 170), say "top level or in a dict or list", and note the model-field case.
P2-4: a classifier returning asyncio.shield(task) still leaks at GC.
Classification: category 2 (deliberate asyncio misuse in a synchronous classifier).
_release_awaitable (:954) cancels the outer future that shield returns and retrieves its exception. But shield detaches the inner task the classifier created, which later fails. At collection, asyncio logs Task exception was never retrieved ... exception=RuntimeError('FBL-SECRET-task') at ERROR, and that text is payload. This is the same class as the disclosed "custom awaitable wrapping a failed future": the plugin can't reach the inner task.
Fix: docs only. Add "asyncio.shield, or any task the classifier creates and does not return" to the classifier docstring and the hostile-case list.
Judgement on the disclosed trade-off (override models become the sentinel)
It is acceptable as a default for unknown overrides: it is provably leak-free, and the rows are kept and flagged is_truncated.
It is under-disclosed and broader than it needs to be. It silently empties results from SQLModel, google-genai Interactions and LiteLLM, and it reaches every event type. The leak it prevents needs an override that drops the flag and a field mutated to the wrong type, which is not the case for the libraries above.
P2-1's allowlist keeps the leak-freedom and restores those payloads. I'd do it, or at least fix the release note, before upstreaming.
On R3: its "common types" clause targeted non-overriding types misclassified as overrides, and I found none. SQLModel and genai Interactions models genuinely override model_dump, so I've treated their sentinel as the disclosed trade-off, not as an R3 failure. If you read R3 literally, P2-1 is the item to reconsider.
VERDICT: APPROVE @ 380335e
…rror-class Brings in fork PR #15 (2c16d9d, "record error-bearing tool results as TOOL_ERROR"). Only the plugin and its test file conflicted, and every conflict was additive, so both sides are kept: - Plugin imports: #15's `import pydantic` and this branch's `from pydantic import BaseModel` are both kept; each side's code uses its own form. - BigQueryLoggerConfig docstring and fields: #15's tool_result_classifier comes first and this branch's debug_content_formatter_errors last, so #15's already-merged positional index is unchanged and the new field is appended at the end, as the class's comment asks. - Test imports: functools (#15) and io (this branch) are both kept. One test needed adapting. #15 records a tool error without a message by its type name, so on_tool_error_callback no longer produces an empty error_message. The precedence test's empty-message case now comes from on_model_error_callback, which still records str(error) as is, so it keeps pinning that an empty event message is treated as absent. A new case pins how the two compose: a message-less tool error reads "RuntimeError; content_formatter raised ImportError". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Suggested title for the upstream squash (release-please):
feat(plugins): record error-bearing tool results as TOOL_ERROR in BigQuery analytics. This PR's commits are titledfix(plugins): …, but the change adds public API and changes which event types the plugin emits, so the squashed title should usefeatto list it under Features.Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
2. Or, if no issue exists, describe the change:
Problem:
BigQueryAgentAnalyticsPluginwritesTOOL_ERRORonly fromon_tool_error_callback, andafter_tool_callbackwritesTOOL_COMPLETED/status = 'OK'for whatever the tool returned. So two common failures are recorded as successes:isError: true.McpToolreturns theCallToolResultas a result dict instead of raising.ReflectAndRetryToolPluginis registered ahead of the analytics plugin.PluginManagerstops at the first plugin that answers the error, so the retry plugin's response reachesafter_tool_callbackas the tool's result.With the plugins in the opposite order,
on_tool_error_callbackrecords theTOOL_ERROR. The retry plugin's answer then still reachesafter_tool_callback, which writes a second, spuriousTOOL_COMPLETEDrow. Because the tool span is already closed, that row pops a span it does not own and carries the enclosing agent span's id and latency.Separately,
_get_tool_originreturnsUNKNOWNforToolboxToolsettools and forSkillToolset's own tools.Solution:
after_tool_callbacknow decides whether a result reports a failure. Three checks run in order:ReflectAndRetryToolPluginanswer. Identified by itsresponse_type. It stands for a raised error and is recorded asTOOL_ERRORbefore any classifier runs, so a classifier can't hide a raised error in one plugin order only. The message is the answer'serror_details, which isstr(error), the same texton_tool_error_callbackrecords. If that is empty the exception's type name is used, and if both are empty a fixed message.BigQueryLoggerConfig.tool_result_classifierruns next. It follows theToolResultClassifierprotocol: keyword-only(*, tool, tool_args, tool_context, result), returning aToolResultClassification(status="ERROR" | "OK", error_message=...)orNone.When the plugin is created, it rejects a classifier that:
__call__, either directly or behindfunctools.partial;inspectcan read, including one whose__signature__raises;The signature is read without evaluating annotations: on Python 3.14 they are read as strings. So a classifier that annotates its parameters with names imported only under
TYPE_CHECKINGpasses, where 3.14's default evaluation raisesNameError.At run time, a classifier that raises an
Exception, or whose verdict can't be read, is skipped with a warning that names only the tool.A returned awaitable is skipped the same way, after it is released without being awaited:
So Python doesn't warn about the coroutine, and asyncio doesn't later log an exception the future or task holds. Otherwise asyncio logs a failed future's unretrieved exception, which can carry the result, when the future is collected. An async generator, or any other awaitable, runs nothing until iterated or awaited, so it is simply dropped, along with any future it wraps.
isError(is_errorin SDK 2.x dumps) is exactlyTrue, from any tool, which covers aCallToolResultdump. Or aCallToolResultmodel itself, frommcp.typesor the SDK 2.xmcp_typespackage, with that flag.Reading the result. Fields are read without calling any method of the result, its keys, or its values:
type.__subclasscheck__against the value's actual type, never throughisinstance's__class__fallback or a metaclass hook;strkeys rather than looked up, so no key of the result is hashed or compared;__dict__descriptor, so a subclass's__getattribute__or__dict__property can't intercept the read.Each check runs on its own, so a malformed or hostile field can't hide a valid signal in another field. If classification still fails, the call falls back to its
TOOL_COMPLETEDrow with a constant warning.A closed span always gets its row.
TOOL_COMPLETEDresult does run the result's code. Onmaina result whose attribute reads or__class__raise made the serializer raise, lost the row, and put the exception, which can carry the payload, in_safe_callback's traceback log.exceptblock. Logged inside it, a failing log handler would print the caught exception, which can carry the result.model_dump(warnings=False). Pydantic still dumps a field whose value no longer matches its type, such as an MCP result'scontentlist appended to after validation. But by default it first emits aUserWarningthat quotes the value, to stderr or thepy.warningslogger, before anycontent_formatter, column denylist orlog_multi_modal_contentsetting applies. The flag is per call, so no process-wide warning filter changes.model_dumpis an override isn't called, top-level or nested. Whatever its signature, an override may not pass the flag on, assuper().model_dump(exclude=...)doesn't. So the row records the serializer's sentinel in its place, as it does for amodel_dumpthat fails or doesn't take the flag. Reading the fields instead would record what the override or Pydantic's own serializers leave out.LLM_REQUESTrow is dumped with the same flag. Pydantic serializes models nested in it itself, without calling their overrides.BaseExceptionsubclasses that aren'tExceptions (cancellation,KeyboardInterrupt,SystemExit) still propagate, as they do from every callback of this plugin.The
TOOL_ERRORrow.status = 'ERROR'and a non-nullerror_message.{tool, args, tool_origin}, the same shapeon_tool_error_callbackwrites.TOOL_STARTING.final_response_tool_nameslogs noAGENT_RESPONSE.Privacy.
error_messageis redacted and truncated, butcontent_formatterandpayload_column_denylistdon't apply to it.TOOL_ERRORcontent, because acontent_formatterwritten forTOOL_COMPLETEDrows would not scrub it there.LLM_REQUESTrow records the function response only incontent_parts(aspart_attributes.function_response), and only whilelog_multi_modal_contentis on andcontent_partsisn't denied.error_messagethrough a classifier.One outcome row per call.
on_tool_error_callbackkeeps a weak reference to the tool context of the call whose failure it recorded, keyed by plugin instance.after_tool_callbackthen skips that call, so it neither writes a second row nor pops another span.str(error)raises. Nothing that can fail runs between the mark and the row, so a marked call always has itsTOOL_ERROR.Other changes.
str()raises, now records its type name aserror_message._get_tool_originreturnsTOOLBOXfortoolbox_adk.ToolboxTool, andSKILLforSkillToolset's own tools, looking both up insys.moduleswithout importing them.Plugin order, as now documented in the plugin's docstring. Register the analytics plugin before plugins that answer or re-raise tool callbacks.
ReflectAndRetryToolPluginahead, a call gets no outcome row when:extract_error_from_resultanswersafter_tool_callback.TOOL_ERROR. A failure that only a later plugin detects in a result is recorded asTOOL_COMPLETED, unlesstool_result_classifierflags the same result.Why the per-tool error hooks aren't used. The built-in rules don't consult the tools'
_detect_error_in_responsehooks, which set the OpenTelemetryerror.type.errorkey, which a function tool may also return as data.TOOL_COMPLETEDrow, which carries it, to aTOOL_ERRORrow that leaves it out.GoogleTool's{"status": "ERROR", "error_details": ...}; see Follow-ups. A classifier can record those as failures today.Release note (suggested)
BigQueryAgentAnalyticsPluginnow records tool results that report a failure asTOOL_ERRORwithstatus = 'ERROR', instead ofTOOL_COMPLETED/OK. These areReflectAndRetryToolPluginanswers, and any tool's dict result whose top-levelisErrororis_erroris exactlyTrue, such as an MCPCallToolResult, or aCallToolResultmodel itself.TOOL_COMPLETEDwill see these calls move toTOOL_ERROR.ReflectAndRetryToolPluginregistered ahead of the analytics plugin, the exception text moves.str(error)used to reach theTOOL_COMPLETEDrow as the answer'serror_detailsincontent, wherecontent_formatterandpayload_column_denylistapply. It now reaches theTOOL_ERRORrow'serror_message, where only the built-in redaction applies. That is the same text theTOOL_ERRORrow already records when the analytics plugin is registered first.event_allowlistlistsTOOL_COMPLETEDbut notTOOL_ERRORstop recording these calls.TOOL_ERRORrow for privacy. Queries that readJSON_VALUE(content, '$.result…'), such as the MCP error text, will no longer find it. The function response is still in a laterLLM_REQUESTrow'scontent_partswhen multimodal content logging is on.BigQueryLoggerConfig.tool_result_classifier,ToolResultClassification,ToolResultClassifier.tool_originreportsTOOLBOXandSKILLfor Toolbox and SkillToolset tools.TOOL_COMPLETEDlatency no longer includes the plugin's own truncation of the result.TOOL_COMPLETEDrow, with a sentinel in place of the result, instead of losing the row and logging the exception.TOOL_COMPLETEDrow and for the function response in the nextLLM_REQUESTrow.model_dumpis an override, or whose dump fails, is recorded as the sentinel ([UNPARSEABLE_JSON_BLOB]in the row). Before, the override's output was recorded, or for a failed dump the model's fields, including ones the dump leaves out.Follow-ups (not in this PR)
Each item below is left as it is on
main, or is outside google#7112; the reason follows the dash.GoogleTool's{"status": "ERROR", "error_details": …}envelope, which the BigQuery, Spanner, Bigtable, Pub/Sub, GCS, Eventarc and Data Agent tools return. It widens which results count as failures and needs its own review, and a classifier records them today.McpTool's graceful-error envelope,{"error": "MCP tool execution failed: …"}or{"error": "Unexpected error during MCP tool execution: …"}, which the default-on_MCP_GRACEFUL_ERROR_HANDLINGreturns forMcpErrorand transport failures such as a gateway 403. A rule keyed on an{"error": …}shape must first rule out tools that return the same shape as data,McpTool's own_detect_error_in_responsedoesn't flag it either, and a classifier records it today.FunctionTool's missing-argument envelope,{"error": "Invoking `<name>()` failed as the following mandatory input parameters are not present: …"}. Same reason as theMcpToolenvelope: the{"error": …}shape is also data, and the HITL confirmation pause, for one, uses it.TOOL_ERRORrows. It is a privacy decision, and acontent_formatterwritten forTOOL_COMPLETEDrows would not scrub the result there.errorkey, including data and confirmation envelopes._log_event's content-formatter and content-parser boundaries log inside theirexceptblocks, so a log handler that fails at that moment prints the caught exception. That code predates this change and is shared by every event type; this PR's boundaries log after leaving the block.content_formatterfailure writes[FORMATTER_FAILED]with a nullerror_message. It is a separate behavior of the formatter boundary, outside BigQueryAgentAnalyticsPlugin records error-bearing tool results as TOOL_COMPLETED with status OK google/adk-python#7112.**kwargs, passes the construction check.inspectcan't tell that its body rejects the keywords, and such a classifier fails at run time with the constant warning like any failing classifier. On Python 3.11operator.itemgetteris one of them.model_dump, withwarnings=False. One that takes the keyword but calls a Pydantic dump inside without passing it on can still warn, and so can a customto_dict()that calls one. The plugin can't tell without calling them, and skipping every such object would drop ordinary results.ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, on by default, with any recording tracer),SqliteSessionService, and the other session services' event dumps. These are outside this plugin; passingwarnings=Falsethere, asflows/llm_flows/tools/_caller.pyalready does, is an upstream change of its own.is_erroris accepted in any dict, not only from MCP models or MCP tools. Narrowing it would be a behavior change of its own. It matters only for plain dicts that use the snake_case spelling, since ADK'sMcpTooldumps use camelCase.before_tool_callbackleaves noTOOL_STARTING, and the outcome row pops the agent's span. The span handling predates this PR;pop_span(expected_kind="tool")could guard it.skill_toolsetwould need adding. The test that enumerates the module's tool classes fails when that happens.TOOL_COMPLETEDis not classified. That path doesn't go throughafter_tool_callback.after_tool_callbackeither. That's the flow's design, not this plugin's.AgentToolrun clears the outer span stack, and two analytics instances share the module-level span stack. Both are span-stack design; each call still gets exactly one outcome row.ReflectAndRetryToolPlugincallsstr(error)itself, so an exception whose__str__raises ends the run there. That plugin's code is outside this change. Registered first, the analytics plugin records the call'sTOOL_ERRORbefore the run ends.[UNPARSEABLE_JSON_BLOB]. The content parser reads the bracketed[UNSUPPORTED_OBJECT]sentinel as malformed JSON, as it always has.tool_argsserialization has no new boundary. The model supplies them as JSON, so only a tool that plants such an object in its own argument dict reaches it.nameor class hooks raise.__name__raise.__signature__returns aSignaturesubclass whosebindraises something other thanTypeError. That raises in the application's own code at startup.__await__returningfuture.__await__(). The plugin doesn't run the wrapper's code to reach the future, so asyncio logs that future's exception when it's collected.Testing Plan
Unit Tests:
The tests are in
tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py, inTestToolResultErrorClassificationandTestToolProvenance: 145 cases pass, and one more runs only on Python 3.14 and later.Hostile fields. A valid error signal survives a hostile sibling field: a value whose
__class__raises, a key that collides with a wanted key and raises when compared, a raising==or truth test. That holds for both the MCP flag and the retry answer.Hostile MCP models. A
CallToolResultsubclass whose attribute access raises still reports its flag, and model recognition runs no metaclass hook.Hostile results, contained by the serializer. Results with a raising
==, raisinggetor other methods, numpy or pandas values, or a raising__getattr__keep their row without any fallback firing.Hostile results that raise when serialized. These keep their
TOOL_COMPLETEDrow with the sentinel, and a constant warning is logged:__getattribute__raises;__class__raises;CallToolResultsubclass whose attribute access raises, either successful or with an error the classifier records asOK.No payload in the log. In all the cases above, no payload reaches the log.
Isolation. A failing rule doesn't hide another rule's signal. A boundary's warning doesn't carry the caught exception, even through a failing log handler, and that includes the serialization boundary.
Pydantic warnings that would quote the result. The tests use an MCP result whose
contentlist was appended to after validation, and an application model given a value of the wrong type after validation. Each keeps itsTOOL_COMPLETEDrow with the dumped value, and neither stderr (Python's default warning display) nor thepy.warningslogger (logging.captureWarnings(True)) carries the payload. The plugin's own dumps stay quiet across a real turn withlog_multi_modal_contentoff, where the nextLLM_REQUESTrow dumps the function response too. Those turn tests switch off ADK's span content capture, which dumps the same response with the warnings on whenever a recording tracer is installed (see Follow-ups).Result models with their own
model_dump. Three overrides: one that takes no keywords and callssuper().model_dump(exclude=...), one that takes keywords but doesn't pass them on, and one that builds its own dict. Each is recorded as the sentinel whether it is the result or is nested in it, with no payload and no excluded field in the row, and no payload on stderr orpy.warnings. The same holds across a real turn. A result model whose dump fails is recorded as the sentinel rather than as its fields, which would include one declared withField(exclude=True). An object that only looks like a model is still dumped by itsmodel_dump, which receiveswarnings=False.Unreadable error messages. An error whose
str()raises gets exactly oneTOOL_ERROR, with its type name. This holds when nothing answers it, when a later handler's answer reachesafter_tool_callback, and end to end with an agenton_tool_error_callbackthat answers it.What a classifier returns, followed past garbage collection. Each of these gets the built-in rule's row, and a constant warning:
asyncio.gather;The test yields to the loop and collects garbage. Neither asyncio's log nor a warning then carries the payload, and asyncio logs no error at all. A pending task is cancelled before it runs, and a pending future is cancelled.
Classifier:
functools.partialof an object with an async__call__and a classifier whose__signature__raises;BaseExceptionpropagates.Other units:
mcp_typespackage;End to end:
ReflectAndRetryToolPlugin, in both plugin orders;OKclassifier, in both orders;ToolNode, in both orders;on_tool_error_callbackanswering an error whosestr()raises.Results (Python 3.11.13, macOS), on this head:
pytest tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py -n 8: 685 passed, 1 skipped (the 3.14-only test). The same on Python 3.10.16 and 3.13.7.test_reflect_retry_tool_plugin.py,test_reflect_retry_utils.py,test_reflect_retry_model_plugin.pyandtools/mcp_tool/test_mcp_tool.py: 846 passed, 1 skipped.pytest tests/unittests/plugins tests/unittests/workflow/test_tool_node.py -n 8: 997 passed, 1 skipped.cli/test_fast_api.pythen the turn tests; another wascli/test_fast_api.py,telemetry/test_setup.pyandflows/llm_flows/test_llm_callback_span_consistency.pythen the whole plugin file (840 passed).pytest tests/unittests -n 8 --timeout=300on this head, run in four parts: 16441 passed. The 9 failures and 4 collection errors fail on this machine without this change too:cli/utils/test_cli_create.py::test_handle_login_with_google_option_3and two LiveKit tests;labs/antigravitycollection errors (google-antigravitycan't be installed on x86_64);test_import_loadingallowlist cases (this Python loadssitecustomize).NameError;pre-commit run --files <changed files>: all hooks pass (isort 8.0.1, pyink 25.12.0, ruff, addlicense, compliance checks, codespell).mypy src/google/adk/plugins/bigquery_agent_analytics_plugin.py: no issues.Mutation check. Each of 66 mutations removes one guard from a temporary copy of the plugin, and each makes at least one test fail. No mutation survives.
Three of them change code that only Python 3.14 runs. For those,
mutate.pyruns the check embedded in it with the interpreterPY314names: it validates a classifier whose annotations name something undefined, and fails if the classifier is rejected or its annotations are evaluated. This report used CPython 3.14.0a6.The per-mutation report and the script are below. Run the script from the worktree root with
PY314=<python3.14> python mutate.py <first> <last>; this report ran 0–21, 22–43 and 44–65.Per-mutation report (mutation → how many of the 145 passing cases it makes fail, and up to two of them; the script prints them all)
mutate.py
Manual End-to-End (E2E) Tests:
I ran the reproduction script from google#7112 unchanged. It uses a scripted
BaseLlmand captures rows by replacing_log_event, so it needs no BigQuery.Before:
After:
With the analytics plugin registered before
ReflectAndRetryToolPlugin, a raising tool used to produceTOOL_ERRORplus a spuriousTOOL_COMPLETEDthat carried the agent's span. It now produces oneTOOL_ERROR.Checklist
Additional context
This is independent of google#7114, which covers only part of the issue and treats any dict with an
error_detailskey as an error. That rule would misclassify successful results from tools that report details in that field.🤖 Generated with Claude Code