Watch
1
0
Fork
You've already forked souveraine
0

turns: never mistake an empty success for Annie

This commit is contained in:
Fimeg 2026-08-11 17:09:42 -04:00
commit 7842c04053
3 changed files with 131 additions and 1 deletions

View file

@ -380,6 +380,30 @@ impl LlmProvider for ClaudeSubscriptionProvider {
if status.is_success() {
let text = resp.text().await.context("reading claude response body")?;
let result = parse_completion(&text)?;
if !completion_has_payload(&result) {
let shape = empty_completion_shape(&text);
if attempt + 1 < max_attempts {
let delay = backoff(attempt);
warn!(
"claude subscription returned empty success on {model} ({shape}, attempt {}), retry in {:?}",
attempt + 1,
delay
);
strain.push(InferenceStrain::Transient {
attempt,
// The transport succeeded; the body did not. Keep
// the actual HTTP status instead of inventing one.
status: status.as_u16(),
model: model.clone(),
delay_ms: delay.as_millis() as u64,
});
tokio::time::sleep(delay).await;
continue;
}
anyhow::bail!(
"claude subscription returned an empty completion after {max_attempts} attempts on {model} ({shape})"
);
}
if let Some(usage) = &result.usage {
// Without this the cache is invisible: a hit and a short
// prompt look identical from the outside.
@ -992,6 +1016,40 @@ fn parse_completion(text: &str) -> Result<CompletionResult> {
})
}
fn completion_has_payload(result: &CompletionResult) -> bool {
!result.content.trim().is_empty() || !result.tool_calls.is_empty()
}
/// Describe an HTTP-success response that carried no output Souveraine knows
/// how to continue from. Keep this structural: block kinds and stop reason are
/// enough to diagnose a new wire shape without putting model prose in logs.
fn empty_completion_shape(text: &str) -> String {
let Ok(value) = serde_json::from_str::<Value>(text) else {
return "unparseable response".to_string();
};
let stop = value
.get("stop_reason")
.and_then(Value::as_str)
.unwrap_or("missing");
let block_types = value
.get("content")
.and_then(Value::as_array)
.map(|blocks| {
blocks
.iter()
.map(|block| {
block
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
})
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_else(|| "not-an-array".to_string());
format!("stop_reason={stop}, block_types=[{block_types}]")
}
fn map_stop_reason(reason: &str) -> String {
match reason {
"end_turn" | "stop_sequence" => "stop".to_string(),
@ -1311,6 +1369,21 @@ mod tests {
assert!(r.tool_calls.is_empty());
}
#[test]
fn empty_completion_shape_is_not_a_usable_payload() {
let raw = r#"{
"id":"msg_empty","type":"message","role":"assistant","stop_reason":"end_turn",
"content":[],
"usage":{"input_tokens":60535,"output_tokens":2}
}"#;
let result = parse_completion(raw).unwrap();
assert!(!completion_has_payload(&result));
assert_eq!(
empty_completion_shape(raw),
"stop_reason=end_turn, block_types=[]"
);
}
#[test]
fn parses_anthropic_tool_use_response() {
let raw = r#"{

View file

@ -5,7 +5,7 @@ use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::bridge::bifrost::{
ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage,
ChatCompletionRequest, CompletionResult, ContentPart, ImageUrlSource, Message as BifrostMessage,
};
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
@ -61,6 +61,10 @@ fn pressure_to_max_tokens(pressure: f32, output_limit: u32) -> Option<u32> {
Some((output_limit as f32 * ratio) as u32)
}
fn completion_has_payload(response: &CompletionResult) -> bool {
!response.content.trim().is_empty() || !response.tool_calls.is_empty()
}
fn pulse_text(elapsed: Duration) -> String {
let minutes = elapsed.as_secs() / 60;
let stamp = chrono::Local::now().format("%H:%M");
@ -420,6 +424,24 @@ pub(crate) async fn run_turn(
"LLM call returned"
);
// An HTTP-success body with no text, reasoning, or tools is not an
// assistant turn. Persisting it creates a blank assistant message and
// tells the surface that the agent chose silence. Provider adapters
// should retry malformed successes at their own wire seam; this guard
// is the provider-independent last line that keeps one from entering
// conversation history as a valid answer.
if !completion_has_payload(&response) {
anyhow::bail!(
"model returned an empty completion (model={model}, finish_reason={}, output_tokens={})",
response.finish_reason.as_deref().unwrap_or("missing"),
response
.usage
.as_ref()
.map(|usage| usage.completion_tokens)
.unwrap_or(0)
);
}
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient {
attempt,
@ -1151,3 +1173,29 @@ pub(crate) async fn run_turn(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn completion(content: &str, reasoning: Option<&str>) -> CompletionResult {
CompletionResult {
content: content.to_string(),
reasoning: reasoning.map(str::to_string),
reasoning_signature: None,
tool_calls: Vec::new(),
finish_reason: Some("stop".to_string()),
usage: None,
}
}
#[test]
fn empty_completion_is_not_a_primary_answer() {
assert!(!completion_has_payload(&completion("", None)));
assert!(!completion_has_payload(&completion(" \n", Some("\t"))));
assert!(completion_has_payload(&completion("here", None)));
// Private reasoning without text or a tool is not an answer to the
// human; accepting it would still leave the primary bubble empty.
assert!(!completion_has_payload(&completion("", Some("thinking"))));
}
}

View file

@ -537,6 +537,15 @@ Singleton {
case "inference_strain":
console.log(`[Souveraine] inference strain: attempt ${event.attempt}, status ${event.status}, model ${event.model}`);
break;
case "error":
// A failed engine turn is still a visible answer from the
// substrate. Silently dropping this event leaves the empty
// assistant container behind and makes a healthy server look as
// though the agent simply stopped speaking.
root.appendToStreaming(
Translation.tr("**Request failed** — %1").arg(event.message ?? Translation.tr("unknown turn error"))
);
break;
case "atmosphere":
case "outfit":
case "itinerary":