Merge pull request #1046 from YuriNachos/feature/web-search-localization
feat(web-search): add country and language parameters
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Docs: add `/help` hub, Node/npm PATH sanity guide, and installer PATH warnings (for “installed but command not found” setups). (#861)
|
||||
- Docs: clarify web_search country defaults to Brave’s region choice. (#1046) — thanks @YuriNachos.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Agents: default to no narration for routine tool calls. (#1008) — thanks @cpojer.
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
|
||||
@@ -73,6 +73,29 @@ Search the web with Brave’s API.
|
||||
|
||||
- `query` (required)
|
||||
- `count` (1–10; default from config)
|
||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||
- `ui_lang` (optional): ISO language code for UI elements
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// German-specific search
|
||||
await web_search({
|
||||
query: "TV online schauen",
|
||||
count: 10,
|
||||
country: "DE",
|
||||
search_lang: "de"
|
||||
});
|
||||
|
||||
// French search with French UI
|
||||
await web_search({
|
||||
query: "actualités",
|
||||
country: "FR",
|
||||
search_lang: "fr",
|
||||
ui_lang: "fr"
|
||||
});
|
||||
```
|
||||
|
||||
## web_fetch
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import base64
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
@@ -96,8 +95,8 @@ def request_images(
|
||||
if model != "dall-e-2":
|
||||
args["quality"] = quality
|
||||
|
||||
if model.startswith("dall-e"):
|
||||
args["response_format"] = "b64_json"
|
||||
# Note: response_format no longer supported by OpenAI Images API
|
||||
# dall-e models now return URLs by default
|
||||
|
||||
if model.startswith("gpt-image"):
|
||||
if background:
|
||||
@@ -212,12 +211,19 @@ def main() -> int:
|
||||
args.output_format,
|
||||
args.style,
|
||||
)
|
||||
b64 = res.get("data", [{}])[0].get("b64_json")
|
||||
if not b64:
|
||||
# OpenAI Images API now returns URLs by default
|
||||
image_url = res.get("data", [{}])[0].get("url")
|
||||
if not image_url:
|
||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
# Download image from URL
|
||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
|
||||
(out_dir / filename).write_bytes(image_bytes)
|
||||
filepath = out_dir / filename
|
||||
try:
|
||||
urllib.request.urlretrieve(image_url, filepath)
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Failed to download image from {image_url}: {e}") from e
|
||||
|
||||
items.append({"prompt": prompt, "file": filename})
|
||||
|
||||
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
|
||||
|
||||
@@ -21,3 +21,71 @@ describe("web tools defaults", () => {
|
||||
expect(tool?.name).toBe("web_search");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search country and language parameters", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
// @ts-expect-error global fetch cleanup
|
||||
global.fetch = priorFetch;
|
||||
});
|
||||
|
||||
it("should pass country parameter to Brave API", async () => {
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ web: { results: [] } }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||
expect(tool).not.toBeNull();
|
||||
|
||||
await tool?.execute?.(1, { query: "test", country: "DE" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get("country")).toBe("DE");
|
||||
});
|
||||
|
||||
it("should pass search_lang parameter to Brave API", async () => {
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ web: { results: [] } }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||
await tool?.execute?.(1, { query: "test", search_lang: "de" });
|
||||
|
||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get("search_lang")).toBe("de");
|
||||
});
|
||||
|
||||
it("should pass ui_lang parameter to Brave API", async () => {
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ web: { results: [] } }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||
await tool?.execute?.(1, { query: "test", ui_lang: "de" });
|
||||
|
||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get("ui_lang")).toBe("de");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,22 @@ const WebSearchSchema = Type.Object({
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
||||
}),
|
||||
),
|
||||
search_lang: Type.Optional(
|
||||
Type.String({
|
||||
description: "ISO language code for search results (e.g., 'de', 'en', 'fr').",
|
||||
}),
|
||||
),
|
||||
ui_lang: Type.Optional(
|
||||
Type.String({
|
||||
description: "ISO language code for UI elements.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const WebFetchSchema = Type.Object({
|
||||
@@ -291,8 +307,13 @@ async function runWebSearch(params: {
|
||||
timeoutSeconds: number;
|
||||
cacheTtlMs: number;
|
||||
provider: (typeof SEARCH_PROVIDERS)[number];
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(`${params.provider}:${params.query}:${params.count}`);
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`
|
||||
);
|
||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true };
|
||||
|
||||
@@ -304,6 +325,15 @@ async function runWebSearch(params: {
|
||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
url.searchParams.set("count", String(params.count));
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.ui_lang) {
|
||||
url.searchParams.set("ui_lang", params.ui_lang);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
@@ -424,7 +454,7 @@ export function createWebSearchTool(options?: {
|
||||
label: "Web Search",
|
||||
name: "web_search",
|
||||
description:
|
||||
"Search the web using Brave Search API. Returns titles, URLs, and snippets for fast research.",
|
||||
"Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
|
||||
parameters: WebSearchSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const apiKey = resolveSearchApiKey(search);
|
||||
@@ -435,6 +465,9 @@ export function createWebSearchTool(options?: {
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined;
|
||||
const country = readStringParam(params, "country");
|
||||
const search_lang = readStringParam(params, "search_lang");
|
||||
const ui_lang = readStringParam(params, "ui_lang");
|
||||
const result = await runWebSearch({
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
@@ -442,6 +475,9 @@ export function createWebSearchTool(options?: {
|
||||
timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||
provider: resolveSearchProvider(search),
|
||||
country,
|
||||
search_lang,
|
||||
ui_lang,
|
||||
});
|
||||
return jsonResult(result);
|
||||
},
|
||||
|
||||
@@ -278,7 +278,10 @@ export async function initSessionState(params: {
|
||||
ctx.MessageThreadId,
|
||||
);
|
||||
}
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
|
||||
// Fresh start for new sessions - don't inherit old state like compactionCount
|
||||
sessionStore[sessionKey] = isNewSession
|
||||
? sessionEntry
|
||||
: { ...sessionStore[sessionKey], ...sessionEntry };
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
||||
if (store[groupResolution.legacyKey] && !store[sessionKey]) {
|
||||
@@ -286,7 +289,10 @@ export async function initSessionState(params: {
|
||||
}
|
||||
delete store[groupResolution.legacyKey];
|
||||
}
|
||||
store[sessionKey] = { ...store[sessionKey], ...sessionEntry };
|
||||
// Fresh start for new sessions - don't inherit old state like compactionCount
|
||||
store[sessionKey] = isNewSession
|
||||
? sessionEntry
|
||||
: { ...store[sessionKey], ...sessionEntry };
|
||||
});
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
|
||||
@@ -135,8 +135,10 @@ async function saveSessionStoreUnlocked(
|
||||
|
||||
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.promises.writeFile(tmp, json, "utf-8");
|
||||
await fs.promises.writeFile(tmp, json, { mode: 0o600, encoding: "utf-8" });
|
||||
await fs.promises.rename(tmp, storePath);
|
||||
// Ensure permissions are set even if rename loses them
|
||||
await fs.promises.chmod(storePath, 0o600);
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
@@ -148,7 +150,8 @@ async function saveSessionStoreUnlocked(
|
||||
// Best-effort: try a direct write (recreating the parent dir), otherwise ignore.
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(storePath, json, "utf-8");
|
||||
await fs.promises.writeFile(storePath, json, { mode: 0o600, encoding: "utf-8" });
|
||||
await fs.promises.chmod(storePath, 0o600);
|
||||
} catch (err2) {
|
||||
const code2 =
|
||||
err2 && typeof err2 === "object" && "code" in err2
|
||||
|
||||
Reference in New Issue
Block a user