refactor(agent/chat_completion_helpers): unify Bedrock converse/converse_stream recovery, fold role ladder, reflow comments

This commit is contained in:
Teknium
2026-09-02 23:22:23 -07:00
parent b2ab212a57
commit e5758d1593
+154 -235
View File
@@ -514,9 +514,7 @@ def _report_stale_nonstream_kill(agent, api_kwargs: dict, elapsed: float, stale_
stale_timeout, model, f"{estimate_request_context_tokens(api_kwargs):,}")
try:
agent._buffer_status(
f"⚠️ No response from provider for {int(elapsed)}s "
f"(non-streaming, model: {model}). {hint or 'Aborting call.'}"
)
f"⚠️ No response from provider for {int(elapsed)}s (non-streaming, model: {model}). {hint or 'Aborting call.'}")
except Exception:
logger.debug("stale status buffering failed", exc_info=True)
@@ -564,9 +562,8 @@ def _derive_stream_stale_timeout(agent, api_kwargs: dict) -> float:
_timeout = _scale_stale_timeout_for_context(
_configured_stale_base(agent), estimate_request_context_tokens(api_kwargs))
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
# ``model`` (OpenAI/Anthropic) wins over ``modelId`` (Bedrock). Bedrock's
# dotted, region-prefixed inference-profile id cannot match the floor's
# start-of-slug regex directly, so it is normalized to a canonical slug.
# ``model`` (OpenAI/Anthropic) wins over ``modelId`` (Bedrock). Bedrock's dotted,
# region-prefixed profile id can't match the floor's slug regex: normalize it first.
_model_id = api_kwargs.get("model") or api_kwargs.get("modelId") or ""
_reasoning_floor = get_reasoning_stale_timeout_floor(_model_id)
if _reasoning_floor is None and api_kwargs.get("modelId"):
@@ -605,6 +602,35 @@ def _bedrock_reasoning_stale_floor(model_id: object) -> "float | None":
return next((f for f in map(get_reasoning_stale_timeout_floor, candidates) if f is not None), None)
def _bedrock_converse_call(api_kwargs: dict, *, stream: bool, on_stream_denied=None):
"""Pop the Hermes routing keys and call ``converse`` / ``converse_stream`` (boto3
directly) with the shared recovery: a cachePoint rejection (Nova: toolConfig.tools,
#97281) drops the marker and resends once inside the same attempt; a streaming IAM
denial hands off to ``on_stream_denied(client, kwargs, exc)``; a stale connection
evicts the cached client so the outer retry builds a fresh pool. Streaming returns the
event stream; non-streaming an OpenAI-shaped SimpleNamespace."""
from agent.bedrock_adapter import (_get_bedrock_runtime_client, invalidate_runtime_client,
is_stale_connection_error, is_streaming_access_denied_error, normalize_converse_response,
recover_from_cache_point_rejection)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
method = client.converse_stream if stream else client.converse
finish = (lambda raw: raw.get("stream", [])) if stream else normalize_converse_response
try:
raw_response = method(**api_kwargs)
except Exception as exc:
retry_kwargs = recover_from_cache_point_rejection(exc, api_kwargs)
if retry_kwargs is not None:
return finish(method(**retry_kwargs))
if on_stream_denied is not None and is_streaming_access_denied_error(exc):
return on_stream_denied(client, api_kwargs, exc)
if is_stale_connection_error(exc):
invalidate_runtime_client(region)
raise
return finish(raw_response)
def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
"""Run one non-streaming LLM request for the active api_mode and return it.
@@ -622,36 +648,13 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
request_client = make_client("anthropic_messages_request", kind="anthropic_messages")
return agent._anthropic_messages_create(api_kwargs, client=request_client)
if agent.api_mode == "bedrock_converse":
# boto3 directly; normalize_converse_response yields an OpenAI-shaped
# SimpleNamespace so the loop treats it like a chat_completions response.
from agent.bedrock_adapter import (_get_bedrock_runtime_client, invalidate_runtime_client,
is_stale_connection_error, normalize_converse_response, recover_from_cache_point_rejection)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
try:
raw_response = client.converse(**api_kwargs)
except Exception as _bedrock_exc:
# A model that refuses cachePoint in one section (Nova:
# toolConfig.tools, #97281) fails every turn otherwise — drop the
# marker and resend before surfacing the error.
_retry_kwargs = recover_from_cache_point_rejection(_bedrock_exc, api_kwargs)
if _retry_kwargs is not None:
return normalize_converse_response(client.converse(**_retry_kwargs))
# Evict the cached client on stale-connection failures so the outer
# retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
invalidate_runtime_client(region)
raise
return normalize_converse_response(raw_response)
return _bedrock_converse_call(api_kwargs, stream=False)
if agent.provider == "moa":
# MoA is a virtual provider backed by the in-process MoAClient facade —
# never rebuild a request-local client from the virtual metadata. After
# a client replacement agent.client may be a native OpenAI client while
# provider stays "moa": pop the MoA-internal key so the SDK doesn't
# reject it, but ONLY then — the facade consumes it, and stripping it
# there forces a duplicate reference fan-out. Only the facade's
# completions object exposes ``prepare()`` (#78382).
# MoA is a virtual provider backed by the in-process MoAClient facade — never
# rebuild a request-local client from the virtual metadata. After a client
# replacement agent.client may be a native OpenAI client while provider stays
# "moa": pop the MoA-internal key ONLY then (the facade consumes it; stripping
# it there forces a duplicate fan-out). Only the facade exposes ``prepare()`` (#78382).
_completions = getattr(getattr(agent.client, "chat", None), "completions", None)
if not callable(getattr(_completions, "prepare", None)):
api_kwargs.pop("_moa_prepared_request", None)
@@ -682,9 +685,8 @@ def should_use_direct_api_call(agent) -> bool:
return getattr(agent, "platform", None) == "subagent"
# How often an in-flight direct_api_call refreshes last_activity_ts. Must stay
# well under the async-delegation idle stall threshold (450s) and below the
# 30s monitor sweep so progress tokens change every sample.
# How often an in-flight direct_api_call refreshes last_activity_ts. Must stay well
# under the async-delegation idle stall threshold (450s) and below the 30s monitor sweep.
_DIRECT_API_ACTIVITY_HEARTBEAT_SECONDS = 15.0
@@ -775,17 +777,15 @@ class _InlineRequest:
self._watchdog = None
def _activity_heartbeat(self) -> None:
# Do not put the API call itself on another worker thread — that is
# the nested-pool deadlock this path exists to avoid (#60203). This
# ticker only refreshes the activity clock.
# Never put the API call itself on another worker thread — that is the nested-pool
# deadlock this path exists to avoid (#60203). This ticker only refreshes the clock.
while not self._hb_stop.wait(_DIRECT_API_ACTIVITY_HEARTBEAT_SECONDS):
with contextlib.suppress(Exception):
self.agent._touch_activity("waiting for non-streaming API response")
def _on_stale(self) -> None:
# Timer thread: aborts sockets only, never issues a request (keeps
# the no-worker property). False = request finished or an interrupt
# owns the outcome; stay silent.
# Timer thread: aborts sockets only, never issues a request (keeps the no-worker
# property). False = request finished or an interrupt owns the outcome; stay silent.
if not self.abort("stale_call_kill"):
return
elapsed = time.time() - self.call_start
@@ -885,9 +885,8 @@ def direct_api_call(agent, api_kwargs: dict):
# (fail-closed), and a leaked heartbeat thread would mask real stalls forever.
call_start = time.time()
stale_timeout = _resolve_direct_stale_timeout(agent, api_kwargs)
# Never override an explicit per-call timeout; otherwise pin
# read=stale_timeout so a no-op abort can't leave the read=None socket
# hanging until TCP dies (#85252).
# Never override an explicit per-call timeout; otherwise pin read=stale_timeout so a
# no-op abort can't leave the read=None socket hanging until TCP dies (#85252).
hard_timeout = _inline_nonstream_hard_timeout(stale_timeout)
if hard_timeout is not None and "timeout" not in api_kwargs:
api_kwargs = {**api_kwargs, "timeout": hard_timeout}
@@ -1045,9 +1044,8 @@ def _resolve_nonstream_watchdogs(agent, api_kwargs: dict) -> _NonStreamWatchdogs
codex_floor = openai_codex_stale_timeout_floor(est_tokens)
if codex_floor:
stale_timeout = max(stale_timeout, codex_floor)
# Flat hard ceiling (#64507) for a request that emits SOME bytes then
# wedges. Default sits ABOVE the max floor (1200s) — a backstop, never a
# tighter limit. 0 disables.
# Flat hard ceiling (#64507) for a request that emits SOME bytes then wedges.
# Default sits ABOVE the max floor (1200s) — a backstop, never tighter. 0 disables.
hard_timeout = env_float("HERMES_CODEX_HARD_TIMEOUT_SECONDS", 1500.0)
if hard_timeout > 0:
stale_timeout = min(stale_timeout, hard_timeout)
@@ -1063,9 +1061,8 @@ def _resolve_nonstream_watchdogs(agent, api_kwargs: dict) -> _NonStreamWatchdogs
if ttfb_timeout <= 0:
ttfb_enabled = False
elif openai_codex_backend:
# Large requests legitimately spend tens of seconds in admission /
# prefill before the first SSE event: scale the cutoff up to the idle
# default unless HERMES_CODEX_TTFB_STRICT keeps the smaller one.
# Large requests legitimately spend tens of seconds in admission/prefill before the
# first SSE event: scale the cutoff up to the idle default unless TTFB_STRICT is set.
disable_above = env_float("HERMES_CODEX_TTFB_DISABLE_ABOVE_TOKENS", 10_000.0)
strict = os.environ.get("HERMES_CODEX_TTFB_STRICT", "").strip().lower() in {"1", "true", "yes", "on"}
if not strict and disable_above > 0 and est_tokens >= disable_above and ttfb_timeout < idle_default:
@@ -1110,16 +1107,13 @@ class _NonStreamRequest:
self.result = {"response": None, "error": None}
self.clients = _RequestClientRegistry(agent)
# Request-local cancel flag: agent._interrupt_requested is cleared at turn
# boundaries but this daemon worker can outlive the turn, so the worker
# needs to know THIS request was force-closed and not surface the
# resulting transport error as a network bug (#6600).
# boundaries but this daemon worker can outlive the turn, so it must know THIS
# request was force-closed and not surface the transport error as a bug (#6600).
self.cancelled = False
# Codex retirement token: the worker checks
# ``agent._active_codex_stream_request_token`` to know it still owns the
# turn; a watchdog kill clears it so a worker still draining SSE raises
# instead of returning partial output as "completed"
# (run_codex_stream._request_is_current). ``codex_retired`` is the
# request-local mirror used to swallow our own force-close error.
# Codex retirement token: the worker checks ``agent._active_codex_stream_request_token``
# to know it still owns the turn; a watchdog kill clears it so a worker still
# draining SSE raises instead of returning partial output as "completed"
# (run_codex_stream._request_is_current). ``codex_retired`` mirrors it locally.
self.codex_token = object() if agent.api_mode == "codex_responses" else None
self.codex_retired = False
self.wd = _resolve_nonstream_watchdogs(agent, api_kwargs)
@@ -1139,9 +1133,8 @@ class _NonStreamRequest:
self.agent._active_codex_stream_request_token = None
def _make_client(self, reason: str, kind: str = "openai"):
# Per-request clients are registered with the abort machinery so the
# watchdogs force-close the worker's connection, never the shared
# client (#67142).
# Per-request clients are registered with the abort machinery so the watchdogs
# force-close the worker's connection, never the shared client (#67142).
if kind == "anthropic_messages":
client = self.agent._create_request_anthropic_client(reason=reason)
else:
@@ -1204,9 +1197,7 @@ class _NonStreamRequest:
elapsed=elapsed)
self.agent._emit_wait_notice(
f"⏳ waiting on {self.api_kwargs.get('model', 'the provider')} — "
f"{int(elapsed)}s with no response yet (provider may be slow "
f"or overloaded{recovery})"
)
f"{int(elapsed)}s with no response yet (provider may be slow or overloaded{recovery})")
except Exception:
logger.debug("wait-notice construction failed", exc_info=True)
@@ -1220,10 +1211,8 @@ class _NonStreamRequest:
"but sent no stream events. Killing connection so the retry loop can reconnect.", elapsed,
wd.ttfb_timeout, self._model())
agent._buffer_status(
f"⚠️ No first byte from provider in {int(elapsed)}s "
f"(codex stream, model: {self._model()}). "
f"Reconnecting." + (f" {silent_hint}" if silent_hint else "")
)
f"⚠️ No first byte from provider in {int(elapsed)}s (codex stream, model: {self._model()}). "
f"Reconnecting." + (f" {silent_hint}" if silent_hint else ""))
self._abort_request("codex_ttfb_kill")
agent._emit_wait_notice(f"⚠ no response from provider in {int(elapsed)}s — reconnecting...")
agent._touch_activity(f"codex stream killed after {int(elapsed)}s with no first byte")
@@ -1240,16 +1229,13 @@ class _NonStreamRequest:
"connection so the retry loop can reconnect.", event_stale_elapsed, wd.idle_timeout,
self._model(), f"{wd.est_tokens:,}")
agent._buffer_status(
f"⚠️ Codex stream sent no events for {int(event_stale_elapsed)}s "
f"after first byte (model: {self._model()}). "
f"Reconnecting."
)
f"⚠️ Codex stream sent no events for {int(event_stale_elapsed)}s after first byte "
f"(model: {self._model()}). Reconnecting.")
self._abort_request("codex_stream_idle_kill")
agent._touch_activity(f"codex stream killed after {int(event_stale_elapsed)}s with no SSE events")
self._await_worker_after_kill(
f"Codex stream produced no SSE events for {int(event_stale_elapsed)}s "
f"after first byte (threshold: {int(wd.idle_timeout)}s)"
)
f"after first byte (threshold: {int(wd.idle_timeout)}s)")
def _stale_kill(self, elapsed: float) -> None:
"""No response within the stale timeout: kill and count toward the
@@ -1424,17 +1410,13 @@ def _build_codex_kwargs(agent, api_messages, tools_for_api, reasoning_config, re
from agent.native_compaction import native_compaction_context_management
is_codex_backend, is_xai_responses, is_github_responses = classify_responses_route(agent)
# Native server-side compaction (gpt-5.6 on direct OpenAI API / ChatGPT
# Codex routes only) — None on every other route/model, leaving the
# request unchanged from pre-feature behavior.
# Native server-side compaction (gpt-5.6 on direct OpenAI / ChatGPT Codex routes
# only) — None on every other route/model, leaving the request unchanged.
context_management = native_compaction_context_management(agent, is_codex_backend=is_codex_backend,
is_xai_responses=is_xai_responses, is_github_responses=is_github_responses)
# xAI's /responses endpoint 400s on ``pattern``/``format`` schema keywords
# and on ``enum`` values containing ``/`` (HuggingFace IDs from MCP
# servers) — strip them (#27197). Deep-copy first: the sanitizers mutate
# in place and tools_for_api aliases agent.tools, so an in-place strip
# would permanently degrade the shared registry for every later non-xAI
# request (#27907).
# xAI's /responses endpoint 400s on ``pattern``/``format`` schema keywords and on
# ``enum`` values containing ``/`` — strip them (#27197). Deep-copy first: the
# sanitizers mutate in place and tools_for_api aliases agent.tools (#27907).
if is_xai_responses:
try:
import copy as _copy
@@ -1546,9 +1528,8 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
reasoning_config = _reasoning_config_for_wire(agent)
if tools_for_api is None:
tools_for_api = agent.tools
# The one place request_overrides are consumed: static /fast values are
# already pinned in agent.request_overrides; auto/cold windows layer the
# fast override here, per request, only while the window is open.
# The one place request_overrides are consumed: static /fast values are already pinned
# in agent.request_overrides; auto/cold windows layer the fast override per request.
request_overrides = effective_request_overrides(agent)
if agent.api_mode == "anthropic_messages":
return _build_anthropic_kwargs(agent, api_messages, tools_for_api, reasoning_config, request_overrides)
@@ -1591,10 +1572,9 @@ def _assistant_reasoning_text(agent, assistant_message) -> Optional[str]:
def _assistant_content_for_storage(agent, assistant_message):
# Sanitize surrogates (Kimi/GLM via Ollama return code points that crash
# json.dumps on persist), strip inline <think> tags at the storage boundary
# (they leaked to platforms, inflated context, polluted titles), then
# redact credentials the model inlined in prose before the message enters
# Sanitize surrogates (Kimi/GLM via Ollama emit code points that crash json.dumps),
# strip inline <think> tags at the storage boundary (they leaked to platforms and
# polluted titles), then redact inlined credentials before the message enters
# history / state.db / gateway delivery (no-op with HERMES_REDACT_SECRETS off).
content = _sanitize_surrogates(flatten_message_text(getattr(assistant_message, "content", None)))
if isinstance(content, str) and content:
@@ -1624,9 +1604,8 @@ def _assistant_tool_call_dict(agent, tool_call, index: int) -> dict:
_, response_item_id = agent._split_responses_tool_id(raw_id)
response_item_id = agent._derive_responses_function_call_id(call_id,
response_item_id if isinstance(response_item_id, str) else None)
# Arguments are deliberately NOT redacted: this dict is replayed to the
# model every turn, so a ``***`` mask would break every credential-
# dependent command (#43083) while protecting nothing (tool OUTPUT leaks).
# Arguments are deliberately NOT redacted: this dict is replayed to the model every
# turn, so a ``***`` mask would break credential-dependent commands (#43083).
tc_dict = {"id": call_id, "call_id": call_id, "response_item_id": response_item_id,
"type": tool_call.type,
"function": {"name": tool_call.function.name, "arguments": tool_call.function.arguments}}
@@ -1658,9 +1637,8 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if raw_reasoning_content is not None:
msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
elif assistant_tool_calls and agent._needs_thinking_reasoning_pad():
# DeepSeek v4 / Kimi thinking modes 400 on a replayed tool-call message
# without reasoning_content; pad with a single space (empty string is
# rejected too) without fabricating reasoning.
# DeepSeek v4 / Kimi thinking modes 400 on a replayed tool-call message without
# reasoning_content; pad with a single space (empty string is rejected too).
msg["reasoning_content"] = reasoning_text or " "
elif reasoning_text:
# Streaming-only providers accumulate reasoning via deltas and never set
@@ -1899,7 +1877,7 @@ def _swap_fallback_clients(agent, fb_client, fb_provider: str, fb_model: str, fb
if fb_api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
is_anthropic = fb_provider == "anthropic"
effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if is_anthropic else (fb_client.api_key or "")
effective_key = fb_client.api_key or (resolve_anthropic_token() if is_anthropic else None) or ""
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = fb_base_url
@@ -2194,16 +2172,12 @@ def _iteration_summary_chat_kwargs(agent, api_messages: list) -> dict:
if provider_preferences and "provider" not in profile_extra_body and _is_openrouter():
extra_body["provider"] = provider_preferences
# Pareto Code router plugin — model-gated, same shape as the main-loop emission.
if (
agent.model == "openrouter/pareto-code" and _is_openrouter()
and agent.openrouter_min_coding_score is not None and agent.openrouter_min_coding_score != ""
):
try:
_ps = float(agent.openrouter_min_coding_score)
except (TypeError, ValueError):
_ps = None
if _ps is not None and 0.0 <= _ps <= 1.0:
extra_body["plugins"] = [{"id": "pareto-router", "min_coding_score": _ps}]
_score = agent.openrouter_min_coding_score
if agent.model == "openrouter/pareto-code" and _is_openrouter() and _score is not None and _score != "":
with contextlib.suppress(TypeError, ValueError):
_ps = float(_score)
if 0.0 <= _ps <= 1.0:
extra_body["plugins"] = [{"id": "pareto-router", "min_coding_score": _ps}]
if extra_body:
summary_kwargs["extra_body"] = extra_body
return summary_kwargs
@@ -2365,12 +2339,8 @@ def _relay_stream_identity(agent, name_default: str) -> dict:
def _relay_stream_metadata(agent, api_mode: str) -> dict:
if getattr(agent, "is_subagent", False):
call_role = "delegated"
elif int(getattr(agent, "_fallback_index", 0) or 0) > 0:
call_role = "fallback"
else:
call_role = "primary"
call_role = ("delegated" if getattr(agent, "is_subagent", False)
else "fallback" if int(getattr(agent, "_fallback_index", 0) or 0) > 0 else "primary")
return {"api_mode": api_mode, "api_request_id": getattr(agent, "_current_api_request_id", None),
"call_role": call_role}
@@ -2391,28 +2361,23 @@ def _stream_final_text(response) -> str:
return ""
def _emit_stream_start(agent) -> None:
emit = getattr(agent, "_emit_stream_start", None)
if emit is not None:
emit()
def _emit_stream_end(agent, *, final_text: str, finished: bool, error: str | None) -> None:
emit = getattr(agent, "_emit_stream_end", None)
if emit is not None:
emit(final_text=final_text, finished=finished, error=error)
def _with_stream_emitters(agent, run):
"""Bracket ``run()`` with the agent's stream start/end emitters (end carries
the final text on success, the error string on failure) and re-raise."""
_emit_stream_start(agent)
"""Bracket ``run()`` with the agent's ``_emit_stream_start`` / ``_emit_stream_end``
hooks when present (end carries the final text on success, the error string on
failure) and re-raise."""
start = getattr(agent, "_emit_stream_start", None)
if start is not None:
start()
try:
response = run()
except Exception as exc:
_emit_stream_end(agent, final_text="", finished=False, error=str(exc))
end = getattr(agent, "_emit_stream_end", None)
if end is not None:
end(final_text="", finished=False, error=str(exc))
raise
_emit_stream_end(agent, final_text=_stream_final_text(response), finished=True, error=None)
end = getattr(agent, "_emit_stream_end", None)
if end is not None:
end(final_text=_stream_final_text(response), finished=True, error=None)
return response
@@ -2439,9 +2404,8 @@ class _BedrockStream:
self.result = {"response": None, "error": None}
self.first_delta_fired = False
self.response_started = False
# Liveness for the boto3 worker: ``for event in event_stream`` has NO
# read timeout, so on_event stamps every Bedrock event and the poll loop
# trips a watchdog when the gap exceeds the stale timeout.
# Liveness for the boto3 worker: ``for event in event_stream`` has NO read timeout,
# so on_event stamps every event and the poll loop trips a watchdog on a long gap.
self.started_at = time.time()
self.last_event = self.started_at
# Read (not popped): the worker's own pop inside _open_stream must
@@ -2473,37 +2437,19 @@ class _BedrockStream:
self.agent._fire_reasoning_delta(text)
def _open_stream(self, next_api_kwargs: dict[str, Any]):
from agent.bedrock_adapter import (_get_bedrock_runtime_client, invalidate_runtime_client,
is_stale_connection_error, is_streaming_access_denied_error, normalize_converse_response,
recover_from_cache_point_rejection)
return _bedrock_converse_call(dict(next_api_kwargs), stream=True, on_stream_denied=self._fall_back_to_converse)
final_kwargs = dict(next_api_kwargs)
region = final_kwargs.pop("__bedrock_region__", "us-east-1")
final_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
try:
raw_response = client.converse_stream(**final_kwargs)
except Exception as _bedrock_exc:
# Some families refuse a cachePoint block in one section (Nova:
# toolConfig.tools, #97281): drop it and reopen inside the same
# Relay attempt.
_retry_kwargs = recover_from_cache_point_rejection(_bedrock_exc, final_kwargs)
if _retry_kwargs is not None:
return client.converse_stream(**_retry_kwargs).get("stream", [])
# InvokeModel-only IAM policies cannot stream; fall back inside the
# same Relay attempt (one lifecycle boundary).
if is_streaming_access_denied_error(_bedrock_exc):
self.agent._disable_streaming = True
self.agent._safe_print("\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
"falling back to non-streaming InvokeModel.\n"
" Grant that action to restore streaming output.\n")
logger.info("bedrock: converse_stream denied by IAM (%s) — "
"using non-streaming converse() for this session.", type(_bedrock_exc).__name__)
return normalize_converse_response(client.converse(**final_kwargs))
if is_stale_connection_error(_bedrock_exc):
invalidate_runtime_client(region)
raise
return raw_response.get("stream", [])
def _fall_back_to_converse(self, client, final_kwargs: dict, exc: Exception):
# InvokeModel-only IAM policies cannot stream; fall back inside the same Relay
# attempt (one lifecycle boundary).
from agent.bedrock_adapter import normalize_converse_response
self.agent._disable_streaming = True
self.agent._safe_print("\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
"falling back to non-streaming InvokeModel.\n"
" Grant that action to restore streaming output.\n")
logger.info("bedrock: converse_stream denied by IAM (%s) — "
"using non-streaming converse() for this session.", type(exc).__name__)
return normalize_converse_response(client.converse(**final_kwargs))
def _worker(self):
agent = self.agent
@@ -2585,10 +2531,8 @@ class _BedrockStream:
# THIS call with a TimeoutError and let the streak carry forward.
_check_stale_giveup(agent)
self.result["error"] = TimeoutError(
f"Bedrock stream produced no events for {int(stale_elapsed)}s "
f"(threshold {int(self.stale_timeout)}s) — aborting stalled "
f"stream so the retry/fallback path can recover."
)
f"Bedrock stream produced no events for {int(stale_elapsed)}s (threshold {int(self.stale_timeout)}s) "
f"— aborting stalled stream so the retry/fallback path can recover.")
def _poll(self):
t = threading.Thread(target=_context_thread_target(self._worker), daemon=True)
@@ -2600,9 +2544,8 @@ class _BedrockStream:
if stale_elapsed > self.stale_timeout:
self._on_stale(stale_elapsed)
break
# The Bedrock callback returns a PARTIAL response on interrupt
# without raising (on_interrupt_check), so the in-loop raise may
# never fire. Re-check so /stop is not swallowed (#59999 area).
# The Bedrock callback returns a PARTIAL response on interrupt without raising
# (on_interrupt_check), so the in-loop raise may never fire. Re-check (#59999 area).
self._raise_if_interrupted("Agent interrupted during Bedrock API call (post-worker)")
if self.result["error"] is not None:
raise self.result["error"]
@@ -2618,10 +2561,6 @@ class _BedrockStream:
return _with_stream_emitters(self.agent, self._poll)
def _stream_bedrock_converse(agent, api_kwargs: dict, on_first_delta):
return _BedrockStream(agent, api_kwargs, on_first_delta).run()
class _ToolCallAccumulator:
"""Assemble streamed tool-call deltas into complete ``tool_calls`` entries
(``acc``: slot index -> entry dict). Ollama-compatible endpoints reuse index 0
@@ -2637,7 +2576,8 @@ class _ToolCallAccumulator:
def feed(self, tc_delta) -> Optional[str]:
"""Merge one delta; return the tool name the first time it is complete."""
raw_idx = getattr(tc_delta, "index", None)
raw_idx = raw_idx if raw_idx is not None else 0
if raw_idx is None:
raw_idx = 0
tc_id = getattr(tc_delta, "id", None)
delta_id = tc_id or ""
if isinstance(tc_id, int): # Poolside sends integer ids
@@ -2658,9 +2598,8 @@ class _ToolCallAccumulator:
tc_function = getattr(tc_delta, "function", None)
if tc_function:
if getattr(tc_function, "name", None):
# Assignment, not +=: names arrive complete (OpenAI spec) and
# some providers (MiniMax M2.7 via NVIDIA NIM) resend the full
# name in every chunk — concatenation gives "read_fileread_file".
# Assignment, not +=: names arrive complete and some providers (MiniMax via
# NVIDIA NIM) resend the full name every chunk — += gives "read_fileread_file".
entry["function"]["name"] = tc_function.name
if getattr(tc_function, "arguments", None):
entry["function"]["arguments"] += tc_function.arguments
@@ -2877,14 +2816,12 @@ class _StreamingCall:
choices = getattr(chunk, "choices", None)
choice = choices[0] if choices else None
delta = getattr(choice, "delta", None)
# A stale-attempt fence can win while Relay hands back a tool-call
# chunk: record that a tool call was in flight (retry policy must
# not see a partial text response); the chunk is still rejected.
# A stale-attempt fence can win while Relay hands back a tool-call chunk: record
# the in-flight tool call (retry policy must not see a partial text response).
if getattr(delta, "tool_calls", None):
self.provider_tool_in_flight["yes"] = True
# Marker-only finish chunk (no writable delta) always passes: the
# fence only stops MORE text; fending the completion signal would
# make the drop-guard mislabel a clean end as a drop.
# Marker-only finish chunk (no writable delta) always passes: the fence only stops
# MORE text; fending the completion signal would mislabel a clean end as a drop.
if getattr(choice, "finish_reason", None) and not any(
getattr(delta, attr, None) for attr in ("content", "tool_calls", "reasoning_content", "reasoning")):
return True
@@ -2953,9 +2890,8 @@ class _StreamingCall:
for chunk in _iter_provider_stream_chunks(stream, response=lambda: self._attempt_stream_response):
self._count_chunk(_diag, chunk)
if self.agent._interrupt_requested:
# A half-read SSE response stays checked out of the httpx pool and
# the finally would cache the client WITH the leaked connection:
# close on the owning thread first.
# A half-read SSE response stays checked out of the httpx pool and the finally
# would cache the client WITH the leaked connection: close on the owner first.
try:
stream.close()
except Exception:
@@ -3084,9 +3020,8 @@ class _StreamingCall:
raise EmptyStreamError(
"Provider returned an empty stream with no finish_reason (possible upstream error or malformed SSE response).")
if has_truncated_tool_args and finish_reason is None:
# Partial args WITH finish_reason="length" is a real output cap; with
# NONE the upstream dropped mid tool-call, and stamping "length"
# would burn 3 useless max_tokens retries.
# Partial args WITH finish_reason="length" is a real output cap; with NONE the
# upstream dropped mid tool-call, and stamping "length" burns 3 useless retries.
_dropped_names = [(tool_calls_acc[idx]["function"]["name"] or "?") for idx in sorted(tool_calls_acc)]
logger.warning(
"Stream ended with no finish_reason while a tool call's arguments were still incomplete "
@@ -3095,9 +3030,8 @@ class _StreamingCall:
return _build_partial_stream_stub(
role, full_content, full_reasoning, model_name, usage_obj, dropped_tool_names=_dropped_names or None)
if finish_reason is None and content_parts and not tool_calls_acc and usage_obj is None:
# Text-only drop: otherwise the partial text is stamped "stop" and the
# next step is lost. A usage object proves the provider finished
# (include_usage's final chunk has empty choices, no finish_reason).
# Text-only drop: otherwise the partial text is stamped "stop" and the next step is
# lost. A usage object proves the provider finished (include_usage's final chunk).
logger.warning(
"Stream ended with no finish_reason after delivering text with no tool calls; treating as a mid-stream drop.")
return _build_partial_stream_stub(role, full_content, full_reasoning, model_name, usage_obj)
@@ -3123,8 +3057,7 @@ class _StreamingCall:
content = getattr(message, "content", None)
if not content and getattr(message, "stop_reason", None) is None:
raise EmptyStreamError(
"Provider returned an empty stream with no stop_reason (possible upstream error or malformed event stream)."
)
"Provider returned an empty stream with no stop_reason (possible upstream error or malformed event stream).")
if tool_drop and getattr(message, "stop_reason", None) is None and any(
getattr(block, "type", None) == "tool_use" for block in content or []):
raise EmptyStreamError(
@@ -3138,9 +3071,8 @@ class _StreamingCall:
per-request ``request_client`` so the watchdog can abort this socket
without closing the shared client mid-flight."""
has_tool_use = False
# Eventless stream: the SDK's get_final_message() raises AssertionError
# (no message_start); shims may fabricate a contentless Message or return
# None under ``python -O``. All normalize to EmptyStreamError (retry).
# Eventless stream: the SDK's get_final_message() raises AssertionError (no
# message_start); shims may fabricate a contentless Message. All -> EmptyStreamError.
saw_stream_event = False
self.last_chunk_time["t"] = time.time()
_diag = self._new_diag()
@@ -3164,8 +3096,7 @@ class _StreamingCall:
_stream_context["stream"] = raw_stream
# Snapshot response diagnostics now so they survive a stream dying before the first event.
self._quiet(
lambda: self.agent._stream_diag_capture_response(_diag, getattr(raw_stream, "response", None))
)
lambda: self.agent._stream_diag_capture_response(_diag, getattr(raw_stream, "response", None)))
self._writer_token = claim_stream_writer(self.agent)
stream = self._set_managed_stream(relay_llm.stream(self.api_kwargs, _open_anthropic_stream,
@@ -3202,8 +3133,7 @@ class _StreamingCall:
except AssertionError:
if not saw_stream_event:
raise EmptyStreamError(
"Provider returned an empty stream with no events (possible upstream error or malformed event stream)."
) from None
"Provider returned an empty stream with no events (possible upstream error or malformed event stream).") from None
raise
finally:
try:
@@ -3279,9 +3209,8 @@ class _StreamingCall:
logger.warning("Streaming failed after partial delivery, not retrying: %s", e)
self.result["error"] = e
return False
# Marker explains the re-streamed preamble (``_emit_stream_drop`` logs
# the WARNING); reset the streamed-text buffer so the preamble isn't
# double-recorded, and start fresh accumulators.
# Marker explains the re-streamed preamble (``_emit_stream_drop`` logs the WARNING);
# reset the streamed-text buffer so it isn't double-recorded; fresh accumulators.
self._quiet(self.agent._fire_stream_delta, "\n\n⚠ Connection dropped mid tool-call; reconnecting…\n\n")
self._quiet(self.agent._reset_stream_delivery_tracking)
self.result["partial_tool_names"] = []
@@ -3298,13 +3227,10 @@ class _StreamingCall:
# Exhausted: log full diagnostics (chain, headers, bytes/elapsed).
self.agent._log_stream_retry(kind="exhausted", error=e, attempt=max_retries + 1,
max_attempts=max_retries + 1, mid_tool_call=False, diag=self.clients.diag)
if _is_stream_parse_err:
_what = "Provider returned malformed streaming data after"
elif _is_empty_stream:
# Opened but no chunks: "connection failed" would send users chasing network issues.
_what = "Provider returned an empty response stream after"
else:
_what = "Connection to provider failed after"
# Empty stream: "connection failed" would send users chasing network issues.
_what = ("Provider returned malformed streaming data after" if _is_stream_parse_err
else "Provider returned an empty response stream after" if _is_empty_stream
else "Connection to provider failed after")
self.agent._buffer_status(
f"❌ {_what} {max_retries + 1} attempts. The provider may be experiencing issues — try again in a moment.")
else:
@@ -3391,8 +3317,7 @@ class _StreamingCall:
_recovery = f"; auto-reconnect at {int(stale)}s" if stale is not None and stale != float("inf") else ""
self.agent._emit_wait_notice(
f"⏳ waiting on {self.api_kwargs.get('model', 'the provider')} — {waiting_secs}s with no output yet "
f"(provider may be slow or overloaded, or the model is thinking{_recovery})"
)
f"(provider may be slow or overloaded, or the model is thinking{_recovery})")
else:
# Chunks are flowing — keep the tracker fresh, leave the display alone.
self.agent._touch_activity(f"waiting for stream response ({waiting_secs}s, no chunks yet)")
@@ -3410,8 +3335,7 @@ class _StreamingCall:
)
self.agent._buffer_status(
f"⚠️ No response from provider for {int(elapsed)}s (model: {self.api_kwargs.get('model', 'unknown')}, "
f"context: ~{_est_ctx:,} tokens). Reconnecting..."
)
f"context: ~{_est_ctx:,} tokens). Reconnecting...")
with contextlib.suppress(Exception):
self._cancel_current_stream_attempt("stale_stream_kill")
self.clients.close_once("stale_stream_kill")
@@ -3500,10 +3424,8 @@ class _StreamingCall:
_name_str = ", ".join(_partial_names[:3])
if len(_partial_names) > 3:
_name_str += f", +{len(_partial_names) - 3} more"
_warn = (
f"\n\n⚠ Stream stalled mid tool-call ({_name_str}); the action was not executed. "
f"Ask me to retry if you want to continue."
)
_warn = (f"\n\n⚠ Stream stalled mid tool-call ({_name_str}); the action was not executed. "
f"Ask me to retry if you want to continue.")
_partial_text = (_partial_text or "") + _warn
self._quiet(self.agent._fire_stream_delta, _warn) # visible immediately
logger.warning(
@@ -3514,9 +3436,8 @@ class _StreamingCall:
"Partial stream delivered before error; returning length-truncated stub with %s chars of "
"recovered content so the loop can continue from where the stream died: %s",
len(_partial_text or ""), error)
# Classify content filtering (MiniMax 1027, Azure content_filter,
# Anthropic refusal) before the raw error is swallowed into the stub:
# the loop reads the tag and falls back instead of re-hitting the filter.
# Classify content filtering (MiniMax 1027, Azure content_filter, Anthropic refusal)
# before the error is swallowed into the stub: the loop reads the tag and falls back.
try:
from agent.error_classifier import classify_api_error
_cls = classify_api_error(
@@ -3535,15 +3456,13 @@ class _StreamingCall:
"""Resolve the stale timeout, run the request (worker thread or inline),
drive the heartbeat/stale/interrupt monitor, then translate the outcome."""
self._resolve_stale_timeout()
# Delegated children and cron turns run the request INLINE (a worker
# thread inside their nested pools wedges before the socket opens) but
# must still STREAM (edge proxies kill silent POSTs during thinking).
# Only the poll loop moves to a monitor thread, which never issues a
# request, so the no-worker deadlock fix holds.
self._inline = should_use_direct_api_call(self.agent)
# Delegated children and cron turns run the request INLINE (a worker inside
# their nested pools wedges before the socket opens) but must still STREAM
# (edge proxies kill silent POSTs). Only the poll loop moves to a monitor
# thread, which never issues a request, so the no-worker deadlock fix holds.
self._call_done = threading.Event()
self._monitor_interrupted = {"yes": False}
if self._inline:
if should_use_direct_api_call(self.agent):
self.worker = None
monitor = threading.Thread(
target=_context_thread_target(self._monitor_loop), name="stream-inline-monitor", daemon=True)
@@ -3583,7 +3502,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if agent.api_mode == "codex_responses":
return _stream_codex_passthrough(agent, api_kwargs, on_first_delta)
if agent.api_mode == "bedrock_converse":
return _stream_bedrock_converse(agent, api_kwargs, on_first_delta)
return _BedrockStream(agent, api_kwargs, on_first_delta).run()
# Cross-turn stale-stream circuit breaker (see ``_stale_streak()``).
_check_stale_giveup(agent)
return _StreamingCall(agent, api_kwargs, on_first_delta).run()