mirror of https://github.com/openclaw/openclaw.git
453 lines
12 KiB
TypeScript
453 lines
12 KiB
TypeScript
import { resolveExecDetail } from "./tool-display-exec.js";
|
|
|
|
export type ToolDisplayActionSpec = {
|
|
label?: string;
|
|
detailKeys?: string[];
|
|
};
|
|
|
|
export type ToolDisplaySpec = {
|
|
title?: string;
|
|
label?: string;
|
|
detailKeys?: string[];
|
|
actions?: Record<string, ToolDisplayActionSpec>;
|
|
};
|
|
|
|
export type CoerceDisplayValueOptions = {
|
|
includeFalse?: boolean;
|
|
includeZero?: boolean;
|
|
includeNonFinite?: boolean;
|
|
maxStringChars?: number;
|
|
maxArrayEntries?: number;
|
|
};
|
|
|
|
type ArgsRecord = Record<string, unknown>;
|
|
|
|
function asRecord(args: unknown): ArgsRecord | undefined {
|
|
return args && typeof args === "object" ? (args as ArgsRecord) : undefined;
|
|
}
|
|
|
|
export function normalizeToolName(name?: string): string {
|
|
return (name ?? "tool").trim();
|
|
}
|
|
|
|
export function defaultTitle(name: string): string {
|
|
const cleaned = name.replace(/_/g, " ").trim();
|
|
if (!cleaned) {
|
|
return "Tool";
|
|
}
|
|
return cleaned
|
|
.split(/\s+/)
|
|
.map((part) =>
|
|
part.length <= 2 && part.toUpperCase() === part
|
|
? part
|
|
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
|
|
)
|
|
.join(" ");
|
|
}
|
|
|
|
export function normalizeVerb(value?: string): string | undefined {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
return trimmed.replace(/_/g, " ");
|
|
}
|
|
|
|
export function resolveActionArg(args: unknown): string | undefined {
|
|
if (!args || typeof args !== "object") {
|
|
return undefined;
|
|
}
|
|
const actionRaw = (args as Record<string, unknown>).action;
|
|
if (typeof actionRaw !== "string") {
|
|
return undefined;
|
|
}
|
|
const action = actionRaw.trim();
|
|
return action || undefined;
|
|
}
|
|
|
|
export function resolveToolVerbAndDetailForArgs(params: {
|
|
toolKey: string;
|
|
args?: unknown;
|
|
meta?: string;
|
|
spec?: ToolDisplaySpec;
|
|
fallbackDetailKeys?: string[];
|
|
detailMode: "first" | "summary";
|
|
detailCoerce?: CoerceDisplayValueOptions;
|
|
detailMaxEntries?: number;
|
|
detailFormatKey?: (raw: string) => string;
|
|
}): { verb?: string; detail?: string } {
|
|
return resolveToolVerbAndDetail({
|
|
toolKey: params.toolKey,
|
|
args: params.args,
|
|
meta: params.meta,
|
|
action: resolveActionArg(params.args),
|
|
spec: params.spec,
|
|
fallbackDetailKeys: params.fallbackDetailKeys,
|
|
detailMode: params.detailMode,
|
|
detailCoerce: params.detailCoerce,
|
|
detailMaxEntries: params.detailMaxEntries,
|
|
detailFormatKey: params.detailFormatKey,
|
|
});
|
|
}
|
|
|
|
export function coerceDisplayValue(
|
|
value: unknown,
|
|
opts: CoerceDisplayValueOptions = {},
|
|
): string | undefined {
|
|
const maxStringChars = opts.maxStringChars ?? 160;
|
|
const maxArrayEntries = opts.maxArrayEntries ?? 3;
|
|
|
|
if (value === null || value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
|
|
if (!firstLine) {
|
|
return undefined;
|
|
}
|
|
if (firstLine.length > maxStringChars) {
|
|
return `${firstLine.slice(0, Math.max(0, maxStringChars - 3))}…`;
|
|
}
|
|
return firstLine;
|
|
}
|
|
if (typeof value === "boolean") {
|
|
if (!value && !opts.includeFalse) {
|
|
return undefined;
|
|
}
|
|
return value ? "true" : "false";
|
|
}
|
|
if (typeof value === "number") {
|
|
if (!Number.isFinite(value)) {
|
|
return opts.includeNonFinite ? String(value) : undefined;
|
|
}
|
|
if (value === 0 && !opts.includeZero) {
|
|
return undefined;
|
|
}
|
|
return String(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const values = value
|
|
.map((item) => coerceDisplayValue(item, opts))
|
|
.filter((item): item is string => Boolean(item));
|
|
if (values.length === 0) {
|
|
return undefined;
|
|
}
|
|
const preview = values.slice(0, maxArrayEntries).join(", ");
|
|
return values.length > maxArrayEntries ? `${preview}…` : preview;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function lookupValueByPath(args: unknown, path: string): unknown {
|
|
if (!args || typeof args !== "object") {
|
|
return undefined;
|
|
}
|
|
let current: unknown = args;
|
|
for (const segment of path.split(".")) {
|
|
if (!segment) {
|
|
return undefined;
|
|
}
|
|
if (!current || typeof current !== "object") {
|
|
return undefined;
|
|
}
|
|
const record = current as Record<string, unknown>;
|
|
current = record[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export function formatDetailKey(raw: string, overrides: Record<string, string> = {}): string {
|
|
const segments = raw.split(".").filter(Boolean);
|
|
const last = segments.at(-1) ?? raw;
|
|
const override = overrides[last];
|
|
if (override) {
|
|
return override;
|
|
}
|
|
const cleaned = last.replace(/_/g, " ").replace(/-/g, " ");
|
|
const spaced = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
|
return spaced.trim().toLowerCase() || last.toLowerCase();
|
|
}
|
|
|
|
export function resolvePathArg(args: unknown): string | undefined {
|
|
const record = asRecord(args);
|
|
if (!record) {
|
|
return undefined;
|
|
}
|
|
for (const candidate of [record.path, record.file_path, record.filePath]) {
|
|
if (typeof candidate !== "string") {
|
|
continue;
|
|
}
|
|
const trimmed = candidate.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function resolveReadDetail(args: unknown): string | undefined {
|
|
const record = asRecord(args);
|
|
if (!record) {
|
|
return undefined;
|
|
}
|
|
|
|
const path = resolvePathArg(record);
|
|
if (!path) {
|
|
return undefined;
|
|
}
|
|
|
|
const offsetRaw =
|
|
typeof record.offset === "number" && Number.isFinite(record.offset)
|
|
? Math.floor(record.offset)
|
|
: undefined;
|
|
const limitRaw =
|
|
typeof record.limit === "number" && Number.isFinite(record.limit)
|
|
? Math.floor(record.limit)
|
|
: undefined;
|
|
|
|
const offset = offsetRaw !== undefined ? Math.max(1, offsetRaw) : undefined;
|
|
const limit = limitRaw !== undefined ? Math.max(1, limitRaw) : undefined;
|
|
|
|
if (offset !== undefined && limit !== undefined) {
|
|
const unit = limit === 1 ? "line" : "lines";
|
|
return `${unit} ${offset}-${offset + limit - 1} from ${path}`;
|
|
}
|
|
if (offset !== undefined) {
|
|
return `from line ${offset} in ${path}`;
|
|
}
|
|
if (limit !== undefined) {
|
|
const unit = limit === 1 ? "line" : "lines";
|
|
return `first ${limit} ${unit} of ${path}`;
|
|
}
|
|
return `from ${path}`;
|
|
}
|
|
|
|
export function resolveWriteDetail(toolKey: string, args: unknown): string | undefined {
|
|
const record = asRecord(args);
|
|
if (!record) {
|
|
return undefined;
|
|
}
|
|
|
|
const path =
|
|
resolvePathArg(record) ?? (typeof record.url === "string" ? record.url.trim() : undefined);
|
|
if (!path) {
|
|
return undefined;
|
|
}
|
|
|
|
if (toolKey === "attach") {
|
|
return `from ${path}`;
|
|
}
|
|
|
|
const destinationPrefix = toolKey === "edit" ? "in" : "to";
|
|
const content =
|
|
typeof record.content === "string"
|
|
? record.content
|
|
: typeof record.newText === "string"
|
|
? record.newText
|
|
: typeof record.new_string === "string"
|
|
? record.new_string
|
|
: undefined;
|
|
|
|
if (content && content.length > 0) {
|
|
return `${destinationPrefix} ${path} (${content.length} chars)`;
|
|
}
|
|
|
|
return `${destinationPrefix} ${path}`;
|
|
}
|
|
|
|
export function resolveWebSearchDetail(args: unknown): string | undefined {
|
|
const record = asRecord(args);
|
|
if (!record) {
|
|
return undefined;
|
|
}
|
|
|
|
const query = typeof record.query === "string" ? record.query.trim() : undefined;
|
|
const count =
|
|
typeof record.count === "number" && Number.isFinite(record.count) && record.count > 0
|
|
? Math.floor(record.count)
|
|
: undefined;
|
|
|
|
if (!query) {
|
|
return undefined;
|
|
}
|
|
|
|
return count !== undefined ? `for "${query}" (top ${count})` : `for "${query}"`;
|
|
}
|
|
|
|
export function resolveWebFetchDetail(args: unknown): string | undefined {
|
|
const record = asRecord(args);
|
|
if (!record) {
|
|
return undefined;
|
|
}
|
|
|
|
const url = typeof record.url === "string" ? record.url.trim() : undefined;
|
|
if (!url) {
|
|
return undefined;
|
|
}
|
|
|
|
const mode = typeof record.extractMode === "string" ? record.extractMode.trim() : undefined;
|
|
const maxChars =
|
|
typeof record.maxChars === "number" && Number.isFinite(record.maxChars) && record.maxChars > 0
|
|
? Math.floor(record.maxChars)
|
|
: undefined;
|
|
|
|
const suffix = [
|
|
mode ? `mode ${mode}` : undefined,
|
|
maxChars !== undefined ? `max ${maxChars} chars` : undefined,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.join(", ");
|
|
|
|
return suffix ? `from ${url} (${suffix})` : `from ${url}`;
|
|
}
|
|
|
|
export { resolveExecDetail };
|
|
|
|
export function resolveActionSpec(
|
|
spec: ToolDisplaySpec | undefined,
|
|
action: string | undefined,
|
|
): ToolDisplayActionSpec | undefined {
|
|
if (!spec || !action) {
|
|
return undefined;
|
|
}
|
|
return spec.actions?.[action] ?? undefined;
|
|
}
|
|
|
|
export function resolveDetailFromKeys(
|
|
args: unknown,
|
|
keys: string[],
|
|
opts: {
|
|
mode: "first" | "summary";
|
|
coerce?: CoerceDisplayValueOptions;
|
|
maxEntries?: number;
|
|
formatKey?: (raw: string) => string;
|
|
},
|
|
): string | undefined {
|
|
if (opts.mode === "first") {
|
|
for (const key of keys) {
|
|
const value = lookupValueByPath(args, key);
|
|
const display = coerceDisplayValue(value, opts.coerce);
|
|
if (display) {
|
|
return display;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const entries: Array<{ label: string; value: string }> = [];
|
|
for (const key of keys) {
|
|
const value = lookupValueByPath(args, key);
|
|
const display = coerceDisplayValue(value, opts.coerce);
|
|
if (!display) {
|
|
continue;
|
|
}
|
|
entries.push({ label: opts.formatKey ? opts.formatKey(key) : key, value: display });
|
|
}
|
|
if (entries.length === 0) {
|
|
return undefined;
|
|
}
|
|
if (entries.length === 1) {
|
|
return entries[0].value;
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const unique: Array<{ label: string; value: string }> = [];
|
|
for (const entry of entries) {
|
|
const token = `${entry.label}:${entry.value}`;
|
|
if (seen.has(token)) {
|
|
continue;
|
|
}
|
|
seen.add(token);
|
|
unique.push(entry);
|
|
}
|
|
if (unique.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return unique
|
|
.slice(0, opts.maxEntries ?? 8)
|
|
.map((entry) => `${entry.label} ${entry.value}`)
|
|
.join(" · ");
|
|
}
|
|
|
|
export function resolveToolVerbAndDetail(params: {
|
|
toolKey: string;
|
|
args?: unknown;
|
|
meta?: string;
|
|
action?: string;
|
|
spec?: ToolDisplaySpec;
|
|
fallbackDetailKeys?: string[];
|
|
detailMode: "first" | "summary";
|
|
detailCoerce?: CoerceDisplayValueOptions;
|
|
detailMaxEntries?: number;
|
|
detailFormatKey?: (raw: string) => string;
|
|
}): { verb?: string; detail?: string } {
|
|
const actionSpec = resolveActionSpec(params.spec, params.action);
|
|
const fallbackVerb =
|
|
params.toolKey === "web_search"
|
|
? "search"
|
|
: params.toolKey === "web_fetch"
|
|
? "fetch"
|
|
: params.toolKey.replace(/_/g, " ").replace(/\./g, " ");
|
|
const verb = normalizeVerb(actionSpec?.label ?? params.action ?? fallbackVerb);
|
|
|
|
let detail: string | undefined;
|
|
if (params.toolKey === "exec") {
|
|
detail = resolveExecDetail(params.args);
|
|
}
|
|
if (!detail && params.toolKey === "read") {
|
|
detail = resolveReadDetail(params.args);
|
|
}
|
|
if (
|
|
!detail &&
|
|
(params.toolKey === "write" || params.toolKey === "edit" || params.toolKey === "attach")
|
|
) {
|
|
detail = resolveWriteDetail(params.toolKey, params.args);
|
|
}
|
|
if (!detail && params.toolKey === "web_search") {
|
|
detail = resolveWebSearchDetail(params.args);
|
|
}
|
|
if (!detail && params.toolKey === "web_fetch") {
|
|
detail = resolveWebFetchDetail(params.args);
|
|
}
|
|
|
|
const detailKeys =
|
|
actionSpec?.detailKeys ?? params.spec?.detailKeys ?? params.fallbackDetailKeys ?? [];
|
|
if (!detail && detailKeys.length > 0) {
|
|
detail = resolveDetailFromKeys(params.args, detailKeys, {
|
|
mode: params.detailMode,
|
|
coerce: params.detailCoerce,
|
|
maxEntries: params.detailMaxEntries,
|
|
formatKey: params.detailFormatKey,
|
|
});
|
|
}
|
|
if (!detail && params.meta) {
|
|
detail = params.meta;
|
|
}
|
|
return { verb, detail };
|
|
}
|
|
|
|
export function formatToolDetailText(
|
|
detail: string | undefined,
|
|
opts: { prefixWithWith?: boolean } = {},
|
|
): string | undefined {
|
|
if (!detail) {
|
|
return undefined;
|
|
}
|
|
const normalized = detail.includes(" · ")
|
|
? detail
|
|
.split(" · ")
|
|
.map((part) => part.trim())
|
|
.filter((part) => part.length > 0)
|
|
.join(", ")
|
|
: detail;
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
return opts.prefixWithWith ? `with ${normalized}` : normalized;
|
|
}
|