feat: add tool descriptor planner

This commit is contained in:
Shakker
2026-05-02 07:10:52 +01:00
committed by Shakker
parent 8080c9cf03
commit c5224a341e
8 changed files with 412 additions and 0 deletions

170
src/tools/availability.ts Normal file
View File

@@ -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<ToolAvailabilitySignal, { readonly kind: "config" }>;
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);
}

11
src/tools/descriptors.ts Normal file
View File

@@ -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;
}

13
src/tools/diagnostics.ts Normal file
View File

@@ -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;
}
}

18
src/tools/execution.ts Normal file
View File

@@ -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;
}
}
}

23
src/tools/index.ts Normal file
View File

@@ -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";

58
src/tools/planner.ts Normal file
View File

@@ -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<string>();
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 };
}

22
src/tools/protocol.ts Normal file
View File

@@ -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);
}

97
src/tools/types.ts Normal file
View File

@@ -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<string>;
readonly config?: JsonObject;
readonly isConfigValueAvailable?: (params: {
readonly value: JsonValue;
readonly path: readonly string[];
readonly signal: Extract<ToolAvailabilitySignal, { readonly kind: "config" }>;
}) => boolean;
readonly env?: Readonly<Record<string, string | undefined>>;
readonly enabledPluginIds?: ReadonlySet<string>;
readonly values?: Readonly<Record<string, JsonPrimitive | undefined>>;
};
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;
};