FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(sessions): stamp Redis sessions with the event timestamp by feiiiiii5 · Pull Request #7293 · google/adk-python · GitHub

Repository navigation

fix(sessions): stamp Redis sessions with the event timestamp - #7293

Closed
feiiiiii5 wants to merge 1 commit into
google:mainfrom
feiiiiii5:fix/redis-append-event-timestamp
Closed

feiiiiii5 wants to merge 1 commit into
google:mainfrom
feiiiiii5:fix/redis-append-event-timestamp

Conversation

Copy link
Copy Markdown
Contributor

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):

Testing Plan

Problem:

RedisSessionService.append_event() wrote time.time() into session.last_update_time, so a session's activity time was the moment the event was appended rather than the moment the event happened. The other three backends store event.timestamp: InMemorySessionService at in_memory_session_service.py:357-364, SqliteSessionService at sqlite_session_service.py:433 and :515-519, DatabaseSessionService at database_session_service.py:976-1010.

last_update_time is the sort key list_sessions documents as "oldest first" (base_session_service.py:102-118), and the Redis backend sorts on it (_redis_session_service.py:316-318), so the divergence reorders a user's session list whenever an event predates its append. ADK re-delivers the same event object to a second session reference, and such an event carries its original timestamp, so two sessions that received the identical event report different activity times.

Solution:

Store event.timestamp, which is what the contract harness already specifies. The repository registers this as a known divergence in tests/unittests/sessions/_conformance.py, and that file's docstring states a divergence "becomes a defect anyone can pick up, and whoever fixes the backend has to delete the entry in the same change" (_conformance.py:15-31). Because the marker is xfail(strict=True), a test that starts passing while its entry is still registered fails as XPASS, so the source fix and the registry deletion ship together:

    # The event's own timestamp, matching the other three backends. The wall
    # clock is wrong here: it records the append, not when the event happened.
    session.last_update_time = event.timestamp

import time stays: create_session still uses time.time() for a brand-new session, correct there because no event exists yet. create_session, get_session and the other backends are untouched.

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Added test_append_event_stamps_session_with_event_timestamp to tests/unittests/integrations/redis/test_redis_session_service.py. It pins time to a value 100 seconds past the event's own timestamp, so the previous implementation cannot agree by coincidence, and asserts both the caller's session and the reloaded session carry the event's timestamp.

Command output

Base at 9625b06c9a1be6b9ecd85225657690ae5c0e9d3e, CPython 3.14, new test without the source fix:

FAILED tests/unittests/integrations/redis/test_redis_session_service.py::test_append_event_stamps_session_with_event_timestamp
E   assert 1790353307.6994162 == 1790353207.6994162 ± 1.0e-06
1 failed

Same base, source fix without the registry deletion, which is what strict=True forbids:

XFAIL .../test_session_last_update_time_updates_on_event[redis] - Redis stamps the session with the wall clock instead of the appended event's timestamp.
5 passed, 1 xfailed

With both halves, all six backends of the contract test plus the new one pass:

PASSED .../test_redis_session_service.py::test_append_event_stamps_session_with_event_timestamp
PASSED .../test_session_last_update_time_updates_on_event[in_memory]
PASSED .../test_session_last_update_time_updates_on_event[in_memory_light_copy]
PASSED .../test_session_last_update_time_updates_on_event[database]
PASSED .../test_session_last_update_time_updates_on_event[sqlite]
PASSED .../test_session_last_update_time_updates_on_event[redis]
PASSED .../test_session_last_update_time_updates_on_event[per_agent_database]
7 passed, 2 warnings

The redis parameter moves from XFAIL to PASSED once the registry entry is gone. Full session and Redis suites:

$ pytest tests/unittests/sessions tests/unittests/integrations/redis -q
526 passed, 1 xfailed, 10 warnings in 13.49s

The one remaining xfail is the other Redis divergence named in #7292's scope note. Gates: pre-commit run --all-files passes for the three files in this diff; it also reports two pre-existing failures unrelated to it, trailing-whitespace on docs/guides/integrations/{e2b,gcs}/index.md and check-new-py-prefix on src/google/adk/memory/_sqlite_memory_service.py, both present on main at 9625b06. mypy reports no error in _redis_session_service.py.

