Skip to content

[BUG] Delegation end_turn placeholder corrupts MessageAddedEvent stream for subscribers #3808

Description

@liramon2

Checks

  • I have updated to the lastest minor and patch version of Strands
  • I have checked the documentation and this is not expected behavior
  • I have searched ./issues and there are no duplicates of my issue

SDK Language

Python

Strands Version

1.52.0

Language Runtime Version

3.10.0

Operating System

macOS 26.5.2

Installation Method

pip

Steps to Reproduce

When a delegated tool fires, the delegation middleware's handling of MessageAddedEvent for the real content diverges depending on whether a session manager is attached.

With a session manager attached, MessageAddedEvent never fires for the real delegated content. Only the placeholder reaches subscribers like MemoryManager. The event is suppressed to avoid creating duplicate records in RepositorySessionManager, which uses incremental appends and would persist both the placeholder and the real content if the event fired.

Without a session manager, MessageAddedEvent fires for both the placeholder and the real content, so subscribers see two assistant messages for what is a single logical turn, a phantom extra message.

Both scenarios share the same root cause: the event loop unconditionally fires MessageAddedEvent for the placeholder before the delegation middleware runs, and there's no way to prevent or retract that first event under the current end_turn design. Firing the real event as well duplicates the message and produces a phantom turn (no session manager) or corrupts session restore (session manager attached, hence the suppression); not firing it loses data for memory extraction and other subscribers.

import asyncio
import tempfile

from strands import Agent
from strands.hooks import MessageAddedEvent
from strands.session import FileSessionManager


async def run(session_manager=None):
    sub = Agent(name="billing", system_prompt="Always respond with exactly: REAL_ANSWER_42", callback_handler=None)
    orch = Agent(
        name="orch",
        system_prompt=(
            "You must call the billing tool immediately for any balance question. "
            "Never ask the user for clarification. Never respond directly."
        ),
        tools=[sub.as_tool(delegate=True)],
        session_manager=session_manager,
        callback_handler=None,
    )

    texts = []
    orch.add_hook(
        lambda e: texts.extend(b["text"] for b in e.message.get("content", []) if "text" in b)
        if e.message.get("role") == "assistant"
        else None,
        MessageAddedEvent,
    )

    await orch.invoke_async("What is my balance using the billing tool?")
    return texts


async def main():
    session_manager = FileSessionManager(session_id="repro", storage_dir=tempfile.mkdtemp())

    print("With session manager:", await run(session_manager))
    print("Without session manager:", await run())


if __name__ == "__main__":
    asyncio.run(main())

# Output:
# With session manager: ['Turn ended early by hook after tool execution']
# Without session manager: ['Turn ended early by hook after tool execution', 'REAL_ANSWER_42']

Expected Behavior

MessageAddedEvent fires exactly once per delegation turn, carrying the real delegated content (REAL_ANSWER_42), regardless of whether a session manager is attached.

Actual Behavior

With session manager: MessageAddedEvent fires once, but with the internal placeholder text ("Turn ended early by hook after tool execution") instead of the real content. The real answer never reaches subscribers.

Without session manager: MessageAddedEvent fires twice: once with the placeholder, once with the real content for what is a single logical turn.

Additional Context

No response

Possible Solution

AfterToolsEvent.end_turn would gain a sentinel value (e.g. SUPPRESS_MESSAGE) alongside its existing bool | str type, meaning "stop the loop but don't append or persist any message." The delegation plugin's _on_after_tools sets this sentinel instead of True. The event loop checks for it in the end_turn branch and skips the _append_messages call entirely, so no placeholder is ever written to agent.messages and no MessageAddedEvent fires for it.

The delegation middleware in _handle_stream then becomes the sole appender: since no placeholder exists, it always appends the real delegation message unconditionally and fires MessageAddedEvent once, on both the session-manager and no-session-manager paths. This removes the _session_manager special case and the redact_latest_message call entirely — there's nothing to redact since nothing wrong was ever written — fixing both the data-loss and phantom-turn variants at the root.

Related Issues

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-hooksFeatures or requests that might be implementable via hooksarea-sessionsRelated to session or session managmentbugSomething isn't workingpythonPull requests that update python code

    Type

    Fields

    Language

    Python

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions