Files
clawdbot/src/agents/tools/common.ts
2026-01-07 04:16:39 +01:00

233 lines
6.0 KiB
TypeScript

import fs from "node:fs/promises";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { detectMime } from "../../media/mime.js";
import { sanitizeToolResultImages } from "../tool-images.js";
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
export type AnyAgentTool = AgentTool<any, unknown>;
export type StringParamOptions = {
required?: boolean;
trim?: boolean;
label?: string;
allowEmpty?: boolean;
};
export type ActionGate<T extends Record<string, boolean | undefined>> = (
key: keyof T,
defaultValue?: boolean,
) => boolean;
export function createActionGate<T extends Record<string, boolean | undefined>>(
actions: T | undefined,
): ActionGate<T> {
return (key, defaultValue = true) => {
const value = actions?.[key];
if (value === undefined) return defaultValue;
return value !== false;
};
}
export function readStringParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions & { required: true },
): string;
export function readStringParam(
params: Record<string, unknown>,
key: string,
options?: StringParamOptions,
): string | undefined;
export function readStringParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions = {},
) {
const {
required = false,
trim = true,
label = key,
allowEmpty = false,
} = options;
const raw = params[key];
if (typeof raw !== "string") {
if (required) throw new Error(`${label} required`);
return undefined;
}
const value = trim ? raw.trim() : raw;
if (!value && !allowEmpty) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return value;
}
export function readStringOrNumberParam(
params: Record<string, unknown>,
key: string,
options: { required?: boolean; label?: string } = {},
): string | undefined {
const { required = false, label = key } = options;
const raw = 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 Error(`${label} required`);
return undefined;
}
export function readNumberParam(
params: Record<string, unknown>,
key: string,
options: { required?: boolean; label?: string; integer?: boolean } = {},
): number | undefined {
const { required = false, label = key, integer = false } = options;
const raw = 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 = Number.parseFloat(trimmed);
if (Number.isFinite(parsed)) value = parsed;
}
}
if (value === undefined) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return integer ? Math.trunc(value) : value;
}
export function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions & { required: true },
): string[];
export function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options?: StringParamOptions,
): string[] | undefined;
export function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions = {},
) {
const { required = false, label = key } = options;
const raw = params[key];
if (Array.isArray(raw)) {
const values = raw
.filter((entry) => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
if (values.length === 0) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return values;
}
if (typeof raw === "string") {
const value = raw.trim();
if (!value) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return [value];
}
if (required) throw new Error(`${label} required`);
return undefined;
}
export type ReactionParams = {
emoji: string;
remove: boolean;
isEmpty: boolean;
};
export function readReactionParams(
params: Record<string, unknown>,
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 = readStringParam(params, emojiKey, {
required: true,
allowEmpty: true,
});
if (remove && !emoji) {
throw new Error(options.removeErrorMessage);
}
return { emoji, remove, isEmpty: !emoji };
}
export function jsonResult(payload: unknown): AgentToolResult<unknown> {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
details: payload,
};
}
export async function imageResult(params: {
label: string;
path: string;
base64: string;
mimeType: string;
extraText?: string;
details?: Record<string, unknown>;
}): Promise<AgentToolResult<unknown>> {
const content: AgentToolResult<unknown>["content"] = [
{
type: "text",
text: params.extraText ?? `MEDIA:${params.path}`,
},
{
type: "image",
data: params.base64,
mimeType: params.mimeType,
},
];
const result: AgentToolResult<unknown> = {
content,
details: { path: params.path, ...params.details },
};
return await sanitizeToolResultImages(result, params.label);
}
export async function imageResultFromFile(params: {
label: string;
path: string;
extraText?: string;
details?: Record<string, unknown>;
}): Promise<AgentToolResult<unknown>> {
const buf = await fs.readFile(params.path);
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,
});
}