feat(web-search): ✨ add web search tool with multi-engine support#197
Conversation
…n filtering Migrate API key and base URL storage from single fields to per-engine maps, enabling independent configuration for each search engine. Remove legacy `clearApiKey` action in favor of saving an empty string. - Store `apiKeys` and `baseUrls` as engine-keyed maps in settings - Automatically migrate existing single `apiKey`/`baseUrl` on read - Update agent tool to remove `maxResults`/`includeRawContent` parameters (now exclusively controlled by app settings) - Improve agent tool descriptions for query and timeRange parameters - Add Brave domain filtering via `site:`/`-site:` query modifiers - Add Exa published date range support for timeRange parameter - Change default `includeRawContent` to `true` - Remove unused translation keys `alwaysOn` and `off` in profile agent access - UI: replace lock icon with read-only checkbox for built-in agents, clamp descriptions, clear API key input on engine change
… search Introduce ToolContext to bundle workspace path, writable roots, thread id, terminal manager, and database pool, simplifying function signatures across executors and tool gateway. Web search improvements: - Use a static OnceLock HTTP client to avoid recreating per request. - Remove providerResponse from tool output to reduce payload size. - Adjust topic_from_time_range to only return "news" for day and week. Subagent tool selection: - Conditionally add web_search tool based on user settings. - Pass a flag to helper_tools to control inclusion. UI: refine profile library card styling (font sizes, spacing, layout) and add debounced web search base URL update.
Add logic to transfer subagent access IDs from the source profile to the new duplicate when cloning a profile. On failure, the created profile is preserved and a warning is logged, ensuring robustness. Also replace inline error message construction in agents settings panel with a shared utility function for consistent invoke error handling.
AI Code Review SummaryPR: #197 (feat(web-search): ✨ add web search tool with multi-engine support) Overall AssessmentDetected 7 actionable findings, prioritize CRITICAL/HIGH before merge. Major Findings by Severity
Actionable Suggestions
Potential Risks
Test Suggestions
File-Level Coverage Notes
Inline Downgraded Items (processed but not inline)
Coverage Status
Uncovered list:
No-patch covered list:
Runtime/Budget
|
| insert_string(&mut body, "country", country); | ||
| } | ||
|
|
||
| let value = post_json(client.post(endpoint).bearer_auth(api_key).json(&body)).await?; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| response_json(builder.send().await).await | ||
| } | ||
|
|
||
| async fn response_json( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ) | ||
| })?; | ||
| let status = response.status(); | ||
| let body = response.text().await.map_err(|error| { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| fn map_tavily_result(value: &Value) -> StandardSearchResult { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
|
|
||
| fn parse_input(input: &Value, settings: &WebSearchSettings) -> Result<WebSearchInput, AppError> { | ||
| let query = input |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
|
|
||
| const HTTP_TIMEOUT_SECS: u64 = 30; | ||
|
|
||
| static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new(); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| pub async fn load_web_search_settings(pool: &SqlitePool) -> Result<WebSearchSettings, AppError> { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| return optimistic; | ||
| } | ||
|
|
||
| try { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| })); | ||
| } | ||
|
|
||
| export async function updateWebSearchSettings(patch: WebSearchSettingsPatch) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| </span> | ||
|
|
||
| <div | ||
| className={cn( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
- Add MAX_QUERY_CHARS (500) and MAX_RESPONSE_BYTES (5MB) constants - Validate web search query length before making request - Check HTTP response size to prevent large payloads - Move Tavily API key from bearer auth to request body - Fix http_error function to accept &str instead of String - Update settings overlay to debounce max results value with onBlur - Add local state for max results to prevent premature saves
| profileList(), | ||
| promptCommandList(), | ||
| customSubagentList(), | ||
| settingsGet(WEB_SEARCH_SETTINGS_KEY), |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ) | ||
| })?; | ||
| let status = response.status(); | ||
| let raw_body = response.bytes().await.map_err(|error| { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ) | ||
| })?; | ||
| let status = response.status(); | ||
| let raw_body = response.bytes().await.map_err(|error| { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ) | ||
| })?; | ||
| let status = response.status(); | ||
| let raw_body = response.bytes().await.map_err(|error| { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| [], | ||
| ); | ||
|
|
||
| const handleSaveWebSearchApiKey = async () => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ) | ||
| .then(async (profile) => { | ||
| const mapped = mapProfileDto(profile); | ||
| try { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| })); | ||
| } | ||
|
|
||
| export async function updateWebSearchSettings(patch: WebSearchSettingsPatch) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| const optimistic: WebSearchSettings = { | ||
| ...current, | ||
| ...patch, | ||
| hasApiKey: Object.prototype.hasOwnProperty.call(patch, "apiKey") |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| }; | ||
|
|
||
| useEffect(() => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| className="w-full md:w-[360px]" | ||
| onChange={(event) => setWebSearchBaseUrl(event.target.value)} | ||
| onBlur={() => { | ||
| const trimmed = webSearchBaseUrl.trim(); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Add localized error handling for slug conflicts when creating or saving custom agents. The application now detects the `custom_subagent.slug_conflict` error code and displays a specific translation with the conflicting slug, improving user feedback. Split error state into page-level (`pageErrorMessage`) and editor-level (`editorErrorMessage`) to show errors in their correct context. The editor now displays inline error messages near the header, with a dismiss button, while global errors like create/delete failures remain at the bottom. This provides clearer error attribution.
| const mapped = mapPersistedWebSearchSettings(persisted); | ||
| settingsStore.setState({ webSearch: mapped }); | ||
| return mapped; | ||
| } catch (error) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ) | ||
| })?; | ||
| let status = response.status(); | ||
| let raw_body = response.bytes().await.map_err(|error| { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ) | ||
| })?; | ||
| let status = response.status(); | ||
| let raw_body = response.bytes().await.map_err(|error| { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }); | ||
| }); | ||
|
|
||
| describe("updateWebSearchSettings", () => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| @@ -0,0 +1,165 @@ | |||
| import type { WebSearchEngine, WebSearchSettings } from "@/modules/settings-center/model/types"; | |||
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| placeholder={webSearch.hasApiKey ? "••••••••••••" : t("settings.general.webSearchApiKeyPlaceholder")} | ||
| onChange={(event) => setWebSearchApiKey(event.target.value)} | ||
| /> | ||
| <Button |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ), | ||
| )); | ||
| } | ||
| let body = String::from_utf8_lossy(&raw_body); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| fn brave_query(query: &str, include_domains: &[String], exclude_domains: &[String]) -> String { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| const optimistic: WebSearchSettings = { | ||
| ...current, | ||
| ...patch, | ||
| hasApiKey: Object.prototype.hasOwnProperty.call(patch, "apiKey") |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Improve robustness of web search executor by streaming response body with size limits and sanitizing domain filters to prevent injection attacks. Enhance settings UI to display API key save errors and add a clear button when a key exists. - Refactor response reading to use streaming chunks with early size validation, preventing large responses from being fully buffered. - Add domain filter sanitization to strip schemes, ports, paths, and invalid characters, reducing risk of query manipulation. - Correct UI button label to show "Clear saved key" when a key is stored and the input is empty. - Display backend save failures as user-visible error messages using the new i18n `webSearchApiKeySaveError` translation. - Update `updateWebSearchSettings` to clear optimistic `hasApiKey` and `baseUrl` when switching engines, and to reject the promise on persistence failure, enabling proper rollback and error display. - Add test coverage for rollback on read/write failures and engine switch logic.
| ...pendingCommands, | ||
| ], | ||
| customSubagents: customSubagents ?? [], | ||
| webSearch: mapPersistedWebSearchSettings(webSearchSetting?.value), |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
|
|
||
| try { | ||
| const existing = await settingsGet(WEB_SEARCH_SETTINGS_KEY); | ||
| const persisted = buildPersistedWebSearchSettings( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| <span className="mt-2 block text-[12px] leading-5 text-app-muted"> | ||
| <span | ||
| className="mt-2 line-clamp-2 text-[12px] leading-5 text-app-muted" | ||
| title={agent.invocationDescription.trim() || t("settings.profileAgentAccess.noDescription")} |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| profileList(), | ||
| promptCommandList(), | ||
| customSubagentList(), | ||
| settingsGet(WEB_SEARCH_SETTINGS_KEY), |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
|
|
||
| try { | ||
| const existing = await settingsGet(WEB_SEARCH_SETTINGS_KEY); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| })); | ||
| } | ||
|
|
||
| export async function updateWebSearchSettings(patch: WebSearchSettingsPatch) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| .then(async (profile) => { | ||
| const mapped = mapProfileDto(profile); | ||
| try { | ||
| const subagentAccessIds = await profileSubagentAccessGet(source.id); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
|
|
||
| const isClearingWebSearchApiKey = webSearch.hasApiKey && !webSearchApiKey.trim(); | ||
|
|
||
| useEffect(() => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| onChange={(event) => { | ||
| const nextValue = Number(event.target.value); | ||
| if (Number.isFinite(nextValue)) { | ||
| setWebSearchMaxResults(clampWebSearchMaxResults(nextValue)); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| canDelete={agentProfiles.length > 1} | ||
| primaryModelLabel={getProfilePrimaryModelLabel(profile)} | ||
| thinkingLevelLabel={getProfileThinkingLevelLabel(profile)} | ||
| primaryModel={getProfilePrimaryModelSummary(profile)} |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
…I state bugs Added error handling in settings hydration to catch Web Search fetch failures, preventing them from blocking other phase-2 data. Fixed useEffect dependencies for webSearch fields (engine, baseUrl, maxResults) to avoid stale state and incorrect resets. Allowed empty input for maxResults to improve user experience when editing. Added test to verify graceful degradation when Web Search settings hydration fails.
| api_key: &str, | ||
| input: &WebSearchInput, | ||
| ) -> Result<ToolOutput, AppError> { | ||
| let endpoint = settings |
There was a problem hiding this comment.
[MEDIUM] Missing HTTPS scheme enforcement on custom web search base URLs
Custom base URLs configured for search engines (Tavily, Brave, Exa, Firecrawl) do not enforce the secure HTTPS protocol. If a user or an attacker manipulates the base URL to use 'http://', the app will transmit sensitive API keys in plain text over the network, exposing them to interception.
Suggestion: Validate that any custom configured base URL starts with 'https://' before making requests, or configure the shared reqwest::Client with .https_only(true) in http_client() to prevent unencrypted requests automatically.
Risk: Exposure of third-party API keys (Tavily, Brave, Exa, Firecrawl) over unencrypted connections if configured with custom HTTP endpoints.
Confidence: 0.85
| const clamped = clampWebSearchMaxResults(draftValue); | ||
| setWebSearchMaxResults(clamped); | ||
| if (clamped !== webSearch.maxResults) { | ||
| void onUpdateWebSearchSettings({ maxResults: clamped }); |
There was a problem hiding this comment.
[MEDIUM] Silent Rejection of Web Search Settings Promises
Buddy, when web search settings (such as maxResults, baseUrl, enabled, or includeRawContent) are changed, the app invokes 'onUpdateWebSearchSettings' and discards the Promise with the 'void' operator. If the backend command fails, the frontend UI state will reflect the updated state temporarily but become desynchronized with the actual persistent backend settings.
Suggestion: Handle potential errors from the 'onUpdateWebSearchSettings' promise. Display a toast or set an error state when the update fails, and revert the UI state back to the previous prop values on failure.
Risk: Silent save failures where users believe settings were updated, causing unexpected application behavior and hard-to-reproduce test failures in integration or end-to-end environments.
Confidence: 0.95
|
|
||
| fn http_client() -> &'static reqwest::Client { | ||
| HTTP_CLIENT.get_or_init(|| { | ||
| reqwest::Client::builder() |
There was a problem hiding this comment.
[LOW] Missing Custom User-Agent Header in HTTP Client
The shared HTTP client is initialized without a custom User-Agent header.
Suggestion: Add a descriptive .user_agent("tiy-agent/1.0") or similar unique identifier to the reqwest builder.
Risk: Some search API gateways or scrapers (such as Firecrawl/Brave) aggressively rate-limit or outright reject requests utilizing the default reqwest user agent to prevent automated abuse.
Confidence: 0.90
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Serialize, Deserialize)] |
There was a problem hiding this comment.
[LOW] Exposure of sensitive API keys in WebSearchSettings Debug logs
The WebSearchSettings struct derives the Debug trait automatically. This includes the api_keys map and api_key fields in the generated string representation. If this struct is ever formatted in debug logging, error messages, or telemetry, the API keys will be exposed.
Suggestion: Implement the Debug trait manually for WebSearchSettings and redact the sensitive keys, or wrap the API keys in a helper type that implements a secure, redacted Debug representation.
Risk: Accidental exposure of sensitive API keys in log outputs or telemetry data.
Confidence: 0.90
| {editorErrorMessage ? ( | ||
| <div className="flex min-w-[240px] flex-1 items-center gap-2 py-0.5 text-[14px] font-semibold text-app-error"> | ||
| <AlertTriangle className="size-4 shrink-0" /> | ||
| <span className="min-w-0 truncate">{editorErrorMessage}</span> |
There was a problem hiding this comment.
[LOW] Add hover tooltip for truncated editor error messages
The inline editor error message utilizes Tailwind's truncate utility to fit inside a single line. If the error text is long, users won't be able to read the full context.
Suggestion: Add a title={editorErrorMessage} attribute to the span so that users can hover over the truncated text to view the complete error message.
Risk: Important details of a subagent save/update error could be cut off, making it difficult for the user to troubleshoot formatting or validation errors.
Confidence: 0.90
| <p className="text-[13px] font-medium text-app-foreground">{name}</p> | ||
| <span className="rounded-full border border-app-info/30 bg-app-info/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-app-info"> | ||
| {t("settings.profileAgentAccess.alwaysOn")} | ||
| <span className="rounded-full border border-app-success/30 bg-app-success/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-app-success"> |
There was a problem hiding this comment.
[LOW] Identical Styling for Interactive and Non-Interactive Agents
Buddy, the built-in agents (which are always enabled and immutable) now display the identical 'Allowed' badge and checked checkbox visual style as enabled custom agents. However, unlike custom agents, the checkbox for built-in agents is completely read-only and non-interactive.
Suggestion: Differentiate immutable system permissions from user-configurable permissions. Keep the lock icon or mark built-in agents clearly with an 'Always Allowed' or 'System' label, and add 'aria-disabled="true"' to the non-interactive checkboxes.
Risk: End-users will be confused as to why clicking certain checkboxes has no effect, and automated visual regression or accessibility testing scripts will struggle to distinguish interactive versus non-interactive states.
Confidence: 0.90
| setWebSearchApiKeyError(null); | ||
| setIsSavingWebSearchKey(true); | ||
| try { | ||
| await onUpdateWebSearchSettings({ apiKey: webSearchApiKey }); |
There was a problem hiding this comment.
[LOW] Trim Web Search API Key before saving
The input text is saved as-is without trimming leading or trailing whitespaces. If a user accidentally copies a trailing space or newline with their API key, it will be saved and might cause the search engine query requests to fail authentication.
Suggestion: Trim the API key string before calling the update handler, for example: await onUpdateWebSearchSettings({ apiKey: webSearchApiKey.trim() });.
Risk: A user could paste an API key with trailing whitespace, leading to persistent, hard-to-debug search authentication errors.
Confidence: 0.95
Summary
Add a built-in web search tool with multi-engine support (Tavily, Brave, Exa, Firecrawl), configurable per-engine API keys and base URLs, domain filtering, and a full settings UI in the General settings panel.
Changes
Backend (Rust)
web_search.rs): implements tool execution with standardized result format across all four engines, including time-range filtering, domain include/exclude, and country hintsweb_search_settings.rs): persists and loads web search settings from SQLite, with per-engine API key and base URL maps, legacy migration, and sanitizationexecutors/mod.rs): introducesToolContextstruct to bundle workspace path, writable roots, thread ID, terminal manager, and DB pool — simplifies theexecute_toolsignature and passes the pool needed by web searchagent_session_tools.rs):web_searchtool is added to default and plan-read-only profiles only when the feature is enabled and an API key is configured; also wired into subagent helper profilestool_gateway.rs): updated to passToolContextinstead of individual parametersorchestrator.rs,runtime_orchestration.rs): propagates web search enabled state to helper agentsFrontend (TypeScript/React)
types.ts,settings-store.ts,defaults.ts): addsWebSearchSettingstype with engine, API key status, base URL, max results, and raw content toggleweb-search-settings.ts): handles mapping between persisted and in-memory formats, per-engine API key/base URL normalization, and legacy field migrationsettings-ipc-actions.ts,settings-ipc-actions.test.ts): addsupdateWebSearchSettingswith optimistic UI updates and backend sync; includes comprehensive unit tests for per-engine key/URL persistence and migrationsettings-center-overlay.tsx): adds Web Search section with enable toggle, engine picker, API key input (masked), base URL, max results slider, and raw content switchsettings-center-overlay.tsx): redesigns cards to show model brand icon and compact layout with hover-reveal actionsprofile-agent-access.tsx): replaces lock icon with checkbox for always-on agents, improves truncation and layoutagents-settings-panel.tsx): adds web_search to tool categories, uses shared error message helper, shows invocation descriptionen.ts,zh-CN.ts): adds translation keys for all web search settings labels and descriptionstool-names.ts): addsweb_searchto default-collapsed tools listTest Plan
npm run typecheckandnpm run test:unitcargo test --locked --manifest-path src-tauri/Cargo.toml🤖 Generated with TiyCode