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 ### 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: 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: 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. - 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 struct AgentsFileEntry: Codable, Sendable {
public let name: String public let name: String
public let path: 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 struct AgentsFileEntry: Codable, Sendable {
public let name: String public let name: String
public let path: String public let path: String

View File

@@ -32,7 +32,7 @@ const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
return await lancedbImportPromise; return await lancedbImportPromise;
} catch (err) { } catch (err) {
// Common on macOS today: upstream package may not ship darwin native bindings. // 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, AgentSummarySchema,
type AgentsFileEntry, type AgentsFileEntry,
AgentsFileEntrySchema, AgentsFileEntrySchema,
type AgentsCreateParams,
AgentsCreateParamsSchema,
type AgentsCreateResult,
AgentsCreateResultSchema,
type AgentsUpdateParams,
AgentsUpdateParamsSchema,
type AgentsUpdateResult,
AgentsUpdateResultSchema,
type AgentsDeleteParams,
AgentsDeleteParamsSchema,
type AgentsDeleteResult,
AgentsDeleteResultSchema,
type AgentsFilesGetParams, type AgentsFilesGetParams,
AgentsFilesGetParamsSchema, AgentsFilesGetParamsSchema,
type AgentsFilesGetResult, type AgentsFilesGetResult,
@@ -226,6 +238,9 @@ export const validateAgentIdentityParams =
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema); export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema); export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema); 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>( export const validateAgentsFilesListParams = ajv.compile<AgentsFilesListParams>(
AgentsFilesListParamsSchema, AgentsFilesListParamsSchema,
); );
@@ -438,6 +453,12 @@ export {
WebLoginWaitParamsSchema, WebLoginWaitParamsSchema,
AgentSummarySchema, AgentSummarySchema,
AgentsFileEntrySchema, AgentsFileEntrySchema,
AgentsCreateParamsSchema,
AgentsCreateResultSchema,
AgentsUpdateParamsSchema,
AgentsUpdateResultSchema,
AgentsDeleteParamsSchema,
AgentsDeleteResultSchema,
AgentsFilesListParamsSchema, AgentsFilesListParamsSchema,
AgentsFilesListResultSchema, AgentsFilesListResultSchema,
AgentsFilesGetParamsSchema, AgentsFilesGetParamsSchema,
@@ -519,6 +540,12 @@ export type {
WebLoginWaitParams, WebLoginWaitParams,
AgentSummary, AgentSummary,
AgentsFileEntry, AgentsFileEntry,
AgentsCreateParams,
AgentsCreateResult,
AgentsUpdateParams,
AgentsUpdateResult,
AgentsDeleteParams,
AgentsDeleteResult,
AgentsFilesListParams, AgentsFilesListParams,
AgentsFilesListResult, AgentsFilesListResult,
AgentsFilesGetParams, AgentsFilesGetParams,

View File

@@ -44,6 +44,62 @@ export const AgentsListResultSchema = Type.Object(
{ additionalProperties: false }, { 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( export const AgentsFileEntrySchema = Type.Object(
{ {
name: NonEmptyString, name: NonEmptyString,

View File

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

View File

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

View File

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

View File

@@ -143,6 +143,9 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
method.startsWith("wizard.") || method.startsWith("wizard.") ||
method.startsWith("update.") || method.startsWith("update.") ||
method === "channels.logout" || method === "channels.logout" ||
method === "agents.create" ||
method === "agents.update" ||
method === "agents.delete" ||
method === "skills.install" || method === "skills.install" ||
method === "skills.update" || method === "skills.update" ||
method === "cron.add" || 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 fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import {
listAgentIds,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../../agents/agent-scope.js";
import { import {
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_BOOTSTRAP_FILENAME,
@@ -12,17 +16,30 @@ import {
DEFAULT_SOUL_FILENAME, DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME, DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
} from "../../agents/workspace.js"; } from "../../agents/workspace.js";
import { loadConfig } from "../../config/config.js"; import { movePathToTrash } from "../../browser/trash.js";
import { normalizeAgentId } from "../../routing/session-key.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 { import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
validateAgentsCreateParams,
validateAgentsDeleteParams,
validateAgentsFilesGetParams, validateAgentsFilesGetParams,
validateAgentsFilesListParams, validateAgentsFilesListParams,
validateAgentsFilesSetParams, validateAgentsFilesSetParams,
validateAgentsListParams, validateAgentsListParams,
validateAgentsUpdateParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import { listAgentsForGateway } from "../session-utils.js"; import { listAgentsForGateway } from "../session-utils.js";
@@ -123,6 +140,30 @@ function resolveAgentIdOrError(agentIdRaw: string, cfg: ReturnType<typeof loadCo
return agentId; 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 = { export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => { "agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) { if (!validateAgentsListParams(params)) {
@@ -141,6 +182,189 @@ export const agentsHandlers: GatewayRequestHandlers = {
const result = listAgentsForGateway(cfg); const result = listAgentsForGateway(cfg);
respond(true, result, undefined); 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 }) => { "agents.files.list": async ({ params, respond }) => {
if (!validateAgentsFilesListParams(params)) { if (!validateAgentsFilesListParams(params)) {
respond( respond(

View File

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