Skip to content

fix(ts): ignore pending tool execution when interrupt state is not activated - #5628

Merged
opieter-aws merged 2 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/pasted-text-file
Oct 28, 2026
Merged

opieter-aws merged 2 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/pasted-text-file

Conversation

@opieter-aws

@opieter-aws opieter-aws commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Human Overview

Description

Restoring a snapshot could resume a stored tool call even when the interrupt state wasn't activated. InterruptState.fromJSON copied pendingToolExecution verbatim and getPendingExecution() never checked activated, while the agent's "resume only with interrupt responses" guard applies only to activated states. The next ordinary prompt therefore ran the stored tool call without a model call or an approval. Python already requires activated before replaying (event_loop.py); this brings TypeScript in line.

The gate lives in getPendingExecution() so every caller gets it. As defense in depth, fromJSON restores a pending execution only for an activated state and validates it (an assistant message with tool uses, completed results keyed by its tool-use ids), raising SessionError naming the field instead of failing later in the loop.

Behaviour change: after a consumer breaks the stream at InterruptEvent (so the state never activates), a new prompt now calls the model instead of replaying the stored tool call.

Related Issues

None

Documentation PR

Included

Type of Change

Bug fix

Testing

  • I ran hatch run prepare (not run: the hatch lint env can't build pyaudio locally; ran hatch test and ruff directly)

Additional Details

Extended context (optional)
  • Agent.loadSnapshot can now throw SessionError for a malformed pending tool execution in an activated state. Snapshots written by the SDK during a real interrupt always pass.
  • Restored resumeResponses and context are left unchanged: on an activated restore they're overwritten or gated behind fresh interrupt responses, and on a non-activated restore their readers don't run. Other restored interrupt fields are out of scope here and tracked separately.
  • Python only gets a parity test. Its restore path still keeps a non-activated pending execution (blocked by the event-loop check); aligning from_dict with the TypeScript restore checks is a separate follow-up.

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.

@github-actions github-actions Bot added area-hil Human in the loop and suspend/resume typescript Pull requests that update typescript code bug Something isn't working labels Oct 28, 2026
@opieter-aws
opieter-aws marked this pull request as ready for review October 28, 2026 17:29
@opieter-aws
opieter-aws requested a review from a team as a code owner October 28, 2026 17:29
@opieter-aws
opieter-aws requested a review from mehtarac October 28, 2026 17:29
@opieter-aws

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent can you review?

@github-actions github-actions Bot added complexity/low Touched functions have low cognitive complexity (<=10) size/s labels Oct 28, 2026
@codecov

codecov Bot commented Oct 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve

Clean, well-scoped bug fix. Centralizing the activated gate in getPendingExecution() is the right call — it's the single resume read-site (agent.ts:1721), and the other direct reads of pendingToolExecution (agent.ts:1381, 1850) only run when the state is already activated, so they stay consistent. The runtime set-paths (executor._storePendingToolExecution, _executeTools) only fire alongside an InterruptError that triggers activate(), so the gate doesn't regress legitimate in-memory resume. I ran the TS interrupt suites locally: 60/60 pass.

Review notes
  • Correctness: Core gate + fromJSON defense-in-depth (drop-when-not-activated, then validate) are complementary and consistent. Buggy snapshots (pending set, activated=false) self-heal on restore. Python parity via event-loop guard is matched.
  • Testing: Strong coverage — it.each over the malformed-restore cases, full-object/Map equality assertions, a snapshot round-trip into a fresh agent, and the new-prompt-rejects-when-activated case. The behavior-change assertion (toolRuns 1 -> 0, callCount === 2) clearly pins the fix.
  • Docs: The "session state is trusted input" note and the loadSnapshot @throws SessionError addition are accurate and helpful.
  • Nits (non-blocking, no change requested): the field=<...> | structured error prefix is new to the TS codebase; validatePendingToolExecution parses to validate then discards while getPendingExecution re-parses — both are intentional and restore-time only.

Nice work on the parity reasoning and the extended-context writeup in the description.

@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.

Changes requested. The activated gate itself is correct and genuinely well-tested — I mutation-tested all three gates and each is caught by exactly the expected test, including the Python one. But it only closes the plain-prompt replay. Because activate() still runs after the InterruptEvent yields (agent.ts:2061 via _createInterruptResult, called at agent.ts:1975), a consumer that breaks the stream at InterruptEvent — the HITL pattern this PR is about — is left with activated=false plus a stored pending execution, and now:

  • the tool call the human just approved is silently dropped on resume — base ran it, this PR answers the user without it, endTurn, no error;
  • the stale pendingToolExecution is never cleared, and the next interrupt that activates without a tool cycle of its own replays it.

Moving this._interruptState.activate() above the yield loop at agent.ts:1968 fixes both and restores parity with Python, which activates at strands-py/src/strands/agent/agent.py:1804 before yielding its stop event. It fails exactly one test: the one this PR rewrote.

Measured 3-way matrix, verification, questions, appendix

Matrix — base df200532 / this PR 72fa875f / PR + activate() before the yields

All rows start by streaming until the first InterruptEvent and breaking (for await … if (event instanceof InterruptEvent) break).

flow base this PR PR + fix
A. resume with response:'yes', tool-raised interrupt toolRuns=["tool-1"] toolRuns=[] toolRuns=["tool-1"]
B. resume 'yes', BeforeToolCallEvent approval gate stop=interrupt, wedged stop=endTurn, toolRuns=[] stop=endTurn, toolRuns=["tool-1"]
B. resume 'no', same gate stop=interrupt, wedged toolRuns=[] toolRuns=[] (denied)
C. plain prompt instead replays tool-1 (the bug) model called ✅ but pending=true lingers TypeError: Agent is in an interrupted state…
C. then an unrelated AgentStreamStage gate + resume n/a (already consumed) replays tool-1, splicing assistant[toolUse]/user[toolResult] in three turns late n/a

Base was unsafe but functional (it replayed unconditionally, so a 'no' would have run the tool too — exactly what this PR is fixing). This PR is safe but silently broken: resume() early-returns at interrupt.ts:284 because the state isn't activated, so the human's answer is never applied at all. Neither is right; the one-line move gives you both.

This is also where I'd push back on the automated assessment above: "the other direct reads … only run when the state is already activated, so they stay consistent" and "the gate doesn't regress legitimate in-memory resume" are both contradicted by rows A and B.

✅ Verified

  • Head 72fa875fafa3c6b97de8ff72a24d997e106414eb vs base df2005323b7720089ad0a8e990cb47f0ee604145.
  • npx vitest run --project unit-node src/__tests__/interrupt.test.ts src/agent/__tests__/agent.interrupt.test.ts src/middleware/__tests__/ src/session/__tests__/ src/agent/__tests__/agent.snapshot.test.ts → 12 files, all pass, no type errors.
  • Mutation tests (all three gates are load-bearing): removing the activated check in getPendingExecution fails 2 tests; removing it in fromJSON fails 1; removing both Python guards at event_loop.py:263,894 fails exactly test_event_loop_cycle_ignores_pending_tool_execution_when_interrupt_state_not_activated. So the Python parity test does exercise the event-loop guard — it isn't echoing a mock.
  • Python behaviour confirmed at runtime, not just read: after the same stream break, activated: True, pending_tool_execution set: True, and a plain prompt is rejected with TypeError prompt_type=<class 'str'> | must resume from interrupt with list of interruptResponse's.
  • Candidate fix under the existing suites: Tests 1 failed | 352 passed (353) — the one failure is this PR's rewritten test. I ran a targeted subset (interrupt / middleware / session / snapshot), not the full unit suite.
  • Artifacts: evidence-matrix.txt, mutation-tests.log, repro-5628.test.ts, py-parity-repro.py, py-parity-repro.out.

Questions (non-blocking)

  1. Should the fromJSON drop-and-validate hardening land in Python too? strands-py/src/strands/interrupt.py:210-229 restores pending_tool_execution unconditionally and has no equivalent validation, so the TS reader is now stricter than the Python one for the same wire format — which matters if a session is written by one SDK and read by the other.
  2. fromJSON silently discards a pending execution when activated=false, while toJSON (interrupt.ts:359) still writes it whenever it is set — so a round-trip isn't idempotent. Intentional over a logger.debug on the drop?

Appendix — non-blocking (3)

  • agent.ts:1546: the new @throws SessionError if the snapshot's interrupt state carries a malformed pending tool execution is only accurate for an activated state; a non-activated one is dropped without validation, so no throw.
  • The field=<…> | error prefix is consistent with repo convention, not new — see interrupt.ts:294, hooks/registry.ts:113, multiagent/graph.ts:609, and agent.py:1794 on the Python side. Noting it only because it came up as a concern; no change wanted.
  • Other readers of pendingToolExecution check out: agent.ts:1381 is short-circuited by the activated && conjunct; agent.ts:1850's continue can't spin because _throwIfCancelled() runs at the top of each cycle (agent.ts:1663); 1869 and the writers at 2597/executor.ts:254 are unaffected.

Comment thread strands-ts/src/interrupt.ts Resolved
@strandly-the-agent

Copy link
Copy Markdown
Contributor

Addendum to my review above — one measured alternative, in case you'd rather keep accepting a fresh prompt after a stream break than adopt the TypeError behaviour that moving activate() implies.

Clearing the stale state on entry closes the deferred replay without changing the fresh-prompt contract, at the cost of leaving "approval sent after a break" as a silent no-op (which it already is on this branch):

// agent.ts:1637, right after the "Agent is in an interrupted state" check
if (!this._interruptState.activated) {
  this._interruptState.deactivate()
}
What I measured

Repro: tool interrupt → consumer break at InterruptEvent → plain prompt → unrelated AgentStreamStage gate → human answers the gate.

PR head 72fa875f + activate() before yields + deactivate() on non-activated entry
stale tool-1 replays on the gate's resume yes (toolRuns=["tool-1"], stale confirm also listed in the gate result's interrupts) n/a — plain prompt throws TypeError no (toolRuns=[])
approval sent right after the break no-op, endTurn runs the approved tool no-op, endTurn
interrupt/snapshot/middleware/session suites pass 382/383 (fails the rewritten test) 386/386

