Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 87 additions & 18 deletions src/anthropic/lib/tools/_beta_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Iterator,
Coroutine,
AsyncIterator,
cast,
)
from contextlib import contextmanager, asynccontextmanager
from typing_extensions import TypedDict, override
Expand Down Expand Up @@ -55,6 +56,17 @@
log = logging.getLogger(__name__)


def _has_tool_result(messages: List[BetaMessageParam]) -> bool:
"""Return True if any message in the list contains a tool_result content block."""
for msg in messages:
content = msg.get("content", [])
if isinstance(content, list) and any(
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
):
return True
return False


class RequestOptions(TypedDict, total=False):
extra_headers: Headers | None
extra_query: Query | None
Expand Down Expand Up @@ -85,7 +97,6 @@ def __init__(
merged_headers = {**helper_header, **(options.get("extra_headers") or {})}
options = {**options, "extra_headers": merged_headers}
self._options = options
self._messages_modified = False
self._cached_tool_call_response: BetaMessageParam | None = None
self._max_iterations = max_iterations
self._iteration_count = 0
Expand Down Expand Up @@ -116,10 +127,14 @@ def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[Respon
{"role": message.role, "content": message.content} if isinstance(message, BetaMessage) else message
for message in messages
]
self._messages_modified = True
self.set_messages_params(lambda params: {**params, "messages": [*params["messages"], *message_params]})
self._cached_tool_call_response = None

def _set_messages_list(self, messages: List[BetaMessageParam]) -> None:
# `messages` is a parameter here (not a loop variable), so lambdas that
# capture it satisfy both ruff B023 and mypy's callable-type inference.
self.set_messages_params(lambda params: {**params, "messages": messages})

def _should_stop(self) -> bool:
if self._max_iterations is not None and self._iteration_count >= self._max_iterations:
return True
Expand Down Expand Up @@ -260,6 +275,7 @@ def _check_and_compact(self) -> bool:
def __run__(self) -> Iterator[RunnerItemT]:
while not self._should_stop():
with self._handle_request() as item:
pre_yield_message_count = len(cast(List[BetaMessageParam], self._params["messages"]))
yield item
message = self._get_last_message()
assert message is not None
Expand All @@ -273,15 +289,41 @@ def __run__(self) -> Iterator[RunnerItemT]:

# If the compaction was performed, skip tool call generation this iteration
if not self._check_and_compact():
response = self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return

if not self._messages_modified:
self.append_messages(message, response)
all_messages = list(self._params["messages"])
user_appended = all_messages[pre_yield_message_count:]

if _has_tool_result(user_appended):
# User provided their own tool result. Ensure the assistant message
# precedes it — insert it only when the user did not include it.
if not any(m.get("role") == "assistant" for m in user_appended):
asst_param: BetaMessageParam = {"role": message.role, "content": message.content}
new_messages: List[BetaMessageParam] = [
*all_messages[:pre_yield_message_count],
asst_param,
*user_appended,
]
self._set_messages_list(new_messages)
else:
response = self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return

if user_appended:
# User appended extra (non-tool_result) messages. Insert the
# auto-generated (assistant, tool_result) pair before them so
# message ordering stays valid for the API.
asst_param = {"role": message.role, "content": message.content}
new_messages = [
*all_messages[:pre_yield_message_count],
asst_param,
response,
*user_appended,
]
self._set_messages_list(new_messages)
else:
self.append_messages(message, response)

self._messages_modified = False
self._cached_tool_call_response = None

def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
Expand Down Expand Up @@ -541,6 +583,7 @@ async def _check_and_compact(self) -> bool:
async def __run__(self) -> AsyncIterator[RunnerItemT]:
while not self._should_stop():
async with self._handle_request() as item:
pre_yield_message_count = len(cast(List[BetaMessageParam], self._params["messages"]))
yield item
message = await self._get_last_message()
assert message is not None
Expand All @@ -554,15 +597,41 @@ async def __run__(self) -> AsyncIterator[RunnerItemT]:

# If the compaction was performed, skip tool call generation this iteration
if not await self._check_and_compact():
response = await self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return

if not self._messages_modified:
self.append_messages(message, response)
all_messages = list(self._params["messages"])
user_appended = all_messages[pre_yield_message_count:]

if _has_tool_result(user_appended):
# User provided their own tool result. Ensure the assistant message
# precedes it — insert it only when the user did not include it.
if not any(m.get("role") == "assistant" for m in user_appended):
asst_param: BetaMessageParam = {"role": message.role, "content": message.content}
new_messages: List[BetaMessageParam] = [
*all_messages[:pre_yield_message_count],
asst_param,
*user_appended,
]
self._set_messages_list(new_messages)
else:
response = await self.generate_tool_call_response()
if response is None:
log.debug("Tool call was not requested, exiting from tool runner loop.")
return

if user_appended:
# User appended extra (non-tool_result) messages. Insert the
# auto-generated (assistant, tool_result) pair before them so
# message ordering stays valid for the API.
asst_param = {"role": message.role, "content": message.content}
new_messages = [
*all_messages[:pre_yield_message_count],
asst_param,
response,
*user_appended,
]
self._set_messages_list(new_messages)
else:
self.append_messages(message, response)

self._messages_modified = False
self._cached_tool_call_response = None

async def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@
"anthropic-beta": "structured-outputs-2025-12-15",
"x-stainless-retry-count": "0",
"x-stainless-read-timeout": "600",
"content-length": "789"
"content-length": "598"
},
"body": {
"max_tokens": 1024,
Expand All @@ -146,12 +146,29 @@
"role": "user",
"content": "What's the weather in SF in Celsius?"
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01GHndag5wQmbzNihYmV2UBj",
"name": "get_weather",
"input": {
"location": "San Francisco, CA",
"units": "c"
},
"caller": {
"type": "direct"
}
}
]
},
{
"role": "user",
"content": [
{
"tool_use_id": "toolu_01GHndag5wQmbzNihYmV2UBj",
"content": "The weather in San Francisco, CA is currently sunny with a temperature of 20°C.",
"content": "The weather in San Francisco, CA is currently sunny with a temperature of 20\u00b0C.",
"type": "tool_result"
}
]
Expand Down Expand Up @@ -191,25 +208,40 @@
}
},
"response": {
"status_code": 400,
"status_code": 200,
"headers": {
"content-type": "application/json",
"content-length": "316",
"connection": "keep-alive",
"x-should-retry": "false",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"server": "cloudflare",
"cf-cache-status": "DYNAMIC",
"x-robots-tag": "none",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'"
},
"body": {
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.0.content.1: unexpected `tool_use_id` found in `tool_result` blocks: toolu_01GHndag5wQmbzNihYmV2UBj. Each `tool_result` block must have a corresponding `tool_use` block in the previous message."
},
"request_id": "req_011CYHyk9NPsBYeGbC9LuDNK"
"model": "claude-haiku-4-5-20251001",
"id": "msg_01DSPL7PHKQYTe9VAFkHzsA3",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "The weather in San Francisco, CA is currently **20\u00b0C** and **Sunny**. Nice weather!"
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 787,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"cache_creation": {
"ephemeral_5m_input_tokens": 0,
"ephemeral_1h_input_tokens": 0
},
"output_tokens": 26,
"service_tier": "standard"
}
}
}
}
Expand Down
Loading