fix: land Brave llm-context gaps (#33383) (thanks @thirumaleshp)

This commit is contained in:
Peter Steinberger
2026-03-08 13:54:50 +00:00
parent 8a1015f1aa
commit acac7e3132
5 changed files with 265 additions and 5 deletions

View File

@@ -7,7 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
### Fixes
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.

View File

@@ -111,6 +111,29 @@ Brave provides paid plans; check the Brave API portal for the current limits and
}
```
**Brave LLM Context mode:**
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
brave: {
mode: "llm-context",
},
},
},
},
}
```
`llm-context` returns extracted page chunks for grounding instead of standard Brave snippets.
In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`,
`freshness`, `date_after`, and `date_before` are rejected.
## Using Gemini (Google Search grounding)
Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding),
@@ -247,6 +270,9 @@ await web_search({
});
```
When Brave `llm-context` mode is enabled, `ui_lang`, `freshness`, `date_after`, and
`date_before` are not supported. Use Brave `web` mode for those filters.
## web_fetch
Fetch a URL and extract readable content.

View File

@@ -1682,6 +1682,14 @@ export function createWebSearchTool(options?: {
}
const resolvedSearchLang = normalizedBraveLanguageParams.search_lang;
const resolvedUiLang = normalizedBraveLanguageParams.ui_lang;
if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") {
return jsonResult({
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && provider !== "brave" && provider !== "perplexity") {
return jsonResult({
@@ -1690,6 +1698,14 @@ export function createWebSearchTool(options?: {
docs: "https://docs.openclaw.ai/tools/web",
});
}
if (rawFreshness && provider === "brave" && braveMode === "llm-context") {
return jsonResult({
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined;
if (rawFreshness && !freshness) {
return jsonResult({
@@ -1715,6 +1731,14 @@ export function createWebSearchTool(options?: {
docs: "https://docs.openclaw.ai/tools/web",
});
}
if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") {
return jsonResult({
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
if (rawDateAfter && !dateAfter) {
return jsonResult({

View File

@@ -31,6 +31,23 @@ function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string }) {
});
}
function createBraveSearchTool(braveConfig?: { mode?: "web" | "llm-context" }) {
return createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
apiKey: "brave-config-test", // pragma: allowlist secret
...(braveConfig ? { brave: braveConfig } : {}),
},
},
},
},
sandboxed: true,
});
}
function createKimiSearchTool(kimiConfig?: { apiKey?: string; baseUrl?: string; model?: string }) {
return createWebSearchTool({
config: {
@@ -162,7 +179,7 @@ describe("web_search country and language parameters", () => {
}>,
) {
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const tool = createBraveSearchTool();
expect(tool).not.toBeNull();
await tool?.execute?.("call-1", { query: "test", ...params });
expect(mockFetch).toHaveBeenCalled();
@@ -180,7 +197,7 @@ describe("web_search country and language parameters", () => {
it("should pass language parameter to Brave API as search_lang", async () => {
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const tool = createBraveSearchTool();
await tool?.execute?.("call-1", { query: "test", language: "de" });
const url = new URL(mockFetch.mock.calls[0][0] as string);
@@ -204,7 +221,7 @@ describe("web_search country and language parameters", () => {
it("rejects unsupported Brave search_lang values before upstream request", async () => {
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const tool = createBraveSearchTool();
const result = await tool?.execute?.("call-1", { query: "test", search_lang: "xx" });
expect(mockFetch).not.toHaveBeenCalled();
@@ -511,8 +528,27 @@ describe("web_search external content wrapping", () => {
return mock;
}
function installBraveLlmContextFetch(
result: Record<string, unknown>,
mock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
grounding: {
generic: [result],
},
sources: [{ url: "https://example.com/ctx", hostname: "example.com" }],
}),
} as Response),
),
) {
global.fetch = withFetchPreconnect(mock);
return mock;
}
async function executeBraveSearch(query: string) {
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const tool = createBraveSearchTool();
return tool?.execute?.("call-1", { query });
}
@@ -545,6 +581,154 @@ describe("web_search external content wrapping", () => {
});
});
it("uses Brave llm-context endpoint when mode is configured", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "Context title",
url: "https://example.com/ctx",
snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "llm-context test",
country: "DE",
search_lang: "de",
});
const requestUrl = new URL(mockFetch.mock.calls[0]?.[0] as string);
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
expect(requestUrl.searchParams.get("q")).toBe("llm-context test");
expect(requestUrl.searchParams.get("country")).toBe("DE");
expect(requestUrl.searchParams.get("search_lang")).toBe("de");
const details = result?.details as {
mode?: string;
results?: Array<{
title?: string;
url?: string;
snippets?: string[];
siteName?: string;
}>;
sources?: Array<{ hostname?: string }>;
};
expect(details.mode).toBe("llm-context");
expect(details.results?.[0]?.url).toBe("https://example.com/ctx");
expect(details.results?.[0]?.title).toContain("<<<EXTERNAL_UNTRUSTED_CONTENT");
expect(details.results?.[0]?.snippets?.[0]).toContain("<<<EXTERNAL_UNTRUSTED_CONTENT");
expect(details.results?.[0]?.snippets?.[0]).toContain("Context chunk one");
expect(details.results?.[0]?.siteName).toBe("example.com");
expect(details.sources?.[0]?.hostname).toBe("example.com");
});
it("rejects freshness in Brave llm-context mode", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
url: "https://example.com",
snippets: ["unused"],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", { query: "test", freshness: "week" });
expect(result?.details).toMatchObject({ error: "unsupported_freshness" });
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects date_after/date_before in Brave llm-context mode", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
url: "https://example.com",
snippets: ["unused"],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "test",
date_after: "2025-01-01",
date_before: "2025-01-31",
});
expect(result?.details).toMatchObject({ error: "unsupported_date_filter" });
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects ui_lang in Brave llm-context mode", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
url: "https://example.com",
snippets: ["unused"],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "test",
ui_lang: "de-DE",
});
expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" });
expect(mockFetch).not.toHaveBeenCalled();
});
it("does not wrap Brave result urls (raw for tool chaining)", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const url = "https://example.com/some-page";

View File

@@ -48,6 +48,32 @@ describe("web search provider config", () => {
expect(res.ok).toBe(true);
});
it("accepts brave llm-context mode config", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {
mode: "llm-context",
},
}),
);
expect(res.ok).toBe(true);
});
it("rejects invalid brave mode config values", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {
mode: "invalid-mode",
},
}),
);
expect(res.ok).toBe(false);
});
});
describe("web search provider auto-detection", () => {