Skip to content

fix(google_adk): support google-adk 2.7.0+ tool dispatch changes [MLOS-864] - #19848

Open
heyitsgrace996 wants to merge 5 commits into
mainfrom
grace/mlos-864-google-adk-2.7-compat
Open

fix(google_adk): support google-adk 2.7.0+ tool dispatch changes [MLOS-864]#19848
heyitsgrace996 wants to merge 5 commits into
mainfrom
grace/mlos-864-google-adk-2.7-compat

Conversation

@heyitsgrace996

@heyitsgrace996 heyitsgrace996 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Description

google-adk 2.7.0 merged __call_tool_live into __call_tool_async and deleted the former. We wrap both by name at startup, so any app with LLM Observability enabled and google-adk 2.7.0+ installed failed to boot:

AttributeError: module 'google.adk.flows.llm_flows.functions' has no attribute
'__call_tool_live'. Did you mean: '__call_tool_async'?
  ddtrace/contrib/internal/google_adk/patch.py, line 228, in patch

google-adk change ref: google/adk-python@8b9d222

What Changed

This PR does 4 things:

  1. Fixes the crash: Only wrap the tool dispatch functions the installed version actually defines, in both patch() and unpatch().

  2. Fixes underlying tool stream bugs: Three bugs on the tool path nobody could reach while startup was dying: the tool read from args[0] when ADK calls with keywords only, streaming tools tagged as a generator object with the span closed before anything was produced, and an un-awaited coroutine in the agent-less fallback.

  3. Bumps test versioning: Lockfiles were frozen on google-adk 1.28.1, so 2.x was never tested. Regenerates the lockfiles and pins the floor at ==1.0.0 alongside latest, so the oldest supported version is always covered.

  4. Repairs two existing tests: The tool wrapping assertions were commented out with a # TODO: fix this and would have caught this. test_agent_run_async was failing on google-adk >= 2.6.3.

Testing

  • New tests cover the crash (symbol removed via monkeypatch, so it holds on any ADK version), keyword-only dispatch, and streaming span lifetime.
  • Run against both riot envs (1.0.0 and 2.7.1) on Python 3.12; streaming tests skip below 2.7.0.
  • Also verified against real Vertex Gemini: released ddtrace reproduces the traceback, this branch passes 15/15 checks.

Risks

Low, confined to the google_adk integration. The streaming path only runs on >= 2.7.0; older versions are unchanged.

google-adk 2.7.0 removed the internal `__call_tool_live` function and routes
live tool execution through `__call_tool_async` instead. The integration
patched `__call_tool_live` unconditionally at import time, so applications
enabling LLM Observability (which patches with raise_errors=True) crashed on
startup with an AttributeError and could not boot at all.

Only patch the tool dispatch functions the installed version defines, reusing
the check_module_path guard already applied to the code executors.

Also fixes three defects the startup crash was masking on 2.7.0+:

- The live dispatch path passes every argument by keyword, so reading the tool
  from `args[0]` raised IndexError. Use get_argument_value instead.
- Streaming tools return an async generator from `__call_tool_async`. The span
  was tagged with the generator object and finished before any chunk was
  produced. Keep the span open until the stream is exhausted and tag the
  streamed items, capped at MAX_STREAMED_TOOL_CHUNKS.
- The agent-less fallback returned an un-awaited coroutine.

`_traced_functions_call_tool_live` also never assigned its accumulated result,
so every live tool span on google-adk < 2.7 recorded a null output.

Re-enable the tool dispatch assertions in test_google_adk_patch.py, which were
commented out due to class-body name mangling, and add coverage for the missing
symbol, the keyword-only dispatch path and the streamed output tagging. Pin the
riot min version explicitly and regenerate the lockfiles so the latest slot
resolves to google-adk 2.7.1 instead of the stale 1.28.1.
Address review feedback on the streaming tool wrappers.

`_traced_tool_stream` iterated the generator returned by the tool but never closed
it. When a consumer stops early it closes the ddtrace wrapper, leaving the wrapped
generator to wait for async generator finalization. google-adk closes the stream
itself with `Aclosing`, so mirror that and close it in the wrapper's `finally`.
`contextlib.aclosing` is 3.10+, so this calls `aclose()` directly. The pre-2.7
`__call_tool_live` wrapper had the same gap.

Add the two streaming lifetime tests that were missing: a tool that raises partway
through the stream, and a consumer that stops early and closes it. Both assert the
span is still finished.

Also skip content-less events in test_agent_run_async. google-adk synthesizes an
error event with no content in `workflow/_node_runner.py` before re-raising, so the
unguarded loop raised AttributeError on google-adk >= 2.6.3. This is the guard that
`test_agent_with_tool_calculation` in the same file and google-adk's own
`runners.py` already use; it needs no cassette re-recording.
@datadog-official

datadog-official Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog auto-retried 5 jobs - 5 passed on retry View in Datadog

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 92ce7d0 | Docs | View more details | Give us feedback!

@cit-pr-commenter-54b7da

