Skip to content

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

Open

Description

@karolpiotrowicz

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.

For reference, the Go implementation on google/adk-go main as of October 30, 2026 ignores GCSBucketName when content_parts logging is disabled, so the text stays inline (bigquery_agent_analytics_plugin.go:392-397).

Inline binary parts get mime_type text/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).

Activity

  1. karolpiotrowicz commented on Oct 30, 2026

    @karolpiotrowicz
    Author

    @haiyuan-eng-google, could you assign this one to yourself? I don't have permission to assign issues in adk-python.

  2. karolpiotrowicz commented on Oct 30, 2026

    @karolpiotrowicz
    Author

    The Go plugin's differences from adk-python v2.13.0 are tracked in google/adk-go#2047.

  3. caohy1988 commented on Oct 31, 2026

    @caohy1988

    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

    1. Add one predicate, _content_parts_persisted() = config.log_multi_modal_content and "content_parts" not in self._denied_columns.

    2. 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.

    3. 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.

    4. 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.

    5. 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.
  4. its-amann commented on Nov 1, 2026

    @its-amann

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions