{"record":{"id":"8a1e9445e6d7fa96","repo":"zeroclaw-labs/zeroclaw","slug":"provider-completed-without-final-text-or-tool-call-8a1e94","errorCode":null,"errorMessage":"provider completed without final text or tool calls","messagePattern":"provider completed without final text or tool calls","errorType":"exception","errorClass":"SemanticEmptyTerminalCompletion","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/agent/turn/execution.rs","lineNumber":89,"sourceCode":"        let (served_provider, served_model) = accepted_route\n            .as_ref()\n            .map(|route| (route.provider_ref().to_string(), route.model().to_string()))\n            .unwrap_or_else(|| (self.provider_name.to_string(), self.model.to_string()));\n        match result {\n            Ok(response) => {\n                // A terminal response without final text or tools was billed\n                // but cannot be accepted. Keep it out of context-window fill\n                // and successful response telemetry before returning its typed\n                // cause to every one-shot caller.\n                if response.is_semantically_empty_terminal() {\n                    if let Some(usage) = response.usage.as_ref() {\n                        crate::agent::cost::record_rejected_tool_loop_cost_usage(\n                            &served_provider,\n                            &served_model,\n                            usage,\n                        );\n                    }\n                    return Err(anyhow::Error::new(SemanticEmptyTerminalCompletion));\n                }\n                zeroclaw_providers::dispatch::commit_accepted_provider_route(accepted_route);\n                // Only a semantically valid result controls accepted context\n                // usage and successful response telemetry.\n                if let Some(usage) = response.usage.as_ref() {\n                    crate::agent::cost::record_tool_loop_cost_usage(\n                        &served_provider,\n                        &served_model,\n                        usage,\n                    );\n                }\n                Ok(response)\n            }\n            Err(error) => {\n                // Accounted dispatch carries rejected usage on failures in the\n                // typed Reliable error chain. Keep the original error intact so\n                // terminal-cause classification remains the provider's source\n                // of truth.","sourceCodeStart":71,"sourceCodeEnd":107,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/agent/turn/execution.rs#L71-L107","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Raise the model's max_tokens / completion budget for tool-loop turns.","Put a non-reasoning model late in the reliable chain so the loop always has a candidate that produces visible text.","Confirm the rejected usage shows up in cost reporting (it was recorded as rejected, not accepted); use it to size budgets.","If it recurs on one provider only, check that provider's response shape (content filtering, beta flags) against a raw dump."],"exampleFix":"# before: reasoning model runs the tool loop with a small budget\nmodel = \"deepseek-r1\"\nmax_tokens = 1024\n\n# after: budget sized past thinking, non-reasoning fallback behind it\nmodel = \"deepseek-r1\"\nmax_tokens = 8192\nfallback_models = [\"gpt-4o-mini\"]","handlingStrategy":"fallback","validationCode":"fn response_is_semantically_empty(resp: &ChatResponse) -> bool {\n    resp.tool_calls.is_empty()\n        && resp.text.as_deref().map(|t| strip_think_tags(t).trim().is_empty()).unwrap_or(true)\n}","typeGuard":null,"tryCatchPattern":"match turn::run_model_query(&ctx, request).await {\n    Ok(resp) => Ok(resp),\n    Err(e) if e.is::<SemanticEmptyTerminalCompletion>() => {\n        // rejected usage was already recorded; try the non-reasoning fallback route\n        ctx.with_model(plain_fallback_model()).run_model_query(request).await\n    }\n    Err(e) => Err(e),\n}","preventionTips":["Configure the turn's model chain with a non-reasoning fallback so run_model_query has a candidate that emits text.","Watch rejected vs accepted usage meters: a rising rejected ratio means budget/model problems, not flakiness.","Set per-turn max_tokens above the model's reasoning spend before enabling it in the loop."],"tags":["llm","agent-loop","cost-accounting","empty-response"],"backgroundTag":"llm-empty-completion","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}