This comment was marked as resolved.

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 25, 2026

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 25, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 250 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 250 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=135)
ddtrace.appsec._listeners -×-> ddtrace.trace  (product:appsec -> product:tracing, score=133)
ddtrace.llmobs._integrations.bedrock_agents -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)
ddtrace.llmobs._integrations.openai_agents -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)
ddtrace.internal.openfeature._span_enrichment -×-> ddtrace.trace  (product:openfeature -> product:tracing, score=133)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@pr-commenter

pr-commenter Bot commented Aug 25, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-26 18:08:36

Comparing candidate commit 92ce7d0 in PR branch grace/mlos-864-google-adk-2.7-compat with baseline commit 0d9449f in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 8 performance regressions! Performance is the same for 578 metrics, 10 unstable metrics, 2 known flaky benchmarks, 16 flaky benchmarks without significant changes.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:httppropagationextract-wsgi_valid_headers_all

  • 🟥 execution_time [+407.797ns; +452.094ns] or [+7.525%; +8.343%]

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+1.964µs; +2.132µs] or [+10.404%; +11.293%]

scenario:iastaspects-add_aspect

  • 🟥 execution_time [+11.866µs; +15.824µs] or [+11.547%; +15.399%]

scenario:iastaspects-repr_aspect

  • 🟥 execution_time [+64.558µs; +70.360µs] or [+17.649%; +19.235%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+134.795µs; +141.565µs] or [+33.735%; +35.429%]

scenario:iastaspectssplit-rsplit_aspect

  • 🟥 execution_time [+18.978µs; +22.521µs] or [+13.461%; +15.975%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+391.684ns; +446.461ns] or [+14.389%; +16.401%]

scenario:tracer-small

  • 🟥 execution_time [+28.850µs; +30.830µs] or [+8.604%; +9.195%]

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:coreapiscenario-context_with_data_listeners

  • unstable execution_time [-746.983ns; +733.824ns] or [-6.764%; +6.645%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-35.660ns; +30.943ns] or [-5.829%; +5.058%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1705.945ns; +1628.982ns] or [-9.939%; +9.490%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1117.322ns; +1357.448ns] or [-8.670%; +10.533%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-337.630ns; +317.059ns] or [-9.167%; +8.608%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-259.465ns; +246.588ns] or [-8.898%; +8.456%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-71.784ns; +73.591ns] or [-6.348%; +6.508%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3883.294ns; +3969.304ns] or [-9.681%; +9.896%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-836.872ns; +690.876ns] or [-10.391%; +8.578%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-43.100µs; +39.778µs] or [-6.888%; +6.357%]

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

scenario:iastaspects-title_noaspect

  • 🟥 execution_time [+21.626µs; +26.303µs] or [+10.967%; +13.338%]

scenario:span-start

  • 🟥 execution_time [+1.597ms; +1.740ms] or [+10.629%; +11.579%]

Known flaky benchmarks without significant changes:

  • scenario:errortrackingflasksqli-baseline
  • scenario:flasksimple-iast-get
  • scenario:iastaspects-casefold_aspect
  • scenario:iastaspects-casefold_noaspect
  • scenario:iastaspects-index_aspect
  • scenario:iastaspects-ljust_noaspect
  • scenario:iastaspects-lower_aspect
  • scenario:iastaspects-replace_aspect
  • scenario:iastaspects-swapcase_aspect
  • scenario:iastaspects-translate_aspect
  • scenario:iastaspects-translate_noaspect
  • scenario:iastaspects-upper_noaspect
  • scenario:packagespackageforrootmodulemapping-cache_off
  • scenario:packagespackageforrootmodulemapping-cache_on
  • scenario:sethttpmeta-all-enabled
  • scenario:telemetryaddmetric-record-100-metrics

@github-actions

Copy link
Copy Markdown
Contributor

This change is marked for backport to 4.14 and it does not conflict with that branch.
The command used to test backporting was

git fetch origin 4.14 && git checkout origin/4.14 && git checkout -b backport-19848-to-4.14 && git cherry-pick -x --mainline 1 2c264c0ce850476f3b169625c4995ff82e541fc1

Follow-up review feedback on the streaming tool wrappers.

Guard the `aclose()` added in the previous commit. If closing the wrapped generator
raised, it both masked the exception the stream was already raising and skipped the
tagging and `span.finish()` below it, stranding the span.

Build the stream before starting the span in `_traced_functions_call_tool_live`.
`integration.trace()` activates by default, so a span created before a call that
raises is never finished and reparents everything after it. Evaluating `wrapped()`
first means there is no such window rather than a caught one.

Fold that wrapper onto `_traced_tool_stream` instead of repeating the accumulate,
cap, close, tag and finish sequence. The `with` form is dropped because
`_traced_tool_stream` finishes the span itself, and `Span.__exit__` only sets
exception info and finishes. This also puts the pre-2.7 path on the implementation
the 2.7+ tests exercise, since the streaming tests are gated above 2.7.0.

Move MAX_STREAMED_TOOL_CHUNKS above its use sites.

