mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
feat: add tool descriptor planner
This commit is contained in:
170
src/tools/availability.ts
Normal file
170
src/tools/availability.ts
Normal 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
11
src/tools/descriptors.ts
Normal 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
13
src/tools/diagnostics.ts
Normal 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
18
src/tools/execution.ts
Normal 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
23
src/tools/index.ts
Normal 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
58
src/tools/planner.ts
Normal 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
22
src/tools/protocol.ts
Normal 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
97
src/tools/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user