From b092a939276725fb74f1ab059b4ec13e3cd52195 Mon Sep 17 00:00:00 2001 From: Quentin Bisson Date: Wed, 26 Aug 2026 15:31:15 +0200 Subject: [PATCH 1/2] fix(flows): pair every function call with a result in request contents A function call and its result are two separate session events. When a turn ends between them (a restart, an OOM kill, a disconnect, a cancellation), the session keeps a function_call that no function_response answers. That history is replayed on every later turn, and a provider that requires strict pairing rejects the whole conversation. Anthropic answers "tool_use ids were found without tool_result blocks immediately after", and the session stays unusable until it is deleted. Give every unanswered call a placeholder result in the immediately following content, while the request contents are assembled. The stored events are left untouched, so recorded history stays intact and a session that is already broken heals on its next turn without a migration. It sits above the session service, so it covers every store and every provider. A call the framework is holding open (a long-running tool, an approval, a request for user input) is described as awaiting a response, not as having returned nothing: told a tool returned nothing, the model reissues the call or proceeds without it; told the call is still awaiting a response, it can wait. Those ids are read from long_running_tool_ids, which lives on the event and does not survive the conversion to contents. --- src/google/adk/flows/llm_flows/contents.py | 155 ++++++++ tests/unittests/apps/test_compaction.py | 16 +- .../flows/llm_flows/test_contents_function.py | 371 ++++++++++++++++++ 3 files changed, 540 insertions(+), 2 deletions(-) diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index 9aca51ec05b..0d3db816fd3 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -289,6 +289,155 @@ def _drop_orphaned_function_responses( return result_events +# Stands in for a result that was never recorded. It names no cause, because +# there is none to name: the turn may have been interrupted, or the process may +# have died between the two events. +_MISSING_FUNCTION_RESULT = 'No response available for this function call.' + +# Stands in for a call ADK is deliberately holding open: a long-running tool, a +# human approval, or a request for user input. No function response exists for +# these until the answer arrives, so the call is pending rather than lost. The +# distinction changes what the model does next: told a tool returned nothing, it +# reissues the call or proceeds without it; told the call is still awaiting a +# response, it can wait. +_PENDING_FUNCTION_RESULT = ( + 'This call is awaiting a response and has not completed yet.' +) + + +def _pending_call_ids(events: list[Event]) -> set[str]: + """Returns the ids of the calls ADK is holding open. + + ``long_running_tool_ids`` lives on the event and does not survive the + conversion to contents, so it is read here. + + Args: + events: The events being assembled into request contents. + + Returns: + The ids of the calls that are awaiting a response. + """ + pending: set[str] = set() + for event in events: + if event.long_running_tool_ids: + pending.update(event.long_running_tool_ids) + return pending + + +def _unanswered_calls( + calls: list[types.FunctionCall], + answered_ids: list[str | None], +) -> list[types.FunctionCall]: + """Returns the calls that ``answered_ids`` does not account for. + + Ids are consumed one at a time rather than matched through a set, so a turn + that carries several calls without an id (Gemini omits them, and ADK strips + its own ``adk-`` ids before the request is built) does not have a single + response silently answer all of them. + + Args: + calls: The function calls in one content. + answered_ids: The ids of the function responses that follow it, in order. + + Returns: + The calls with no response of their own. + """ + remaining = list(answered_ids) + unanswered: list[types.FunctionCall] = [] + for call in calls: + if call.id in remaining: + remaining.remove(call.id) + continue + unanswered.append(call) + return unanswered + + +def _pair_unanswered_function_calls( + contents: list[types.Content], + pending_ids: set[str], +) -> list[types.Content]: + """Gives every function call a function response in the next content. + + A call and its result are two separate session events. When a turn ends + between them (a restart, an OOM kill, a disconnect, a cancellation), the + session keeps a ``function_call`` that no ``function_response`` answers. That + history is replayed on every later turn, and a provider that requires strict + pairing then rejects the whole conversation: Anthropic answers ``tool_use ids + were found without tool_result blocks immediately after``, and the session + stays unusable until it is deleted. + + The repair runs on the request rather than the stored events, so recorded + history stays intact and a session that is already broken heals on its next + turn without a migration. It also sits above the session service, so it + applies to every store. + + Pairing is checked positionally, against the immediately following content, + because that is the invariant the provider enforces. A conversation whose + calls are all answered is returned unchanged, and any conversation this does + change is one the provider would have rejected. + + Args: + contents: The contents built for the request. Not mutated in place, except + that a placeholder is appended to the ``parts`` list of a content that + already answers part of a turn. + pending_ids: The ids of the calls ADK is holding open. + + Returns: + The contents, with a placeholder response for every unanswered call. + """ + paired: list[types.Content] = [] + synthesized = 0 + for index, content in enumerate(contents): + paired.append(content) + + calls = [p.function_call for p in content.parts or [] if p.function_call] + if not calls: + continue + + following = contents[index + 1] if index + 1 < len(contents) else None + answered_ids = [ + p.function_response.id + for p in (following.parts or [] if following else []) + if p.function_response + ] + unanswered = _unanswered_calls(calls, answered_ids) + if not unanswered: + continue + synthesized += len(unanswered) + + parts = [ + types.Part( + function_response=types.FunctionResponse( + id=call.id, + name=call.name, + response={ + 'result': ( + _PENDING_FUNCTION_RESULT + if call.id and call.id in pending_ids + else _MISSING_FUNCTION_RESULT + ) + }, + ) + ) + for call in unanswered + ] + + # Join a turn that already answers this call event, so the results stay in + # one message; otherwise the results need a turn of their own, before + # whatever currently follows. + if following is not None and answered_ids: + following.parts = list(following.parts or []) + parts + else: + paired.append(types.Content(role='user', parts=parts)) + + if synthesized: + logger.info( + 'Paired %d unanswered function call(s) before the model request', + synthesized, + ) + return paired + + def _rearrange_events_for_latest_function_response( events: list[Event], ) -> list[Event]: @@ -778,6 +927,12 @@ def _get_contents( ) ) + # Ids are read after the conversion so a stripped call id is answered by a + # response with the same stripped id. + contents = _pair_unanswered_function_calls( + contents, _pending_call_ids(result_events) + ) + # for scoped agents (task / single_turn), prepend a # synthetic user-role content built from the originating FC's args. # The FC lives in an UNSCOPED parent event (e.g., the coordinator's diff --git a/tests/unittests/apps/test_compaction.py b/tests/unittests/apps/test_compaction.py index 0a5135c650b..3356ab9978b 100644 --- a/tests/unittests/apps/test_compaction.py +++ b/tests/unittests/apps/test_compaction.py @@ -1146,7 +1146,13 @@ async def test_sliding_window_pending_function_call_remains_in_contents( result_contents[1].parts[0].function_call.name, 'tool', ) - self.assertEqual(result_contents[2].parts[0].text, 'e3') + # The pending call has no result yet, so contents assembly pairs it with a + # placeholder before the next content. + self.assertEqual( + result_contents[2].parts[0].function_response.name, + 'tool', + ) + self.assertEqual(result_contents[3].parts[0].text, 'e3') async def test_token_threshold_excludes_pending_function_call_events(self): """Token-threshold compaction stays contiguous before pending calls.""" @@ -1227,7 +1233,13 @@ async def test_token_threshold_pending_function_call_remains_in_contents( result_contents[1].parts[0].function_call.name, 'tool', ) - self.assertEqual(result_contents[2].parts[0].text, 'e3') + # The pending call has no result yet, so contents assembly pairs it with a + # placeholder before the next content. + self.assertEqual( + result_contents[2].parts[0].function_response.name, + 'tool', + ) + self.assertEqual(result_contents[3].parts[0].text, 'e3') async def test_completed_function_call_pair_is_still_compacted(self): """Completed function call/response pairs must still be compacted.""" diff --git a/tests/unittests/flows/llm_flows/test_contents_function.py b/tests/unittests/flows/llm_flows/test_contents_function.py index 7fa444bc8ee..fddf7ff1747 100644 --- a/tests/unittests/flows/llm_flows/test_contents_function.py +++ b/tests/unittests/flows/llm_flows/test_contents_function.py @@ -639,3 +639,374 @@ async def test_orphaned_function_response_dropped_mid_history(): ("user", "Regular message"), ("user", "Later message"), ] + + +@pytest.mark.asyncio +async def test_interrupted_call_is_paired_with_placeholder(): + """A call whose turn ended before its result gets a placeholder result.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + function_call = types.FunctionCall( + id="call_123", name="search_tool", args={"query": "test"} + ) + + invocation_context.session.events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Search for test"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([types.Part(function_call=function_call)]), + ), + # The turn ended here: the result was never recorded. + Event( + invocation_id="inv3", + author="user", + content=types.UserContent("Are you still there?"), + ), + ] + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + assert llm_request.contents == [ + types.UserContent("Search for test"), + types.ModelContent([types.Part(function_call=function_call)]), + types.Content( + role="user", + parts=[ + types.Part( + function_response=types.FunctionResponse( + id="call_123", + name="search_tool", + response={"result": contents._MISSING_FUNCTION_RESULT}, + ) + ) + ], + ), + types.UserContent("Are you still there?"), + ] + + +@pytest.mark.asyncio +async def test_partially_answered_turn_joins_the_existing_response(): + """A placeholder joins the response turn that answers the other call.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + answered_call = types.FunctionCall( + id="call_1", name="search_tool", args={"query": "test"} + ) + lost_call = types.FunctionCall( + id="call_2", name="fetch_tool", args={"url": "http://example.com"} + ) + function_response = types.FunctionResponse( + id="call_1", name="search_tool", response={"results": ["item1"]} + ) + + invocation_context.session.events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Search and fetch"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([ + types.Part(function_call=answered_call), + types.Part(function_call=lost_call), + ]), + ), + Event( + invocation_id="inv3", + author="user", + content=types.UserContent( + [types.Part(function_response=function_response)] + ), + ), + ] + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + assert llm_request.contents == [ + types.UserContent("Search and fetch"), + types.ModelContent([ + types.Part(function_call=answered_call), + types.Part(function_call=lost_call), + ]), + types.UserContent([ + types.Part(function_response=function_response), + types.Part( + function_response=types.FunctionResponse( + id="call_2", + name="fetch_tool", + response={"result": contents._MISSING_FUNCTION_RESULT}, + ) + ), + ]), + ] + + +@pytest.mark.asyncio +async def test_pending_long_running_call_gets_the_pending_placeholder(): + """A call ADK holds open is described as awaiting a response.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + long_running_call = types.FunctionCall( + id="lro_call_1", name="ask_user", args={"question": "proceed?"} + ) + + invocation_context.session.events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Do the thing"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent( + [types.Part(function_call=long_running_call)] + ), + long_running_tool_ids={"lro_call_1"}, + ), + ] + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + assert llm_request.contents == [ + types.UserContent("Do the thing"), + types.ModelContent([types.Part(function_call=long_running_call)]), + types.Content( + role="user", + parts=[ + types.Part( + function_response=types.FunctionResponse( + id="lro_call_1", + name="ask_user", + response={"result": contents._PENDING_FUNCTION_RESULT}, + ) + ) + ], + ), + ] + + +@pytest.mark.asyncio +async def test_answered_long_running_call_is_left_alone(): + """A long-running call that has a result keeps it.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + long_running_call = types.FunctionCall( + id="lro_call_1", name="ask_user", args={"question": "proceed?"} + ) + function_response = types.FunctionResponse( + id="lro_call_1", name="ask_user", response={"answer": "yes"} + ) + + invocation_context.session.events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Do the thing"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent( + [types.Part(function_call=long_running_call)] + ), + long_running_tool_ids={"lro_call_1"}, + ), + Event( + invocation_id="inv3", + author="user", + content=types.UserContent( + [types.Part(function_response=function_response)] + ), + ), + ] + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + assert llm_request.contents == [ + types.UserContent("Do the thing"), + types.ModelContent([types.Part(function_call=long_running_call)]), + types.UserContent([types.Part(function_response=function_response)]), + ] + + +@pytest.mark.asyncio +async def test_one_response_does_not_answer_two_calls_without_id(): + """Calls without an id are answered one at a time, not as a group.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + first_call = types.FunctionCall(name="search_tool", args={"query": "a"}) + second_call = types.FunctionCall(name="search_tool", args={"query": "b"}) + function_response = types.FunctionResponse( + name="search_tool", response={"results": ["item1"]} + ) + + invocation_context.session.events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Search twice"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([ + types.Part(function_call=first_call), + types.Part(function_call=second_call), + ]), + ), + Event( + invocation_id="inv3", + author="user", + content=types.UserContent( + [types.Part(function_response=function_response)] + ), + ), + ] + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + assert llm_request.contents[-1] == types.UserContent([ + types.Part(function_response=function_response), + types.Part( + function_response=types.FunctionResponse( + name="search_tool", + response={"result": contents._MISSING_FUNCTION_RESULT}, + ) + ), + ]) + + +@pytest.mark.asyncio +async def test_paired_contents_satisfy_the_anthropic_invariant(): + """Every tool_use block is followed by its tool_result block.""" + anthropic_llm = pytest.importorskip("google.adk.models.anthropic_llm") + + agent = Agent(model="claude-sonnet-4-5", name="test_agent") + llm_request = LlmRequest(model="claude-sonnet-4-5") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + function_call = types.FunctionCall( + id="call_123", name="search_tool", args={"query": "test"} + ) + + invocation_context.session.events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Search for test"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([types.Part(function_call=function_call)]), + ), + Event( + invocation_id="inv3", + author="user", + content=types.UserContent("Are you still there?"), + ), + ] + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + messages = [ + anthropic_llm.content_to_message_param(content) + for content in llm_request.contents + ] + _assert_tool_use_blocks_are_answered(messages) + + +def _assert_tool_use_blocks_are_answered(messages) -> None: + """Asserts each tool_use block is answered by the next message.""" + for index, message in enumerate(messages): + tool_use_ids = [ + block["id"] + for block in message["content"] + if isinstance(block, dict) and block.get("type") == "tool_use" + ] + if not tool_use_ids: + continue + following = messages[index + 1] if index + 1 < len(messages) else None + result_ids = ( + [ + block["tool_use_id"] + for block in following["content"] + if isinstance(block, dict) and block.get("type") == "tool_result" + ] + if following + else [] + ) + assert sorted(tool_use_ids) == sorted(result_ids) + + +@pytest.mark.asyncio +async def test_unpaired_contents_fail_the_anthropic_invariant(): + """The invariant check rejects the history the repair is there to fix.""" + anthropic_llm = pytest.importorskip("google.adk.models.anthropic_llm") + + unpaired = [ + types.UserContent("Search for test"), + types.ModelContent([ + types.Part( + function_call=types.FunctionCall( + id="call_123", name="search_tool", args={"query": "test"} + ) + ) + ]), + types.UserContent("Are you still there?"), + ] + + messages = [ + anthropic_llm.content_to_message_param(content) for content in unpaired + ] + with pytest.raises(AssertionError): + _assert_tool_use_blocks_are_answered(messages) From a46240fa594cdee0921000715aa1224dc492b841 Mon Sep 17 00:00:00 2001 From: Quentin Bisson Date: Wed, 26 Aug 2026 15:57:10 +0200 Subject: [PATCH 2/2] fix(flows): keep a placeholder result ahead of trailing parts Anthropic requires every tool result to precede any other block in the message that carries it. Appending the placeholder to the end of a partially answered turn put it after a trailing text part, which is rejected for the same reason the missing result was. Insert the placeholder into the leading run of responses instead, and warn only for a call with no recorded response, so a call that is merely awaiting an answer stays quiet across the turns it spans. --- src/google/adk/flows/llm_flows/contents.py | 83 +++++++++++----- .../flows/llm_flows/test_contents_function.py | 97 +++++++++++++++++-- 2 files changed, 144 insertions(+), 36 deletions(-) diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index 0d3db816fd3..8451dbbc0a4 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -378,7 +378,7 @@ def _pair_unanswered_function_calls( Args: contents: The contents built for the request. Not mutated in place, except - that a placeholder is appended to the ``parts`` list of a content that + that a placeholder is inserted into the ``parts`` list of a content that already answers part of a turn. pending_ids: The ids of the calls ADK is holding open. @@ -386,7 +386,7 @@ def _pair_unanswered_function_calls( The contents, with a placeholder response for every unanswered call. """ paired: list[types.Content] = [] - synthesized = 0 + lost = 0 for index, content in enumerate(contents): paired.append(content) @@ -403,41 +403,72 @@ def _pair_unanswered_function_calls( unanswered = _unanswered_calls(calls, answered_ids) if not unanswered: continue - synthesized += len(unanswered) - - parts = [ - types.Part( - function_response=types.FunctionResponse( - id=call.id, - name=call.name, - response={ - 'result': ( - _PENDING_FUNCTION_RESULT - if call.id and call.id in pending_ids - else _MISSING_FUNCTION_RESULT - ) - }, - ) - ) - for call in unanswered - ] + + parts: list[types.Part] = [] + for call in unanswered: + is_pending = bool(call.id) and call.id in pending_ids + if not is_pending: + lost += 1 + parts.append( + types.Part( + function_response=types.FunctionResponse( + id=call.id, + name=call.name, + response={ + 'result': ( + _PENDING_FUNCTION_RESULT + if is_pending + else _MISSING_FUNCTION_RESULT + ) + }, + ) + ) + ) # Join a turn that already answers this call event, so the results stay in # one message; otherwise the results need a turn of their own, before # whatever currently follows. - if following is not None and answered_ids: - following.parts = list(following.parts or []) + parts + if answered_ids: + following.parts = _with_results_inserted(following.parts, parts) else: paired.append(types.Content(role='user', parts=parts)) - if synthesized: - logger.info( - 'Paired %d unanswered function call(s) before the model request', - synthesized, + if lost: + logger.warning( + 'Supplied a placeholder result for %d function call(s) with no' + ' recorded response', + lost, ) return paired +def _with_results_inserted( + parts: list[types.Part] | None, + results: list[types.Part], +) -> list[types.Part]: + """Returns ``parts`` with ``results`` added to its leading run of responses. + + Anthropic requires every tool result to precede any other block in the + message that carries it, so a placeholder appended after a trailing text part + would be rejected for the same reason the missing result was. + + Args: + parts: The parts of the content that already answers part of the turn. + results: The placeholder function response parts to insert. + + Returns: + A new list of parts, with the placeholders before the first part that is + not a function response. + """ + existing = list(parts or []) + insert_index = len(existing) + for index, part in enumerate(existing): + if not part.function_response: + insert_index = index + break + return existing[:insert_index] + results + existing[insert_index:] + + def _rearrange_events_for_latest_function_response( events: list[Event], ) -> list[Event]: diff --git a/tests/unittests/flows/llm_flows/test_contents_function.py b/tests/unittests/flows/llm_flows/test_contents_function.py index fddf7ff1747..28f79aab0bd 100644 --- a/tests/unittests/flows/llm_flows/test_contents_function.py +++ b/tests/unittests/flows/llm_flows/test_contents_function.py @@ -966,7 +966,11 @@ async def test_paired_contents_satisfy_the_anthropic_invariant(): def _assert_tool_use_blocks_are_answered(messages) -> None: - """Asserts each tool_use block is answered by the next message.""" + """Asserts each tool_use block is answered by the next message. + + Also asserts the placement Anthropic requires: every tool_result block comes + before any other block in the message that carries it. + """ for index, message in enumerate(messages): tool_use_ids = [ block["id"] @@ -976,16 +980,89 @@ def _assert_tool_use_blocks_are_answered(messages) -> None: if not tool_use_ids: continue following = messages[index + 1] if index + 1 < len(messages) else None - result_ids = ( - [ - block["tool_use_id"] - for block in following["content"] - if isinstance(block, dict) and block.get("type") == "tool_result" - ] - if following - else [] - ) + blocks = following["content"] if following else [] + types_in_order = [ + block.get("type") for block in blocks if isinstance(block, dict) + ] + result_ids = [ + block["tool_use_id"] + for block in blocks + if isinstance(block, dict) and block.get("type") == "tool_result" + ] assert sorted(tool_use_ids) == sorted(result_ids) + leading_results = 0 + for block_type in types_in_order: + if block_type != "tool_result": + break + leading_results += 1 + assert leading_results == len(result_ids) + + +@pytest.mark.asyncio +async def test_placeholder_stays_ahead_of_a_trailing_text_part(): + """A placeholder joins the leading run of results, not the end of the turn.""" + anthropic_llm = pytest.importorskip("google.adk.models.anthropic_llm") + + agent = Agent(model="claude-sonnet-4-5", name="test_agent") + llm_request = LlmRequest(model="claude-sonnet-4-5") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + answered_call = types.FunctionCall( + id="call_1", name="search_tool", args={"query": "test"} + ) + lost_call = types.FunctionCall( + id="call_2", name="fetch_tool", args={"url": "http://example.com"} + ) + function_response = types.FunctionResponse( + id="call_1", name="search_tool", response={"results": ["item1"]} + ) + + invocation_context.session.events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Search and fetch"), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.ModelContent([ + types.Part(function_call=answered_call), + types.Part(function_call=lost_call), + ]), + ), + Event( + invocation_id="inv3", + author="user", + content=types.UserContent([ + types.Part(function_response=function_response), + types.Part(text="and please hurry"), + ]), + ), + ] + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + assert llm_request.contents[-1].parts == [ + types.Part(function_response=function_response), + types.Part( + function_response=types.FunctionResponse( + id="call_2", + name="fetch_tool", + response={"result": contents._MISSING_FUNCTION_RESULT}, + ) + ), + types.Part(text="and please hurry"), + ] + _assert_tool_use_blocks_are_answered([ + anthropic_llm.content_to_message_param(content) + for content in llm_request.contents + ]) @pytest.mark.asyncio