Consolidate the release note onto the two symptoms a customer can observe: the
failure to start, and streaming tool spans recording no output. The removed entries
described internal functions and paths unreachable behind the startup failure.
@heyitsgrace996 heyitsgrace996 changed the title fix(google_adk): support google-adk 2.7.0+ tool dispatch changes fix(google_adk): support google-adk 2.7.0+ tool dispatch changes [MLOS-864] Aug 26, 2026
The cap and its omitted-items marker were added earlier in this branch and
never shipped. Nothing else in the repo bounds what it retains from a stream,
including `_traced_agent_run_async` in this file, so match that and keep every
streamed item.
@heyitsgrace996
heyitsgrace996 marked this pull request as ready for review August 26, 2026 14:13
@heyitsgrace996
heyitsgrace996 requested review from a team as code owners August 26, 2026 14:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 351d9a8c64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +111 to +113
try:
result = await wrapped(*args, **kwargs)
except Exception:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Finish the tool span when its task is cancelled

When an ADK tool task is cancelled while wrapped is awaited—for example because of a request timeout or client disconnect—asyncio.CancelledError inherits from BaseException on every supported Python version, so this handler is skipped. Unlike the previous with integration.trace(...) implementation, the span is never tagged or finished and remains active, which can prevent the trace from being emitted and incorrectly parent later spans if execution continues; finish the span in a finally block or explicitly handle cancellation before re-raising it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed - cleanup moved into a finally block with a hand-off flag

Comment on lines +180 to +181
async for item in _traced_tool_stream(agen, span, integration, args, kwargs):
yield item

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close the delegated live stream when its consumer exits early

On google-adk versions before 2.7, if a consumer stops a live tool stream early and closes _traced_functions_call_tool_live, Python does not automatically close the inner async generator used by this async for. Consequently _traced_tool_stream remains suspended at its yield, its finally block does not run, and the tool span remains active and unfinished; explicitly close the delegated iterator from an outer finally block (or use an equivalent async-closing construct).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - the stream is now closed in an outer finally

@brettlangdon brettlangdon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/question on the release note, otherwise release note + riotfile.py changes lgtm

…eardown

Address two P1 review findings on the streaming tool wrappers.

`asyncio.CancelledError` is a `BaseException`, so `except Exception` skipped it and
the span was neither tagged nor finished. Because `integration.trace()` activates
the span, it also stayed active and reparented everything after it. Move the cleanup
into a `finally` guarded by an explicit hand-off flag, so every exit finishes the
span except the streaming one, which `_traced_tool_stream` owns. This is the shape
`vertexai` already uses. Cancellation is not recorded as a tool error, matching
`django` and `langgraph`, which treat it as ordinary teardown.

Close the stream `_traced_functions_call_tool_live` delegates to. Abandoning the
outer generator does not close the inner one, so its cleanup waited on async
generator finalization: the span took four event loop iterations to finish where
`main` took two. Closing it explicitly restores parity.

Also address review feedback on the release note: shorten the first entry and state
the affected version range on both, since the two fixes apply to opposite sides of
2.7.0 and read as one fix without it.
Comment on lines +119 to +121
# open so the streamed items are tagged instead of the generator object.
stream = _traced_tool_stream(result, span, integration, args, kwargs)
stream_handed_off = True

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streaming tool span is leaked when the returned generator is never started

stream_handed_off = True skips the finally, so the span is only finished if _traced_tool_stream actually runs. A never-started async generator never runs its finally, neither on aclose() nor on GC, so span.finish() is never called. The span is left unfinished, unflushed, and still activated.

Two reachable paths in google-adk 2.7.1:

  1. Non-live dispatch. _execute_single_function_call_async awaits __call_tool_async at functions.py:627 with no isasyncgen check (that check only exists at functions.py:1105, on the live path). An async generator FunctionTool result goes straight into _normalize_tool_result as {'result': <agen>} and is never iterated or closed.
  2. Cancellation landing between the await and the first async for.

Pre-diff this could not happen, since with integration.trace(...) closed the span on every exit path.

Fix options, roughly in order of preference:

A. Backstop the handoff with a finalizer. Keeps the current shape and covers both paths:

stream = _traced_tool_stream(result, span, integration, args, kwargs)
weakref.finalize(stream, _finish_unstarted_span, span, integration, args, kwargs)

where _finish_unstarted_span returns early unless inspect.getasyncgenstate(...) was AGEN_CREATED. Duration is meaningless in that case, but the span is no longer lost. Requires _traced_tool_stream to mark the span as claimed on first iteration so the two do not race.

B. Only hand off on a stream the caller will consume. Check inspect.getasyncgenstate(result) is not AGEN_CREATED at tag time, or gate the async generator branch so the non-live call site falls through to the existing finally and tags the generator object as it did before this PR. Simpler, but path 2 still leaks.

C. Return a proxy instead of a new generator. Wrap result in a wrapt.ObjectProxy that finishes the span on exhaustion, on aclose(), and on finalization. More code, but it also preserves the wrapped stream's identity and attributes, which the current substitution drops.

Worth a regression test for the abandoned-stream case: the three new streaming tests all consume or aclose() the result, so none of them would catch this.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants