Skip to content

Bug: toolRunner does not propagate container.id across iterations, and setMessagesParams causes duplicate tool call loops #964

@leonardomalzacher

Description

@leonardomalzacher

Summary

Two related bugs when using client.beta.messages.toolRunner with code_execution_20260120 and client-side tools (betaTool):

  1. container.id is not automatically forwarded from one iteration to the next, causing a hard error when client-side tool results are sent back.
  2. Using setMessagesParams to manually set container.id as a workaround causes the model to enter an infinite duplicate tool-calling loop.

Environment

  • @anthropic-ai/sdk version: 0.80.0
  • Node.js version: v22.14.0
  • Model: claude-opus-4-6 or claude-sonnet-4-6

Reproduction

import "dotenv/config";
import Anthropic from "@anthropic-ai/sdk";
import { betaTool } from "@anthropic-ai/sdk/helpers/beta/json-schema";

const client = new Anthropic();

const calculateSumTool = betaTool({
  name: "calculate_sum",
  description: "Add two numbers together",
  inputSchema: {
    type: "object",
    properties: {
      a: { type: "number", description: "First number" },
      b: { type: "number", description: "Second number" },
    },
    required: ["a", "b"],
  },
  run: async (input) => String(input.a + input.b),
});

const runner = client.beta.messages.toolRunner({
  model: "claude-opus-4-6",
  max_tokens: 1024,
  tools: [
    { ...calculateSumTool, allowed_callers: ["code_execution_20260120"] },
    { type: "code_execution_20260120", name: "code_execution" },
  ],
  messages: [{ role: "user", content: "What's the sum of 5 and 3?" }],
  stream: true,
});

for await (const messageStream of runner) {
  for await (const event of messageStream) {}

  const iterationMessage = await messageStream.finalMessage();
  console.log("Container: ", iterationMessage.container);
  console.log("Output: ", iterationMessage.content);

  const toolResult = await runner.generateToolResponse();
  console.log("Tool Result", toolResult);

  // could be a tmp or permanent fix -> causes infinite tool call loop
  if (iterationMessage.container?.id) {
    runner.setMessagesParams((params) => ({
      ...params,
      container: iterationMessage.container!.id, // passing string directly
    }));
  }
}

Bug 1: Missing container.id propagation

After iteration A completes, iterationMessage.container contains a valid container.id. However, when runner.generateToolResponse() triggers the next request, the container.id is not included, causing:

{
  "type": "invalid_request_error",
  "message": "container_id is required when there are pending tool uses generated by code execution with tools."
}

Expected behavior: toolRunner should automatically carry container.id from the previous iteration's response into the next request, the same way it already stitches tool_use / tool_result message pairs.


Bug 2: setMessagesParams workaround — two failure modes

Since the runner doesn't handle this automatically, the only recourse is setMessagesParams. Both available approaches are broken:

Approach A — pass an object:

runner.setMessagesParams((params) => ({
  ...params,
  container: {
    id: iterationMessage.container?.id,
    skills: [],
  },
}));

Fails immediately with:

{
  "type": "invalid_request_error",
  "message": "container: Input should be a valid string"
}

This is surprising — the container.id logged just before the call is a valid string (e.g. container_011CZZfD7NfLQumhoWHGwYmw), yet the API rejects it. It's unclear whether setMessagesParams is serializing the field incorrectly, or whether the API's container field type contract differs from the SDK's TypeScript types.

Approach B — pass the string directly:

runner.setMessagesParams((params) => ({
  ...params,
  container: iterationMessage.container!.id,
}));

Does not error, but causes an infinite tool-calling loop. The model repeatedly calls code_executioncalculate_sum with identical inputs and near-identical outputs, never terminating:

Container:  { id: 'container_011CZZfD7NfLQumhoWHGwYmw', expires_at: '...' }
Output:  [ { type: 'server_tool_use', name: 'code_execution', input: { code: '...calculate_sum({"a": 5, "b": 3})...' } },
           { type: 'tool_use', name: 'calculate_sum', input: { a: 5, b: 3 } } ]
Tool Result { role: 'user', content: [{ type: 'tool_result', tool_use_id: '...', content: '8' }] }
DEBUG: container_011CZZfD7NfLQumhoWHGwYmw
// ... repeats indefinitely with new tool IDs but identical inputs/outputs

This strongly suggests that setMessagesParams is corrupting or resetting the accumulated message history between iterations, causing the model to lose context of what it has already executed.


Additional context

The two bugs compound each other completely: Bug 1 makes container.id forwarding mandatory for this use case, and Bug 2 means both available workarounds either hard-error or loop infinitely. There is currently no working path to use client-side betaTools alongside code_execution_20260120 in a streaming toolRunner loop.


Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions