Files
Moltbot/src/gateway/server-methods/agents.ts
2026-02-18 01:34:35 +00:00

530 lines
15 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import {
listAgentIds,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../../agents/agent-scope.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
isWorkspaceOnboardingCompleted,
} from "../../agents/workspace.js";
import { movePathToTrash } from "../../browser/trash.js";
import {
applyAgentConfig,
findAgentEntryIndex,
listAgentEntries,
pruneAgentConfig,
} from "../../commands/agents.config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateAgentsCreateParams,
validateAgentsDeleteParams,
validateAgentsFilesGetParams,
validateAgentsFilesListParams,
validateAgentsFilesSetParams,
validateAgentsListParams,
validateAgentsUpdateParams,
} from "../protocol/index.js";
import { listAgentsForGateway } from "../session-utils.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
const BOOTSTRAP_FILE_NAMES = [
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
] as const;
const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
(name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
);
const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const;
const ALLOWED_FILE_NAMES = new Set<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]);
function resolveAgentWorkspaceFileOrRespondError(
params: Record<string, unknown>,
respond: RespondFn,
): {
cfg: ReturnType<typeof loadConfig>;
agentId: string;
workspaceDir: string;
name: string;
} | null {
const cfg = loadConfig();
const rawAgentId = params.agentId;
const agentId = resolveAgentIdOrError(
typeof rawAgentId === "string" || typeof rawAgentId === "number" ? String(rawAgentId) : "",
cfg,
);
if (!agentId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
return null;
}
const rawName = params.name;
const name = (
typeof rawName === "string" || typeof rawName === "number" ? String(rawName) : ""
).trim();
if (!ALLOWED_FILE_NAMES.has(name)) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`));
return null;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
return { cfg, agentId, workspaceDir, name };
}
type FileMeta = {
size: number;
updatedAtMs: number;
};
async function statFile(filePath: string): Promise<FileMeta | null> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return null;
}
return {
size: stat.size,
updatedAtMs: Math.floor(stat.mtimeMs),
};
} catch {
return null;
}
}
async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
const files: Array<{
name: string;
path: string;
missing: boolean;
size?: number;
updatedAtMs?: number;
}> = [];
const bootstrapFileNames = options?.hideBootstrap
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
: BOOTSTRAP_FILE_NAMES;
for (const name of bootstrapFileNames) {
const filePath = path.join(workspaceDir, name);
const meta = await statFile(filePath);
if (meta) {
files.push({
name,
path: filePath,
missing: false,
size: meta.size,
updatedAtMs: meta.updatedAtMs,
});
} else {
files.push({ name, path: filePath, missing: true });
}
}
const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME);
const primaryMeta = await statFile(primaryMemoryPath);
if (primaryMeta) {
files.push({
name: DEFAULT_MEMORY_FILENAME,
path: primaryMemoryPath,
missing: false,
size: primaryMeta.size,
updatedAtMs: primaryMeta.updatedAtMs,
});
} else {
const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME);
const altMeta = await statFile(altMemoryPath);
if (altMeta) {
files.push({
name: DEFAULT_MEMORY_ALT_FILENAME,
path: altMemoryPath,
missing: false,
size: altMeta.size,
updatedAtMs: altMeta.updatedAtMs,
});
} else {
files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true });
}
}
return files;
}
function resolveAgentIdOrError(agentIdRaw: string, cfg: ReturnType<typeof loadConfig>) {
const agentId = normalizeAgentId(agentIdRaw);
const allowed = new Set(listAgentIds(cfg));
if (!allowed.has(agentId)) {
return null;
}
return agentId;
}
function sanitizeIdentityLine(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function resolveOptionalStringParam(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
async function moveToTrashBestEffort(pathname: string): Promise<void> {
if (!pathname) {
return;
}
try {
await fs.access(pathname);
} catch {
return;
}
try {
await movePathToTrash(pathname);
} catch {
// Best-effort: path may already be gone or trash unavailable.
}
}
export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`,
),
);
return;
}
const cfg = loadConfig();
const result = listAgentsForGateway(cfg);
respond(true, result, undefined);
},
"agents.create": async ({ params, respond }) => {
if (!validateAgentsCreateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.create params: ${formatValidationErrors(
validateAgentsCreateParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const rawName = String(params.name ?? "").trim();
const agentId = normalizeAgentId(rawName);
if (agentId === DEFAULT_AGENT_ID) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `"${DEFAULT_AGENT_ID}" is reserved`),
);
return;
}
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" already exists`),
);
return;
}
const workspaceDir = resolveUserPath(String(params.workspace ?? "").trim());
// Resolve agentDir against the config we're about to persist (vs the pre-write config),
// so subsequent resolutions can't disagree about the agent's directory.
let nextConfig = applyAgentConfig(cfg, {
agentId,
name: rawName,
workspace: workspaceDir,
});
const agentDir = resolveAgentDir(nextConfig, agentId);
nextConfig = applyAgentConfig(nextConfig, { agentId, agentDir });
// Ensure workspace & transcripts exist BEFORE writing config so a failure
// here does not leave a broken config entry behind.
const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap);
await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap });
await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true });
await writeConfigFile(nextConfig);
// Always write Name to IDENTITY.md; optionally include emoji/avatar.
const safeName = sanitizeIdentityLine(rawName);
const emoji = resolveOptionalStringParam(params.emoji);
const avatar = resolveOptionalStringParam(params.avatar);
const identityPath = path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME);
const lines = [
"",
`- Name: ${safeName}`,
...(emoji ? [`- Emoji: ${sanitizeIdentityLine(emoji)}`] : []),
...(avatar ? [`- Avatar: ${sanitizeIdentityLine(avatar)}`] : []),
"",
];
await fs.appendFile(identityPath, lines.join("\n"), "utf-8");
respond(true, { ok: true, agentId, name: rawName, workspace: workspaceDir }, undefined);
},
"agents.update": async ({ params, respond }) => {
if (!validateAgentsUpdateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.update params: ${formatValidationErrors(
validateAgentsUpdateParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = normalizeAgentId(String(params.agentId ?? ""));
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`),
);
return;
}
const workspaceDir =
typeof params.workspace === "string" && params.workspace.trim()
? resolveUserPath(params.workspace.trim())
: undefined;
const model = resolveOptionalStringParam(params.model);
const avatar = resolveOptionalStringParam(params.avatar);
const nextConfig = applyAgentConfig(cfg, {
agentId,
...(typeof params.name === "string" && params.name.trim()
? { name: params.name.trim() }
: {}),
...(workspaceDir ? { workspace: workspaceDir } : {}),
...(model ? { model } : {}),
});
await writeConfigFile(nextConfig);
if (workspaceDir) {
const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap);
await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap });
}
if (avatar) {
const workspace = workspaceDir ?? resolveAgentWorkspaceDir(nextConfig, agentId);
await fs.mkdir(workspace, { recursive: true });
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
await fs.appendFile(identityPath, `\n- Avatar: ${sanitizeIdentityLine(avatar)}\n`, "utf-8");
}
respond(true, { ok: true, agentId }, undefined);
},
"agents.delete": async ({ params, respond }) => {
if (!validateAgentsDeleteParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.delete params: ${formatValidationErrors(
validateAgentsDeleteParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = normalizeAgentId(String(params.agentId ?? ""));
if (agentId === DEFAULT_AGENT_ID) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `"${DEFAULT_AGENT_ID}" cannot be deleted`),
);
return;
}
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`),
);
return;
}
const deleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const agentDir = resolveAgentDir(cfg, agentId);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
const result = pruneAgentConfig(cfg, agentId);
await writeConfigFile(result.config);
if (deleteFiles) {
await Promise.all([
moveToTrashBestEffort(workspaceDir),
moveToTrashBestEffort(agentDir),
moveToTrashBestEffort(sessionsDir),
]);
}
respond(true, { ok: true, agentId, removedBindings: result.removedBindings }, undefined);
},
"agents.files.list": async ({ params, respond }) => {
if (!validateAgentsFilesListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.list params: ${formatValidationErrors(
validateAgentsFilesListParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg);
if (!agentId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
return;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
let hideBootstrap = false;
try {
hideBootstrap = await isWorkspaceOnboardingCompleted(workspaceDir);
} catch {
// Fall back to showing BOOTSTRAP if workspace state cannot be read.
}
const files = await listAgentFiles(workspaceDir, { hideBootstrap });
respond(true, { agentId, workspace: workspaceDir, files }, undefined);
},
"agents.files.get": async ({ params, respond }) => {
if (!validateAgentsFilesGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.get params: ${formatValidationErrors(
validateAgentsFilesGetParams.errors,
)}`,
),
);
return;
}
const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond);
if (!resolved) {
return;
}
const { agentId, workspaceDir, name } = resolved;
const filePath = path.join(workspaceDir, name);
const meta = await statFile(filePath);
if (!meta) {
respond(
true,
{
agentId,
workspace: workspaceDir,
file: { name, path: filePath, missing: true },
},
undefined,
);
return;
}
const content = await fs.readFile(filePath, "utf-8");
respond(
true,
{
agentId,
workspace: workspaceDir,
file: {
name,
path: filePath,
missing: false,
size: meta.size,
updatedAtMs: meta.updatedAtMs,
content,
},
},
undefined,
);
},
"agents.files.set": async ({ params, respond }) => {
if (!validateAgentsFilesSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.set params: ${formatValidationErrors(
validateAgentsFilesSetParams.errors,
)}`,
),
);
return;
}
const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond);
if (!resolved) {
return;
}
const { agentId, workspaceDir, name } = resolved;
await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name);
const content = String(params.content ?? "");
await fs.writeFile(filePath, content, "utf-8");
const meta = await statFile(filePath);
respond(
true,
{
ok: true,
agentId,
workspace: workspaceDir,
file: {
name,
path: filePath,
missing: false,
size: meta?.size,
updatedAtMs: meta?.updatedAtMs,
content,
},
},
undefined,
);
},
};