Manual End-to-End (E2E) Tests:

Not applicable. The fixed path is covered by an offline unit test against FakeRedisAsync; it needs no external service, so there is no manual run to report.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end. (Not applicable; see above.)
  • Any dependent changes have been merged and published in downstream modules.

The second Redis entry in divergences is a separate defect: append_event writes the session key unconditionally, so appending to a deleted, expired or never-stored session recreates it instead of raising SessionNotFoundError. It overlaps open PR #7140, which owns those lines, so it is deliberately left registered here.

Additional context

A session persisted through one backend and read through another (src/google/adk/sessions/migration/) shows the Redis session jumping forward by however long an event sat in a queue, where SQLite does not. That is the migration-path form of the same defect.

RedisSessionService.append_event stored time.time() in
session.last_update_time, so a session's activity time was the moment
the event was appended rather than the moment the event happened. The
three other session backends all store event.timestamp.

last_update_time is the sort key list_sessions documents as "oldest
first", so the divergence reorders a user's session list whenever an
event predates its append. ADK re-delivers the same event object to a
second session reference, and that event carries its original
timestamp, so replayed events are the case where the two disagree most.

The shared session-service contract harness already recorded this as a
divergence with xfail(strict=True), so the entry is removed with the
fix: a test that starts passing while its entry is still registered
fails as XPASS(strict). The second Redis entry stays; it is a different
defect.

adk-bot commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Thank you @feiiiiii5 for your contribution! 🎉

Your changes have been successfully imported and merged via Copybara in commit def458b.

Closing this PR as the changes are now in the main branch.

adk-bot added the merged [Status] This PR is merged label Sep 29, 2026
adk-bot closed this Sep 29, 2026
caohy1988 added a commit to caohy1988/adk-python that referenced this pull request Sep 30, 2026
…uery analytics (#15)

* fix(plugins): record error-bearing tool results as TOOL_ERROR in BigQuery 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>

* fix: stamp Redis sessions with the event timestamp

Merge google#7293

Fixes google#7292

PiperOrigin-RevId: 990023711

* fix(plugins): classify BigQuery analytics tool results fail-closed

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>

* fix(plugins): read BigQuery analytics tool results field by field

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>

* fix(plugins): keep BigQuery analytics outcome rows for unserializable 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>

* feat: honor tool_thread_pool_config for sync tools outside live mode

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

* fix(plugins): release awaitables a BigQuery analytics classifier returns

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>

* docs(plugins): say what cancelling a returned task runs

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>

* fix: drop unpairable trailing FRs in rearrange

Merge google#6752

Fixes google#6751

PiperOrigin-RevId: 990390762

* fix: replay a parallel tool call that never ran when a sibling answered

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

* chore(scripts): detect added files through git only

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

* fix(tools): surface NodeTool failures to on_tool_error and return dict 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

* feat(mcp): add an opt-in modern-protocol connect path for MCP SDK 2.x

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

* feat(workflow): propagate skip_summarization from node tools to tool 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

* fix(plugins): keep Pydantic from quoting tool results in warnings

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>

* fix(eval): skip content-less events when mapping Vertex multi-turn turns

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

* fix: detect a dead MCP session whose transport sits behind a dispatcher

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

* fix: only apply --avatar_config to live sessions requesting video

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

* fix: follow redirects when downloading skills in GcpSkillRegistry

Merge google#6824

PiperOrigin-RevId: 990507320

* fix(plugins): leave a result model's own model_dump uncalled

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>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: feiiiiii5 <feiiiiii5@users.noreply.github.com>
Co-authored-by: George Weale <gweale@google.com>
Co-authored-by: Aarav Mittal <137450929+a2105z@users.noreply.github.com>
Co-authored-by: Xuan Yang <xygoogle@google.com>
Co-authored-by: Shangjie Chen <deanchen@google.com>
Co-authored-by: Kathy Wu <wukathy@google.com>
Co-authored-by: Liang Wu <wuliang@google.com>
Co-authored-by: Shinobu Aoki <aoki@codebee.jp>
caohy1988 added a commit to caohy1988/adk-python that referenced this pull request Sep 30, 2026
…ytics error_message (#14)

* fix(plugins): record content_formatter failure class in BigQuery rows

When BigQueryLoggerConfig.content_formatter raised, or returned a type the
parser cannot store, BigQueryAgentAnalyticsPlugin wrote the
[FORMATTER_FAILED] sentinel, logged a constant warning, and left the
row's error_message NULL, so the developer had no signal about why.

Set error_message to a fixed-shape description that names only a class,
for example "content_formatter raised ImportError" or "content_formatter
returned unsupported type tuple", and never the exception message, args,
or traceback, which can embed the content the formatter was protecting.
Because type(name, ...) can mint a class named after that content, a name
is used only when code chose it: a static type compiled into C, or a
class bound under that name in its imported module. Any other class is
described by its nearest such ancestor, for example "content_formatter
raised <subclass of ValueError>". An event that already carries an
error_message, such as a TOOL_ERROR, keeps it first, followed by "; " and
the formatter failure.

Add BigQueryLoggerConfig.debug_content_formatter_errors (default False),
which attaches the traceback to the local formatter-failure warning for
debugging. The traceback is never written to BigQuery, and the docstring
warns that the process's log handlers can forward it, and the content it
embeds, elsewhere.

The fail-closed contract is unchanged: the sentinel content, the constant
log text by default, and the formatter_failed drop counter.

Refs: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485 (item A)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: stamp Redis sessions with the event timestamp

Merge google#7293

Fixes google#7292

PiperOrigin-RevId: 990023711

* fix(plugins): stop formatter diagnostics leaking names or dropping rows

Review of the previous commit found three ways the new content_formatter
diagnostics could still leak payload text or drop a row. This closes them.

Trusted labels only. A class created at runtime can be named after the
content a formatter was protecting and then bound into its module, so "the
class is bound under its name in its module" proved nothing, and such a
name reached error_message. error_message now names a class only by a label
that no runtime data can have chosen: the compiled name of a static C type,
or a fixed string for an allowlisted class matched by identity
(LlmRequest, types.Content, types.Part, pydantic BaseModel, and
google.api_core GoogleAPICallError). Every other class, including every
Python-defined library exception and every class the developer defines, is
described by its nearest trusted ancestor, for example "content_formatter
raised <subclass of ValueError>". Trade-off: fewer exact names, for example
json.JSONDecodeError now reads "<subclass of ValueError>" and a
google.api_core NotFound "<subclass of GoogleAPICallError>";
debug_content_formatter_errors shows the exact class locally.

No class code runs while diagnosing. Flags, name, and MRO are read through
type's own descriptors, and allowlist matching compares identity, so a
metaclass __getattribute__, __eq__, or __hash__ can no longer run inside
the fail-closed boundary. Such a hook could raise asyncio.CancelledError,
which escaped the boundary's `except Exception` and dropped the row.
Classification now cannot raise at all, so it needs no BaseException
handler, and a genuine KeyboardInterrupt or SystemExit delivered by a
signal still propagates. The exact-class check on formatter results also
compares identity now, for the same reason.

Best-effort debug traceback. With debug_content_formatter_errors, the
traceback is rendered to text once, inside the plugin, and appended to the
warning; handlers never receive the live exception, whose own code could
otherwise fail inside a stock StreamHandler and drop the row. A rendering
failure falls back to a constant placeholder. KeyboardInterrupt and
SystemExit still propagate, because a signal can deliver them at any
bytecode; any other BaseException raised while rendering, CancelledError
included, can only come from the exception being rendered, since rendering
never awaits. The warning is emitted after the except block, so a failing
handler's handleError cannot reach the formatter's exception through its
exception chain.

Tests pin each guard: registered payload-named classes and their
subclasses, a renamed allowlisted class, metaclass hooks raising
CancelledError, SystemExit, or RuntimeError on both failure paths,
unrenderable tracebacks under a stock StreamHandler, interrupt propagation,
and a failing log handler. Removing any guard fails at least one test.

Refs: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485 (item A)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(plugins): keep formatter diagnosis from ever dropping the row

Review of the previous commit showed three more ways that diagnosing a
content_formatter failure could escape and drop the row, even after the
hook-by-hook fixes:

- a rendering hook raising SystemExit or KeyboardInterrupt, which the
  previous policy re-raised as possibly genuine;
- type's own descriptors raising TypeError once a meta-metaclass drops
  `type` from the failed class's metaclass MRO, which disproved
  "classification cannot raise";
- a log handler or filter raising while the warning is emitted, which
  also misattributed an unsupported result as "raised RuntimeError".

Instead of patching one hook at a time, all of diagnosis (naming the
failed class, rendering the debug traceback, and emitting the warning)
now runs in _diagnose_formatter_failure, behind one boundary that
contains whatever is raised, of any type, and falls back to a constant
note. It runs only after the fail-closed state is settled: the
formatter's try/except now only records the failure and writes the
sentinel, and the failure is counted before diagnosis starts. Nothing
diagnosis does can reach the row, the sentinel, the counter, or the
callback's caller.

BaseException is contained deliberately. An interrupt raised by a hook,
handler, or filter cannot be told apart from one a signal handler
delivered, and letting it through would let the content under redaction
abort the agent run and lose the row. Diagnosis never awaits, so a real
asyncio cancellation is never swallowed; a signal that lands inside the
short window is absorbed, and the next is delivered normally. Interrupts
raised by the formatter call itself still propagate, as before.

Two inner guards remain where they keep the warning: labeling falls
back to "<unknown class>" when type's descriptors raise, and rendering
falls back to a placeholder. Diagnosis still runs after the except
block, so a failing handler's handleError cannot print the formatter's
exception.

A result whose __class__ merely claims to be str is now reported as an
unsupported result instead of "raised TypeError".

Tests: a property test injects RuntimeError, CancelledError,
KeyboardInterrupt, SystemExit, and another BaseException at each
diagnostic step (labeling, rendering, a handler, a filter) on both paths
and requires the sentinel row and its count. Also added: the
meta-metaclass regression on both paths, rendering hooks raising
interrupts, a stderr check that a failing handler never prints the
formatter's exception, and a guard that interrupts from the formatter
call still propagate. Removing the boundary or any guard fails a test.

Refs: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485 (item A)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(plugins): close remaining formatter paths that leak or drop rows

Review of the previous commit found three more paths, each also present
on main, by which a content_formatter failure could still leak payload
text or lose the row:

- The failure warning can be logged while the caller is handling an
  exception, as ADK is when it runs error callbacks. A log handler that
  fails prints the exception being handled through handleError, so a
  closed stream could print the caller's exception, or the formatter's
  when it re-raised that one, to stderr.
- The result check used isinstance(formatted, (dict, list)), which falls
  back to the object's own __class__. A returned object could raise
  CancelledError, SystemExit, or KeyboardInterrupt there and lose the
  row, or raise an ordinary exception that was then reported as the
  formatter having raised.
- A rejected coroutine was released unstarted, so Python warned
  "coroutine '<name>' was never awaited", and the formatter can set that
  name from the content.

Everything after the formatter call now runs in
_settle_formatter_outcome, behind the one boundary that already
contained diagnosis: judging the result, closing a rejected coroutine or
generator, naming the class, rendering the debug traceback, and logging.
The result is judged only by its real type, through issubclass(type(x),
...) and identity, which runs none of its code. A str, dict, or list
subclass is still accepted; an object whose __class__ merely claims to
be one is now a counted formatter failure rather than an
[UNSUPPORTED_OBJECT] row. A rejected coroutine or generator is closed
before release, which for an unstarted one runs no code and emits no
warning. The warning is emitted while a constant stand-in exception,
raised from None, is being handled, so a failing handler prints only
that. The formatter call itself still lets KeyboardInterrupt,
SystemExit, and CancelledError propagate.

Docs: the content_formatter docstring now says "raises an Exception",
explains judging by real type, and notes that interrupts from the call
propagate. The boundary's docstring states plainly that a genuine
signal delivered while it runs, including while a handler blocks, is
absorbed. The debug flag notes that a failing handler echoes the
rendered traceback to stderr.

Tests: a caller handling a payload exception with a closed handler
(re-raised and distinct); results whose __class__ property or
__getattribute__ raises each interrupt type or claims to be a dict; an
async formatter and a payload-named coroutine, with warnings recorded;
and two more steps in the injection property test, judging the result
and closing a rejected generator. Removing any guard fails a test.

Refs: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485 (item A)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(plugins): honor genuine interrupts and isolate all plugin logging

Review of the previous commit found two remaining gaps, and two guards
that no test pinned:

- A KeyboardInterrupt or SystemExit delivered while a content_formatter
  failure was being described was absorbed. A real SIGINT, or a SIGTERM
  whose handler calls sys.exit while a log handler blocks on I/O, left
  the row written but the process running, unaware of the signal.
- Only the formatter-failure warning ran while a stand-in exception was
  handled. Any other plugin warning, such as the parser's, could still
  have a failing handler print the exception the caller is handling, as
  ADK is when it runs error callbacks.

Interrupts are now sorted by the code that raised them, because Python
cannot tell a signal from a direct raise. The failed class's and the
rejected result's own code runs only while the debug traceback is
rendered and while a rejected generator is closed. Each of those steps
has its own guard, and whatever it raises, interrupts included, is
contained, so the content under redaction cannot end the agent run.
Everywhere else in the boundary only plugin code and the application's
log filters and handlers run, so a KeyboardInterrupt or SystemExit there
came from a signal or from the application. _settle_formatter_outcome
returns it as a new exception without text (a SystemExit keeps an int
exit code), and _log_event raises it once the row has been handed to
the writer. CancelledError stays contained: nothing in the boundary
awaits, so it cannot be a real cancellation. The trade-off is that a
signal landing while payload-controlled code runs is still absorbed.

The module logger's handle() now runs every record's filters and
handlers while a constant stand-in exception is handled, with its
__context__ cleared. So no plugin log call can print the caller's
exception through handleError, or through a handler that walks
__context__ and ignores __suppress_context__. Records are unchanged:
Logger._log resolves exc_info and the calling function before handle()
runs. This replaces the stand-in around the formatter warning alone.

The boundary's docstring now bounds its never-drop claim to failed or
rejected results. An admitted str, dict, or list subclass goes on to
the parser, whose boundary catches Exception only.

Tests:
- the injection property test expects an interrupt from any step but
  closing to be raised after the row, as a new exception, and checks
  that a rejected generator's cleanup never reaches the unraisable hook;
- a real SIGINT and SIGTERM delivered while the warning is emitted;
- a parse-failure warning with a closed handler, and with a handler
  that walks __context__, while the caller handles a payload exception;
- the plugin's error logs keep their own exc_info;
- supported results (None, a str subclass whose hooks raise, dict and
  list subclasses, and exact Content, Part, and LlmRequest) pass through
  unchanged.

Removing any new guard fails a test.

Refs: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485 (item A)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(plugins): note that closing a rejected coroutine is contained too

The boundary's docstring said the rejected result's own code runs only
while the debug traceback is rendered and while a rejected generator is
closed. The same guard also covers closing a rejected coroutine, which
runs the coroutine's own cleanup if it was started. Wording only.

Refs: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485 (item A)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: honor tool_thread_pool_config for sync tools outside live mode

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

* fix: drop unpairable trailing FRs in rearrange

Merge google#6752

Fixes google#6751

PiperOrigin-RevId: 990390762

* fix: replay a parallel tool call that never ran when a sibling answered

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

* chore(scripts): detect added files through git only

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

* fix(tools): surface NodeTool failures to on_tool_error and return dict 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

* feat(mcp): add an opt-in modern-protocol connect path for MCP SDK 2.x

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

* feat(workflow): propagate skip_summarization from node tools to tool 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

* fix(eval): skip content-less events when mapping Vertex multi-turn turns

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

* fix: detect a dead MCP session whose transport sits behind a dispatcher

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

* fix: only apply --avatar_config to live sessions requesting video

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

* fix: follow redirects when downloading skills in GcpSkillRegistry

Merge google#6824

PiperOrigin-RevId: 990507320

* test: cover api server auto create session

Merge google#6786

PiperOrigin-RevId: 990538126

* feat: propagate grounding metadata from MCP _meta

Merge google#7047

Fixes google#6081

PiperOrigin-RevId: 990543079

* docs: explain local lockfile needed by tox in adk-setup skill

Merge google#7318

PiperOrigin-RevId: 990578226

* feat: let BigQuery tools run where CMEK is required

Merge google#7232

Fixes google#3931

PiperOrigin-RevId: 990587308

* feat: add the model consult session context handover layer

When a small executor model escalates a hard step to a larger advisor
model, the advisor has to be told what has happened so far, or it answers
a question it does not understand. This adds the layer that turns a
session event log into contents the advisor can read:

- tool calls and tool results are flattened into plain text, so a model
  that was never given those tool declarations can still follow them;
- consecutive events from the same role are merged into one turn, which
  third-party advisor models require;
- the transcript is held to a character budget by keeping the head and
  the tail, marking the omitted middle, and trimming the newest turn when
  it does not fit on its own, so one long turn cannot silently multiply
  the cost of a consult;
- thoughts, media parts, per-part length and event count are each
  configurable, and the in-flight escalation call itself is skipped.

The layer is not wired into a tool yet; that follows in a later change.

Co-authored-by: Xuan Yang <xygoogle@google.com>
PiperOrigin-RevId: 990610529

* feat(tools): invoke advisor models without tools for model_consult

Invoke the advisor `BaseLlm` via `generate_content_async(req, stream=False)` with `config.tools = []` and `config.tool_config = None` so mid-task consultations return text guidance without entering a tool loop, while still emitting standard OpenTelemetry client duration and token usage metrics:
- `resolve_advisor_llm` and `resolve_thinking_level` for model and thinking-level normalization
- `call_advisor` with automatic fallback retry when `thinking_config` is rejected, `MAX_TOKENS` truncation and thought-exhaustion handling across `Gemini` and `LiteLlm`, and OpenTelemetry metric emission
- `AdvisorResult`, `AdvisorUsage`, and `AdvisorError`

Co-authored-by: Xuan Yang <xygoogle@google.com>
PiperOrigin-RevId: 990613834

* feat(tools): add ModelConsultTool with turn and session budgets

Add `ModelConsultTool`, an ADK tool that lets an executor `LlmAgent`
consult a stronger advisor model mid-generation without relinquishing
control of the conversation.

Key capabilities:
- Per-turn (`max_uses`) and session-wide (`session_max_uses`) consult
  budgets stored in session state (`temp:` and persistent state keys)
  with `has_remaining_budget` helper and per-session concurrency/delta
  coordination for parallel tool calls.
- Automatic context handover via `build_advisor_contents` (with both
  `'events'` and `'transcript'` handover modes on
  `ModelConsultContextConfig`).
- Forwards the executor's `static_instruction`, state-interpolated
  `canonical_instruction`, and non-self tool inventory (`canonical_tools`)
  to the advisor system instruction.
- Graceful degradation on budget exhaustion (`status='limit_reached'`),
  missing question (`status='invalid_request'`), and advisor runtime or
  timeout errors (`status='error'`).
- Top-level lazy export of `ModelConsultTool` from `google.adk.tools`.

Co-authored-by: Xuan Yang <xygoogle@google.com>
PiperOrigin-RevId: 990614577

* docs(tools): add ModelConsultTool developer guide and sample agent

Adds the developer guide and runnable order-support refund policy sample for `ModelConsultTool`:
- Adds the `ModelConsultTool` unit guide under `docs/guides/tools/model_consult/model_consult_tool/index.md` covering getting started, how mid-generation escalation works, `ModelConsultTool` and `ModelConsultContextConfig` options, custom context budgets, custom `BaseLlm` advisors, and limitations, and registers it in `docs/guides/README.md`.
- Adds a runnable e-commerce order support sample under `contributing/samples/tools/model_consult/` demonstrating `get_order`, `get_customer_profile`, and `issue_refund` paired with `ModelConsultTool`.

Co-authored-by: Xuan Yang <xygoogle@google.com>
PiperOrigin-RevId: 990615455

* fix: isolate and clean up single_turn LlmAgent node_input events

Merge google#7320

Fixes google#7227

PiperOrigin-RevId: 990632092

* refactor(tools): extract shared URL validation helpers into _url_validator.py

Extract the SSRF and URL target validation helpers (`_parse_request_target`,
`_is_blocked_hostname`, `_is_blocked_address`, `_embedded_ipv4`,
`_resolve_direct_addresses`, and `_reject_blocked_proxied_hostname`) into
`_url_validator.py`.

Previously, `ComputerUseToolset._wrap_navigate_with_url_validation`
lazily imported private helpers from `load_web_page` inside the wrapper
function to avoid pulling `requests` into the computer-use import path.
Moving the validation logic into `_url_validator.py` allows
`load_web_page`, `ComputerUseToolset`, and other outbound HTTP tools to
import the shared validation helpers directly at the module level
without extra runtime dependencies.

Test files that patched `load_web_page.socket` were updated after
private helpers were pruned from the `load_web_page` namespace.

Co-authored-by: Jason Zhang <jasoncz@google.com>
PiperOrigin-RevId: 990692502

* fix(plugins): chain re-raised interrupts to nothing, look up handle per call

Review of the previous commit found two small hardening gaps:

- The KeyboardInterrupt or SystemExit set aside while a content_formatter
  failure was described is raised after the row with `from None`. That
  only sets __suppress_context__: Python still records the exception the
  caller is handling, such as the error ADK passes to an error callback,
  as its __context__, so code that walks __context__ could reach it and
  its text. It is now raised while a context-free stand-in is handled,
  so that stand-in is its only link.
- The stand-in wrapper bound Logger.handle when the plugin was imported,
  so a later class-level patch of Logger.handle, as instrumentation and
  test fixtures apply, never saw this logger's records. The class's
  handle is now looked up on each call, still inside the stand-in.

The content_formatter docstring also states the downstream effect
plainly: the row's status is left as the event set it, usually 'OK', so
a query that counts any non-NULL error_message as an error, such as the
BigQuery Agent Analytics SDK's error predicate, counts a formatter
failure as an error. Behavior is unchanged.

Tests: the re-raised interrupt links to neither the caller's exception
nor anything chained to it (KeyboardInterrupt and SystemExit), and a
class-level patch of Logger.handle made after import sees the plugin's
formatter warning, handled while the stand-in is. Both fail before this
change.

Refs: GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#485 (item A)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: feiiiiii5 <feiiiiii5@users.noreply.github.com>
Co-authored-by: George Weale <gweale@google.com>
Co-authored-by: Aarav Mittal <137450929+a2105z@users.noreply.github.com>
Co-authored-by: Xuan Yang <xygoogle@google.com>
Co-authored-by: Shangjie Chen <deanchen@google.com>
Co-authored-by: Kathy Wu <wukathy@google.com>
Co-authored-by: Liang Wu <wuliang@google.com>
Co-authored-by: Shinobu Aoki <aoki@codebee.jp>
Co-authored-by: YASHcode-IIITV <yashrajsiwal@gmail.com>
Co-authored-by: Chaitanya Laxman <chaitanyalaxman118@gmail.com>
Co-authored-by: Arjun Ganesh <iarjunganesh@gmail.com>
Co-authored-by: Vishal Bulbule <vishal.bulbule@techtrapture.com>
Co-authored-by: Abhay Joshi <abhayjoshi@google.com>
Co-authored-by: Jason Zhang <jasoncz@google.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merged [Status] This PR is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Redis append_event stamps last_update_time with the wall clock, not the event timestamp

3 participants


Back | FazBrowse Home | New Git URL