zeroclaw-labs/zeroclaw · error · SemanticEmptyTerminalCompletion

provider completed without final text or tool calls

Error message

provider completed without final text or tool calls

What it means

OpenAI-compatible provider's require_terminal_text guard: identical contract to the Azure variant. String-only completions strip think tags; an empty remainder is the typed SemanticEmptyTerminalCompletion, because callers of the string API cannot accept a tool-call-only turn and an empty Ok would silently corrupt agent loops.

Source

Thrown at crates/zeroclaw-providers/src/openai.rs:81

#[derive(Debug, Deserialize)]
struct ResponseMessage {
    #[serde(default)]
    content: Option<String>,
}

impl ResponseMessage {
    fn effective_content(&self) -> String {
        self.content.clone().unwrap_or_default()
    }
}

/// String-only completions have no native tool-call escape hatch. An empty or
/// reasoning-only result is therefore a typed terminal failure, not a valid
/// string result for direct callers that do not use the structured chat API.
fn require_terminal_text(content: String) -> anyhow::Result<String> {
    if zeroclaw_api::model_provider::strip_think_tags(&content).is_empty() {
        return Err(anyhow::Error::new(
            zeroclaw_api::model_provider::SemanticEmptyTerminalCompletion,
        ));
    }
    Ok(content)
}

#[derive(Debug, Serialize)]
struct NativeChatRequest {
    model: String,
    messages: Vec<NativeMessage>,
    #[serde(skip_serializing_if = "Option::is_none")]
    temperature: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    tools: Option<Vec<NativeToolSpec>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    tool_choice: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    max_tokens: Option<u32>,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Raise max_tokens and retry; truncation inside reasoning is the most common cause.
  2. Serve/point at a non-reasoning chat model for string-only call sites.
  3. Move tool-using flows to the structured chat API where native tool_calls count as a valid terminal.
  4. Inspect the raw completion (finish_reason, content) once with logging to confirm which shape you are getting.

Example fix

// before
let text = openai.chat_with_system(Some(sys), user, "deepseek-r1", None).await?;
// whole answer inside <think> tags -> stripped to empty -> typed error

// after
let text = openai.chat_with_system_opts(Some(sys), user, "deepseek-chat", None, Opts { max_tokens: Some(4096) }).await?;
Defensive patterns

Strategy: fallback

Validate before calling

fn completion_is_usable(content: &str) -> bool {
    !zeroclaw_api::model_provider::strip_think_tags(content).trim().is_empty()
}

Try / catch

match openai.chat_with_system(system, message, model, temp).await {
    Ok(text) => Ok(text),
    Err(e) if e.is::<SemanticEmptyTerminalCompletion>() => openai.chat_with_system(system, message, fallback_model, temp).await,
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: chat_with_system on the OpenAI provider (or any OpenAI-compatible endpoint configured through it) yields empty content or reasoning-only content: o1-style/thinking models, deepseek-r1 via compatible base URLs, max_tokens truncation, or finish_reason 'length' with no visible tokens.

Common situations: Custom base_url pointing at vLLM/Ollama serving reasoning models, low max_tokens defaults, responses where the model wraps its whole answer in <think> and the harness strips it, degenerate sampling producing only whitespace.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/534ef7bf9d1a0dcb. Report an issue: GitHub.