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

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
### Added
- Gateway: add `agents.create`, `agents.update`, `agents.delete` RPC methods for web UI agent management. (#11045) Thanks @advaitpaliwal.
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.

View File

@@ -1589,6 +1589,140 @@ public struct AgentSummary: Codable, Sendable {
}
}
public struct AgentsCreateParams: Codable, Sendable {
public let name: String
public let workspace: String
public let emoji: String?
public let avatar: String?
public init(
name: String,
workspace: String,
emoji: String?,
avatar: String?
) {
self.name = name
self.workspace = workspace
self.emoji = emoji
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case name
case workspace
case emoji
case avatar
}
}
public struct AgentsCreateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let name: String
public let workspace: String
public init(
ok: Bool,
agentid: String,
name: String,
workspace: String
) {
self.ok = ok
self.agentid = agentid
self.name = name
self.workspace = workspace
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case name
case workspace
}
}
public struct AgentsUpdateParams: Codable, Sendable {
public let agentid: String
public let name: String?
public let workspace: String?
public let model: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
workspace: String?,
model: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.workspace = workspace
self.model = model
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case workspace
case model
case avatar
}
}
public struct AgentsUpdateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public init(
ok: Bool,
agentid: String
) {
self.ok = ok
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
}
}
public struct AgentsDeleteParams: Codable, Sendable {
public let agentid: String
public let deletefiles: Bool?
public init(
agentid: String,
deletefiles: Bool?
) {
self.agentid = agentid
self.deletefiles = deletefiles
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case deletefiles = "deleteFiles"
}
}
public struct AgentsDeleteResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let removedbindings: Int
public init(
ok: Bool,
agentid: String,
removedbindings: Int
) {
self.ok = ok
self.agentid = agentid
self.removedbindings = removedbindings
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case removedbindings = "removedBindings"
}
}
public struct AgentsFileEntry: Codable, Sendable {
public let name: String
public let path: String

View File

@@ -1589,6 +1589,140 @@ public struct AgentSummary: Codable, Sendable {
}
}
public struct AgentsCreateParams: Codable, Sendable {
public let name: String
public let workspace: String
public let emoji: String?
public let avatar: String?
public init(
name: String,
workspace: String,
emoji: String?,
avatar: String?
) {
self.name = name
self.workspace = workspace
self.emoji = emoji
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case name
case workspace
case emoji
case avatar
}
}
public struct AgentsCreateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let name: String
public let workspace: String
public init(
ok: Bool,
agentid: String,
name: String,
workspace: String
) {
self.ok = ok
self.agentid = agentid
self.name = name
self.workspace = workspace
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case name
case workspace
}
}
public struct AgentsUpdateParams: Codable, Sendable {
public let agentid: String
public let name: String?
public let workspace: String?
public let model: String?
public let avatar: String?
public init(
agentid: String,
name: String?,
workspace: String?,
model: String?,
avatar: String?
) {
self.agentid = agentid
self.name = name
self.workspace = workspace
self.model = model
self.avatar = avatar
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case workspace
case model
case avatar
}
}
public struct AgentsUpdateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public init(
ok: Bool,
agentid: String
) {
self.ok = ok
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
}
}
public struct AgentsDeleteParams: Codable, Sendable {
public let agentid: String
public let deletefiles: Bool?
public init(
agentid: String,
deletefiles: Bool?
) {
self.agentid = agentid
self.deletefiles = deletefiles
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case deletefiles = "deleteFiles"
}
}
public struct AgentsDeleteResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let removedbindings: Int
public init(
ok: Bool,
agentid: String,
removedbindings: Int
) {
self.ok = ok
self.agentid = agentid
self.removedbindings = removedbindings
}
private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case removedbindings = "removedBindings"
}
}
public struct AgentsFileEntry: Codable, Sendable {
public let name: String
public let path: String

View File

@@ -32,7 +32,7 @@ const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
return await lancedbImportPromise;
} catch (err) {
// Common on macOS today: upstream package may not ship darwin native bindings.
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`);
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`, { cause: err });
}
};

View File

