feat(ui): add Agents dashboard

This commit is contained in:
Gustavo Madeira Santana
2026-02-02 21:31:17 -05:00
parent c8af8e9555
commit 2a68bcbeb3
32 changed files with 3652 additions and 21 deletions

View File

@@ -19,6 +19,7 @@ type ResolvedAgentConfig = {
workspace?: string;
agentDir?: string;
model?: AgentEntry["model"];
skills?: AgentEntry["skills"];
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
heartbeat?: AgentEntry["heartbeat"];
@@ -112,6 +113,7 @@ export function resolveAgentConfig(
typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
? entry.model
: undefined,
skills: Array.isArray(entry.skills) ? entry.skills : undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
heartbeat: entry.heartbeat,
@@ -123,6 +125,18 @@ export function resolveAgentConfig(
};
}
export function resolveAgentSkillsFilter(
cfg: OpenClawConfig,
agentId: string,
): string[] | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.skills;
if (!raw) {
return undefined;
}
const normalized = raw.map((entry) => String(entry).trim()).filter(Boolean);
return normalized.length > 0 ? normalized : [];
}
export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) {

View File

@@ -4,6 +4,7 @@ import {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveSessionAgentId,
resolveAgentSkillsFilter,
} from "../../agents/agent-scope.js";
import { resolveModelRefFromString } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
@@ -24,6 +25,31 @@ import { initSessionState } from "./session.js";
import { stageSandboxMedia } from "./stage-sandbox-media.js";
import { createTypingController } from "./typing.js";
function mergeSkillFilters(channelFilter?: string[], agentFilter?: string[]): string[] | undefined {
const normalize = (list?: string[]) => {
if (!Array.isArray(list)) {
return undefined;
}
return list.map((entry) => String(entry).trim()).filter(Boolean);
};
const channel = normalize(channelFilter);
const agent = normalize(agentFilter);
if (!channel && !agent) {
return undefined;
}
if (!channel) {
return agent;
}
if (!agent) {
return channel;
}
if (channel.length === 0 || agent.length === 0) {
return [];
}
const agentSet = new Set(agent);
return channel.filter((name) => agentSet.has(name));
}
export async function getReplyFromConfig(
ctx: MsgContext,
opts?: GetReplyOptions,
@@ -38,6 +64,12 @@ export async function getReplyFromConfig(
sessionKey: agentSessionKey,
config: cfg,
});
const mergedSkillFilter = mergeSkillFilters(
opts?.skillFilter,
resolveAgentSkillsFilter(cfg, agentId),
);
const resolvedOpts =
mergedSkillFilter !== undefined ? { ...opts, skillFilter: mergedSkillFilter } : opts;
const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
@@ -164,8 +196,8 @@ export async function getReplyFromConfig(
provider,
model,
typing,
opts,
skillFilter: opts?.skillFilter,
opts: resolvedOpts,
skillFilter: mergedSkillFilter,
});
if (directiveResult.kind === "reply") {
return directiveResult.reply;
@@ -216,7 +248,7 @@ export async function getReplyFromConfig(
sessionScope,
workspaceDir,
isGroup,
opts,
opts: resolvedOpts,
typing,
allowTextCommands,
inlineStatusRequested,
@@ -238,7 +270,7 @@ export async function getReplyFromConfig(
contextTokens,
directiveAck,
abortedLastRun,
skillFilter: opts?.skillFilter,
skillFilter: mergedSkillFilter,
});
if (inlineActionResult.kind === "reply") {
return inlineActionResult.reply;
@@ -284,7 +316,7 @@ export async function getReplyFromConfig(
perMessageQueueMode,
perMessageQueueOptions,
typing,
opts,
opts: resolvedOpts,
defaultProvider,
defaultModel,
timeoutMs,

View File

@@ -4,6 +4,7 @@ import {
resolveAgentDir,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
resolveAgentSkillsFilter,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
@@ -187,11 +188,13 @@ export async function agentCommand(
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId);
const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
snapshotVersion: skillsSnapshotVersion,
skillFilter,
})
: sessionEntry?.skillsSnapshot;

View File

@@ -124,6 +124,7 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"agents.list.*.identity.avatar": "Identity Avatar",
"agents.list.*.skills": "Agent Skill Filter",
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
@@ -346,6 +347,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.mattermost.requireMention": "Mattermost Require Mention",
"channels.signal.account": "Signal Account",
"channels.imessage.cliPath": "iMessage CLI Path",
"agents.list[].skills": "Agent Skill Filter",
"agents.list[].identity.avatar": "Agent Avatar",
"discovery.mdns.mode": "mDNS Discovery Mode",
"plugins.enabled": "Enable Plugins",
@@ -377,6 +379,10 @@ const FIELD_HELP: Record<string, string> = {
"gateway.remote.sshTarget":
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
"agents.list.*.skills":
"Optional allowlist of skills for this agent (omit = all skills; empty = no skills).",
"agents.list[].skills":
"Optional allowlist of skills for this agent (omit = all skills; empty = no skills).",
"agents.list[].identity.avatar":
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
"discovery.mdns.mode":

View File

@@ -24,6 +24,8 @@ export type AgentConfig = {
workspace?: string;
agentDir?: string;
model?: AgentModelConfig;
/** Optional allowlist of skills for this agent (omit = all skills; empty = none). */
skills?: string[];
memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig;

View File

@@ -427,6 +427,7 @@ export const AgentEntrySchema = z
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: AgentModelSchema.optional(),
skills: z.array(z.string()).optional(),
memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(),
heartbeat: HeartbeatSchema,

View File

@@ -6,6 +6,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200;
const MAX_ASSISTANT_EMOJI = 16;
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {
agentId: "main",
@@ -17,6 +18,7 @@ export type AssistantIdentity = {
agentId: string;
name: string;
avatar: string;
emoji?: string;
};
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
@@ -64,6 +66,33 @@ function normalizeAvatarValue(value: string | undefined): string | undefined {
return undefined;
}
function normalizeEmojiValue(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.length > MAX_ASSISTANT_EMOJI) {
return undefined;
}
let hasNonAscii = false;
for (let i = 0; i < trimmed.length; i += 1) {
if (trimmed.charCodeAt(i) > 127) {
hasNonAscii = true;
break;
}
}
if (!hasNonAscii) {
return undefined;
}
if (isAvatarUrl(trimmed) || looksLikeAvatarPath(trimmed)) {
return undefined;
}
return trimmed;
}
export function resolveAssistantIdentity(params: {
cfg: OpenClawConfig;
agentId?: string | null;
@@ -92,5 +121,13 @@ export function resolveAssistantIdentity(params: {
avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ??
DEFAULT_ASSISTANT_IDENTITY.avatar;
return { agentId, name, avatar };
const emojiCandidates = [
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_EMOJI),
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_EMOJI),
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_EMOJI),
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_EMOJI),
];
const emoji = emojiCandidates.map((candidate) => normalizeEmojiValue(candidate)).find(Boolean);
return { agentId, name, avatar, emoji };
}

View File

@@ -9,6 +9,20 @@ import {
AgentParamsSchema,
type AgentSummary,
AgentSummarySchema,
type AgentsFileEntry,
AgentsFileEntrySchema,
type AgentsFilesGetParams,
AgentsFilesGetParamsSchema,
type AgentsFilesGetResult,
AgentsFilesGetResultSchema,
type AgentsFilesListParams,
AgentsFilesListParamsSchema,
type AgentsFilesListResult,
AgentsFilesListResultSchema,
type AgentsFilesSetParams,
AgentsFilesSetParamsSchema,
type AgentsFilesSetResult,
AgentsFilesSetResultSchema,
type AgentsListParams,
AgentsListParamsSchema,
type AgentsListResult,
@@ -209,6 +223,15 @@ 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 validateAgentsFilesListParams = ajv.compile<AgentsFilesListParams>(
AgentsFilesListParamsSchema,
);
export const validateAgentsFilesGetParams = ajv.compile<AgentsFilesGetParams>(
AgentsFilesGetParamsSchema,
);
export const validateAgentsFilesSetParams = ajv.compile<AgentsFilesSetParams>(
AgentsFilesSetParamsSchema,
);
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
NodePairRequestParamsSchema,
);
@@ -408,6 +431,13 @@ export {
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
AgentSummarySchema,
AgentsFileEntrySchema,
AgentsFilesListParamsSchema,
AgentsFilesListResultSchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
AgentsFilesSetParamsSchema,
AgentsFilesSetResultSchema,
AgentsListParamsSchema,
AgentsListResultSchema,
ModelsListParamsSchema,
@@ -482,6 +512,13 @@ export type {
WebLoginStartParams,
WebLoginWaitParams,
AgentSummary,
AgentsFileEntry,
AgentsFilesListParams,
AgentsFilesListResult,
AgentsFilesGetParams,
AgentsFilesGetResult,
AgentsFilesSetParams,
AgentsFilesSetResult,
AgentsListParams,
AgentsListResult,
SkillsStatusParams,

View File

@@ -84,6 +84,7 @@ export const AgentIdentityResultSchema = Type.Object(
agentId: NonEmptyString,
name: Type.Optional(NonEmptyString),
avatar: Type.Optional(NonEmptyString),
emoji: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);

View File

@@ -44,6 +44,70 @@ export const AgentsListResultSchema = Type.Object(
{ additionalProperties: false },
);
export const AgentsFileEntrySchema = Type.Object(
{
name: NonEmptyString,
path: NonEmptyString,
missing: Type.Boolean(),
size: Type.Optional(Type.Integer({ minimum: 0 })),
updatedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
content: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const AgentsFilesListParamsSchema = Type.Object(
{
agentId: NonEmptyString,
},
{ additionalProperties: false },
);
export const AgentsFilesListResultSchema = Type.Object(
{
agentId: NonEmptyString,
workspace: NonEmptyString,
files: Type.Array(AgentsFileEntrySchema),
},
{ additionalProperties: false },
);
export const AgentsFilesGetParamsSchema = Type.Object(
{
agentId: NonEmptyString,
name: NonEmptyString,
},
{ additionalProperties: false },
);
export const AgentsFilesGetResultSchema = Type.Object(
{
agentId: NonEmptyString,
workspace: NonEmptyString,
file: AgentsFileEntrySchema,
},
{ additionalProperties: false },
);
export const AgentsFilesSetParamsSchema = Type.Object(
{
agentId: NonEmptyString,
name: NonEmptyString,
content: Type.String(),
},
{ additionalProperties: false },
);
export const AgentsFilesSetResultSchema = Type.Object(
{
ok: Type.Literal(true),
agentId: NonEmptyString,
workspace: NonEmptyString,
file: AgentsFileEntrySchema,
},
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object({}, { additionalProperties: false });
export const ModelsListResultSchema = Type.Object(
@@ -53,7 +117,12 @@ export const ModelsListResultSchema = Type.Object(
{ additionalProperties: false },
);
export const SkillsStatusParamsSchema = Type.Object({}, { additionalProperties: false });
export const SkillsStatusParamsSchema = Type.Object(
{
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const SkillsBinsParamsSchema = Type.Object({}, { additionalProperties: false });

View File

@@ -11,6 +11,13 @@ import {
} from "./agent.js";
import {
AgentSummarySchema,
AgentsFileEntrySchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
AgentsFilesListParamsSchema,
AgentsFilesListResultSchema,
AgentsFilesSetParamsSchema,
AgentsFilesSetResultSchema,
AgentsListParamsSchema,
AgentsListResultSchema,
ModelChoiceSchema,
@@ -182,6 +189,13 @@ export const ProtocolSchemas: Record<string, TSchema> = {
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
AgentSummary: AgentSummarySchema,
AgentsFileEntry: AgentsFileEntrySchema,
AgentsFilesListParams: AgentsFilesListParamsSchema,
AgentsFilesListResult: AgentsFilesListResultSchema,
AgentsFilesGetParams: AgentsFilesGetParamsSchema,
AgentsFilesGetResult: AgentsFilesGetResultSchema,
AgentsFilesSetParams: AgentsFilesSetParamsSchema,
AgentsFilesSetResult: AgentsFilesSetResultSchema,
AgentsListParams: AgentsListParamsSchema,
AgentsListResult: AgentsListResultSchema,
ModelChoice: ModelChoiceSchema,

View File

@@ -9,6 +9,13 @@ import type {
} from "./agent.js";
import type {
AgentSummarySchema,
AgentsFileEntrySchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
AgentsFilesListParamsSchema,
AgentsFilesListResultSchema,
AgentsFilesSetParamsSchema,
AgentsFilesSetResultSchema,
AgentsListParamsSchema,
AgentsListResultSchema,
ModelChoiceSchema,
@@ -171,6 +178,13 @@ export type ChannelsLogoutParams = Static<typeof ChannelsLogoutParamsSchema>;
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 AgentsFilesListParams = Static<typeof AgentsFilesListParamsSchema>;
export type AgentsFilesListResult = Static<typeof AgentsFilesListResultSchema>;
export type AgentsFilesGetParams = Static<typeof AgentsFilesGetParamsSchema>;
export type AgentsFilesGetResult = Static<typeof AgentsFilesGetResultSchema>;
export type AgentsFilesSetParams = Static<typeof AgentsFilesSetParamsSchema>;
export type AgentsFilesSetResult = Static<typeof AgentsFilesSetResultSchema>;
export type AgentsListParams = Static<typeof AgentsListParamsSchema>;
export type AgentsListResult = Static<typeof AgentsListResultSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;

View File

@@ -32,6 +32,9 @@ const BASE_METHODS = [
"talk.mode",
"models.list",
"agents.list",
"agents.files.list",
"agents.files.get",
"agents.files.set",
"skills.status",
"skills.bins",
"skills.install",

View File

@@ -1,13 +1,128 @@
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 {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
} from "../../agents/workspace.js";
import { loadConfig } from "../../config/config.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateAgentsFilesGetParams,
validateAgentsFilesListParams,
validateAgentsFilesSetParams,
validateAgentsListParams,
} from "../protocol/index.js";
import { listAgentsForGateway } from "../session-utils.js";
const BOOTSTRAP_FILE_NAMES = [
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
] as const;
const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const;
const ALLOWED_FILE_NAMES = new Set<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]);
type FileMeta = {
size: number;
updatedAtMs: number;
};
async function statFile(filePath: string): Promise<FileMeta | null> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return null;
}
return {
size: stat.size,
updatedAtMs: Math.floor(stat.mtimeMs),
};
} catch {
return null;
}
}
async function listAgentFiles(workspaceDir: string) {
const files: Array<{
name: string;
path: string;
missing: boolean;
size?: number;
updatedAtMs?: number;
}> = [];
for (const name of BOOTSTRAP_FILE_NAMES) {
const filePath = path.join(workspaceDir, name);
const meta = await statFile(filePath);
if (meta) {
files.push({
name,
path: filePath,
missing: false,
size: meta.size,
updatedAtMs: meta.updatedAtMs,
});
} else {
files.push({ name, path: filePath, missing: true });
}
}
const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME);
const primaryMeta = await statFile(primaryMemoryPath);
if (primaryMeta) {
files.push({
name: DEFAULT_MEMORY_FILENAME,
path: primaryMemoryPath,
missing: false,
size: primaryMeta.size,
updatedAtMs: primaryMeta.updatedAtMs,
});
} else {
const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME);
const altMeta = await statFile(altMemoryPath);
if (altMeta) {
files.push({
name: DEFAULT_MEMORY_ALT_FILENAME,
path: altMemoryPath,
missing: false,
size: altMeta.size,
updatedAtMs: altMeta.updatedAtMs,
});
} else {
files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true });
}
}
return files;
}
function resolveAgentIdOrError(agentIdRaw: string, cfg: ReturnType<typeof loadConfig>) {
const agentId = normalizeAgentId(agentIdRaw);
const allowed = new Set(listAgentIds(cfg));
if (!allowed.has(agentId)) {
return null;
}
return agentId;
}
export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) {
@@ -26,4 +141,143 @@ export const agentsHandlers: GatewayRequestHandlers = {
const result = listAgentsForGateway(cfg);
respond(true, result, undefined);
},
"agents.files.list": async ({ params, respond }) => {
if (!validateAgentsFilesListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.list params: ${formatValidationErrors(
validateAgentsFilesListParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg);
if (!agentId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
return;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const files = await listAgentFiles(workspaceDir);
respond(true, { agentId, workspace: workspaceDir, files }, undefined);
},
"agents.files.get": async ({ params, respond }) => {
if (!validateAgentsFilesGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.get params: ${formatValidationErrors(
validateAgentsFilesGetParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg);
if (!agentId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
return;
}
const name = String(params.name ?? "").trim();
if (!ALLOWED_FILE_NAMES.has(name)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`),
);
return;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const filePath = path.join(workspaceDir, name);
const meta = await statFile(filePath);
if (!meta) {
respond(
true,
{
agentId,
workspace: workspaceDir,
file: { name, path: filePath, missing: true },
},
undefined,
);
return;
}
const content = await fs.readFile(filePath, "utf-8");
respond(
true,
{
agentId,
workspace: workspaceDir,
file: {
name,
path: filePath,
missing: false,
size: meta.size,
updatedAtMs: meta.updatedAtMs,
content,
},
},
undefined,
);
},
"agents.files.set": async ({ params, respond }) => {
if (!validateAgentsFilesSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.set params: ${formatValidationErrors(
validateAgentsFilesSetParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg);
if (!agentId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
return;
}
const name = String(params.name ?? "").trim();
if (!ALLOWED_FILE_NAMES.has(name)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`),
);
return;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name);
const content = String(params.content ?? "");
await fs.writeFile(filePath, content, "utf-8");
const meta = await statFile(filePath);
respond(
true,
{
ok: true,
agentId,
workspace: workspaceDir,
file: {
name,
path: filePath,
missing: false,
size: meta?.size,
updatedAtMs: meta?.updatedAtMs,
content,
},
},
undefined,
);
},
};

View File

@@ -1,11 +1,16 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { GatewayRequestHandlers } from "./types.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
listAgentIds,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import {
ErrorCodes,
errorShape,
@@ -75,7 +80,20 @@ export const skillsHandlers: GatewayRequestHandlers = {
return;
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const agentIdRaw = typeof params?.agentId === "string" ? params.agentId.trim() : "";
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : resolveDefaultAgentId(cfg);
if (agentIdRaw) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentId)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${agentIdRaw}"`),
);
return;
}
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },