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:
- 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.
- 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).
- 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.)
Description
Summary
group_messages(_compaction.py:129-252) pairs an assistantfunction_calldeclaration with itsrole="tool"result into onetool_callgroup only when they are adjacent (the declaration absorbs immediately following reasoning/tool messages,:208-225). A standalone run ofrole="tool"messages elsewhere in the list forms its owntool_callgroup (:227-240) with no link to the declaring group.Every group-excluding compaction strategy then operates on these groups independently:
TruncationStrategyexcludes oldest-first, protecting only system groups (:700-727);SelectiveToolCallCompactionStrategykeeps only the newest Ntool_callgroups (:802-826);SummarizationStrategykeeps the newest ~target_countgroups and excludes the older ones (:1069-1076,:1131-1133);TokenBudgetComposedStrategy's fallback loops exclude whole groups oldest-first (:1189-1209), andContextWindowCompactionStrategycomposes 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 afunction_call_outputwith no matchingfunction_call(or vice versa). Strict providers reject it: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_requestinterrupt 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)
Output on rev
b3d523ee:The same layout orphans under
SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=1)(keeps the result group, excludes the declaration group) and underSummarizationStrategy(newest-groups retention) — it is the grouping model, not one strategy.Impact
No tool call found for function call output), OpenAI chat-completions-compatible backends (toolmessage without a preceding assistanttool_calls), Mistral (invalid_request_message_order).RUN_ERRORemitted), 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:
call_id, not adjacency. Ingroup_messages/annotate_message_groups, when a standalonerole="tool"run'scall_ids match an earlier declaration's, assign it the declaring message's group id (or record apaired_group_idlink) so group-level exclusion keeps or drops the pair atomically.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).call_idlinkage and treat declaration+result groups as one exclusion unit.Workaround (application-side, today)
A
CompactionStrategywrapper that runs proposal (2) after the wrapped strategy: scan the projection for orphans andset_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 stockContextWindowCompactionStrategy.)