Checks
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
Checks
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
MessageAddedEventfor the real content diverges depending on whether a session manager is attached.With a session manager attached,
MessageAddedEventnever fires for the real delegated content. Only the placeholder reaches subscribers likeMemoryManager. The event is suppressed to avoid creating duplicate records inRepositorySessionManager, which uses incremental appends and would persist both the placeholder and the real content if the event fired.Without a session manager,
MessageAddedEventfires 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
MessageAddedEventfor 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.Expected Behavior
MessageAddedEventfires 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:
MessageAddedEventfires 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:
MessageAddedEventfires 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_turnwould gain a sentinel value (e.g. SUPPRESS_MESSAGE) alongside its existingbool | strtype, meaning "stop the loop but don't append or persist any message." The delegation plugin's_on_after_toolssets this sentinel instead of True. The event loop checks for it in theend_turnbranch and skips the_append_messagescall entirely, so no placeholder is ever written toagent.messagesand noMessageAddedEventfires for it.The delegation middleware in
_handle_streamthen becomes the sole appender: since no placeholder exists, it always appends the real delegation message unconditionally and firesMessageAddedEventonce, on both the session-manager and no-session-manager paths. This removes the_session_managerspecial case and theredact_latest_messagecall 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