mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 13:21:25 +00:00
feat(gateway): add agents.create/update/delete methods (#11045)
* feat(gateway): add agents.create/update/delete methods * fix(lint): preserve memory-lancedb load error cause * feat(gateway): trash agent files on agents.delete * chore(protocol): regenerate Swift gateway models * fix(gateway): stabilize agents.create dirs and agentDir * feat(gateway): support avatar in agents.create * fix: prep agents.create/update/delete handlers (#11045) (thanks @advaitpaliwal) - Reuse movePathToTrash from browser/trash.ts (has ~/.Trash fallback on non-macOS) - Fix partial-failure: workspace setup now runs before config write - Always write Name to IDENTITY.md regardless of emoji/avatar - Add unit tests for agents.create, agents.update, agents.delete - Add CHANGELOG entry --------- Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
@@ -12,17 +16,30 @@ import {
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_USER_FILENAME,
|
||||
ensureAgentWorkspace,
|
||||
} from "../../agents/workspace.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.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";
|
||||
|
||||
@@ -123,6 +140,30 @@ function resolveAgentIdOrError(agentIdRaw: string, cfg: ReturnType<typeof loadCo
|
||||
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)) {
|
||||
@@ -141,6 +182,189 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user