diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e71dbc2..59260e83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 07c9db84e..9e8844226 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 07c9db84e..9e8844226 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index efbc8a8fb..ce26e33e9 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -32,7 +32,7 @@ const loadLanceDB = async (): Promise => { 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 }); } }; diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index f89facc23..0df7c9a76 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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(AgentWaitParamsSchema); export const validateWakeParams = ajv.compile(WakeParamsSchema); export const validateAgentsListParams = ajv.compile(AgentsListParamsSchema); +export const validateAgentsCreateParams = ajv.compile(AgentsCreateParamsSchema); +export const validateAgentsUpdateParams = ajv.compile(AgentsUpdateParamsSchema); +export const validateAgentsDeleteParams = ajv.compile(AgentsDeleteParamsSchema); export const validateAgentsFilesListParams = ajv.compile( 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, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index d1230d8d4..aaa886dd5 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 23918ef6d..6e0e672b5 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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 = { WebLoginStartParams: WebLoginStartParamsSchema, WebLoginWaitParams: WebLoginWaitParamsSchema, AgentSummary: AgentSummarySchema, + AgentsCreateParams: AgentsCreateParamsSchema, + AgentsCreateResult: AgentsCreateResultSchema, + AgentsUpdateParams: AgentsUpdateParamsSchema, + AgentsUpdateResult: AgentsUpdateResultSchema, + AgentsDeleteParams: AgentsDeleteParamsSchema, + AgentsDeleteResult: AgentsDeleteResultSchema, AgentsFileEntry: AgentsFileEntrySchema, AgentsFilesListParams: AgentsFilesListParamsSchema, AgentsFilesListResult: AgentsFilesListResultSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index f89b3d956..1f2b2d662 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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; export type WebLoginWaitParams = Static; export type AgentSummary = Static; export type AgentsFileEntry = Static; +export type AgentsCreateParams = Static; +export type AgentsCreateResult = Static; +export type AgentsUpdateParams = Static; +export type AgentsUpdateResult = Static; +export type AgentsDeleteParams = Static; +export type AgentsDeleteResult = Static; export type AgentsFilesListParams = Static; export type AgentsFilesListResult = Static; export type AgentsFilesGetParams = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 4f997c2df..1ff570b05 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -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", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index f76a637fa..1d8437f73 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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" || diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts new file mode 100644 index 000000000..bd5cc5cc3 --- /dev/null +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +/* ------------------------------------------------------------------ */ +/* Mocks */ +/* ------------------------------------------------------------------ */ + +const mocks = vi.hoisted(() => ({ + loadConfigReturn: {} as Record, + 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( + "../../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("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) { + 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") }), + ); + }); +}); diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 1254eac6e..d0f3589d3 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -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 { + 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( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index cdb811265..a34cb45ac 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -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.