Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5086,60 +5086,68 @@ public struct AgentSummary: Codable, Sendable {
}

public struct AgentsCreateParams: Codable, Sendable {
public let name: String
public let agentid: String
public let displayname: String
public let workspace: String
public let model: String?
public let emoji: String?
public let avatar: String?
public let config: [String: AnyCodable]?

public init(
name: String,
agentid: String,
displayname: String,
workspace: String,
model: String?,
emoji: String?,
avatar: String?)
avatar: String?,
config: [String: AnyCodable]?)
{
self.name = name
self.agentid = agentid
self.displayname = displayname
self.workspace = workspace
self.model = model
self.emoji = emoji
self.avatar = avatar
self.config = config
}

private enum CodingKeys: String, CodingKey {
case name
case agentid = "agentId"
case displayname = "displayName"
case workspace
case model
case emoji
case avatar
case config
}
}

public struct AgentsCreateResult: Codable, Sendable {
public let ok: Bool
public let agentid: String
public let name: String
public let displayname: String
public let workspace: String
public let model: String?

public init(
ok: Bool,
agentid: String,
name: String,
displayname: String,
workspace: String,
model: String?)
{
self.ok = ok
self.agentid = agentid
self.name = name
self.displayname = displayname
self.workspace = workspace
self.model = model
}

private enum CodingKeys: String, CodingKey {
case ok
case agentid = "agentId"
case name
case displayname = "displayName"
case workspace
case model
}
Expand Down
6 changes: 4 additions & 2 deletions packages/gateway-protocol/src/schema/agents-models-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,13 @@ export const AgentsListResultSchema = Type.Object(
/** Creates a configured agent with workspace, identity, and optional model. */
export const AgentsCreateParamsSchema = Type.Object(
{
name: NonEmptyString,
agentId: NonEmptyString,
displayName: NonEmptyString,
workspace: NonEmptyString,
model: Type.Optional(NonEmptyString),
emoji: Type.Optional(Type.String()),
avatar: Type.Optional(Type.String()),
config: Type.Optional(Type.Object({}, { additionalProperties: true })),
},
{ additionalProperties: false },
);
Expand All @@ -118,7 +120,7 @@ export const AgentsCreateResultSchema = Type.Object(
{
ok: Type.Literal(true),
agentId: NonEmptyString,
name: NonEmptyString,
displayName: NonEmptyString,
workspace: NonEmptyString,
model: Type.Optional(NonEmptyString),
},
Expand Down
28 changes: 28 additions & 0 deletions src/commands/agents.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import type { AgentIdentityFile } from "../agents/identity-file.js";
import { identityHasValues, loadAgentIdentityFromWorkspace } from "../agents/identity-file.js";
import { listRouteBindings } from "../config/bindings.js";
import type { AgentConfig } from "../config/types.agents.js";
import type { IdentityConfig } from "../config/types.base.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
Expand Down Expand Up @@ -154,6 +155,33 @@ export function applyAgentConfig(
};
}

/**
* Merges arbitrary AgentConfig overrides into an existing agent entry.
* Protected fields (id, name, workspace, agentDir) are stripped from overrides
* so explicit create/update params always win.
*/
export function mergeAgentConfigOverrides(
cfg: OpenClawConfig,
agentId: string,
overrides: Partial<Omit<AgentConfig, "id" | "name" | "workspace" | "agentDir">>,
): OpenClawConfig {
const id = normalizeAgentId(agentId);
const list = listAgentEntries(cfg);
const index = list.findIndex((e) => normalizeAgentId(e.id) === id);
if (index < 0) {
return cfg;
}
const nextList = [...list];
nextList[index] = { ...list[index], ...overrides };
return {
...cfg,
agents: {
...cfg.agents,
list: nextList,
},
};
}

/** Remove an agent and any config references that route or allow traffic to it. */
export function pruneAgentConfig(
cfg: OpenClawConfig,
Expand Down
10 changes: 9 additions & 1 deletion src/gateway/server-methods/agents-config-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
applyAgentConfig,
findAgentEntryIndex,
listAgentEntries,
mergeAgentConfigOverrides,
pruneAgentConfig,
} from "../../commands/agents.config.js";
import { mutateConfigFileWithRetry } from "../../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions.js";
import type { AgentConfig } from "../../config/types.agents.js";
import type { IdentityConfig } from "../../config/types.base.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";

Expand Down Expand Up @@ -47,6 +49,7 @@ export async function createAgentConfigEntry(params: {
model?: string;
identity?: IdentityConfig;
agentDir: string;
configOverrides?: Partial<Omit<AgentConfig, "id" | "name" | "workspace" | "agentDir">>;
}): Promise<void> {
await mutateConfigFileWithRetry({
afterWrite: { mode: "auto" },
Expand All @@ -62,7 +65,12 @@ export async function createAgentConfigEntry(params: {
identity: params.identity,
agentDir: params.agentDir,
});
Object.assign(draft, latestNextConfig);
Object.assign(
draft,
params.configOverrides
? mergeAgentConfigOverrides(latestNextConfig, params.agentId, params.configOverrides)
: latestNextConfig,
);
},
});
}
Expand Down
99 changes: 85 additions & 14 deletions src/gateway/server-methods/agents-mutate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
listAgentEntries: vi.fn((_cfg?: unknown) => [] as Array<Record<string, unknown>>),
findAgentEntryIndex: vi.fn((_list?: unknown, _agentId?: string) => -1),
applyAgentConfig: vi.fn((_cfg: unknown, _opts: unknown) => ({})),
mergeAgentConfigOverrides: vi.fn((cfg: unknown, _agentId: unknown, _overrides: unknown) => cfg),
pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })),
writeConfigFile: vi.fn(async (_nextConfig?: unknown) => {}),
ensureAgentWorkspace: vi.fn(
Expand Down Expand Up @@ -101,8 +102,10 @@ vi.mock("../../config/config.js", async () => {

vi.mock("../../commands/agents.config.js", () => ({
applyAgentConfig: mocks.applyAgentConfig,
mergeAgentConfigOverrides: mocks.mergeAgentConfigOverrides,
findAgentEntryIndex: mocks.findAgentEntryIndex,
listAgentEntries: mocks.listAgentEntries,
mergeAgentConfigOverrides: mocks.mergeAgentConfigOverrides,
pruneAgentConfig: mocks.pruneAgentConfig,
}));

Expand Down Expand Up @@ -522,15 +525,16 @@ describe("agents.create", () => {

it("creates a new agent successfully", async () => {
const { respond, promise } = makeCall("agents.create", {
name: "Test Agent",
agentId: "test-agent",
displayName: "Test Agent",
workspace: "/home/user/agents/test",
});
await promise;

expectRespondOk(respond, {
ok: true,
agentId: "test-agent",
name: "Test Agent",
displayName: "Test Agent",
});
expect(mocks.ensureAgentWorkspace).toHaveBeenCalled();
expect(mocks.writeConfigFile).toHaveBeenCalled();
Expand All @@ -547,7 +551,8 @@ describe("agents.create", () => {
});

const { promise } = makeCall("agents.create", {
name: "Order Test",
agentId: "order-test",
displayName: "Order Test",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -559,7 +564,8 @@ describe("agents.create", () => {

it("rejects creating an agent with reserved 'main' id", async () => {
const { respond, promise } = makeCall("agents.create", {
name: "main",
agentId: "main",
displayName: "Main Agent",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -571,7 +577,8 @@ describe("agents.create", () => {
mocks.findAgentEntryIndex.mockReturnValue(0);

const { respond, promise } = makeCall("agents.create", {
name: "Existing",
agentId: "existing",
displayName: "Existing",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -588,7 +595,8 @@ describe("agents.create", () => {
});

const { respond, promise } = makeCall("agents.create", {
name: "Race Agent",
agentId: "race-agent",
displayName: "Race Agent",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -597,7 +605,7 @@ describe("agents.create", () => {
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});

it("rejects invalid params (missing name)", async () => {
it("rejects invalid params (missing id)", async () => {
const { respond, promise } = makeCall("agents.create", {
workspace: "/tmp/ws",
});
Expand All @@ -608,7 +616,8 @@ describe("agents.create", () => {

it("writes identity to both config and IDENTITY.md", async () => {
const { promise } = makeCall("agents.create", {
name: "Plain Agent",
agentId: "plain-agent",
displayName: "Plain Agent",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -624,7 +633,8 @@ describe("agents.create", () => {

it("writes emoji and avatar to both config and IDENTITY.md", async () => {
const { promise } = makeCall("agents.create", {
name: "Fancy Agent",
agentId: "fancy-agent",
displayName: "Fancy Agent",
workspace: "/tmp/ws",
emoji: "🤖",
avatar: "https://example.com/avatar.png",
Expand Down Expand Up @@ -659,7 +669,8 @@ describe("agents.create", () => {
);

const { respond, promise } = makeCall("agents.create", {
name: "Unsafe Agent",
agentId: "unsafe-agent",
displayName: "Unsafe Agent",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -682,7 +693,8 @@ describe("agents.create", () => {
});

const { promise } = makeCall("agents.create", {
name: "Unreadable Identity",
agentId: "unreadable-identity",
displayName: "Unreadable Identity",
workspace: "/tmp/ws",
});

Expand All @@ -701,7 +713,8 @@ describe("agents.create", () => {
});

const { respond, promise } = makeCall("agents.create", {
name: "Unsafe Identity Read",
agentId: "unsafe-identity-read",
displayName: "Unsafe Identity Read",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -718,7 +731,8 @@ describe("agents.create", () => {
agentsTesting.setDepsForTests({ root: makeRootForTest({ read: rootRead }) });

const { promise } = makeCall("agents.create", {
name: "NB Agent",
agentId: "nb-agent",
displayName: "NB Agent",
workspace: "/tmp/ws",
});
await promise;
Expand All @@ -731,7 +745,8 @@ describe("agents.create", () => {

it("passes model to applyAgentConfig when provided", async () => {
const { respond, promise } = makeCall("agents.create", {
name: "Model Agent",
agentId: "model-agent",
displayName: "Model Agent",
workspace: "/tmp/ws",
model: "sonnet-4.6",
});
Expand All @@ -740,6 +755,62 @@ describe("agents.create", () => {
expectRespondOk(respond, { ok: true, model: "sonnet-4.6" });
expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), { model: "sonnet-4.6" });
});

it("merges config overrides into agent entry when config is provided", async () => {
const sandboxConfig = {
browser: { enabled: false, allowHostControl: true },
mode: "non-main",
backend: "ssh",
scope: "agent",
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
ssh: {
target: "user@sandbox-host.example.com:22",
strictHostKeyChecking: true,
updateHostKeys: true,
identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" },
knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" },
},
sessionToolsVisibility: "all",
prune: { idleHours: 4, maxAgeDays: 3 },
};

const { promise } = makeCall("agents.create", {
agentId: "ssh-agent",
displayName: "SSH Agent",
workspace: "/tmp/ws",
config: { sandbox: sandboxConfig },
});
await promise;

expect(mocks.mergeAgentConfigOverrides).toHaveBeenCalledWith(
expect.anything(),
"ssh-agent",
expect.objectContaining({ sandbox: sandboxConfig }),
);
// Protected fields must not be forwarded to the merge helper
expect(mocks.mergeAgentConfigOverrides).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.not.objectContaining({ id: expect.anything() }),
);
expect(mocks.mergeAgentConfigOverrides).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.not.objectContaining({ workspace: expect.anything() }),
);
});

it("skips mergeAgentConfigOverrides when no config is provided", async () => {
const { promise } = makeCall("agents.create", {
agentId: "plain-no-config",
displayName: "Plain",
workspace: "/tmp/ws",
});
await promise;

expect(mocks.mergeAgentConfigOverrides).not.toHaveBeenCalled();
});
});

describe("agents.update", () => {
Expand Down
Loading