Also confirmed: the full TS unit run on this head has 2 failures in src/telemetry/__tests__/config.test.ts; they fail identically on base df200532, so unrelated. Python parity test passes (4/4 in the -k subset).

Either fix is a one-liner; which one is a design call (Python parity vs. tolerating the half-state), so flagging rather than requesting.

@github-actions

Copy link
Copy Markdown
Contributor

Documentation Preview Ready

Your documentation preview has been successfully deployed!

Changed pages:

Updated at: 2026-10-28T18:05:17.680Z

@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Approve (re-review of 18bfe3e, resolving my earlier Changes requested on 72fa875)

The blocking issue is fixed. activate() now runs before the InterruptEvent yields (agent.ts:1970, with a comment explaining why) and is removed from _createInterruptResult. So a consumer that breaks the stream at InterruptEvent — the HITL pattern this PR targets — is left activated=true, which means:

  • the approved tool call is run once on resume instead of silently dropped;
  • a plain prompt after the break is rejected with TypeError instead of answering without the tool, matching Python (agent.py:1804 activates before its stop event) and the snapshot-restore test.
What I verified
  • The one-line move I asked for landed, plus the duplicate activate() in _createInterruptResult was removed, and the loadSnapshot @throws doc is now correctly scoped to an activated state.
  • Tests were rewritten to pin the contract, not the symptom: rejects a new prompt instead of replaying the stored tool cycle asserts the TypeError; two new tests (runs a tool-approved call once when resumed, runs a hook-approved call once when resumed) prove the approved call runs exactly once — the matrix rows that were broken before.
  • interrupt.ts is unchanged — the getPendingExecution/fromJSON gate stays as the correct defense for restored state.
  • Ran locally, all green, no type errors: interrupt.test.ts (30) + agent.interrupt.test.ts (32) = 62 pass; the broader middleware + session + snapshot suites = 293 pass, so moving activate() introduced no regressions.

Non-blocking follow-ups from my earlier review still stand as noted in the PR description (Python from_dict hardening parity is tracked separately; the toJSON-writes / fromJSON-drops asymmetry is now effectively unreachable at runtime since a real stream break leaves activated=true).

Thanks for the thorough fix and the matching test rewrite — the resume behavior now matches Python.

@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.

Approve. Both previous findings are fixed at 18bfe3e and the fix doesn't trade one bug for another.

  • 🔴 resume silently dropped the approved tool call after a stream break → fixed: activate() now runs before the InterruptEvent yields (agent.ts:1970), mirroring the middleware path at agent.ts:1391 and Python. Re-measured: tool-raised interrupt → break → 'yes' runs the tool exactly once; BeforeToolCallEvent gate → 'no' denies it; state is activated=false, pending=false afterwards in both cases.
  • 🟡 rewritten test pinned the symptom → fixed: it now asserts the TypeError, matching Python and the restored-state test below it.
  • Appendix @throws wording → fixed.

One stale line to update, nothing else: the PR description's Behaviour change paragraph still says a new prompt "now calls the model" — it now throws TypeError: Agent is in an interrupted state… (which is the right behaviour).

What I verified / attacked
  • Delta since 72fa875 is only agent.ts (+3/−2) and agent.interrupt.test.ts; Python and docs unchanged, so I reviewed this inline rather than re-fanning out.
  • _createInterruptResult has exactly one caller (agent.ts:1977), so dropping activate() from it leaves no path un-activated. _streamWithMiddleware's deactivate check (agent.ts:1378-1384) is gated on stopReason !== 'interrupt', so the earlier activation doesn't get undone.
  • Attacks on the moved activate() (all pass, repro-5628-followup.test.ts): snapshot taken inside the InterruptEvent handler now serialises activated=true so fromJSON keeps the pending execution and a fresh agent resumes it with zero model calls; break → agent.cancel() → resume recovers cleanly (4 messages, no replay storm); break → resume → later unrelated AgentStreamStage gate does not replay the old tool (the stale-pending finding from last round is gone).
  • Full npx vitest run --project unit-node: 5646 passed, 2 failed — both src/telemetry/__tests__/config.test.ts resource-attribute tests, which fail identically on base df200532 because my sandbox exports OTEL_RESOURCE_ATTRIBUTES. Unrelated to this PR.
  • Not re-run: Python suite (unchanged since last review, where the mutation test showed the parity test is load-bearing).

@opieter-aws
opieter-aws merged commit a4865cd into strands-agents:main Oct 28, 2026
45 of 46 checks passed
Unshure added a commit to strandly-the-agent/harness-sdk that referenced this pull request Oct 28, 2026
Reapplies two fixes from main that were dropped when the earlier
rebase of 31800e8 resolved via --theirs:

- fa313cc (strands-agents#5429) extract structuredOutput before the deferred
  append, so a message-added hook that offloads the JSON block
  can't lose it.
- a4865cd (strands-agents#5628) activate interrupt state before yielding
  InterruptEvents in the catch path, so a consumer that stops at
  an InterruptEvent can still resume.
Unshure added a commit to strandly-the-agent/harness-sdk that referenced this pull request Oct 28, 2026
Reapplies two fixes from main that were dropped when the earlier
rebase of 31800e8 resolved via --theirs:

- fa313cc (strands-agents#5429) extract structuredOutput before the deferred
  append, so a message-added hook that offloads the JSON block
  can't lose it.
- a4865cd (strands-agents#5628) activate interrupt state before yielding
  InterruptEvents in the catch path, so a consumer that stops at
  an InterruptEvent can still resume.
Unshure added a commit to strandly-the-agent/harness-sdk that referenced this pull request Oct 29, 2026
Reapplies two fixes from main that were dropped when the earlier
rebase of 31800e8 resolved via --theirs:

- fa313cc (strands-agents#5429) extract structuredOutput before the deferred
  append, so a message-added hook that offloads the JSON block
  can't lose it.
- a4865cd (strands-agents#5628) activate interrupt state before yielding
  InterruptEvents in the catch path, so a consumer that stops at
  an InterruptEvent can still resume.
gautamsirdeshmukh pushed a commit to gautamsirdeshmukh/harness-sdk that referenced this pull request Nov 2, 2026

This branch was successfully deployed

1 inactive deployment
auto-approve — 18bfe3e8 Deployed Oct 28, 2026 by opieter-aws via Build and Deploy Preview #3243
manual-approval — 72fa875f Deployed Oct 28, 2026 by opieter-aws via Build and Deploy Preview #3235
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 bug Something isn't working complexity/low Touched functions have low cognitive complexity (<=10) size/s typescript Pull requests that update typescript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants