Skip to content

fix: preserve summary role attribution - #3614

Closed
JackYPCOnline wants to merge 2 commits into
strands-agents:mainfrom
JackYPCOnline:agent-tasks/summary-role-attribution
Closed

JackYPCOnline wants to merge 2 commits into
strands-agents:mainfrom
JackYPCOnline:agent-tasks/summary-role-attribution

Conversation

@JackYPCOnline

Copy link
Copy Markdown
Contributor

Description

SummarizingConversationManager stores model-generated summaries as user-authored history, losing their provenance across compression and session restore. Keep a fixed user preamble for provider compatibility while retaining the generated summary as an assistant message. No public API changes.

Related Issues

No public issue (privately reported).

Documentation PR

No documentation changes; the public API is unchanged.

Type of Change

Bug fix

Testing

Focused tests, lint, package build, and commit hooks pass. hatch run prepare reached the all-version suite but was blocked by existing Python 3.14 OpenTelemetry exporter import errors in tests/strands/telemetry/test_config.py.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Keep generated conversation summaries attributed to the assistant while retaining a leading user message for provider compatibility.
Advance past pinned and synthetic messages during repeated summarization, and normalize summaries restored from legacy sessions.
@github-actions github-actions Bot added size/m bug Something isn't working area-context Session or context related python Pull requests that update python code labels Aug 3, 2026
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.31579% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...sation_manager/summarizing_conversation_manager.py 76.31% 5 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

@JackYPCOnline

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent Can you review this PR see if this fix:
There is a bug in the SummarizingConversationManager component of the strands-agents/sdk-python SDK. When the AI agent summarizes long conversations, it accidentally labels the summary as coming from a trusted user instead of the AI itself. This means any malicious instructions that were picked up from external content (like a webpage) get saved with elevated trust and persist permanently in the session.

@strandly-the-agent strandly-the-agent left a comment

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.

Does this fix the reported bug? Partly — and it adds a worse one.

