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..7b5e3592ba 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,87 @@ 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' }); + }); + + // `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( @@ -233,6 +315,45 @@ 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(); + }); + + 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', () => { it('returns human-readable names for known tools', () => { expect(formatToolName('shell')).toBe('Running command'); @@ -277,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 dc5c8b794e..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', @@ -313,7 +320,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 +470,35 @@ 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, 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 ([^)]+)\)\s*$/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': @@ -484,8 +519,21 @@ 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 + // 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/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/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/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 5e2f317985..999737489b 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,18 @@ impl WebSearchTool { &self, results: &[SearchResultItem], query: &str, + 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 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 +100,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}`._"); + return format!("_No results for `{query}`_ (via {provider})"); } - 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 +233,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,8 +288,75 @@ 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")); + // 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(), + 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] @@ -274,9 +377,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 +409,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 +424,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 +439,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 +523,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..65022ebb0a 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,17 @@ 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 }); + // 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}" + "tools.web_search: query_len={} results={count} provider={provider}", + query.chars().count() )]; RpcOutcome::new(payload, log).into_cli_compatible_json() }) @@ -975,6 +993,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]