You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
BigQuery Agent Analytics plugin: long text lost when gcs_bucket_name is set with log_multi_modal_content=False, and inline binary parts logged as text/plain when not uploaded #7868
bigquery_agent_analytics_plugin.py has two content-parsing defects. Both are present in v2.13.0, the revision the adk-python links below are pinned to. Both also behave the same on adk-python main as of October 30, 2026, where only the line numbers differ.
Long text is lost when gcs_bucket_name is set and log_multi_modal_content=False
The plugin creates the GCS offloader when gcs_bucket_name is set and content_parts is not in payload_column_denylist (bigquery_agent_analytics_plugin.py:7045-7067). log_multi_modal_content plays no part in that decision.
HybridContentParser then uploads any text part over 32 KiB, or over max_content_length characters. It writes a 200-character preview into that part's content_parts entry and leaves the text out of the summary that becomes content (bigquery_agent_analytics_plugin.py:4325-4355). _log_event writes content_parts as an empty list when log_multi_modal_content is false (bigquery_agent_analytics_plugin.py:8997-8999), which discards the only entry that referenced the uploaded object.
The loss affects a user message, the prompt in an LLM_REQUEST event, and any other content parsed from a types.Content. No row references the GCS object that holds the text, and plugin initialization logs no warning when gcs_bucket_name is set and log_multi_modal_content is false.
A fix should keep the text reachable from the row for every combination of gcs_bucket_name and log_multi_modal_content.
Inline binary parts get mime_typetext/plain when they are not uploaded
Each content_parts entry starts with "mime_type": "text/plain" (bigquery_agent_analytics_plugin.py:4254-4262). The part.inline_data branch sets the entry's mime_type from inline_data.mime_type only after a successful upload (bigquery_agent_analytics_plugin.py:4276-4307). Without a bucket the entry keeps text/plain with the text [BINARY DATA], and after a failed upload it keeps text/plain with [UPLOAD FAILED].
With the default configuration, which has no bucket, an inline image/png part is therefore recorded as text/plain, and a query that selects inline media parts by content_parts.mime_type finds none of them.
A fix should record inline_data.mime_type in the content_parts entry of an inline binary part whether or not the bytes were uploaded.
For reference, the same Go implementation writes the part's own MIME type whether or not the bytes are uploaded (json_formatter.go:333-354).
Fix plan: #7868 — offloaded text lost with log_multi_modal_content=False; inline binary parts logged as text/plain
Line references are against main at e9e8f61. bqaa.py = src/google/adk/plugins/bigquery_agent_analytics_plugin.py; tests: = tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py.
Root cause
A. Offloaded text is unreachable when content_parts is not written.
bqaa.py:7437-7460 builds the GCSOffloader whenever gcs_bucket_name is set, unless content_parts is in payload_column_denylist. log_multi_modal_content is never consulted.
bqaa.py:4579-4601 uploads a text part larger than 32 KiB (inline_text_limit, bqaa.py:4274) or longer than max_content_length characters. It stores a 200-character preview and the URI in that part's entry and leaves the text out of summary_text. That summary (bqaa.py:4684) is what content holds: the LLM_REQUEST prompt at bqaa.py:4737 and the text_summary at bqaa.py:4770.
bqaa.py:9545-9547 writes content_parts as [] when log_multi_modal_content is false, which drops the only reference. Inline binary parts are uploaded and orphaned the same way (bqaa.py:4534-4556).
The denylist case already has the right guard and warning (bqaa.py:7439-7451). log_multi_modal_content=False removes content_parts just as completely but is not covered by that guard.
B. Inline binary parts keep text/plain unless they were uploaded.
Every entry starts as text/plain (bqaa.py:4508-4516). The inline_data branch assigns the real type only after a successful upload (bqaa.py:4555). The failure path (bqaa.py:4557-4559, [UPLOAD FAILED]) and the no-bucket path (bqaa.py:4560-4561, [BINARY DATA]) keep text/plain, so under the default configuration every inline image is recorded as text.
Proposed change
Add one predicate, _content_parts_persisted() = config.log_multi_modal_content and "content_parts" not in self._denied_columns.
At parser setup (bqaa.py:7438-7460), build no offloader when the predicate is false, and extend the existing warning to cover log_multi_modal_content=False. Text then stays inline in content and is truncated only at max_content_length (default 500 × 1024 characters), with is_truncated set. That is the no-bucket behavior and the adk-go behavior cited in the issue.
In _log_event, evaluate the predicate once per row:
pass it as a new keyword-only offload argument (default True) to HybridContentParser.parse (bqaa.py:9456), which ignores the offloader when it is false (bqaa.py:4534, bqaa.py:4579);
use the same value for the content_parts write (bqaa.py:9545-9547);
use it in the parse skip at bqaa.py:9449, which skips parsing when content is denied and parts are not persisted.
The upload decision and the row then cannot disagree, even if the config is changed after start.
In the inline_data branch, set part_data["mime_type"] = mime_type right after bqaa.py:4533, keeping the application/octet-stream fallback. Remove the post-upload assignment at bqaa.py:4555. Placeholder texts and storage_mode stay as they are.
Update the docstrings at bqaa.py:2481, bqaa.py:2523 and the comment at bqaa.py:2747 to say that offload happens only when content_parts is persisted.
No schema or view change is needed. The eval view selects text parts by mime_type = 'text/plain' and already excludes both placeholders (bqaa.py:5553-5557), and its test already models [BINARY DATA] as image/png (tests:14270).
Tests
Mirror test_content_parts_denied_disables_gcs_offload (tests:22052) with gcs_bucket_name set and log_multi_modal_content=False. Expect plugin.offloader is None and the warning in the log. Expect no upload_from_string call for a 32 KiB+1 prompt or for an inline image. The row's content.prompt[0].content holds the full text and content_parts == [].
Parametrize bucket {unset, set} × log_multi_modal_content {True, False}. In every case the long text must be reachable, either inline in content or through a GCS_REFERENCE part's uri. Add one case that flips the flag to False after start and asserts nothing is uploaded.
In TestOffloadUnitSeparation (tests:17297), add parser tests for an inline image/png part:
with no offloader: mime_type == "image/png", text [BINARY DATA], storage INLINE;
with an offloader that raises: image/png and [UPLOAD FAILED];
after a successful upload: output unchanged;
with mime_type=None: application/octet-stream on every path.
These must still pass unchanged: test_multimodal_offloading (tests:3181), test_no_offloader_falls_back_to_truncate (tests:17431), test_restart_rebuilds_parser_and_offloader (tests:26966) and test_eval_ready_requires_user_text (tests:14220).
Risks / compatibility
Configs that set a bucket with multimodal logging off stop uploading.
No row referenced those objects. Only the object naming ({date}/{trace_id}/{span_id}_*, bqaa.py:4581-4584) tied them to a row, so a job that lists the bucket by prefix loses them. The startup warning names the bucket.
Text longer than max_content_length is now truncated inline rather than lost. Full fidelity still requires log_multi_modal_content=True.
New rows record the real MIME type for inline binary parts; existing rows keep text/plain. Queries that select media by mime_type must not assume a uri and should check storage_mode. The placeholder filter keeps the eval view correct for old rows and for text/plain blobs.
parse() gains only a keyword-only argument with a default, so existing callers are unaffected. The table schema, the views and the GCS object layout are unchanged.
Hi @haiyuan-eng-google, I'm wrapping up #7896 and would be happy to help with the Python fix here if you don't have bandwidth to take it on. Would you be open to me picking it up? I can use @caohy1988's plan as a starting point and add regression coverage for both the offloading and MIME type cases.
Activity
karolpiotrowicz commentedon Oct 30, 2026
@haiyuan-eng-google, could you assign this one to yourself? I don't have permission to assign issues in adk-python.
karolpiotrowicz commentedon Oct 30, 2026
The Go plugin's differences from adk-python v2.13.0 are tracked in google/adk-go#2047.
caohy1988 commentedon Oct 31, 2026
Fix plan: #7868 — offloaded text lost with
log_multi_modal_content=False; inline binary parts logged astext/plainLine references are against
mainate9e8f61.bqaa.py=src/google/adk/plugins/bigquery_agent_analytics_plugin.py;tests:=tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py.Root cause
A. Offloaded text is unreachable when
content_partsis not written.bqaa.py:7437-7460builds theGCSOffloaderwhenevergcs_bucket_nameis set, unlesscontent_partsis inpayload_column_denylist.log_multi_modal_contentis never consulted.bqaa.py:4579-4601uploads a text part larger than 32 KiB (inline_text_limit,bqaa.py:4274) or longer thanmax_content_lengthcharacters. It stores a 200-character preview and the URI in that part's entry and leaves the text out ofsummary_text. That summary (bqaa.py:4684) is whatcontentholds: theLLM_REQUESTprompt atbqaa.py:4737and thetext_summaryatbqaa.py:4770.bqaa.py:9545-9547writescontent_partsas[]whenlog_multi_modal_contentis false, which drops the only reference. Inline binary parts are uploaded and orphaned the same way (bqaa.py:4534-4556).bqaa.py:7439-7451).log_multi_modal_content=Falseremovescontent_partsjust as completely but is not covered by that guard.B. Inline binary parts keep
text/plainunless they were uploaded.text/plain(bqaa.py:4508-4516). Theinline_databranch assigns the real type only after a successful upload (bqaa.py:4555). The failure path (bqaa.py:4557-4559,[UPLOAD FAILED]) and the no-bucket path (bqaa.py:4560-4561,[BINARY DATA]) keeptext/plain, so under the default configuration every inline image is recorded as text.Proposed change
Add one predicate,
_content_parts_persisted()=config.log_multi_modal_content and "content_parts" not in self._denied_columns.At parser setup (
bqaa.py:7438-7460), build no offloader when the predicate is false, and extend the existing warning to coverlog_multi_modal_content=False. Text then stays inline incontentand is truncated only atmax_content_length(default 500 × 1024 characters), withis_truncatedset. That is the no-bucket behavior and the adk-go behavior cited in the issue.In
_log_event, evaluate the predicate once per row:offloadargument (defaultTrue) toHybridContentParser.parse(bqaa.py:9456), which ignores the offloader when it is false (bqaa.py:4534,bqaa.py:4579);content_partswrite (bqaa.py:9545-9547);bqaa.py:9449, which skips parsing whencontentis denied and parts are not persisted.The upload decision and the row then cannot disagree, even if the config is changed after start.
In the
inline_databranch, setpart_data["mime_type"] = mime_typeright afterbqaa.py:4533, keeping theapplication/octet-streamfallback. Remove the post-upload assignment atbqaa.py:4555. Placeholder texts andstorage_modestay as they are.Update the docstrings at
bqaa.py:2481,bqaa.py:2523and the comment atbqaa.py:2747to say that offload happens only whencontent_partsis persisted.No schema or view change is needed. The eval view selects text parts by
mime_type = 'text/plain'and already excludes both placeholders (bqaa.py:5553-5557), and its test already models[BINARY DATA]asimage/png(tests:14270).Tests
test_content_parts_denied_disables_gcs_offload(tests:22052) withgcs_bucket_nameset andlog_multi_modal_content=False. Expectplugin.offloader is Noneand the warning in the log. Expect noupload_from_stringcall for a 32 KiB+1 prompt or for an inline image. The row'scontent.prompt[0].contentholds the full text andcontent_parts == [].log_multi_modal_content{True, False}. In every case the long text must be reachable, either inline incontentor through aGCS_REFERENCEpart'suri. Add one case that flips the flag to False after start and asserts nothing is uploaded.TestOffloadUnitSeparation(tests:17297), add parser tests for an inlineimage/pngpart:mime_type == "image/png", text[BINARY DATA], storageINLINE;image/pngand[UPLOAD FAILED];mime_type=None:application/octet-streamon every path.test_multimodal_offloading(tests:3181),test_no_offloader_falls_back_to_truncate(tests:17431),test_restart_rebuilds_parser_and_offloader(tests:26966) andtest_eval_ready_requires_user_text(tests:14220).Risks / compatibility
{date}/{trace_id}/{span_id}_*,bqaa.py:4581-4584) tied them to a row, so a job that lists the bucket by prefix loses them. The startup warning names the bucket.max_content_lengthis now truncated inline rather than lost. Full fidelity still requireslog_multi_modal_content=True.text/plain. Queries that select media bymime_typemust not assume auriand should checkstorage_mode. The placeholder filter keeps the eval view correct for old rows and fortext/plainblobs.parse()gains only a keyword-only argument with a default, so existing callers are unaffected. The table schema, the views and the GCS object layout are unchanged.its-amann commentedon Nov 1, 2026
Hi @haiyuan-eng-google, I'm wrapping up #7896 and would be happy to help with the Python fix here if you don't have bandwidth to take it on. Would you be open to me picking it up? I can use @caohy1988's plan as a starting point and add regression coverage for both the offloading and MIME type cases.
fix(plugins): prevent BQAA offload data loss and preserve binary MIME
fix(plugins): prevent BQAA offload data loss and preserve binary MIME