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.
|
- 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: 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: 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: 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.
|
- 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.
|
- 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)
|
- `query` (required)
|
||||||
- `count` (1–10; default from config)
|
- `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
|
## web_fetch
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -96,8 +95,8 @@ def request_images(
|
|||||||
if model != "dall-e-2":
|
if model != "dall-e-2":
|
||||||
args["quality"] = quality
|
args["quality"] = quality
|
||||||
|
|
||||||
if model.startswith("dall-e"):
|
# Note: response_format no longer supported by OpenAI Images API
|
||||||
args["response_format"] = "b64_json"
|
# dall-e models now return URLs by default
|
||||||
|
|
||||||
if model.startswith("gpt-image"):
|
if model.startswith("gpt-image"):
|
||||||
if background:
|
if background:
|
||||||
@@ -212,12 +211,19 @@ def main() -> int:
|
|||||||
args.output_format,
|
args.output_format,
|
||||||
args.style,
|
args.style,
|
||||||
)
|
)
|
||||||
b64 = res.get("data", [{}])[0].get("b64_json")
|
# OpenAI Images API now returns URLs by default
|
||||||
if not b64:
|
image_url = res.get("data", [{}])[0].get("url")
|
||||||
|
if not image_url:
|
||||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
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}"
|
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})
|
items.append({"prompt": prompt, "file": filename})
|
||||||
|
|
||||||
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
(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";
|
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
|
||||||
|
|
||||||
@@ -21,3 +21,71 @@ describe("web tools defaults", () => {
|
|||||||
expect(tool?.name).toBe("web_search");
|
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,
|
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({
|
const WebFetchSchema = Type.Object({
|
||||||
@@ -291,8 +307,13 @@ async function runWebSearch(params: {
|
|||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
cacheTtlMs: number;
|
cacheTtlMs: number;
|
||||||
provider: (typeof SEARCH_PROVIDERS)[number];
|
provider: (typeof SEARCH_PROVIDERS)[number];
|
||||||
|
country?: string;
|
||||||
|
search_lang?: string;
|
||||||
|
ui_lang?: string;
|
||||||
}): Promise<Record<string, unknown>> {
|
}): 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);
|
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||||
if (cached) return { ...cached.value, cached: true };
|
if (cached) return { ...cached.value, cached: true };
|
||||||
|
|
||||||
@@ -304,6 +325,15 @@ async function runWebSearch(params: {
|
|||||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||||
url.searchParams.set("q", params.query);
|
url.searchParams.set("q", params.query);
|
||||||
url.searchParams.set("count", String(params.count));
|
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(), {
|
const res = await fetch(url.toString(), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -424,7 +454,7 @@ export function createWebSearchTool(options?: {
|
|||||||
label: "Web Search",
|
label: "Web Search",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
description:
|
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,
|
parameters: WebSearchSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const apiKey = resolveSearchApiKey(search);
|
const apiKey = resolveSearchApiKey(search);
|
||||||
@@ -435,6 +465,9 @@ export function createWebSearchTool(options?: {
|
|||||||
const query = readStringParam(params, "query", { required: true });
|
const query = readStringParam(params, "query", { required: true });
|
||||||
const count =
|
const count =
|
||||||
readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined;
|
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({
|
const result = await runWebSearch({
|
||||||
query,
|
query,
|
||||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||||
@@ -442,6 +475,9 @@ export function createWebSearchTool(options?: {
|
|||||||
timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||||
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||||
provider: resolveSearchProvider(search),
|
provider: resolveSearchProvider(search),
|
||||||
|
country,
|
||||||
|
search_lang,
|
||||||
|
ui_lang,
|
||||||
});
|
});
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -278,7 +278,10 @@ export async function initSessionState(params: {
|
|||||||
ctx.MessageThreadId,
|
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) => {
|
await updateSessionStore(storePath, (store) => {
|
||||||
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
||||||
if (store[groupResolution.legacyKey] && !store[sessionKey]) {
|
if (store[groupResolution.legacyKey] && !store[sessionKey]) {
|
||||||
@@ -286,7 +289,10 @@ export async function initSessionState(params: {
|
|||||||
}
|
}
|
||||||
delete store[groupResolution.legacyKey];
|
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 = {
|
const sessionCtx: TemplateContext = {
|
||||||
|
|||||||
@@ -135,8 +135,10 @@ async function saveSessionStoreUnlocked(
|
|||||||
|
|
||||||
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
try {
|
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);
|
await fs.promises.rename(tmp, storePath);
|
||||||
|
// Ensure permissions are set even if rename loses them
|
||||||
|
await fs.promises.chmod(storePath, 0o600);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code =
|
const code =
|
||||||
err && typeof err === "object" && "code" in err
|
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.
|
// Best-effort: try a direct write (recreating the parent dir), otherwise ignore.
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
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) {
|
} catch (err2) {
|
||||||
const code2 =
|
const code2 =
|
||||||
err2 && typeof err2 === "object" && "code" in err2
|
err2 && typeof err2 === "object" && "code" in err2
|
||||||
|
|||||||
Reference in New Issue
Block a user