Files
openclaw/src/agents/pi-tools.schema.ts
2026-04-03 21:07:19 +09:00

284 lines
9.6 KiB
TypeScript

import type { ModelCompatConfig } from "../config/types.models.js";
import { stripUnsupportedSchemaKeywords } from "../plugin-sdk/provider-tools.js";
import { resolveUnsupportedToolSchemaKeywords } from "../plugins/provider-model-compat.js";
import { copyPluginToolMeta } from "../plugins/tools.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
function extractEnumValues(schema: unknown): unknown[] | undefined {
if (!schema || typeof schema !== "object") {
return undefined;
}
const record = schema as Record<string, unknown>;
if (Array.isArray(record.enum)) {
return record.enum;
}
if ("const" in record) {
return [record.const];
}
const variants = Array.isArray(record.anyOf)
? record.anyOf
: Array.isArray(record.oneOf)
? record.oneOf
: null;
if (variants) {
const values = variants.flatMap((variant) => {
const extracted = extractEnumValues(variant);
return extracted ?? [];
});
return values.length > 0 ? values : undefined;
}
return undefined;
}
function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
if (!existing) {
return incoming;
}
if (!incoming) {
return existing;
}
const existingEnum = extractEnumValues(existing);
const incomingEnum = extractEnumValues(incoming);
if (existingEnum || incomingEnum) {
const values = Array.from(new Set([...(existingEnum ?? []), ...(incomingEnum ?? [])]));
const merged: Record<string, unknown> = {};
for (const source of [existing, incoming]) {
if (!source || typeof source !== "object") {
continue;
}
const record = source as Record<string, unknown>;
for (const key of ["title", "description", "default"]) {
if (!(key in merged) && key in record) {
merged[key] = record[key];
}
}
}
const types = new Set(values.map((value) => typeof value));
if (types.size === 1) {
merged.type = Array.from(types)[0];
}
merged.enum = values;
return merged;
}
return existing;
}
type FlattenableVariantKey = "anyOf" | "oneOf";
type TopLevelConditionalKey = FlattenableVariantKey | "allOf";
function hasTopLevelArrayKeyword(
schemaRecord: Record<string, unknown>,
key: TopLevelConditionalKey,
): boolean {
return Array.isArray(schemaRecord[key]);
}
function getFlattenableVariantKey(
schemaRecord: Record<string, unknown>,
): FlattenableVariantKey | null {
if (hasTopLevelArrayKeyword(schemaRecord, "anyOf")) {
return "anyOf";
}
if (hasTopLevelArrayKeyword(schemaRecord, "oneOf")) {
return "oneOf";
}
return null;
}
function getTopLevelConditionalKey(
schemaRecord: Record<string, unknown>,
): TopLevelConditionalKey | null {
return (
getFlattenableVariantKey(schemaRecord) ??
(hasTopLevelArrayKeyword(schemaRecord, "allOf") ? "allOf" : null)
);
}
function hasTopLevelObjectSchema(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return "type" in schemaRecord && "properties" in schemaRecord && conditionalKey === null;
}
function isObjectLikeSchemaMissingType(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return (
!("type" in schemaRecord) &&
(typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) &&
conditionalKey === null
);
}
function isTypedSchemaMissingProperties(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return "type" in schemaRecord && !("properties" in schemaRecord) && conditionalKey === null;
}
function isTrulyEmptySchema(schemaRecord: Record<string, unknown>): boolean {
return Object.keys(schemaRecord).length === 0;
}
export function normalizeToolParameterSchema(
schema: unknown,
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
): unknown {
const schemaRecord =
schema && typeof schema === "object" ? (schema as Record<string, unknown>) : undefined;
if (!schemaRecord) {
return schema;
}
// Provider quirks:
// - Gemini rejects several JSON Schema keywords, so we scrub those.
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
// - Anthropic expects full JSON Schema draft 2020-12 compliance.
// - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright.
//
// Normalize once here so callers can always pass `tools` through unchanged.
const isGeminiProvider =
options?.modelProvider?.toLowerCase().includes("google") ||
options?.modelProvider?.toLowerCase().includes("gemini");
const isAnthropicProvider = options?.modelProvider?.toLowerCase().includes("anthropic");
const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat);
function applyProviderCleaning(s: unknown): unknown {
if (isGeminiProvider && !isAnthropicProvider) {
return cleanSchemaForGemini(s);
}
if (unsupportedToolSchemaKeywords.size > 0) {
return stripUnsupportedSchemaKeywords(s, unsupportedToolSchemaKeywords);
}
return s;
}
const conditionalKey = getTopLevelConditionalKey(schemaRecord);
const flattenableVariantKey = getFlattenableVariantKey(schemaRecord);
if (hasTopLevelObjectSchema(schemaRecord, conditionalKey)) {
return applyProviderCleaning(schemaRecord);
}
if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, type: "object" });
}
if (isTypedSchemaMissingProperties(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, properties: {} });
}
if (!flattenableVariantKey) {
if (isTrulyEmptySchema(schemaRecord)) {
// Handle the proven MCP no-parameter case: a truly empty schema object.
return applyProviderCleaning({ type: "object", properties: {} });
}
if (conditionalKey === "allOf") {
// Top-level `allOf` is not safely flattenable with the same heuristics we
// use for unions. Keep it explicit rather than silently rewriting it.
return schema;
}
return schema;
}
const variants = schemaRecord[flattenableVariantKey] as unknown[];
const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>();
let objectVariants = 0;
for (const entry of variants) {
if (!entry || typeof entry !== "object") {
continue;
}
const props = (entry as { properties?: unknown }).properties;
if (!props || typeof props !== "object") {
continue;
}
objectVariants += 1;
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
if (!(key in mergedProperties)) {
mergedProperties[key] = value;
continue;
}
mergedProperties[key] = mergePropertySchemas(mergedProperties[key], value);
}
const required = Array.isArray((entry as { required?: unknown }).required)
? (entry as { required: unknown[] }).required
: [];
for (const key of required) {
if (typeof key !== "string") {
continue;
}
requiredCounts.set(key, (requiredCounts.get(key) ?? 0) + 1);
}
}
const baseRequired = Array.isArray(schemaRecord.required)
? schemaRecord.required.filter((key) => typeof key === "string")
: undefined;
const mergedRequired =
baseRequired && baseRequired.length > 0
? baseRequired
: objectVariants > 0
? Array.from(requiredCounts.entries())
.filter(([, count]) => count === objectVariants)
.map(([key]) => key)
: undefined;
const nextSchema: Record<string, unknown> = { ...schemaRecord };
const flattenedSchema = {
type: "object",
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}),
properties:
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
additionalProperties:
"additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true,
};
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// - Anthropic accepts proper JSON Schema with constraints.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
return applyProviderCleaning(flattenedSchema);
}
export function normalizeToolParameters(
tool: AnyAgentTool,
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
): AnyAgentTool {
function preserveToolMeta(target: AnyAgentTool): AnyAgentTool {
copyPluginToolMeta(tool, target);
copyChannelAgentToolMeta(tool as never, target as never);
return target;
}
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema) {
return tool;
}
return preserveToolMeta({
...tool,
parameters: normalizeToolParameterSchema(schema, options),
});
}
/**
* @deprecated Use normalizeToolParameters with modelProvider instead.
* This function should only be used for Gemini providers.
*/
export function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
}