Skip to content

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

Description

@antsok

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

Metadata

Metadata

Labels

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

Type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions