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

In the agent turn's run_model_query, a direct string completion that is semantically empty (no visible text, no tool calls) is rejected before the route is committed as accepted: rejected usage is recorded via record_rejected_tool_loop_cost_usage, the accepted-provider-route commit is skipped, and the typed SemanticEmptyTerminalCompletion propagates. This keeps accepted-context accounting and success telemetry clean when the model produced nothing usable.

Source

Thrown at crates/zeroclaw-runtime/src/agent/turn/execution.rs:89

        let (served_provider, served_model) = accepted_route
            .as_ref()
            .map(|route| (route.provider_ref().to_string(), route.model().to_string()))
            .unwrap_or_else(|| (self.provider_name.to_string(), self.model.to_string()));
        match result {
            Ok(response) => {
                // A terminal response without final text or tools was billed
                // but cannot be accepted. Keep it out of context-window fill
                // and successful response telemetry before returning its typed
                // cause to every one-shot caller.
                if response.is_semantically_empty_terminal() {
                    if let Some(usage) = response.usage.as_ref() {
                        crate::agent::cost::record_rejected_tool_loop_cost_usage(
                            &served_provider,
                            &served_model,
                            usage,
                        );
                    }
                    return Err(anyhow::Error::new(SemanticEmptyTerminalCompletion));
                }
                zeroclaw_providers::dispatch::commit_accepted_provider_route(accepted_route);
                // Only a semantically valid result controls accepted context
                // usage and successful response telemetry.
                if let Some(usage) = response.usage.as_ref() {
                    crate::agent::cost::record_tool_loop_cost_usage(
                        &served_provider,
                        &served_model,
                        usage,
                    );
                }
                Ok(response)
            }
            Err(error) => {
                // Accounted dispatch carries rejected usage on failures in the
                // typed Reliable error chain. Keep the original error intact so
                // terminal-cause classification remains the provider's source
                // of truth.

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Raise the model's max_tokens / completion budget for tool-loop turns.
  2. Put a non-reasoning model late in the reliable chain so the loop always has a candidate that produces visible text.
  3. Confirm the rejected usage shows up in cost reporting (it was recorded as rejected, not accepted); use it to size budgets.
  4. If it recurs on one provider only, check that provider's response shape (content filtering, beta flags) against a raw dump.

Example fix

# before: reasoning model runs the tool loop with a small budget
model = "deepseek-r1"
max_tokens = 1024

# after: budget sized past thinking, non-reasoning fallback behind it
model = "deepseek-r1"
max_tokens = 8192
fallback_models = ["gpt-4o-mini"]
Defensive patterns

Strategy: fallback

Validate before calling

fn response_is_semantically_empty(resp: &ChatResponse) -> bool {
    resp.tool_calls.is_empty()
        && resp.text.as_deref().map(|t| strip_think_tags(t).trim().is_empty()).unwrap_or(true)
}

Try / catch

match turn::run_model_query(&ctx, request).await {
    Ok(resp) => Ok(resp),
    Err(e) if e.is::<SemanticEmptyTerminalCompletion>() => {
        // rejected usage was already recorded; try the non-reasoning fallback route
        ctx.with_model(plain_fallback_model()).run_model_query(request).await
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: run_model_query receives a response whose text is empty/think-only and tool_calls is empty; the guard fires before commit_accepted_provider_route, so cost meters mark usage as rejected and the turn fails with the typed terminal error.

Common situations: Agent loops over reasoning models that think without answering, max_tokens exhaustion in the tool loop, gateway hiccup returning 200 with empty content mid-session.

Related errors


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