The "elevated trust" premise doesn't hold. There is no role-keyed trust, permission, or authority boundary anywhere in strands-py. Every consumer of role is provider wire-format (models/*.py), telemetry bucketing (telemetry/tracer.py:1294), the guardrail wrap target (models/bedrock.py:460-475), or token-estimate baselining (event_loop/event_loop.py:139-152) — none of them gate authority. Relabeling can't revoke trust the SDK never granted, and if anything an assistant turn reads to a model as its own prior conclusion, which needs no compliance step at all. What the PR does fix is real: AI-authored text was mislabeled as user input. That's a provenance bug worth fixing — it just shouldn't be described as an injection mitigation, and per SECURITY.md a vuln report belongs in HackerOne / aws-security@amazon.com rather than a public thread. Ironically the relabel also drops the summary out of Bedrock guardrail scanning (🟡 #2).

Separately, I found a data-loss regression unrelated to the reported bug (🔴 #1).

Verified on 384ea76d vs main@a2656017:

  • ✅ 84/84 in test_summarizing_conversation_manager.py + test_repository_session_manager.py; ruff clean
  • 🔴 repro: snapshot round-trip → removed_message_count 5→8; user:question 3 is in the live history, absent after restore, and was never summarized. Pre-PR base: 5→7, no loss.
  • ✅ fix verified: comparing on tracking_id5→6, restored == live, 84/84 still pass
  • ✅ live Bedrock: assistant-first is rejected by claude-3-haiku / nova-micro / mistral-7b (A conversation must start with a user message) → the preamble is load-bearing, not decorative
  • 🔴 refuted a draft finding of my own: assistant,assistant adjacency does not break providers — nova-micro and mistral-7b both return HTTP 200. Pre- and post-PR violate on complementary split parities anyway (~50/50), so it's the same class flipped, not a new one. Not counted against this PR.

Verdict: worth holding the draft until 🔴 #1 is addressed. The progress-loop rewrite is a genuine improvement and I'd keep it — it's a necessary fix for a stall that commit 1b29bee8 itself introduces (verified: against commit 1 alone, the second reduce_context is a byte-identical no-op (7,3)→(7,3); on the full PR (7,3)→(6,4)), and it terminates cleanly across pinned-everything, preserve >= len, both summary_ratio clamp bounds, and a 40-message/12-round run. This is agent-generated review for a human to arbitrate, not a gate.

Questions

(blocking-ish) The PR body frames this as provenance; your comment frames it as trust. Those select different designs, and I think only you can settle which you're after. If it's provenance, would metadata.custom do the job more cleanly? types/content.py:222 documents it as "Arbitrary user/framework metadata (e.g. compression provenance)" — literally this use case — with working precedent in compression/pin_message.py:90-104, and it's stripped before the model call (event_loop/streaming.py:519-520), so it costs you no role change, no guardrail side effect, no telemetry re-bucketing, and no persisted-state contract shift. If it's trust, the honest version is a content-level control (delimit the summary, state that it's machine-generated compression to be treated as data), and the role flip doesn't buy it.

(non-blocking) Before this PR all three summary producers agreed on role="user"; after it they don't. compression/context_compression.py:189-192 still hard-casts to user (with the docstring at :163 and comment still justifying the rationale this PR overrides), and the model-invokable agentic tool at _context_manager/modes/agentic/agentic_context.py:138,143-144 splices a bare user-role summary with no preamble — arguably the more exposed path for the stated threat, since a model can call it on fetched content. TS is unchanged too (strands-ts/.../summarizing-conversation-manager.ts:167). Is a follow-up planned to fix generate_summary once (it's not public per compression/__init__.py), or is the scope deliberately Python + this manager only? Worth saying so in the body either way, so the changelog doesn't read as an SDK-wide fix.

Appendix — non-blocking (4)
  • Telemetry re-bucketing. telemetry/tracer.py:1294 picks the redaction policy key off role, so a summary of user content now lands under gen_ai.output.messages instead of gen_ai.input.messages. Mechanical consequence of the relabel; misclassification, no leak.
  • preserve_recent_messages=0 → history can end on the assistant summary, which live mistral.mistral-7b-instruct-v0:2 rejects (requires the last turn in the conversation to be a user message); Claude/Nova accept it. preserve_recent_messages is unvalidated (summarizing_conversation_manager.py:44,77) — that gap pre-dates this PR, so it's not yours, but is 0 meant to be legal at all?
  • Preamble/prompt voice mismatch. DEFAULT_SUMMARIZATION_PROMPT (compression/context_compression.py:23-50) is unchanged and still says "You MUST NOT respond conversationally", "You MUST NOT address the user directly", "format the summary in the third person" — written for replay as a user message, now emitted as an assistant turn narrating the user's own requests back at them. And "Previous conversation summary:" is a bare four-word label with no verb: it reads as a truncated user request or a section header priming more summarizing. Downstream of the Question above, so not a finding on its own.
  • Preamble tracking_id isn't stable across restores of a legacy session_create_summary_preamble() (summarizing_conversation_manager.py:113-118) mints a fresh UUID each time it's used as the fallback at :102, which is mildly at odds with the durability contract at types/content.py:236-240. Self-heals after the first sync_agent(); no consumer keyed on it that I could find.

Things I checked that held up: removed_message_count accounting on the plain (non-snapshot) session path is byte-clean across two summarization rounds; no preamble accumulation (exactly 1 across 12 rounds and across restores); summary-of-summary converges; main→PR state migration works correctly (coercion + synthesized preamble); _fix_broken_tool_use mutates in place so it doesn't break identity and can't eat the preamble; the rewritten toolUse/toolResult comment at :294-296 is more accurate than what it replaced.

Comment on lines +204 to +208
persisted_to_summarize = [
message
for message in to_summarize
if all(message is not previous for previous in previous_summary_messages)
]

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.

🔴 Identity-based filtering breaks across a snapshot round-trip → real messages are silently and permanently lost on the next session restore.

Agent.load_snapshot (agent/agent.py:1607-1611) copy.deepcopys data["messages"] and separately calls restore_from_session. After that, self._summary_preamble / self._summary_message are content-identical but not the same objects as the ones in agent.messages — so every is not previous check (here, and at :224-230, :232-234, :236-237) silently stops excluding them, and removed_message_count += len(persisted_to_summarize) at :233 over-counts by however many artifacts leaked in. That offset is the slice index into persisted session rows (session/repository_session_manager.py:219-223), so the over-count skips real rows forever.

Repro — FileSessionManager, 5 turns, summarize, take_snapshot(preset="session")load_snapshot, summarize again, restore:

PR    : removed_message_count 5 -> 8   (delta 3, but only ONE real persisted row was consumed)
        LIVE     : [preamble, summary, 'user:question 3', 'assistant:...', 'user:question 4', ...]
        RESTORED : [preamble, summary,                                     'user:question 4', ...]
        >>> 'user:question 3' is gone, and it was never summarized <<<
base  : removed_message_count 5 -> 7   restored == live, no loss

Pre-PR is immune because its arithmetic (+= len(to_summarize) then -= 1) is identity-independent — so this is a regression introduced here. Reachable through the public Agent.take_snapshot/load_snapshot API, and already called that way in-repo per request at multiagent/a2a/executor.py:216 and per retry at vended_plugins/goal/plugin.py:365. (multiagent/swarm.py:115, multiagent/graph.py:256, agent/_agent_as_tool.py:268 all use the same messages = copy.deepcopy(...) shape.)

Every message here already carries a durable tracking_id — minted at :117 for the preamble and :242 for the summary — and types/content.py:236-240 documents it as "Durable, stable UUID… Survives session save/restore and snapshots… Preserved when a message is copied or restored." That's exactly the identity you want:

Suggested change
persisted_to_summarize = [
message
for message in to_summarize
if all(message is not previous for previous in previous_summary_messages)
]
persisted_to_summarize = [
message for message in to_summarize if not self._is_generated_summary_message(message)
]

with a helper (and the same substitution at the three other filter sites):

def _is_generated_summary_message(self, message: Message, *, preamble_only: bool = False) -> bool:
    """Whether ``message`` is a preamble/summary this manager generated.

    Compares on the durable ``tracking_id`` as well as object identity, so the check still
    holds after a snapshot round-trip or any other copy of ``agent.messages``.
    """
    candidates = (self._summary_preamble,) if preamble_only else (self._summary_preamble, self._summary_message)
    for previous in candidates:
        if previous is None:
            continue
        if message is previous:
            return True
        tracking_id = previous.get("tracking_id")
        if tracking_id and message.get("tracking_id") == tracking_id:
            return True
    return False

I ran this: the repro flips to 5 -> 6 with restored == live and no loss, all 84 tests still pass, ruff clean. Worth a regression test for the exact path too (save → summarize → snapshot round-trip → summarize → assert no persisted message is lost) — nothing in the suite exercises it today, which is how this got through.

"""
return run_async(lambda: generate_summary(messages, agent.model, self.summarization_system_prompt))
summary = run_async(lambda: generate_summary(messages, agent.model, self.summarization_system_prompt))
return cast(Message, {**summary, "role": "assistant"})

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.

🟡 The relabel removes the summary from Bedrock guardrail scanning — the one role-keyed security mechanism in the SDK.

models/bedrock.py:460-475 (_find_last_user_text_message_index) plus the wrap at :509-511 target the last user message with text when guardrail_latest_message=True. Before this PR a summary of possibly-attacker-influenced scraped content was role="user" and got scanned. After it, the last user text message is the constant preamble, so that's all the guardrail sees:

PRE-PR  (summary=user)      -> text sent to guardrail: ['SUMMARY: <untrusted scraped text>']
POST-PR (summary=assistant) -> text sent to guardrail: ['Previous conversation summary:']

Reachable whenever no later user turn carries text — i.e. mid-tool-loop, where trailing user turns are toolResult-only (which is the exact case the :504-506 comment says that index lookup exists for).

🟡 rather than 🔴: it stops the summary being scanned, it doesn't disable guardrails for other messages, and I didn't test an end-to-end bypass against a live guardrail. But this PR is motivated by a security concern, so it's worth deciding deliberately rather than inheriting it as a side effect — either keep scannable content in the preamble, or extend the guardrail-target search to include the manager's attributed summary.


result = summarization_agent("Please summarize this conversation.")
return cast(Message, {**result.message, "role": "user"})
return cast(Message, {**result.message, "role": "assistant"})

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.

🟡 The coercion runs after the extension point, so a custom summarization_agent's role is silently overwritten with no opt-out.

summarization_agent is a documented public constructor parameter, and this line discards whatever role its result carried — as does :338 for the default model path. Before this PR the overwrite was to user, so this isn't new behavior, but it is newly load-bearing: a caller whose summarization agent deliberately shapes its output (or who depends on the pre-PR user contract) has no way to keep it.

Suggest either documenting it as an intentional non-overridable contract in the summarization_agent docstring (:57-58), or gating the coercion so a subclass overriding _generate_summary can opt out. Cheap either way; mostly I want the choice to be explicit rather than implicit.

self._summary_preamble = None
return None

self._summary_message = cast(Message, {**self._summary_message, "role": "assistant"})

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.

🟡 Persisted-state contract changed with no version marker — and "No public API changes" is arguable because of it.

get_state() / restore_from_session() are a serialization contract with sessions written by other SDK versions. Adding the summary_preamble key is fine (there's precedent for additive keys: sliding_window_conversation_manager.py:134,147). The part that isn't additive is redefining what summary_message's role means, with nothing in the serialized dict saying which contract wrote it — while the SDK versions its other serialized contracts (types/_snapshot.py:33,45,60-63, experimental/checkpoint/checkpoint.py:40,57,73-80).

Concretely, state written by this branch and read by main restores agent.messages as ['assistant', 'user'] — assistant-first, which I live-verified is rejected by all three model families I could reach (claude-3-haiku, nova-micro, mistral-7b: ValidationException: A conversation must start with a user message). That's exactly what PR #698 (d9f8d8a7) existed to fix. It only bites a rollback or a mixed-version fleet sharing a session store, but sessions outlive SDK versions, so it's worth a schema_version on this manager's state slice (or at minimum a migration note in the PR body).

Also: given this contract shift, would you check whether the repo's api/needs-review label applies (team/API_BAR_RAISING.md)? The PR currently carries bug/python/area-context/size/m. Your call — I'm flagging the workflow, not asserting the verdict.

assert manager.removed_message_count == 5


def test_reduce_context_with_pinned_prefix_makes_progress():

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.

🟡 Coverage is missing exactly the lines the 🔴 above lives behind.

pytest --cov=strands.agent.conversation_manager.summarizing_conversation_manager --cov-report=term-missing on this branch:

summarizing_conversation_manager.py   119   5   26   5   93%   98-99, 214, 219, 237, 297->301
  • 98-99restore_from_session's no-prior-summary branch
  • 214, 219both new ContextWindowOverflowException raise paths of the progress loop this PR adds
  • 237summary_input.insert(0, self._summary_message), i.e. the branch that keeps a prior summary from being dropped
  • (297->301 is pre-existing, not yours)

Mutation testing found one survivor: deleting the "summary_preamble" key from get_state() leaves all 84 tests green — because restore_from_session regenerates a textually identical preamble at :102, nothing distinguishes "restored the persisted one" from "silently regenerated one". Also, the += 1 → += 2 mutation in the loop is killed by only this test; nothing else drives the loop past its first iteration.

On this test specifically: it does pin down a real bug (against 1b29bee8 alone the second reduce_context is a byte-identical no-op (7,3)→(7,3); on the full PR (7,3)→(6,4)), so it's genuine, not padding. But it asserts magic tuples with no derivation. Consider also asserting the invariant that actually matters — removed_message_count strictly increases and len(agent.messages) strictly decreases between calls — which survives accounting refactors. And the missing test that matters most is a session save → restore → re-summarize round trip; that's the hole 🔴 #1 shipped through.

(Credit where it's due: tests/strands/session/test_repository_session_manager.py:153 flipping the fixture role to "user" is not a tautology — I mutation-verified it's the only test covering the backward-compat coercion.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-context Session or context related bug Something isn't working python Pull requests that update python code size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants