Skip to content

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

Open

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

  1. piyushmakad commented on Oct 12, 2026

    @piyushmakad

    I reproduced the datetime.date serialization failure on Windows with Python 3.11. I have a focused fix that uses Pydantic JSON serialization in validate_schema, plus regression tests for a model and a list of models. The 65 tests in test_schema_utils.py pass. Would a PR for this one part of #7480 be welcome?

  2. added
    models[Component] This issue is related to model support
    needs review[Status] The PR/issue is awaiting review from the maintainer
    on Oct 14, 2026
  3. llalitkumarrr commented on Oct 14, 2026

    @llalitkumarrr
    Collaborator

    Hello @wukath,

    Right now the history sent back to Claude gets changed between requests. Claude's thinking signatures then fail and the API returns a 400. We reproduced all three causes on 2.11.0.

    PR #7500 should fix the empty thinking cause. The date serialization cause can be fixed safely inside the Anthropic adapter.
    For the single-turn sub-agent leak the fix could be override_isolation_scope=fc_id. But this might lead to other issues.

    Could you please take a look at it and share your input?

  4. nboukraa commented on Oct 17, 2026

    @nboukraa
    Author

    In short: #7500 fixes the empty thinking cause, a date fix in the Anthropic adapter covers both paths, and override_isolation_scope=fc_id keeps the coordinator's history intact but changes what the sub-agent is asked. Thanks @llalitkumarrr for reproducing all three. The details below may help @wukath. All of them are on 2.11.0, with the reproduction script from this issue extended to also record the sub-agent's own requests.

    1. Empty thinking. With the one-line change from #7500 (head 1b91520), case 3 sends each thinking block back exactly as Claude sent it: {"type": "thinking", "thinking": "", "signature": ...}.

    2. Dates. A fix in the Anthropic adapter works for me. With AgentTool and with a single-turn sub-agent, the run stops at the same line, json.dumps(response_data) in _part_to_message_block (anthropic_llm.py:568), so one fix there covers both. For context: validate_schema was switched to mode="json" on 1 October (d1e61e5, from #6750, marked as a breaking change) and rolled back the same day (731ea30).

    3. Single-turn leak. I added override_isolation_scope=fc_id to the run_node call in _SingleTurnAgentTool.run_async, as task mode already does. Case 1 then passes: the coordinator's history only grows, and it still receives the sub-agent's answer as a tool_result.

    One thing changes for the sub-agent itself. Without the change, its request holds one message, its input (go). With it, its first message is the user's message to the coordinator (Hi) followed by _SINGLE_TURN_NUDGE ("Complete the task using only the information provided above"), and its input comes second. The cause is in _build_task_input_user_content. It looks for the function call whose id equals the scope, but the session the sub-agent sees holds only the user's message, not the coordinator's call (toolu_0). So it falls back to user_content. Its docstring expects that to hold the node input, stamped by the workflow wrapper, but on this path it still holds the user's message to the coordinator. The same happens in our own coordinator: its specialist gets the owner's question and the nudge before its JSON input.

    I can run a branch through the same checks if that helps.

  5. nboukraa commented on Oct 24, 2026

    @nboukraa
    Author

    Status on 2.12.0 and 2.13.0, which narrows this issue to two causes.

    Cause 1 (single-turn leak) is fixed in 2.12.0 by 2e06fdc ("keep tool sub-branch events out of the calling agent's LLM context"). _is_tool_sub_branch_event keeps the sub-agent's events out of the coordinator's request by branch. Case 1 of the script in this issue passes on 2.12.0, and our own coordinator with single-turn sub-agents also keeps its history append-only.

    So _SingleTurnAgentTool no longer needs override_isolation_scope=fc_id, which would have two side effects:

    1. The sub-agent would receive the user's message to the coordinator before its own input, as my comment above describes.
    2. The user's next message would go to the finished sub-agent instead of the coordinator. _find_active_task_scope treats a scope as closed only after a finish_task call, and only task agents have that tool, so the runner would take the finished sub-agent for a paused task.

    Causes 2 and 3 remain in 2.13.0. validate_schema still dumps without mode="json", and _part_to_message_block still sends a thought part with a signature and no text as redacted_thinking. #7591 and #7500 address them.

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

Metadata

Metadata

Labels

models[Component] This issue is related to model supportneeds review[Status] The PR/issue is awaiting review from the maintainer

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions