bedrock.guardrail was only attached on the Converse route (guardrailConfig in the body). Claude on Bedrock goes through the AnthropicBedrock SDK, i.e. InvokeModel, whose body has no guardrailConfig, so the default Claude route ran with no guardrail at all (#52179; live-verified by JiaDe-Wu: the blocked word came back through Hermes). Bedrock reads the guardrail for InvokeModel from X-Amzn-Bedrock-GuardrailIdentifier / -GuardrailVersion / -Trace headers. Attach them as default_headers in build_anthropic_bedrock_client so every AnthropicBedrock client Hermes builds (primary init, /model switch, fallback, per-request rebuild, auxiliary) enforces the same guardrail, with prompt caching / thinking / 1M context kept (the reason Claude is not routed through Converse). InvokeModel blocks do NOT change stop_reason (stays end_turn) and return the guardrail's canned text as an ordinary assistant reply, flagged only by amazon-bedrock-guardrailAction=INTERVENED in the body (SDK: response.model_extra). AnthropicTransport.response_finish_reason maps that to content_filter so the loop runs its refusal handling instead of reasoning over the canned text; _derive_finish_reason uses it for the anthropic_messages branch. Mantle (openai.gpt-5.x) is documented by AWS as not supporting Guardrails on the Responses endpoint; the docs now say so instead of promising "all model invocations". Header mechanism proposed in #52312 by @JoaoMarcos44 (stale base, 7-file conflict, detection keyed on a Converse-only stopReason); reimplemented on current main. Live probe (local sink, SigV4 fake creds): before, no X-Amzn-Bedrock-* header on the InvokeModel request; after, headers present, SigV4 intact, INTERVENED → content_filter.
318 lines
16 KiB
Python
318 lines
16 KiB
Python
"""Post-call response verification for the conversation turn's retry loop: stop the thinking
|
|
spinner, validate the response shape (retry / eager fallback / terminal invalid-response
|
|
result), derive ``finish_reason`` per api_mode, route content-policy refusals and
|
|
``length`` truncation, fold usage into the compressor, and mark the logical relay call
|
|
complete. Nothing here imports ``agent.conversation_loop`` at module level (cycle) —
|
|
loop-internal helpers resolve lazily so ``patch("agent.conversation_loop.X")`` keeps intercepting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
|
|
from agent.turn_api_call import stop_thinking_spinner
|
|
from agent.turn_truncation import handle_content_policy_refusal, recover_from_truncation
|
|
from agent.turn_usage import record_response_usage
|
|
|
|
logger = logging.getLogger("agent.conversation_loop")
|
|
|
|
|
|
@dataclass
|
|
class ResponseCheckVerdict:
|
|
"""``action``: ``"break"`` (leave the retry loop — success, or a fallback/refusal restart
|
|
armed on ``_retry``), ``"continue"`` (retry the API call) or ``"return"`` (``result`` is
|
|
the turn's result dict). The other fields are the retry-loop locals rebound."""
|
|
|
|
action: str
|
|
thinking_spinner: Any
|
|
messages: Any
|
|
active_system_prompt: Any
|
|
finish_reason: Any
|
|
retry_count: Any
|
|
compression_attempts: Any
|
|
length_continue_retries: Any
|
|
truncated_response_parts: Any
|
|
truncated_tool_call_retries: Any
|
|
_preflight_compression_blocked: Any
|
|
_last_preflight_pressure: Any
|
|
api_duration: Any
|
|
result: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
def _codex_finish_reason(response: Any) -> str:
|
|
"""Responses API max-output exhaustion is a normal Codex incomplete turn: route it to
|
|
the Codex continuation path (``"incomplete"``), not the length rollback."""
|
|
status = getattr(response, "status", None)
|
|
if isinstance(status, str):
|
|
status = status.strip().lower()
|
|
incomplete_details = getattr(response, "incomplete_details", None)
|
|
if isinstance(incomplete_details, dict):
|
|
incomplete_reason = incomplete_details.get("reason")
|
|
else:
|
|
incomplete_reason = getattr(incomplete_details, "reason", None)
|
|
if incomplete_reason is not None:
|
|
incomplete_reason = str(incomplete_reason).strip().lower()
|
|
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
|
|
return "incomplete"
|
|
if status == "incomplete" and incomplete_reason == "content_filter":
|
|
return "content_filter"
|
|
return "stop"
|
|
|
|
|
|
def _derive_finish_reason(agent: Any, response: Any, messages: Any) -> str:
|
|
if agent.api_mode == "codex_responses":
|
|
return _codex_finish_reason(response)
|
|
transport = agent._get_transport()
|
|
if agent.api_mode == "anthropic_messages":
|
|
return transport.response_finish_reason(response)
|
|
normalized = transport.normalize_response(response) # Bedrock already normalized at dispatch
|
|
finish_reason = normalized.finish_reason
|
|
if agent.api_mode != "bedrock_converse" and agent._should_treat_stop_as_truncated(
|
|
finish_reason, normalized, messages
|
|
):
|
|
agent._vprint(
|
|
f"{agent.log_prefix}⚠️ Treating suspicious Ollama/GLM stop response as truncated",
|
|
force=True,
|
|
)
|
|
return "length"
|
|
return finish_reason
|
|
|
|
|
|
def check_api_response(
|
|
agent: Any, *, response: Any, _retry: Any, thinking_spinner: Any, messages: Any,
|
|
api_messages: Any, api_kwargs: Any, active_system_prompt: Any, conversation_history: Any,
|
|
finish_reason: Any, retry_count: Any, max_retries: Any, compression_attempts: Any,
|
|
max_compression_attempts: Any, length_continue_retries: Any, truncated_response_parts: Any,
|
|
truncated_tool_call_retries: Any, current_turn_user_idx: Any, api_call_count: Any,
|
|
api_request_id: Any, api_start_time: Any, effective_task_id: Any, turn_id: Any,
|
|
_preflight_compression_blocked: Any, _last_preflight_pressure: Any,
|
|
) -> ResponseCheckVerdict:
|
|
"""Verify ``response`` in the original order. The retry buffer is NOT cleared on success
|
|
(bytes back != usable content); ``_preflight_compression_blocked``/``_last_preflight_pressure``
|
|
reset only when the usage fold re-arms the compression budget."""
|
|
from agent.turn_recovery import validate_response_shape
|
|
|
|
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ResponseCheckVerdict:
|
|
return ResponseCheckVerdict(
|
|
action=action, thinking_spinner=thinking_spinner, messages=messages,
|
|
active_system_prompt=active_system_prompt, finish_reason=finish_reason,
|
|
retry_count=retry_count, compression_attempts=compression_attempts,
|
|
length_continue_retries=length_continue_retries,
|
|
truncated_response_parts=truncated_response_parts,
|
|
truncated_tool_call_retries=truncated_tool_call_retries,
|
|
_preflight_compression_blocked=_preflight_compression_blocked,
|
|
_last_preflight_pressure=_last_preflight_pressure, api_duration=api_duration,
|
|
result=result,
|
|
)
|
|
|
|
api_duration = time.time() - api_start_time
|
|
|
|
# Silent stop: the response box / tool messages that follow are more informative.
|
|
thinking_spinner = stop_thinking_spinner(agent, thinking_spinner)
|
|
|
|
if not agent.quiet_mode:
|
|
agent._vprint(f"{agent.log_prefix}⏱️ API call completed in {api_duration:.2f}s")
|
|
|
|
if agent.verbose_logging:
|
|
resp_model = getattr(response, 'model', 'N/A') if response else 'N/A'
|
|
logging.debug(f"API Response received - Model: {resp_model}, Usage: {response.usage if hasattr(response, 'usage') else 'N/A'}")
|
|
|
|
response_invalid, error_details = validate_response_shape(agent, response)
|
|
if response_invalid:
|
|
_iv = retry_invalid_response(
|
|
agent, response=response, error_details=error_details, _retry=_retry,
|
|
thinking_spinner=thinking_spinner, messages=messages, api_messages=api_messages,
|
|
api_kwargs=api_kwargs, active_system_prompt=active_system_prompt,
|
|
conversation_history=conversation_history, retry_count=retry_count,
|
|
max_retries=max_retries, compression_attempts=compression_attempts,
|
|
api_call_count=api_call_count, api_request_id=api_request_id,
|
|
api_start_time=api_start_time, api_duration=api_duration,
|
|
effective_task_id=effective_task_id, turn_id=turn_id,
|
|
)
|
|
thinking_spinner = _iv.thinking_spinner
|
|
active_system_prompt = _iv.active_system_prompt
|
|
retry_count = _iv.retry_count
|
|
compression_attempts = _iv.compression_attempts
|
|
if _iv.action != "fallthrough":
|
|
return _verdict(_iv.action, _iv.result)
|
|
|
|
agent._turn_received_provider_response = True
|
|
finish_reason = _derive_finish_reason(agent, response, messages)
|
|
|
|
# HTTP-200 refusals are deterministic: one fallback try, else return the refusal.
|
|
if finish_reason == "content_filter":
|
|
_rv = handle_content_policy_refusal(
|
|
agent, response, _retry, thinking_spinner=thinking_spinner, messages=messages,
|
|
api_messages=api_messages, api_kwargs=api_kwargs,
|
|
active_system_prompt=active_system_prompt, conversation_history=conversation_history,
|
|
api_call_count=api_call_count, effective_task_id=effective_task_id, turn_id=turn_id,
|
|
api_request_id=api_request_id, api_start_time=api_start_time, retry_count=retry_count,
|
|
max_retries=max_retries,
|
|
)
|
|
thinking_spinner = None
|
|
active_system_prompt = _rv.active_system_prompt
|
|
if _rv.action == "return":
|
|
return _verdict("return", _rv.result)
|
|
retry_count = 0
|
|
compression_attempts = 0
|
|
return _verdict("break")
|
|
|
|
if finish_reason == "length":
|
|
_tv = recover_from_truncation(
|
|
agent, response, finish_reason, _retry, messages=messages,
|
|
conversation_history=conversation_history, api_kwargs=api_kwargs,
|
|
api_call_count=api_call_count, effective_task_id=effective_task_id,
|
|
current_turn_user_idx=current_turn_user_idx,
|
|
length_continue_retries=length_continue_retries,
|
|
truncated_response_parts=truncated_response_parts,
|
|
truncated_tool_call_retries=truncated_tool_call_retries, retry_count=retry_count,
|
|
compression_attempts=compression_attempts,
|
|
)
|
|
messages = _tv.messages
|
|
length_continue_retries = _tv.length_continue_retries
|
|
truncated_response_parts = _tv.truncated_response_parts
|
|
truncated_tool_call_retries = _tv.truncated_tool_call_retries
|
|
retry_count = _tv.retry_count
|
|
compression_attempts = _tv.compression_attempts
|
|
if _tv.action in ("return", "break", "continue"):
|
|
return _verdict(_tv.action, _tv.result)
|
|
|
|
# Fold provider usage into compressor / anchors / session counters / state.db
|
|
# (agent/turn_usage.py). A rearmed budget also clears the preflight-block latch.
|
|
_usage_outcome = record_response_usage(
|
|
agent, response, messages=messages, api_call_count=api_call_count,
|
|
api_duration=api_duration, compression_attempts=compression_attempts,
|
|
max_compression_attempts=max_compression_attempts,
|
|
)
|
|
compression_attempts = _usage_outcome.compression_attempts
|
|
if _usage_outcome.rearmed:
|
|
_preflight_compression_blocked = False
|
|
_last_preflight_pressure = None
|
|
|
|
_retry.has_retried_429 = False
|
|
# Clearing Nous rate-limit state proves the limit reset so other sessions may resume.
|
|
if agent.provider == "nous":
|
|
try:
|
|
from agent.nous_rate_guard import clear_nous_rate_limit
|
|
clear_nous_rate_limit()
|
|
except Exception:
|
|
pass
|
|
from agent import relay_llm
|
|
|
|
relay_llm.complete_logical_call(api_request_id, outcome="success")
|
|
agent._touch_activity(f"API call #{api_call_count} completed")
|
|
return _verdict("break")
|
|
|
|
|
|
@dataclass
|
|
class InvalidResponseVerdict:
|
|
"""``action``: ``"continue"`` (retry the API call after backoff), ``"break"`` (fallback
|
|
armed / redirect pending) or ``"return"`` (``result``: terminal invalid-response result or
|
|
interrupt during backoff). Rebinds ``thinking_spinner``/``active_system_prompt``/
|
|
``retry_count``/``compression_attempts``."""
|
|
|
|
action: str
|
|
thinking_spinner: Any
|
|
active_system_prompt: Any
|
|
retry_count: Any
|
|
compression_attempts: Any
|
|
result: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
def retry_invalid_response(
|
|
agent: Any, *, response: Any, error_details: Any, _retry: Any, thinking_spinner: Any,
|
|
messages: Any, api_messages: Any, api_kwargs: Any, active_system_prompt: Any,
|
|
conversation_history: Any, retry_count: Any, max_retries: Any, compression_attempts: Any,
|
|
api_call_count: Any, api_request_id: Any, api_start_time: Any, api_duration: Any,
|
|
effective_task_id: Any, turn_id: Any,
|
|
) -> InvalidResponseVerdict:
|
|
"""Malformed/empty provider response: fire the error hook, stop the spinner, eager
|
|
fallback (empty responses often mean rate limiting), terminal result at max retries,
|
|
else jittered backoff that preserves a pending redirect."""
|
|
from agent.conversation_loop import _arm_fallback_restart
|
|
from agent.retry_utils import jittered_backoff
|
|
from agent.turn_recovery import describe_invalid_response, interruptible_backoff_sleep
|
|
|
|
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> InvalidResponseVerdict:
|
|
return InvalidResponseVerdict(
|
|
action=action, thinking_spinner=thinking_spinner,
|
|
active_system_prompt=active_system_prompt, retry_count=retry_count,
|
|
compression_attempts=compression_attempts, result=result,
|
|
)
|
|
|
|
agent._invoke_api_request_error_hook(
|
|
task_id=effective_task_id, turn_id=turn_id, api_request_id=api_request_id,
|
|
api_call_count=api_call_count, api_start_time=api_start_time, api_kwargs=api_kwargs,
|
|
error_type="InvalidAPIResponse",
|
|
error_message=", ".join(error_details) or "Invalid API response",
|
|
status_code=getattr(getattr(response, "error", None), "code", None),
|
|
retry_count=retry_count, max_retries=max_retries, retryable=True, reason="invalid_response",
|
|
)
|
|
# Retry status is buffered and only surfaced if every retry+fallback exhausts.
|
|
thinking_spinner = stop_thinking_spinner(agent, thinking_spinner)
|
|
retry_count += 1
|
|
|
|
# Eager fallback: empty/malformed responses often mean rate limiting.
|
|
if agent._fallback_index < len(agent._fallback_chain):
|
|
agent._buffer_status("⚠️ Empty/malformed response — switching to fallback...")
|
|
if agent._try_activate_fallback():
|
|
active_system_prompt = _arm_fallback_restart(
|
|
agent, api_messages, active_system_prompt, _retry)
|
|
retry_count = 0
|
|
compression_attempts = 0
|
|
return _verdict("break")
|
|
|
|
error_msg, provider_name, _failure_hint = describe_invalid_response(
|
|
agent, response, api_duration
|
|
)
|
|
agent._buffer_vprint(f"⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}")
|
|
agent._buffer_vprint(f" 🏢 Provider: {provider_name}")
|
|
agent._buffer_vprint(f" 📝 Provider message: {agent._clean_error_message(error_msg)}")
|
|
agent._buffer_vprint(f" ⏱️ {_failure_hint}")
|
|
|
|
if retry_count >= max_retries:
|
|
if agent._has_pending_fallback():
|
|
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
|
|
if agent._try_activate_fallback():
|
|
active_system_prompt = _arm_fallback_restart(
|
|
agent, api_messages, active_system_prompt, _retry)
|
|
retry_count = 0
|
|
compression_attempts = 0
|
|
return _verdict("break")
|
|
# Terminal — flush buffered retry trace so user sees what happened.
|
|
agent._flush_status_buffer()
|
|
agent._emit_status(f"❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.")
|
|
logger.error("%sInvalid API response after %d retries.", agent.log_prefix, max_retries)
|
|
agent._persist_session(messages, conversation_history)
|
|
_final_response = f"Invalid API response after {max_retries} retries: {_failure_hint}"
|
|
return _verdict("return", {
|
|
"final_response": _final_response,
|
|
"messages": messages,
|
|
"completed": False,
|
|
"api_calls": api_call_count,
|
|
"error": _final_response,
|
|
"failed": True,
|
|
})
|
|
|
|
wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0)
|
|
agent._buffer_vprint(f"⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...")
|
|
logger.warning("Invalid API response (retry %d/%d): %s | Provider: %s", retry_count, max_retries, ', '.join(error_details), provider_name)
|
|
|
|
# A redirect cancels only the live request; the helper preserves the pending
|
|
# correction (restart_with_redirected_messages) instead of clear_interrupt()-ing it.
|
|
_interrupted = interruptible_backoff_sleep(
|
|
agent, wait_time, _retry, messages=messages, conversation_history=conversation_history,
|
|
api_call_count=api_call_count,
|
|
abort_message="Interrupt detected during retry wait, aborting.",
|
|
interrupt_text=f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).",
|
|
activity_label=f"retry backoff ({retry_count}/{max_retries})",
|
|
)
|
|
if _interrupted is not None:
|
|
return _verdict("return", _interrupted)
|
|
if _retry.restart_with_redirected_messages:
|
|
return _verdict("break") # rebuild this iteration from the correction
|
|
return _verdict("continue")
|