hermes(voice): auto-retry transient provider errors; prime STT acronyms
Diagnosed from a live voice session (e1b9fccb90ef): three turns failed with
raw '**Error:** HTTP 502 ... hermes-{claude,codex}-broker' because the agent
pod hosting the model brokers rolled mid-conversation. The voice client
correctly refused to speak the error envelope, but it then dropped the user's
utterance and forced them to repeat it three times.
Voice: on a TRANSIENT provider error (5xx/502/'error sending request'/timeout),
conversation mode now re-runs the errored turn in place through the app's own
regenerate action (which truncates the errored turn — no duplicate user
message) and stays in Thinking so its cues cover the reconnect gap. Bounded to
MAX_TRANSIENT_RETRIES (2); a non-transient error or an exhausted budget still
drops cleanly to 'let's try that again — listening'. The raw error is never
spoken. New probe scenarios cover retry-then-recover and the bounded-then-drop
path; source contract updated.
STT: the same session mis-transcribed 'CUI' as 'cue'. Prime the default
initial_prompt with the domain acronyms the user uses (CUI, FOUO, DoD, NIST,
CMMC, FIPS, RMF, POA&M, ATO, SBU) so they bias to uppercase forms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
700301d554
commit
fd0a4b23f9
@ -37,7 +37,9 @@ CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper"))
|
||||
DEFAULT_INITIAL_PROMPT = (
|
||||
"Proper nouns and names keep their capitalization and accents, for "
|
||||
"example Córdoba, Cancún, Málaga, Moscú, Москва, New York, and names "
|
||||
"like Amy, Claude, and Hermes."
|
||||
"like Amy, Claude, and Hermes. Technical and government acronyms are "
|
||||
"written as uppercase letters, for example CUI, FOUO, DoD, NIST, CMMC, "
|
||||
"FIPS, RMF, POA&M, ATO, and SBU."
|
||||
)
|
||||
INITIAL_PROMPT = os.getenv("HERMES_STT_INITIAL_PROMPT", DEFAULT_INITIAL_PROMPT).strip()
|
||||
|
||||
|
||||
@ -154,6 +154,17 @@
|
||||
// callback can beat the settle re-render that stamps the answer onto
|
||||
// data-raw-text, so a transient empty read must never tear a live reply down.
|
||||
let finalizeAttempts=0;
|
||||
// Bounded auto-retry for a TRANSIENT provider error (a broker 5xx/502 or
|
||||
// "error sending request" reply — e.g. a model broker that blipped while its
|
||||
// pod rolled mid-conversation). Instead of silently dropping the user's
|
||||
// utterance and forcing them to repeat it, re-run the last user turn through
|
||||
// the app's own regenerate path (which truncates the errored turn, so no
|
||||
// duplicate user message), capped so a persistent failure still surfaces.
|
||||
// Reset on any real answer and on each new user utterance.
|
||||
let transientRetryCount=0;
|
||||
const MAX_TRANSIENT_RETRIES=2;
|
||||
const RECONNECT_CAPTION={en:'Reconnecting…',ru:'Переподключение…',es:'Reconectando…'};
|
||||
const TRANSIENT_ERROR_RE=/\b(429|50[0-9])\b|error sending request|bad gateway|gateway timeout|timed? ?out|timeout|temporarily|overloaded|unavailable|connection (refused|reset|error)|reset by peer|upstream/i;
|
||||
const TTS_LANGUAGES=['en','ru','es'];
|
||||
// FIX 3: conversation-mode language chooser. Auto (default) plus the languages
|
||||
// the private voice map supports — kept in lockstep with
|
||||
@ -1122,11 +1133,94 @@
|
||||
setState('listening',statusLabel);
|
||||
}
|
||||
|
||||
function errorTurnText(turn){
|
||||
// The human-visible error text of an errored assistant turn: the provider
|
||||
// details block the renderer appends for a provider failure, else the body.
|
||||
if(!turn||typeof turn.querySelector!=='function') return '';
|
||||
let txt='';
|
||||
try{
|
||||
const details=turn.querySelector('.provider-error-details');
|
||||
if(details&&typeof details.textContent==='string') txt=details.textContent;
|
||||
if(!txt){
|
||||
const body=turn.querySelector('.msg-body');
|
||||
if(body&&typeof body.textContent==='string') txt=body.textContent;
|
||||
}
|
||||
}catch(_){ }
|
||||
return txt||'';
|
||||
}
|
||||
|
||||
function errorTurnIsTransient(turn){
|
||||
// A broker/provider blip (5xx/502, "error sending request", timeout,
|
||||
// overloaded) is recoverable by re-running the same turn; a content or
|
||||
// policy error is not, and must never be retried into an identical failure.
|
||||
return TRANSIENT_ERROR_RE.test(errorTurnText(turn));
|
||||
}
|
||||
|
||||
function findRegenerateButton(turn){
|
||||
// The app renders a single "regenerate" action on the last assistant turn
|
||||
// (onclick="regenerateResponse(this)"): clicking it truncates the errored
|
||||
// turn and re-runs the last user message with no duplicate user turn. Match
|
||||
// the action buttons by class and filter by the onclick target so this stays
|
||||
// correct without relying on a descendant/substring attribute selector.
|
||||
if(!turn||typeof turn.querySelectorAll!=='function') return null;
|
||||
let buttons;
|
||||
try{buttons=turn.querySelectorAll('.msg-action-btn');}catch(_){return null;}
|
||||
if(!buttons) return null;
|
||||
for(let i=0;i<buttons.length;i+=1){
|
||||
const button=buttons[i];
|
||||
const onclick=(button&&typeof button.getAttribute==='function')?(button.getAttribute('onclick')||''):'';
|
||||
if(onclick.indexOf('regenerateResponse')>=0) return button;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function retryTransientResponse(token,turn){
|
||||
// Re-run the errored user turn through the app's own regenerate path and
|
||||
// keep the conversation in Thinking — its cues cover the reconnect gap — so
|
||||
// the retried answer streams straight into TTS. Bounded by
|
||||
// MAX_TRANSIENT_RETRIES; a busy session or a missing regenerate control
|
||||
// declines the retry so the caller drops cleanly. Returns true iff launched.
|
||||
if(typeof S==='undefined'||!S||!S.session||S.busy) return false;
|
||||
if(transientRetryCount>=MAX_TRANSIENT_RETRIES) return false;
|
||||
const button=findRegenerateButton(turn);
|
||||
if(!button) return false;
|
||||
transientRetryCount+=1;
|
||||
// Tear down only the response-side speech/observer state; do NOT resync
|
||||
// capture (that would drop the turn and re-open the microphone).
|
||||
thinkingSession=null;
|
||||
thinkingTurnId='';
|
||||
finalizeAttempts=0;
|
||||
stopResponseObserver();
|
||||
cancelThinkingCues();
|
||||
cancelSpeechTurn();
|
||||
stopPlayback();
|
||||
pendingStitch=null;
|
||||
const localized=normalizeSttLanguage(sessionLanguage)||'en';
|
||||
setConversationAssistantCaption(RECONNECT_CAPTION[localized]||RECONNECT_CAPTION.en);
|
||||
setState('thinking');
|
||||
// Reset the answer baseline to the errored turn so the regenerated answer
|
||||
// (which replaces it) is detected as fresh output, then arm the observer
|
||||
// and the thinking cues before triggering the app's regenerate.
|
||||
rememberAssistantBaseline();
|
||||
thinkingTurnId=String(token)+'-retry-'+String(transientRetryCount);
|
||||
startResponseObserver(token);
|
||||
scheduleThinkingCues(token,sessionLanguage,thinkingTurnId);
|
||||
try{button.click();}catch(_){return false;}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAssistantResponseError(token){
|
||||
// An error/system envelope (cancellation notice, provider failure) is a
|
||||
// transcript artifact, not a reply: never feed it to TTS or the reply
|
||||
// caption, show a brief non-spoken state instead, and resynchronize
|
||||
// capture so the next utterance starts a clean streaming turn.
|
||||
// caption. A TRANSIENT provider blip (a broker 5xx/502) is auto-retried in
|
||||
// place — up to MAX_TRANSIENT_RETRIES — so a momentary backend hiccup does
|
||||
// not silently discard the user's utterance and force them to repeat it. A
|
||||
// non-transient error, or an exhausted retry budget, shows a brief non-
|
||||
// spoken state and resynchronizes capture for the next utterance.
|
||||
const rows=assistantRows();
|
||||
const turn=rows.length?assistantTurnOf(rows[rows.length-1]):null;
|
||||
if(turn&&errorTurnIsTransient(turn)&&retryTransientResponse(token,turn)) return;
|
||||
transientRetryCount=0;
|
||||
thinkingSession=null;
|
||||
thinkingTurnId='';
|
||||
finalizeAttempts=0;
|
||||
@ -1349,6 +1443,8 @@
|
||||
cancelVoicePreflight(turnId||captureTurnId);
|
||||
const text=String(transcript||'').trim();
|
||||
if(!text){clearSttLanguage();restartSoon(token,350);return;}
|
||||
// A fresh user utterance starts its own transient-retry budget.
|
||||
transientRetryCount=0;
|
||||
if(!bargeCancelPromise&&typeof S!=='undefined'&&(S.busy||S.activeStreamId)){
|
||||
// A new utterance finished while the previous model turn was still in
|
||||
// flight (continuous capture makes this a normal interruption): remember
|
||||
@ -2893,6 +2989,9 @@
|
||||
return;
|
||||
}
|
||||
finalizeAttempts=0;
|
||||
// A real answer arrived: the provider recovered, so clear the transient
|
||||
// retry budget for the next turn.
|
||||
transientRetryCount=0;
|
||||
setConversationAssistantCaption(text);
|
||||
// Any answer text, even a not-yet-speakable partial clause, owns the audio
|
||||
// timeline from this point forward. A cue must never overlap or become part
|
||||
|
||||
@ -757,6 +757,41 @@ function makeHarness(options = {}) {
|
||||
runDueTimeouts,
|
||||
setAssistantReply(text) { assistantRows = [{ dataset: { rawText: text } }]; },
|
||||
setAssistantError(text) { assistantRows = [{ dataset: { rawText: text, error: '1' } }]; },
|
||||
// A TRANSIENT provider error turn: an error envelope carrying a
|
||||
// .provider-error-details block (so the extraction reports error), plus the
|
||||
// last-assistant "regenerate" action button the auto-retry clicks. The
|
||||
// button records each click so the scenario can assert the bounded retry.
|
||||
setAssistantTransientError(text) {
|
||||
// An errored turn is a COMPLETED turn: its stream ended, so the session is
|
||||
// no longer busy (the same precondition the app's regenerate action needs).
|
||||
context.S.busy = false;
|
||||
context.S.activeStreamId = null;
|
||||
if (context.S.session) context.S.session.active_stream_id = null;
|
||||
const turn = buildAssistantTurn();
|
||||
const seg = domEl('div', 'assistant-segment');
|
||||
seg.appendChild(domEl('div', 'msg-body', null, text));
|
||||
seg.appendChild(domEl('details', 'provider-error-details', null, text));
|
||||
turn.blocks.appendChild(seg);
|
||||
const foot = domEl('div', 'msg-foot');
|
||||
const actions = domEl('span', 'msg-actions');
|
||||
const regen = domEl('button', 'msg-action-btn', { onclick: 'regenerateResponse(this)', title: 'regenerate' });
|
||||
regen.click = () => {
|
||||
// Mirror the app's regenerate: truncate the errored turn and start a
|
||||
// fresh stream. The scenario injects the next state (another error, or a
|
||||
// recovered answer) to model whether the retried turn succeeds.
|
||||
this._regenerateClicks = (this._regenerateClicks || 0) + 1;
|
||||
assistantRows = [];
|
||||
context.S.busy = true;
|
||||
streamCounter += 1;
|
||||
context.S.activeStreamId = `stream-${streamCounter}`;
|
||||
};
|
||||
actions.appendChild(regen);
|
||||
foot.appendChild(actions);
|
||||
turn.appendChild(foot);
|
||||
assistantRows = [turn];
|
||||
},
|
||||
regenerateClicks() { return this._regenerateClicks || 0; },
|
||||
endStream() { context.S.busy = false; context.S.activeStreamId = null; if (context.S.session) context.S.session.active_stream_id = null; },
|
||||
clearAssistantRows() { assistantRows = []; },
|
||||
// ── Interim-fold reproduction ─────────────────────────────────────────
|
||||
// Stream an interim acknowledgement as a live answer segment, then FOLD it
|
||||
@ -1018,6 +1053,62 @@ scenarios.errored_turn_is_not_spoken_and_capture_resyncs = async () => {
|
||||
};
|
||||
};
|
||||
|
||||
// A TRANSIENT provider error (a broker 5xx/502 that blipped mid-conversation)
|
||||
// is auto-retried in place through the app's regenerate action instead of
|
||||
// dropping the user's utterance: the error is never spoken, the overlay stays
|
||||
// in Thinking (its cues cover the reconnect gap), the regenerate button is
|
||||
// clicked once, and when the retried answer arrives it is spoken normally.
|
||||
scenarios.transient_error_auto_retries_then_speaks = async () => {
|
||||
const harness = makeHarness();
|
||||
await harness.start();
|
||||
await harness.silence(300);
|
||||
await harness.speak('alpha', 1300);
|
||||
await harness.silence(1500);
|
||||
await harness.tick(400); // dispatched; thinking
|
||||
const ttsBefore = harness.ttsCalls.length;
|
||||
harness.setAssistantTransientError('**Error:** HTTP 502: error sending request for url (http://hermes-codex-broker:9003/v1/responses)');
|
||||
await harness.tick(120); // observer sees the transient error -> one auto-retry
|
||||
const stateAfterError = harness.state();
|
||||
const labelAfterError = harness.elements.voiceModeLabel.textContent;
|
||||
const clicksAfterError = harness.regenerateClicks();
|
||||
const ttsDuringError = harness.ttsCalls.slice(ttsBefore).map((r) => r.text);
|
||||
// The regenerated turn recovers with a real answer.
|
||||
harness.setAssistantReply('Controlled Unclassified Information.');
|
||||
harness.endStream();
|
||||
await harness.tick(400);
|
||||
return {
|
||||
stateAfterError,
|
||||
labelAfterError,
|
||||
clicksAfterError,
|
||||
ttsDuringError,
|
||||
ttsTexts: harness.ttsCalls.map((r) => r.text),
|
||||
state: harness.state(),
|
||||
};
|
||||
};
|
||||
|
||||
// The auto-retry is bounded: a provider that keeps returning a transient error
|
||||
// is retried at most MAX_TRANSIENT_RETRIES (2) times, then the turn is dropped
|
||||
// and capture resynchronizes so the failure surfaces instead of looping.
|
||||
scenarios.transient_error_retry_is_bounded = async () => {
|
||||
const harness = makeHarness();
|
||||
await harness.start();
|
||||
await harness.silence(300);
|
||||
await harness.speak('alpha', 1300);
|
||||
await harness.silence(1500);
|
||||
await harness.tick(400);
|
||||
harness.setAssistantTransientError('**Error:** HTTP 503: service unavailable');
|
||||
await harness.tick(150); // retry 1 (click truncates + starts a fresh stream)
|
||||
harness.setAssistantTransientError('**Error:** HTTP 503: service unavailable');
|
||||
await harness.tick(150); // retry 2
|
||||
harness.setAssistantTransientError('**Error:** HTTP 503: service unavailable');
|
||||
await harness.tick(150); // budget exhausted -> drop + resync
|
||||
return {
|
||||
clicks: harness.regenerateClicks(),
|
||||
state: harness.state(),
|
||||
label: harness.elements.voiceModeLabel.textContent,
|
||||
};
|
||||
};
|
||||
|
||||
// Voice barge-in appends a single-line cut marker naming the sentence that
|
||||
// was playing, so the model knows where its reply was cut off.
|
||||
scenarios.barge_cut_marker_records_spoken_tail = async () => {
|
||||
|
||||
@ -512,6 +512,10 @@ def test_initial_prompt_is_env_overridable_and_default_primes_places(monkeypatch
|
||||
assert "Córdoba" in module.DEFAULT_INITIAL_PROMPT
|
||||
assert "Москва" in module.DEFAULT_INITIAL_PROMPT
|
||||
assert "Amy" in module.DEFAULT_INITIAL_PROMPT
|
||||
# Domain acronyms are primed so they are not misheard as common words
|
||||
# (e.g. "CUI" transcribed as "cue").
|
||||
assert "CUI" in module.DEFAULT_INITIAL_PROMPT
|
||||
assert "DoD" in module.DEFAULT_INITIAL_PROMPT
|
||||
assert module.INITIAL_PROMPT == module.DEFAULT_INITIAL_PROMPT
|
||||
|
||||
monkeypatch.setenv("HERMES_STT_INITIAL_PROMPT", "Custom prime.")
|
||||
|
||||
@ -388,6 +388,31 @@ def test_errored_turn_is_never_spoken_and_capture_resyncs(probe_results):
|
||||
assert scenario["sends"] == ["alpha", "bravo"]
|
||||
|
||||
|
||||
def test_transient_provider_error_auto_retries_then_speaks(probe_results):
|
||||
"""A broker 5xx/502 blip is retried in place, not dropped: the error is
|
||||
never spoken, the overlay stays in Thinking, the app's regenerate action is
|
||||
clicked once, and the recovered answer is spoken normally."""
|
||||
scenario = probe_results["transient_error_auto_retries_then_speaks"]
|
||||
assert scenario["stateAfterError"] == "thinking"
|
||||
assert scenario["labelAfterError"] == "Thinking…"
|
||||
assert scenario["clicksAfterError"] == 1
|
||||
# The raw provider error text is never sent to TTS.
|
||||
assert scenario["ttsDuringError"] == []
|
||||
assert all("502" not in text and "Error" not in text for text in scenario["ttsTexts"])
|
||||
# The regenerated answer is spoken and the turn ends in Speaking.
|
||||
assert "Controlled Unclassified Information." in scenario["ttsTexts"]
|
||||
assert scenario["state"] == "speaking"
|
||||
|
||||
|
||||
def test_transient_provider_error_retry_is_bounded(probe_results):
|
||||
"""A provider stuck returning a transient error is retried at most twice,
|
||||
then the turn is dropped and capture resyncs so the failure surfaces."""
|
||||
scenario = probe_results["transient_error_retry_is_bounded"]
|
||||
assert scenario["clicks"] == 2
|
||||
assert scenario["state"] == "listening"
|
||||
assert scenario["label"] == "Let’s try that again — listening"
|
||||
|
||||
|
||||
def test_barge_cut_marker_is_one_bounded_line(probe_results):
|
||||
scenario = probe_results["barge_cut_marker_records_spoken_tail"]
|
||||
assert scenario["ttsTexts"][0] == "The first point is ready."
|
||||
@ -418,6 +443,13 @@ def test_cut_marker_error_resync_and_speaking_cycles_source_contract():
|
||||
assert "Let’s try that again — listening" in source
|
||||
assert "segment.dataset.error==='1'" in source
|
||||
assert ".provider-error-details" in source
|
||||
# A TRANSIENT provider error is auto-retried in place (bounded) via the app's
|
||||
# own regenerate action, never spoken, before the drop path runs.
|
||||
assert "function retryTransientResponse(token,turn)" in source
|
||||
assert "function errorTurnIsTransient(turn)" in source
|
||||
assert "const MAX_TRANSIENT_RETRIES=2" in source
|
||||
assert "if(transientRetryCount>=MAX_TRANSIENT_RETRIES) return false" in source
|
||||
assert "regenerateResponse" in source # clicks the app's regenerate button
|
||||
# A cancellation with no in-flight stream never gates the send.
|
||||
assert "if(!cancellation.streamId){" in source
|
||||
# Speaking ⇄ thinking cycles inside one interim-message turn.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user