mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: land Brave llm-context gaps (#33383) (thanks @thirumaleshp)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user