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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
373
src/gateway/server-methods/agents-mutate.test.ts
Normal file
373
src/gateway/server-methods/agents-mutate.test.ts
Normal 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") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user