import z from "zod" import { Tool } from "./tool" import { registry } from "../science/connectors" import type { ConnectorHit } from "../science/connectors" /** * Small, database-agnostic surface over the scientific connector registry. * * There are intentionally only TWO tools regardless of how many databases are * registered — the model picks a `db` id from `science_list_dbs` and searches * through `science_search`. This keeps the tool count flat as connectors grow. */ export const ScienceListDbsTool = Tool.define("science_list_dbs", { description: [ "List the scientific databases available to search via `science_search`.", "Returns each database's id, name, domain, and description.", "Call this first to discover which `db` id to pass to `science_search`.", ].join("\n"), parameters: z.object({ domain: z .string() .optional() .describe("Optional domain filter (e.g. 'chemistry', 'biology', 'literature', 'structure')"), }), async execute(params, _ctx) { const entries = registry.catalog().filter((e) => !params.domain || e.domain === params.domain) if (!entries.length) { return { title: "Scientific databases", output: params.domain ? `No databases registered for domain "${params.domain}".` : "No scientific databases are registered yet.", metadata: { count: 0, domains: [] as string[] }, } } const byDomain = new Map() for (const e of entries) { const list = byDomain.get(e.domain) ?? [] list.push(e) byDomain.set(e.domain, list) } const sections = [...byDomain.entries()].map(([domain, list]) => { const rows = list.map((e) => `- **${e.id}** (${e.name}) — ${e.description}`) return `### ${domain}\n${rows.join("\n")}` }) return { title: `Scientific databases (${entries.length})`, output: sections.join("\n\n"), metadata: { count: entries.length, domains: [...byDomain.keys()] }, } }, }) export const ScienceSearchTool = Tool.define("science_search", { description: [ "Search a scientific database registered in the connector registry.", "Pass a `db` id (from `science_list_dbs`) and a `query`.", "Returns normalized hits: id, title, summary, and URL.", ].join("\n"), parameters: z.object({ db: z.string().describe("Database id to search (from science_list_dbs, e.g. 'uniprot', 'arxiv')"), query: z.string().describe("Search query in the database's native syntax"), limit: z.number().default(10).describe("Max results (1-50)"), organism: z.string().optional().describe("Optional organism/taxon filter where supported"), }), async execute(params, ctx) { const connector = registry.get(params.db) if (!connector) { const available = registry .catalog() .map((e) => e.id) .join(", ") return { title: "Unknown database", output: `No database "${params.db}". Available: ${available || "(none registered)"}. Use science_list_dbs.`, metadata: { error: "unknown_db" } as Record, } } const limit = Math.min(Math.max(params.limit, 1), 50) let hits: ConnectorHit[] try { hits = await connector.search(params.query, { limit, organism: params.organism, signal: ctx.abort, }) } catch (err) { // A source error is NOT the same as "no results" — surface it as an // actionable, degraded result instead of throwing a raw `HTTP 429` string. if (ctx.abort.aborted) throw err const message = err instanceof Error ? err.message : String(err) const rateLimited = /\b(429|503|408)\b/.test(message) || /rate.?limit/i.test(message) const guidance = rateLimited ? `${connector.name} is rate limiting requests. Wait a few seconds, then retry${ connector.id === "arxiv" ? " (arXiv allows ~1 request every 3s)" : "" }.` : `${connector.name} returned an error: ${message}` return { title: `${connector.name} temporarily unavailable — ${rateLimited ? "rate limited, retry shortly" : "source error"}`, output: [`Could not complete the search for "${params.query}".`, guidance].join("\n"), metadata: { db: connector.id, count: 0, error: rateLimited ? "rate_limited" : "source_error", message, } as Record, } } if (!hits.length) { return { title: `${connector.name}: ${params.query}`, output: `No results for "${params.query}" in ${connector.name}.`, metadata: { db: connector.id, count: 0 } as Record, } } const rows = hits.map((h) => { const lines = [`## ${h.title}`, `**id**: ${h.id}${h.score !== undefined ? ` · score: ${h.score}` : ""}`] if (h.url) lines.push(`**url**: ${h.url}`) if (h.summary) lines.push(h.summary) return lines.join("\n") }) return { title: `${connector.name}: ${params.query}`, output: [`**${connector.name}** — ${hits.length} result(s):`, "", rows.join("\n\n---\n\n")].join("\n"), metadata: { db: connector.id, count: hits.length } as Record, } }, }) export const ScienceTools = [ScienceListDbsTool, ScienceSearchTool] export const SCIENCE_TOOL_IDS = new Set(["science_list_dbs", "science_search"])