fix(agents): fix Brave llm-context empty snippets (#41387)

Merged via squash.

Prepared head SHA: 1e6f1d9d51607a115e4bf912f53149a26a5cdd82
Co-authored-by: zheliu2 <15888718+zheliu2@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Zhe Liu
2026-03-09 22:39:57 -04:00
committed by GitHub
parent 1720174757
commit 25c2facc2b
4 changed files with 92 additions and 10 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
## 2026.3.8

View File

@@ -23,6 +23,7 @@ const {
resolveKimiBaseUrl,
extractKimiCitations,
resolveBraveMode,
mapBraveLlmContextResults,
} = __testing;
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
@@ -393,3 +394,77 @@ describe("resolveBraveMode", () => {
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
});
});
describe("mapBraveLlmContextResults", () => {
it("maps plain string snippets correctly", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
},
],
},
});
expect(results).toEqual([
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
siteName: "example.com",
},
]);
});
it("filters out non-string and empty snippets", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com",
title: "Test",
snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[],
},
],
},
});
expect(results[0].snippets).toEqual(["valid"]);
});
it("handles missing snippets array", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://example.com", title: "No Snippets" } as never],
},
});
expect(results[0].snippets).toEqual([]);
});
it("handles empty grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]);
});
it("handles missing grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]);
});
it("resolves siteName from URL hostname", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBe("docs.example.org");
});
it("sets siteName to undefined for invalid URLs", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBeUndefined();
});
});

View File

@@ -272,8 +272,7 @@ type BraveSearchResponse = {
};
};
type BraveLlmContextSnippet = { text: string };
type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] };
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
@@ -1429,6 +1428,18 @@ async function runKimiSearch(params: {
};
}
function mapBraveLlmContextResults(
data: BraveLlmContextResponse,
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
return genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
siteName: resolveSiteName(entry.url) || undefined,
}));
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
@@ -1477,13 +1488,7 @@ async function runBraveLlmContextSearch(params: {
}
const data = (await res.json()) as BraveLlmContextResponse;
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
const mapped = genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean),
siteName: resolveSiteName(entry.url) || undefined,
}));
const mapped = mapBraveLlmContextResults(data);
return { results: mapped, sources: data.sources };
},
@@ -2122,4 +2127,5 @@ export const __testing = {
extractKimiCitations,
resolveRedirectUrl: resolveCitationRedirectUrl,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;

View File

@@ -694,7 +694,7 @@ describe("web_search external content wrapping", () => {
const mockFetch = installBraveLlmContextFetch({
title: "Context title",
url: "https://example.com/ctx",
snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }],
snippets: ["Context chunk one", "Context chunk two"],
});
const tool = createWebSearchTool({