448 lines
18 KiB
Python
448 lines
18 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. Extracted from ``run_conversation``; nothing here imports
|
|
``agent.conversation_loop`` at module level (cycle) — loop-internal helpers resolve lazily so
|
|
existing ``patch("agent.conversation_loop.X")`` sites keep intercepting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
|
|
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 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.conversation_loop import (
|
|
validate_response_shape,
|
|
)
|
|
api_duration = None
|
|
|
|
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
|
|
|
|
# Stop thinking spinner silently -- the response box or tool
|
|
# execution messages that follow are more informative.
|
|
if thinking_spinner:
|
|
thinking_spinner.stop("")
|
|
thinking_spinner = None
|
|
if agent.thinking_callback:
|
|
agent.thinking_callback("")
|
|
|
|
if not agent.quiet_mode:
|
|
agent._vprint(f"{agent.log_prefix}⏱️ API call completed in {api_duration:.2f}s")
|
|
|
|
if agent.verbose_logging:
|
|
# Log response with provider info if available
|
|
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'}")
|
|
|
|
# Validate response shape before proceeding
|
|
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
|
|
|
|
# Check finish_reason before proceeding
|
|
if agent.api_mode == "codex_responses":
|
|
status = getattr(response, "status", None)
|
|
if isinstance(status, str):
|
|
status = status.strip().lower()
|
|
incomplete_details = getattr(response, "incomplete_details", None)
|
|
incomplete_reason = 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"}:
|
|
# Responses API max-output exhaustion is a normal Codex
|
|
# incomplete turn: use the Codex continuation path, not the
|
|
# length rollback.
|
|
finish_reason = "incomplete"
|
|
elif status == "incomplete" and incomplete_reason == "content_filter":
|
|
finish_reason = "content_filter"
|
|
else:
|
|
finish_reason = "stop"
|
|
elif agent.api_mode == "anthropic_messages":
|
|
_tfr = agent._get_transport()
|
|
finish_reason = _tfr.map_finish_reason(response.stop_reason)
|
|
elif agent.api_mode == "bedrock_converse":
|
|
# Bedrock response already normalized at dispatch — use transport
|
|
_bt_fr = agent._get_transport()
|
|
_bedrock_result = _bt_fr.normalize_response(response)
|
|
finish_reason = _bedrock_result.finish_reason
|
|
else:
|
|
_cc_fr = agent._get_transport()
|
|
_finish_result = _cc_fr.normalize_response(response)
|
|
finish_reason = _finish_result.finish_reason
|
|
assistant_message = _finish_result
|
|
if agent._should_treat_stop_as_truncated(
|
|
finish_reason,
|
|
assistant_message,
|
|
messages,
|
|
):
|
|
agent._vprint(
|
|
f"{agent.log_prefix}⚠️ Treating suspicious Ollama/GLM stop response as truncated",
|
|
force=True,
|
|
)
|
|
finish_reason = "length"
|
|
|
|
# ── Content-policy refusal (HTTP 200) ──────────────────
|
|
# Refusal finish reasons (``content_filter``, ``guardrail_intervened``)
|
|
# 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 == "return":
|
|
return _verdict("return", _tv.result)
|
|
if _tv.action == "break":
|
|
return _verdict("break")
|
|
if _tv.action == "continue":
|
|
return _verdict("continue")
|
|
|
|
# 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 # Reset on success
|
|
# Don't clear the retry buffer: bytes back != usable content; it is
|
|
# cleared once genuine content lands. 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") # Success, exit retry loop
|
|
return _verdict("fallthrough")
|
|
|
|
|
|
@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,
|
|
describe_invalid_response,
|
|
interruptible_backoff_sleep,
|
|
jittered_backoff,
|
|
)
|
|
|
|
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",
|
|
)
|
|
# Stop spinner silently — retry status is now buffered
|
|
# and only surfaced if every retry+fallback exhausts.
|
|
if thinking_spinner:
|
|
thinking_spinner.stop("")
|
|
thinking_spinner = None
|
|
if agent.thinking_callback:
|
|
agent.thinking_callback("")
|
|
|
|
# Invalid response — could be rate limiting, provider timeout,
|
|
# upstream server error, or malformed response.
|
|
retry_count += 1
|
|
|
|
# Eager fallback: empty/malformed responses often mean rate limiting
|
|
# — switch now instead of extended backoff.
|
|
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}")
|
|
cleaned_provider_error = agent._clean_error_message(error_msg)
|
|
agent._buffer_vprint(f" 📝 Provider message: {cleaned_provider_error}")
|
|
agent._buffer_vprint(f" ⏱️ {_failure_hint}")
|
|
|
|
if retry_count >= max_retries:
|
|
# Try fallback before giving up
|
|
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 # Mark as failure for filtering
|
|
})
|
|
|
|
# Backoff before retry — jittered exponential: 5s base, 120s cap
|
|
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
|
|
# destroying it with clear_interrupt().
|
|
_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") # Retry the API call
|
|
return _verdict("fallthrough")
|