Skip to content
Closed
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
72 changes: 46 additions & 26 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def interruptible_api_call(agent, api_kwargs: dict):
"""
result = {"response": None, "error": None}
request_client_holder = {"client": None, "owner_tid": None}
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
# because that flag is cleared at run_conversation() turn boundaries, but
Expand All @@ -149,9 +150,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
# hang.)
_request_cancelled = {"value": False}

def _set_request_client(client):
def _set_request_client(client, *, kind: str = "openai"):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# #29507: stamp the owning thread so a stranger-thread interrupt
# only shuts the connection down rather than racing the worker
# for FD ownership during ``client.close()``.
Expand Down Expand Up @@ -185,7 +187,13 @@ def _close_request_client_once(reason: str) -> None:
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
Expand All @@ -205,7 +213,16 @@ def _call():
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
)
elif agent.api_mode == "anthropic_messages":
result["response"] = agent._anthropic_messages_create(api_kwargs)
request_client = _set_request_client(
agent._create_request_anthropic_client(
reason="anthropic_messages_request"
),
kind="anthropic_messages",
)
result["response"] = agent._anthropic_messages_create(
api_kwargs,
client=request_client,
)
elif agent.api_mode == "bedrock_converse":
# Bedrock uses boto3 directly β€” no OpenAI client needed.
# normalize_converse_response produces an OpenAI-compatible
Expand Down Expand Up @@ -499,11 +516,7 @@ def _call():
f"Aborting call."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stale_call_kill")
_close_request_client_once("stale_call_kill")
except Exception:
pass
agent._touch_activity(
Expand Down Expand Up @@ -536,13 +549,11 @@ def _call():
)
# Force-close the in-flight worker-local HTTP connection to stop
# token generation without poisoning the shared client used to
# seed future retries.
# seed future retries. For Anthropic Messages the SDK client is
# shared; closing it here would release TLS FDs from the poll
# thread and can resurrect #29507 (TLS bytes written into SQLite).
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("interrupt_abort")
_close_request_client_once("interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during API call")
Expand Down Expand Up @@ -1722,6 +1733,7 @@ def _on_reasoning(text):

result = {"response": None, "error": None, "partial_tool_names": []}
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag β€” see interruptible_api_call for the full
# rationale. The streaming retry loop is where the 7-minute cascading-
Expand All @@ -1732,9 +1744,10 @@ def _on_reasoning(text):
# exit immediately instead of retrying. (PR #6600.)
_request_cancelled = {"value": False}

def _set_request_client(client):
def _set_request_client(client, *, kind: str = "openai"):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# See #29507 explanation in the non-streaming variant above.
request_client_holder["owner_tid"] = threading.get_ident()
return client
Expand All @@ -1757,7 +1770,13 @@ def _close_request_client_once(reason: str) -> None:
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
Expand Down Expand Up @@ -2159,7 +2178,7 @@ def _call_chat_completions():
usage=usage_obj,
)

def _call_anthropic():
def _call_anthropic(request_client):
"""Stream an Anthropic Messages API response.

Fires delta callbacks for real-time token delivery, but returns
Expand All @@ -2183,7 +2202,7 @@ def _call_anthropic():
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
)
# Use the Anthropic SDK's streaming context manager
with agent._anthropic_client.messages.stream(**api_kwargs) as stream:
with request_client.messages.stream(**api_kwargs) as stream:
# The Anthropic SDK exposes the raw httpx response on
# ``stream.response``. Snapshot diagnostic headers
# immediately so they survive a stream that dies before the
Expand Down Expand Up @@ -2217,7 +2236,7 @@ def _call_anthropic():
pass

if agent._interrupt_requested:
break
raise InterruptedError("Agent interrupted during Anthropic stream")

event_type = getattr(event, "type", None)

Expand Down Expand Up @@ -2266,8 +2285,13 @@ def _call():
raise InterruptedError("Agent interrupted before stream retry")
try:
if agent.api_mode == "anthropic_messages":
agent._try_refresh_anthropic_client_credentials()
result["response"] = _call_anthropic()
request_client = _set_request_client(
agent._create_request_anthropic_client(
reason="anthropic_stream_request"
),
kind="anthropic_messages",
)
result["response"] = _call_anthropic(request_client)
else:
result["response"] = _call_chat_completions()
return # success
Expand Down Expand Up @@ -2631,11 +2655,7 @@ def _call():
"(not a network error)."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stream_interrupt_abort")
_close_request_client_once("stream_interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")
Expand Down
87 changes: 84 additions & 3 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3762,6 +3762,86 @@ def _abort_request_openai_client(self, client: Any, *, reason: str) -> None:
exc,
)

def _create_request_anthropic_client(self, *, reason: str) -> Any:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When salvaging this request-local client helper onto current main, route every current shared-Anthropic cleanup site through it. Current main still closes/rebuilds the shared client in streaming mid-tool retry, retry, and stale cleanup (agent/chat_completion_helpers.py:2836, 2896, 3082) in addition to the paths changed here.

"""Build a request-local Anthropic client for one in-flight call.

The shared ``_anthropic_client`` remains the long-lived primary, but
stale/interrupt handling must not close it from the poll thread. A
per-request client lets the stranger thread abort sockets while the
owning worker performs the SDK-level close, matching the #29507 close
discipline used by OpenAI-wire requests.
"""
if self.api_mode == "anthropic_messages":
self._try_refresh_anthropic_client_credentials()
_drop_1m = bool(getattr(self, "_oauth_1m_beta_disabled", False))
if getattr(self, "provider", None) == "bedrock":
from agent.anthropic_adapter import build_anthropic_bedrock_client
region = getattr(self, "_bedrock_region", "us-east-1") or "us-east-1"
client = build_anthropic_bedrock_client(region)
else:
from agent.anthropic_adapter import build_anthropic_client
client = build_anthropic_client(
self._anthropic_api_key,
getattr(self, "_anthropic_base_url", None),
timeout=get_provider_request_timeout(self.provider, self.model),
drop_context_1m_beta=_drop_1m,
)
logger.debug(
"Anthropic request client created (%s, shared=False) provider=%s model=%s",
reason,
getattr(self, "provider", None),
getattr(self, "model", None),
)
return client

def _close_request_anthropic_client(self, client: Any, *, reason: str) -> None:
if client is None:
return
try:
client.close()
logger.info(
"Anthropic client closed (%s, shared=False) provider=%s model=%s",
reason,
getattr(self, "provider", None),
getattr(self, "model", None),
)
except Exception as exc:
logger.debug(
"Anthropic client close failed (%s, shared=False) provider=%s model=%s error=%s",
reason,
getattr(self, "provider", None),
getattr(self, "model", None),
exc,
)

def _abort_request_anthropic_client(self, client: Any, *, reason: str) -> None:
"""Cross-thread abort for request-local Anthropic clients.

As with OpenAI request clients, stranger threads must not call the SDK
``close()`` method because that can release TLS FDs while the owning
worker's SSL BIO is still unwinding (#29507).
"""
if client is None:
return
try:
shutdown_count = self._force_close_tcp_sockets(client)
logger.info(
"Anthropic client aborted (%s, shared=False, tcp_force_closed=%d, "
"deferred_close=stranger_thread) provider=%s model=%s",
reason,
shutdown_count,
getattr(self, "provider", None),
getattr(self, "model", None),
)
except Exception as exc:
logger.debug(
"Anthropic client abort failed (%s, shared=False) provider=%s model=%s error=%s",
reason,
getattr(self, "provider", None),
getattr(self, "model", None),
exc,
)

def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta: callable = None):
"""Forwarder β€” see ``agent.codex_runtime.run_codex_stream``."""
from agent.codex_runtime import run_codex_stream
Expand Down Expand Up @@ -4101,15 +4181,16 @@ def _credential_pool_may_recover_rate_limit(self) -> bool:
return False
return pool.has_available()

def _anthropic_messages_create(self, api_kwargs: dict):
if self.api_mode == "anthropic_messages":
def _anthropic_messages_create(self, api_kwargs: dict, *, client: Any = None):
if client is None and self.api_mode == "anthropic_messages":
self._try_refresh_anthropic_client_credentials()
# Defensive: strip Responses-only kwargs that can leak in under an
# api_mode-flip race (the Anthropic SDK raises a non-retryable
# TypeError on them). See #31673.
from agent.anthropic_adapter import create_anthropic_message
request_client = client or self._anthropic_client
return create_anthropic_message(
self._anthropic_client,
request_client,
api_kwargs,
log_prefix=getattr(self, "log_prefix", ""),
prefer_stream=not bool(getattr(self, "_disable_streaming", False)),
Expand Down
Loading