turns: never mistake an empty success for Annie
This commit is contained in:
parent
dfc72d7214
commit
7842c04053
3 changed files with 131 additions and 1 deletions
|
|
@ -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#"{
|
||||
|
|
|
|||
|
|
@ -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"))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Reference in a new issue