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

Azure OpenAI provider's require_terminal_text guard for string-only completions: it strips <think> tags from the content and, if nothing visible remains, raises the typed SemanticEmptyTerminalCompletion. A string-only API has no tool-call escape hatch, so an empty or reasoning-only result is a terminal failure, not a valid empty string.

Source

Thrown at crates/zeroclaw-providers/src/azure_openai.rs:71

#[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 {
    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")]
    reasoning_effort: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Increase max_tokens on the deployment/request so the model finishes reasoning and emits final text.
  2. Switch the deployment (deployment id maps to a model version) to a non-reasoning chat model for string callers.
  3. Use the structured chat API if tool-call-only answers are legitimate for your flow.
  4. Check Azure content-filter results in the raw response; a filtered completion can arrive as empty content.

Example fix

// before
let text = azure.chat_with_system(Some(sys), user, "gpt-deploy", None).await?;
// deployment is a reasoning model, returns only <think>...</think>

// after: request a bigger budget / non-reasoning deployment
let text = azure.chat_with_system_opts(Some(sys), user, "gpt-4o-deploy", None, Opts { max_tokens: Some(4096), ..Default::default() }).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 azure.chat_with_system(system, message, deployment, temp).await {
    Ok(text) => Ok(text),
    Err(e) if e.is::<SemanticEmptyTerminalCompletion>() => {
        // think-only or empty: switch deployment rather than retrying the same one
        azure.chat_with_system(system, message, plain_deployment, temp).await
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: chat_with_system on Azure OpenAI returns content that is empty, whitespace, or entirely wrapped in think tags (reasoning models served via Azure, e.g. DeepSeek-R1 or o-series style endpoints), or finish_reason 'length' with only partial reasoning emitted.

Common situations: Deploying reasoning models through Azure OpenAI with default max_tokens (small), content filters blanking the response, deployment names resolving to a reasoning variant, prompt ends with a stop sequence before the answer.

Related errors


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