From fefe1499080df99f41d26e06e1cbda952650241d Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 27 Jul 2026 12:36:20 +0530 Subject: [PATCH 1/3] feat(search): attribute managed web search to the resolved provider Managed search routes through the OpenHuman backend, which serves the overwhelming majority of traffic via Exa, but nothing in the product said so: a completed web search rendered as a generic "Searching the web" row and the Connections copy described the managed path as an unattributed proxy. Carry the provider the backend actually resolved to through the result and surface it, without hardcoding a name: - SearchResponse gains an optional `provider` field (aliased `resolvedProvider`/`searchProvider`); older backends that omit it deserialize unchanged and fall back to the managed default. - resolve_managed_provider() prefers the backend-reported value and falls back to "Exa" only when it is absent or blank, so a future routing change surfaces with no code edit. The result heading now reads "(via )", matching the marker the Brave/Querit/Seltz engines already emit, in place of the fixed "(via backend Parallel)". - The tools.web_search RPC returns and logs the same resolved provider. - The tool timeline titles a completed web_search "Searched with " and moves the query to the detail line; running rows are unchanged. The marker is read from the heading line only and capped in length so a "(via ...)" string inside a result excerpt cannot be misread as the provider. formatTimelineEntry backs every tool-call surface, so Chat, the processing transcript, past-turn insights and the source panel all agree. - Connections -> Search engine now names Exa as the current provider, in English and all 13 other locales. --- app/src/lib/i18n/__tests__/coverage.test.ts | 8 + app/src/lib/i18n/ar.ts | 2 +- app/src/lib/i18n/bn.ts | 3 +- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/fr.ts | 2 +- app/src/lib/i18n/hi.ts | 3 +- app/src/lib/i18n/id.ts | 2 +- app/src/lib/i18n/it.ts | 2 +- app/src/lib/i18n/ko.ts | 2 +- app/src/lib/i18n/pl.ts | 3 +- app/src/lib/i18n/pt.ts | 2 +- app/src/lib/i18n/ru.ts | 2 +- app/src/lib/i18n/zh-CN.ts | 3 +- .../__tests__/toolTimelineFormatting.test.ts | 67 ++++++++ app/src/utils/toolTimelineFormatting.ts | 40 ++++- src/openhuman/search/tools/mod.rs | 3 + src/openhuman/search/tools/parallel.rs | 12 ++ src/openhuman/search/tools/parallel_tests.rs | 29 ++++ src/openhuman/search/tools/web_search.rs | 151 ++++++++++++++++-- src/openhuman/tools/schemas.rs | 33 +++- 22 files changed, 337 insertions(+), 38 deletions(-) diff --git a/app/src/lib/i18n/__tests__/coverage.test.ts b/app/src/lib/i18n/__tests__/coverage.test.ts index f723de9068..e3b3a4fafa 100644 --- a/app/src/lib/i18n/__tests__/coverage.test.ts +++ b/app/src/lib/i18n/__tests__/coverage.test.ts @@ -61,4 +61,12 @@ describe('i18n coverage', () => { .map(([key]) => key); expect(keysWithEmDashes).toEqual([]); }); + + // The OpenHuman Managed search option must name the provider behind it, so + // the managed path does not read as an unattributed black box (#5136). The + // provider name is a proper noun, so it stays literal in every locale. + it.each(['en', ...LOCALES])('locale %s names Exa in the managed search copy', locale => { + const flat = locale === 'en' ? enFlat : loadLocale(locale); + expect(flat['settings.search.engineManagedDesc']).toContain('Exa'); + }); }); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 688641ac81..8945b6e68b 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1663,7 +1663,7 @@ const messages: TranslationMap = { 'settings.search.engineDisabledDesc': 'أزل أدوات البحث من سياق الوكيل وقائمة الأدوات المتاحة.', 'settings.search.engineManagedLabel': 'OpenHuman مُدار', 'settings.search.engineManagedDesc': - 'خطأ تم سحبها من خلال الركيزة الخلفية - لا حاجة لمفتاح Xqx0xx.', + 'الإعداد الافتراضي. يتم توجيهه عبر خادم OpenHuman الخلفي، المدعوم حاليًا بواسطة Exa: لا حاجة إلى مفتاح API.', 'settings.search.localManagedUnavailable': 'بحث OpenHuman المُدار غير متاح للمستخدمين المحليين. أضف مفتاح Parallel أو Brave الخاص بك لتفعيل البحث على الويب.', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index c9ab8981fa..c67b01d70c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1696,7 +1696,8 @@ const messages: TranslationMap = { 'settings.search.engineDisabledDesc': 'এজেন্ট প্রেক্ষাপট এবং উপলব্ধ টুল তালিকা থেকে সার্চ টুলগুলি সরিয়ে দিন।', 'settings.search.engineManagedLabel': 'OpenHuman পরিচালিত', - 'settings.search.engineManagedDesc': 'ডিফল্ট xqx1x ব্যাক-এন্ড দ্বারা রুট', + 'settings.search.engineManagedDesc': + 'ডিফল্ট। OpenHuman ব্যাক-এন্ডের মাধ্যমে রুট করা হয়, বর্তমানে Exa দ্বারা চালিত: কোনো API key প্রয়োজন নেই।', 'settings.search.localManagedUnavailable': 'লোকাল ব্যবহারকারীদের জন্য OpenHuman Managed সার্চ উপলভ্য নয়। ওয়েব সার্চ চালু করতে আপনার নিজের Parallel বা Brave API key যোগ করুন।', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 69db11e60c..bda0cbd64a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1757,7 +1757,7 @@ const messages: TranslationMap = { 'Suchwerkzeuge aus dem Agenten-Kontext und der verfügbaren Tool-Liste entfernen.', 'settings.search.engineManagedLabel': 'OpenHuman Verwaltet', 'settings.search.engineManagedDesc': - 'Standard. Wird über das OpenHuman-Backend weitergeleitet – kein API-Schlüssel erforderlich.', + 'Standard. Wird über das OpenHuman-Backend geleitet, aktuell betrieben von Exa: kein API-Schlüssel erforderlich.', 'settings.search.localManagedUnavailable': 'Die von OpenHuman verwaltete Suche ist für lokale Benutzer nicht verfügbar. Füge deinen eigenen Parallel- oder Brave-API-Schlüssel hinzu, um die Websuche zu aktivieren.', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 074d30abb0..4b377d6327 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1796,7 +1796,7 @@ const en: TranslationMap = { 'Remove search tools from the agent context and available tool list.', 'settings.search.engineManagedLabel': 'OpenHuman Managed', 'settings.search.engineManagedDesc': - 'Default. Routed through the OpenHuman backend: no API key required.', + 'Default. Routed through the OpenHuman backend, currently powered by Exa: no API key required.', 'settings.search.localManagedUnavailable': 'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f7b2778665..efb8fc5e8d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1735,7 +1735,7 @@ const messages: TranslationMap = { 'Elimina las herramientas de búsqueda del contexto del agente y de la lista de herramientas disponibles.', 'settings.search.engineManagedLabel': 'OpenHuman Gestionado', 'settings.search.engineManagedDesc': - 'Predeterminado. Enrutado a través del backend OpenHuman: no se requiere la clave API.', + 'Predeterminado. Enrutado a través del backend de OpenHuman, actualmente con tecnología de Exa: no se requiere clave API.', 'settings.search.localManagedUnavailable': 'La búsqueda gestionada por OpenHuman no está disponible para usuarios locales. Añade tu propia API key de Parallel o Brave para habilitar la búsqueda web.', 'settings.search.engineParallelLabel': 'paralelo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index f047d440ac..53b870ba46 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1751,7 +1751,7 @@ const messages: TranslationMap = { "Supprimer les outils de recherche du contexte agent et de la liste d'outils disponibles.", 'settings.search.engineManagedLabel': 'OpenHuman Géré', 'settings.search.engineManagedDesc': - 'Par défaut. Routé via le backend OpenHuman: aucune clé API requise.', + 'Par défaut. Acheminé via le backend OpenHuman, actuellement propulsé par Exa: aucune clé API requise.', 'settings.search.localManagedUnavailable': 'La recherche gérée par OpenHuman n’est pas disponible pour les utilisateurs locaux. Ajoutez votre propre clé API Parallel ou Brave pour activer la recherche web.', 'settings.search.engineParallelLabel': 'Parallèle', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index f3c14c16ff..6b9aa1695e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1694,7 +1694,8 @@ const messages: TranslationMap = { 'settings.search.engineDisabledLabel': 'Disabled', 'settings.search.engineDisabledDesc': 'एजेंट संदर्भ और उपलब्ध टूल सूची से खोज टूल हटाएं।', 'settings.search.engineManagedLabel': 'OpenHuman प्रबंधित', - 'settings.search.engineManagedDesc': 'डिफ़ॉल्ट। OpenHuman backend: no API key required.', + 'settings.search.engineManagedDesc': + 'डिफ़ॉल्ट। OpenHuman बैक-एंड के माध्यम से रूट किया जाता है, वर्तमान में Exa द्वारा संचालित: किसी API key की आवश्यकता नहीं।', 'settings.search.localManagedUnavailable': 'लोकल उपयोगकर्ताओं के लिए OpenHuman Managed search उपलब्ध नहीं है। वेब सर्च चालू करने के लिए अपनी Parallel या Brave API key जोड़ें।', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ec76f52529..1af4ff7b06 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1711,7 +1711,7 @@ const messages: TranslationMap = { 'Hapus alat pencarian dari konteks agen dan daftar alat yang tersedia.', 'settings.search.engineManagedLabel': 'OpenHuman Dikelola', 'settings.search.engineManagedDesc': - 'Baku. Diarahkan melalui backend OpenHuman: tidak diperlukan kunci API.', + 'Baku. Diarahkan melalui backend OpenHuman, saat ini didukung oleh Exa: tidak diperlukan kunci API.', 'settings.search.localManagedUnavailable': 'Pencarian OpenHuman Managed tidak tersedia untuk pengguna lokal. Tambahkan API key Parallel atau Brave Anda sendiri untuk mengaktifkan pencarian web.', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index b2a8b7734e..bc4e728a9b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1735,7 +1735,7 @@ const messages: TranslationMap = { "Rimuovi gli strumenti di ricerca dal contesto dell'agente e dall'elenco degli strumenti disponibili.", 'settings.search.engineManagedLabel': 'OpenHuman Gestito', 'settings.search.engineManagedDesc': - 'Predefinito. Instradato tramite il backend OpenHuman: nessuna chiave API necessaria.', + 'Predefinito. Instradato tramite il backend di OpenHuman, attualmente basato su Exa: nessuna chiave API necessaria.', 'settings.search.localManagedUnavailable': 'La ricerca gestita da OpenHuman non è disponibile per gli utenti locali. Aggiungi la tua chiave API Parallel o Brave per abilitare la ricerca web.', 'settings.search.engineParallelLabel': 'parallelo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 16c9425ca4..e1a64c796e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1686,7 +1686,7 @@ const messages: TranslationMap = { '에이전트 컨텍스트 및 사용 가능한 도구 목록에서 검색 도구를 제거합니다.', 'settings.search.engineManagedLabel': 'OpenHuman 관리됨', 'settings.search.engineManagedDesc': - '기본값입니다. OpenHuman 백엔드를 통해 라우팅되며 API 키가 필요하지 않습니다.', + '기본값입니다. OpenHuman 백엔드를 통해 라우팅되며 현재 Exa로 구동됩니다. API 키가 필요하지 않습니다.', 'settings.search.localManagedUnavailable': '로컬 사용자는 OpenHuman 관리 검색을 사용할 수 없습니다. 웹 검색을 활성화하려면 자체 Parallel, Brave 또는 Querit API 키를 추가하세요.', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index b599a2c319..09ce73c1de 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1727,7 +1727,8 @@ const messages: TranslationMap = { 'settings.search.engineDisabledDesc': 'Usuń narzędzia wyszukiwania z kontekstu agenta i listy dostępnych narzędzi.', 'settings.search.engineManagedLabel': 'OpenHuman zarządzane', - 'settings.search.engineManagedDesc': 'Domyślnie. Przez backend OpenHuman: bez klucza API.', + 'settings.search.engineManagedDesc': + 'Domyślnie. Kierowane przez backend OpenHuman, obecnie oparte na Exa: bez klucza API.', 'settings.search.localManagedUnavailable': 'Wyszukiwarka zarządzana przez OpenHuman jest niedostępna dla użytkowników lokalnych. Dodaj własny klucz API Parallel lub Brave, aby włączyć wyszukiwanie w sieci.', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 76c1f52696..bf8826b729 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1733,7 +1733,7 @@ const messages: TranslationMap = { 'Remover ferramentas de busca do contexto do agente e da lista de ferramentas disponíveis.', 'settings.search.engineManagedLabel': 'OpenHuman Gerenciado', 'settings.search.engineManagedDesc': - 'Padrão. Roteado através do backend OpenHuman: nenhuma chave API é necessária.', + 'Padrão. Roteado através do backend do OpenHuman, atualmente com tecnologia Exa: nenhuma chave API é necessária.', 'settings.search.localManagedUnavailable': 'A busca gerenciada pela OpenHuman não está disponível para usuários locais. Adicione sua própria chave de API do Parallel ou Brave para habilitar a busca na web.', 'settings.search.engineParallelLabel': 'Paralelo', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 38434d9ae5..1f457df09b 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1716,7 +1716,7 @@ const messages: TranslationMap = { 'Удалить инструменты поиска из контекста агента и списка доступных инструментов.', 'settings.search.engineManagedLabel': 'OpenHuman Управляемый', 'settings.search.engineManagedDesc': - 'По умолчанию. Маршрутизируется через серверную часть OpenHuman: ключ API не ​​требуется.', + 'По умолчанию. Маршрутизируется через серверную часть OpenHuman, сейчас на базе Exa: ключ API не требуется.', 'settings.search.localManagedUnavailable': 'Поиск OpenHuman Managed недоступен для локальных пользователей. Добавьте свой ключ API Parallel или Brave, чтобы включить веб-поиск.', 'settings.search.engineParallelLabel': 'Параллельно', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index f961f5d662..148d13cc21 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1607,7 +1607,8 @@ const messages: TranslationMap = { 'settings.search.engineDisabledLabel': 'Disabled', 'settings.search.engineDisabledDesc': '从智能体上下文和可用工具列表中移除搜索工具。', 'settings.search.engineManagedLabel': 'OpenHuman 托管', - 'settings.search.engineManagedDesc': '默认选项。通过 OpenHuman 后端路由,无需 API 密钥。', + 'settings.search.engineManagedDesc': + '默认选项。通过 OpenHuman 后端路由,当前由 Exa 提供支持,无需 API 密钥。', 'settings.search.localManagedUnavailable': '本地用户无法使用 OpenHuman 托管搜索。请添加你自己的 Parallel、Brave 或 Querit API 密钥以启用网页搜索。', 'settings.search.engineParallelLabel': 'Parallel', diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index 7566645072..384d618dad 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -6,6 +6,7 @@ import { buildProcessingBlocks, categorizeTool, extractAgentSources, + extractSearchProvider, formatTimelineEntry, formatToolName, isKnownClientTool, @@ -167,6 +168,44 @@ describe('formatTimelineEntry', () => { ).toEqual({ title: 'Searching: rust async trait' }); }); + it('attributes a completed web_search to the resolved provider', () => { + expect( + formatTimelineEntry( + entry({ + name: 'web_search', + status: 'success', + argsBuffer: JSON.stringify({ query: 'rust async trait' }), + result: 'Search results for: rust async trait (via Exa)\n1. Some title\n https://x.dev', + }) + ) + ).toEqual({ title: 'Searched with Exa', detail: 'rust async trait' }); + }); + + it('reflects a different provider from the result (attribution is dynamic)', () => { + expect( + formatTimelineEntry( + entry({ + name: 'web_search', + status: 'success', + argsBuffer: JSON.stringify({ query: 'weather' }), + result: 'Search results for: weather (via Brave)\n1. Forecast', + }) + ) + ).toEqual({ title: 'Searched with Brave', detail: 'weather' }); + }); + + it('keeps the running label when no result is present yet', () => { + expect( + formatTimelineEntry( + entry({ + name: 'web_search', + status: 'running', + argsBuffer: JSON.stringify({ query: 'rust async trait' }), + }) + ) + ).toEqual({ title: 'Searching: rust async trait' }); + }); + it('formats file_read with shortened path', () => { expect( formatTimelineEntry( @@ -233,6 +272,34 @@ describe('formatTimelineEntry', () => { }); }); +describe('extractSearchProvider', () => { + it('reads the provider from a `(via …)` marker', () => { + expect(extractSearchProvider('Search results for: q (via Exa)\n1. foo')).toBe('Exa'); + expect(extractSearchProvider('Search results for: q (via Brave)')).toBe('Brave'); + expect(extractSearchProvider('# Search results — `q` (via Querit)')).toBe('Querit'); + }); + + it('returns undefined when there is no marker or no result', () => { + expect(extractSearchProvider(undefined)).toBeUndefined(); + expect(extractSearchProvider('')).toBeUndefined(); + expect(extractSearchProvider('No results found for: q')).toBeUndefined(); + }); + + it('trims surrounding whitespace in the provider name', () => { + expect(extractSearchProvider('foo (via Exa )')).toBe('Exa'); + }); + + it('ignores a `(via …)` string that appears only in a result excerpt', () => { + expect( + extractSearchProvider('Search results for: q\n1. Title\n Booked (via SomeAirline) today.') + ).toBeUndefined(); + }); + + it('ignores an implausibly long marker', () => { + expect(extractSearchProvider(`Search results for: q (via ${'x'.repeat(64)})`)).toBeUndefined(); + }); +}); + describe('formatToolName', () => { it('returns human-readable names for known tools', () => { expect(formatToolName('shell')).toBe('Running command'); diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index dc5c8b794e..8a45eed55f 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -313,7 +313,9 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string; } // ── Tool-specific formatting with args-derived detail ────────────── - const toolDetail = formatToolDetail(entry.name, parsedArgs); + // Pass the completed result text so args-aware formatters can surface + // details only known post-execution (e.g. the resolved search provider). + const toolDetail = formatToolDetail(entry.name, parsedArgs, entry.result); if (toolDetail) { return { title: toolDetail.title, detail: toolDetail.detail ?? entry.detail }; } @@ -461,9 +463,33 @@ function shortenPath(filePath: string): string { return `…/${parts.slice(-2).join('/')}`; } +/** Upper bound on a provider label, so a malformed marker can't blow up a row. */ +const MAX_SEARCH_PROVIDER_LENGTH = 32; + +/** + * Extract the resolved search provider from a completed web-search result. + * Every search engine tags its output with a `(via )` marker on the + * heading line (managed resolves to "Exa" by default, or to whatever the + * backend reports; BYOK engines tag "Brave"/"Querit"/"Seltz"). Reading it back + * keeps the timeline attribution dynamic: it is driven by what actually ran, + * never by a hardcoded provider name (#5136). + * + * Only the first line is inspected, so a `(via …)` string inside a result + * excerpt cannot be mistaken for the provider. Returns `undefined` while the + * call is still running (no result yet) or if no marker is present. + */ +export function extractSearchProvider(result: string | undefined): string | undefined { + if (!result) return undefined; + const headingLine = result.split('\n', 1)[0]; + const provider = headingLine?.match(/\(via ([^)]+)\)/i)?.[1]?.trim(); + if (!provider || provider.length > MAX_SEARCH_PROVIDER_LENGTH) return undefined; + return provider; +} + function formatToolDetail( name: string, - args: ParsedToolArgs | null + args: ParsedToolArgs | null, + result?: string ): { title: string; detail?: string } | null { switch (name) { case 'shell': @@ -486,6 +512,16 @@ function formatToolDetail( case 'web_search': { const query = args?.query?.trim(); + // Once the call completes, attribute the search to the provider that + // actually served it ("Searched with Exa"); the query moves to the + // detail line so it stays visible. + const provider = extractSearchProvider(result); + if (provider) { + return { + title: `Searched with ${provider}`, + detail: query ? truncateDetail(query) : undefined, + }; + } return { title: query ? `Searching: ${truncateDetail(query)}` : 'Searching the web' }; } diff --git a/src/openhuman/search/tools/mod.rs b/src/openhuman/search/tools/mod.rs index a47912dba1..911cfc8f93 100644 --- a/src/openhuman/search/tools/mod.rs +++ b/src/openhuman/search/tools/mod.rs @@ -21,3 +21,6 @@ pub use searxng::{ pub use seltz::SeltzSearchTool; pub use tinyfish::{TinyFishAgentRunTool, TinyFishFetchTool, TinyFishSearchTool}; pub use web_search::WebSearchTool; +// Crate-internal: the `tools.web_search` RPC reuses the same provider +// resolution so both managed-search surfaces attribute a call identically. +pub(crate) use web_search::resolve_managed_provider; diff --git a/src/openhuman/search/tools/parallel.rs b/src/openhuman/search/tools/parallel.rs index 44a7dd6555..3ac0b05fdb 100644 --- a/src/openhuman/search/tools/parallel.rs +++ b/src/openhuman/search/tools/parallel.rs @@ -43,6 +43,18 @@ pub struct SearchResponse { pub results: Vec, #[serde(rename = "costUsd")] pub cost_usd: f64, + /// Upstream provider the managed backend resolved this search to + /// (e.g. "Exa"). Optional: older backends omit it, so this stays + /// `None` and callers fall back to the managed default. Surfaced to + /// the UI as the search attribution ("Searched with Exa", #5136). + /// Aliased so a rename on the backend side keeps deserializing. + #[serde( + default, + alias = "resolvedProvider", + alias = "searchProvider", + skip_serializing_if = "Option::is_none" + )] + pub provider: Option, } #[derive(Debug, Deserialize, serde::Serialize)] diff --git a/src/openhuman/search/tools/parallel_tests.rs b/src/openhuman/search/tools/parallel_tests.rs index eb70759f12..f7fac1adf6 100644 --- a/src/openhuman/search/tools/parallel_tests.rs +++ b/src/openhuman/search/tools/parallel_tests.rs @@ -97,6 +97,35 @@ fn search_response_deserializes() { let resp: SearchResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.results.len(), 1); assert_eq!(resp.results[0].title, "Example"); + // Older backends omit the resolved provider entirely (#5136). + assert_eq!(resp.provider, None); +} + +#[test] +fn search_response_reads_resolved_provider() { + // The backend names the provider it routed the managed search to, so the + // UI attribution ("Searched with Exa") is never hardcoded (#5136). + let json = r#"{ + "searchId": "s123", + "results": [], + "costUsd": 0.01, + "provider": "Exa" + }"#; + let resp: SearchResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.provider.as_deref(), Some("Exa")); +} + +#[test] +fn search_response_reads_provider_aliases() { + // Tolerate the backend naming the field differently so a rename upstream + // does not silently drop attribution. + for field in ["resolvedProvider", "searchProvider"] { + let json = format!( + r#"{{ "searchId": "s123", "results": [], "costUsd": 0.01, "{field}": "Brave" }}"# + ); + let resp: SearchResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(resp.provider.as_deref(), Some("Brave"), "field {field}"); + } } // ── ParallelExtractTool ───────────────────────────────────────── diff --git a/src/openhuman/search/tools/web_search.rs b/src/openhuman/search/tools/web_search.rs index 5e2f317985..9fd8d17ff6 100644 --- a/src/openhuman/search/tools/web_search.rs +++ b/src/openhuman/search/tools/web_search.rs @@ -6,6 +6,26 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::sync::Arc; +/// Provider the OpenHuman Managed search path resolves to today. Exa powers +/// the overwhelming majority of managed search traffic, so it is the labelled +/// default whenever the backend response does not name a provider. This is a +/// *fallback* label, not a hardcoded one: [`resolve_managed_provider`] prefers +/// the provider the backend actually reports, so a future routing change flows +/// through to the UI attribution ("Searched with …", #5136) with no code edit. +const MANAGED_DEFAULT_PROVIDER: &str = "Exa"; + +/// Resolve the provider name to attribute a managed search to. Uses the +/// backend-reported provider when present and non-empty, otherwise falls back +/// to [`MANAGED_DEFAULT_PROVIDER`]. Shared with the `tools.web_search` RPC so +/// both managed-search surfaces attribute a call the same way. +pub(crate) fn resolve_managed_provider(resp: &SearchResponse) -> &str { + resp.provider + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + .unwrap_or(MANAGED_DEFAULT_PROVIDER) +} + /// Web search tool backed by the server-side Parallel integration proxy. pub struct WebSearchTool { client: Option>, @@ -37,15 +57,13 @@ impl WebSearchTool { &self, results: &[SearchResultItem], query: &str, + provider: &str, ) -> anyhow::Result { if results.is_empty() { return Ok(format!("No results found for: {}", query)); } - let mut lines = vec![format!( - "Search results for: {} (via backend Parallel)", - query - )]; + let mut lines = vec![format!("Search results for: {} (via {})", query, provider)]; for (i, result) in results.iter().take(self.max_results).enumerate() { let title = if result.title.trim().is_empty() { @@ -77,11 +95,16 @@ impl WebSearchTool { Ok(lines.join("\n")) } - fn render_results_markdown(&self, results: &[SearchResultItem], query: &str) -> String { + fn render_results_markdown( + &self, + results: &[SearchResultItem], + query: &str, + provider: &str, + ) -> String { if results.is_empty() { return format!("_No results for `{query}`._"); } - let mut out = format!("# Search results — `{query}`\n"); + let mut out = format!("# Search results — `{query}` (via {provider})\n"); for r in results.iter().take(self.max_results) { let title = if r.title.trim().is_empty() { "Untitled" @@ -205,9 +228,17 @@ impl Tool for WebSearchTool { .post::("/agent-integrations/parallel/search", &body) .await?; - let mut result = ToolResult::success(self.parse_parallel_results(&resp.results, &query)?); + // Attribute the search to the provider the managed backend resolved to + // (Exa by default). The provider name is echoed in the result text so + // the UI can surface "Searched with " without a hardcode. + let provider = resolve_managed_provider(&resp); + tracing::debug!(provider, "[web_search] managed search resolved provider"); + + let mut result = + ToolResult::success(self.parse_parallel_results(&resp.results, &query, provider)?); if options.prefer_markdown { - result.markdown_formatted = Some(self.render_results_markdown(&resp.results, &query)); + result.markdown_formatted = + Some(self.render_results_markdown(&resp.results, &query, provider)); } Ok(result) } @@ -252,10 +283,62 @@ mod tests { #[test] fn test_parse_parallel_results_empty() { - let result = tool().parse_parallel_results(&[], "test query").unwrap(); + let result = tool() + .parse_parallel_results(&[], "test query", "Exa") + .unwrap(); assert!(result.contains("No results found")); } + fn response_with_provider(provider: Option<&str>) -> SearchResponse { + SearchResponse { + search_id: "search-1".into(), + results: vec![], + cost_usd: 0.0, + provider: provider.map(str::to_string), + } + } + + #[test] + fn test_resolve_managed_provider_defaults_to_exa() { + // Backend omits the provider → fall back to the managed default. + assert_eq!( + resolve_managed_provider(&response_with_provider(None)), + "Exa" + ); + // Blank / whitespace-only provider is treated as absent. + assert_eq!( + resolve_managed_provider(&response_with_provider(Some(" "))), + "Exa" + ); + } + + #[test] + fn test_resolve_managed_provider_uses_backend_value() { + // A provider named by the backend wins over the default and is trimmed, + // so a future routing change surfaces without a code edit. + assert_eq!( + resolve_managed_provider(&response_with_provider(Some(" Brave "))), + "Brave" + ); + } + + #[test] + fn test_parse_parallel_results_attribution_is_dynamic() { + let results = vec![SearchResultItem { + title: "T".into(), + url: "https://t.com".into(), + publish_date: None, + excerpts: vec![], + }]; + let exa = tool().parse_parallel_results(&results, "q", "Exa").unwrap(); + assert!(exa.contains("(via Exa)")); + assert!(!exa.contains("via backend Parallel")); + let brave = tool() + .parse_parallel_results(&results, "q", "Brave") + .unwrap(); + assert!(brave.contains("(via Brave)")); + } + #[test] fn test_parse_parallel_results_with_data() { let results = vec![ @@ -274,9 +357,9 @@ mod tests { ]; let result = tool() - .parse_parallel_results(&results, "parallel ai") + .parse_parallel_results(&results, "parallel ai", "Exa") .unwrap(); - assert!(result.contains("via backend Parallel")); + assert!(result.contains("(via Exa)")); assert!(result.contains("Parallel AI Docs")); assert!(result.contains("https://docs.parallel.ai/home")); assert!(result.contains("Parallel Search Quickstart")); @@ -306,7 +389,7 @@ mod tests { excerpts: vec![], }, ]; - let result = tool.parse_parallel_results(&results, "q").unwrap(); + let result = tool.parse_parallel_results(&results, "q", "Exa").unwrap(); assert!(result.contains("Result 1")); assert!(result.contains("Result 2")); assert!(!result.contains("Result 3")); @@ -321,7 +404,7 @@ mod tests { publish_date: None, excerpts: vec![long_excerpt], }]; - let result = tool().parse_parallel_results(&results, "q").unwrap(); + let result = tool().parse_parallel_results(&results, "q", "Exa").unwrap(); assert!(result.contains("...")); let excerpt_line = result.lines().find(|l| l.trim().starts_with('x')).unwrap(); assert!(excerpt_line.trim().len() <= 503); @@ -336,7 +419,7 @@ mod tests { publish_date: None, excerpts: vec![excerpt], }]; - let result = tool().parse_parallel_results(&results, "q").unwrap(); + let result = tool().parse_parallel_results(&results, "q", "Exa").unwrap(); assert!(result.contains("...")); // Should have 500 crabs + "..." let excerpt_line = result.lines().find(|l| l.contains('🦀')).unwrap(); @@ -420,6 +503,46 @@ mod tests { assert!(result .output() .contains("Rendered excerpt from backend search.")); + // Backend omitted a provider → attribution falls back to the managed + // default (Exa) rather than the legacy "backend Parallel" wording. + assert!(result.output().contains("(via Exa)")); + assert!(!result.output().contains("backend Parallel")); + } + + #[tokio::test] + async fn test_execute_attributes_backend_reported_provider() { + // When the backend names the resolved provider, the tool result echoes + // it verbatim — the attribution is dynamic, not a hardcoded "Exa". + let app = Router::new().route( + "/agent-integrations/parallel/search", + post(|Json(_body): Json| async move { + Json(json!({ + "success": true, + "data": { + "searchId": "search-xyz", + "provider": "Brave", + "results": [ + { + "url": "https://example.com/r", + "title": "Result", + "excerpts": ["Excerpt."] + } + ], + "costUsd": 0.01 + } + })) + }), + ); + + let base_url = start_mock_backend(app).await; + let client = Arc::new(IntegrationClient::new(base_url, "test-token".into())); + let result = WebSearchTool::new(Some(client), 5, 15) + .execute(json!({"query": "anything"})) + .await + .expect("execute() should render backend results"); + + assert!(result.output().contains("(via Brave)")); + assert!(!result.output().contains("(via Exa)")); } #[tokio::test] diff --git a/src/openhuman/tools/schemas.rs b/src/openhuman/tools/schemas.rs index ca41057ebc..ddeffae8d5 100644 --- a/src/openhuman/tools/schemas.rs +++ b/src/openhuman/tools/schemas.rs @@ -137,12 +137,22 @@ pub fn tools_schemas(function: &str) -> ControllerSchema { required: false, }, ], - outputs: vec![FieldSchema { - name: "results", - ty: TypeSchema::Array(Box::new(TypeSchema::Json)), - comment: "Each item: {url, title, publish_date?, excerpts[]}.", - required: true, - }], + outputs: vec![ + FieldSchema { + name: "results", + ty: TypeSchema::Array(Box::new(TypeSchema::Json)), + comment: "Each item: {url, title, publish_date?, excerpts[]}.", + required: true, + }, + FieldSchema { + name: "provider", + ty: TypeSchema::String, + comment: "Upstream provider the managed backend resolved this \ + search to, for attribution. Reported by the backend when \ + it names one; falls back to the managed default.", + required: true, + }, + ], }, "tools_seltz_search" => ControllerSchema { namespace: "tools", @@ -531,9 +541,13 @@ fn handle_web_search(params: Map) -> ControllerFuture { .map_err(|e| format!("parallel search failed: {e:#}"))?; let count = resp.results.len(); - let payload = json!({ "results": resp.results }); + // Attribute the search to the provider the managed backend resolved to, + // so this RPC surface labels a call the same way the agent-facing + // `web_search` tool does (#5136). + let provider = crate::openhuman::search::tools::resolve_managed_provider(&resp); + let payload = json!({ "results": resp.results, "provider": provider }); let log = vec![format!( - "tools.web_search: query=\"{query}\" results={count}" + "tools.web_search: query=\"{query}\" results={count} provider={provider}" )]; RpcOutcome::new(payload, log).into_cli_compatible_json() }) @@ -975,6 +989,9 @@ mod tests { assert_eq!(s.namespace, "tools"); assert_eq!(s.function, "web_search"); assert!(s.inputs.iter().any(|f| f.name == "query" && f.required)); + // The resolved search provider is part of the documented output so + // callers can attribute a managed search (#5136). + assert!(s.outputs.iter().any(|f| f.name == "provider")); } #[test] From f23ccaa8f161e58e2bcc710e0dc4b4ff18064613 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 27 Jul 2026 13:45:14 +0530 Subject: [PATCH 2/3] fix(search): make provider attribution reach the real production path Review found three ways the attribution never actually surfaced, plus a parsing bug: - The canonical search slot is registered and streamed as `web_search_tool` (src/openhuman/search/registry.rs); `web_search` is only the settings family id and never reaches a timeline row. The formatter matched the latter, so every real search fell through to the humanized "Web Search Tool" and the new attribution never ran. Handle both names, and add `web_search_tool` to the client-known set so the client label wins over the server's default. - Production renders tool results as markdown (`output_for_llm(true)`), so the markdown heading is what the timeline parses. Brave emitted `(Brave)` and Seltz/Querit emitted no marker at all, so BYOK engines could not be attributed. All three now emit the shared `(via )` marker their plain-text renderers already carried. - Empty-result renderings carried no marker, so a completed search that found nothing kept rendering as still in progress. Both managed forms and the three BYOK markdown forms now attribute an empty result too. - The heading echoes the user's query, so a query containing "(via ...)" won over the real trailing marker ("login (via OAuth) (via Exa)" resolved to OAuth). Anchor the match to the end of the heading line. Regression tests cover each: the streamed tool name running and completed, markdown and empty-result attribution, the decoy-marker query, and the per-engine markdown markers. --- .../__tests__/toolTimelineFormatting.test.ts | 57 +++++++++++++++++++ app/src/utils/toolTimelineFormatting.ts | 22 +++++-- src/openhuman/search/tools/brave.rs | 31 +++++++++- src/openhuman/search/tools/querit.rs | 31 +++++++++- src/openhuman/search/tools/seltz.rs | 13 ++++- src/openhuman/search/tools/web_search.rs | 24 +++++++- 6 files changed, 165 insertions(+), 13 deletions(-) diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index 384d618dad..7b5e3592ba 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -206,6 +206,49 @@ describe('formatTimelineEntry', () => { ).toEqual({ title: 'Searching: rust async trait' }); }); + // `web_search_tool` is the name the core actually registers and streams for + // the canonical search slot; `web_search` is only the settings-family id. + // Without this the real production row fell through to "Web Search Tool". + it('formats the streamed web_search_tool name while running', () => { + expect( + formatTimelineEntry( + entry({ + name: 'web_search_tool', + status: 'running', + argsBuffer: JSON.stringify({ query: 'rust async trait' }), + }) + ) + ).toEqual({ title: 'Searching: rust async trait' }); + }); + + it('attributes a completed web_search_tool from the markdown result', () => { + // Production renders tool results as markdown (`output_for_llm(true)`), + // so the marker arrives on the markdown heading line. + expect( + formatTimelineEntry( + entry({ + name: 'web_search_tool', + status: 'success', + argsBuffer: JSON.stringify({ query: 'rust async trait' }), + result: '# Search results — `rust async trait` (via Exa)\n\n## [T](https://x.dev)', + }) + ) + ).toEqual({ title: 'Searched with Exa', detail: 'rust async trait' }); + }); + + it('attributes a completed web_search_tool that returned no results', () => { + expect( + formatTimelineEntry( + entry({ + name: 'web_search_tool', + status: 'success', + argsBuffer: JSON.stringify({ query: 'zzzz' }), + result: '_No results for `zzzz`_ (via Exa)', + }) + ) + ).toEqual({ title: 'Searched with Exa', detail: 'zzzz' }); + }); + it('formats file_read with shortened path', () => { expect( formatTimelineEntry( @@ -298,6 +341,17 @@ describe('extractSearchProvider', () => { it('ignores an implausibly long marker', () => { expect(extractSearchProvider(`Search results for: q (via ${'x'.repeat(64)})`)).toBeUndefined(); }); + + it('reads the trailing marker when the echoed query also contains one', () => { + // The heading echoes the user's query, so a query like `login (via OAuth)` + // puts a decoy marker ahead of the real one. Only the trailing marker counts. + expect(extractSearchProvider('Search results for: login (via OAuth) (via Exa)')).toBe('Exa'); + }); + + it('reads the marker from an empty-result heading', () => { + expect(extractSearchProvider('No results found for: q (via Exa)')).toBe('Exa'); + expect(extractSearchProvider('_No results for `q`_ (via Brave)')).toBe('Brave'); + }); }); describe('formatToolName', () => { @@ -344,6 +398,9 @@ describe('isKnownClientTool', () => { expect(isKnownClientTool('shell')).toBe(true); expect(isKnownClientTool('subagent:researcher')).toBe(true); expect(isKnownClientTool('delegate_to_integrations_agent')).toBe(true); + // The streamed search-slot name, so the client label wins over the + // server's humanized "Web Search Tool". + expect(isKnownClientTool('web_search_tool')).toBe(true); }); it('does not recognize dynamic Composio/MCP actions (server labels them)', () => { diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 8a45eed55f..6a99be54ca 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -23,6 +23,12 @@ const TOOL_DISPLAY_NAMES: Record = { http_request: 'Fetching', curl: 'Fetching', web_search: 'Searching the web', + // The name the core actually registers and streams for the canonical search + // slot, whichever engine owns it (`src/openhuman/search/registry.rs`). + // `web_search` above is the settings-family id, which never reaches a + // timeline row — without this entry a real search rendered as the + // humanized "Web Search Tool". + web_search_tool: 'Searching the web', gitbooks_search: 'Searching docs', file_read: 'Reading file', file_write: 'Writing file', @@ -102,6 +108,7 @@ const CLIENT_KNOWN_TOOLS = new Set([ 'http_request', 'curl', 'web_search', + 'web_search_tool', 'gitbooks_search', 'file_read', 'file_write', @@ -474,14 +481,16 @@ const MAX_SEARCH_PROVIDER_LENGTH = 32; * keeps the timeline attribution dynamic: it is driven by what actually ran, * never by a hardcoded provider name (#5136). * - * Only the first line is inspected, so a `(via …)` string inside a result - * excerpt cannot be mistaken for the provider. Returns `undefined` while the - * call is still running (no result yet) or if no marker is present. + * Only the first line is inspected, and only its *trailing* marker, so neither + * a `(via …)` string inside a result excerpt nor one inside the echoed query + * (`Search results for: login (via OAuth) (via Exa)`) can be mistaken for the + * provider. Returns `undefined` while the call is still running (no result + * yet) or if no marker is present. */ export function extractSearchProvider(result: string | undefined): string | undefined { if (!result) return undefined; const headingLine = result.split('\n', 1)[0]; - const provider = headingLine?.match(/\(via ([^)]+)\)/i)?.[1]?.trim(); + const provider = headingLine?.match(/\(via ([^)]+)\)\s*$/i)?.[1]?.trim(); if (!provider || provider.length > MAX_SEARCH_PROVIDER_LENGTH) return undefined; return provider; } @@ -510,7 +519,10 @@ function formatToolDetail( }; } - case 'web_search': { + // `web_search_tool` is the name the core streams; `web_search` is kept for + // the settings-family id and older persisted rows. + case 'web_search': + case 'web_search_tool': { const query = args?.query?.trim(); // Once the call completes, attribute the search to the provider that // actually served it ("Searched with Exa"); the query moves to the diff --git a/src/openhuman/search/tools/brave.rs b/src/openhuman/search/tools/brave.rs index f1c1e34291..a5e71cb7d4 100644 --- a/src/openhuman/search/tools/brave.rs +++ b/src/openhuman/search/tools/brave.rs @@ -269,9 +269,13 @@ fn render_web_plain(results: &[WebResult], query: &str, max: usize) -> String { fn render_web_markdown(results: &[WebResult], query: &str, max: usize) -> String { if results.is_empty() { - return format!("_No results for `{query}`._"); + return format!("_No results for `{query}`_ (via Brave)"); } - let mut out = format!("# Search results — `{query}` (Brave)\n"); + // `(via Brave)` — the shared attribution marker every engine emits, which + // the tool timeline reads back to label the row (#5136). The markdown + // renderer is what production shows (`output_for_llm(true)`), so it has to + // carry the same marker the plain-text renderer already did. + let mut out = format!("# Search results — `{query}` (via Brave)\n"); for r in results.iter().take(max) { let title = if r.title.trim().is_empty() { "Untitled" @@ -668,6 +672,29 @@ mod tests { assert_eq!(t.name(), "web_search_tool"); } + #[test] + fn web_markdown_carries_provider_marker() { + // The markdown renderer is what production shows + // (`output_for_llm(true)`), so its heading must end with the shared + // `(via )` marker the plain-text renderer already emits — + // that is what the tool timeline reads back to label the row (#5136). + // It previously read `(Brave)`, which no marker parser matched. + let results = vec![WebResult { + title: "Example".into(), + url: "https://example.com".into(), + description: "Desc.".into(), + age: None, + }]; + let out = render_web_markdown(&results, "test", 5); + assert!(out.lines().next().unwrap().ends_with("(via Brave)")); + assert!(out.contains("[Example](https://example.com)")); + + // A completed empty search is attributed too, so the timeline does not + // keep showing it as in-progress. + let empty = render_web_markdown(&[], "test", 5); + assert!(empty.trim_end().ends_with("(via Brave)")); + } + #[test] fn news_tool_name() { let t = BraveNewsSearchTool::new(Some("k".into()), 5, 5); diff --git a/src/openhuman/search/tools/querit.rs b/src/openhuman/search/tools/querit.rs index 2fdecf3a6e..9532a00e8a 100644 --- a/src/openhuman/search/tools/querit.rs +++ b/src/openhuman/search/tools/querit.rs @@ -162,10 +162,13 @@ impl QueritSearchTool { fn render_results_markdown(&self, results: &[QueritResultItem], query: &str) -> String { if results.is_empty() { - return format!("_No results for `{query}`._"); + return format!("_No results for `{query}`_ (via Querit)"); } - let mut out = format!("# Search results -- `{query}`\n"); + // Carry the shared `(via )` attribution marker the plain-text + // renderer already emits, so the tool timeline can label the row + // (#5136). Production shows the markdown rendering. + let mut out = format!("# Search results -- `{query}` (via Querit)\n"); for item in results.iter().take(self.max_results) { let title = item .title @@ -567,6 +570,30 @@ mod tests { assert!(result.contains("First result snippet.")); } + #[test] + fn test_render_markdown_carries_provider_marker() { + // The markdown renderer is what production shows + // (`output_for_llm(true)`), so it must carry the same shared + // `(via )` marker the plain-text renderer emits — that is + // what the tool timeline reads back to label the row (#5136). + let results = vec![QueritResultItem { + url: "https://example.com/a".into(), + page_age: None, + title: Some("First Result".into()), + snippet: Some("Snippet.".into()), + site_name: None, + site_icon: None, + sentence: vec![], + }]; + let result = tool().render_results_markdown(&results, "test"); + assert!(result.lines().next().unwrap().ends_with("(via Querit)")); + + // A completed empty search is attributed too, so the timeline does not + // keep showing it as in-progress. + let empty = tool().render_results_markdown(&[], "test"); + assert!(empty.trim_end().ends_with("(via Querit)")); + } + #[test] fn test_build_filters_maps_supported_fields() { let filters = QueritSearchTool::build_filters(&json!({ diff --git a/src/openhuman/search/tools/seltz.rs b/src/openhuman/search/tools/seltz.rs index a9e4b4a607..3bbd5a93b1 100644 --- a/src/openhuman/search/tools/seltz.rs +++ b/src/openhuman/search/tools/seltz.rs @@ -118,10 +118,13 @@ impl SeltzSearchTool { fn render_results_markdown(&self, docs: &[SeltzDocument], query: &str) -> String { if docs.is_empty() { - return format!("_No results for `{query}`._"); + return format!("_No results for `{query}`_ (via Seltz)"); } - let mut out = format!("# Search results — `{query}`\n"); + // Carry the shared `(via )` attribution marker the plain-text + // renderer already emits, so the tool timeline can label the row + // (#5136). Production shows the markdown rendering. + let mut out = format!("# Search results — `{query}` (via Seltz)\n"); for doc in docs.iter().take(self.max_results) { let title = doc .title @@ -423,6 +426,9 @@ mod tests { fn test_render_markdown_empty() { let result = tool().render_results_markdown(&[], "test"); assert!(result.contains("No results")); + // A completed empty search still carries attribution, so the timeline + // labels the row instead of leaving it as in-progress (#5136). + assert!(result.trim_end().ends_with("(via Seltz)")); } #[test] @@ -434,6 +440,9 @@ mod tests { published_date: Some("2026-01-01".into()), }]; let result = tool().render_results_markdown(&docs, "test"); + // The markdown renderer is what production shows, so it must carry the + // shared `(via )` marker on its heading line (#5136). + assert!(result.lines().next().unwrap().ends_with("(via Seltz)")); assert!(result.contains("[Example](https://example.com)")); assert!(result.contains("Published: 2026-01-01")); assert!(result.contains("> Some content.")); diff --git a/src/openhuman/search/tools/web_search.rs b/src/openhuman/search/tools/web_search.rs index 9fd8d17ff6..999737489b 100644 --- a/src/openhuman/search/tools/web_search.rs +++ b/src/openhuman/search/tools/web_search.rs @@ -60,7 +60,12 @@ impl WebSearchTool { provider: &str, ) -> anyhow::Result { if results.is_empty() { - return Ok(format!("No results found for: {}", query)); + // Still attribute an empty search: the call completed, so the + // timeline must not keep showing it as in-progress (#5136). + return Ok(format!( + "No results found for: {} (via {})", + query, provider + )); } let mut lines = vec![format!("Search results for: {} (via {})", query, provider)]; @@ -102,7 +107,7 @@ impl WebSearchTool { provider: &str, ) -> String { if results.is_empty() { - return format!("_No results for `{query}`._"); + return format!("_No results for `{query}`_ (via {provider})"); } let mut out = format!("# Search results — `{query}` (via {provider})\n"); for r in results.iter().take(self.max_results) { @@ -287,8 +292,23 @@ mod tests { .parse_parallel_results(&[], "test query", "Exa") .unwrap(); assert!(result.contains("No results found")); + // A completed empty search is still attributed, so the timeline labels + // the row instead of leaving it as in-progress (#5136). + assert!(result.trim_end().ends_with("(via Exa)")); } + #[test] + fn test_render_markdown_empty_carries_provider() { + // The markdown rendering is what production shows, so its empty form + // needs the marker too — and it must sit at the end of the line, where + // the timeline parser looks for it. + let result = tool().render_results_markdown(&[], "test query", "Exa"); + assert!(result.contains("No results")); + assert!(result.trim_end().ends_with("(via Exa)")); + } + + /// A minimal `SearchResponse` carrying only the provider under test, so + /// the resolution cases read without result/cost noise. fn response_with_provider(provider: Option<&str>) -> SearchResponse { SearchResponse { search_id: "search-1".into(), From 634ea962f6947c50cfb4b7eda906db908093eae8 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 27 Jul 2026 13:48:19 +0530 Subject: [PATCH 3/3] fix(tools): stop logging the raw web-search query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tools.web_search RPC echoed the user's query text into its log line. A search query is user-authored and can carry PII or credentials, so log its length instead — matching the sibling seltz/querit handlers in the same file, which already log query_len only. --- src/openhuman/tools/schemas.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/openhuman/tools/schemas.rs b/src/openhuman/tools/schemas.rs index ddeffae8d5..65022ebb0a 100644 --- a/src/openhuman/tools/schemas.rs +++ b/src/openhuman/tools/schemas.rs @@ -546,8 +546,12 @@ fn handle_web_search(params: Map) -> ControllerFuture { // `web_search` tool does (#5136). let provider = crate::openhuman::search::tools::resolve_managed_provider(&resp); let payload = json!({ "results": resp.results, "provider": provider }); + // Log the query's length, never its text: a search query is + // user-authored and can carry PII or credentials. This matches the + // sibling seltz/querit handlers below, which already log `query_len`. let log = vec![format!( - "tools.web_search: query=\"{query}\" results={count} provider={provider}" + "tools.web_search: query_len={} results={count} provider={provider}", + query.chars().count() )]; RpcOutcome::new(payload, log).into_cli_compatible_json() })