Skip to content

fix(interrupt): restore a pending tool execution only for an activated state, and validate it (Python) - #5654

Open
strandly-the-agent wants to merge 1 commit into
strands-agents:mainfrom
strandly-the-agent:port/interrupt-restore-validation-to-py
Open

strandly-the-agent wants to merge 1 commit into
strands-agents:mainfrom
strandly-the-agent:port/interrupt-restore-validation-to-py

Conversation

@strandly-the-agent

Copy link
Copy Markdown
Collaborator

Requested by: no human requester — automated run (daily parity sync, window 2026-10-28 UTC)

Human Overview

Description

Python port of the restore-path half of #5628. _InterruptState.from_dict now restores pending_tool_execution (typed, or the legacy context.tool_use_message migration) only when the state is activated, and validates what it restores: the message must be a dict with a content list and role: assistant; completed_tool_results must be a list of dicts with status and a content list. Malformed data raises SessionException with the TS field=<...> | ... messages (snake_case paths). The replay gate itself (event_loop.py checks activated and pending_tool_execution is not None) already existed; #5628 added the Python test for it.

One deliberate deviation from TS, forced by an independent review of this branch: a well-formed execution the loop cannot replay (no tool uses, or a result for an unknown tool use) is dropped with a logger.warning instead of raising. See the decision log.

Related Issues

Closes #5651. Source: #5628.

Documentation PR

None needed: #5628's site/ edits are shared prose already on main.

Type of Change

Bug fix

Testing

  • 13 new/updated tests in tests/strands/test_interrupt.py (matrix below); existing fixtures gained a toolUse block because an empty content is now invalid input (it matches what _stop_for_interrupts actually persists, cf. test_event_loop.py:2016).
  • pytest over interrupt/event_loop/session/multiagent/background_tasks/snapshot/agent_as_tool/subagent: 982 passed; ruff check, ruff format --check, mypy: clean (verbatim below).
  • I ran hatch run prepare (hatch unavailable in the sandbox; ran ruff + mypy + pytest directly)

Additional Details

