mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { compactActivityPath, redactActivityText } from "./activity-text";
|
|
|
|
export type GenericToolStatus = "running" | "done" | "error";
|
|
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
|
|
|
|
interface ToolField {
|
|
key:
|
|
| "query"
|
|
| "pattern"
|
|
| "glob"
|
|
| "path"
|
|
| "file_path"
|
|
| "url"
|
|
| "action"
|
|
| "key"
|
|
| "label"
|
|
| "name"
|
|
| "channel"
|
|
| "chat_id"
|
|
| "session_id"
|
|
| "ui_summary";
|
|
value: string;
|
|
}
|
|
|
|
export interface GenericToolTrace {
|
|
name: string;
|
|
family: ToolFamily;
|
|
groupKey: string;
|
|
fields: ToolField[];
|
|
collectedSource: boolean;
|
|
}
|
|
|
|
export interface GenericToolRunItem {
|
|
trace: GenericToolTrace;
|
|
status: GenericToolStatus;
|
|
error?: string;
|
|
}
|
|
|
|
export interface GenericToolPresentation {
|
|
status: GenericToolStatus;
|
|
label: string;
|
|
detail: string;
|
|
aside: string;
|
|
}
|
|
|
|
const CONTENT_SEARCH_TOOLS = new Set([
|
|
"grep",
|
|
"rg",
|
|
"ripgrep",
|
|
"search_code",
|
|
"search_content",
|
|
"search_files_content",
|
|
"find_text",
|
|
]);
|
|
const FILE_SEARCH_TOOLS = new Set([
|
|
"find",
|
|
"find_file",
|
|
"find_files",
|
|
"glob",
|
|
"search_files",
|
|
]);
|
|
const LIST_TOOLS = new Set(["list_dir", "list_directory", "list_files", "ls"]);
|
|
const READ_TOOLS = new Set(["read", "read_file", "read_text_file"]);
|
|
const MEMORY_TOOLS = new Set(["memory_search", "search_memory", "recall_memory"]);
|
|
const EXCLUDED_TOOL_PREFIXES = ["mcp_"];
|
|
const EXCLUDED_TOOLS = new Set([
|
|
"apply_patch",
|
|
"cli_anything_run",
|
|
"edit_file",
|
|
"exec",
|
|
"exec_command",
|
|
"execute_command",
|
|
"run_cli_app",
|
|
"run_command",
|
|
"run_shell",
|
|
"shell",
|
|
"terminal",
|
|
"web_fetch",
|
|
"web_search",
|
|
"x_search",
|
|
"write_file",
|
|
]);
|
|
|
|
export function parseGenericToolTrace(line: string): GenericToolTrace | null {
|
|
const call = parseCall(line);
|
|
if (!call || isExcludedTool(call.name)) return null;
|
|
const family = toolFamily(call.name);
|
|
const fields = safeFields(call.args);
|
|
const collectedSource = fields.some((field) => isCollectedSourcePath(field.value));
|
|
return {
|
|
name: call.name,
|
|
family,
|
|
groupKey: family === "generic"
|
|
? `${family}:${call.name}`
|
|
: `${family}:${collectedSource ? "collected" : "workspace"}`,
|
|
fields,
|
|
collectedSource,
|
|
};
|
|
}
|
|
|
|
export function canGroupGenericToolRuns(previous: GenericToolRunItem, next: GenericToolRunItem): boolean {
|
|
return previous.trace.groupKey === next.trace.groupKey;
|
|
}
|
|
|
|
function compactGenericToolPath(value: string): string {
|
|
const normalized = redactActivityText(value).replace(/\\/g, "/");
|
|
if (isCollectedSourcePath(normalized)) {
|
|
return truncateMiddle(normalized.split("/").pop() || "collected source", 64);
|
|
}
|
|
return compactActivityPath(normalized);
|
|
}
|
|
|
|
export function describeGenericToolRun(items: GenericToolRunItem[]): GenericToolPresentation {
|
|
const status = aggregateStatus(items);
|
|
const family = items[0]?.trace.family ?? "generic";
|
|
const name = items[0]?.trace.name ?? "tool";
|
|
const collected = items.length > 0 && items.every((item) => item.trace.collectedSource);
|
|
return {
|
|
status,
|
|
label: activityLabel(family, status, collected, name, items),
|
|
detail: activityDetail(items, family, name),
|
|
aside: activityAside(items, family),
|
|
};
|
|
}
|
|
|
|
function parseCall(line: string): { name: string; args: unknown } | null {
|
|
const match = /^([a-zA-Z0-9_.-]+)\((.*)\)$/.exec(line.trim());
|
|
if (!match) return null;
|
|
const name = compactToolName(match[1]);
|
|
let args: unknown;
|
|
try {
|
|
args = match[2].trim() ? JSON.parse(match[2]) : {};
|
|
} catch {
|
|
args = {};
|
|
}
|
|
return { name, args };
|
|
}
|
|
|
|
function compactToolName(name: string): string {
|
|
return name.toLowerCase().split(".").pop() || name.toLowerCase();
|
|
}
|
|
|
|
function isExcludedTool(name: string): boolean {
|
|
return EXCLUDED_TOOLS.has(name) || EXCLUDED_TOOL_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
}
|
|
|
|
function toolFamily(name: string): ToolFamily {
|
|
if (CONTENT_SEARCH_TOOLS.has(name)) return "content-search";
|
|
if (FILE_SEARCH_TOOLS.has(name)) return "file-search";
|
|
if (LIST_TOOLS.has(name)) return "list";
|
|
if (READ_TOOLS.has(name)) return "read";
|
|
if (MEMORY_TOOLS.has(name)) return "memory";
|
|
return "generic";
|
|
}
|
|
|
|
function safeFields(args: unknown): ToolField[] {
|
|
if (!args || typeof args !== "object" || Array.isArray(args)) return [];
|
|
const record = args as Record<string, unknown>;
|
|
const fields: ToolField[] = [];
|
|
for (const key of [
|
|
"query",
|
|
"pattern",
|
|
"glob",
|
|
"path",
|
|
"file_path",
|
|
"url",
|
|
"action",
|
|
"key",
|
|
"label",
|
|
"name",
|
|
"channel",
|
|
"chat_id",
|
|
"session_id",
|
|
"ui_summary",
|
|
] as const) {
|
|
const value = record[key];
|
|
if (typeof value === "string" && value.trim()) {
|
|
fields.push({ key, value: value.trim() });
|
|
}
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
function aggregateStatus(items: GenericToolRunItem[]): GenericToolStatus {
|
|
if (items.some((item) => item.status === "error")) return "error";
|
|
if (items.some((item) => item.status === "running")) return "running";
|
|
return "done";
|
|
}
|
|
|
|
function activityLabel(
|
|
family: ToolFamily,
|
|
status: GenericToolStatus,
|
|
collected: boolean,
|
|
name: string,
|
|
items: GenericToolRunItem[],
|
|
): string {
|
|
if (family === "content-search") {
|
|
return statusCopy(
|
|
status,
|
|
collected ? "Reviewing sources" : "Searching files",
|
|
collected ? "Reviewed sources" : "Searched files",
|
|
collected ? "Could not review sources" : "Could not search files",
|
|
);
|
|
}
|
|
if (family === "file-search") {
|
|
return statusCopy(status, "Finding files", "Found files", "Could not find files");
|
|
}
|
|
if (family === "list") {
|
|
return statusCopy(status, "Listing files", "Listed files", "Could not list files");
|
|
}
|
|
if (family === "read") {
|
|
return statusCopy(
|
|
status,
|
|
collected ? "Reading source" : "Reading file",
|
|
collected ? "Read source" : "Read file",
|
|
collected ? "Could not read source" : "Could not read file",
|
|
);
|
|
}
|
|
if (family === "memory") {
|
|
return statusCopy(status, "Searching memory", "Searched memory", "Could not search memory");
|
|
}
|
|
|
|
const action = fieldValue(items[0]?.trace, "action").toLowerCase();
|
|
switch (name) {
|
|
case "generate_image":
|
|
return statusCopy(status, "Generating image", "Generated image", "Could not generate image");
|
|
case "spawn":
|
|
return statusCopy(status, "Delegating task", "Delegated task", "Could not delegate task");
|
|
case "message":
|
|
return statusCopy(status, "Sending message", "Sent message", "Could not send message");
|
|
case "my":
|
|
return action === "set" || action === "modify"
|
|
? statusCopy(status, "Updating agent settings", "Updated agent settings", "Could not update agent settings")
|
|
: statusCopy(status, "Checking agent settings", "Checked agent settings", "Could not check agent settings");
|
|
case "cron":
|
|
if (action === "add") return statusCopy(status, "Scheduling automation", "Scheduled automation", "Could not schedule automation");
|
|
if (action === "remove") return statusCopy(status, "Removing automation", "Removed automation", "Could not remove automation");
|
|
return statusCopy(status, "Checking automations", "Checked automations", "Could not check automations");
|
|
case "create_goal":
|
|
return statusCopy(status, "Starting long task", "Started long task", "Could not start long task");
|
|
case "update_goal":
|
|
return statusCopy(status, "Updating long task", "Updated long task", "Could not update long task");
|
|
// TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1.
|
|
case "exec_session":
|
|
case "write_stdin":
|
|
return statusCopy(status, "Continuing command", "Continued command", "Could not continue command");
|
|
case "list_exec_sessions":
|
|
return statusCopy(status, "Checking running commands", "Checked running commands", "Could not check running commands");
|
|
case "screenshot":
|
|
case "capture_screenshot":
|
|
return statusCopy(status, "Capturing screenshot", "Captured screenshot", "Could not capture screenshot");
|
|
default: {
|
|
const humanName = humanizeToolName(name);
|
|
return statusCopy(
|
|
status,
|
|
`Running ${humanName}`,
|
|
`Completed ${humanName}`,
|
|
`Could not complete ${humanName}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: string): string {
|
|
if (items.length !== 1) return "";
|
|
const trace = items[0].trace;
|
|
if (family === "content-search") {
|
|
return quote(fieldValue(trace, "query") || fieldValue(trace, "pattern"));
|
|
}
|
|
if (family === "file-search") {
|
|
return compactDetail(
|
|
fieldValue(trace, "glob")
|
|
|| fieldValue(trace, "query")
|
|
|| fieldValue(trace, "pattern")
|
|
|| fieldValue(trace, "path"),
|
|
);
|
|
}
|
|
if (family === "list" || family === "read") {
|
|
return compactDetail(fieldValue(trace, "path") || fieldValue(trace, "file_path"));
|
|
}
|
|
if (family === "memory") return quote(fieldValue(trace, "query"));
|
|
|
|
switch (name) {
|
|
case "spawn":
|
|
return safeText(fieldValue(trace, "label"));
|
|
case "message":
|
|
return safeText(fieldValue(trace, "channel"));
|
|
case "my":
|
|
return safeText(fieldValue(trace, "key"));
|
|
case "cron":
|
|
return safeText(fieldValue(trace, "name"));
|
|
case "create_goal":
|
|
return safeText(fieldValue(trace, "ui_summary"));
|
|
case "update_goal":
|
|
return safeText(fieldValue(trace, "action"));
|
|
// TODO(0.3.2): Remove write_stdin display compatibility after 0.3.1.
|
|
case "exec_session":
|
|
case "write_stdin":
|
|
return compactIdentifier(fieldValue(trace, "session_id"));
|
|
case "screenshot":
|
|
case "capture_screenshot":
|
|
return "";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function activityAside(items: GenericToolRunItem[], family: ToolFamily): string {
|
|
const pathCount = uniqueValues(items, ["path", "file_path"]).length;
|
|
if (pathCount > 1) return `${pathCount} files`;
|
|
if (items.length <= 1) return "";
|
|
if (family === "content-search" || family === "file-search" || family === "memory") {
|
|
return `${items.length} searches`;
|
|
}
|
|
return `${items.length} actions`;
|
|
}
|
|
|
|
function fieldValue(trace: GenericToolTrace | undefined, key: ToolField["key"]): string {
|
|
return trace?.fields.find((field) => field.key === key)?.value ?? "";
|
|
}
|
|
|
|
function uniqueValues(items: GenericToolRunItem[], keys: ToolField["key"][]): string[] {
|
|
const values = items.flatMap((item) => item.trace.fields)
|
|
.filter((field) => keys.includes(field.key))
|
|
.map((field) => field.value);
|
|
return [...new Set(values)];
|
|
}
|
|
|
|
function statusCopy(status: GenericToolStatus, running: string, done: string, failed: string): string {
|
|
return status === "running" ? running : status === "error" ? failed : done;
|
|
}
|
|
|
|
function compactDetail(value: string): string {
|
|
return value ? truncateMiddle(compactGenericToolPath(value), 88) : "";
|
|
}
|
|
|
|
function safeText(value: string): string {
|
|
return value ? truncateMiddle(redactActivityText(value).replace(/\s+/g, " ").trim(), 88) : "";
|
|
}
|
|
|
|
function quote(value: string): string {
|
|
const safe = safeText(value);
|
|
return safe ? `“${safe}”` : "";
|
|
}
|
|
|
|
function compactIdentifier(value: string): string {
|
|
const safe = safeText(value);
|
|
if (safe.length <= 16) return safe;
|
|
return `${safe.slice(0, 7)}…${safe.slice(-5)}`;
|
|
}
|
|
|
|
function humanizeToolName(name: string): string {
|
|
const words = name
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
.replace(/[._-]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "tool action";
|
|
}
|
|
|
|
function isCollectedSourcePath(value: string): boolean {
|
|
const normalized = value.replace(/\\/g, "/");
|
|
return normalized.includes("/.nanobot/tool-results/") || normalized.includes("/nanobot/tool-results/");
|
|
}
|
|
|
|
function truncateMiddle(value: string, maxLength: number): string {
|
|
if (value.length <= maxLength) return value;
|
|
const head = Math.ceil((maxLength - 1) * 0.62);
|
|
const tail = Math.floor((maxLength - 1) * 0.38);
|
|
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
|
}
|