-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Python: fix(python): return call-level compaction summaries in agent responses #8117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e94bbfd
d58f77f
899bf0e
72b1e5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -445,6 +445,55 @@ def source_dependencies(summary_id: str) -> set[str]: | |
| source_messages.insert(insertion_index, message) | ||
|
|
||
|
|
||
| def _summarized_by_summary_id(message: Message) -> str | None: | ||
| annotation = _read_group_annotation_raw(message) | ||
| if annotation is None: | ||
| return None | ||
| summary_id = annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) | ||
| return summary_id if isinstance(summary_id, str) else None | ||
|
|
||
|
|
||
| def _merge_compaction_summaries_into_transcript( # pyright: ignore[reportUnusedFunction] | ||
| transcript_messages: list[Message], | ||
| working_messages: Sequence[Message], | ||
| ) -> None: | ||
| """Merge compaction summaries of transcript messages back into the transcript. | ||
|
|
||
| Compaction annotates shared Message objects with exclusion flags but inserts summary | ||
| messages only into the working (model-input) list. A caller assembling its returned | ||
| transcript from a separate list keeps only the exclusion flags, so the summaries must | ||
| be copied back or the response (and persisted history loaded with ``skip_excluded``) | ||
| silently loses the summarized content (issue #8099). Each summary is inserted before | ||
| the first transcript message whose back-link matches the summary id, which also works | ||
| when summarized messages carry no ``message_id``. Nested summaries resolve naturally: | ||
| an inner summary is merged first (it precedes the outer one in the working list), and | ||
| the outer summary then matches the inner one through its own back-link. Summaries of | ||
| caller-owned input messages stay out — those sources are not part of the returned | ||
| transcript, so persisting such summaries would duplicate content. | ||
| """ | ||
| transcript_identities = {id(message) for message in transcript_messages} | ||
| for message in working_messages: | ||
| if id(message) in transcript_identities: | ||
| continue | ||
| annotation = _read_group_annotation_raw(message) | ||
| if annotation is None or not message.message_id: | ||
| continue | ||
| if SUMMARY_OF_MESSAGE_IDS_KEY not in annotation and SUMMARY_OF_GROUP_IDS_KEY not in annotation: | ||
| continue | ||
| insertion_index = next( | ||
| ( | ||
| index | ||
| for index, transcript_message in enumerate(transcript_messages) | ||
| if _summarized_by_summary_id(transcript_message) == message.message_id | ||
| ), | ||
| None, | ||
| ) | ||
| if insertion_index is None: | ||
| continue | ||
| transcript_messages.insert(insertion_index, message) | ||
|
Comment on lines
+483
to
+493
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to share the existing summary-reconciliation logic here? |
||
| transcript_identities.add(id(message)) | ||
|
|
||
|
|
||
| def _write_group_annotation( | ||
| message: Message, | ||
| *, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3250,6 +3250,7 @@ async def _get_response_with_function_invocation( | |
| max_errors: int, | ||
| ) -> ChatResponse[Any]: | ||
| """Run the non-streaming function invocation loop.""" | ||
| from ._compaction import _merge_compaction_summaries_into_transcript # pyright: ignore[reportPrivateUsage] | ||
| from ._middleware import MiddlewareFailure | ||
| from ._types import ChatResponse, add_usage_details | ||
|
|
||
|
|
@@ -3314,6 +3315,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non | |
| client_kwargs=request_kwargs, | ||
| ), | ||
| ) | ||
| # Compaction inserts summaries only into prepared_messages while its exclusion | ||
| # flags land on Message objects shared with the transcript; copy the summaries | ||
| # back before the transcript is prepended to a terminal response (issue #8099). | ||
| # The merge is unconditional: compaction may also come from the inner client's | ||
| # own default strategy, which this layer does not see as a parameter. | ||
| _merge_compaction_summaries_into_transcript(function_call_messages, prepared_messages) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we make sure this merged transcript is included on every terminal path? When the next model turn produces an approval request, user-input request, or middleware termination, |
||
| if options.get("tool_choice") == "none" and _function_call_limit_reached( | ||
| total_function_calls, max_function_calls | ||
| ): | ||
|
|
@@ -3386,6 +3393,9 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non | |
| client_kwargs=request_kwargs, | ||
| ), | ||
| ) | ||
| # See the Phase 2 merge: the final no-tools call can compact further groups, so its | ||
| # summaries must also reach the returned transcript. | ||
| _merge_compaction_summaries_into_transcript(function_call_messages, prepared_messages) | ||
| _ensure_function_invocation_limit_fallback_response(response) | ||
| aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) | ||
| self._update_function_invocation_continuation_state( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens when compaction creates a summary of an earlier summary?
working_messagesplaces the outer summary before the inner one, so this pass skips the outer summary before its anchor has been inserted. The inner summary is then inserted while still excluded, andskip_excluded=Trueremoves it along with the original tool group, recreating the silent data loss this PR is meant to prevent. Could this resolve the summary dependencies to a fixed point, like_reconcile_compaction_summariesdoes?