Traceability matrix (TS #5628 `interrupt.test.ts` → Python)
TS test Python test (tests/strands/test_interrupt.py) Result
fromJSON round-trip with the shared pendingToolExecution fixture test_interrupt_state_from_dict (:87), test_interrupt_state_pending_tool_execution_round_trip_is_independent pass
drops a pending tool execution when the interrupt state is not activated ..._drops_pending_tool_execution_when_not_activated (:256), params None / _PENDING_TOOL_EXECUTION pass
it.each: a non-assistant message ..._rejects_invalid_pending_tool_execution[non-assistant message] (:280) pass
it.each: an unparseable message [unparseable message] (:285) pass
it.each: non-object completed tool results [non-list completed tool results] (:290) — list in Python pass
it.each: an unparseable completed tool result [completed tool result without status] (:295), [completed tool result without content list] (:300), [non-dict completed tool result] (:305) pass
it.each: a message without tool uses ..._drops_unreplayable_pending_tool_execution[message without tool uses] (:322) — drops + warns instead of raising (decision log) pass
it.each: a completed tool result for an unknown tool use [completed tool result for an unknown tool use] (:341) — drops + warns (decision log) pass
keeps the parse error as the cause ..._wraps_invalid_pending_tool_execution (:227) covers the PendingToolExecution(**data) path only; the content-not-a-list branch raises without __cause__ (nothing to chain: Python has no message parser) partial
getPendingExecution returns undefined when not activated no Python method; the gate lives in event_loop.py:263 / :894, tested by test_event_loop_cycle_ignores_pending_tool_execution_when_interrupt_state_not_activated (added by #5628) n/a
getPendingExecution returns the reconstructed execution when activated no reconstruction step in Python (plain dataclass of TypedDicts) n/a
agent.interrupt.test.ts (agent-level gating, activate() before yielding InterruptEvent) Python already activates in _stop_for_interrupts before the stop event n/a
— (Python-only) [tool use without a toolUseId] (:333), ..._accepts_state_written_for_a_partial_tool_batch (:357), ..._validates_legacy_tool_context (:372) pass
Gate output (verbatim)
== branch: port/interrupt-restore-validation-to-py (on main aba77371a)
== pytest
982 passed, 102 warnings in 25.49s
== ruff check
All checks passed!
== ruff format --check
2 files already formatted
== mypy
Success: no issues found in 1 source file

Selection: tests/strands/test_interrupt.py tests/strands/event_loop tests/strands/session tests/strands/multiagent tests/strands/background_tasks tests/strands/agent/test_snapshot.py tests/strands/agent/test_agent_as_tool.py tests/strands/vended_tools/test_subagent.py --ignore=tests/strands/multiagent/a2a (a2a extra not installed in the sandbox). tests/strands/agent/test_agent.py::test_baggage_attributes_stamped_on_agent_span fails on main in this sandbox too (missing opentelemetry.processor), unrelated.

Structural map and decision log
TS Python
InterruptState.fromJSON gate data.activated && data.pendingToolExecution from_dict: if activated and pending_tool_execution_data is not None (interrupt.py:~222)
validatePendingToolExecution + parseRestoredData module-level _restore_pending_tool_execution (interrupt.py:~238)
SessionError SessionException (lazy import, as before)
completedToolResults object keyed by id completed_tool_results list of ToolResult dicts (existing Python shape)
  1. Semantic mismatches drop + warn instead of raising (deviation). An independent reviewer reproduced, with a real FileSessionManager, that the SDK itself can persist a pending execution with no tool uses: models/openai.py maps finish_reason == "tool_calls" to stopReason: tool_use without requiring a toolUse block, and a BeforeToolsEvent interrupt then stores the text-only message. A faithful raise made such a session fail on every load with no recovery. Dropping degrades to "no replay, call the model again" because the loop already guards on pending_tool_execution is not None. Structural problems (non-dict message, non-list content/results, non-assistant role, result without status/content) still raise, as in TS. TS has the same write path (agent.ts _executeTools stores assistantMessage.toJSON() unconditionally), so this may be worth mirroring there — flagged as an open question.
  2. Legacy context.tool_use_message path is gated and validated too. TS has no legacy path. The legacy pair was written by the same _stop_for_interrupts with the same invariants, and before 29dfce6b9 the replay gate was literally activated and "tool_use_message" in context, so gating restores the original semantics.
  3. Validation is structurally laxer than TS's Message.fromMessageData. Python messages are TypedDicts with no runtime parser; unknown extra blocks are tolerated, only the fields the replay path reads are checked.
  4. _agent_tool_streamer.py and background_tasks/_manager.py call from_dict only to read .interrupts. Left unchanged: with (1) they can only raise on genuinely corrupt data, and from_dict applies the retained-response filter they rely on.
  5. The pre-existing TypeError message was re-shaped to the house field=<pending_tool_execution> | ... form; its test updated.

New dependencies / capabilities: none. Sensitive surface: Agent(..., session_manager=...), Agent.load_snapshot, Graph/Swarm session restore and _AgentToolStreamer can now raise SessionException on malformed pending state (TS already does); a non-activated pending execution is no longer restored (it was never replayed).

Open questions

  • Should TS also tolerate a text-only pending message (decision 1), or should both SDKs stop writing one (_stop_for_interrupts / _executeTools guard on empty tool uses)?
  • Independent review also suggested _agent_tool_streamer.pending_interrupt_ids read ids directly rather than via from_dict; not done here for the reason in (4).

Validation: an independent validator pass (test matrix, gates, 28 adversarial shapes — two TypeError leaks found and fixed) and an independent reviewer pass (BLOCKER on decision 1, fixed; nits applied). Evidence uploaded to the portal under issue #5651.

Reviewer: @zastrowm — rule 2 (org member who approved the source PR). Rule 1 did not apply: the #5628 author opieter-aws is not a public strands-agents member as visible to this token (GET /orgs/strands-agents/members/opieter-aws → 404, not in public_members).

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.

…d state, and validate it

Port of strands-agents#5628 (TS). _InterruptState.from_dict drops a pending tool execution
when the state is not activated and raises SessionException naming the field
when the restored data is malformed. Well-formed state the event loop could
not replay (no tool uses, a result for an unknown tool use) is dropped with a
warning instead of failing the session load.

Closes strands-agents#5651
@strandly-the-agent
strandly-the-agent requested a review from a team as a code owner October 29, 2026 14:39
@github-actions github-actions Bot added bug Something isn't working area-hil Human in the loop and suspend/resume area-persistence Session management or checkpointing python Pull requests that update python code labels Oct 29, 2026

This branch has not been deployed

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

Labels

area-hil Human in the loop and suspend/resume area-persistence Session management or checkpointing bug Something isn't working python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[PORT] Restore a pending tool execution only for an activated interrupt state, and validate it (TS→Py)

2 participants