From c5224a341e79cd7f5322012a072ea3d346ebff80 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 2 May 2026 07:10:52 +0100 Subject: [PATCH] feat: add tool descriptor planner --- src/tools/availability.ts | 170 ++++++++++++++++++++++++++++++++++++++ src/tools/descriptors.ts | 11 +++ src/tools/diagnostics.ts | 13 +++ src/tools/execution.ts | 18 ++++ src/tools/index.ts | 23 ++++++ src/tools/planner.ts | 58 +++++++++++++ src/tools/protocol.ts | 22 +++++ src/tools/types.ts | 97 ++++++++++++++++++++++ 8 files changed, 412 insertions(+) create mode 100644 src/tools/availability.ts create mode 100644 src/tools/descriptors.ts create mode 100644 src/tools/diagnostics.ts create mode 100644 src/tools/execution.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/planner.ts create mode 100644 src/tools/protocol.ts create mode 100644 src/tools/types.ts diff --git a/src/tools/availability.ts b/src/tools/availability.ts new file mode 100644 index 00000000000..cd6ccf619cd --- /dev/null +++ b/src/tools/availability.ts @@ -0,0 +1,170 @@ +import type { + JsonObject, + JsonPrimitive, + JsonValue, + ToolAvailabilityContext, + ToolAvailabilityDiagnostic, + ToolAvailabilityExpression, + ToolAvailabilitySignal, + ToolDescriptor, +} from "./types.js"; + +function isRecord(value: JsonValue | undefined): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function resolveConfigPath( + config: JsonObject | undefined, + path: readonly string[], +): JsonValue | undefined { + let current: JsonValue | undefined = config; + for (const segment of path) { + if (!isRecord(current)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +function hasConfiguredValue(params: { + value: JsonValue | undefined; + signal: Extract; + context: ToolAvailabilityContext; +}): boolean { + const { value, signal } = params; + if (value === undefined || value === null) { + return false; + } + if ((signal.check ?? "exists") === "available") { + return ( + params.context.isConfigValueAvailable?.({ + value, + path: signal.path, + signal, + }) === true + ); + } + if ((signal.check ?? "exists") === "exists") { + return true; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === "object") { + return Object.keys(value).length > 0; + } + return true; +} + +function hasAvailabilityExpressionShape(value: ToolAvailabilityExpression): boolean { + return "kind" in value || "allOf" in value || "anyOf" in value; +} + +function diagnostic( + reason: ToolAvailabilityDiagnostic["reason"], + signal: ToolAvailabilitySignal, + message: string, +): ToolAvailabilityDiagnostic { + return { reason, signal, message }; +} + +function evaluateSignal( + signal: ToolAvailabilitySignal, + context: ToolAvailabilityContext, +): ToolAvailabilityDiagnostic | null { + switch (signal.kind) { + case "always": + return null; + case "auth": + return context.authProviderIds?.has(signal.providerId) + ? null + : diagnostic("auth-missing", signal, `Missing auth provider: ${signal.providerId}`); + case "config": { + const value = resolveConfigPath(context.config, signal.path); + return hasConfiguredValue({ value, signal, context }) + ? null + : diagnostic("config-missing", signal, `Missing config path: ${signal.path.join(".")}`); + } + case "env": + return context.env?.[signal.name]?.trim() + ? null + : diagnostic("env-missing", signal, `Missing environment value: ${signal.name}`); + case "plugin-enabled": + return context.enabledPluginIds?.has(signal.pluginId) + ? null + : diagnostic("plugin-disabled", signal, `Plugin is not enabled: ${signal.pluginId}`); + case "context": { + const value: JsonPrimitive | undefined = context.values?.[signal.key]; + if (!("equals" in signal)) { + return value === undefined + ? diagnostic("context-mismatch", signal, `Missing context value: ${signal.key}`) + : null; + } + return value === signal.equals + ? null + : diagnostic("context-mismatch", signal, `Context value did not match: ${signal.key}`); + } + default: + return diagnostic("unsupported-signal", signal, "Unsupported availability signal"); + } +} + +function evaluateExpression( + expression: ToolAvailabilityExpression, + context: ToolAvailabilityContext, +): readonly ToolAvailabilityDiagnostic[] { + if ("kind" in expression) { + const diagnostic = evaluateSignal(expression, context); + return diagnostic ? [diagnostic] : []; + } + if ("allOf" in expression) { + if (expression.allOf.length === 0) { + return [ + { + reason: "unsupported-signal", + message: "Empty availability allOf group", + }, + ]; + } + return expression.allOf.flatMap((entry) => evaluateExpression(entry, context)); + } + if ("anyOf" in expression) { + if (expression.anyOf.length === 0) { + return [ + { + reason: "unsupported-signal", + message: "Empty availability anyOf group", + }, + ]; + } + const diagnostics = expression.anyOf.map((entry) => evaluateExpression(entry, context)); + return diagnostics.some((entries) => entries.length === 0) ? [] : diagnostics.flat(); + } + return [ + { + reason: "unsupported-signal", + message: "Unsupported availability expression", + }, + ]; +} + +export function evaluateToolAvailability(params: { + descriptor: ToolDescriptor; + context?: ToolAvailabilityContext; +}): readonly ToolAvailabilityDiagnostic[] { + const context = params.context ?? {}; + const availability = params.descriptor.availability ?? { kind: "always" }; + if (!hasAvailabilityExpressionShape(availability)) { + return [ + { + reason: "unsupported-signal", + message: "Unsupported availability expression", + }, + ]; + } + return evaluateExpression(availability, context); +} diff --git a/src/tools/descriptors.ts b/src/tools/descriptors.ts new file mode 100644 index 00000000000..b843ccb365c --- /dev/null +++ b/src/tools/descriptors.ts @@ -0,0 +1,11 @@ +import type { ToolDescriptor } from "./types.js"; + +export function defineToolDescriptor(descriptor: ToolDescriptor): ToolDescriptor { + return descriptor; +} + +export function defineToolDescriptors( + descriptors: readonly ToolDescriptor[], +): readonly ToolDescriptor[] { + return descriptors; +} diff --git a/src/tools/diagnostics.ts b/src/tools/diagnostics.ts new file mode 100644 index 00000000000..f7449502877 --- /dev/null +++ b/src/tools/diagnostics.ts @@ -0,0 +1,13 @@ +export type ToolPlanContractErrorCode = "duplicate-tool-name" | "missing-executor"; + +export class ToolPlanContractError extends Error { + readonly code: ToolPlanContractErrorCode; + readonly toolName: string; + + constructor(params: { code: ToolPlanContractErrorCode; toolName: string; message: string }) { + super(params.message); + this.name = "ToolPlanContractError"; + this.code = params.code; + this.toolName = params.toolName; + } +} diff --git a/src/tools/execution.ts b/src/tools/execution.ts new file mode 100644 index 00000000000..1a483af482a --- /dev/null +++ b/src/tools/execution.ts @@ -0,0 +1,18 @@ +import type { ToolExecutorRef } from "./types.js"; + +export function formatToolExecutorRef(ref: ToolExecutorRef): string { + switch (ref.kind) { + case "core": + return `core:${ref.executorId}`; + case "plugin": + return `plugin:${ref.pluginId}:${ref.toolName}`; + case "channel": + return `channel:${ref.channelId}:${ref.actionId}`; + case "mcp": + return `mcp:${ref.serverId}:${ref.toolName}`; + default: { + const exhaustive: never = ref; + return exhaustive; + } + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 00000000000..6a4e4933712 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,23 @@ +export { evaluateToolAvailability } from "./availability.js"; +export { defineToolDescriptor, defineToolDescriptors } from "./descriptors.js"; +export { ToolPlanContractError } from "./diagnostics.js"; +export { formatToolExecutorRef } from "./execution.js"; +export { buildToolPlan } from "./planner.js"; +export { toToolProtocolDescriptor, toToolProtocolDescriptors } from "./protocol.js"; +export type { + BuildToolPlanOptions, + HiddenToolPlanEntry, + JsonObject, + JsonPrimitive, + JsonValue, + ToolAvailabilityContext, + ToolAvailabilityDiagnostic, + ToolAvailabilityExpression, + ToolAvailabilitySignal, + ToolDescriptor, + ToolExecutorRef, + ToolOwnerRef, + ToolPlan, + ToolPlanEntry, + ToolUnavailableReason, +} from "./types.js"; diff --git a/src/tools/planner.ts b/src/tools/planner.ts new file mode 100644 index 00000000000..2c7ae30db10 --- /dev/null +++ b/src/tools/planner.ts @@ -0,0 +1,58 @@ +import { evaluateToolAvailability } from "./availability.js"; +import { ToolPlanContractError } from "./diagnostics.js"; +import type { + BuildToolPlanOptions, + HiddenToolPlanEntry, + ToolDescriptor, + ToolPlan, + ToolPlanEntry, +} from "./types.js"; + +function compareDescriptors(left: ToolDescriptor, right: ToolDescriptor): number { + return ( + (left.sortKey ?? left.name).localeCompare(right.sortKey ?? right.name) || + left.name.localeCompare(right.name) + ); +} + +function assertUniqueNames(descriptors: readonly ToolDescriptor[]): void { + const seen = new Set(); + for (const descriptor of descriptors) { + if (seen.has(descriptor.name)) { + throw new ToolPlanContractError({ + code: "duplicate-tool-name", + toolName: descriptor.name, + message: `Duplicate tool descriptor name: ${descriptor.name}`, + }); + } + seen.add(descriptor.name); + } +} + +export function buildToolPlan(options: BuildToolPlanOptions): ToolPlan { + const descriptors = options.descriptors.toSorted(compareDescriptors); + assertUniqueNames(descriptors); + + const visible: ToolPlanEntry[] = []; + const hidden: HiddenToolPlanEntry[] = []; + + for (const descriptor of descriptors) { + const diagnostics = [ + ...evaluateToolAvailability({ descriptor, context: options.availability }), + ]; + if (diagnostics.length > 0) { + hidden.push({ descriptor, diagnostics }); + continue; + } + if (!descriptor.executor) { + throw new ToolPlanContractError({ + code: "missing-executor", + toolName: descriptor.name, + message: `Visible tool descriptor has no executor ref: ${descriptor.name}`, + }); + } + visible.push({ descriptor, executor: descriptor.executor }); + } + + return { visible, hidden }; +} diff --git a/src/tools/protocol.ts b/src/tools/protocol.ts new file mode 100644 index 00000000000..5403e005be0 --- /dev/null +++ b/src/tools/protocol.ts @@ -0,0 +1,22 @@ +import type { JsonObject, ToolPlanEntry } from "./types.js"; + +export type ToolProtocolDescriptor = { + readonly name: string; + readonly description: string; + readonly inputSchema: JsonObject; +}; + +// Shared descriptor shape only. Model/provider adapters still own schema normalization. +export function toToolProtocolDescriptor(entry: ToolPlanEntry): ToolProtocolDescriptor { + return { + name: entry.descriptor.name, + description: entry.descriptor.description, + inputSchema: entry.descriptor.inputSchema, + }; +} + +export function toToolProtocolDescriptors( + entries: readonly ToolPlanEntry[], +): readonly ToolProtocolDescriptor[] { + return entries.map(toToolProtocolDescriptor); +} diff --git a/src/tools/types.ts b/src/tools/types.ts new file mode 100644 index 00000000000..b774da85103 --- /dev/null +++ b/src/tools/types.ts @@ -0,0 +1,97 @@ +export type JsonPrimitive = string | number | boolean | null; + +export type JsonValue = + | JsonPrimitive + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +export type JsonObject = { readonly [key: string]: JsonValue }; + +export type ToolOwnerRef = + | { readonly kind: "core" } + | { readonly kind: "plugin"; readonly pluginId: string } + | { readonly kind: "channel"; readonly channelId: string; readonly pluginId?: string } + | { readonly kind: "mcp"; readonly serverId: string }; + +export type ToolExecutorRef = + | { readonly kind: "core"; readonly executorId: string } + | { readonly kind: "plugin"; readonly pluginId: string; readonly toolName: string } + | { readonly kind: "channel"; readonly channelId: string; readonly actionId: string } + | { readonly kind: "mcp"; readonly serverId: string; readonly toolName: string }; + +export type ToolAvailabilitySignal = + | { readonly kind: "always" } + | { readonly kind: "auth"; readonly providerId: string } + | { + readonly kind: "config"; + readonly path: readonly string[]; + readonly check?: "exists" | "non-empty" | "available"; + } + | { readonly kind: "env"; readonly name: string } + | { readonly kind: "plugin-enabled"; readonly pluginId: string } + | { readonly kind: "context"; readonly key: string; readonly equals?: JsonPrimitive }; + +export type ToolAvailabilityExpression = + | ToolAvailabilitySignal + | { readonly allOf: readonly ToolAvailabilityExpression[] } + | { readonly anyOf: readonly ToolAvailabilityExpression[] }; + +export type ToolDescriptor = { + readonly name: string; + readonly title?: string; + readonly description: string; + readonly inputSchema: JsonObject; + readonly outputSchema?: JsonObject; + readonly owner: ToolOwnerRef; + readonly executor?: ToolExecutorRef; + readonly availability?: ToolAvailabilityExpression; + readonly annotations?: JsonObject; + readonly sortKey?: string; +}; + +export type ToolAvailabilityContext = { + readonly authProviderIds?: ReadonlySet; + readonly config?: JsonObject; + readonly isConfigValueAvailable?: (params: { + readonly value: JsonValue; + readonly path: readonly string[]; + readonly signal: Extract; + }) => boolean; + readonly env?: Readonly>; + readonly enabledPluginIds?: ReadonlySet; + readonly values?: Readonly>; +}; + +export type ToolUnavailableReason = + | "auth-missing" + | "config-missing" + | "context-mismatch" + | "env-missing" + | "plugin-disabled" + | "unsupported-signal"; + +export type ToolAvailabilityDiagnostic = { + readonly reason: ToolUnavailableReason; + readonly signal?: ToolAvailabilitySignal; + readonly message: string; +}; + +export type ToolPlanEntry = { + readonly descriptor: ToolDescriptor; + readonly executor: ToolExecutorRef; +}; + +export type HiddenToolPlanEntry = { + readonly descriptor: ToolDescriptor; + readonly diagnostics: readonly ToolAvailabilityDiagnostic[]; +}; + +export type ToolPlan = { + readonly visible: readonly ToolPlanEntry[]; + readonly hidden: readonly HiddenToolPlanEntry[]; +}; + +export type BuildToolPlanOptions = { + readonly descriptors: readonly ToolDescriptor[]; + readonly availability?: ToolAvailabilityContext; +};