From 6967072b22840dc40cc2f799a85e4a4c3590bce4 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 10:26:03 -0300 Subject: [PATCH 1/6] Describe public-web research by role, and add Exa and Firecrawl Research guidance and the Research Desk skill were written for Parallel's two tools by name. A catalogue entry now says which of its tools search and which fetch (`research`), the guidance is built from whichever providers a Bot holds, and a skill can declare `@research/search` and `@research/fetch` to match any provider it was granted. Exa and Firecrawl join Parallel, each free and with an API key. Exa and Firecrawl list the tools that only read (`readTools`), and anything else they advertise is a write: Firecrawl publishes tool families such as firecrawl_monitor_* that no write list could name. A deployment key can travel in a vendor's own header, as Exa reads x-api-key. --- examples/fintech/skills.yaml | 22 +- server/src/plugins/catalogue.ts | 167 +++++++++++- server/src/plugins/mcp.ts | 8 +- server/src/plugins/plugin-store/calls.ts | 3 +- server/src/plugins/plugin-store/servers.ts | 2 + server/src/plugins/selection.ts | 12 +- server/src/plugins/tools.ts | 90 +++++-- server/src/plugins/transport.ts | 3 + server/tests/parallel-research.test.ts | 98 -------- server/tests/web-research.test.ts | 280 +++++++++++++++++++++ shared/research.ts | 26 ++ 11 files changed, 576 insertions(+), 135 deletions(-) delete mode 100644 server/tests/parallel-research.test.ts create mode 100644 server/tests/web-research.test.ts create mode 100644 shared/research.ts diff --git a/examples/fintech/skills.yaml b/examples/fintech/skills.yaml index 62f5aa3d6..a29760d01 100644 --- a/examples/fintech/skills.yaml +++ b/examples/fintech/skills.yaml @@ -22,21 +22,17 @@ skills: - slug: research-public-web title: Research the public web - summary: Search current public sources with Parallel and write a cited brief. + summary: Search current public sources and write a cited brief. instructions: >- - Use Parallel to discover relevant public sources and read - selected pages before answering. Use the authenticated connector when it is granted; - otherwise use the anonymous connector when granted. Honor an explicit user choice of another - authorized provider. Keep search objectives specific and queries concise. Use one session_id - per conversation across related search and fetch calls. Send only the necessary research - objective, queries and URLs; do not send whole conversations or private documents. Cite the - source URLs and state missing evidence or provider errors. A missing grant requires an - administrator; never work around a denial. + Use the public-web research tools you hold to discover relevant sources and read selected + pages before answering. Honor an explicit user choice of another authorized provider. Keep + search objectives specific and queries concise. Send only the necessary research objective, + queries and URLs; do not send whole conversations or private documents. Cite the source URLs + and state missing evidence or provider errors. A missing grant requires an administrator; + never work around a denial. tools: - - parallel/web_search - - parallel/web_fetch - - parallel-authenticated/web_search - - parallel-authenticated/web_fetch + - "@research/search" + - "@research/fetch" - slug: find-a-document title: Find a document diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index 771490ac3..a548e4853 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -31,12 +31,21 @@ import { isNeverAllowedHostname } from "../computer/target"; // Type-only, so naming the transport here creates no import cycle with the registry that resolves it. import type { CuratedTransportKind } from "./transport"; +import type { ResearchRole } from "@openbot/shared/research"; export type CatalogueAuth = /** Answers without any credential at all. */ | { kind: "none" } /** One token, held by the deployment, used for everybody. */ - | { kind: "deployment-bearer" } + | { + kind: "deployment-bearer"; + /** + * The header the token travels in. Absent means `Authorization`, as a Bearer token unless the + * token names its own scheme. A vendor that reads its key from a header of its own, as Exa + * reads `x-api-key`, names it here, and the token is sent in it exactly as stored. + */ + header?: string; + } /** * First-party and in-process. There is no credential, because there is nothing to authenticate * to: the call runs against this deployment's own tables, as the person whose turn it is. @@ -138,9 +147,95 @@ export type CatalogueEntry = { * naming it resolves to a Composio dial with no app and no brokered gate. */ transport?: CuratedTransportKind; + /** + * The only tools on this server that read, when the vendor's list is too long or moves too fast + * for {@link CatalogueEntry.writeTools} to be trusted complete. + * + * With it, every advertised tool NOT named here is a write. Firecrawl is why: it publishes tool + * families (`firecrawl_monitor_*`) rather than names, a write list can only hold names, and a + * monitor deletion nobody listed would otherwise classify as a read. + */ + readTools?: readonly string[]; + /** + * Which of this server's tools do public-web research, by role. + * + * The roles are what the Bot's guidance and a skill's `@research/search` and `@research/fetch` + * declarations are written against, so a research skill works on whichever provider a + * deployment connected. The vendor's own names stay here, beside the host they belong to. + */ + research?: ResearchTools; docsUrl: string; }; +/** + * One provider's public-web research tools. + * + * `search` finds sources for a query and `fetch` reads pages by URL. Both are required, because a + * Bot that can find a page and cannot read it has links and no evidence. + */ +export type ResearchTools = { + search: string; + fetch: string; + /** How this provider's own parameters are best used, added to the shared guidance verbatim. */ + notes?: readonly string[]; +}; + +/** The research role a granted `serverId/toolName` ref plays, or null when it plays none. */ +export function researchRoleFor(ref: string): ResearchRole | null { + // A tool without a ref plays no role. Bot instructions are built from this, so it never throws. + if (typeof ref !== "string") return null; + const slash = ref.indexOf("/"); + if (slash <= 0) return null; + const research = catalogueEntry(ref.slice(0, slash))?.research; + const tool = ref.slice(slash + 1); + if (research?.search === tool) return "search"; + if (research?.fetch === tool) return "fetch"; + return null; +} + +const PARALLEL_RESEARCH: ResearchTools = Object.freeze({ + search: "web_search", + fetch: "web_fetch", + notes: Object.freeze([ + "Generate one session_id for the conversation and reuse it on related search and fetch calls.", + "Omit model_name unless the exact configured model identifier is known.", + ]), +}); + +const EXA_RESEARCH: ResearchTools = Object.freeze({ + search: "web_search_exa", + fetch: "web_fetch_exa", +}); + +/** Exa's research and multi-step agent tools spend credits, so only the three that read are reads. */ +const EXA_READ_TOOLS = Object.freeze([ + "web_search_exa", + "web_fetch_exa", + "web_search_advanced_exa", +]); + +const FIRECRAWL_RESEARCH: ResearchTools = Object.freeze({ + search: "firecrawl_search", + fetch: "firecrawl_scrape", +}); + +/** Crawls, agents, browser sessions and feedback start work or change state at Firecrawl. */ +const FIRECRAWL_WRITE_TOOLS = Object.freeze([ + "firecrawl_crawl", + "firecrawl_agent", + "firecrawl_interact", + "firecrawl_interact_stop", + "firecrawl_feedback", + "firecrawl_search_feedback", +]); + +const FIRECRAWL_READ_TOOLS = Object.freeze([ + "firecrawl_search", + "firecrawl_scrape", + "firecrawl_map", + "firecrawl_parse", +]); + /** * A short list, deliberately. * @@ -170,6 +265,7 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ path: "/mcp", auth: { kind: "none" }, writeTools: Object.freeze([]), + research: PARALLEL_RESEARCH, docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp", }, { @@ -182,8 +278,66 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ path: "/mcp", auth: { kind: "deployment-bearer" }, writeTools: Object.freeze([]), + research: PARALLEL_RESEARCH, docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp", }, + { + key: "exa", + title: "Exa Search", + vendor: "Exa", + summary: + "Public-web search and page reading. Free rate-limited access without a key.", + host: "https://mcp.exa.ai", + path: "/mcp", + auth: { kind: "none" }, + writeTools: Object.freeze([]), + readTools: EXA_READ_TOOLS, + research: EXA_RESEARCH, + docsUrl: "https://exa.ai/docs/reference/exa-mcp", + }, + { + key: "exa-authenticated", + title: "Exa Search (API key)", + vendor: "Exa", + summary: + "Public-web search and page reading using this deployment's Exa API key.", + host: "https://mcp.exa.ai", + path: "/mcp", + // Exa reads its key from `x-api-key`, not from `Authorization`. + auth: { kind: "deployment-bearer", header: "x-api-key" }, + writeTools: Object.freeze([]), + readTools: EXA_READ_TOOLS, + research: EXA_RESEARCH, + docsUrl: "https://exa.ai/docs/reference/exa-mcp", + }, + { + key: "firecrawl", + title: "Firecrawl", + vendor: "Firecrawl", + summary: + "Public-web search and page scraping. Free access without a key offers search, scrape and parse.", + host: "https://mcp.firecrawl.dev", + path: "/v2/mcp", + auth: { kind: "none" }, + writeTools: FIRECRAWL_WRITE_TOOLS, + readTools: FIRECRAWL_READ_TOOLS, + research: FIRECRAWL_RESEARCH, + docsUrl: "https://docs.firecrawl.dev/mcp-server", + }, + { + key: "firecrawl-authenticated", + title: "Firecrawl (API key)", + vendor: "Firecrawl", + summary: + "Public-web search and page scraping using this deployment's Firecrawl API key.", + host: "https://mcp.firecrawl.dev", + path: "/v2/mcp", + auth: { kind: "deployment-bearer" }, + writeTools: FIRECRAWL_WRITE_TOOLS, + readTools: FIRECRAWL_READ_TOOLS, + research: FIRECRAWL_RESEARCH, + docsUrl: "https://docs.firecrawl.dev/mcp-server", + }, { key: "google-drive", @@ -355,6 +509,15 @@ export function serverCredentialKind(entry: CatalogueEntry): "mcp" | null { return entry.auth.kind === "deployment-bearer" ? "mcp" : null; } +/** The header this entry's deployment token is sent in, when it is not `Authorization`. */ +export function tokenHeaderFor( + entry: CatalogueEntry | null, +): string | undefined { + return entry?.auth.kind === "deployment-bearer" + ? entry.auth.header + : undefined; +} + export function catalogueEntry(key: string): CatalogueEntry | null { return BY_KEY.get(key) ?? null; } @@ -453,6 +616,8 @@ export function classifyTool( // The reviewed list outranks the column, and only in this direction: a name a person reviewed as a // write stays a write whatever the listing recorded about it. if (entry?.writeTools.includes(toolName)) return "write"; + // An entry that names its reads names all of them, so anything else it advertises is a write. + if (entry?.readTools && !entry.readTools.includes(toolName)) return "write"; // Anything the column holds settles the rest. `typeof` rather than truthiness so the empty string // is treated as the value it is instead of as an absence of one. if (typeof recorded === "string") diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 5906a535f..d2d310def 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -211,6 +211,8 @@ type Connection = { url: string; /** The bearer token for this server, already decrypted. Absent for a server that needs none. */ token?: string; + /** The header the token goes in, sent as stored, when the vendor does not read `Authorization`. */ + tokenHeader?: string; }; /** @@ -342,7 +344,11 @@ async function withClient( ): Promise { const transport = new StreamableHTTPClientTransport(new URL(connection.url), { requestInit: connection.token - ? { headers: { Authorization: authorizationHeader(connection.token) } } + ? { + headers: connection.tokenHeader + ? { [connection.tokenHeader]: connection.token.trim() } + : { Authorization: authorizationHeader(connection.token) }, + } : undefined, }); const client = new Client({ name: "openbot", version: "1.0.0" }); diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts index b4c8e76bf..70f9e9482 100644 --- a/server/src/plugins/plugin-store/calls.ts +++ b/server/src/plugins/plugin-store/calls.ts @@ -8,7 +8,7 @@ import { } from "../../computer/policy"; import { withoutStatement } from "../../db/query-failure"; import { mcpTools, pluginGrants } from "../../db/schema"; -import { classifyTool } from "../catalogue"; +import { classifyTool, tokenHeaderFor } from "../catalogue"; import { VERSION_ARG } from "../composio"; import { inspectToolArguments } from "../content-governance"; import { shareTargetOf } from "../share-target"; @@ -476,6 +476,7 @@ export function callMethods(context: StoreContext) { { url: effectiveUrl(row, entry), token, + tokenHeader: tokenHeaderFor(entry), actorId: vendorUserId ?? input.credentialActorId ?? input.actorId, botId: input.botId, }, diff --git a/server/src/plugins/plugin-store/servers.ts b/server/src/plugins/plugin-store/servers.ts index cdb2f4a7a..661b1d04d 100644 --- a/server/src/plugins/plugin-store/servers.ts +++ b/server/src/plugins/plugin-store/servers.ts @@ -26,6 +26,7 @@ import { customUrlRefusal, resolveServerUrl, serverCredentialKind, + tokenHeaderFor, } from "../catalogue"; import { toolkitOf } from "../composio"; import type { ListedTool } from "../mcp"; @@ -1290,6 +1291,7 @@ export function serverMethods(context: StoreContext) { listed = await transport.listTools({ url: effectiveUrl(row, entry), token, + tokenHeader: tokenHeaderFor(entry), }); } catch (error) { /* diff --git a/server/src/plugins/selection.ts b/server/src/plugins/selection.ts index 105ff53e8..f36e8c0e9 100644 --- a/server/src/plugins/selection.ts +++ b/server/src/plugins/selection.ts @@ -1,4 +1,6 @@ import { textOf } from "../agents/message-text"; +import { researchRoleOf } from "@openbot/shared/research"; +import { researchRoleFor } from "./catalogue"; /** * Choosing which of a Bot's tools to put in front of the model, one run at a time. * @@ -197,7 +199,15 @@ function declaredBy( ): Set { const refs = new Set(); for (const skill of skills) { - for (const ref of skill.tools) if (granted.has(ref)) refs.add(ref); + for (const ref of skill.tools) { + if (granted.has(ref)) refs.add(ref); + // A research role stands for that role on every provider, still only among what is granted. + const role = researchRoleOf(ref); + if (role) { + for (const held of granted) + if (researchRoleFor(held) === role) refs.add(held); + } + } } return refs; } diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index be15ae675..1b88c2d92 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -3,6 +3,12 @@ import type { AuditInitiator } from "../audit"; import { type GrantedTool, parametersFor } from "@openbot/toolkit/granted-tool"; import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { markUntrusted } from "../untrusted-content"; +import { + type CatalogueEntry, + catalogueEntry, + researchRoleFor, +} from "./catalogue"; +import type { ResearchRole } from "@openbot/shared/research"; import type { SelectableSkill } from "./selection"; import { isDeploymentFault, @@ -110,19 +116,7 @@ export function grantedToolGuidance( (system) => !held.includes(system), ); - const researchConnector = ["parallel-authenticated", "parallel"].find( - (server) => - ["web_search", "web_fetch"].every((name) => - tools.some((tool) => tool.ref === `${server}/${name}`), - ), - ); - const researchTools = researchConnector - ? tools.filter( - (tool) => - tool.ref === `${researchConnector}/web_search` || - tool.ref === `${researchConnector}/web_fetch`, - ) - : []; + const research = researchProviders(tools); return [ ...(tools.length > 0 @@ -145,13 +139,7 @@ export function grantedToolGuidance( "access, and the thing that is missing is yours, not theirs.", ] : []), - ...(researchConnector - ? [ - `Parallel provides public-web search and extraction: ${researchTools.map((tool) => tool.name).join(" and ")} discover sources and read selected pages.`, - "Parallel searches the public web; it does not connect a person's private account. Send only the research objective, necessary search queries and requested URLs, not a full transcript or private documents. Generate one session_id for the conversation and reuse it on related search/fetch calls. Omit model_name unless the exact configured model identifier is known.", - "Cite source URLs and distinguish excerpts from full-page reads. Report provider errors or missing sources; do not invent evidence or quietly bypass a denial. Interactive browser work still uses the authorized computer tools when needed.", - ] - : []), + ...researchGuidance(research), /* * The vendors this deployment connects to and this Bot does not hold. * @@ -174,6 +162,68 @@ export function grantedToolGuidance( ].join("\n"); } +type ResearchProvider = { + entry: CatalogueEntry; + search: GrantedTool; + fetch: GrantedTool; +}; + +/** + * The research providers this Bot can actually use: a search tool and a fetch tool from one server. + * + * One per vendor. A deployment that added a vendor twice, once free and once with its key, has + * granted the keyed one for its limits, so that is the one the Bot is told about. + */ +function researchProviders(tools: GrantedTool[]): ResearchProvider[] { + const byServer = new Map< + string, + Partial> + >(); + for (const tool of tools) { + const role = researchRoleFor(tool.ref); + if (!role) continue; + const server = tool.ref.slice(0, tool.ref.indexOf("/")); + byServer.set(server, { ...byServer.get(server), [role]: tool }); + } + const byVendor = new Map(); + for (const [server, held] of byServer) { + const entry = catalogueEntry(server); + if (!entry || !held.search || !held.fetch) continue; + const provider = { entry, search: held.search, fetch: held.fetch }; + const existing = byVendor.get(entry.vendor); + if (!existing || entry.auth.kind === "deployment-bearer") + byVendor.set(entry.vendor, provider); + } + return [...byVendor.values()]; +} + +/** + * What a Bot is told about public-web research, the same for every provider. + * + * Described, not preferred: a Bot holding one of these and some other search tool is not told to + * pass the other over. With more than one, the person's choice decides. + */ +function researchGuidance(providers: ResearchProvider[]): string[] { + if (providers.length === 0) return []; + return [ + "For public-web research you can use:", + ...providers.map( + ({ entry, search, fetch }) => + `- ${entry.vendor}: ${search.name} finds sources and ${fetch.name} reads selected pages.`, + ), + ...(providers.length > 1 + ? [ + "Use the one the person asks for. Otherwise pick one and keep to it for the conversation.", + ] + : []), + "These search the public web; they do not connect a person's private account. Send only the research objective, necessary search queries and requested URLs, not a full transcript or private documents.", + ...providers.flatMap(({ entry }) => + (entry.research?.notes ?? []).map((note) => `${entry.vendor}: ${note}`), + ), + "Cite source URLs and distinguish excerpts from full-page reads. Report provider errors or missing sources; do not invent evidence or quietly bypass a denial. Interactive browser work still uses the authorized computer tools when needed.", + ]; +} + /** * Every MCP tool granted to one Bot, ready to hand to the runtime. * diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 60a18b333..7a7d8bc10 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -63,6 +63,8 @@ export type VendorTransport = { listTools(connection: { url: string; token?: string; + /** Where `token` goes when the vendor does not read `Authorization`; only MCP reads it. */ + tokenHeader?: string; /** * Declared by the shared connection shape, and never supplied on THIS path. * @@ -85,6 +87,7 @@ export type VendorTransport = { connection: { url: string; token?: string; + tokenHeader?: string; /** * Who this call is for, and which Bot is making it. * diff --git a/server/tests/parallel-research.test.ts b/server/tests/parallel-research.test.ts deleted file mode 100644 index e416fb064..000000000 --- a/server/tests/parallel-research.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { parse } from "yaml"; -import { z } from "zod"; -import { - catalogueEntry, - classifyTool, - resolveServerUrl, - serverCredentialKind, -} from "../src/plugins/catalogue"; -import type { GrantedTool } from "@openbot/toolkit/granted-tool"; -import { grantedToolGuidance } from "../src/plugins/tools"; - -const tool = (server: string, name: string): GrantedTool => ({ - name: `mcp__${server}__${name}`, - ref: `${server}/${name}`, - description: "Public web", - parameters: z.object({}), - execute: async () => "unused", -}); -const pair = (server = "parallel") => [ - tool(server, "web_search"), - tool(server, "web_fetch"), -]; -describe("Parallel public-web research", () => { - test("pins anonymous and authenticated configurations to the official endpoint", () => { - for (const key of ["parallel", "parallel-authenticated"]) { - const resolved = resolveServerUrl(key, "https://attacker.example"); - expect(resolved?.url).toBe("https://search.parallel.ai/mcp"); - expect(classifyTool(resolved!.entry, "web_search", true)).toBe("read"); - expect(classifyTool(resolved!.entry, "web_fetch", true)).toBe("read"); - expect(classifyTool(resolved!.entry, "not_advertised", false)).toBe( - "write", - ); - expect(classifyTool(resolved!.entry, "web_search", true, "write")).toBe( - "write", - ); - } - expect(catalogueEntry("parallel")?.auth.kind).toBe("none"); - expect(serverCredentialKind(catalogueEntry("parallel")!)).toBeNull(); - expect( - serverCredentialKind(catalogueEntry("parallel-authenticated")!), - ).toBe("mcp"); - }); - test("describes Parallel only while both research tools are actually offered", () => { - expect(grantedToolGuidance(pair())).toContain( - "Parallel provides public-web search and extraction", - ); - // Described, not preferred: a Bot holding another search tool is not told to pass it over. - expect(grantedToolGuidance(pair())).not.toContain("by default"); - expect(grantedToolGuidance([tool("parallel", "web_search")])).not.toContain( - "Parallel provides public-web search and extraction", - ); - expect(grantedToolGuidance([], ["parallel"])).not.toContain( - "Parallel provides public-web search and extraction", - ); - expect(grantedToolGuidance([])).toBe(""); - }); - test("uses deployment-authenticated research when both connectors are authorized", () => { - const guidance = grantedToolGuidance([ - ...pair(), - ...pair("parallel-authenticated"), - ]); - expect(guidance).toContain( - "mcp__parallel-authenticated__web_search and mcp__parallel-authenticated__web_fetch discover sources", - ); - expect(guidance).toContain( - "it does not connect a person's private account", - ); - }); - test("ships a Research Desk declaration referencing catalogue tools without granting access", () => { - const skills = parse( - readFileSync( - new URL("../../examples/fintech/skills.yaml", import.meta.url), - "utf8", - ), - ).skills; - const skill = skills.find( - (item: { slug: string }) => item.slug === "research-public-web", - ); - expect(skill.tools).toEqual([ - "parallel/web_search", - "parallel/web_fetch", - "parallel-authenticated/web_search", - "parallel-authenticated/web_fetch", - ]); - const desk = parse( - readFileSync( - new URL( - "../../examples/fintech/catalog/research-desk.yaml", - import.meta.url, - ), - "utf8", - ), - ); - expect(desk.skills).toContain("research-public-web"); - }); -}); diff --git a/server/tests/web-research.test.ts b/server/tests/web-research.test.ts new file mode 100644 index 000000000..f9a16efa7 --- /dev/null +++ b/server/tests/web-research.test.ts @@ -0,0 +1,280 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { researchRoleOf } from "@openbot/shared/research"; +import { parse } from "yaml"; +import { z } from "zod"; +import { + catalogueEntry, + classifyTool, + researchRoleFor, + resolveServerUrl, + serverCredentialKind, + tokenHeaderFor, +} from "../src/plugins/catalogue"; +import { listTools } from "../src/plugins/mcp"; +import { selectTools } from "../src/plugins/selection"; +import { grantedToolGuidance } from "../src/plugins/tools"; + +const tool = (server: string, name: string): GrantedTool => ({ + name: `mcp__${server}__${name}`, + ref: `${server}/${name}`, + description: "Public web", + parameters: z.object({}), + execute: async () => "unused", +}); +const RESEARCH_TOOLS: Record = { + parallel: ["web_search", "web_fetch"], + "parallel-authenticated": ["web_search", "web_fetch"], + exa: ["web_search_exa", "web_fetch_exa"], + "exa-authenticated": ["web_search_exa", "web_fetch_exa"], + firecrawl: ["firecrawl_search", "firecrawl_scrape"], + "firecrawl-authenticated": ["firecrawl_search", "firecrawl_scrape"], +}; +const pair = (server = "parallel") => + (RESEARCH_TOOLS[server] ?? []).map((name) => tool(server, name)); + +describe("research providers in the catalogue", () => { + test("each is pinned to its vendor's official endpoint", () => { + const urls: Record = { + parallel: "https://search.parallel.ai/mcp", + "parallel-authenticated": "https://search.parallel.ai/mcp", + exa: "https://mcp.exa.ai/mcp", + "exa-authenticated": "https://mcp.exa.ai/mcp", + firecrawl: "https://mcp.firecrawl.dev/v2/mcp", + "firecrawl-authenticated": "https://mcp.firecrawl.dev/v2/mcp", + }; + for (const [key, url] of Object.entries(urls)) { + expect(resolveServerUrl(key, "https://attacker.example")?.url).toBe(url); + } + }); + + test("names its search and fetch tools, and both read", () => { + for (const [key, [search, fetch]] of Object.entries(RESEARCH_TOOLS)) { + const entry = catalogueEntry(key)!; + expect(entry.research).toMatchObject({ search, fetch }); + expect(classifyTool(entry, search, true)).toBe("read"); + expect(classifyTool(entry, fetch, true)).toBe("read"); + expect(classifyTool(entry, "not_advertised", false)).toBe("write"); + expect(classifyTool(entry, search, true, "write")).toBe("write"); + expect(researchRoleFor(`${key}/${search}`)).toBe("search"); + expect(researchRoleFor(`${key}/${fetch}`)).toBe("fetch"); + } + expect(researchRoleFor("notion/notion-search")).toBeNull(); + // Instructions are built from these, so a tool missing its ref must not throw. + expect(researchRoleFor(undefined as never)).toBeNull(); + expect( + grantedToolGuidance([{ name: "mcp__drive__search" } as GrantedTool]), + ).toContain("drive: search"); + }); + + test("a tool outside an entry's reads is a write, even one the vendor called a read", () => { + const firecrawl = catalogueEntry("firecrawl")!; + // Published as a family, so no write list could have named it. + expect(classifyTool(firecrawl, "firecrawl_monitor_delete", true)).toBe( + "write", + ); + expect( + classifyTool(firecrawl, "firecrawl_monitor_delete", true, "read"), + ).toBe("write"); + expect(classifyTool(firecrawl, "firecrawl_crawl", true)).toBe("write"); + expect(classifyTool(firecrawl, "firecrawl_map", true)).toBe("read"); + expect(classifyTool(firecrawl, "firecrawl_parse", true)).toBe("read"); + // Spends credits on a multi-step run. + expect(classifyTool(catalogueEntry("exa")!, "agent_run", true)).toBe( + "write", + ); + }); + + test("says whose credential each uses, and where Exa's key goes", () => { + expect(serverCredentialKind(catalogueEntry("parallel")!)).toBeNull(); + expect(serverCredentialKind(catalogueEntry("exa")!)).toBeNull(); + expect(serverCredentialKind(catalogueEntry("firecrawl")!)).toBeNull(); + for (const key of [ + "parallel-authenticated", + "exa-authenticated", + "firecrawl-authenticated", + ]) + expect(serverCredentialKind(catalogueEntry(key)!)).toBe("mcp"); + expect(tokenHeaderFor(catalogueEntry("exa-authenticated"))).toBe( + "x-api-key", + ); + expect( + tokenHeaderFor(catalogueEntry("firecrawl-authenticated")), + ).toBeUndefined(); + expect(tokenHeaderFor(catalogueEntry("parallel"))).toBeUndefined(); + expect(tokenHeaderFor(null)).toBeUndefined(); + }); +}); + +describe("what a Bot is told about research", () => { + test("a provider is described only while both of its tools are held", () => { + for (const server of Object.keys(RESEARCH_TOOLS)) { + const guidance = grantedToolGuidance(pair(server)); + expect(guidance).toContain("For public-web research you can use:"); + expect(guidance).toContain("do not connect a person's private account"); + // Described, not preferred: a Bot holding another search tool is not told to pass it over. + expect(guidance).not.toContain("by default"); + expect(grantedToolGuidance(pair(server).slice(0, 1))).not.toContain( + "public-web research", + ); + } + expect(grantedToolGuidance([], ["exa"])).not.toContain( + "public-web research", + ); + expect(grantedToolGuidance([])).toBe(""); + }); + + test("names each provider's own tools and adds only its own notes", () => { + const exa = grantedToolGuidance(pair("exa")); + expect(exa).toContain( + "- Exa: mcp__exa__web_search_exa finds sources and mcp__exa__web_fetch_exa reads selected pages.", + ); + expect(exa).not.toContain("session_id"); + expect(grantedToolGuidance(pair())).toContain( + "Parallel: Generate one session_id", + ); + }); + + test("a vendor added twice is described once, with its key", () => { + const guidance = grantedToolGuidance([ + ...pair(), + ...pair("parallel-authenticated"), + ]); + expect(guidance).toContain( + "- Parallel: mcp__parallel-authenticated__web_search finds sources", + ); + expect(guidance).not.toContain("mcp__parallel__web_search"); + }); + + test("two vendors are both described, and the person's choice decides", () => { + const guidance = grantedToolGuidance([...pair(), ...pair("firecrawl")]); + expect(guidance).toContain("- Parallel:"); + expect(guidance).toContain("- Firecrawl:"); + expect(guidance).toContain("Use the one the person asks for."); + }); +}); + +describe("a skill declaring research by role", () => { + const others = Array.from({ length: 12 }, (_, index) => ({ + ref: `slack/tool_${index}`, + })); + const research = { + slug: "research", + title: "Research", + summary: "Research the public web.", + tools: ["@research/search", "@research/fetch"], + }; + const slack = { + slug: "slack", + title: "Slack", + summary: "Slack.", + tools: others.map((entry) => entry.ref), + }; + const choose = (slug: string) => async () => + JSON.stringify({ skills: [slug] }); + + test("brings whichever provider's tools the Bot holds", async () => { + const tools = [ + ...others, + { ref: "exa/web_search_exa" }, + { ref: "exa/web_fetch_exa" }, + { ref: "exa/web_search_advanced_exa" }, + ]; + const selection = await selectTools({ + tools, + skills: [research, slack], + text: "find recent coverage of this", + choose: choose("research"), + }); + const offered = selection.offered.map((entry) => entry.ref); + expect(offered).toContain("exa/web_search_exa"); + expect(offered).toContain("exa/web_fetch_exa"); + expect(offered).not.toContain("slack/tool_0"); + // Not a research role, and no skill declares it, so it is offered as any undeclared tool is. + expect(offered).toContain("exa/web_search_advanced_exa"); + }); + + test("never offers a provider the Bot was not granted", async () => { + const selection = await selectTools({ + tools: [...others, { ref: "firecrawl/firecrawl_search" }], + skills: [research, slack], + text: "find recent coverage of this", + choose: choose("research"), + }); + const offered = selection.offered.map((entry) => entry.ref); + expect(offered).toEqual(["firecrawl/firecrawl_search"]); + }); + + test("a role ref is only ever the two roles", () => { + expect(researchRoleOf("@research/search")).toBe("search"); + expect(researchRoleOf("@research/fetch")).toBe("fetch"); + expect(researchRoleOf("@research/delete")).toBeNull(); + expect(researchRoleOf("@research/search/more")).toBeNull(); + expect(researchRoleOf("research/search")).toBeNull(); + }); + + test("the shipped Research Desk skill declares roles, not one provider", () => { + const skills = parse( + readFileSync( + new URL("../../examples/fintech/skills.yaml", import.meta.url), + "utf8", + ), + ).skills; + const skill = skills.find( + (item: { slug: string }) => item.slug === "research-public-web", + ); + expect(skill.tools).toEqual(["@research/search", "@research/fetch"]); + const desk = parse( + readFileSync( + new URL( + "../../examples/fintech/catalog/research-desk.yaml", + import.meta.url, + ), + "utf8", + ), + ); + expect(desk.skills).toContain("research-public-web"); + }); +}); + +describe("the header a deployment token is sent in", () => { + let server: ReturnType; + let seen: Headers | undefined; + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(request) { + seen ??= request.headers; + return new Response("no", { status: 401 }); + }, + }); + }); + afterAll(() => server.stop(true)); + + const listed = async (connection: { + token: string; + tokenHeader?: string; + }) => { + seen = undefined; + await listTools({ + url: `http://127.0.0.1:${server.port}/mcp`, + ...connection, + }).catch(() => undefined); + return seen; + }; + + test("is Authorization as a Bearer token by default", async () => { + const headers = await listed({ token: "secret" }); + expect(headers?.get("authorization")).toBe("Bearer secret"); + }); + + test("is the vendor's own header, as stored, when the entry names one", async () => { + const headers = await listed({ + token: " secret ", + tokenHeader: "x-api-key", + }); + expect(headers?.get("x-api-key")).toBe("secret"); + expect(headers?.get("authorization")).toBeNull(); + }); +}); diff --git a/shared/research.ts b/shared/research.ts new file mode 100644 index 000000000..4577d4015 --- /dev/null +++ b/shared/research.ts @@ -0,0 +1,26 @@ +/** + * Public-web research by role rather than by provider, shared by the server and the app. + * + * A skill declares `@research/search` and `@research/fetch` instead of one provider's tool names, + * and the server matches each to that role's tool on every research connector in its catalogue. So + * a research skill keeps working when a deployment switches provider. + */ +export type ResearchRole = "search" | "fetch"; + +/** + * Shaped like a `serverId/toolName` ref so a declaration passes the same check as any other, and + * starting with a character no server id may contain, so it can never be mistaken for one. + */ +export const RESEARCH_ROLE_PREFIX = "@research"; + +export const RESEARCH_ROLES: readonly { ref: string; label: string }[] = [ + { ref: `${RESEARCH_ROLE_PREFIX}/search`, label: "Search the web" }, + { ref: `${RESEARCH_ROLE_PREFIX}/fetch`, label: "Read pages" }, +]; + +/** The research role a skill's tool ref names, or null when it names a server's own tool. */ +export function researchRoleOf(ref: string): ResearchRole | null { + const [prefix, role, extra] = ref.split("/"); + if (prefix !== RESEARCH_ROLE_PREFIX || extra !== undefined) return null; + return role === "search" || role === "fetch" ? role : null; +} From 450e5ae6b12e5caa41176f71418026bd665b9c6a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 10:26:03 -0300 Subject: [PATCH 2/6] Offer research roles when writing a skill The Skills page gains a "Public-web research" group for the two roles, instead of listing them as not connected, and list_skill_tools tells the authoring assistant about them. The admin page says a deployment key is sent with every call, since it is no longer always a bearer token. --- app/src/components/skills/skill-tools.tsx | 21 +++++++++++++++++++ app/src/lib/skills/form.ts | 6 +++++- app/src/lib/skills/proposal.ts | 7 ++++++- app/src/routes/_authed/admin/plugins/$key.tsx | 2 +- app/tests/skill-form.test.ts | 6 ++++++ app/tests/skill-proposal.test.ts | 1 + 6 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/src/components/skills/skill-tools.tsx b/app/src/components/skills/skill-tools.tsx index 906338641..ec6df54a1 100644 --- a/app/src/components/skills/skill-tools.tsx +++ b/app/src/components/skills/skill-tools.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Field, FieldLabel } from "@/components/ui/field"; import { pluginsPageQueryOptions } from "@/lib/plugins/queries"; +import { RESEARCH_ROLES } from "@openbot/shared/research"; import { undeclaredElsewhere } from "@/lib/skills/form"; /** @@ -133,6 +134,26 @@ export function SkillTools({ )} + {plugins.data ? ( +
+

Public-web research

+
+ {RESEARCH_ROLES.map((role) => ( + + ))} +
+
+ ) : null} + {elsewhere.length > 0 ? (

Not connected here

diff --git a/app/src/lib/skills/form.ts b/app/src/lib/skills/form.ts index 74baed8da..2346f722d 100644 --- a/app/src/lib/skills/form.ts +++ b/app/src/lib/skills/form.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { RESEARCH_ROLES } from "@openbot/shared/research"; /** * The four things a person decides about a skill. @@ -75,6 +76,9 @@ export function undeclaredElsewhere( selected: readonly string[], offered: readonly string[], ): string[] { - const known = new Set(offered); + const known = new Set([ + ...offered, + ...RESEARCH_ROLES.map((role) => role.ref), + ]); return selected.filter((ref) => !known.has(ref)); } diff --git a/app/src/lib/skills/proposal.ts b/app/src/lib/skills/proposal.ts index 4420ec701..758b566f6 100644 --- a/app/src/lib/skills/proposal.ts +++ b/app/src/lib/skills/proposal.ts @@ -244,5 +244,10 @@ export function describeToolRefs(servers: readonly PluginServer[]): string { ); return [`${server.title} (${server.id}):`, ...tools].join("\n"); }); - return ["Refs are `serverId/toolName`.", ...blocks].join("\n"); + return [ + "Refs are `serverId/toolName`.", + // The server resolves these against its catalogue, so a research skill outlives a provider swap. + "For public-web research, declare `@research/search` and `@research/fetch` instead of one provider's tools: they stand for search and page reading on whichever research connector the Bot holds.", + ...blocks, + ].join("\n"); } diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index b687baaa5..69cab47f2 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -715,7 +715,7 @@ function RouteComponent() { Access token - Sent as a bearer token on every call to this vendor. + Sent with every call to this vendor. diff --git a/app/tests/skill-form.test.ts b/app/tests/skill-form.test.ts index 0d35729a0..8d2dc9314 100644 --- a/app/tests/skill-form.test.ts +++ b/app/tests/skill-form.test.ts @@ -90,6 +90,12 @@ describe("declared tools no connected server offers", () => { expect(undeclaredElsewhere(offered, offered)).toEqual([]); }); + test("a research role is never reported as not connected", () => { + expect( + undeclaredElsewhere(["@research/search", "@research/fetch"], []), + ).toEqual([]); + }); + test("declaring nothing leaves nothing over", () => { expect(undeclaredElsewhere([], offered)).toEqual([]); }); diff --git a/app/tests/skill-proposal.test.ts b/app/tests/skill-proposal.test.ts index af65fddd8..5361db73f 100644 --- a/app/tests/skill-proposal.test.ts +++ b/app/tests/skill-proposal.test.ts @@ -232,6 +232,7 @@ describe("the tool refs a skill may declare", () => { ]); expect(answer).toContain("Notion (notion):"); expect(answer).toContain("notion/notion-create (writes)"); + expect(answer).toContain("`@research/search` and `@research/fetch`"); }); test("a connector advertising nothing is left out rather than shown empty", () => { From 4e70ab5d986c427f1fbe69281aba374c1d554732 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 10:26:03 -0300 Subject: [PATCH 3/6] Document public-web research across providers docs/parallel-research.md becomes docs/web-research.md, covering Parallel, Exa and Firecrawl, the research roles, and how to add a provider. --- CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 4 ++-- docs/README.md | 2 +- docs/architecture.md | 2 +- docs/parallel-research.md | 19 ------------------- docs/web-research.md | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 23 deletions(-) delete mode 100644 docs/parallel-research.md create mode 100644 docs/web-research.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eaee412e..3426222c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,26 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `code_required` (below). A server and a computer must be upgraded together: an older computer has no `/sign-in/code`. +### Public-web research works with Exa and Firecrawl, not only Parallel + +Four new catalogue entries give a Bot public-web search and page reading without its browser: +**Exa Search** (free, rate-limited), **Exa Search (API key)**, **Firecrawl** (free) and **Firecrawl +(API key)**. They sit beside the two Parallel entries, and nothing is granted to any Bot until an administrator does it. +See [docs/web-research.md](docs/web-research.md). + +- **The research guidance follows whichever provider a Bot holds.** It used to switch on only for + Parallel's two tools. Now a Bot holding any provider's search and fetch tools is told how to use + them, and a Bot holding more than one uses the one the person asks for. +- **The shipped `research-public-web` skill declares roles, not Parallel.** Its tools are now + `@research/search` and `@research/fetch`, which match the search and fetch tools of every research + provider the Bot was granted. A skill written by a person can declare the same roles, and the + Skills page offers them under "Public-web research". A role grants nothing. +- **Exa and Firecrawl list their reads.** Every other tool they advertise counts as a write, so a + Firecrawl monitor deletion or an Exa agent run goes through the same policy as any write. +- **An API key can go in the vendor's own header.** Exa reads `x-api-key`; the deployment key for + that entry is sent there. The admin page now says the key is "Sent with every call to this vendor" + rather than calling it a bearer token. + ### Bot turns hold their conversation more carefully Routines, responsibilities, background research, wake-ups, group turns and Bot-to-Bot handoffs keep a diff --git a/README.md b/README.md index f17bf4b55..7d5b93677 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you - **Secrets never enter the transcript**: the trail records that a secret was requested and how long it was, not what it said. - **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand-written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only. - **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component. -- **Governed MCP**: Google Drive, Notion and [Parallel Search](docs/parallel-research.md) ship in the catalogue, and Composio brokers a few hundred more apps behind one account, each reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website. +- **Governed MCP**: Google Drive, Notion and the [public-web research providers](docs/web-research.md) Parallel, Exa and Firecrawl ship in the catalogue, and Composio brokers a few hundred more apps behind one account, each reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website. - **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. A Bot granted the shipped `skill-creator` skill can write one with you in the conversation, and saves it only when you press the button on the card. - **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once. - **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail. @@ -445,7 +445,7 @@ Use `bash scripts/start.sh` for the whole stack and `bash scripts/stop.sh` to ta - [docs/deployment.md](docs/deployment.md) - [charts/openbot/README.md](charts/openbot/README.md): the Helm chart - [docs/releasing.md](docs/releasing.md) -- [docs/parallel-research.md](docs/parallel-research.md): public-web search and extraction through Parallel +- [docs/web-research.md](docs/web-research.md): public-web search and extraction through Parallel, Exa or Firecrawl ## Contributing diff --git a/docs/README.md b/docs/README.md index 6f76028ce..0c32f15ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ Start with the root [README](../README.md), then use these references: - [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration. - [Routines](routines.md): standing instructions a Bot runs on a schedule, the worker that fires them, and who they run as. - [Automatic Learning](automatic-learning.md): which Bots contribute conversations to a Learning container, and receive its published skills. -- [Parallel research](parallel-research.md): public-web search and extraction through the Parallel Search connector. +- [Public-web research](web-research.md): search and page reading through Parallel, Exa or Firecrawl. - Plugins, one connector per page — what an administrator registers, what each person consents to, and what the failures mean: - [Composio](plugins/composio.md): the broker, and so the one page here that is a catalogue of apps rather than a single connector. - [Google Drive](plugins/google-drive.md) diff --git a/docs/architecture.md b/docs/architecture.md index e3095a861..68248991e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -307,7 +307,7 @@ MCP servers and skills share the plugin grant table, but they have different own - MCP tools are admin-governed because they can reach external systems with stored credentials. - Skills are reusable instructions. A person can create personal skills and attach them only to Bots they own. Administrators create deployment skills. -The curated MCP catalogue contains Parallel Search (anonymous, or with this deployment's API key; see [parallel-research.md](parallel-research.md)), Google Drive, Notion, and the built-in Routines server. Custom MCP servers must pass URL checks; unknown tools and custom-server tools are treated as writes unless positively classified as reads. +The curated MCP catalogue contains the public-web research providers Parallel, Exa and Firecrawl (see [web-research.md](web-research.md)), Google Drive, Notion, and the built-in Routines server. Custom MCP servers must pass URL checks; unknown tools and custom-server tools are treated as writes unless positively classified as reads. A catalogue entry says whose credential a Bot reaches it with, which is a different question from whether it is reachable at all. A deployment-wide token answers the same for everybody; Google Drive and Notion are both `user-oauth`, so a Bot reaches them as the person asking and sees only what that person can see. An administrator enabling the connector and a person connecting their own account are two decisions, and neither can be made for the other. See [Google Drive](plugins/google-drive.md) and [Notion](plugins/notion.md). diff --git a/docs/parallel-research.md b/docs/parallel-research.md deleted file mode 100644 index b948719ef..000000000 --- a/docs/parallel-research.md +++ /dev/null @@ -1,19 +0,0 @@ -# Public-web research with Parallel - -Parallel provides public-web search and extraction for OpenBot. When both its `web_search` and `web_fetch` tools are granted to a Bot, the built-in Bot guidance describes them to it for public-web research, alongside whatever other tools it holds. The shipped Research Desk example also includes a public-web research skill. This configuration is conditional on ordinary authorization: no existing Bot receives new access automatically. - -## Enable research - -1. As an administrator, open Plugins and add **Parallel Search** from the catalogue. This pins `https://search.parallel.ai/mcp` and discovers its tools. No API key is needed for anonymous light use. -2. Grant **web_search** and **web_fetch** to the Bots that should research the public web. For the shipped fintech package, select **Research Desk**. Its `research-public-web` skill already declares these tools; the declaration alone grants no access. -3. Ask the Bot to research a topic. It should search, read selected sources, and cite their URLs. Run `/research-public-web` to explicitly select the example skill if your deployment has many tools. - -For production or higher limits, instead add **Parallel Search (API key)**, with your Parallel API key stored through the existing deployment credential flow. Grant its two tools and remove the anonymous grants. Both use the official endpoint; the authenticated entry sends the key as a Bearer token. If both are granted, the guidance selects the authenticated connector. Keys remain in the server-side credential vault. - -## Choice and controls - -Administrators can revoke either tool, remove the connector, or grant another provider. Users can explicitly request a different authorized provider. Existing tool selection, action policies, audit records, vendor error handling, and background-run authorization are unchanged. Every call goes through `PluginStore.callTool`; no raw MCP server is added to the agent configuration. - -The provider receives model-selected objectives, search queries, requested public URLs and a conversation session identifier. These arguments can contain information from the user's request: avoid sending private context that is not needed for public research. Full conversations are not automatically forwarded. Free anonymous access has provider-managed limits; failures remain visible and are not silently retried through another provider. Source URLs and excerpts use the existing tool-result rendering; this change adds no custom source-card UI. - -See [Parallel's official Search MCP documentation](https://docs.parallel.ai/integrations/mcp/search-mcp) for the two tools, free access, API-key authentication and limits. diff --git a/docs/web-research.md b/docs/web-research.md new file mode 100644 index 000000000..1b590682d --- /dev/null +++ b/docs/web-research.md @@ -0,0 +1,38 @@ +# Public-web research + +OpenBot ships three public-web research providers in the plugin catalogue: **Parallel**, **Exa** and **Firecrawl**. Each one gives a Bot a search tool, which finds sources for a query, and a fetch tool, which reads pages by URL. A Bot holding both tools from one provider is told how to research with them. None of this uses the Bot's browser, and no existing Bot gets new access automatically. + +| Catalogue entry | Search tool | Fetch tool | Credential | +| ------------------------- | ------------------ | ------------------ | ---------------------------------------------------- | +| Parallel Search | `web_search` | `web_fetch` | None; provider-managed limits for light use | +| Parallel Search (API key) | `web_search` | `web_fetch` | Deployment API key, sent as a Bearer token | +| Exa Search | `web_search_exa` | `web_fetch_exa` | None; free, rate-limited | +| Exa Search (API key) | `web_search_exa` | `web_fetch_exa` | Deployment API key, sent in Exa's `x-api-key` header | +| Firecrawl | `firecrawl_search` | `firecrawl_scrape` | None; free access offers search, scrape and parse | +| Firecrawl (API key) | `firecrawl_search` | `firecrawl_scrape` | Deployment API key, sent as a Bearer token | + +## Enable research + +1. As an administrator, open Plugins and add a provider from the catalogue. For the keyed entries, enter the provider's API key; it is stored in the server-side credential vault. +2. Grant that provider's search and fetch tools to the Bots that should research the public web. For the shipped fintech package, select **Research Desk**. +3. Ask the Bot to research a topic. It searches, reads selected sources and cites their URLs. Run `/research-public-web` to select the example skill explicitly if your deployment has many tools. + +For production or higher limits, use a keyed entry and remove the grants on the free one. If a Bot holds a vendor's free and keyed entries, it is told about the keyed one. + +## Skills declare roles, not providers + +A skill names the research tools it needs as `@research/search` and `@research/fetch`, instead of one provider's tool names. Each role matches that role's tool on every research provider in the catalogue, intersected as always with what the Bot was granted. The shipped `research-public-web` skill does this, so it works with whichever provider a deployment connects. A role declaration grants nothing. + +## Choice and controls + +A Bot holding more than one provider is told about each and uses the one the person asks for. Administrators can revoke tools, remove a connector or grant another provider. Every call goes through `PluginStore.callTool`, with the usual grants, action policies and audit records; no raw MCP server is added to the agent configuration. + +Exa and Firecrawl name the tools that only read (`readTools` in the catalogue), and every other tool they advertise counts as a write. Firecrawl publishes tool families such as `firecrawl_monitor_*`, and Exa's agent tools spend credits, so neither can be safely described by a list of writes alone. + +The provider receives model-selected objectives, search queries, requested public URLs and, for Parallel, a conversation session identifier. These can contain information from the person's request, so the Bot is told not to send private context public research does not need. Full conversations are not forwarded. Failures stay visible and are not silently retried through another provider. + +## Adding a provider + +A provider is a catalogue entry in `server/src/plugins/catalogue.ts` with a `research: { search, fetch, notes? }` field naming its two tools, plus `readTools` when its write tools cannot be listed exhaustively. A vendor that reads its key from its own header names it in `auth: { kind: "deployment-bearer", header }`. The Bot guidance and the skill roles pick the new entry up with no other change. + +Provider documentation: [Parallel](https://docs.parallel.ai/integrations/mcp/search-mcp), [Exa](https://exa.ai/docs/reference/exa-mcp), [Firecrawl](https://docs.firecrawl.dev/mcp-server). From 5436b75bd1f5628cc16b72192d265a4aaec1fa18 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 10:54:25 -0300 Subject: [PATCH 4/6] Search for facts that change when a Bot holds a search tool Every Bot was told "this is not an instruction to go looking" and not to hunt the open web, which was written for Bots whose only way to look was their browser. So General Assistant, holding Exa, charted the top five economies from memory, and asked whether it could search, said yes. The provenance guidance now leaves finding sources to a search tool, the research guidance tells a Bot to search before answering about current figures, rankings, prices or recent events, a search or two at most, and the browser guidance says opening a page is not a web search. --- CHANGELOG.md | 4 ++++ server/src/plugins/tools.ts | 6 ++++++ server/tests/copilot.test.ts | 7 +++++++ server/tests/web-research.test.ts | 5 +++++ shared/bot-prompt.test.ts | 6 ++++++ shared/bot-prompt.ts | 16 ++++++++++++---- 6 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3426222c5..0fcaab996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ See [docs/web-research.md](docs/web-research.md). - **The research guidance follows whichever provider a Bot holds.** It used to switch on only for Parallel's two tools. Now a Bot holding any provider's search and fetch tools is told how to use them, and a Bot holding more than one uses the one the person asks for. +- **A Bot holding a research provider searches without being asked** when an answer depends on facts + that change, such as current figures, rankings or recent events, and stops after a search or two. + Before, every Bot was told not to go looking, so one holding a search tool still answered from + memory. A Bot with only its browser now says it can open pages but cannot search. - **The shipped `research-public-web` skill declares roles, not Parallel.** Its tools are now `@research/search` and `@research/fetch`, which match the search and fetch tools of every research provider the Bot was granted. A skill written by a person can declare the same roles, and the diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 1b88c2d92..8ec8d38b1 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -202,6 +202,10 @@ function researchProviders(tools: GrantedTool[]): ResearchProvider[] { * * Described, not preferred: a Bot holding one of these and some other search tool is not told to * pass the other over. With more than one, the person's choice decides. + * + * Told to search unprompted for facts that change, because a Bot holding these answered "top five + * countries by GDP" from memory and then, asked, said it could search. The bound in the next line + * is what keeps that from becoming the open-web hunt the provenance guidance warns about. */ function researchGuidance(providers: ResearchProvider[]): string[] { if (providers.length === 0) return []; @@ -216,6 +220,8 @@ function researchGuidance(providers: ResearchProvider[]): string[] { "Use the one the person asks for. Otherwise pick one and keep to it for the conversation.", ] : []), + "Search before answering when the answer depends on facts that change: current figures, rankings, prices, who holds a role, recent events. Do not answer those from memory while you hold these tools, even when the person did not ask you to search.", + "Keep it bounded: a search or two and a read of the best sources, then answer, and say what you could not confirm.", "These search the public web; they do not connect a person's private account. Send only the research objective, necessary search queries and requested URLs, not a full transcript or private documents.", ...providers.flatMap(({ entry }) => (entry.research?.notes ?? []).map((note) => `${entry.vendor}: ${note}`), diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index c068e1aec..48b43149e 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -1155,6 +1155,13 @@ describe("where a Bot says its answer came from", () => { expect(guidance).toContain("do not go hunting the open web"); }); + test("it leaves finding sources to a search tool, and offers to check one without", () => { + // Without this, a Bot granted a search tool still answered current figures from memory. + const guidance = PROVENANCE_GUIDANCE.toLowerCase().replace(/\s+/g, " "); + expect(guidance).toContain("finding sources is what a search tool is for"); + expect(guidance).toContain("offer to check a source the person names"); + }); + test("it names the answers that must not be stated without a source", () => { // The general rule is easy to read past. The list is what makes it bite on the turn that // produced this: a threshold, a deadline, a filing obligation, a figure. diff --git a/server/tests/web-research.test.ts b/server/tests/web-research.test.ts index f9a16efa7..aa378bf48 100644 --- a/server/tests/web-research.test.ts +++ b/server/tests/web-research.test.ts @@ -115,6 +115,11 @@ describe("what a Bot is told about research", () => { expect(guidance).toContain("do not connect a person's private account"); // Described, not preferred: a Bot holding another search tool is not told to pass it over. expect(guidance).not.toContain("by default"); + // Searches unprompted for facts that change, and only a little. + expect(guidance).toContain( + "Search before answering when the answer depends on facts that change", + ); + expect(guidance).toContain("a search or two"); expect(grantedToolGuidance(pair(server).slice(0, 1))).not.toContain( "public-web research", ); diff --git a/shared/bot-prompt.test.ts b/shared/bot-prompt.test.ts index c704cac50..2f5d14f34 100644 --- a/shared/bot-prompt.test.ts +++ b/shared/bot-prompt.test.ts @@ -22,4 +22,10 @@ describe("COMPUTER_GUIDANCE", () => { "Say what you found or did in plain language, briefly.", ); }); + + test("does not let the browser pass for a web search", () => { + // Asked "can you search the web?", a Bot with only a browser said yes. + expect(COMPUTER_GUIDANCE).toContain("Opening a page is not a web search."); + expect(COMPUTER_GUIDANCE).toContain("cannot search"); + }); }); diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index 7b487d40d..306e97566 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -19,6 +19,8 @@ const COMPUTER_GUIDANCE_LINES = [ "You are a Bot with your own computer, a real web browser the person can watch you use.", "When you are asked to look at, open, visit, check or read a web page, call computer_navigate.", "Never claim you cannot browse: opening a page is something you can actually do.", + "Opening a page is not a web search. To find sources rather than read a page you can name, use a", + "search tool if you hold one; if you do not, say you can open and read pages but cannot search.", "Opening a page returns its title and its readable text. Answer from that text.", "Never tell the person to go and look at the page themselves: you have already read it.", "", @@ -107,6 +109,10 @@ export const COMPUTER_GUIDANCE = COMPUTER_GUIDANCE_LINES.reduce( * and they went hunting the open web and looped on a government 404 page, which is worse than the * problem: an unsourced answer marked as unsourced is honest, and a hunt for one is a Bot that * never answers. The instruction is to say where the answer came from, not to go looking. + * + * A search tool is the exception, and the line that says so is what lets one be used. Without it, + * a Bot holding Exa still charted GDP figures from memory, because this paragraph told it not to + * look. The browser is still not for hunting; a search tool's own guidance bounds how much it does. */ const PROVENANCE_GUIDANCE_LINES = [ "Say where an answer came from. When you read it with one of your tools, cite what you read.", @@ -119,10 +125,12 @@ const PROVENANCE_GUIDANCE_LINES = [ "own policy or the current regulation' costs you a sentence. Being confidently wrong about a", "number somebody acts on costs them a great deal more.", "", - "This is not an instruction to go looking. If nothing you can reach covers the question, answer as", - "well as you can and mark it plainly as unverified. Do not go hunting the open web for something", - "to cite, and do not keep retrying a page that is not giving you one: an unsourced answer that", - "says it is unsourced is honest, and a search that never ends is a Bot that never answers.", + "This is not an instruction to go looking. Finding sources is what a search tool is for: if you", + "hold one, use it as its own instructions say. Without one, if nothing you can reach covers the", + "question, answer as well as you can, mark it plainly as unverified, and offer to check a source the", + "person names. Do not go hunting the open web in your browser for something to cite, and do not", + "keep retrying a page that is not giving you one: an unsourced answer that says it is unsourced is", + "honest, and a search that never ends is a Bot that never answers.", ]; export const PROVENANCE_GUIDANCE = PROVENANCE_GUIDANCE_LINES.reduce( From a064188e172e232c743c75a1c35a6ff28864804b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 11:01:44 -0300 Subject: [PATCH 5/6] Name research calls for what they did, not for the provider The transcript drew mcp__exa__web_search_exa as "Web search exa", telling the person which vendor the deployment buys search from. Research calls now read "Search the web" and "Read web pages" whichever provider made them, and the Bot is told not to name the provider unless asked. The provider tool table moves to shared/research.ts so the app can recognise these tools; a test keeps its keys in step with the catalogue. The Bot's access page still groups them under the provider. --- CHANGELOG.md | 3 ++ app/src/lib/plugins/tool-name.ts | 12 +++++ app/tests/tool-name.test.ts | 21 +++++++++ docs/web-research.md | 4 +- server/src/plugins/catalogue.ts | 57 +++-------------------- server/src/plugins/tools.ts | 7 ++- server/tests/web-research.test.ts | 11 ++++- shared/research.ts | 75 +++++++++++++++++++++++++++++-- 8 files changed, 129 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcaab996..21110535e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ See [docs/web-research.md](docs/web-research.md). that change, such as current figures, rankings or recent events, and stops after a search or two. Before, every Bot was told not to go looking, so one holding a search tool still answered from memory. A Bot with only its browser now says it can open pages but cannot search. +- **A conversation doesn't say which provider searched.** Research calls show as "Search the web" + and "Read web pages" rather than "Web search exa", and the Bot doesn't name the provider unless + asked. The Bot's access page still shows which provider it holds. - **The shipped `research-public-web` skill declares roles, not Parallel.** Its tools are now `@research/search` and `@research/fetch`, which match the search and fetch tools of every research provider the Bot was granted. A skill written by a person can declare the same roles, and the diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts index feb547749..02e27967d 100644 --- a/app/src/lib/plugins/tool-name.ts +++ b/app/src/lib/plugins/tool-name.ts @@ -1,3 +1,8 @@ +import { + RESEARCH_ROLE_LABELS, + researchRoleOfTool, +} from "@openbot/shared/research"; + /** * A tool call, named the way the person watching would name it. * @@ -22,6 +27,13 @@ export function readToolName(name: string): ToolName { const [, server, ...rest] = parts; const tool = rest.join("__"); + /* + * Research is named for what it did, not for who did it. `web_search_exa` would read "Web search + * exa", which tells the person which vendor this deployment buys search from and nothing about + * their answer. Which provider is granted is an administrator's fact, on the Bot's access page. + */ + const role = researchRoleOfTool(server ?? "", tool); + if (role) return { label: RESEARCH_ROLE_LABELS[role] }; const label = humanise(tool); /* diff --git a/app/tests/tool-name.test.ts b/app/tests/tool-name.test.ts index c2a5b40a1..cf5b1308c 100644 --- a/app/tests/tool-name.test.ts +++ b/app/tests/tool-name.test.ts @@ -8,6 +8,27 @@ import { readToolName } from "../src/lib/plugins/tool-name"; * that exists to keep two vendors' `search` apart. A reader should never see either. */ describe("naming a tool call", () => { + test("a research call says what it did and not which provider did it", () => { + for (const name of [ + "mcp__exa__web_search_exa", + "mcp__exa-authenticated__web_search_exa", + "mcp__parallel__web_search", + "mcp__firecrawl__firecrawl_search", + ]) { + expect(readToolName(name)).toEqual({ label: "Search the web" }); + } + expect(readToolName("mcp__exa__web_fetch_exa")).toEqual({ + label: "Read web pages", + }); + expect(readToolName("mcp__firecrawl__firecrawl_scrape")).toEqual({ + label: "Read web pages", + }); + // Only the research pair is relabelled; a provider's other tools keep their own names. + expect(readToolName("mcp__firecrawl__firecrawl_map").label).toBe( + "Firecrawl map", + ); + }); + test("an MCP tool reads as an action against a named server", () => { expect(readToolName("mcp__slack__post_message")).toEqual({ label: "Post message", diff --git a/docs/web-research.md b/docs/web-research.md index 1b590682d..091f5f764 100644 --- a/docs/web-research.md +++ b/docs/web-research.md @@ -25,6 +25,8 @@ A skill names the research tools it needs as `@research/search` and `@research/f ## Choice and controls +In a conversation, research calls are labelled for what they did, "Search the web" and "Read web pages", whichever provider made them, and the Bot does not name the provider unless asked. Which provider a Bot holds is shown on its access page. + A Bot holding more than one provider is told about each and uses the one the person asks for. Administrators can revoke tools, remove a connector or grant another provider. Every call goes through `PluginStore.callTool`, with the usual grants, action policies and audit records; no raw MCP server is added to the agent configuration. Exa and Firecrawl name the tools that only read (`readTools` in the catalogue), and every other tool they advertise counts as a write. Firecrawl publishes tool families such as `firecrawl_monitor_*`, and Exa's agent tools spend credits, so neither can be safely described by a list of writes alone. @@ -33,6 +35,6 @@ The provider receives model-selected objectives, search queries, requested publi ## Adding a provider -A provider is a catalogue entry in `server/src/plugins/catalogue.ts` with a `research: { search, fetch, notes? }` field naming its two tools, plus `readTools` when its write tools cannot be listed exhaustively. A vendor that reads its key from its own header names it in `auth: { kind: "deployment-bearer", header }`. The Bot guidance and the skill roles pick the new entry up with no other change. +A provider is a catalogue entry in `server/src/plugins/catalogue.ts`, plus its two tools in `RESEARCH_TOOLS` in `shared/research.ts` (`{ search, fetch, notes? }`, keyed by the entry's key). The table is shared because the app labels these tools too. Add `readTools` to the entry when its write tools cannot be listed exhaustively. A vendor that reads its key from its own header names it in `auth: { kind: "deployment-bearer", header }`. The Bot guidance, the skill roles and the transcript labels pick the new entry up with no other change. Provider documentation: [Parallel](https://docs.parallel.ai/integrations/mcp/search-mcp), [Exa](https://exa.ai/docs/reference/exa-mcp), [Firecrawl](https://docs.firecrawl.dev/mcp-server). diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index a548e4853..479fdd55e 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -31,7 +31,10 @@ import { isNeverAllowedHostname } from "../computer/target"; // Type-only, so naming the transport here creates no import cycle with the registry that resolves it. import type { CuratedTransportKind } from "./transport"; -import type { ResearchRole } from "@openbot/shared/research"; +import { + type ResearchRole, + researchRoleOfTool, +} from "@openbot/shared/research"; export type CatalogueAuth = /** Answers without any credential at all. */ @@ -156,57 +159,18 @@ export type CatalogueEntry = { * monitor deletion nobody listed would otherwise classify as a read. */ readTools?: readonly string[]; - /** - * Which of this server's tools do public-web research, by role. - * - * The roles are what the Bot's guidance and a skill's `@research/search` and `@research/fetch` - * declarations are written against, so a research skill works on whichever provider a - * deployment connected. The vendor's own names stay here, beside the host they belong to. - */ - research?: ResearchTools; docsUrl: string; }; -/** - * One provider's public-web research tools. - * - * `search` finds sources for a query and `fetch` reads pages by URL. Both are required, because a - * Bot that can find a page and cannot read it has links and no evidence. - */ -export type ResearchTools = { - search: string; - fetch: string; - /** How this provider's own parameters are best used, added to the shared guidance verbatim. */ - notes?: readonly string[]; -}; - /** The research role a granted `serverId/toolName` ref plays, or null when it plays none. */ export function researchRoleFor(ref: string): ResearchRole | null { // A tool without a ref plays no role. Bot instructions are built from this, so it never throws. if (typeof ref !== "string") return null; const slash = ref.indexOf("/"); if (slash <= 0) return null; - const research = catalogueEntry(ref.slice(0, slash))?.research; - const tool = ref.slice(slash + 1); - if (research?.search === tool) return "search"; - if (research?.fetch === tool) return "fetch"; - return null; + return researchRoleOfTool(ref.slice(0, slash), ref.slice(slash + 1)); } -const PARALLEL_RESEARCH: ResearchTools = Object.freeze({ - search: "web_search", - fetch: "web_fetch", - notes: Object.freeze([ - "Generate one session_id for the conversation and reuse it on related search and fetch calls.", - "Omit model_name unless the exact configured model identifier is known.", - ]), -}); - -const EXA_RESEARCH: ResearchTools = Object.freeze({ - search: "web_search_exa", - fetch: "web_fetch_exa", -}); - /** Exa's research and multi-step agent tools spend credits, so only the three that read are reads. */ const EXA_READ_TOOLS = Object.freeze([ "web_search_exa", @@ -214,11 +178,6 @@ const EXA_READ_TOOLS = Object.freeze([ "web_search_advanced_exa", ]); -const FIRECRAWL_RESEARCH: ResearchTools = Object.freeze({ - search: "firecrawl_search", - fetch: "firecrawl_scrape", -}); - /** Crawls, agents, browser sessions and feedback start work or change state at Firecrawl. */ const FIRECRAWL_WRITE_TOOLS = Object.freeze([ "firecrawl_crawl", @@ -265,7 +224,6 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ path: "/mcp", auth: { kind: "none" }, writeTools: Object.freeze([]), - research: PARALLEL_RESEARCH, docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp", }, { @@ -278,7 +236,6 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ path: "/mcp", auth: { kind: "deployment-bearer" }, writeTools: Object.freeze([]), - research: PARALLEL_RESEARCH, docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp", }, { @@ -292,7 +249,6 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ auth: { kind: "none" }, writeTools: Object.freeze([]), readTools: EXA_READ_TOOLS, - research: EXA_RESEARCH, docsUrl: "https://exa.ai/docs/reference/exa-mcp", }, { @@ -307,7 +263,6 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ auth: { kind: "deployment-bearer", header: "x-api-key" }, writeTools: Object.freeze([]), readTools: EXA_READ_TOOLS, - research: EXA_RESEARCH, docsUrl: "https://exa.ai/docs/reference/exa-mcp", }, { @@ -321,7 +276,6 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ auth: { kind: "none" }, writeTools: FIRECRAWL_WRITE_TOOLS, readTools: FIRECRAWL_READ_TOOLS, - research: FIRECRAWL_RESEARCH, docsUrl: "https://docs.firecrawl.dev/mcp-server", }, { @@ -335,7 +289,6 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ auth: { kind: "deployment-bearer" }, writeTools: FIRECRAWL_WRITE_TOOLS, readTools: FIRECRAWL_READ_TOOLS, - research: FIRECRAWL_RESEARCH, docsUrl: "https://docs.firecrawl.dev/mcp-server", }, diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 8ec8d38b1..baa73f39a 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -8,7 +8,7 @@ import { catalogueEntry, researchRoleFor, } from "./catalogue"; -import type { ResearchRole } from "@openbot/shared/research"; +import { type ResearchRole, RESEARCH_TOOLS } from "@openbot/shared/research"; import type { SelectableSkill } from "./selection"; import { isDeploymentFault, @@ -224,8 +224,11 @@ function researchGuidance(providers: ResearchProvider[]): string[] { "Keep it bounded: a search or two and a read of the best sources, then answer, and say what you could not confirm.", "These search the public web; they do not connect a person's private account. Send only the research objective, necessary search queries and requested URLs, not a full transcript or private documents.", ...providers.flatMap(({ entry }) => - (entry.research?.notes ?? []).map((note) => `${entry.vendor}: ${note}`), + (RESEARCH_TOOLS[entry.key]?.notes ?? []).map( + (note) => `${entry.vendor}: ${note}`, + ), ), + "Do not name the provider in your answer unless the person asks which one you used: cite the sources you read.", "Cite source URLs and distinguish excerpts from full-page reads. Report provider errors or missing sources; do not invent evidence or quietly bypass a denial. Interactive browser work still uses the authorized computer tools when needed.", ]; } diff --git a/server/tests/web-research.test.ts b/server/tests/web-research.test.ts index aa378bf48..4e4d30d92 100644 --- a/server/tests/web-research.test.ts +++ b/server/tests/web-research.test.ts @@ -1,7 +1,10 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import type { GrantedTool } from "@openbot/toolkit/granted-tool"; -import { researchRoleOf } from "@openbot/shared/research"; +import { + RESEARCH_TOOLS as SHARED_RESEARCH_TOOLS, + researchRoleOf, +} from "@openbot/shared/research"; import { parse } from "yaml"; import { z } from "zod"; import { @@ -52,7 +55,7 @@ describe("research providers in the catalogue", () => { test("names its search and fetch tools, and both read", () => { for (const [key, [search, fetch]] of Object.entries(RESEARCH_TOOLS)) { const entry = catalogueEntry(key)!; - expect(entry.research).toMatchObject({ search, fetch }); + expect(SHARED_RESEARCH_TOOLS[key]).toMatchObject({ search, fetch }); expect(classifyTool(entry, search, true)).toBe("read"); expect(classifyTool(entry, fetch, true)).toBe("read"); expect(classifyTool(entry, "not_advertised", false)).toBe("write"); @@ -61,6 +64,9 @@ describe("research providers in the catalogue", () => { expect(researchRoleFor(`${key}/${fetch}`)).toBe("fetch"); } expect(researchRoleFor("notion/notion-search")).toBeNull(); + // The app labels research from this table, so every key must be a real catalogue entry. + for (const key of Object.keys(SHARED_RESEARCH_TOOLS)) + expect(catalogueEntry(key)).not.toBeNull(); // Instructions are built from these, so a tool missing its ref must not throw. expect(researchRoleFor(undefined as never)).toBeNull(); expect( @@ -136,6 +142,7 @@ describe("what a Bot is told about research", () => { "- Exa: mcp__exa__web_search_exa finds sources and mcp__exa__web_fetch_exa reads selected pages.", ); expect(exa).not.toContain("session_id"); + expect(exa).toContain("Do not name the provider in your answer"); expect(grantedToolGuidance(pair())).toContain( "Parallel: Generate one session_id", ); diff --git a/shared/research.ts b/shared/research.ts index 4577d4015..276369769 100644 --- a/shared/research.ts +++ b/shared/research.ts @@ -2,11 +2,78 @@ * Public-web research by role rather than by provider, shared by the server and the app. * * A skill declares `@research/search` and `@research/fetch` instead of one provider's tool names, - * and the server matches each to that role's tool on every research connector in its catalogue. So - * a research skill keeps working when a deployment switches provider. + * and the server matches each to that role's tool on every research connector. So a research skill + * keeps working when a deployment switches provider, and the transcript can say "Search the web" + * instead of naming whichever vendor answered. */ export type ResearchRole = "search" | "fetch"; +/** + * One provider's public-web research tools. + * + * `search` finds sources for a query and `fetch` reads pages by URL. Both are required, because a + * Bot that can find a page and cannot read it has links and no evidence. + */ +export type ResearchTools = { + search: string; + fetch: string; + /** How this provider's own parameters are best used, added to the shared guidance verbatim. */ + notes?: readonly string[]; +}; + +const PARALLEL: ResearchTools = Object.freeze({ + search: "web_search", + fetch: "web_fetch", + notes: Object.freeze([ + "Generate one session_id for the conversation and reuse it on related search and fetch calls.", + "Omit model_name unless the exact configured model identifier is known.", + ]), +}); + +const EXA: ResearchTools = Object.freeze({ + search: "web_search_exa", + fetch: "web_fetch_exa", +}); + +const FIRECRAWL: ResearchTools = Object.freeze({ + search: "firecrawl_search", + fetch: "firecrawl_scrape", +}); + +/** + * The research tools on each catalogue entry, by the entry's key. + * + * Here rather than on the entries in `server/src/plugins/catalogue.ts` because the app labels these + * tools too, and it cannot import the server. A key here that is not a catalogue entry is a test + * failure, so the two lists cannot drift. + */ +export const RESEARCH_TOOLS: Readonly> = + Object.freeze({ + parallel: PARALLEL, + "parallel-authenticated": PARALLEL, + exa: EXA, + "exa-authenticated": EXA, + firecrawl: FIRECRAWL, + "firecrawl-authenticated": FIRECRAWL, + }); + +/** What a research call is called on screen, whichever provider made it. */ +export const RESEARCH_ROLE_LABELS: Readonly> = + Object.freeze({ search: "Search the web", fetch: "Read web pages" }); + +/** The research role a tool on this server plays, or null when it plays none. */ +export function researchRoleOfTool( + server: string, + tool: string, +): ResearchRole | null { + const research = Object.hasOwn(RESEARCH_TOOLS, server) + ? RESEARCH_TOOLS[server] + : undefined; + if (research?.search === tool) return "search"; + if (research?.fetch === tool) return "fetch"; + return null; +} + /** * Shaped like a `serverId/toolName` ref so a declaration passes the same check as any other, and * starting with a character no server id may contain, so it can never be mistaken for one. @@ -14,8 +81,8 @@ export type ResearchRole = "search" | "fetch"; export const RESEARCH_ROLE_PREFIX = "@research"; export const RESEARCH_ROLES: readonly { ref: string; label: string }[] = [ - { ref: `${RESEARCH_ROLE_PREFIX}/search`, label: "Search the web" }, - { ref: `${RESEARCH_ROLE_PREFIX}/fetch`, label: "Read pages" }, + { ref: `${RESEARCH_ROLE_PREFIX}/search`, label: RESEARCH_ROLE_LABELS.search }, + { ref: `${RESEARCH_ROLE_PREFIX}/fetch`, label: RESEARCH_ROLE_LABELS.fetch }, ]; /** The research role a skill's tool ref names, or null when it names a server's own tool. */ From fb94e40de8ca5a78f708b5c4eec09672095333a7 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 14 Oct 2026 13:49:25 -0300 Subject: [PATCH 6/6] Fold a reply's tool calls into one line (#791) * Fold a reply's tool calls into one line Adjacent connector calls and browser steps used to draw a line each above the answer. They now fold into one row that says what was done ("Searched the web and read 2 pages"), with an icon per kind of step, the running step's name while it works, and a count of blocked or failed steps. Opened, each step shows its query, page or connector, and opens to its inputs and output. The browser grouping this replaces folded only browser steps; groupToolSteps folds both. A single connector call keeps its own line, and a Team Bot consent request, a tool the app draws itself, and any pending approval stay outside the fold. * Draw a tool group's icons at a readable size The stack's icons were 11px in 20px circles and the step icons 13px, too small to tell a search from a page at a glance. Stack icons are now 15px in 24px circles and step icons 15px. --- CHANGELOG.md | 10 + .../components/channels/browser-activity.tsx | 98 +--- .../components/channels/chat-transcript.tsx | 12 +- app/src/components/channels/tool-activity.tsx | 440 ++++++++++++++++++ app/src/components/channels/tool-line.tsx | 4 + app/tests/approvals-rules.test.tsx | 16 +- app/tests/browser-activity.test.ts | 18 +- app/tests/tool-activity.test.tsx | 148 ++++++ 8 files changed, 626 insertions(+), 120 deletions(-) create mode 100644 app/src/components/channels/tool-activity.tsx create mode 100644 app/tests/tool-activity.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 21110535e..2aa59af7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `code_required` (below). A server and a computer must be upgraded together: an older computer has no `/sign-in/code`. +### A reply's tool calls fold into one line + +A Bot that searched, read two pages and checked Drive before answering drew four lines above its +answer. Adjacent connector calls and browser steps now fold into one line that says what was done, +such as "Searched the web and read 2 pages", with an icon for each kind of step. It names the step in +progress while the Bot works, and counts issues when a step was blocked or failed. Opened, it lists +each step with its query, page or connector, and each step opens to what it was given and what came +back. A single connector call keeps its own line, and approvals, connection requests and anything the +app draws itself stay outside the fold. + ### Public-web research works with Exa and Firecrawl, not only Parallel Four new catalogue entries give a Bot public-web search and page reading without its browser: diff --git a/app/src/components/channels/browser-activity.tsx b/app/src/components/channels/browser-activity.tsx index c0cae4c54..4995fdf2d 100644 --- a/app/src/components/channels/browser-activity.tsx +++ b/app/src/components/channels/browser-activity.tsx @@ -1,14 +1,6 @@ -import { PendingApprovals } from "@/components/approvals/inline-approval"; import type { VisibleChatItem } from "./chat-messages"; -import { ToolLine } from "./tool-line"; type BrowserStep = Extract; -export type BrowserGroup = { - kind: "browser"; - id: string; - steps: BrowserStep[]; -}; -export type TranscriptItem = VisibleChatItem | BrowserGroup; const labels: Record = { computer_navigate: "Opened page", @@ -20,24 +12,9 @@ const labels: Record = { computer_scroll: "Scrolled page", }; -/** Group adjacent browsing steps without moving messages or hiding requests for human help. */ -export function groupBrowserSteps( - items: readonly VisibleChatItem[], -): TranscriptItem[] { - const grouped: TranscriptItem[] = []; - for (const item of items) { - if ( - item.kind !== "tool" || - !Object.hasOwn(labels, item.toolCall.function.name) - ) { - grouped.push(item); - continue; - } - const last = grouped.at(-1); - if (last?.kind === "browser") last.steps.push(item); - else grouped.push({ kind: "browser", id: item.id, steps: [item] }); - } - return grouped; +/** Whether a tool call is one of the browser steps a tool group draws with its page link. */ +export function isBrowserStep(item: BrowserStep): boolean { + return Object.hasOwn(labels, item.toolCall.function.name); } function objectOf(value: string | undefined): Record { @@ -83,72 +60,3 @@ export function browserStepDetails(step: BrowserStep) { pending: step.result === undefined, }; } - -export function BrowserActivity({ - group, - active, -}: { - group: BrowserGroup; - active: boolean; -}) { - const steps = group.steps.map((step) => ({ - id: step.id, - ...browserStepDetails(step), - })); - const pages = steps.filter((step) => step.visited).length; - const issues = steps.filter((step) => step.failed).length; - const running = active && steps.some((step) => step.pending); - // The folded line draws its own steps, so a waiting approval gets its card beside it, not inside. - return ( - <> - -
    - {steps.map((step) => ( -
  1. -
    - - {step.label} - - {step.href ? ( - - {step.title ?? step.href} - - ) : null} - {step.pending && ( - - {active ? "In progress" : "Not completed"} - - )} -
    - {step.failed && ( -

    - {step.reason ?? "This step could not be completed."} -

    - )} -
  2. - ))} -
-
- step.toolCall.id)} - /> - - ); -} diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index d6ac4cd2f..e890e2ec0 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -62,10 +62,10 @@ import { readTeamBotConsent } from "@/lib/team-bots"; import { cn } from "@/lib/utils"; import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive"; import { - BrowserActivity, - groupBrowserSteps, + groupToolSteps, + ToolActivity, type TranscriptItem, -} from "./browser-activity"; +} from "./tool-activity"; import { attachmentModality, type SentAttachment, @@ -1525,7 +1525,7 @@ export function ChatTranscript({ * cost was markdown parsing and chart SVGs, and those are skipped by the memoised children below, * which is where the 25x came from. This runs per render and is not worth guarding. */ - const items = groupBrowserSteps(toVisibleChatItems(messages)); + const items = groupToolSteps(toVisibleChatItems(messages)); /* * ONLY WHILE THERE IS NOTHING ELSE TO LOOK AT. Once a reply starts streaming, or a tool line @@ -1668,12 +1668,12 @@ export function ChatTranscript({
{items.length === 0 && restoring ? : null} {items.map((item, index) => - item.kind === "browser" ? ( + item.kind === "tools" ? ( - diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx new file mode 100644 index 000000000..5fc9e69d5 --- /dev/null +++ b/app/src/components/channels/tool-activity.tsx @@ -0,0 +1,440 @@ +import { + IconFileText, + IconPlug, + IconSearch, + IconWorld, +} from "@tabler/icons-react"; +import { Streamdown } from "streamdown"; +import { PendingApprovals } from "@/components/approvals/inline-approval"; +import { markdownComponents } from "@/lib/markdown"; +import { readToolName } from "@/lib/plugins/tool-name"; +import { + asText, + forDisplay, + REFUSAL_MARKER, + toolResultFailed, +} from "@/lib/plugins/tool-result"; +import { readTeamBotConsent } from "@/lib/team-bots"; +import { researchRoleOfTool } from "@openbot/shared/research"; +import { browserStepDetails, isBrowserStep } from "./browser-activity"; +import type { VisibleChatItem } from "./chat-messages"; +import { ToolLine } from "./tool-line"; + +type ToolItem = Extract; + +/** Adjacent things a Bot did, drawn as one line that opens to each of them. */ +export type ToolGroup = { + kind: "tools"; + id: string; + steps: ToolItem[]; +}; +export type TranscriptItem = VisibleChatItem | ToolGroup; + +/** + * Fold adjacent tool calls into one row, so a reply that searched, read two pages and checked Drive + * reads as one thing the Bot did rather than four lines before the answer. + * + * Only calls drawn as a plain line are folded: browser steps and connector (MCP) tools. A tool the + * app draws itself, a chart or a request for help, stays where it is, and so does a refusal that + * asks the person to connect their own account, because a question for the person must never sit + * behind a disclosure. A connector call on its own keeps its own line; one browser step is still a + * group, as it always was, so the row does not change shape when the second step arrives. + * + * The group takes its first step's id, so the row keeps its identity while later steps stream in. + */ +export function groupToolSteps( + items: readonly VisibleChatItem[], +): TranscriptItem[] { + const grouped: TranscriptItem[] = []; + let run: ToolItem[] = []; + const close = () => { + const [first] = run; + if (!first) return; + if (run.length === 1 && !isBrowserStep(first)) grouped.push(first); + else grouped.push({ kind: "tools", id: first.id, steps: run }); + run = []; + }; + for (const item of items) { + if (item.kind === "tool" && foldable(item)) { + run.push(item); + continue; + } + close(); + grouped.push(item); + } + close(); + return grouped; +} + +function foldable(item: ToolItem): boolean { + if (isBrowserStep(item)) return true; + if (!item.toolCall.function.name.startsWith("mcp__")) return false; + return !asksForConsent(item.result); +} + +function asksForConsent(result: string | undefined): boolean { + if (result === undefined) return false; + const answer = asText(result); + if (!answer.startsWith(REFUSAL_MARKER)) return false; + return ( + readTeamBotConsent(answer.slice(REFUSAL_MARKER.length).trim()) !== null + ); +} + +type StepKind = "search" | "fetch" | "browser" | "connector"; + +export type StepDetails = { + id: string; + kind: StepKind; + label: string; + /** What it was done to: a query, a page, a connector. Muted beside the label. */ + detail?: string; + href?: string; + pending: boolean; + failed: boolean; + refused: boolean; + reason?: string; + /** Pages a browser step opened, or a fetch read; zero for anything else. */ + pages: number; + /** The connector a connector step reached, said the way a person would. */ + connector?: string; + inputs: [string, string][]; + output?: string; +}; + +function objectOf(value: string | undefined): Record { + try { + const parsed: unknown = JSON.parse(value ?? ""); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + if (Array.isArray(value)) { + const found = value.find( + (entry): entry is string => typeof entry === "string" && !!entry.trim(), + ); + if (found) return found.trim(); + } + } + return undefined; +} + +function urlsOf(args: Record): string[] { + const urls = Array.isArray(args.urls) ? args.urls : [args.url]; + return urls.filter( + (url): url is string => typeof url === "string" && !!url.trim(), + ); +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** "google-drive" as "Google Drive". */ +function connectorName(key: string): string { + return key + .split(/[-_]/) + .filter(Boolean) + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "); +} + +/** A value from a tool's arguments, short enough for one line. */ +function inputValue(value: unknown): string { + const text = typeof value === "string" ? value : JSON.stringify(value); + return text.length > 200 ? `${text.slice(0, 199)}…` : text; +} + +export function stepDetails(step: ToolItem): StepDetails { + const args = objectOf(step.toolCall.function.arguments); + const inputs = Object.entries(args).map( + ([key, value]) => [key, inputValue(value)] as [string, string], + ); + if (isBrowserStep(step)) { + const browser = browserStepDetails(step); + return { + id: step.id, + kind: "browser", + label: browser.label ?? "Used the browser", + ...(browser.title ? { detail: browser.title } : {}), + ...(browser.href ? { href: browser.href } : {}), + pending: browser.pending, + failed: browser.failed, + refused: false, + ...(browser.reason ? { reason: browser.reason } : {}), + pages: browser.visited ? 1 : 0, + inputs, + }; + } + + const name = step.toolCall.function.name; + const [, server = "", ...rest] = name.split("__"); + const role = researchRoleOfTool(server, rest.join("__")); + const { label, detail } = readToolName(name); + const answer = step.result === undefined ? undefined : asText(step.result); + const refused = answer?.startsWith(REFUSAL_MARKER) ?? false; + const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer; + const urls = role === "fetch" ? urlsOf(args) : []; + const base = { + id: step.id, + label, + pending: step.result === undefined, + failed: !refused && toolResultFailed(step.result), + refused, + inputs, + ...(body ? { output: body } : {}), + }; + if (role === "search") { + const query = firstString( + args.query, + args.objective, + args.search_queries, + args.queries, + ); + return { + ...base, + kind: "search", + ...(query ? { detail: query } : {}), + pages: 0, + }; + } + if (role === "fetch") { + const [first, ...more] = urls; + const href = first && /^https?:\/\//.test(first) ? first : undefined; + return { + ...base, + kind: "fetch", + ...(first + ? { + detail: more.length + ? `${hostOf(first)} and ${more.length} more` + : hostOf(first), + } + : {}), + ...(href && !more.length ? { href } : {}), + pages: Math.max(urls.length, 1), + }; + } + return { + ...base, + kind: "connector", + ...(detail ? { detail } : {}), + pages: 0, + connector: connectorName(server), + }; +} + +function joinPhrases(phrases: string[]): string { + if (phrases.length <= 1) return phrases[0] ?? ""; + return `${phrases.slice(0, -1).join(", ")} and ${phrases.at(-1)}`; +} + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +/** + * What a group of steps did, as one phrase: "Searched the web and read 2 pages". + * + * Counted by kind rather than listed, because the list is what the line opens to. Research is + * never attributed to a provider; a connector is named, because which system was reached is the + * person's own fact. + */ +export function summarise(steps: readonly StepDetails[]): string { + const of = (kind: StepKind) => steps.filter((step) => step.kind === kind); + const phrases: string[] = []; + if (of("search").length) phrases.push("searched the web"); + const read = of("fetch").reduce((sum, step) => sum + step.pages, 0); + if (read) phrases.push(`read ${plural(read, "page", "pages")}`); + const browser = of("browser"); + const browsed = browser.reduce((sum, step) => sum + step.pages, 0); + if (browsed) phrases.push(`browsed ${plural(browsed, "page", "pages")}`); + else if (browser.length) phrases.push("used the browser"); + const connectors = [ + ...new Set(of("connector").map((step) => step.connector ?? "")), + ].filter(Boolean); + if (connectors.length === 1) phrases.push(`used ${connectors[0]}`); + else if (connectors.length > 1) + phrases.push( + `used ${plural(connectors.length, "connector", "connectors")}`, + ); + const sentence = joinPhrases(phrases); + return sentence.charAt(0).toUpperCase() + sentence.slice(1); +} + +const KIND_ICONS = { + search: IconSearch, + fetch: IconFileText, + browser: IconWorld, + connector: IconPlug, +} as const; + +function StepIcon({ + kind, + size = "size-4", +}: { + kind: StepKind; + size?: string; +}) { + const Icon = KIND_ICONS[kind]; + return ; +} + +/** + * One small icon per kind of step, overlapped, in the order they first happened. + * + * The first is drawn on top. Drawn the other way, each circle's ring cuts into the icon before it, + * and a magnifying glass with its handle cut off reads as the letter C. + */ +function IconStack({ kinds }: { kinds: StepKind[] }) { + return ( + + {kinds.map((kind, index) => ( + 0 ? "-ml-1.5" : "" + }`} + > + + + ))} + + ); +} + +function StepStatus({ step, active }: { step: StepDetails; active: boolean }) { + if (step.pending) + return ( + + {active ? "In progress" : "Not completed"} + + ); + if (step.refused) + return Blocked; + if (step.failed) + return ( + + Didn't work + + ); + return null; +} + +function StepRow({ step, active }: { step: StepDetails; active: boolean }) { + const line = ( + + + {step.label} + {step.href ? ( + + {step.detail ?? step.href} + + ) : step.detail ? ( + {step.detail} + ) : null} + + + ); + const reason = + step.failed && step.kind === "browser" ? ( +

+ {step.reason ?? "This step could not be completed."} +

+ ) : null; + // A browser step says everything it has in its line. Anything else opens to what it was given + // and what came back, which no other place in the transcript shows. + if (step.kind === "browser" || (!step.inputs.length && !step.output)) + return ( +
  • + {line} + {reason} +
  • + ); + return ( +
  • +
    + + + ▸ + + {line} + +
    + {step.inputs.length ? ( +
    + {step.inputs.map(([key, value]) => ( +
    +
    {key}
    +
    {value}
    +
    + ))} +
    + ) : null} + {step.output ? ( +
    + + {forDisplay(step.output)} + +
    + ) : null} +
    +
    +
  • + ); +} + +export function ToolActivity({ + group, + active, +}: { + group: ToolGroup; + /** This is the newest row of a turn still in flight, so a step without a result is running. */ + active: boolean; +}) { + const steps = group.steps.map(stepDetails); + const running = active && steps.some((step) => step.pending); + const current = steps.find((step) => step.pending); + const issues = steps.filter((step) => step.failed || step.refused).length; + const kinds = [...new Set(steps.map((step) => step.kind))]; + // The folded line draws its own steps, so a waiting approval gets its card beside it, not inside. + return ( + <> + } + label={running && current ? `${current.label}…` : summarise(steps)} + detail={issues ? plural(issues, "issue", "issues") : undefined} + running={running} + > +
      + {steps.map((step) => ( + + ))} +
    +
    + step.toolCall.id)} + /> + + ); +} diff --git a/app/src/components/channels/tool-line.tsx b/app/src/components/channels/tool-line.tsx index 1e13bb3c3..91306c720 100644 --- a/app/src/components/channels/tool-line.tsx +++ b/app/src/components/channels/tool-line.tsx @@ -11,6 +11,7 @@ import type { ReactNode } from "react"; * something with nothing to show yet. */ export function ToolLine({ + icon, label, detail, running, @@ -18,6 +19,8 @@ export function ToolLine({ failed, children, }: { + /** Drawn before the label, small and muted. A group of steps puts its kinds here. */ + icon?: ReactNode; /** What was done, in a couple of words: "Searched Slack", "Filled in", "Read the page". */ label: string; /** What it was done to. Muted, and truncated rather than wrapped. */ @@ -42,6 +45,7 @@ export function ToolLine({ running ? "tool-line-running" : "" }`} > + {icon ? {icon} : null} {refused ? `Blocked: ${label}` diff --git a/app/tests/approvals-rules.test.tsx b/app/tests/approvals-rules.test.tsx index 60d9da290..3b5521008 100644 --- a/app/tests/approvals-rules.test.tsx +++ b/app/tests/approvals-rules.test.tsx @@ -26,9 +26,9 @@ import userEvent from "@testing-library/user-event"; import { ApprovalSettings } from "@/components/approvals/settings"; import { InlineApproval } from "@/components/approvals/inline-approval"; import { - BrowserActivity, - groupBrowserSteps, -} from "@/components/channels/browser-activity"; + groupToolSteps, + ToolActivity, +} from "@/components/channels/tool-activity"; import { toVisibleChatItems } from "@/components/channels/chat-messages"; beforeAll(() => GlobalRegistrator.register()); @@ -382,7 +382,7 @@ test("a pending change is drawn in the conversation where the action was, and de test("in the real transcript shape a waiting click is folded into browser activity, so its card is drawn beside the group", async () => { // What the channel transcript builds from the conversation: a navigate that finished and the // click the chat stopped on, unanswered, grouped into one browser-activity line. - const items = groupBrowserSteps( + const items = groupToolSteps( toVisibleChatItems([ { id: "u1", role: "user", content: "Order a pizza on httpbin" }, { @@ -416,16 +416,16 @@ test("in the real transcript shape a waiting click is folded into browser activi }, ] as never), ); - const group = items.find((item) => item.kind === "browser"); - if (group?.kind !== "browser") throw new Error("no browser group"); + const group = items.find((item) => item.kind === "tools"); + if (group?.kind !== "tools") throw new Error("no tool group"); const view = render( - {/* BrowserActivity mounts the waiting cards beside its folded line itself. */} - + {/* ToolActivity mounts the waiting cards beside its folded line itself. */} + , ); await view.findByRole("region", { name: "Waiting for your approval" }); diff --git a/app/tests/browser-activity.test.ts b/app/tests/browser-activity.test.ts index 445761a5c..8b6ea5852 100644 --- a/app/tests/browser-activity.test.ts +++ b/app/tests/browser-activity.test.ts @@ -1,8 +1,6 @@ import { expect, test } from "bun:test"; -import { - browserStepDetails, - groupBrowserSteps, -} from "../src/components/channels/browser-activity"; +import { browserStepDetails } from "../src/components/channels/browser-activity"; +import { groupToolSteps } from "../src/components/channels/tool-activity"; import type { VisibleChatItem } from "../src/components/channels/chat-messages"; function step( @@ -25,13 +23,11 @@ function step( test("navigation and intervening browser actions become one stable row as results stream", () => { const first = step("one"); const items = [first, step("read", "computer_read"), step("two")]; - const grouped = groupBrowserSteps(items); - expect(grouped).toEqual([{ kind: "browser", id: "one", steps: items }]); + const grouped = groupToolSteps(items); + expect(grouped).toEqual([{ kind: "tools", id: "one", steps: items }]); expect( - groupBrowserSteps([ - { ...first, result: '{"ok":true}' }, - ...items.slice(1), - ])[0]?.id, + groupToolSteps([{ ...first, result: '{"ok":true}' }, ...items.slice(1)])[0] + ?.id, ).toBe("one"); expect(items).toHaveLength(3); }); @@ -50,7 +46,7 @@ test("grouping keeps messages, other tools, and human assistance requests in the step("shell", "computer_run_command"), ]; for (const boundary of boundaries) { - const grouped = groupBrowserSteps([step("one"), boundary, step("two")]); + const grouped = groupToolSteps([step("one"), boundary, step("two")]); expect(grouped.map((item) => item.id)).toEqual(["one", boundary.id, "two"]); expect(grouped[1]).toBe(boundary); } diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx new file mode 100644 index 000000000..16114b3d3 --- /dev/null +++ b/app/tests/tool-activity.test.tsx @@ -0,0 +1,148 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import type { VisibleChatItem } from "../src/components/channels/chat-messages"; +import { + groupToolSteps, + stepDetails, + summarise, + ToolActivity, + type ToolGroup, +} from "../src/components/channels/tool-activity"; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +type ToolItem = Extract; + +function call( + id: string, + name: string, + args: Record = {}, + result?: string, +): ToolItem { + return { + kind: "tool", + id, + toolCall: { + id, + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ...(result === undefined ? {} : { result }), + }; +} + +const search = (id = "search", result = '"Results"') => + call(id, "mcp__exa__web_search_exa", { query: "top 5 GDP 2025" }, result); +const fetch = (urls: string[], id = "fetch") => + call(id, "mcp__exa__web_fetch_exa", { urls }, '"Page text"'); +const text = (id: string): VisibleChatItem => ({ + kind: "text", + id, + role: "assistant", + text: "Here is what I found.", +}); + +test("adjacent connector calls fold into one row named after the first", () => { + const items = [ + search(), + fetch(["https://imf.org/a"]), + call("drive", "mcp__google-drive__search_files", { q: "budget" }, '"x"'), + text("answer"), + ]; + const grouped = groupToolSteps(items); + expect(grouped.map((item) => item.kind)).toEqual(["tools", "text"]); + expect(grouped[0]).toMatchObject({ id: "search", steps: items.slice(0, 3) }); +}); + +test("one connector call keeps its own line, and anything the app draws stays where it is", () => { + expect(groupToolSteps([search()])).toEqual([search()]); + const chart = call("chart", "showBarChart"); + const help = call("help", "computer_request_help"); + for (const boundary of [chart, help, text("between")]) { + const grouped = groupToolSteps([search("a"), boundary, search("b")]); + expect(grouped.map((item) => item.id)).toEqual(["a", boundary.id, "b"]); + } +}); + +test("a request to connect the person's own account is never folded away", () => { + const consent = call( + "consent", + "mcp__gmail__send", + {}, + JSON.stringify( + 'Refused. [team-bot-consent]{"botId":"b","serverId":"gmail"} Use your own Gmail?', + ), + ); + const grouped = groupToolSteps([search("a"), consent, search("b")]); + expect(grouped.map((item) => item.id)).toContain("consent"); +}); + +test("the summary says what was done, never which provider searched", () => { + const steps = [ + search(), + fetch(["https://imf.org/a", "https://worldbank.org/b"]), + ].map(stepDetails); + expect(summarise(steps)).toBe("Searched the web and read 2 pages"); + expect(JSON.stringify(steps.map((step) => step.label))).not.toContain("exa"); + expect(steps[0]?.detail).toBe("top 5 GDP 2025"); + expect(steps[1]?.detail).toBe("imf.org and 1 more"); + expect( + summarise( + [ + call("d1", "mcp__google-drive__search_files", {}, '"x"'), + call("d2", "mcp__google-drive__read_file", {}, '"x"'), + call("n", "mcp__notion__search", {}, '"x"'), + ].map(stepDetails), + ), + ).toBe("Used 2 connectors"); + expect( + summarise( + [call("d", "mcp__google-drive__search_files", {}, '"x"')].map( + stepDetails, + ), + ), + ).toBe("Used Google Drive"); +}); + +test("a refusal and a failure each count as an issue on the folded line", () => { + const group: ToolGroup = { + kind: "tools", + id: "a", + steps: [ + search("a", JSON.stringify("Refused. Not allowed here.")), + search("b", JSON.stringify("That tool could not be called: timeout")), + ], + }; + const view = render( + + + , + ); + expect(view.getByText("2 issues")).toBeDefined(); + expect(view.getByText("Blocked")).toBeDefined(); + expect(view.getByText("Didn't work")).toBeDefined(); +}); + +test("while running, the line names the step in progress; opened, a step shows its inputs", () => { + const group: ToolGroup = { + kind: "tools", + id: "a", + steps: [ + search(), + call("f", "mcp__exa__web_fetch_exa", { urls: ["https://imf.org"] }), + ], + }; + const view = render( + + + , + ); + expect(view.getByText("Read web pages…")).toBeDefined(); + fireEvent.click(view.getAllByText("Search the web")[0] as HTMLElement); + expect(view.getByText("query")).toBeDefined(); + expect(view.getAllByText("top 5 GDP 2025").length).toBeGreaterThan(0); +});