Hmbown/CodeWhale · error · anyhow::Error

Kimi returned an empty native web-search tool call list

Error message

Kimi returned an empty native web-search tool call list

What it means

Kimi's native web-search integration requested a continuation because the model returned a finish_reason of tool_calls, but the assistant message carried an empty (or missing-after-check) tool_calls array. The provider contract requires at least one $web_search tool call in that round; an empty list means the upstream response is malformed relative to the finish reason, so the client bails instead of looping on nothing.

Solutions

  1. Inspect the raw Kimi response body to confirm tool_calls is genuinely empty and capture it for a bug report
  2. Verify the configured model actually supports the $web_search native tool (e.g. kimi/k2 variants with web search enabled)
  3. Check for a proxy or API gateway that mutates/strips tool_calls from responses
  4. Pin/adjust the Kimi model or API version and retry; if upstream persists, handle the error and fall back to non-native search

Example fix

// before: hard bail on empty list
if tool_calls.is_empty() {
    bail!("Kimi returned an empty native web-search tool call list");
}
// after: treat as final text answer instead of failing the request
if tool_calls.is_empty() {
    return Ok(ProviderNativeSearchResponse::text_fallback(message));
}
Defensive patterns

Strategy: try-catch

Validate before calling

// before the search request, confirm the model supports Kimi native web search
assert!(model_id.starts_with("kimi"), "native $web_search requires a Kimi model");

Type guard

fn tool_calls_valid(msg: &Value) -> bool {
    msg.get("tool_calls").and_then(Value::as_array).map_or(false, |t| !t.is_empty())
}

Try / catch

match provider.search(req).await {
    Err(e) if e.to_string().contains("empty native web-search tool call list") => {
        log::warn!("Kimi returned no search calls; falling back");
        fallback_search(req).await
    }
    other => other,
}

Prevention

When it happens

Trigger: During search_builtin, the streamed Kimi response ends with finish reason 'tool_calls' but message.tool_calls is present yet an empty JSON array (after passing the earlier .context check for a missing field).

Common situations: Kimi API behavior changes or regressions; a proxy/gateway stripping tool_calls contents; an unexpected model (non-Kimi or new Kimi version) routed at the Kimi endpoint that emits tool_calls finish reason without calls.

Understand the failure class

Background: "invalid response format", "malformed payload", "missing data field": when an API returns 200 but the response shape is wrong — this error's family across 23 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/e85d933af848784f. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/client/provider_native_search/kimi.rs:100

        let payload = client.post_json(&url, &body, &[]).await?;
        let choice = payload
            .pointer("/choices/0")
            .context("Kimi web search response omitted choices[0]")?;
        let message = choice
            .get("message")
            .and_then(Value::as_object)
            .context("Kimi web search response omitted assistant message")?;
        if choice.get("finish_reason").and_then(Value::as_str) != Some("tool_calls") {
            return Ok(parse_final_message(message));
        }

        messages.push(Value::Object(message.clone()));
        let tool_calls = message
            .get("tool_calls")
            .and_then(Value::as_array)
            .context("Kimi returned tool_calls finish reason without tool calls")?;
        if tool_calls.is_empty() {
            bail!("Kimi returned an empty native web-search tool call list");
        }
        reserve_native_search_tool_calls(&mut tool_calls_executed, tool_calls.len())?;
        for tool_call in tool_calls {
            if tool_call.pointer("/function/name").and_then(Value::as_str) != Some("$web_search") {
                bail!("Kimi native search requested an unexpected tool");
            }
            let id = tool_call
                .get("id")
                .and_then(Value::as_str)
                .context("Kimi native web-search call omitted id")?;
            let arguments = tool_call
                .pointer("/function/arguments")
                .and_then(Value::as_str)
                .context("Kimi native web-search call omitted arguments")?;
            let _: Value = serde_json::from_str(arguments)
                .context("Kimi native web-search arguments were not valid JSON")?;
            messages.push(json!({
                "role": "tool",

View on GitHub (pinned to 73e0f67d83)