Skip to content

AnthropicLlm: single_turn sub-agent turns, AgentTool dates and empty thinking blocks change the history Claude signed (2.11.0)聽#7480

Description

@nboukraa

馃敶 Required Information

Describe the Bug:
With AnthropicLlm, the history ADK sends to Claude is not the previous request plus Claude's reply. Claude signs each thinking block over everything before it (preserved thinking), and on accounts where that check is enforced the API refuses the request with a 400 ("Invalid signature in redacted_thinking block. The block is bound to a different conversation."). Three causes, all in 2.11.0:

  1. A single_turn sub-agent's turn leaks into the coordinator's history. _SingleTurnAgentTool.run_async (tools/agent_tool.py) runs the sub-agent with override_branch but no override_isolation_scope, unlike _dispatch_task_fc for task mode. InvocationContext.isolation_scope says single-turn agents are scoped under the originating function call id, and the collaboration guide says each single-turn agent "operates in its own isolated session branch". The sub-agent's events keep scope None, and a root coordinator has no branch, so _should_include_event_in_context keeps them. They are left out of the request right after the call, then inserted as "For context: below is a transcript of what another agent did" text in the next one, before the model's previous reply.
  2. AgentTool returns Python objects that the Anthropic adapter cannot serialize. validate_schema (utils/_schema_utils.py) returns model_dump(exclude_none=True) without mode="json", so a datetime.date field in the sub-agent's output_schema reaches json.dumps in anthropic_llm._part_to_message_block and the run stops with TypeError: Object of type date is not JSON serializable.
  3. A thinking block with empty text comes back as redacted_thinking. _part_to_message_block sends a thought part with no text and a signature as {"type": "redacted_thinking", "data": <signature>}. Claude Sonnet 5.5 and Opus 5.5 return thinking blocks with empty text by default (display: "omitted"), so every thinking block is rewritten. (It also drops the caller field of tool_use blocks. That one is consistent across requests.)

AgentTool avoids cause 1 but its docstring calls direct use discouraged, and no public setting gives a root coordinator a branch (a Workflow node gets none, ParallelAgent is deprecated and fails to run a single-turn sub-agent).

Steps to Reproduce:

  1. pip install "google-adk==2.11.0" (with anthropic 1.11.0, httpx2)
  2. Run the script below. It uses a fake Messages API behind the real AsyncAnthropic client, so no key or network is needed.
  3. It compares each request with the previous request plus the model's reply.

Expected Behavior:
Each request starts with the previous request's messages and the model's reply, unchanged, and a sub-agent's output schema with a date reaches the model as JSON.

Observed Behavior:

