Summary
When using AsyncAnthropic.messages.stream() (streaming mode) with code_execution_20260120 (or any container-backed tool), the Message.container field returned by stream.get_final_message() is None, because the streaming aggregator does not merge delta.container from the message_delta SSE event into the accumulated Message.
The same API call via non-streaming messages.create() returns the container correctly.
This forces every caller to either:
- manually subscribe to
RawMessageDeltaEvent and extract event.delta.container.id themselves, or
- fall back to non-streaming mode (losing streaming benefits)
Without the container id, the subsequent API request fails with:
anthropic.BadRequestError: 400 - container_id is required when there are pending tool uses generated by code execution with tools.
Reproduction
Environment:
- SDK version:
anthropic==0.94.0
- Model:
claude-opus-4-7
- Tool:
code_execution_20260120
import asyncio
from anthropic import AsyncAnthropic
TOOLS = [{"type": "code_execution_20260120", "name": "code_execution"}]
PROMPT = [{"role": "user", "content": "print(2+2)"}]
COMMON = dict(
model="claude-opus-4-7",
max_tokens=2048,
system="Use code_execution.",
messages=PROMPT,
tools=TOOLS,
)
async def main() -> None:
client = AsyncAnthropic()
# --- streaming: .container is None ---
async with client.messages.stream(**COMMON) as stream:
async for _ in stream:
pass
final = await stream.get_final_message()
print("streaming final.container =", final.container) # None
# --- non-streaming: .container is populated ---
msg = await client.messages.create(**COMMON)
print("non-streaming msg.container =", msg.container) # Container(id=..., expires_at=...)
asyncio.run(main())
Where the data actually lands in streaming mode
Walking the raw stream events, the container info is present in a message_delta SSE event:
[RawMessageDeltaEvent]
{
"delta": {
"container": {
"id": "container_011CaGQWUDRWUFkRs833a8rF",
"expires_at": "2026-04-21T07:00:21.511595Z"
},
"stop_reason": "end_turn"
},
"type": "message_delta",
"usage": {...}
}
But the streaming aggregator in src/anthropic/lib/streaming/_messages.py never merges delta.container into the accumulated Message. Confirmed on main as of this writing (0.94.0 through 0.96.0): the message_delta branch of accumulate_event() only updates current_snapshot.stop_reason / stop_sequence / usage; grep container on the file returns zero non-import matches.
(The higher-level tool_runner helper appears to handle some aspects of container lifecycle on its own, but users of the low-level messages.stream() API are affected by this gap.)
Expected behavior
When a message_delta SSE event carries delta.container, the streaming aggregator should set the corresponding field on the accumulated Message, so stream.get_final_message().container matches the behavior of messages.create().
Impact
Any application doing multi-turn tool-use loops with code_execution_20260120 + streaming receives a 400 on the second turn, because the caller cannot obtain the container id from the SDK's happy-path API. The common workarounds (manual SSE parsing, or switching to non-streaming) feel like SDK bugs rather than user concerns.
Workaround
Intercept RawMessageDeltaEvent manually and stash event.delta.container.id during iteration:
from anthropic.types import RawMessageDeltaEvent
streamed_container_id: str | None = None
async with client.messages.stream(...) as stream:
async for ev in stream:
if isinstance(ev, RawMessageDeltaEvent):
delta_container = getattr(ev.delta, "container", None)
if delta_container is not None:
streamed_container_id = delta_container.id
final = await stream.get_final_message()
# streamed_container_id now has the value that `final.container` should have held
Suggested fix
In src/anthropic/lib/streaming/_messages.py accumulate_event(), extend the elif event.type == "message_delta" branch (lines 503-516 in 0.94.0, same place where current_snapshot.stop_reason / stop_sequence / usage are updated) to also propagate container:
if getattr(event.delta, "container", None) is not None:
current_snapshot.container = event.delta.container
Related
Summary
When using
AsyncAnthropic.messages.stream()(streaming mode) withcode_execution_20260120(or any container-backed tool), theMessage.containerfield returned bystream.get_final_message()isNone, because the streaming aggregator does not mergedelta.containerfrom themessage_deltaSSE event into the accumulatedMessage.The same API call via non-streaming
messages.create()returns the container correctly.This forces every caller to either:
RawMessageDeltaEventand extractevent.delta.container.idthemselves, orWithout the container id, the subsequent API request fails with:
Reproduction
Environment:
anthropic==0.94.0claude-opus-4-7code_execution_20260120Where the data actually lands in streaming mode
Walking the raw stream events, the container info is present in a
message_deltaSSE event:But the streaming aggregator in
src/anthropic/lib/streaming/_messages.pynever mergesdelta.containerinto the accumulatedMessage. Confirmed onmainas of this writing (0.94.0 through 0.96.0): themessage_deltabranch ofaccumulate_event()only updatescurrent_snapshot.stop_reason/stop_sequence/usage;grep containeron the file returns zero non-import matches.(The higher-level
tool_runnerhelper appears to handle some aspects of container lifecycle on its own, but users of the low-levelmessages.stream()API are affected by this gap.)Expected behavior
When a
message_deltaSSE event carriesdelta.container, the streaming aggregator should set the corresponding field on the accumulatedMessage, sostream.get_final_message().containermatches the behavior ofmessages.create().Impact
Any application doing multi-turn tool-use loops with
code_execution_20260120+ streaming receives a 400 on the second turn, because the caller cannot obtain the container id from the SDK's happy-path API. The common workarounds (manual SSE parsing, or switching to non-streaming) feel like SDK bugs rather than user concerns.Workaround
Intercept
RawMessageDeltaEventmanually and stashevent.delta.container.idduring iteration:Suggested fix
In
src/anthropic/lib/streaming/_messages.pyaccumulate_event(), extend theelif event.type == "message_delta"branch (lines 503-516 in 0.94.0, same place wherecurrent_snapshot.stop_reason/stop_sequence/usageare updated) to also propagatecontainer:Related