Skip to content

fix(apps): stop orphaned function_call from permanently blocking compaction - #7237

Closed
chelsealong wants to merge 1 commit into
google:mainfrom
chelsealong:fix-7236-compaction-orphan-permanent-block
Closed

chelsealong wants to merge 1 commit into
google:mainfrom
chelsealong:fix-7236-compaction-orphan-permanent-block

Conversation

@chelsealong

Copy link
Copy Markdown
Contributor

Fixes #7236

Problem

_longest_self_contained_prefix (in src/google/adk/apps/compaction.py)
walks a compaction window keeping a set of "open" obligations keyed by call
id (a function_call opens an id, the matching function_response closes
it), and only records a cut point where that set is completely empty.

Once the first compaction has happened, an orphaned function_call (a
tool call whose function_response never arrives, e.g. the process
handling the tool died mid-call) becomes the first candidate event of
every subsequent compaction window. Its id can never close, so the "open"
set can never empty again, safe_length freezes at 0 forever, and both
_events_to_compact_for_token_threshold and the sliding-window path (which
share this helper) silently stop compacting for the rest of the session,
even as the prompt keeps growing past the configured threshold. There is no
log or error.

Fix

When the strict left-to-right pass finds no balanced point at all, retry
once while ignoring call ids that _provably_dead_call_ids proves can
never be fulfilled:

  • no function_response with the same id exists anywhere in the
    session
    (not just the compaction window -- a response that lives past
    the window's end still protects its call, which is what the original
    guard from Fix/5602 longrunningfunctiontool resume #5832 protects against splitting),
  • the id isn't marked long-running (Event.long_running_tool_ids) or tied
    to a pending tool-confirmation/auth request (these are expected to stay
    open), and
  • the call wasn't opened by the newest invocation in the session, which may
    still be in flight.

If the strict pass already finds a non-empty prefix, behavior is unchanged.
The retry only fires on the stall case this issue describes, so healthy
events after a dead orphaned call can be compacted again instead of the
block persisting for the lifetime of the session.

Testing plan

Added
test_sliding_window_orphaned_call_after_prior_compaction_does_not_block_forever
in tests/unittests/apps/test_compaction.py, reproducing the reported
scenario: a prior compaction, followed by an orphaned function_call with
no matching response anywhere in the session, followed by a further
invocation. Verified the test fails without the fix (reverted only the
source file) and passes with it:

$ git checkout HEAD~1 -- src/google/adk/apps/compaction.py
$ python -m pytest tests/unittests/apps/test_compaction.py -k orphaned_call_after_prior_compaction -q
...
AssertionError: Expected 'append_event' to have been called once. Called 0 times.
1 failed, 55 deselected in 1.15s

$ git checkout HEAD -- src/google/adk/apps/compaction.py
$ python -m pytest tests/unittests/apps/test_compaction.py -q
........................................................                 [100%]
56 passed, 4 warnings in 1.27s

Also ran the full tests/unittests/apps/ and
tests/unittests/flows/llm_flows/context/ suites (excluding two files that
fail to collect in this environment for unrelated reasons -- missing
optional sqlalchemy/openai extras, confirmed by importing them directly
on unmodified main):

$ python -m pytest tests/unittests/apps/ --ignore=tests/unittests/apps/test_compaction_runner_e2e.py \
    tests/unittests/flows/llm_flows/context/ --ignore=tests/unittests/flows/llm_flows/context/test_contents.py -q
209 passed, 1 xfailed, 5 warnings in 1.96s

Ran formatting/lint as configured in pyproject.toml:

$ python -m isort --check-only src/google/adk/apps/compaction.py tests/unittests/apps/test_compaction.py
$ python -m pyink --check --fast src/google/adk/apps/compaction.py tests/unittests/apps/test_compaction.py
All done! 2 files would be left unchanged.
$ python -m pylint --rcfile=pylintrc src/google/adk/apps/compaction.py
Your code has been rated at 9.93/10 (only pre-existing, unrelated warnings)

AI assistance disclosure

This PR was written with the assistance of an AI coding agent (Claude Code),
following the approach the issue reporter proposed. All changes were
reviewed and tested before submission.

…action

_longest_self_contained_prefix walks a compaction window keeping a set of
open obligations keyed by call id, and only records a cut point where that
set is empty. Once the first compaction has happened, an orphaned
function_call with no matching function_response anywhere in the session
(e.g. the process handling the tool died) becomes the first candidate
event of every following window, so the set can never empty again and
compaction silently stops forever.

When the strict pass finds no balanced point, retry once while ignoring
call ids that are provably dead: no response exists anywhere in the
session (not just the window, to avoid splitting a pair whose response
lives past the window boundary), the call isn't long-running or a pending
auth/tool-confirmation request, and it wasn't opened by the newest
invocation in the session (which may still be in flight). Everything else
keeps blocking exactly as before.

Fixes google#7236
@adk-bot

adk-bot commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Thank you @chelsealong for your contribution! 🎉

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

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

@adk-bot adk-bot added the merged [Status] This PR is merged label Oct 2, 2026
@adk-bot adk-bot closed this Oct 2, 2026
copybara-service Bot pushed a commit that referenced this pull request Oct 2, 2026
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.

Orphaned function_call permanently and silently disables context compaction (once triggered)

3 participants