mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:50:45 +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