feat!: copy middleware context inputs to prevent accidental mutation#2742
feat!: copy middleware context inputs to prevent accidental mutation#2742zastrowm wants to merge 8 commits into
Conversation
Middleware now receives isolated copies of agent state rather than live references. Messages are deep-cloned via Message.clone(), systemPrompt via cloneSystemPrompt(), toolSpecs/toolChoice via structuredClone(), and invocationState via shallow copy. modelState is excluded from the context entirely and snapshotted/written-back outside the middleware chain so middleware cannot affect it at any point.
The project's eslint config uses an explicit globals allowlist that doesn't include structuredClone. Use the existing deepCopy utility instead.
|
Assessment: Comment Solid, well-tested defensive-copy change with a clear design rationale documented in the middleware README and PR description. The snapshot/writeback approach for Review Categories
Nice work isolating middleware from agent internals — the functional-style test cases passing modified contexts to |
ExecuteToolStage: deep-copy toolUse so middleware cannot mutate the model's parsed tool input in the assistant message. AgentStreamStage: shallow-copy array args so middleware cannot push/splice the caller's original array. invocationState is intentionally left as a shared reference across all stages — tools and hooks write to it and those mutations must appear on AgentResult.
| const context: AgentStreamContext = { | ||
| agent: this, | ||
| args, | ||
| args: Array.isArray(args) ? ([...args] as typeof args) : args, |
There was a problem hiding this comment.
Issue (Suggestion): The AgentStreamStage copy of args is shallow ([...args]), so push/splice on the array is isolated (nicely tested), but the Message objects inside the array are still shared by reference with the caller. A middleware that mutates context.args[0].content (or a block's fields) would leak back into the caller's Message — the same class of accidental-mutation bug this PR closes elsewhere.
This is also inconsistent with InvokeModelStage, which deep-copies each message via msg.clone().
Suggestion: Decide whether this is intentional and either:
- deep-copy the messages here too (e.g.
args.map((m) => m.clone())when the array holdsMessages) for consistency withInvokeModelStage, or - if shallow-by-design (perf /
argsmay hold non-Messageentries), document it alongside theinvocationState/modelStatesections inmiddleware/README.mdand add a test asserting the shallow boundary (mutatingcontext.args[0]does surface, so callers know the contract).
Minor, since args is the raw caller input rather than agent-internal state — but worth a deliberate call given the rest of the PR's isolation guarantees.
|
Re-review (round 2) — Assessment: Approve (one minor suggestion) All five items from the previous review have been addressed, and the PR's scope has grown thoughtfully (now Prior findings — all resolved ✅
VerificationChecked out the PR head and ran the suites locally: One new minor suggestion on the expanded scope: Nice iteration — the three-stage isolation story is now coherent and well-tested. |
| ...(toolChoice !== undefined && { toolChoice }), | ||
| modelState: this.modelState, | ||
| invocationState, | ||
| messages: this.messages.map((msg) => msg.clone()), |
There was a problem hiding this comment.
would it be more or less organized to write these cloning utilities in middleware/ instead of inline
There was a problem hiding this comment.
I tried it, but it just obscures the code IMO
| /** The tool use request (name, toolUseId, input). */ | ||
| readonly toolUse: ToolUseData | ||
| /** Per-invocation state shared across hooks and tools. */ | ||
| /** Per-invocation state shared across hooks and tools. Shared by reference — mutations are intentionally visible to subsequent hooks, tools, and the AgentResult. */ |
There was a problem hiding this comment.
"and the SDK should never make mutations directly"
|
|
||
| ## `invocationState` is shared by reference | ||
|
|
||
| `invocationState` is not copied. Tools and hooks write to it, and those mutations must appear on `AgentResult.invocationState`. |
There was a problem hiding this comment.
"and the SDK should never write to it"
There was a problem hiding this comment.
Fair, though this is larger than just middleware IMO
| if (data.metadata !== undefined) { | ||
| data.metadata = JSON.parse(JSON.stringify(data.metadata)) | ||
| } | ||
| return Message.fromMessageData(data) |
There was a problem hiding this comment.
The same toJSON() reference-sharing problem as flagged by the bot still affects ToolUseBlock.input and JsonBlock.json. Why not do a full JSON round-trip on the entire MessageData?
There was a problem hiding this comment.
I have to look more into this; it's either a miss that that metadata isn't part of toJSON() and fromMessageData OR metadata isn't normally copied and it's just in this case that we need it
There was a problem hiding this comment.
Yeah, switching to doin ga deep copy of the entire thing to avoid special casing metadata
There was a problem hiding this comment.
Confirming this empirically — I checked out HEAD (07cb13f) and ran a probe: cloning a Message with a ToolUseBlock, then mutating clone.content[0].input.nested.a, also mutates the original (1 → 999). So clone()'s targeted metadata round-trip closes only one leak; ToolUseBlock.input and JsonBlock.json still pass through toJSON() as live references. The full-MessageData round-trip you suggest fixes all three at once and removes the metadata special-case — agree that's the cleaner direction.
One thing to flag when that fix lands: the existing "message content blocks are deep copied" test only asserts receivedContent).not.toBe(...) (instance identity), which already passes today despite the nested-input leak — so it won't guard against regressions here. Worth upgrading it to mutate a nested field of a ToolUseBlock.input and assert the original is unchanged (same shape as the metadata-isolation test).
For the record, this also corrects my round-2 summary, which prematurely marked the clone() issue fully resolved — the metadata fix was real, but the underlying toJSON() reference-sharing wasn't.
|
|
||
| ## `invocationState` is shared by reference | ||
|
|
||
| `invocationState` is not copied. Tools and hooks write to it, and those mutations must appear on `AgentResult.invocationState`. |
There was a problem hiding this comment.
Is this true for the InvokeModelStage, given that we do { ...invocationState }?
There was a problem hiding this comment.
I think that'd break invocationState changes that any ModelStreamUpdateEvent and ContentBlockEvent hooks make
| ...(toolChoice !== undefined && { toolChoice }), | ||
| modelState: this.modelState, | ||
| invocationState, | ||
| messages: this.messages.map((msg) => msg.clone()), |
There was a problem hiding this comment.
These copies run on every model call even when no InvokeModelStage middleware is registered. Consider gating this, since it introduces latency as the conversation history grow
There was a problem hiding this comment.
going to defer this until we see performance problems. In general and compared to network requests, this is trivial and I think code clarity is more important until we're seen impact here
invocationState is intentionally shared mutable state — hooks and tools write to it during streaming and those mutations must appear on AgentResult. Shallow-copying it would break ModelStreamUpdateEvent and ContentBlockEvent hook mutations.
…vocationState - Remove args shallow copy from AgentStreamStage (shared by reference like invocationState — contains non-cloneable objects) - Document AgentStreamContext fields as shared by reference in README - Add "SDK should never write to it" note on invocationState
|
Re-review (round 3) — Assessment: Request Changes (one correctness item) Catching up after @opieter-aws and @notowen333's review and the latest push ( One correctness item should block merge, and I want to correct my own round-2 summary on it: Correction to round-2:
|
…tadata Avoids reference sharing for all nested fields (metadata, ToolUseBlock.input, JsonBlock.json) by deep-copying the entire MessageData rather than only metadata.
|
Re-review (round 4) — Assessment: Request Changes (one item remaining) Reviewed One blocker still stands — Still reproduces on
|
|
Re-review (round 5) — Assessment: Approve ✅ Reviewed Verified locally on this HEAD:
All blocking items are resolved. The One optional, non-blocking follow-upThe "message content blocks are deep copied" test ( |
Description
Middleware previously received live references to the agent's internal state. A middleware that accidentally mutated the messages array, a toolSpec, or modelState would corrupt agent state for subsequent turns.
This PR makes middleware context inputs defensive copies across all three stages:
InvokeModelStage:
messages: deep copy viaMessage.clone()systemPrompt: deep copy viacloneSystemPrompt()toolSpecs/toolChoice: deep copy viadeepCopy()modelState: removed from context — snapshotted before the chain and written back after it completesinvocationState: shared by reference (hooks and tools write to it during streaming)ExecuteToolStage:
toolUse: deep copy (prevents mutation of the parsed tool input in the assistant message)invocationState: shared by referenceAgentStreamStage:
args: shallow copy when array (prevents push/splice on the caller's array)options: shared by reference (contains non-cloneable objects and sharedinvocationState)Related Issues
Documentation PR
Design rationale documented in
strands-ts/src/middleware/README.md.Type of Change
Breaking change
Breaking Changes
modelStatehas been removed fromInvokeModelContext. Middleware that previously read or wrotecontext.modelStatewill fail to compile.Migration
Note: writes to
agent.modelStatewithin middleware will be overwritten by the post-chain writeback. This is intentional — model state is provider-internal bookkeeping.Testing
23 tests in
copy-on-input.test.tscovering:Messages deep copy (array reference, content blocks, metadata isolation)
Array systemPrompt deep copy; string passthrough
toolSpecs fresh copy per invocation
toolChoice deep copy when present (structured output)
modelState excluded from context; model writes sync back; middleware mutations discarded
invocationState shared by reference with mutations visible on result
ExecuteToolStage toolUse.input isolation
AgentStreamStage args array isolation
Functional style: passing modified context to
next()I ran
hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.