Client auto retry for empty, rate-limited and missing-reasoning responses (#103)

* feat: per-client auto retry for empty, rate-limited and missing-reasoning responses (#102)

* fix: scope auto-retry alert close to the emitting client

* fix: make auto-retry Abort effective between attempts in scene-free generations

* fix: auto-retry alert was invisible for fast retry sequences - enforce minimum display time, keep content through fade-out, offset below status snackbar

* fix: reset auto-retry alert display window on client content handoff

* fix: address auto-retry review round 3 - client-owned abort latch, reasoning retry extraction, clamped retry config, countdown and layout polish

* fix: discard stale auto-retry abort latch at sequence entry, disable Abort during linger

* fix: explicit dialog choice discards stale auto-retry abort latch

* fix: key auto-retry abort and alert by per-sequence generation id - same-client concurrency (FOCAL streaks, background chats) is normal operation, not experimental

* test: update direct _generate_with_error_handling callers for generation id param

* docs: concurrent requests are used beyond visual prompt generation

* fix: don't consume an active scene's interrupt flag in retry checks, ignore late aborts for finished sequences

* refactor: unify the three auto-retry policy blocks into _auto_retry_or_prompt

* docs: add Mistral to concurrent requests supported clients
This commit is contained in:
veguAI
2026-07-19 11:46:38 +03:00
committed by GitHub
parent 0cf438ffe9
commit 190742b903
18 changed files with 1097 additions and 84 deletions

View File

@@ -7,6 +7,7 @@
- "Help Agent: A new Help agent provides an interactive help chat that answers questions about Talemate itself — settings, agents, clients, the world editor and more — grounded in the bundled documentation, which it can search and read before answering. Opened via the help icon in the app bar, it works with or without a loaded scene, supports multiple persistent multi-turn chats, and runs in the background so it never blocks the main Talemate loop. A per-chat Scene Aware toggle lets a conversation see (or stay unaware of) the currently loaded scene, and each question carries a small snapshot of what you are looking at in the interface so contextual questions like 'what does this setting do?' can be answered. The most recent answer can be regenerated via a button right on the message, matching the director chat. Beyond answering questions, the help agent can read your actual configuration — any agent's settings, application settings, and a read-only view of your clients (API keys are never exposed) — and, when asked, change agent settings (globally or as a per-scene override) and application settings directly from the chat, recording exactly what changed. It cannot change scene content — scene changes remain the director chat's job."
- "Scene Backdrop: Any scene illustration can now be set as the scene backdrop — an image that fills the whole scene view behind the messages instead of rendering inline. The backdrop belongs to the scene and is saved with it, so it survives reloads and history edits. Set it via 'Set as scene backdrop' on any illustration's image menu, the 'Set backdrop' button in the Visual Library, or enable 'Auto Backdrop' per visual type (Settings → Appearance → Message Visuals) to have newly generated Scene Backgrounds ('Visualize Scene (Background)') and/or Scene Illustrations ('Visualize Moment') promoted automatically. An 'Immersive' quick-toggle chip in the scene tools turns the backdrop on and off without forgetting the chosen image. Message text sits on translucent panels with a drop shadow for legibility — panel opacity and the text shadow are configurable — and a small marker icon shows which message's image is the current backdrop (click it for the image menu, which stays reachable via an Illustration chip on the message hover toolbar)."
- "Visual Prompt Finalization: The Visualizer agent gained a Prompt Finalization settings tab defining post-processing actions (exact, fuzzy or regex match and replace, or an AI instruction) that rewrite image prompts right before they are sent to the image generation backend. Actions can target positive and/or negative prompts, be restricted to specific visual types, and be overridden per scene. Characters can define their own actions under World Editor → Characters → Visuals → Prompt Finalization, which run after the agent's. Reusable action sets are managed as a new 'Visual prompt finalizer' template type, including a shipped Ideogram JSON preset that converts the positive prompt into an Ideogram 4.0 structured JSON prompt. Prompt-only generation output is finalized as well, and a new FinalizePrompt node exposes the step to custom node graphs."
- "Client Auto Retry: Clients can now automatically retry on response issues before you are notified — separate 05 retry sliders for empty responses, API rate limiting (HTTP 429, with progressively longer waits between attempts), and reasoning models that skip their reasoning tokens. A notification shows the retry progress with an abort option, and when retries run out (or a slider is at 0, the default) the usual generation error dialog appears."
- "New Node Graph Events: Node graphs can now hook into more of this release's features. `agent.visual.prompt_finalize.before` / `.after` fire around visual prompt finalization (mutable prompts, and the `.before` finalizer list can be modified — they fire even with the agent setting disabled, so a graph can act as its own finalizer). `agent.creator.dialogue_examples.before` / `.after` fire around character example dialogue generation (inject instructions, or rewrite the generated examples). `agent.help.chat.before` / `.after` fire around help chat responses. Scene asset mutations gained `asset_deleted`, `scene.backdrop_changed`, `scene.cover_image_changed` and `character.cover_image_changed` alongside the existing `asset_saved`. All are documented in the node editor's Events reference."
- "Simplified Character Card Import: The character card import dialog gained an AI Generation section with individual toggles for each generation step — content context, description rewrite, attribute extraction, dialogue instructions, example dialogue, and story intent — plus Full/Minimal preset buttons to flip them all at once. Disabled steps fall back to the card's original data (the description and example dialogue import verbatim), so a minimal import needs no text generation at all and completes in a fraction of the time."
improvements:

View File

@@ -0,0 +1,16 @@
# Auto Retry
By default, when a generation runs into a response issue — an empty response, an API rate limit, or a reasoning model that skipped its reasoning tokens — Talemate notifies you immediately with a dialog offering to retry, ignore, or cancel the generation.
You can instead configure a client to quietly retry a number of times on its own before you are notified. Each response issue has its own slider (0 to 5 retries, default 0 = notify immediately):
- **Empty Response** — the model returned an empty response. Retries fire immediately.
- **Rate Limited** — the API responded with HTTP 429. Retries wait progressively longer between attempts (2s, 4s, 8s, 16s, capped at 30s). Other API errors are not affected and still notify you immediately.
- **Missing Reasoning** — a reasoning model's response did not contain the expected reasoning pattern. Retries fire immediately. See [Reasoning](reasoning.md).
The **Empty Response** and **Rate Limited** sliders are on the **Advanced** tab of the [client configuration](client-configuration.md) dialog. The **Missing Reasoning** slider is on the **Reasoning** tab, next to the **Pattern Not Found Behavior** setting (it only appears when that setting is **Fail** — with **Ignore**, a missing pattern is never treated as an error).
While automatic retries are running, a notification at the top of the screen shows which client is retrying, why, and the attempt count. You can abort the generation from there at any time. If all automatic retries are exhausted, the usual generation error dialog appears.
!!! note "Rate limit responses vs. the Rate Limit slider"
The **Rate Limited** retry slider reacts to the *API* telling Talemate to slow down (HTTP 429). This is separate from the [Rate Limit](rate-limiting.md) slider, which is Talemate's own client-side cap on requests per minute.

View File

@@ -19,7 +19,7 @@ The tabs that appear depend on the client type. The core set is:
|---|---|
| **General** | Client type, name, API URL / key, model, context length, prompt template (for local clients). |
| **Coercion** | Prefill text used to enforce compliance. Only shown for clients that can be coerced. |
| **Advanced** | Inference Presets, Structured Data Format, [Section Format](section-format.md), [Response Length Enforcement](response-length.md), Prompt Caching, and [Rate Limit](rate-limiting.md). |
| **Advanced** | Inference Presets, Structured Data Format, [Section Format](section-format.md), [Response Length Enforcement](response-length.md), Prompt Caching, [Rate Limit](rate-limiting.md), and [Auto Retry](auto-retry.md). |
| **Reasoning** | [Reasoning model support](reasoning.md) settings. |
| **System Prompts** | Per-client [system prompt overrides](../app-settings/system-prompts.md). |
@@ -37,6 +37,7 @@ The Advanced tab contains settings that you usually only need to touch once per
- **Response Length Enforcement** — how the response length is communicated to the model. See [Response Length Enforcement](response-length.md).
- **Optimize for Prompt Caching** — moves volatile context after the scene history to improve cache hit rates. See [Volatile Context Placement](../prompts/volatile-context-placement.md).
- **Rate Limit** — caps requests per minute. See [Rate Limiting](rate-limiting.md).
- **Auto Retry** — automatic retries on empty or rate-limited responses before you are notified. See [Auto Retry](auto-retry.md).
From the General tab you can also jump straight to Advanced with the :material-cog-outline: **Advanced Options** button underneath the basic fields.

View File

@@ -4,9 +4,16 @@ Concurrent requests is an experimental feature that allows certain LLM clients t
## What It Does
When enabled, operations that require multiple LLM queries (such as generating image prompts) will execute those queries in parallel instead of sequentially. This can significantly reduce the total time needed for these batch operations.
When enabled, operations that require multiple LLM queries will execute those queries in parallel instead of sequentially. This can significantly reduce the total time needed for these batch operations.
**Currently, this feature is only used for visual prompt generation** (creating prompts for image generation). It is not applied to regular conversation or narration tasks.
Operations that take advantage of this include:
- Visual/image prompt generation
- Function-calling batches, where an AI response requests several tool calls at once (for example director chat query actions or help agent documentation lookups)
- Multi-query world state updates
- World state snapshots, which only run as true background tasks when the client can handle a concurrent request
Regular conversation and narration tasks are single generations and are unaffected.
## Supported Clients
@@ -15,6 +22,7 @@ Concurrent requests are available for the following hosted API clients:
- [Anthropic](/talemate/user-guide/clients/types/anthropic/)
- [OpenAI](/talemate/user-guide/clients/types/openai/)
- [Google Gemini](/talemate/user-guide/clients/types/google/)
- [MistralAI](/talemate/user-guide/clients/types/mistral/)
- [OpenRouter](/talemate/user-guide/clients/types/openrouter/)
- [Pi Bridge](/talemate/user-guide/clients/types/pi-bridge/) — each concurrent request runs its own pi instance
@@ -41,8 +49,8 @@ You can also enable this feature through the client's settings dialog under the
Consider enabling concurrent requests if:
- You frequently use the visual/image generation features
- You want to reduce wait times during image prompt generation
- You frequently use the visual/image generation features, the director or help chats, or tracked world states
- You want to reduce wait times during batch operations
- You are not experiencing rate limit issues with the API
You can safely leave this disabled if:

View File

@@ -2,6 +2,8 @@
You can rate limit a client to N requests per minute. The slider is on the **Advanced** tab of the [client configuration](client-configuration.md) dialog.
This is Talemate's own client-side cap. If instead the *API* is rate limiting you (HTTP 429 responses), see the **Rate Limited** slider under [Auto Retry](auto-retry.md).
![Rate limit](/talemate/img/0.30.0/client-ratelimit.png)
Once the limit is hit you will get a popup notification.

View File

@@ -66,6 +66,8 @@ When the configured reasoning pattern is not found in a response, you can contro
- **Fail** (default) - Raises an error, causing the request to fail. Use this when you expect the model to always include reasoning tokens and want to be alerted if it doesn't.
- **Ignore** - Returns the response as-is without stripping anything. Use this when the model may sometimes respond without reasoning tokens (e.g., for simple queries).
When set to **Fail**, the **Auto Retry** slider next to it lets the client automatically retry a number of times before you are notified. See [Auto Retry](auto-retry.md).
## Forcing Reasoning Off for Specific Actions
The **Enable Reasoning** checkbox is a global setting for a client — when it's on, every prompt that client handles uses reasoning. Sometimes that isn't what you want. A reasoning model might do an excellent job writing dialogue but waste time (and tokens) "thinking" before simple, mechanical tasks like summarization or world-state updates.

View File

@@ -381,16 +381,23 @@
panel to create a new scene. Covers the import dialog, character detection/manual selection, options for character book
entries, alternate greetings, shared context setup, writing style template, player character setup, and troubleshooting
failed analysis.
- path: user-guide/clients/auto-retry.md
title: Auto Retry
summary: 'Per-client sliders (0-5, default 0 = notify immediately) for automatically retrying response issues before the
generation error dialog appears: empty responses, API rate limiting (HTTP 429, with growing backoff between attempts),
and reasoning models that skip their reasoning tokens. Covers the retry notification with its abort option and where each
slider lives (Advanced tab; Reasoning tab for missing reasoning).'
- path: user-guide/clients/client-configuration.md
title: Client Configuration
summary: 'Tour of the per-client settings dialog and its tabs (General, Coercion, Advanced, Reasoning, System Prompts):
where to find Inference Presets, Structured Data Format, Section Format, Response Length Enforcement, Prompt Caching,
and Rate Limit, plus the Simple View toggle for quick setup.'
Rate Limit, and Auto Retry, plus the Simple View toggle for quick setup.'
- path: user-guide/clients/concurrent-requests.md
title: Concurrent Requests (Experimental)
summary: Experimental feature that lets certain clients (Anthropic, OpenAI, Google, OpenRouter, llama.cpp) run multiple
LLM requests in parallel — currently used only for visual/image prompt generation. Covers how to enable it via the client-list
toggle or Concurrency tab and rate-limit caveats.
summary: Experimental feature that lets certain clients (Anthropic, OpenAI, Google, MistralAI, OpenRouter, Pi Bridge, llama.cpp)
run multiple LLM requests in parallel — used by visual/image prompt generation, function-calling batches (director chat query
actions, help agent doc lookups), multi-query world state updates, and background world state snapshots. Covers how to
enable it via the client-list toggle or Concurrency tab and rate-limit caveats.
- path: user-guide/clients/endpoint-override.md
title: Endpoint Override
summary: 'How to point a remote client at a custom API endpoint such as a LiteLLM proxy gateway: the Endpoint Override tab,

View File

@@ -110,6 +110,9 @@ HTTP_ERROR_MESSAGES = {
EMPTY_RESPONSE_MESSAGE = "The model returned an empty response. This can happen due to content filtering, server issues, or a reasoning budget that is too low."
# cap (seconds) for the exponential backoff between automatic rate-limit retries
AUTO_RETRY_MAX_BACKOFF = 30
def get_error_message(status_code: int | None) -> str:
"""Get a human-friendly error message for an HTTP status code."""
@@ -128,6 +131,14 @@ _generation_error_futures: dict[str, asyncio.Future] = {}
GenerationErrorAction = Literal["retry", "cancel", "ignore"]
AutoRetryIssue = Literal["empty_response", "rate_limit", "missing_reasoning"]
AUTO_RETRY_ISSUE_LABELS: dict[AutoRetryIssue, str] = {
"empty_response": "Empty response",
"rate_limit": "Rate limited",
"missing_reasoning": "Missing reasoning tokens",
}
def resolve_generation_error(request_id: str, action: GenerationErrorAction):
"""Called from the websocket handler when the user responds to a generation error dialog."""
@@ -149,6 +160,9 @@ def resolve_all_generation_errors(action: GenerationErrorAction):
class CommonDefaults(pydantic.BaseModel):
rate_limit: int | None = None
retry_empty_response: int = 0
retry_rate_limit: int = 0
retry_missing_reasoning: int = 0
data_format: Literal["yaml", "json"] | None = None
section_format: Literal["markdown", "xml"] | None = None
preset_group: str | None = None
@@ -333,6 +347,8 @@ class ClientBase:
self.remote_model_name = None
self.auto_determine_prompt_template_attempt = None
self._status_failures = 0
self._auto_retry_aborts: set[str] = set()
self._auto_retry_live_ids: set[str] = set()
self.log = structlog.get_logger(f"client.{self.client_type}")
def __str__(self):
@@ -384,6 +400,18 @@ class ClientBase:
def rate_limit(self) -> int | None:
return self.client_config.rate_limit
@property
def retry_empty_response(self) -> int:
return self.client_config.retry_empty_response
@property
def retry_rate_limit(self) -> int:
return self.client_config.retry_rate_limit
@property
def retry_missing_reasoning(self) -> int:
return self.client_config.retry_missing_reasoning
@property
def data_format(self) -> Literal["yaml", "json"]:
return self.client_config.data_format
@@ -1080,6 +1108,9 @@ class ClientBase:
"can_be_coerced": self.can_be_coerced,
"preset_group": self.preset_group or "",
"rate_limit": self.rate_limit,
"retry_empty_response": self.retry_empty_response,
"retry_rate_limit": self.retry_rate_limit,
"retry_missing_reasoning": self.retry_missing_reasoning,
"data_format": self.data_format,
"section_format": self.section_format,
"manual_model_choices": getattr(self.Meta(), "manual_model_choices", []),
@@ -1337,7 +1368,10 @@ class ClientBase:
pass
async def _prompt_generation_error(
self, error_message: str, status_code: int | None = None
self,
error_message: str,
status_code: int | None = None,
generation_id: str | None = None,
) -> GenerationErrorAction:
"""
Emit a generation error to the frontend and wait for the user's choice.
@@ -1358,67 +1392,273 @@ class ClientBase:
"model": self.model_name,
"status_code": status_code,
"error_message": error_message,
"generation_id": generation_id,
},
)
return await future
action = await future
# an explicit dialog choice supersedes an abort clicked before
# the dialog appeared - discard the stale latch so it can't
# override a dialog-retry later in this sequence
if generation_id:
self._auto_retry_aborts.discard(generation_id)
return action
finally:
_generation_error_futures.pop(request_id, None)
def _emit_auto_retry(
self,
issue: AutoRetryIssue,
attempt: int,
total: int,
generation_id: str,
wait: float = 0,
):
"""
Notify the frontend that an automatic retry is in progress.
"""
message = AUTO_RETRY_ISSUE_LABELS[issue]
emit(
"auto_retry",
message=message,
websocket_passthrough=True,
data={
"client": self.name,
"issue": issue,
"message": message,
"attempt": attempt,
"total": total,
"wait": wait,
"generation_id": generation_id,
},
)
def _emit_auto_retry_done(self, generation_id: str):
emit(
"auto_retry_done",
message="",
websocket_passthrough=True,
data={"client": self.name, "generation_id": generation_id},
)
def request_auto_retry_abort(self, generation_id: str):
"""
Latches an abort click from the auto-retry notification, keyed by the
retry sequence it was aimed at - concurrent generations on one client
(FOCAL concurrent callbacks, background chats) must not consume each
other's aborts. Kept on the client because scene.cancel_requested is
unconditionally reset by any plugin-routed websocket action
(Plugin.handle), which would silently drop an abort that lands
mid-attempt or mid-backoff.
"""
if generation_id not in self._auto_retry_live_ids:
# the sequence already ended - a late abort must not latch an
# id nothing will ever observe or clean up
return
self._auto_retry_aborts.add(generation_id)
def _auto_retry_cancelled(self, generation_id: str) -> bool:
"""
Scene-free generations (help chat, background flows) run with
requires_active_scene unset, so _poll_interrupt never observes an
abort click - the auto-retry machinery checks between attempts
instead. Consumes the abort latch like Scene.continue_actions() so a
latched abort doesn't cancel a later generation's retries.
"""
cancelled = False
if generation_id in self._auto_retry_aborts:
self._auto_retry_aborts.discard(generation_id)
cancelled = True
scene = active_scene.get()
if scene and scene.cancel_requested:
# an active scene's flag is observed non-consumingly by every
# concurrent generation's _poll_interrupt and reset by the
# GenerationCancelled handlers - consuming it here would steal
# the stop from them. Only the inactive placeholder scene
# (scene-free flows) has no other reset path.
if not scene.active:
scene.cancel_requested = False
cancelled = True
return cancelled
async def _auto_retry_backoff_wait(self, delay: float, generation_id: str) -> bool:
"""
Sleep for `delay` seconds before an automatic rate-limit retry,
aborting early when the generation is cancelled.
Scene-free generations (e.g. the help chat) run with an inactive
placeholder scene in context, so an inactive scene only counts as a
cancellation when the scene was active at the start of the wait.
Returns False if aborted.
"""
scene = active_scene.get()
scene_was_active = bool(scene and scene.active)
remaining = delay
while remaining > 0:
if self._auto_retry_cancelled(generation_id):
return False
if scene and scene_was_active and not scene.active:
return False
await asyncio.sleep(min(1, remaining))
remaining -= 1
return True
async def _auto_retry_or_prompt(
self,
issue: AutoRetryIssue,
error_message: str,
auto_retries: dict[str, int],
limit: int,
generation_id: str,
status_code: int | None = None,
) -> GenerationErrorAction:
"""
Decide how to proceed after a retryable response issue: auto-retry
while the configured budget allows (rate-limit retries back off
exponentially), then fall through to the user's retry/cancel/ignore
dialog.
Returns "retry" or "ignore". Raises GenerationCancelled when the
generation was aborted or the user cancelled.
"""
if auto_retries[issue] < limit:
if self._auto_retry_cancelled(generation_id):
raise GenerationCancelled("Generation cancelled")
auto_retries[issue] += 1
wait = (
min(2 ** auto_retries[issue], AUTO_RETRY_MAX_BACKOFF)
if issue == "rate_limit"
else 0
)
self._emit_auto_retry(
issue, auto_retries[issue], limit, generation_id, wait=wait
)
if wait and not await self._auto_retry_backoff_wait(wait, generation_id):
raise GenerationCancelled("Generation cancelled")
return "retry"
action = await self._prompt_generation_error(
error_message, status_code=status_code, generation_id=generation_id
)
if action == "cancel":
raise GenerationCancelled("Generation cancelled by user")
return action
async def _generate_with_error_handling(
self, finalized_prompt: str, prompt_param: dict, kind: str
self, finalized_prompt: str, prompt_param: dict, kind: str, generation_id: str
) -> str:
"""
Wraps _cancelable_generate in a retry loop. On API errors or empty
responses, prompts the user with retry/cancel/ignore options.
Rate limit (429) errors and empty responses are automatically retried
up to the client's configured counts before the user is prompted.
Returns the generation response string.
"""
while True:
self.new_request()
auto_retries = {"rate_limit": 0, "empty_response": 0}
try:
while True:
self.new_request()
try:
response = await self._cancelable_generate(
finalized_prompt, prompt_param, kind
)
except GenerationCancelled:
raise
except Exception as e:
self.log.error("generation error", e=traceback.format_exc())
status_code = self._extract_status_code(e)
# exceptions may carry a user-presentable message that is more
# accurate than the generic per-status text (e.g. clients whose
# transport reports errors without HTTP status codes)
error_message = getattr(e, "user_message", None) or get_error_message(
status_code
)
action = await self._prompt_generation_error(
error_message, status_code=status_code
)
if action == "retry":
continue
elif action == "cancel":
raise GenerationCancelled("Generation cancelled by user")
else:
try:
response = await self._cancelable_generate(
finalized_prompt, prompt_param, kind
)
except GenerationCancelled:
raise
except Exception as e:
self.log.error("generation error", e=traceback.format_exc())
status_code = self._extract_status_code(e)
# exceptions may carry a user-presentable message that is more
# accurate than the generic per-status text (e.g. clients whose
# transport reports errors without HTTP status codes)
error_message = getattr(
e, "user_message", None
) or get_error_message(status_code)
action = await self._auto_retry_or_prompt(
"rate_limit",
error_message,
auto_retries,
self.retry_rate_limit if status_code == 429 else 0,
generation_id,
status_code=status_code,
)
if action == "retry":
continue
# ignore - proceed with empty response
return ""
if isinstance(response, GenerationCancelled):
raise response
if isinstance(response, GenerationCancelled):
raise response
# Check for empty response
if not response or not response.strip():
self.log.warning("empty response from generation")
action = await self._prompt_generation_error(
EMPTY_RESPONSE_MESSAGE, status_code=None
if not response or not response.strip():
self.log.warning("empty response from generation")
action = await self._auto_retry_or_prompt(
"empty_response",
EMPTY_RESPONSE_MESSAGE,
auto_retries,
self.retry_empty_response,
generation_id,
)
if action == "retry":
continue
# ignore - proceed with empty response
return response
finally:
if any(auto_retries.values()):
self._emit_auto_retry_done(generation_id)
async def _generate_with_reasoning_handling(
self, finalized_prompt: str, prompt_param: dict, kind: str
) -> str:
"""
Wraps _generate_with_error_handling in a retry loop for responses
missing the expected reasoning pattern. Automatically retries up to
the client's configured count before prompting the user with
retry/cancel/ignore options.
Stores the stripped reasoning on self._reasoning_response and returns
the generation response string.
"""
# identifies this retry sequence in auto_retry emissions and abort
# requests - concurrent generations on one client each get their own
generation_id = str(uuid.uuid4())
self._auto_retry_live_ids.add(generation_id)
auto_retries = {"missing_reasoning": 0}
try:
while True:
response = await self._generate_with_error_handling(
finalized_prompt, prompt_param, kind, generation_id
)
if action == "retry":
continue
elif action == "cancel":
raise GenerationCancelled("Generation cancelled by user")
# else: ignore - proceed with empty response
return response
try:
response, reasoning_response = self.strip_reasoning(response)
except ReasoningResponseError as e:
action = await self._auto_retry_or_prompt(
"missing_reasoning",
str(e),
auto_retries,
self.retry_missing_reasoning,
generation_id,
)
if action == "retry":
continue
# ignore - proceed with raw response
reasoning_response = None
if reasoning_response:
self._reasoning_response = reasoning_response
return response
finally:
# an abort latched but never observed (e.g. the aborted attempt
# succeeded) must not linger once its sequence is over
self._auto_retry_live_ids.discard(generation_id)
self._auto_retry_aborts.discard(generation_id)
if auto_retries["missing_reasoning"]:
self._emit_auto_retry_done(generation_id)
def _extract_status_code(self, exception: Exception) -> int | None:
"""
@@ -1732,28 +1972,9 @@ class ClientBase:
"\n<|RESPONSE_LENGTH_INSTRUCTIONS|>", ""
)
while True:
response = await self._generate_with_error_handling(
finalized_prompt, prompt_param, kind
)
try:
response, reasoning_response = self.strip_reasoning(response)
except ReasoningResponseError as e:
action = await self._prompt_generation_error(
str(e), status_code=None
)
if action == "retry":
continue
elif action == "cancel":
raise GenerationCancelled("Generation cancelled by user")
else:
# ignore - proceed with raw response
reasoning_response = None
if reasoning_response:
self._reasoning_response = reasoning_response
break
response = await self._generate_with_reasoning_handling(
finalized_prompt, prompt_param, kind
)
if coercion_prompt:
response = self.process_response_for_indirect_coercion(

View File

@@ -47,6 +47,12 @@ class Client(pydantic.BaseModel):
# max requests per minute
rate_limit: Union[int, None] = None
# automatic retries before the user is notified of a response issue
# (0 = notify immediately)
retry_empty_response: int = 0
retry_rate_limit: int = 0
retry_missing_reasoning: int = 0
# expected data structure format in responses
data_format: Literal["json", "yaml"] | None = None
@@ -128,6 +134,16 @@ class Client(pydantic.BaseModel):
return False
return v
# clamp rather than reject so an out-of-range hand-edited config.yaml
# doesn't fail to load - an unbounded value here means an unbounded
# automatic retry loop against a (usually paid) API
@pydantic.field_validator(
"retry_empty_response", "retry_rate_limit", "retry_missing_reasoning"
)
@classmethod
def clamp_retry_counts(cls, v: int) -> int:
return max(0, min(5, v))
# Generic choice metadata for fields that should render as <v-select> in the
# frontend. Keyed by field name; values use {"label": ..., "value": ...} format.
FIELD_CHOICES: ClassVar[dict[str, list[dict[str, str]]]] = {

View File

@@ -21,6 +21,8 @@ ReceiveInput = signal("receive_input")
ClientStatus = signal("client_status")
RateLimited = signal("rate_limited")
RateLimitReset = signal("rate_limit_reset")
AutoRetry = signal("auto_retry")
AutoRetryDone = signal("auto_retry_done")
GenerationError = signal("generation_error")
GenerationErrorResponse = signal("generation_error_response")
RequestClientStatus = signal("request_client_status")
@@ -79,6 +81,8 @@ handlers = {
"client_status": ClientStatus,
"rate_limited": RateLimited,
"rate_limit_reset": RateLimitReset,
"auto_retry": AutoRetry,
"auto_retry_done": AutoRetryDone,
"generation_error": GenerationError,
"generation_error_response": GenerationErrorResponse,
"request_client_status": RequestClientStatus,

View File

@@ -240,6 +240,21 @@ async def websocket_endpoint(websocket):
if handler.scene.loading:
scene_task.cancel()
scene_task = None
elif action_type == "auto_retry_abort":
client_name = data.get("client")
generation_id = data.get("generation_id")
log.info(
"auto_retry_abort",
client=client_name,
generation_id=generation_id,
)
if generation_id:
try:
instance.get_client(client_name).request_auto_retry_abort(
generation_id
)
except KeyError:
pass
elif action_type == "request_app_config":
log.info("request_app_config")

View File

@@ -289,6 +289,9 @@ const SAVE_ECHO_FIELDS = [
'vision_enabled',
'concurrent_inference_enabled',
'rate_limit',
'retry_empty_response',
'retry_rate_limit',
'retry_missing_reasoning',
'data_format',
'section_format',
'double_coercion',
@@ -652,6 +655,9 @@ export default {
client.double_coercion = data.data.double_coercion;
client.manual_model_choices = data.data.manual_model_choices;
client.rate_limit = data.data.rate_limit;
client.retry_empty_response = data.data.retry_empty_response;
client.retry_rate_limit = data.data.retry_rate_limit;
client.retry_missing_reasoning = data.data.retry_missing_reasoning;
client.data_format = data.data.data_format;
client.section_format = data.data.section_format;
client.data = data.data;
@@ -689,6 +695,9 @@ export default {
double_coercion: data.data.double_coercion,
manual_model_choices: data.data.manual_model_choices,
rate_limit: data.data.rate_limit,
retry_empty_response: data.data.retry_empty_response,
retry_rate_limit: data.data.retry_rate_limit,
retry_missing_reasoning: data.data.retry_missing_reasoning,
data_format: data.data.data_format,
section_format: data.data.section_format,
data: data.data,

View File

@@ -0,0 +1,129 @@
<template>
<v-snackbar v-model="active" location="top" :timeout="-1" color="mutedbg" class="auto-retry-alert">
<v-progress-circular indeterminate size="16" width="2" color="primary" class="mr-2"></v-progress-circular>
<span class="text-primary">{{ client }}</span>: {{ message }} retrying
<span v-if="wait > 0">in {{ wait }}s </span>({{ attempt }}/{{ total }})
<template v-slot:actions>
<v-btn :disabled="aborting || closing" color="delete" variant="text" prepend-icon="mdi-cancel" @click="abort">Abort</v-btn>
</template>
</v-snackbar>
</template>
<script>
// fast retry sequences (an instant retry that immediately succeeds) can open
// and close within the snackbar's own transition - hold it on screen long
// enough to be readable
const MIN_VISIBLE_MS = 2500;
export default {
name: 'AutoRetryAlert',
data() {
return {
active: false,
client: null,
generationId: null,
message: '',
attempt: 0,
total: 0,
wait: 0,
aborting: false,
closing: false,
openedAt: 0,
closeTimer: null,
countdownTimer: null,
}
},
inject: ['getWebsocket'],
methods: {
open(data) {
if (this.closeTimer) {
clearTimeout(this.closeTimer)
this.closeTimer = null
}
// a handoff to another retry sequence's content starts a fresh
// readability window
if (!this.active || data.generation_id !== this.generationId) {
this.openedAt = Date.now()
}
this.client = data.client
this.generationId = data.generation_id
this.message = data.message
this.attempt = data.attempt
this.total = data.total
this.wait = data.wait || 0
this.aborting = false
this.closing = false
this.active = true
this.startCountdown()
},
startCountdown() {
this.stopCountdown()
if (this.wait <= 0) {
return
}
this.countdownTimer = setInterval(() => {
if (this.wait > 1) {
this.wait -= 1
} else {
this.wait = 0
this.stopCountdown()
}
}, 1000)
},
stopCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
this.countdownTimer = null
}
},
close(generationId = null, immediate = false) {
// with concurrent generations, one sequence finishing must not
// hide another sequence's live retry state
if (generationId && this.generationId && generationId !== this.generationId) {
return
}
this.aborting = false
// the sequence is over - an abort during the readability linger
// would have nothing to act on
this.closing = true
this.stopCountdown()
// keep the content fields - clearing them would blank the text
// while the snackbar is still fading out
const remaining = this.openedAt + MIN_VISIBLE_MS - Date.now()
if (immediate || remaining <= 0) {
this.active = false
return
}
if (this.closeTimer) {
clearTimeout(this.closeTimer)
}
this.closeTimer = setTimeout(() => {
this.active = false
this.closeTimer = null
}, remaining)
},
abort() {
this.aborting = true
// the interrupt cancels any in-flight generation; the dedicated
// abort message latches on the displayed retry sequence so the
// abort survives even when other websocket actions reset the
// scene's cancel flag
this.getWebsocket().send(JSON.stringify({ type: 'auto_retry_abort', client: this.client, generation_id: this.generationId }));
this.getWebsocket().send(JSON.stringify({ type: 'interrupt' }));
},
},
beforeUnmount() {
this.stopCountdown()
if (this.closeTimer) {
clearTimeout(this.closeTimer)
}
},
}
</script>
<style scoped>
/* sit below the top-center status snackbar so the two never overlap */
.auto-retry-alert :deep(.v-snackbar__wrapper) {
margin-top: 64px;
}
</style>

View File

@@ -243,7 +243,7 @@
</v-col>
</v-row>
<v-row v-if="client.reason_enabled && client.requires_reasoning_pattern">
<v-col cols="12">
<v-col :cols="client.reason_failure_behavior === 'fail' ? 6 : 12">
<v-select
v-model="client.reason_failure_behavior"
label="Pattern Not Found Behavior"
@@ -255,6 +255,9 @@
persistent-hint
></v-select>
</v-col>
<v-col cols="6" v-if="client.reason_failure_behavior === 'fail'">
<v-slider v-model="client.retry_missing_reasoning" label="Auto Retry" :min="0" :max="5" :step="1" :persistent-hint="true" hint="Automatic retries when the reasoning pattern is missing, before you are notified. (0 = notify immediately)" thumb-label="always"></v-slider>
</v-col>
</v-row>
<v-row v-if="client.reason_enabled && client.requires_reasoning_pattern">
<v-col cols="12">
@@ -331,6 +334,18 @@
<v-slider v-model="client.rate_limit" label="Rate Limit" :min="0" :max="100" :step="1" :persistent-hint="true" hint="Requests per minute. (0 = no limit)" thumb-label="always"></v-slider>
</v-col>
</v-row>
<!-- AUTO RETRY -->
<v-alert icon="mdi-refresh" density="compact" color="grey-darken-1" variant="text">
Automatic retries on response issues before you are notified. (0 = notify immediately)
</v-alert>
<v-row>
<v-col cols="6">
<v-slider v-model="client.retry_empty_response" label="Empty Response" :min="0" :max="5" :step="1" :persistent-hint="true" hint="Automatic retries when the model returns an empty response." thumb-label="always"></v-slider>
</v-col>
<v-col cols="6">
<v-slider v-model="client.retry_rate_limit" label="Rate Limited" :min="0" :max="5" :step="1" :persistent-hint="true" hint="Automatic retries when the API reports it is rate limited (HTTP 429), waiting progressively longer between attempts." thumb-label="always"></v-slider>
</v-col>
</v-row>
</v-window-item>
<!-- SYSTEM PROMPTS -->
<v-window-item value="system_prompts">
@@ -635,6 +650,9 @@ export default {
this.client.max_token_length = defaults.max_token_length || 8192;
this.client.double_coercion = defaults.double_coercion || null;
this.client.rate_limit = defaults.rate_limit || null;
this.client.retry_empty_response = defaults.retry_empty_response || 0;
this.client.retry_rate_limit = defaults.retry_rate_limit || 0;
this.client.retry_missing_reasoning = defaults.retry_missing_reasoning || 0;
this.client.data_format = defaults.data_format || null;
this.client.section_format = defaults.section_format || null;
this.client.preset_group = defaults.preset_group || '';

View File

@@ -393,6 +393,7 @@
</v-app>
<StatusNotification />
<RateLimitAlert ref="rateLimitAlert" />
<AutoRetryAlert ref="autoRetryAlert" />
<GenerationErrorDialog ref="generationErrorDialog" />
<SceneTimeline
ref="sceneTimeline"
@@ -425,6 +426,7 @@ import DebugTools from './DebugTools.vue';
import AudioQueue from './AudioQueue.vue';
import StatusNotification from './StatusNotification.vue';
import RateLimitAlert from './RateLimitAlert.vue';
import AutoRetryAlert from './AutoRetryAlert.vue';
import GenerationErrorDialog from './GenerationErrorDialog.vue';
import SceneTimeline from './SceneTimeline.vue';
import VersionMismatchAlert from './VersionMismatchAlert.vue';
@@ -474,6 +476,7 @@ export default {
NodeEditor,
DirectorConsole,
RateLimitAlert,
AutoRetryAlert,
GenerationErrorDialog,
SceneTimeline,
VersionMismatchAlert,
@@ -1116,7 +1119,19 @@ export default {
return;
}
if(data.type === 'auto_retry') {
this.$refs.autoRetryAlert.open(data.data);
return;
}
if(data.type === 'auto_retry_done') {
this.$refs.autoRetryAlert.close(data.data.generation_id);
return;
}
if(data.type === 'generation_error') {
// retries exhausted - the dialog supersedes the auto-retry snackbar
this.$refs.autoRetryAlert.close(data.data.generation_id, true);
this.$refs.generationErrorDialog.open(data.data);
return;
}

View File

@@ -0,0 +1,531 @@
"""Tests for issue #102 — per-client automatic retries on response issues.
Each client can be configured to automatically retry N times (0-5, default 0)
per response issue — empty response, rate limit (429) and missing reasoning
tokens — before the user is notified via the generation error dialog. 0 keeps
the previous behavior of prompting the user immediately.
These drive the REAL ClientBase.send_prompt machinery with a scripted
`generate`, with `_prompt_generation_error` mocked to observe (or rule out)
dialog fall-through.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
import talemate.config.state as config_state
from talemate.agents.context import ActiveAgent
from talemate.client.base import (
ClientBase,
_generation_error_futures,
resolve_generation_error,
)
from talemate.client.context import ClientContext
from talemate.config.schema import Client as ClientConfig
from talemate.context import ActiveScene
from talemate.emit.signals import handlers as emit_handlers
from talemate.exceptions import GenerationCancelled
from conftest import MockScene, bootstrap_scene
class Scripted429(RuntimeError):
status_code = 429
class Scripted500(RuntimeError):
status_code = 500
class ScriptedClient(ClientBase):
"""Real ClientBase; `generate` plays back a script of results.
Script entries are a string (the response), an exception instance to
raise, or a callable invoked mid-attempt (returning the response) -
the latter for side effects like an abort click landing while the
attempt is in flight.
"""
client_type = "stub"
@property
def supported_parameters(self):
return ["temperature", "max_tokens"]
def __init__(self, script: list, **kwargs):
super().__init__(**kwargs)
self.script = list(script)
self.calls = 0
async def generate(self, prompt, parameters, kind):
self.calls += 1
await asyncio.sleep(0)
result = self.script.pop(0)
if callable(result):
result = result()
if isinstance(result, Exception):
raise result
return result
@pytest.fixture
def scripted_env():
"""Yields a factory that registers a ScriptedClient with the given
per-issue retry config and collects auto_retry emissions."""
saved_clients = dict(config_state.CONFIG.clients)
emissions = []
def on_auto_retry(emission):
emissions.append(emission)
emit_handlers["auto_retry"].connect(on_auto_retry)
emit_handlers["auto_retry_done"].connect(on_auto_retry)
def make_client(script: list, **config_kwargs) -> ScriptedClient:
config_state.CONFIG.clients["scripted"] = ClientConfig(
type="stub", name="scripted", **config_kwargs
)
return ScriptedClient(script, name="scripted")
scene = MockScene()
agents = bootstrap_scene(scene)
scene.active = True
def _agent_fn():
pass
with ActiveScene(scene), ActiveAgent(agents["summarizer"], _agent_fn):
yield make_client, emissions, scene
emit_handlers["auto_retry"].disconnect(on_auto_retry)
emit_handlers["auto_retry_done"].disconnect(on_auto_retry)
config_state.CONFIG.clients.clear()
config_state.CONFIG.clients.update(saved_clients)
def auto_retry_events(emissions, typ="auto_retry"):
return [e for e in emissions if e.typ == typ]
@pytest.mark.asyncio
async def test_empty_response_auto_retry_then_success(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client(["", "", "ok"], retry_empty_response=2)
dialog = AsyncMock()
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok"
assert client.calls == 3
assert dialog.await_count == 0
retries = auto_retry_events(emissions)
assert [(e.data["attempt"], e.data["total"]) for e in retries] == [(1, 2), (2, 2)]
assert all(e.data["issue"] == "empty_response" for e in retries)
assert all(e.data["client"] == "scripted" for e in retries)
# snackbar closed after the retry sequence resolved
assert len(auto_retry_events(emissions, "auto_retry_done")) == 1
@pytest.mark.asyncio
async def test_empty_response_retries_exhausted_prompts_user(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client(["", "", ""], retry_empty_response=2)
dialog = AsyncMock(return_value="ignore")
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == ""
assert client.calls == 3
assert dialog.await_count == 1
assert len(auto_retry_events(emissions)) == 2
@pytest.mark.asyncio
async def test_default_zero_prompts_user_immediately(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client([""])
dialog = AsyncMock(return_value="ignore")
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == ""
assert client.calls == 1
assert dialog.await_count == 1
assert not auto_retry_events(emissions)
@pytest.mark.asyncio
async def test_rate_limit_auto_retry_with_backoff(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client([Scripted429(), Scripted429(), "ok"], retry_rate_limit=3)
dialog = AsyncMock()
backoff = AsyncMock(return_value=True)
with (
patch.object(client, "_prompt_generation_error", dialog),
patch.object(client, "_auto_retry_backoff_wait", backoff),
):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok"
assert client.calls == 3
assert dialog.await_count == 0
# exponential backoff: 2s then 4s
assert [call.args[0] for call in backoff.await_args_list] == [2, 4]
retries = auto_retry_events(emissions)
assert [(e.data["attempt"], e.data["total"]) for e in retries] == [(1, 3), (2, 3)]
assert all(e.data["issue"] == "rate_limit" for e in retries)
assert [e.data["wait"] for e in retries] == [2, 4]
@pytest.mark.asyncio
async def test_rate_limit_backoff_caps_at_max(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client(
[Scripted429()] * 5 + ["ok"],
retry_rate_limit=5,
)
backoff = AsyncMock(return_value=True)
with patch.object(client, "_auto_retry_backoff_wait", backoff):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok"
assert [call.args[0] for call in backoff.await_args_list] == [2, 4, 8, 16, 30]
@pytest.mark.asyncio
async def test_rate_limit_cancelled_during_backoff(scripted_env):
make_client, _, _ = scripted_env
client = make_client([Scripted429(), "ok"], retry_rate_limit=1)
backoff = AsyncMock(return_value=False)
with patch.object(client, "_auto_retry_backoff_wait", backoff):
with pytest.raises(GenerationCancelled):
await client.send_prompt("hello", kind="analyze_freeform")
@pytest.mark.asyncio
async def test_non_429_error_is_not_auto_retried(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client([Scripted500()], retry_rate_limit=5)
dialog = AsyncMock(return_value="ignore")
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == ""
assert client.calls == 1
assert dialog.await_count == 1
assert not auto_retry_events(emissions)
@pytest.mark.asyncio
async def test_missing_reasoning_auto_retry_then_success(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client(
["no reasoning here", "<think>hmm</think>the answer"],
retry_missing_reasoning=1,
reason_enabled=True,
reason_response_pattern=r"<think>.*?</think>",
)
dialog = AsyncMock()
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "the answer"
assert client.calls == 2
assert dialog.await_count == 0
retries = auto_retry_events(emissions)
assert [(e.data["attempt"], e.data["total"]) for e in retries] == [(1, 1)]
assert retries[0].data["issue"] == "missing_reasoning"
assert len(auto_retry_events(emissions, "auto_retry_done")) == 1
@pytest.mark.asyncio
async def test_missing_reasoning_retries_exhausted_prompts_user(scripted_env):
make_client, emissions, _ = scripted_env
client = make_client(
["no reasoning", "still no reasoning"],
retry_missing_reasoning=1,
reason_enabled=True,
reason_response_pattern=r"<think>.*?</think>",
)
dialog = AsyncMock(return_value="ignore")
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "still no reasoning"
assert client.calls == 2
assert dialog.await_count == 1
assert len(auto_retry_events(emissions)) == 1
@pytest.mark.asyncio
async def test_abort_between_immediate_retries_scene_free(scripted_env):
"""An abort click during scene-free auto-retries (inactive placeholder
scene, _poll_interrupt blind) must cancel between attempts and consume
the flag so later generations retry normally."""
make_client, _, scene = scripted_env
client = make_client(["", "", "ok"], retry_empty_response=2)
scene.active = False
scene.cancel_requested = True
dialog = AsyncMock()
with (
ClientContext(requires_active_scene=False),
patch.object(client, "_prompt_generation_error", dialog),
):
with pytest.raises(GenerationCancelled):
await client.send_prompt("hello", kind="analyze_freeform")
assert client.calls == 1
assert dialog.await_count == 0
assert scene.cancel_requested is False
# flag was consumed - the next generation's retries proceed normally
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok"
scene.active = True
@pytest.mark.asyncio
async def test_abort_between_reasoning_retries_scene_free(scripted_env):
make_client, _, scene = scripted_env
client = make_client(
["no reasoning here"],
retry_missing_reasoning=1,
reason_enabled=True,
reason_response_pattern=r"<think>.*?</think>",
)
scene.active = False
scene.cancel_requested = True
dialog = AsyncMock()
with (
ClientContext(requires_active_scene=False),
patch.object(client, "_prompt_generation_error", dialog),
):
with pytest.raises(GenerationCancelled):
await client.send_prompt("hello", kind="analyze_freeform")
assert client.calls == 1
assert dialog.await_count == 0
assert scene.cancel_requested is False
scene.active = True
def latest_generation_id(emissions):
return auto_retry_events(emissions)[-1].data["generation_id"]
@pytest.mark.asyncio
async def test_abort_latch_survives_scene_flag_reset(scripted_env):
"""Plugin.handle() unconditionally resets scene.cancel_requested on any
plugin-routed websocket action - the client-owned abort latch must
survive that wipe and still cancel between retries. The abort lands
mid-attempt keyed to the sequence's generation id, as it does in
reality (the Abort button only exists once a retry has been shown)."""
make_client, emissions, scene = scripted_env
client = make_client([], retry_empty_response=2)
def abort_mid_attempt():
client.request_auto_retry_abort(latest_generation_id(emissions))
scene.cancel_requested = False # simulates the Plugin.handle wipe
return ""
client.script.extend(["", abort_mid_attempt, "ok"])
dialog = AsyncMock()
with patch.object(client, "_prompt_generation_error", dialog):
with pytest.raises(GenerationCancelled):
await client.send_prompt("hello", kind="analyze_freeform")
assert client.calls == 2
assert dialog.await_count == 0
assert client._auto_retry_aborts == set()
@pytest.mark.asyncio
async def test_abort_scoped_to_its_sequence(scripted_env):
"""An abort keyed to a different sequence's generation id must not
cancel this sequence's retries (concurrent generations on one client)."""
make_client, _, _ = scripted_env
client = make_client([], retry_empty_response=1)
def foreign_abort():
# the concurrent sequence is live on the same client
client._auto_retry_live_ids.add("another-sequence")
client.request_auto_retry_abort("another-sequence")
return ""
client.script.extend([foreign_abort, "ok"])
dialog = AsyncMock()
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok"
assert client.calls == 2
assert dialog.await_count == 0
# the foreign abort was neither consumed nor cleaned up by this sequence
assert client._auto_retry_aborts == {"another-sequence"}
client._auto_retry_aborts.clear()
client._auto_retry_live_ids.clear()
@pytest.mark.asyncio
async def test_late_abort_for_finished_sequence_is_ignored(scripted_env):
"""An abort processed after its sequence's cleanup must not latch an
orphan id that nothing will ever observe or clean up."""
make_client, emissions, _ = scripted_env
client = make_client(["", "ok"], retry_empty_response=1)
dialog = AsyncMock()
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok"
client.request_auto_retry_abort(latest_generation_id(emissions))
assert client._auto_retry_aborts == set()
@pytest.mark.asyncio
async def test_scene_interrupt_not_consumed_on_active_scene(scripted_env):
"""An active scene's cancel_requested is observed non-consumingly by
every concurrent generation's _poll_interrupt and reset by the
GenerationCancelled handlers - the retry check must not steal it. Only
the inactive placeholder (scene-free flows, no other reset path) is
consumed."""
make_client, _, scene = scripted_env
client = make_client([])
scene.cancel_requested = True
assert client._auto_retry_cancelled("gid") is True
assert scene.cancel_requested is True
scene.active = False
assert client._auto_retry_cancelled("gid") is True
assert scene.cancel_requested is False
scene.active = True
@pytest.mark.asyncio
async def test_unobserved_abort_latch_does_not_cancel_later_generation(scripted_env):
"""An abort latched during an attempt that then succeeds is never
observed by a retry check - it must not leak into a later generation's
retry sequence."""
make_client, emissions, _ = scripted_env
client = make_client([], retry_empty_response=1)
def abort_but_succeed():
client.request_auto_retry_abort(latest_generation_id(emissions))
return "ok"
client.script.extend(["", abort_but_succeed, "", "ok2"])
dialog = AsyncMock()
with patch.object(client, "_prompt_generation_error", dialog):
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok"
# the unobserved abort was cleaned up when its sequence ended
assert client._auto_retry_aborts == set()
response = await client.send_prompt("hello", kind="analyze_freeform")
assert response == "ok2"
assert client.calls == 4
assert dialog.await_count == 0
@pytest.mark.asyncio
async def test_dialog_retry_supersedes_stale_abort(scripted_env):
"""An abort latched mid-attempt goes unobserved when the attempt fails
with a non-429 (dialog path). The user's explicit dialog retry is newer
intent - it must discard the stale latch, not be cancelled by it."""
make_client, emissions, _ = scripted_env
client = make_client([], retry_empty_response=2)
def abort_then_fail():
client.request_auto_retry_abort(latest_generation_id(emissions))
return Scripted500()
client.script.extend(["", abort_then_fail, "", "ok"])
task = asyncio.create_task(client.send_prompt("hello", kind="analyze_freeform"))
for _ in range(200):
if _generation_error_futures:
break
await asyncio.sleep(0.01)
assert _generation_error_futures
resolve_generation_error(next(iter(_generation_error_futures)), "retry")
response = await asyncio.wait_for(task, timeout=10)
# dialog retry ran attempt 3 (empty), whose auto-retry proceeded to
# attempt 4 instead of consuming the stale abort
assert response == "ok"
assert client.calls == 4
assert client._auto_retry_aborts == set()
@pytest.mark.asyncio
async def test_backoff_wait_aborts_on_scene_cancel(scripted_env):
make_client, _, scene = scripted_env
client = make_client([])
scene.cancel_requested = True
assert await client._auto_retry_backoff_wait(10, "gid") is False
scene.cancel_requested = False
assert await client._auto_retry_backoff_wait(0.1, "gid") is True
# scene-free generations (help chat) carry an inactive placeholder scene
# in context - that alone must not abort the wait
scene.active = False
assert await client._auto_retry_backoff_wait(0.1, "gid") is True
scene.active = True
def test_config_clamps_retry_counts():
config = ClientConfig(
type="stub",
name="clamped",
retry_empty_response=9999,
retry_rate_limit=-3,
retry_missing_reasoning=5,
)
assert config.retry_empty_response == 5
assert config.retry_rate_limit == 0
assert config.retry_missing_reasoning == 5
def test_config_round_trip():
config = ClientConfig(
type="stub",
name="roundtrip",
retry_empty_response=3,
retry_rate_limit=2,
retry_missing_reasoning=1,
)
restored = ClientConfig(**config.model_dump())
assert restored.retry_empty_response == 3
assert restored.retry_rate_limit == 2
assert restored.retry_missing_reasoning == 1
defaults = ClientConfig(type="stub", name="defaults")
assert defaults.retry_empty_response == 0
assert defaults.retry_rate_limit == 0
assert defaults.retry_missing_reasoning == 0

View File

@@ -1365,7 +1365,9 @@ class TestGenerateWithErrorHandling:
with ClientContext(requires_active_scene=False):
set_client_context_attribute("requires_active_scene", False)
out = await client._generate_with_error_handling("p", {}, "conversation")
out = await client._generate_with_error_handling(
"p", {}, "conversation", "gid"
)
assert out == "great"
@pytest.mark.asyncio
@@ -1386,14 +1388,18 @@ class TestGenerateWithErrorHandling:
# Patch the user-prompt helper to return "retry" then "ignore" if needed.
responses = iter(["retry"])
async def fake_prompt(self, error_message, status_code=None):
async def fake_prompt(
self, error_message, status_code=None, generation_id=None
):
return next(responses)
monkeypatch.setattr(ClientBase, "_prompt_generation_error", fake_prompt)
with ClientContext(requires_active_scene=False):
set_client_context_attribute("requires_active_scene", False)
out = await client._generate_with_error_handling("p", {}, "conversation")
out = await client._generate_with_error_handling(
"p", {}, "conversation", "gid"
)
assert out == "second-time"
assert attempts["n"] == 2
@@ -1409,14 +1415,18 @@ class TestGenerateWithErrorHandling:
client = _AlwaysFails(name="gc3")
async def fake_prompt(self, error_message, status_code=None):
async def fake_prompt(
self, error_message, status_code=None, generation_id=None
):
return "ignore"
monkeypatch.setattr(ClientBase, "_prompt_generation_error", fake_prompt)
with ClientContext(requires_active_scene=False):
set_client_context_attribute("requires_active_scene", False)
out = await client._generate_with_error_handling("p", {}, "conversation")
out = await client._generate_with_error_handling(
"p", {}, "conversation", "gid"
)
assert out == ""
@pytest.mark.asyncio
@@ -1433,7 +1443,9 @@ class TestGenerateWithErrorHandling:
client = _EmptyThenGood(name="gc4")
async def fake_prompt(self, error_message, status_code=None):
async def fake_prompt(
self, error_message, status_code=None, generation_id=None
):
assert error_message == EMPTY_RESPONSE_MESSAGE
return "retry"
@@ -1441,7 +1453,9 @@ class TestGenerateWithErrorHandling:
with ClientContext(requires_active_scene=False):
set_client_context_attribute("requires_active_scene", False)
out = await client._generate_with_error_handling("p", {}, "conversation")
out = await client._generate_with_error_handling(
"p", {}, "conversation", "gid"
)
assert out == "good"
assert attempts["n"] == 2
@@ -1457,7 +1471,9 @@ class TestGenerateWithErrorHandling:
client = _Fails(name="gc5")
async def fake_prompt(self, error_message, status_code=None):
async def fake_prompt(
self, error_message, status_code=None, generation_id=None
):
return "cancel"
monkeypatch.setattr(ClientBase, "_prompt_generation_error", fake_prompt)
@@ -1465,7 +1481,9 @@ class TestGenerateWithErrorHandling:
with ClientContext(requires_active_scene=False):
set_client_context_attribute("requires_active_scene", False)
with pytest.raises(GenerationCancelled):
await client._generate_with_error_handling("p", {}, "conversation")
await client._generate_with_error_handling(
"p", {}, "conversation", "gid"
)
# ---------------------------------------------------------------------------

View File

@@ -213,7 +213,7 @@ async def test_generation_error_dialog_shows_pi_message(client, spawner, monkeyp
captured = {}
async def fake_dialog(message, status_code=None):
async def fake_dialog(message, status_code=None, generation_id=None):
captured["message"] = message
captured["status_code"] = status_code
return "cancel"
@@ -223,7 +223,7 @@ async def test_generation_error_dialog_shows_pi_message(client, spawner, monkeyp
with ClientContext(requires_active_scene=False):
set_client_context_attribute("requires_active_scene", False)
with pytest.raises(GenerationCancelled):
await client._generate_with_error_handling("hi", {}, "conversation")
await client._generate_with_error_handling("hi", {}, "conversation", "gid")
assert "No API key found for openrouter" in captured["message"]
assert captured["status_code"] is None