Skip to content

Python: [Bug]: compaction orphans function_call/result pairs when declaration and result are non-adjacent (group pairing is adjacency-only) #7212

Closed

Description

Description

Summary

group_messages (_compaction.py:129-252) pairs an assistant function_call declaration with its role="tool" result into one tool_call group only when they are adjacent (the declaration absorbs immediately following reasoning/tool messages, :208-225). A standalone run of role="tool" messages elsewhere in the list forms its own tool_call group (:227-240) with no link to the declaring group.

Every group-excluding compaction strategy then operates on these groups independently:

  • TruncationStrategy excludes oldest-first, protecting only system groups (:700-727);
  • SelectiveToolCallCompactionStrategy keeps only the newest N tool_call groups (:802-826);
  • SummarizationStrategy keeps the newest ~target_count groups and excludes the older ones (:1069-1076, :1131-1133);
  • TokenBudgetComposedStrategy's fallback loops exclude whole groups oldest-first (:1189-1209), and ContextWindowCompactionStrategy composes the above (:1443-1460).

When the declaration and its result live in different groups, any of these can exclude one half and keep the other. apply_compaction (_clients.py:390) then hands the model a projection containing a function_call_output with no matching function_call (or vice versa). Strict providers reject it:

openai.BadRequestError: Error code: 400 - {'error': {'message':
'No tool call found for function call output with call_id call_bMSZmmDhW9ujBv7yCQ22iWcN.',
'type': 'invalid_request_error', 'param': 'input', 'code': None}}

When non-adjacency happens in practice

The framework's own human-in-the-loop approval flow produces it. With a harness/AG-UI agent, a function_approval_request interrupt ends the run after the declaration is already part of the persisted assistant message; on resume, the approved call executes and its result message is appended after the approval/resume traffic, no longer adjacent to the declaration. In the next model call's rebuilt message list (per-service-call persistence), the pair sits in two separate groups.

Observed live (Azure Foundry, Responses API /openai/v1/responses, gpt-5.4-mini): a long-running approval-heavy conversation crossed the compaction trigger, the harness before-strategy summarization excluded the declaration's group while the fresh executed result stayed included, and the very next model call failed with the 400 above — killing the run mid-stream.

Minimal repro (library-level, no app code, no network)

import asyncio, warnings
warnings.filterwarnings("ignore")
from agent_framework import CharacterEstimatorTokenizer, Content, Message, TruncationStrategy
from agent_framework._compaction import (
    annotate_message_groups, annotate_token_counts, included_messages,
)

messages = [
    Message(role="user", contents=[Content.from_text(text="original request " + "x" * 1600)]),
    Message(role="assistant", contents=[Content.from_function_call(call_id="c1", name="lookup", arguments={})]),
    # any message between declaration and result breaks adjacency (here: assistant text;
    # in the approval flow it is the interrupt/resume traffic)
    Message(role="assistant", contents=[Content.from_text(text="let me check that " + "y" * 1600)]),
    Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="the lookup result")]),
    Message(role="user", contents=[Content.from_text(text="and what about pricing? " + "z" * 400)]),
]
tok = CharacterEstimatorTokenizer()
annotate_message_groups(messages)          # declaration -> group_msg_1, result -> group_msg_3 (SEPARATE)
annotate_token_counts(messages, tokenizer=tok)

asyncio.run(TruncationStrategy(max_n=600, compact_to=520, tokenizer=tok)(messages))

proj = included_messages(messages)
declared = {c.call_id for m in proj for c in m.contents or [] if c.type == "function_call"}
results  = {c.call_id for m in proj for c in m.contents or [] if c.type == "function_result"}
print([m.role for m in proj])              # ['tool', 'user']
print("ORPHANED:", results - declared)     # {'c1'}  <- provider 400

Output on rev b3d523ee:

['tool', 'user']
ORPHANED: {'c1'}

The same layout orphans under SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=1) (keeps the result group, excludes the declaration group) and under SummarizationStrategy (newest-groups retention) — it is the grouping model, not one strategy.

Impact

  • Any provider that validates call/output pairing rejects the projection: OpenAI Responses API (No tool call found for function call output), OpenAI chat-completions-compatible backends (tool message without a preceding assistant tool_calls), Mistral (invalid_request_message_order).
  • In the harness/AG-UI configuration the failure surfaces exactly when compaction is doing its job on a long approval-heavy conversation — the run dies mid-stream at the first model call after the split.
  • Compounded by the companion defect above (no RUN_ERROR emitted), the client hangs rather than showing an error.

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-core: 1.11.0, agent-framework-ag-ui: 1.0.0rc8

Python Version

No response

Additional Context

Proposed

Any of these would close the gap; (1) is the most structural:

  1. Pair by call_id, not adjacency. In group_messages/annotate_message_groups, when a standalone role="tool" run's call_ids match an earlier declaration's, assign it the declaring message's group id (or record a paired_group_id link) so group-level exclusion keeps or drops the pair atomically.
  2. Post-compaction pairing validation. In apply_compaction (_clients.py:390), before returning the projection, re-include the declaring message for any included result and the result messages for any included declaration (fixpoint; declarations with no result anywhere are the valid pending-calls state and stay untouched).
  3. Strategy-level unit exclusion. Have the group-excluding strategies resolve call_id linkage and treat declaration+result groups as one exclusion unit.

Workaround (application-side, today)

A CompactionStrategy wrapper that runs proposal (2) after the wrapped strategy: scan the projection for orphans and set_excluded(…, excluded=False) the missing halves to a fixpoint. ~60 lines; message-level re-inclusion so no unrelated bulk returns. (Ours: ats.compaction.ToolPairingGuardStrategy, wrapped around both our composed pipeline and stock ContextWindowCompactionStrategy.)

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jul 20, 2026
  2. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Jul 20, 2026
  3. github-actions commented on Jul 20, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    Repro: group_messages() in python/packages/core/agent_framework/_compaction.py:208-240 uses adjacency-only pairing — when a function_call declaration (assistant message with function_call content) is separated from its role="tool" result by any intervening message, they land in separate groups (lines 208-225 absorb only immediately-following tool messages; lines 227-240 create an independent group for orphaned tool messages). Minimal repro: create messages [user, assistant(function_call call_id=c1), assistant(text), tool(function_result call_id=c1), user], call annotate_message_groups + annotate_token_counts, then TruncationStrategy(max_n=600, compact_to=520, tokenizer=CharacterEstimatorTokenizer()) — included_messages() will contain a function_result for c1 with no matching function_call.

    • Failing test: python/packages/core/tests/core/test_compaction_orphan_repro.py
    • Files examined: python/packages/core/agent_framework/_compaction.py, python/packages/core/agent_framework/init.py, python/packages/core/pyproject.toml
    • Tests run: tests/core/test_compaction_orphan_repro.py
    • Reported version: 1.11.0
    • Current version: 1.11.0
  4. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

pythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions