/** * Shared built-in tool contracts and helpers. * * Defines erased tool types, parameter readers, JSON results, progress blocks, and media sanitization. */ import { detectMime } from "@openclaw/media-core/mime"; import { asPositiveSafeInteger, asSafeIntegerInRange, parseStrictFiniteNumber, } from "@openclaw/normalization-core/number-coercion"; import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import type { TSchema } from "typebox"; import type { AgentTool, AgentToolProgress, AgentToolResult, AgentToolUpdateCallback, } from "../../../packages/agent-core/src/types.js"; import { readLocalFileSafely } from "../../infra/fs-safe.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; import type { ImageSanitizationLimits } from "../image-sanitization.js"; import { ToolAuthorizationError, ToolInputError } from "../tool-input-error.js"; import { textResult } from "./tool-results.js"; export { ToolAuthorizationError, ToolInputError }; export { jsonResult, textResult } from "./tool-results.js"; export type AgentToolWithMeta = AgentTool< TParameters, TResult > & { displaySummary?: string; /** Keep this tool model-visible; hidden catalog bridges cannot preserve its result contract. */ catalogMode?: "direct-only"; /** Gateway client capabilities required before this tool can be assembled. */ requiredClientCaps?: string[]; /** Tool-owned execution and transport wait budget, before any harness completion grace. */ getExecutionTimeoutMs?: (args: unknown) => number | undefined; prepareBeforeToolCallParams?: ( params: unknown, ctx: { toolCallId?: string; hookContext?: unknown; signal?: AbortSignal }, ) => unknown; finalizeBeforeToolCallParams?: (params: unknown, preparedParams: unknown) => unknown; }; type ErasedAgentToolExecute = { execute( this: void, toolCallId: string, params: unknown, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise>; }; export type AnyAgentTool = Omit & ErasedAgentToolExecute & { displaySummary?: string; /** Keep this tool model-visible; hidden catalog bridges cannot preserve its result contract. */ catalogMode?: "direct-only"; /** Gateway client capabilities required before this tool can be assembled. */ requiredClientCaps?: string[]; getExecutionTimeoutMs?: AgentToolWithMeta["getExecutionTimeoutMs"]; prepareBeforeToolCallParams?: AgentToolWithMeta< TSchema, unknown >["prepareBeforeToolCallParams"]; finalizeBeforeToolCallParams?: AgentToolWithMeta< TSchema, unknown >["finalizeBeforeToolCallParams"]; }; export function asToolParamsRecord(params: unknown): Record { return asNonArrayRecord(params); } type StringParamOptions = { required?: boolean; trim?: boolean; label?: string; allowEmpty?: boolean; }; export type ActionGate> = ( key: keyof T, defaultValue?: boolean, ) => boolean; export function createActionGate>( actions: T | undefined, ): ActionGate { return (key, defaultValue = true) => { const value = actions?.[key]; if (value === undefined) { return defaultValue; } return value !== false; }; } // Models may emit blank defaults for optional numeric fields. Treat them as // absent while still rejecting nonblank invalid input. function isBlankParamValue(raw: unknown): boolean { return typeof raw === "string" && raw.trim() === ""; } export function readToolStringParam( params: Record, key: string, options: StringParamOptions & { required: true }, ): string; export function readToolStringParam( params: Record, key: string, options?: StringParamOptions, ): string | undefined; export function readToolStringParam( params: Record, key: string, options: StringParamOptions = {}, ) { const { required = false, trim = true, label = key, allowEmpty = false } = options; const raw = readSnakeCaseParamRaw(params, key); if (typeof raw !== "string") { if (required) { throw new ToolInputError(`${label} required`); } return undefined; } const value = trim ? raw.trim() : raw; if (!value && !allowEmpty) { if (required) { throw new ToolInputError(`${label} required`); } return undefined; } return value; } /** * Normalize tool model override input. * - empty/whitespace => undefined * - "default" (case-insensitive) => undefined (sentinel: reset/fallback) * - otherwise returns trimmed explicit model string */ export function normalizeToolModelOverride(value: string | undefined): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); if (!trimmed || trimmed.toLowerCase() === "default") { return undefined; } return trimmed; } export function readStringOrNumberParam( params: Record, key: string, options: { required?: boolean; label?: string } = {}, ): string | undefined { const { required = false, label = key } = options; const raw = readSnakeCaseParamRaw(params, key); if (typeof raw === "number" && Number.isFinite(raw)) { return String(raw); } if (typeof raw === "string") { const value = raw.trim(); if (value) { return value; } } if (required) { throw new ToolInputError(`${label} required`); } return undefined; } export function readNumberParam( params: Record, key: string, options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean; positiveInteger?: boolean; nonNegativeInteger?: boolean; } = {}, ): number | undefined { const { required = false, label = key, integer = false, strict = false, positiveInteger = false, nonNegativeInteger = false, } = options; const raw = readSnakeCaseParamRaw(params, key); let value: number | undefined; if (typeof raw === "number" && Number.isFinite(raw)) { value = raw; } else if (typeof raw === "string") { const trimmed = raw.trim(); if (trimmed) { const parsed = strict ? parseStrictFiniteNumber(trimmed) : Number.parseFloat(trimmed); if (parsed !== undefined && Number.isFinite(parsed)) { value = parsed; } } } if (value === undefined) { if (required) { throw new ToolInputError(`${label} required`); } return undefined; } if (positiveInteger) { return asPositiveSafeInteger(value); } if (nonNegativeInteger) { return asSafeIntegerInRange(value, { min: 0 }); } return integer ? Math.trunc(value) : value; } export function readPositiveIntegerParam( params: Record, key: string, options: { message?: string; max?: number; } = {}, ): number | undefined { const value = readNumberParam(params, key, { positiveInteger: true, strict: true, }); if (value === undefined) { const raw = readSnakeCaseParamRaw(params, key); if (raw != null && !isBlankParamValue(raw)) { throw new ToolInputError(options.message ?? `${key} must be a positive integer`); } } if (value !== undefined && options.max !== undefined && value > options.max) { throw new ToolInputError(options.message ?? `${key} must be a positive integer`); } return value; } export function readNonNegativeIntegerParam( params: Record, key: string, options: { message?: string; max?: number; } = {}, ): number | undefined { const value = readNumberParam(params, key, { nonNegativeInteger: true, strict: true, }); if (value === undefined) { const raw = readSnakeCaseParamRaw(params, key); if (raw != null && !isBlankParamValue(raw)) { throw new ToolInputError(options.message ?? `${key} must be a non-negative integer`); } } if (value !== undefined && options.max !== undefined && value > options.max) { throw new ToolInputError(options.message ?? `${key} must be a non-negative integer`); } return value; } export function readFiniteNumberParam( params: Record, key: string, options: { message?: string; min?: number; max?: number; minExclusive?: boolean; maxExclusive?: boolean; } = {}, ): number | undefined { const value = readNumberParam(params, key, { strict: true, }); if (value === undefined) { const raw = readSnakeCaseParamRaw(params, key); if (raw != null && !isBlankParamValue(raw)) { throw new ToolInputError(options.message ?? `${key} must be a finite number`); } return undefined; } if (options.min !== undefined) { const below = options.minExclusive ? value <= options.min : value < options.min; if (below) { throw new ToolInputError(options.message ?? `${key} must be a finite number`); } } if (options.max !== undefined) { const above = options.maxExclusive ? value >= options.max : value > options.max; if (above) { throw new ToolInputError(options.message ?? `${key} must be a finite number`); } } return value; } export function readStringArrayParam( params: Record, key: string, options: StringParamOptions & { required: true }, ): string[]; export function readStringArrayParam( params: Record, key: string, options?: StringParamOptions, ): string[] | undefined; export function readStringArrayParam( params: Record, key: string, options: StringParamOptions = {}, ) { const { required = false, label = key } = options; const values = normalizeSingleOrTrimmedStringList(readSnakeCaseParamRaw(params, key)); if (values.length > 0) { return values; } if (required) { throw new ToolInputError(`${label} required`); } return undefined; } type ReactionParams = { emoji: string; remove: boolean; isEmpty: boolean; }; export function readReactionParams( params: Record, options: { emojiKey?: string; removeKey?: string; removeErrorMessage: string; }, ): ReactionParams { const emojiKey = options.emojiKey ?? "emoji"; const removeKey = options.removeKey ?? "remove"; const remove = typeof params[removeKey] === "boolean" ? params[removeKey] : false; const emoji = readToolStringParam(params, emojiKey, { required: true, allowEmpty: true, }); if (remove && !emoji) { throw new ToolInputError(options.removeErrorMessage); } return { emoji, remove, isEmpty: !emoji }; } function stringifyToolPayload(payload: unknown): string { if (typeof payload === "string") { return payload; } try { const encoded = JSON.stringify(payload, null, 2); if (typeof encoded === "string") { return encoded; } } catch { // Fall through to String(payload) for non-serializable values. } return String(payload); } export function failedTextResult( text: string, details: TDetails, ): AgentToolResult { return textResult(text, details); } export function payloadTextResult(payload: TDetails): AgentToolResult { return textResult(stringifyToolPayload(payload), payload); } type PublicToolProgress = Pick; function toolProgressResult(progress: PublicToolProgress): AgentToolResult { return { content: [], details: undefined, progress: { text: progress.text, visibility: "channel", privacy: "public", ...(progress.id ? { id: progress.id } : {}), }, }; } // Tool progress is a UI side channel. The model-facing tool result remains in // `content`; progress text must already be safe to show in channel previews. function emitToolProgress( onUpdate: AgentToolUpdateCallback | undefined, progress: PublicToolProgress, ): void { const text = progress.text.trim(); if (!onUpdate || !text) { return; } try { onUpdate(toolProgressResult({ ...progress, text })); } catch { // Progress is best-effort UI state; tool execution must not depend on subscribers. } } // Long-running tools can arm delayed progress and cancel it on completion or // abort. This avoids stale "still working" lines after a fast or canceled call. export function scheduleToolProgress( onUpdate: AgentToolUpdateCallback | undefined, progress: PublicToolProgress, delayMs: number, options: { signal?: AbortSignal } = {}, ): () => void { if (!onUpdate || options.signal?.aborted) { return () => {}; } let cleared = false; const clear = () => { if (cleared) { return; } cleared = true; clearTimeout(timer); options.signal?.removeEventListener("abort", clear); }; const timer: ReturnType = setTimeout(() => { clear(); emitToolProgress(onUpdate, progress); }, delayMs); options.signal?.addEventListener("abort", clear, { once: true }); return clear; } async function imageResult(params: { label: string; path: string; base64: string; mimeType: string; extraText?: string; details?: Record; imageSanitization?: ImageSanitizationLimits; }): Promise> { const content: AgentToolResult["content"] = [ ...(params.extraText ? [{ type: "text" as const, text: params.extraText }] : []), { type: "image", data: params.base64, mimeType: params.mimeType, }, ]; const detailsMedia = params.details?.media && typeof params.details.media === "object" && !Array.isArray(params.details.media) ? (params.details.media as Record) : undefined; const result: AgentToolResult = { content, details: { path: params.path, ...params.details, media: { ...detailsMedia, mediaUrl: params.path, }, }, }; const { sanitizeToolResultImages } = await import("../tool-images.runtime.js"); return await sanitizeToolResultImages(result, params.label, params.imageSanitization); } export async function imageResultFromFile(params: { label: string; path: string; extraText?: string; details?: Record; imageSanitization?: ImageSanitizationLimits; }): Promise> { const buf = (await readLocalFileSafely({ filePath: params.path })).buffer; const mimeType = (await detectMime({ buffer: buf.slice(0, 256) })) ?? "image/png"; return await imageResult({ label: params.label, path: params.path, base64: buf.toString("base64"), mimeType, extraText: params.extraText, details: params.details, imageSanitization: params.imageSanitization, }); } type AvailableTag = { id?: string; name: string; moderated?: boolean; emoji_id?: string | null; emoji_name?: string | null; }; /** * Validate and parse an `availableTags` parameter from untrusted input. * Returns `undefined` when the value is missing or not an array. * Entries that lack a string `name` are silently dropped. */ export function parseAvailableTags(raw: unknown): AvailableTag[] | undefined { if (raw === undefined || raw === null) { return undefined; } if (!Array.isArray(raw)) { return undefined; } const result = raw .filter( (t): t is Record => typeof t === "object" && t !== null && typeof t.name === "string", ) .map((t) => Object.assign( {}, t.id !== undefined && typeof t.id === `string` ? { id: t.id } : {}, { name: t.name as string }, typeof t.moderated === `boolean` ? { moderated: t.moderated } : {}, t.emoji_id === null || typeof t.emoji_id === `string` ? { emoji_id: t.emoji_id } : {}, t.emoji_name === null || typeof t.emoji_name === `string` ? { emoji_name: t.emoji_name } : {}, ), ); // Return undefined instead of empty array to avoid accidentally clearing all tags return result.length ? result : undefined; }