From 9008fa445d5b1fef68e0de13cec4c971c88fe50d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 07:03:16 +0100 Subject: [PATCH] fix(kimi): reject ungrounded web search answers --- CHANGELOG.md | 1 + docs/tools/kimi-search.md | 9 ++ docs/tools/web.md | 30 ++--- .../src/kimi-web-search-provider.runtime.ts | 55 +++++++- .../src/kimi-web-search-provider.test.ts | 124 +++++++++++++++++- 5 files changed, 199 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27bf049d294..cd1148e2302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - CLI/directory: report unsupported directory operations for installed channel plugins instead of prompting to reinstall the plugin when it lacks a directory adapter. Fixes #75770. Thanks @lawong888. - Web search/SearXNG: show the JSON API `search.formats` prerequisite during SearXNG setup before prompting for the base URL. Supersedes #65592. Thanks @evanpaul14. - Web search/SearXNG: pass through `img_src` image URLs from SearXNG image-category results. Supersedes #61416. Thanks @sghael. +- Web search/Kimi: fail explicitly when Moonshot returns an ungrounded chat answer instead of native web-search evidence, so Kimi no longer reports generic fallback text as a successful search. Fixes #52573. Thanks @wangwllu. - Web search: keep public provider requests on the strict SSRF guard and reserve private-network access for explicit self-hosted SearXNG/Firecrawl endpoints. Fixes #74357 and supersedes #74360. Thanks @fede-kamel. - Web search/Firecrawl: allow self-hosted private/internal Firecrawl `baseUrl` endpoints, including HTTP for private targets, while keeping hosted Firecrawl on the strict official endpoint. Fixes #63877 and supersedes #59666, #63941, and #74013. Thanks @jhthompson12, @jzakirov, @Mlightsnow, and @shad0wca7. - Providers/OpenRouter: strip trailing assistant prefill turns from verified OpenRouter Anthropic model requests when reasoning is enabled, so Claude 4.6 routes no longer fail with Anthropic's prefill rejection through the OpenAI-compatible adapter. Fixes #75395. Thanks @sbmilburn. diff --git a/docs/tools/kimi-search.md b/docs/tools/kimi-search.md index 5a60694a7dc..bcb19878506 100644 --- a/docs/tools/kimi-search.md +++ b/docs/tools/kimi-search.md @@ -79,6 +79,15 @@ If you omit `model`, OpenClaw defaults to `kimi-k2.6`. Kimi uses Moonshot web search to synthesize answers with inline citations, similar to Gemini and Grok's grounded response approach. +OpenClaw treats Kimi `web_search` as successful only after Moonshot returns +native web-search grounding evidence, such as a replayable `$web_search` tool +payload, `search_results`, or citation URLs. If Kimi stops immediately with a +plain chat answer like "I cannot browse the internet" and no grounding evidence, +OpenClaw returns a structured `kimi_web_search_ungrounded` error instead of +wrapping that text as a search result. Retry the query, switch to a structured +provider such as Brave, or use `web_fetch` / the browser tool when you already +have a target URL. + ## Supported parameters Kimi search supports `query`. diff --git a/docs/tools/web.md b/docs/tools/web.md index b2761eb4bf1..c34e110ba9d 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -76,7 +76,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. AI-synthesized answers with citations via xAI web grounding. - AI-synthesized answers with citations via Moonshot web search. + AI-synthesized answers with citations via Moonshot web search; ungrounded chat fallbacks fail explicitly. Structured results via the MiniMax Token Plan search API. @@ -97,20 +97,20 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. ### Provider comparison -| Provider | Result style | Filters | API key | -| ----------------------------------------- | -------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- | -| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` | -| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) | -| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` | -| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` | -| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` | -| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` | -| [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | -| [MiniMax Search](/tools/minimax-search) | Structured snippets | Region (`global` / `cn`) | `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` / `MINIMAX_OAUTH_TOKEN` | -| [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None for signed-in local hosts; `OLLAMA_API_KEY` for direct `https://ollama.com` search | -| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | -| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) | -| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` | +| Provider | Result style | Filters | API key | +| ----------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- | +| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` | +| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) | +| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` | +| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` | +| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` | +| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` | +| [Kimi](/tools/kimi-search) | AI-synthesized + citations; fails on ungrounded chat fallbacks | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| [MiniMax Search](/tools/minimax-search) | Structured snippets | Region (`global` / `cn`) | `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` / `MINIMAX_OAUTH_TOKEN` | +| [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None for signed-in local hosts; `OLLAMA_API_KEY` for direct `https://ollama.com` search | +| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | +| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) | +| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` | ## Auto-detection diff --git a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts index 6e35319eaa4..789963591eb 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts @@ -75,6 +75,12 @@ type KimiSearchResponse = { }>; }; +type KimiSearchResult = { + content: string; + citations: string[]; + grounded: boolean; +}; + function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig { const kimi = searchConfig?.kimi; return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {}; @@ -155,6 +161,15 @@ function extractKimiCitations(data: KimiSearchResponse): string[] { return [...new Set(citations)]; } +function hasKimiSearchResults(data: KimiSearchResponse): boolean { + return (data.search_results ?? []).some( + (entry) => + Boolean(normalizeOptionalString(entry.url)) || + Boolean(normalizeOptionalString(entry.title)) || + Boolean(normalizeOptionalString(entry.content)), + ); +} + function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined { const rawArguments = toolCall.function?.arguments; if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) { @@ -169,10 +184,11 @@ async function runKimiSearch(params: { baseUrl: string; model: string; timeoutSeconds: number; -}): Promise<{ content: string; citations: string[] }> { +}): Promise { const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; const messages: Array> = [{ role: "user", content: params.query }]; const collectedCitations = new Set(); + let hasGroundingEvidence = false; for (let round = 0; round < 3; round += 1) { const next = await withTrustedWebSearchEndpoint( @@ -201,16 +217,26 @@ async function runKimiSearch(params: { } const data = (await res.json()) as KimiSearchResponse; + if (hasKimiSearchResults(data)) { + hasGroundingEvidence = true; + } for (const citation of extractKimiCitations(data)) { collectedCitations.add(citation); } + if (collectedCitations.size > 0) { + hasGroundingEvidence = true; + } const choice = data.choices?.[0]; const message = choice?.message; const text = extractKimiMessageText(message); const toolCalls = message?.tool_calls ?? []; if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + return { + done: true, + content: text ?? "No response", + citations: [...collectedCitations], + }; } messages.push({ @@ -228,6 +254,9 @@ async function runKimiSearch(params: { if (!toolCallId || !toolCallName || !toolContent) { continue; } + if (toolCallName === KIMI_WEB_SEARCH_TOOL.function.name) { + hasGroundingEvidence = true; + } pushed = true; messages.push({ role: "tool", @@ -237,20 +266,25 @@ async function runKimiSearch(params: { }); } if (!pushed) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + return { + done: true, + content: text ?? "No response", + citations: [...collectedCitations], + }; } return { done: false }; }, ); if (next.done) { - return { content: next.content, citations: next.citations }; + return { content: next.content, citations: next.citations, grounded: hasGroundingEvidence }; } } return { content: "Search completed but no final answer was produced.", citations: [...collectedCitations], + grounded: hasGroundingEvidence, }; } @@ -304,6 +338,18 @@ export async function executeKimiWebSearchProviderTool( model, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), }); + if (!result.grounded) { + return { + error: "kimi_web_search_ungrounded", + message: + "Kimi returned a chat completion without native web-search grounding. Retry the query, switch to a structured provider such as Brave, or use web_fetch/browser for a specific URL.", + query, + provider: "kimi", + model, + docs: "https://docs.openclaw.ai/tools/kimi-search", + tookMs: Date.now() - start, + }; + } const payload = { query, provider: "kimi", @@ -410,5 +456,6 @@ export const __testing = { resolveKimiModel, resolveKimiBaseUrl, extractKimiCitations, + hasKimiSearchResults, extractKimiToolResultContent, } as const; diff --git a/extensions/moonshot/src/kimi-web-search-provider.test.ts b/extensions/moonshot/src/kimi-web-search-provider.test.ts index f6eaeab7ada..490ae0ddbc5 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.test.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { __testing } from "../test-api.js"; import { createKimiWebSearchProvider } from "./kimi-web-search-provider.js"; @@ -25,7 +25,27 @@ function withEnv(overrides: Record, run: () => void): void { } } +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +async function executeKimiSearch(query: string): Promise> { + const provider = createKimiWebSearchProvider(); + const tool = provider.createTool({ config: {}, searchConfig: {} }); + if (!tool) { + throw new Error("Expected tool definition"); + } + return await tool.execute({ query }); +} + describe("kimi web search provider", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("points missing-key users to fetch/browser alternatives", async () => { await withEnvAsync({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, async () => { const provider = createKimiWebSearchProvider(); @@ -108,6 +128,108 @@ describe("kimi web search provider", () => { ).toEqual(["https://a.test", "https://b.test", "https://c.test"]); }); + it("returns a structured failure for ungrounded chat-only responses", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + choices: [ + { + finish_reason: "stop", + message: { content: "I cannot browse the internet." }, + }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => { + const result = await executeKimiSearch("kimi ungrounded chat fallback"); + + expect(result).toMatchObject({ + error: "kimi_web_search_ungrounded", + provider: "kimi", + message: expect.stringContaining("without native web-search grounding"), + }); + }); + }); + + it("accepts final responses backed by Kimi web search tool replay", async () => { + const toolArguments = JSON.stringify({ + query: "OpenClaw GitHub repository", + usage: { total_tokens: 1200 }, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + choices: [ + { + finish_reason: "tool_calls", + message: { + content: "", + tool_calls: [ + { + id: "call-1", + function: { + name: "$web_search", + arguments: toolArguments, + }, + }, + ], + }, + }, + ], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + choices: [ + { + finish_reason: "stop", + message: { content: "OpenClaw is available on GitHub." }, + }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => { + const result = await executeKimiSearch("kimi grounded tool replay"); + + expect(result).toMatchObject({ + provider: "kimi", + content: expect.stringContaining("OpenClaw is available on GitHub."), + citations: [], + }); + expect(result).not.toHaveProperty("error"); + }); + }); + + it("accepts final responses with search result citations", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + search_results: [{ title: "OpenClaw", url: "https://github.com/openclaw/openclaw" }], + choices: [ + { + finish_reason: "stop", + message: { content: "OpenClaw is on GitHub." }, + }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => { + const result = await executeKimiSearch("kimi grounded citation"); + + expect(result).toMatchObject({ + provider: "kimi", + content: expect.stringContaining("OpenClaw is on GitHub."), + citations: ["https://github.com/openclaw/openclaw"], + }); + expect(result).not.toHaveProperty("error"); + }); + }); + it("returns original tool arguments as tool content", () => { const rawArguments = ' {"query":"MacBook Neo","usage":{"total_tokens":123}} ';