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:
Advait Paliwal
2026-02-07 16:47:58 -08:00
committed by GitHub
parent 9271fcb3d4
commit 980f788731
13 changed files with 984 additions and 5 deletions

View File

@@ -0,0 +1,373 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
/* ------------------------------------------------------------------ */
/* Mocks */
/* ------------------------------------------------------------------ */
const mocks = vi.hoisted(() => ({
loadConfigReturn: {} as Record<string, unknown>,
listAgentEntries: vi.fn(() => [] as Array<{ agentId: string }>),
findAgentEntryIndex: vi.fn(() => -1),
applyAgentConfig: vi.fn((_cfg: unknown, _opts: unknown) => ({})),
pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })),
writeConfigFile: vi.fn(async () => {}),
ensureAgentWorkspace: vi.fn(async () => {}),
resolveAgentDir: vi.fn(() => "/agents/test-agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/workspace/test-agent"),
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/transcripts/test-agent"),
listAgentsForGateway: vi.fn(() => ({
defaultId: "main",
mainKey: "agent:main:main",
scope: "global",
agents: [],
})),
movePathToTrash: vi.fn(async () => "/trashed"),
fsAccess: vi.fn(async () => {}),
fsMkdir: vi.fn(async () => undefined),
fsAppendFile: vi.fn(async () => {}),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: () => mocks.loadConfigReturn,
writeConfigFile: mocks.writeConfigFile,
}));
vi.mock("../../commands/agents.config.js", () => ({
applyAgentConfig: mocks.applyAgentConfig,
findAgentEntryIndex: mocks.findAgentEntryIndex,
listAgentEntries: mocks.listAgentEntries,
pruneAgentConfig: mocks.pruneAgentConfig,
}));
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
resolveAgentDir: mocks.resolveAgentDir,
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
}));
vi.mock("../../agents/workspace.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/workspace.js")>(
"../../agents/workspace.js",
);
return {
...actual,
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
};
});
vi.mock("../../config/sessions/paths.js", () => ({
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
}));
vi.mock("../../browser/trash.js", () => ({
movePathToTrash: mocks.movePathToTrash,
}));
vi.mock("../../utils.js", () => ({
resolveUserPath: (p: string) => `/resolved${p.startsWith("/") ? "" : "/"}${p}`,
}));
vi.mock("../session-utils.js", () => ({
listAgentsForGateway: mocks.listAgentsForGateway,
}));
// Mock node:fs/promises agents.ts uses `import fs from "node:fs/promises"`
// which resolves to the module namespace default, so we spread actual and
// override the methods we need, plus set `default` explicitly.
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
const patched = {
...actual,
access: mocks.fsAccess,
mkdir: mocks.fsMkdir,
appendFile: mocks.fsAppendFile,
};
return { ...patched, default: patched };
});
/* ------------------------------------------------------------------ */
/* Import after mocks are set up */
/* ------------------------------------------------------------------ */
const { agentsHandlers } = await import("./agents.js");
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function makeCall(method: keyof typeof agentsHandlers, params: Record<string, unknown>) {
const respond = vi.fn();
const handler = agentsHandlers[method];
const promise = handler({
params,
respond,
context: {} as never,
req: { type: "req" as const, id: "1", method },
client: null,
isWebchatConnect: () => false,
});
return { respond, promise };
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe("agents.create", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfigReturn = {};
mocks.findAgentEntryIndex.mockReturnValue(-1);
mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({}));
});
it("creates a new agent successfully", async () => {
const { respond, promise } = makeCall("agents.create", {
name: "Test Agent",
workspace: "/home/user/agents/test",
});
await promise;
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
ok: true,
agentId: "test-agent",
name: "Test Agent",
}),
undefined,
);
expect(mocks.ensureAgentWorkspace).toHaveBeenCalled();
expect(mocks.writeConfigFile).toHaveBeenCalled();
});
it("ensures workspace is set up before writing config", async () => {
const callOrder: string[] = [];
mocks.ensureAgentWorkspace.mockImplementation(async () => {
callOrder.push("ensureAgentWorkspace");
});
mocks.writeConfigFile.mockImplementation(async () => {
callOrder.push("writeConfigFile");
});
const { promise } = makeCall("agents.create", {
name: "Order Test",
workspace: "/tmp/ws",
});
await promise;
expect(callOrder.indexOf("ensureAgentWorkspace")).toBeLessThan(
callOrder.indexOf("writeConfigFile"),
);
});
it("rejects creating an agent with reserved 'main' id", async () => {
const { respond, promise } = makeCall("agents.create", {
name: "main",
workspace: "/tmp/ws",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("reserved") }),
);
});
it("rejects creating a duplicate agent", async () => {
mocks.findAgentEntryIndex.mockReturnValue(0);
const { respond, promise } = makeCall("agents.create", {
name: "Existing",
workspace: "/tmp/ws",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("already exists") }),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("rejects invalid params (missing name)", async () => {
const { respond, promise } = makeCall("agents.create", {
workspace: "/tmp/ws",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("invalid") }),
);
});
it("always writes Name to IDENTITY.md even without emoji/avatar", async () => {
const { promise } = makeCall("agents.create", {
name: "Plain Agent",
workspace: "/tmp/ws",
});
await promise;
expect(mocks.fsAppendFile).toHaveBeenCalledWith(
expect.stringContaining("IDENTITY.md"),
expect.stringContaining("- Name: Plain Agent"),
"utf-8",
);
});
it("writes emoji and avatar to IDENTITY.md when provided", async () => {
const { promise } = makeCall("agents.create", {
name: "Fancy Agent",
workspace: "/tmp/ws",
emoji: "🤖",
avatar: "https://example.com/avatar.png",
});
await promise;
expect(mocks.fsAppendFile).toHaveBeenCalledWith(
expect.stringContaining("IDENTITY.md"),
expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/),
"utf-8",
);
});
});
describe("agents.update", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfigReturn = {};
mocks.findAgentEntryIndex.mockReturnValue(0);
mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({}));
});
it("updates an existing agent successfully", async () => {
const { respond, promise } = makeCall("agents.update", {
agentId: "test-agent",
name: "Updated Name",
});
await promise;
expect(respond).toHaveBeenCalledWith(true, { ok: true, agentId: "test-agent" }, undefined);
expect(mocks.writeConfigFile).toHaveBeenCalled();
});
it("rejects updating a nonexistent agent", async () => {
mocks.findAgentEntryIndex.mockReturnValue(-1);
const { respond, promise } = makeCall("agents.update", {
agentId: "nonexistent",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("not found") }),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("ensures workspace when workspace changes", async () => {
const { promise } = makeCall("agents.update", {
agentId: "test-agent",
workspace: "/new/workspace",
});
await promise;
expect(mocks.ensureAgentWorkspace).toHaveBeenCalled();
});
it("does not ensure workspace when workspace is unchanged", async () => {
const { promise } = makeCall("agents.update", {
agentId: "test-agent",
name: "Just a rename",
});
await promise;
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
});
});
describe("agents.delete", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfigReturn = {};
mocks.findAgentEntryIndex.mockReturnValue(0);
mocks.pruneAgentConfig.mockReturnValue({ config: {}, removedBindings: 2 });
});
it("deletes an existing agent and trashes files by default", async () => {
const { respond, promise } = makeCall("agents.delete", {
agentId: "test-agent",
});
await promise;
expect(respond).toHaveBeenCalledWith(
true,
{ ok: true, agentId: "test-agent", removedBindings: 2 },
undefined,
);
expect(mocks.writeConfigFile).toHaveBeenCalled();
// moveToTrashBestEffort calls fs.access then movePathToTrash for each dir
expect(mocks.movePathToTrash).toHaveBeenCalled();
});
it("skips file deletion when deleteFiles is false", async () => {
mocks.fsAccess.mockClear();
const { respond, promise } = makeCall("agents.delete", {
agentId: "test-agent",
deleteFiles: false,
});
await promise;
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({ ok: true }), undefined);
// moveToTrashBestEffort should not be called at all
expect(mocks.fsAccess).not.toHaveBeenCalled();
});
it("rejects deleting the main agent", async () => {
const { respond, promise } = makeCall("agents.delete", {
agentId: "main",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("cannot be deleted") }),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("rejects deleting a nonexistent agent", async () => {
mocks.findAgentEntryIndex.mockReturnValue(-1);
const { respond, promise } = makeCall("agents.delete", {
agentId: "ghost",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("not found") }),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("rejects invalid params (missing agentId)", async () => {
const { respond, promise } = makeCall("agents.delete", {});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("invalid") }),
);
});
});

View File

@@ -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(