UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

381 lines (380 loc) • 19.8 kB
import { Type } from "typebox"; import { Value } from "typebox/value"; import { defineTool, errorResult } from "../tools.js"; import { formatZodValidationError, truncateError } from "../tool-errors.js"; import { toolFace } from "../tool-catalog-entries.js"; export const TOOL_SEARCH_NAME = "ToolSearch"; export const TOOL_SEARCH_DEFAULT_MAX_RESULTS = 5; const MAX_QUERY_RESULTS = 25; export const DEFERRED_NO_PROGRESS_LIMIT = 3; export const DEFERRED_SCHEMA_INCOMPATIBLE_CODE = "tool.deferred_schema_incompatible"; const EMPTY_PARAMS = Type.Object({}); export function deferHint(description, max = 120) { const first = (description.split("\n").find((l) => l.trim() !== "") ?? "").trim(); return first.length > max ? `${first.slice(0, max - 1)}…` : first; } export function safeName(name) { return name.replace(/[\x00-\x1f<>"`]/g, ""); } export function buildDeferredRegistry(deferred, tools) { const reg = new Map(); for (const t of tools) { if (deferred.has(t.name)) reg.set(t.name, { name: t.name, hint: deferHint(t.description), description: t.description, ...(t.aliases !== undefined && t.aliases.length > 0 ? { aliases: [...t.aliases] } : {}) }); } return reg; } function renderSchemaForModel(schema) { let json; try { json = JSON.stringify(schema); } catch { return undefined; } if (json === undefined) return undefined; const text = truncateError(json); return { text, complete: text === json }; } export function staticSchemaRenderable(schema) { return schema !== undefined && renderSchemaForModel(schema)?.complete === true; } export function createPlaceholderTool(info, direct) { const sn = safeName(info.name); const teachingRejection = () => { throw new Error(`Tool "${sn}" is not active yet. Call ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} ` + `(or a keyword \`query\`) to load its full schema, then call ${sn} with the proper arguments.`); }; let lastFailureShape; let repeats = 0; const invalidArgumentsRejection = (target, params, schema, ride) => { const completeness = schema.complete ? "its full parameter schema" : "an ABRIDGED copy of its parameter schema (too large to inline in full)"; const durability = !schema.complete ? `${completeness} is below; the tools list carries the complete declaration. ` : direct?.staticFace?.() === true ? `${completeness} is below and is carried by THIS result only — the tools list keeps the compact ` + `placeholder entry, so re-run ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} if you need the schema again later. ` : `${completeness} is below, and the next request's tools list will advertise it too. `; const shape = formatZodValidationError(target.parameters, params); repeats = shape === lastFailureShape ? repeats + 1 : 1; lastFailureShape = shape; if (repeats >= DEFERRED_NO_PROGRESS_LIMIT) { const stuck = `\`${sn}\` rejected the same arguments ${repeats} times in a row and its parameter schema has already ` + `been delivered, so re-sending them will not start working. This usually means the caller cannot ` + `produce arguments outside the compact placeholder entry advertised for \`${sn}\`. \`${sn}\` is ` + `finished for this run — no further call to it will be answered differently. Other tools are ` + `unaffected; if this was the only work left, the run stops here. ` + `(${DEFERRED_SCHEMA_INCOMPATIBLE_CODE})\nLast rejection: ${shape}`; return { content: [{ type: "text", text: stuck }], details: { invalidArguments: true, noProgress: DEFERRED_SCHEMA_INCOMPATIBLE_CODE, repeats }, isError: true, terminate: true, }; } const text = `Invalid arguments for \`${sn}\`: ${shape} ` + `\`${sn}\` is now active — ${durability}` + `Call \`${sn}\` again with arguments matching it.\nParameter schema: ${schema.text}`; return { content: ride === undefined || ride === "" ? [{ type: "text", text }] : [{ type: "text", text }, { type: "text", text: ride }], details: { invalidArguments: true }, isError: true, }; }; if (direct !== undefined) { return { name: info.name, ...(info.aliases !== undefined ? { aliases: [...info.aliases] } : {}), label: info.name, description: `${info.hint} — deferred: its parameters are not listed here. Call ` + `${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load them; a call that already matches this ` + `tool's real parameters runs directly and activates it.`, parameters: EMPTY_PARAMS, ...(direct.executionMode !== undefined ? { executionMode: direct.executionMode } : {}), execute: async (toolCallId, params, signal, onUpdate) => { const real = direct.resolveReal(); if (real === undefined) return teachingRejection(); if (Value.Check(real.parameters, params)) { lastFailureShape = undefined; repeats = 0; const ride = await direct.activate(); const result = await real.invoke(toolCallId, params, signal, onUpdate); if (ride === undefined || ride === "") return result; return { ...result, content: [...result.content, { type: "text", text: ride }] }; } const schemaRender = renderSchemaForModel(real.parameters); if (schemaRender === undefined) return teachingRejection(); const ride = await direct.activate(); return invalidArgumentsRejection(real, params, schemaRender, ride); }, }; } return defineTool({ name: info.name, ...(info.aliases !== undefined ? { aliases: [...info.aliases] } : {}), description: `${info.hint} — deferred: call ${TOOL_SEARCH_NAME}({"query":"select:${sn}"}) to load its parameters before use.`, parameters: EMPTY_PARAMS, effect: "read", execute: () => teachingRejection(), }); } export function scoreToolMatch(query, info) { const tokens = query.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1); if (tokens.length === 0) return 0; const name = info.name.toLowerCase(); const body = `${info.hint} ${info.description}`.toLowerCase(); let score = 0; for (const tok of tokens) { if (name.includes(tok)) score += 3; if (body.includes(tok)) score += 1; } return score; } function clampMaxResults(n) { if (n === undefined || typeof n !== "number" || !Number.isFinite(n)) return TOOL_SEARCH_DEFAULT_MAX_RESULTS; const floored = Math.floor(n); if (floored < 1) return TOOL_SEARCH_DEFAULT_MAX_RESULTS; return Math.min(floored, MAX_QUERY_RESULTS); } export function resolveToolSearchDetailed(args, registry) { const out = []; const seen = new Set(); const missing = []; const add = (name) => { if (!seen.has(name) && registry.has(name)) { seen.add(name); out.push(name); } }; const q = typeof args.query === "string" ? args.query.trim() : ""; if (q !== "") { const selectMatch = /^select:(.+)$/i.exec(q); if (selectMatch) { for (const raw of selectMatch[1].split(",")) { const name = raw.trim(); if (name === "") continue; if (registry.has(name)) add(name); else if (!missing.includes(name)) missing.push(name); } return { matched: out, missing }; } const qLower = q.toLowerCase(); for (const name of registry.keys()) { if (name.toLowerCase() === qLower) { add(name); return { matched: out, missing }; } } const scored = []; for (const info of registry.values()) { const score = scoreToolMatch(q, info); if (score > 0) scored.push({ name: info.name, score }); } scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); for (const { name } of scored.slice(0, clampMaxResults(args.max_results))) add(name); } return { matched: out, missing }; } export function resolveToolSearch(args, registry) { return resolveToolSearchDetailed(args, registry).matched; } export function extractDiscoveredToolNames(messages, registry) { const names = new Set(); const pendingDirect = new Map(); const pendingSearch = new Map(); for (const m of messages) { if (m.role === "toolResult") { const name = pendingDirect.get(m.toolCallId); if (name !== undefined) { pendingDirect.delete(m.toolCallId); if (m.isError !== true) names.add(name); } const searched = pendingSearch.get(m.toolCallId); if (searched !== undefined) { pendingSearch.delete(m.toolCallId); if (m.isError !== true) for (const n of searched) names.add(n); } continue; } if (m.role !== "assistant") continue; for (const part of m.content) { if (part.type !== "toolCall") continue; if (part.name === TOOL_SEARCH_NAME) { const prior = pendingSearch.get(part.id); const resolved = resolveToolSearch(part.arguments, registry); pendingSearch.set(part.id, prior === undefined ? resolved : [...prior, ...resolved]); } else { const canonical = registry.has(part.name) ? part.name : [...registry.values()].find((i) => i.aliases?.includes(part.name))?.name; if (canonical !== undefined) pendingDirect.set(part.id, canonical); } } } for (const list of pendingSearch.values()) for (const n of list) names.add(n); return [...names]; } export function createToolSearchTool(opts) { const { registry, active, rematerialize, listingRide, mountedNames, staticSchemaFor, isMounted } = opts; const directCallEnabled = opts.directCallEnabled !== false; const activationPosture = directCallEnabled ? (staticSchemaFor !== undefined ? "Most tools start as name-only placeholders to keep requests small; activating one here returns its full " + "parameter schema in the result (the tools list keeps the compact placeholder entry — except for a " + "declaration too large to inline, which goes to the tools list instead). " : "Most tools start as name-only placeholders to keep requests small; activating one here loads its full " + "parameter schema. ") + "Until you have that schema you cannot reliably form a call, so activate a tool rather " + "than guessing its arguments — a call that does match the real schema executes and activates the tool. " + "When any instruction, reminder, or another tool's description names a deferred tool, activate it here " + 'with query "select:<name>". ' : "Most tools start as name-only placeholders to keep requests small; to USE one you must activate it here " + "first. When any instruction, reminder, or another tool's description names a deferred tool, activate it " + 'with query "select:<name>" before calling it. '; let activationChain = Promise.resolve(); const serializeActivation = opts.serializeActivation ?? ((section) => { const p = activationChain.then(section); activationChain = p.then(() => undefined, () => undefined); return p; }); return defineTool({ name: TOOL_SEARCH_NAME, ...toolFace("tool-search"), description: "Discover and activate deferred tools. " + activationPosture + "Query forms: " + '"select:ToolA,ToolB" — activate these exact tools by name; ' + '"notebook jupyter" — keyword search, up to max_results best matches; ' + "a bare tool name — activates that tool directly. " + "Activate every tool you expect to need in one call (select accepts a comma-separated list) " + "rather than one at a time. " + (staticSchemaFor !== undefined ? "Activation returns each tool's full parameter schema in this result — call the tool directly with arguments matching it." : "Activated tools become callable with their full parameters on your next turn."), parameters: Type.Object({ query: Type.Optional(Type.String({ description: 'Query to find deferred tools. Use "select:<tool_name>" for direct selection, or keywords to search.', })), max_results: Type.Optional(Type.Number({ description: "Maximum number of results to return (default: 5)" })), }), execute: async (raw) => { const staleSelect = (raw ?? {}); if (staleSelect.select !== undefined) { return errorResult(`Error (${TOOL_SEARCH_NAME}): \`select\` is not a parameter (the array form is retired) — put the ` + `selection in \`query\` instead: {"query":"select:ToolA,ToolB"}. Nothing was activated.`); } const args = (raw ?? {}); const resolved = resolveToolSearchDetailed(args, registry); const missing = resolved.missing; const withdrawn = isMounted === undefined ? [] : resolved.matched.filter((n) => !isMounted(n)); const matched = isMounted === undefined ? resolved.matched : resolved.matched.filter((n) => isMounted(n)); const mounted = mountedNames?.() ?? new Set(); const missCallable = missing.filter((n) => mounted.has(n)); const missUnknown = missing.filter((n) => !mounted.has(n)); const callableNote = missCallable.length > 0 ? `\nAlready available: ${missCallable.map((n) => safeName(n)).join(", ")} — ${missCallable.length > 1 ? "these tools are" : "this tool is"} not deferred; call ${missCallable.length > 1 ? "them" : "it"} directly right now (no activation needed).` : ""; const withdrawnNote = withdrawn.length > 0 ? `\nNo longer available: ${withdrawn.map((n) => safeName(n)).join(", ")} — ${withdrawn.length > 1 ? "these tools were" : "this tool was"} withdrawn by ${withdrawn.length > 1 ? "their providers" : "its provider"} and cannot be activated or called.` : ""; const missingNote = callableNote + withdrawnNote + (missUnknown.length > 0 ? `\nNot found: ${missUnknown.map((n) => safeName(n)).join(", ")} — not in the deferred registry under this exact name (lookup is case-sensitive). ` + "(Already-active and non-deferred tools are callable directly and don't appear here.)" : ""); if (matched.length === 0) { return { content: 'No deferred tools matched. Use query "select:<tool_name>" (comma-separate several names) for exact ' + "selection, or try different keywords. " + "(Already-active and non-deferred tools are callable directly and don't appear here.)" + missingNote, details: { type: "tool-search", matched: [], activated: [], matches: [], query: typeof args.query === "string" ? args.query : "", total_deferred_tools: registry.size, ...(missing.length > 0 || withdrawn.length > 0 ? { missing: [...missing, ...withdrawn].map(safeName) } : {}), }, }; } const section = serializeActivation(async () => { const newly = matched.filter((n) => !active.has(n)); if (newly.length > 0) { for (const n of newly) active.add(n); try { await rematerialize(active); } catch (e) { for (const n of newly) active.delete(n); throw e; } } if (newly.length > 0) opts.onActivationCommitted?.(); return { newly, ride: newly.length > 0 ? listingRide?.(newly) : undefined }; }); const { newly, ride } = await section; let missingSchemaLines = false; const lines = matched.map((n) => { const info = registry.get(n); const tag = newly.includes(n) ? "activated" : "already active"; const base = `- ${safeName(n)} (${tag})${info ? ` — ${info.hint}` : ""}`; if (staticSchemaFor === undefined) return base; const schema = staticSchemaFor(n); const json = schema === undefined ? undefined : renderSchemaForModel(schema); if (json === undefined || !json.complete) { missingSchemaLines = true; return `${base}\n parameters: not inlined — this tool's full declaration is in the tools list.`; } return `${base}\n parameters: ${json.text}`; }); const head = staticSchemaFor !== undefined ? newly.length > 0 ? `Activated ${newly.length} tool(s) — call them directly with arguments matching the parameter schemas below (the tools list keeps compact placeholder entries${missingSchemaLines ? ", except where a line says otherwise" : ""}):` : `These tools are already active — call them directly; their parameter schemas are repeated below${missingSchemaLines ? " where they can be inlined" : ""}:` : newly.length > 0 ? `Activated ${newly.length} tool(s); they are now available with full parameters — call them directly:` : "These tools are already active — call them directly:"; return { content: `${head}\n${lines.join("\n")}${missingNote}${ride !== undefined ? `\n\n${ride}` : ""}`, details: { type: "tool-search", matched: matched.map(safeName), activated: newly.map(safeName), matches: matched.map(safeName), query: typeof args.query === "string" ? args.query : "", total_deferred_tools: registry.size, ...(missing.length > 0 || withdrawn.length > 0 ? { missing: [...missing, ...withdrawn].map(safeName) } : {}), }, }; }, }); }