openclaw/src/agents/tool-display-common.ts

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;
}