@sema-agent/core
Version:
Stateless, task-oriented AI agent core
381 lines (380 loc) • 19.8 kB
JavaScript
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) } : {}),
},
};
},
});
}