@@ -12,6 +12,18 @@ import {
AgentSummarySchema,
type AgentsFileEntry,
AgentsFileEntrySchema,
type AgentsCreateParams,
AgentsCreateParamsSchema,
type AgentsCreateResult,
AgentsCreateResultSchema,
type AgentsUpdateParams,
AgentsUpdateParamsSchema,
type AgentsUpdateResult,
AgentsUpdateResultSchema,
type AgentsDeleteParams,
AgentsDeleteParamsSchema,
type AgentsDeleteResult,
AgentsDeleteResultSchema,
type AgentsFilesGetParams,
AgentsFilesGetParamsSchema,
type AgentsFilesGetResult,
@@ -226,6 +238,9 @@ export const validateAgentIdentityParams =
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
export const validateAgentsCreateParams = ajv.compile<AgentsCreateParams>(AgentsCreateParamsSchema);
export const validateAgentsUpdateParams = ajv.compile<AgentsUpdateParams>(AgentsUpdateParamsSchema);
export const validateAgentsDeleteParams = ajv.compile<AgentsDeleteParams>(AgentsDeleteParamsSchema);
export const validateAgentsFilesListParams = ajv.compile<AgentsFilesListParams>(
AgentsFilesListParamsSchema,
);
@@ -438,6 +453,12 @@ export {
WebLoginWaitParamsSchema,
AgentSummarySchema,
AgentsFileEntrySchema,
AgentsCreateParamsSchema,
AgentsCreateResultSchema,
AgentsUpdateParamsSchema,
AgentsUpdateResultSchema,
AgentsDeleteParamsSchema,
AgentsDeleteResultSchema,
AgentsFilesListParamsSchema,
AgentsFilesListResultSchema,
AgentsFilesGetParamsSchema,
@@ -519,6 +540,12 @@ export type {
WebLoginWaitParams,
AgentSummary,
AgentsFileEntry,
AgentsCreateParams,
AgentsCreateResult,
AgentsUpdateParams,
AgentsUpdateResult,
AgentsDeleteParams,
AgentsDeleteResult,
AgentsFilesListParams,
AgentsFilesListResult,
AgentsFilesGetParams,

View File

@@ -44,6 +44,62 @@ export const AgentsListResultSchema = Type.Object(
{ additionalProperties: false },
);
export const AgentsCreateParamsSchema = Type.Object(
{
name: NonEmptyString,
workspace: NonEmptyString,
emoji: Type.Optional(Type.String()),
avatar: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const AgentsCreateResultSchema = Type.Object(
{
ok: Type.Literal(true),
agentId: NonEmptyString,
name: NonEmptyString,
workspace: NonEmptyString,
},
{ additionalProperties: false },
);
export const AgentsUpdateParamsSchema = Type.Object(
{
agentId: NonEmptyString,
name: Type.Optional(NonEmptyString),
workspace: Type.Optional(NonEmptyString),
model: Type.Optional(NonEmptyString),
avatar: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const AgentsUpdateResultSchema = Type.Object(
{
ok: Type.Literal(true),
agentId: NonEmptyString,
},
{ additionalProperties: false },
);
export const AgentsDeleteParamsSchema = Type.Object(
{
agentId: NonEmptyString,
deleteFiles: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const AgentsDeleteResultSchema = Type.Object(
{
ok: Type.Literal(true),
agentId: NonEmptyString,
removedBindings: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
export const AgentsFileEntrySchema = Type.Object(
{
name: NonEmptyString,

View File

@@ -11,6 +11,10 @@ import {
} from "./agent.js";
import {
AgentSummarySchema,
AgentsCreateParamsSchema,
AgentsCreateResultSchema,
AgentsDeleteParamsSchema,
AgentsDeleteResultSchema,
AgentsFileEntrySchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
@@ -20,6 +24,8 @@ import {
AgentsFilesSetResultSchema,
AgentsListParamsSchema,
AgentsListResultSchema,
AgentsUpdateParamsSchema,
AgentsUpdateResultSchema,
ModelChoiceSchema,
ModelsListParamsSchema,
ModelsListResultSchema,
@@ -191,6 +197,12 @@ export const ProtocolSchemas: Record<string, TSchema> = {
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
AgentSummary: AgentSummarySchema,
AgentsCreateParams: AgentsCreateParamsSchema,
AgentsCreateResult: AgentsCreateResultSchema,
AgentsUpdateParams: AgentsUpdateParamsSchema,
AgentsUpdateResult: AgentsUpdateResultSchema,
AgentsDeleteParams: AgentsDeleteParamsSchema,
AgentsDeleteResult: AgentsDeleteResultSchema,
AgentsFileEntry: AgentsFileEntrySchema,
AgentsFilesListParams: AgentsFilesListParamsSchema,
AgentsFilesListResult: AgentsFilesListResultSchema,

View File

@@ -10,6 +10,10 @@ import type {
import type {
AgentSummarySchema,
AgentsFileEntrySchema,
AgentsCreateParamsSchema,
AgentsCreateResultSchema,
AgentsDeleteParamsSchema,
AgentsDeleteResultSchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
AgentsFilesListParamsSchema,
@@ -18,6 +22,8 @@ import type {
AgentsFilesSetResultSchema,
AgentsListParamsSchema,
AgentsListResultSchema,
AgentsUpdateParamsSchema,
AgentsUpdateResultSchema,
ModelChoiceSchema,
ModelsListParamsSchema,
ModelsListResultSchema,
@@ -181,6 +187,12 @@ export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type AgentSummary = Static<typeof AgentSummarySchema>;
export type AgentsFileEntry = Static<typeof AgentsFileEntrySchema>;
export type AgentsCreateParams = Static<typeof AgentsCreateParamsSchema>;
export type AgentsCreateResult = Static<typeof AgentsCreateResultSchema>;
export type AgentsUpdateParams = Static<typeof AgentsUpdateParamsSchema>;
export type AgentsUpdateResult = Static<typeof AgentsUpdateResultSchema>;
export type AgentsDeleteParams = Static<typeof AgentsDeleteParamsSchema>;
export type AgentsDeleteResult = Static<typeof AgentsDeleteResultSchema>;
export type AgentsFilesListParams = Static<typeof AgentsFilesListParamsSchema>;
export type AgentsFilesListResult = Static<typeof AgentsFilesListResultSchema>;
export type AgentsFilesGetParams = Static<typeof AgentsFilesGetParamsSchema>;

View File

@@ -32,6 +32,9 @@ const BASE_METHODS = [
"talk.mode",
"models.list",
"agents.list",
"agents.create",
"agents.update",
"agents.delete",
"agents.files.list",
"agents.files.get",
"agents.files.set",

View File

@@ -143,6 +143,9 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
method.startsWith("wizard.") ||
method.startsWith("update.") ||
method === "channels.logout" ||
method === "agents.create" ||
method === "agents.update" ||
method === "agents.delete" ||
method === "skills.install" ||
method === "skills.update" ||
method === "cron.add" ||

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(

View File

@@ -126,7 +126,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const fetchImpl = resolveTelegramFetch(opts.proxyFetch, {
network: telegramCfg.network,
});
}) as unknown as ApiClientOptions["fetch"];
const shouldProvideFetch = Boolean(fetchImpl);
// grammY's ApiClientOptions types still track `node-fetch` types; Node 22+ global fetch
// (undici) is structurally compatible at runtime but not assignable in TS.