Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 79 additions & 3 deletions python/packages/core/agent_framework/_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@
}


def _deduplicate_origin_session_ids(origin_session_ids: Iterable[str]) -> list[str]:
"""Return origin session IDs in first-seen order without duplicates."""
unique_origin_session_ids: list[str] = []
seen_origin_session_ids: set[str] = set()
for origin_session_id in origin_session_ids:
if origin_session_id not in seen_origin_session_ids:
seen_origin_session_ids.add(origin_session_id)
unique_origin_session_ids.append(origin_session_id)
return unique_origin_session_ids


@runtime_checkable
class TokenizerProtocol(Protocol):
"""Protocol for token counters used by token-aware compaction strategies."""
Expand Down Expand Up @@ -1706,6 +1717,16 @@ async def before_run(
if not all_messages:
return

# Track each original message's source before compaction
source_by_id: dict[int, str] = {
id(message): sid for sid, msgs in context.context_messages.items() for message in msgs
}

# Track original messages by message_id for attribution preservation
message_by_message_id: dict[str, Message] = {
message.message_id: message for message in all_messages if message.message_id
}

await _run_compaction_strategy(
all_messages,
strategy=self.before_strategy,
Expand All @@ -1714,9 +1735,64 @@ async def before_run(
)

projected = project_included_messages(all_messages)
projected_set = {id(m) for m in projected}
for sid in list(context.context_messages):
context.context_messages[sid] = [m for m in context.context_messages[sid] if id(m) in projected_set]

# Rebuild provider message lists from the projected list, preserving source attribution
# and including new synthetic messages created by compaction strategies
rebuilt: dict[str, list[Message]] = {sid: [] for sid in context.context_messages}
fallback_sid = next(iter(rebuilt), self.source_id)
last_sid = fallback_sid
for message in projected:
# For new synthetic messages, use the last known source; for original messages, use their tracked source
sid = source_by_id.get(id(message), last_sid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we carry the summarized messages' _attribution.origin_session_ids onto each synthetic replacement before appending it here? With SummarizationStrategy, cross-session memory can be collapsed into a new assistant message whose _attribution is absent. CrossSessionObserver then skips it, so malicious originated content crosses into the primary model without the governance signal. Aggregating the original messages' source and origin IDs into the replacement would preserve the trust boundary while keeping the summary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this. I hadn't accounted for the provenance carried by _attribution.origin_session_ids on the messages being summarized. I'll update the implementation so synthetic replacement messages inherit the aggregated origin session IDs from the messages they replace, and I'll add regression coverage for the cross-session case.

if sid not in rebuilt:
# If the source was somehow removed during compaction, fall back to the last known source
sid = last_sid
rebuilt[sid].append(message)
last_sid = sid

context.context_messages.clear()
context.context_messages.update(rebuilt)

# Preserve attribution metadata on synthetic summary messages
# This ensures cross-session governance signals are not lost when content is summarized
for message in projected:
# Check if this is a synthetic summary message
annotation = _read_group_annotation_raw(message)
if annotation is None:
continue

summarized_message_ids: Any = annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY)
if not isinstance(summarized_message_ids, list) or not summarized_message_ids:
continue

# Collect origin_session_ids from all summarized messages
origin_session_ids: list[str] = []
for msg_id in cast("list[Any]", summarized_message_ids):
if not isinstance(msg_id, str):
continue
original_message = message_by_message_id.get(msg_id)
if original_message is None:
continue
original_attribution = original_message.additional_properties.get("_attribution")
if isinstance(original_attribution, Mapping):
original_origins = original_attribution.get("origin_session_ids")
if isinstance(original_origins, Sequence) and not isinstance(original_origins, str):
for origin in cast("Sequence[Any]", original_origins):
if isinstance(origin, str):
origin_session_ids.append(origin)

if origin_session_ids:
# Deduplicate and attach to the synthetic summary
deduplicated_ids = _deduplicate_origin_session_ids(origin_session_ids)
summary_attribution = message.additional_properties.get("_attribution")
if isinstance(summary_attribution, Mapping):
# Merge with existing attribution if present
merged_attribution = dict(cast("Mapping[str, Any]", summary_attribution))
merged_attribution["origin_session_ids"] = deduplicated_ids
message.additional_properties["_attribution"] = merged_attribution
else:
# Create new attribution dict
message.additional_properties["_attribution"] = {"origin_session_ids": deduplicated_ids}

async def after_run(
self,
Expand Down
Loading
Loading