1. single_turn sub-agent:
request 2, message 3: sent {"role": "user", "content": [{"text": "For context: below is a transcript of what another agent did, quoted between <<<BEGIN_QUOTED_AGENT_CONTENT>>> and <<<END_, expected {"role": "assistant", "content": [{"type": "thinking", "thinking": "summary", "signature": "sig-1"}, {"type": "tool_use", "id": "toolu_1", "name": "clock", "inp
2. AgentTool, output schema with a date:
run stopped: TypeError: Object of type date is not JSON serializable
3. AgentTool, empty thinking text (display omitted):
request 1, message 1: sent {"role": "assistant", "content": [{"type": "redacted_thinking", "data": "sig-0"}, {"id": "toolu_0", "name": "sub", "input": {"request": "go"}, "type": "tool_use, expected {"role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "sig-0"}, {"type": "tool_use", "id": "toolu_0", "name": "sub", "input": {"re
request 2, message 3: sent {"role": "assistant", "content": [{"type": "redacted_thinking", "data": "sig-1"}, {"id": "toolu_1", "name": "clock", "input": {}, "type": "tool_use"}]}, expected {"role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "sig-1"}, {"type": "tool_use", "id": "toolu_1", "name": "clock", "input": {}

Environment Details:

  • ADK Library Version (pip show google-adk): 2.11.0
  • Desktop OS: Linux
  • Python Version (python -V): 3.11.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: claude-sonnet-5-5 through AnthropicLlm

馃煛 Optional Information

Regression: Not known.

Minimal Reproduction Code:

"""Reproduce three history changes in ADK's Anthropic path (no network).

A fake Messages API behind the real AsyncAnthropic client records each
request body. For each pair of consecutive requests, the earlier
messages plus the model's reply should open the later request unchanged.
"""

import asyncio
import datetime
import json

import httpx2
from anthropic import AsyncAnthropic
from google.adk.agents import LlmAgent
from google.adk.models.anthropic_llm import AnthropicLlm
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.tools.agent_tool import AgentTool
from google.genai import types
from pydantic import BaseModel


class Note(BaseModel):
    day: datetime.date


def make_fake(script):
    bodies, replies = [], []

    async def handle(request):
        body = json.loads(request.content)
        if body["system"].startswith("Sub"):
            content = [{"type": "text", "text": '{"day": "2026-10-07"}'}]
            stop = "end_turn"
        else:
            content, stop = script(len(bodies))
            bodies.append(body)
            replies.append(content)
        reply = {
            "id": "msg", "type": "message", "role": "assistant",
            "model": body["model"], "content": content,
            "stop_reason": stop, "stop_sequence": None,
            "usage": {"input_tokens": 1, "output_tokens": 1},
        }
        return httpx2.Response(200, json=reply)

    client = AsyncAnthropic(
        api_key="x",
        http_client=httpx2.AsyncClient(
            transport=httpx2.MockTransport(handle)
        ),
    )
    return client, bodies, replies


def script(thinking_text):
    def turn(n):
        thinking = {
            "type": "thinking", "thinking": thinking_text,
            "signature": f"sig-{n}",
        }
        calls = [("sub", {"request": "go"}), ("clock", {})]
        if n < len(calls):
            name, args = calls[n]
            return [thinking, {
                "type": "tool_use", "id": f"toolu_{n}", "name": name,
                "input": args, "caller": {"type": "direct"},
            }], "tool_use"
        return [thinking, {"type": "text", "text": "done"}], "end_turn"

    return turn


def clock() -> dict:
    """Return the time."""
    return {"time": "noon"}


async def run(mode, thinking_text="summary", output_schema=None):
    client, bodies, replies = make_fake(script(thinking_text))
    model = AnthropicLlm(model="claude-sonnet-5-5", client=client)
    sub = LlmAgent(
        name="sub", model=model, instruction="Sub agent.",
        description="A helper.", output_schema=output_schema,
    )
    if mode == "single_turn":
        sub.mode = "single_turn"
        root = LlmAgent(name="root", model=model, instruction="Root.",
                        tools=[clock], sub_agents=[sub])
    else:
        root = LlmAgent(name="root", model=model, instruction="Root.",
                        tools=[clock, AgentTool(sub)])
    sessions = InMemorySessionService()
    runner = Runner(app_name="r", agent=root, session_service=sessions)
    await sessions.create_session(app_name="r", user_id="u", session_id="s")
    message = types.Content(role="user", parts=[types.Part(text="Hi")])
    try:
        async for _ in runner.run_async(
            user_id="u", session_id="s", new_message=message
        ):
            pass
    except Exception as exc:
        return [f"run stopped: {type(exc).__name__}: {exc}"]
    finally:
        await runner.close()
    problems = []
    for i, later in enumerate(bodies[1:]):
        # ADK also drops "caller" from tool_use blocks, ignored here.
        reply = [
            {k: v for k, v in b.items() if k != "caller"} for b in replies[i]
        ]
        sent = bodies[i]["messages"] + [
            {"role": "assistant", "content": reply}
        ]
        for n, msg in enumerate(sent):
            if n >= len(later["messages"]) or later["messages"][n] != msg:
                problems.append(
                    f"request {i + 1}, message {n}: sent "
                    f"{json.dumps(later['messages'][n])[:160]}, "
                    f"expected {json.dumps(msg)[:160]}"
                )
                break
    return problems


async def main():
    print("1. single_turn sub-agent:")
    print(*await run("single_turn"), sep="\n")
    print("2. AgentTool, output schema with a date:")
    print(*await run("agent_tool", output_schema=Note), sep="\n")
    print("3. AgentTool, empty thinking text (display omitted):")
    print(*await run("agent_tool", thinking_text=""), sep="\n")


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions