Merge pull request #1046 from YuriNachos/feature/web-search-localization

feat(web-search): add country and language parameters
This commit is contained in:
Peter Steinberger
2026-01-16 23:17:46 +00:00
committed by GitHub
7 changed files with 157 additions and 14 deletions

View File

@@ -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 Braves 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.

View File

@@ -73,6 +73,29 @@ Search the web with Braves API.
- `query` (required)
- `count` (110; 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

View File

@@ -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")

View File

@@ -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");
});
});

View File

@@ -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);
},

View File

@@ -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 = {

View File

@@ -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