agent configuration

This commit is contained in:
Davit
2026-04-24 14:35:08 +04:00
parent ae3c82bb21
commit 3dfa8b88a5
30 changed files with 3517 additions and 172 deletions
+128
View File
@@ -98,9 +98,37 @@ export interface SessionSettingsPatchBody {
// ── OpenClaw config (openclaw.json) ──
export interface OpenclawContextLimits {
memoryGetMaxChars?: number;
memoryGetDefaultLines?: number;
toolResultMaxChars?: number;
postCompactionMaxChars?: number;
}
export interface OpenclawSkillsLimits {
maxSkillsPromptChars?: number;
}
export interface OpenclawSubagentsSection {
allowAgents?: string[];
model?: string | { primary?: string; fallbacks?: string[] };
thinking?: string;
requireAgentId?: boolean;
}
export interface OpenclawAgentEntry {
id?: string;
name?: string;
model?: string | { primary?: string } | null;
contextLimits?: OpenclawContextLimits;
skillsLimits?: OpenclawSkillsLimits;
skills?: string[];
subagents?: OpenclawSubagentsSection;
[key: string]: unknown;
}
export interface OpenclawModelEntry {
alias?: string;
[key: string]: unknown;
}
@@ -108,11 +136,111 @@ export interface OpenclawAgentsSection {
list?: OpenclawAgentEntry[];
defaults?: {
model?: { primary?: string } | null;
models?: Record<string, OpenclawModelEntry>;
contextLimits?: OpenclawContextLimits;
skillsLimits?: OpenclawSkillsLimits;
[key: string]: unknown;
};
[key: string]: unknown;
}
// ── Agent budgets (context/skills limits) ──
export type AgentBudgetKey =
| 'memoryGetMaxChars'
| 'memoryGetDefaultLines'
| 'toolResultMaxChars'
| 'postCompactionMaxChars'
| 'maxSkillsPromptChars';
export interface AgentBudgetField {
key: AgentBudgetKey;
label: string;
description: string;
min: number;
max: number;
override: number | null;
default: number | null;
effective: number | null;
}
export interface AgentBudgetResponse {
agentId: string;
known: boolean;
fields: AgentBudgetField[];
}
export type AgentBudgetPatch = Partial<Record<AgentBudgetKey, number | null>>;
// ── Agent model config ──
export interface AgentModelOption {
key: string;
alias: string | null;
}
export interface AgentModelConfigResponse {
agentId: string;
known: boolean;
override: string | null;
systemDefault: string | null;
effective: string | null;
available: AgentModelOption[];
}
export interface AgentModelConfigPatch {
model: string | null;
}
// ── Agent skills (per-agent allowlist) ──
export interface AgentSkillSummary {
name: string;
description: string;
emoji: string;
eligible: boolean;
blockedByAllowlist: boolean;
source: string;
bundled: boolean;
}
export interface AgentSkillsResponse {
agentId: string;
known: boolean;
override: string[] | null;
available: AgentSkillSummary[];
}
export interface AgentSkillsPatch {
skills: string[] | null;
}
// ── Agent subagents ──
export type AgentSubagentsThinking = 'minimal' | 'low' | 'medium' | 'high' | 'inherit' | string;
export interface AgentSubagentsConfig {
allowAgents: string[] | null;
model: string | null;
thinking: string | null;
requireAgentId: boolean | null;
}
export interface AgentSubagentsResponse {
agentId: string;
known: boolean;
config: AgentSubagentsConfig;
availableAgents: { id: string; name: string | null }[];
availableModels: AgentModelOption[];
}
export interface AgentSubagentsPatch {
allowAgents?: string[] | null;
model?: string | null;
thinking?: string | null;
requireAgentId?: boolean | null;
}
export interface OpenclawConfig {
agents?: OpenclawAgentsSection;
gateway?: { port?: number };
+125
View File
@@ -374,6 +374,123 @@ const putWorkspaceFile: RequestHandler = async (req, res, next) => {
}
};
const getBudget: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
return res.json(ocService.getAgentBudget(agent.openclawAgentId));
} catch (error) {
return next(error);
}
};
const updateBudget: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const result = ocService.setAgentBudget(agent.openclawAgentId, req.body || {});
if (!result.ok) {
return res.status(400).json(result);
}
return res.json(result);
} catch (error) {
return next(error);
}
};
const getModelConfig: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
return res.json(ocService.getAgentModelConfig(agent.openclawAgentId));
} catch (error) {
return next(error);
}
};
const updateModelConfig: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const body = (req.body ?? {}) as { model?: string | null };
const result = ocService.setAgentModel(agent.openclawAgentId, body.model ?? null);
if (!result.ok) return res.status(400).json(result);
return res.json(result);
} catch (error) {
return next(error);
}
};
const getSkillsConfig: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
return res.json(ocService.getAgentSkillsConfig(agent.openclawAgentId));
} catch (error) {
return next(error);
}
};
const updateSkillsConfig: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const body = (req.body ?? {}) as { skills?: string[] | null };
const skills = body.skills === undefined ? null : body.skills;
const result = ocService.setAgentSkills(agent.openclawAgentId, skills);
if (!result.ok) return res.status(400).json(result);
return res.json(result);
} catch (error) {
return next(error);
}
};
const getSubagentsConfig: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
return res.json(ocService.getAgentSubagentsConfig(agent.openclawAgentId));
} catch (error) {
return next(error);
}
};
const updateSubagentsConfig: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const result = ocService.setAgentSubagents(agent.openclawAgentId, req.body ?? {});
if (!result.ok) return res.status(400).json(result);
return res.json(result);
} catch (error) {
return next(error);
}
};
const serveWorkspaceUpload: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
@@ -403,4 +520,12 @@ export {
getSessionSettings,
patchSessionSettings,
serveWorkspaceUpload,
getBudget,
updateBudget,
getModelConfig,
updateModelConfig,
getSkillsConfig,
updateSkillsConfig,
getSubagentsConfig,
updateSubagentsConfig,
};
+513 -1
View File
@@ -333,6 +333,274 @@ paths:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
/agent/{id}/budget:
get:
tags:
- agent
security:
- bearerAuth: []
operationId: getAgentBudget
summary: Get per-agent character/token budgets
description: >
Returns the override, default, and effective value for every character
budget that OpenClaw exposes per agent (memory fetch size, tool result
caps, post-compaction cap, skills prompt cap, etc.).
parameters:
- $ref: '#/components/parameters/id'
responses:
200:
description: Budget configuration for this agent
content:
application/json:
schema:
$ref: '#/components/schemas/agentBudgetResponse'
404:
description: Agent not found
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
patch:
tags:
- agent
security:
- bearerAuth: []
operationId: updateAgentBudget
summary: Update per-agent character/token budgets
description: >
Patches one or more budget fields. Send a number to set an override,
or null to unset the override and fall back to the default.
parameters:
- $ref: '#/components/parameters/id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/agentBudgetPatch'
responses:
200:
description: Updated budget configuration
content:
application/json:
schema:
$ref: '#/components/schemas/agentBudgetMutationResponse'
400:
description: Budget update failed
content:
application/json:
schema:
$ref: '#/components/schemas/agentBudgetMutationResponse'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
/agent/{id}/model-config:
get:
tags:
- agent
security:
- bearerAuth: []
operationId: getAgentModelConfig
summary: Get per-agent model override
description: >
Returns the agent's model override (if any), the effective model,
the system default, and the list of configured models available
to pick from.
parameters:
- $ref: '#/components/parameters/id'
responses:
200:
description: Model configuration for this agent
content:
application/json:
schema:
$ref: '#/components/schemas/agentModelConfigResponse'
404:
description: Agent not found
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
patch:
tags:
- agent
security:
- bearerAuth: []
operationId: updateAgentModelConfig
summary: Set or clear the per-agent model override
parameters:
- $ref: '#/components/parameters/id'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
model:
type: string
nullable: true
description: Model key to pin for this agent. Send null to inherit default.
responses:
200:
description: Updated model configuration
content:
application/json:
schema:
$ref: '#/components/schemas/agentModelConfigMutationResponse'
400:
description: Model update failed
content:
application/json:
schema:
$ref: '#/components/schemas/agentModelConfigMutationResponse'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
/agent/{id}/skills:
get:
tags:
- agent
security:
- bearerAuth: []
operationId: getAgentSkills
summary: Get per-agent skill allowlist
description: >
Returns the skill allowlist configured for this agent. A null override
means the agent inherits `agents.defaults.skills`. An empty array means
the agent is restricted to no skills. Also returns the full catalog of
available skills so a UI can render a picker.
parameters:
- $ref: '#/components/parameters/id'
responses:
200:
description: Skill configuration for this agent
content:
application/json:
schema:
$ref: '#/components/schemas/agentSkillsResponse'
404:
description: Agent not found
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
patch:
tags:
- agent
security:
- bearerAuth: []
operationId: updateAgentSkills
summary: Set or clear the per-agent skill allowlist
description: >
Pass an array of skill names to pin an explicit allowlist (this
replaces, not merges with, the inherited defaults). Pass null to
unset the override.
parameters:
- $ref: '#/components/parameters/id'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
skills:
type: array
nullable: true
items:
type: string
description: Allowed skill names, or null to inherit defaults.
responses:
200:
description: Updated skill configuration
content:
application/json:
schema:
$ref: '#/components/schemas/agentSkillsMutationResponse'
400:
description: Skill update failed
content:
application/json:
schema:
$ref: '#/components/schemas/agentSkillsMutationResponse'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
/agent/{id}/subagents:
get:
tags:
- agent
security:
- bearerAuth: []
operationId: getAgentSubagentsConfig
summary: Get per-agent subagent settings
description: >
Returns the allow-list of child agents this agent may spawn, the
default model and thinking level for spawned subagents, and whether
an explicit `agent_id` is required on spawn calls.
parameters:
- $ref: '#/components/parameters/id'
responses:
200:
description: Subagent configuration for this agent
content:
application/json:
schema:
$ref: '#/components/schemas/agentSubagentsResponse'
404:
description: Agent not found
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
patch:
tags:
- agent
security:
- bearerAuth: []
operationId: updateAgentSubagentsConfig
summary: Update per-agent subagent settings
description: >
Patches any subset of `allowAgents`, `model`, `thinking`, or
`requireAgentId`. Send null for any field to unset its override and
fall back to system defaults.
parameters:
- $ref: '#/components/parameters/id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/agentSubagentsPatch'
responses:
200:
description: Updated subagent configuration
content:
application/json:
schema:
$ref: '#/components/schemas/agentSubagentsMutationResponse'
400:
description: Subagent update failed
content:
application/json:
schema:
$ref: '#/components/schemas/agentSubagentsMutationResponse'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
#
#
#
@@ -357,7 +625,17 @@ components:
required: true
schema:
type: string
enum: [AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, BOOTSTRAP.md, MEMORY.md]
enum:
[
AGENTS.md,
SOUL.md,
TOOLS.md,
IDENTITY.md,
USER.md,
HEARTBEAT.md,
BOOTSTRAP.md,
MEMORY.md,
]
##
schemas:
#
@@ -431,3 +709,237 @@ components:
syncedMessages:
type: integer
description: Number of newly imported messages
#
agentBudgetField:
type: object
description: One character/token budget that can be overridden per agent.
properties:
key:
type: string
enum:
- memoryGetMaxChars
- memoryGetDefaultLines
- toolResultMaxChars
- postCompactionMaxChars
- maxSkillsPromptChars
label:
type: string
description:
type: string
min:
type: integer
max:
type: integer
override:
type: integer
nullable: true
description: Value set on this agent (null if inheriting default).
default:
type: integer
nullable: true
description: Inherited default from agents.defaults.
effective:
type: integer
nullable: true
description: Override if set, otherwise default.
#
agentBudgetResponse:
type: object
properties:
agentId:
type: string
known:
type: boolean
description: Whether this agent is present in openclaw.json.
fields:
type: array
items:
$ref: '#/components/schemas/agentBudgetField'
#
agentBudgetPatch:
type: object
description: >
Any subset of known budget keys mapped to a number (override) or null
(unset override).
additionalProperties:
type: integer
nullable: true
#
agentBudgetMutationResponse:
type: object
properties:
ok:
type: boolean
error:
type: string
nullable: true
config:
$ref: '#/components/schemas/agentBudgetResponse'
#
agentModelOption:
type: object
properties:
key:
type: string
description: Model key (what gets written to config).
alias:
type: string
nullable: true
#
agentModelConfigResponse:
type: object
properties:
agentId:
type: string
known:
type: boolean
override:
type: string
nullable: true
systemDefault:
type: string
nullable: true
effective:
type: string
nullable: true
available:
type: array
items:
$ref: '#/components/schemas/agentModelOption'
#
agentModelConfigMutationResponse:
type: object
properties:
ok:
type: boolean
error:
type: string
nullable: true
config:
$ref: '#/components/schemas/agentModelConfigResponse'
#
agentSkillSummary:
type: object
properties:
name:
type: string
description:
type: string
emoji:
type: string
eligible:
type: boolean
blockedByAllowlist:
type: boolean
source:
type: string
bundled:
type: boolean
#
agentSkillsResponse:
type: object
properties:
agentId:
type: string
known:
type: boolean
override:
type: array
nullable: true
items:
type: string
description: Explicit allowlist, or null when inheriting defaults.
available:
type: array
items:
$ref: '#/components/schemas/agentSkillSummary'
#
agentSkillsMutationResponse:
type: object
properties:
ok:
type: boolean
error:
type: string
nullable: true
config:
$ref: '#/components/schemas/agentSkillsResponse'
#
agentSubagentsConfig:
type: object
properties:
allowAgents:
type: array
nullable: true
items:
type: string
description: Explicit child-agent allowlist, or null when unrestricted.
model:
type: string
nullable: true
thinking:
type: string
nullable: true
description: One of minimal, low, medium, high, or null to inherit.
requireAgentId:
type: boolean
nullable: true
#
agentRef:
type: object
properties:
id:
type: string
name:
type: string
nullable: true
#
agentSubagentsResponse:
type: object
properties:
agentId:
type: string
known:
type: boolean
config:
$ref: '#/components/schemas/agentSubagentsConfig'
availableAgents:
type: array
items:
$ref: '#/components/schemas/agentRef'
description: Configured agents (excluding self) that can appear in allowAgents.
availableModels:
type: array
items:
$ref: '#/components/schemas/agentModelOption'
#
agentSubagentsPatch:
type: object
description: Any subset of subagent fields. Pass null to unset an override.
properties:
allowAgents:
type: array
nullable: true
items:
type: string
model:
type: string
nullable: true
thinking:
type: string
nullable: true
enum: [minimal, low, medium, high, inherit]
requireAgentId:
type: boolean
nullable: true
#
agentSubagentsMutationResponse:
type: object
properties:
ok:
type: boolean
error:
type: string
nullable: true
config:
$ref: '#/components/schemas/agentSubagentsResponse'
+40 -62
View File
@@ -5,73 +5,51 @@ import auth from '../../middlewares/auth';
const router = Router();
router.route('/agent')
.get(
auth,
validate.sanitizeQuery,
controller.list,
)
.post(
auth,
validate.create,
controller.create,
);
router
.route('/agent')
.get(auth, validate.sanitizeQuery, controller.list)
.post(auth, validate.create, controller.create);
router.route('/agent/sync')
.post(
auth,
controller.sync,
);
router.route('/agent/sync').post(auth, controller.sync);
router.route('/agent/:id(\\d+)/workspace')
.get(
auth,
validate.id,
controller.workspaceMeta,
);
router.route('/agent/:id(\\d+)/workspace').get(auth, validate.id, controller.workspaceMeta);
router.route('/agent/:id(\\d+)/workspace/file/:filename')
.get(
auth,
validate.workspaceFilename,
controller.getWorkspaceFile,
)
.put(
auth,
validate.workspacePut,
controller.putWorkspaceFile,
);
router
.route('/agent/:id(\\d+)/workspace/file/:filename')
.get(auth, validate.workspaceFilename, controller.getWorkspaceFile)
.put(auth, validate.workspacePut, controller.putWorkspaceFile);
router.route('/agent/:id(\\d+)/workspace/uploads/:filename')
.get(
controller.serveWorkspaceUpload,
);
router.route('/agent/:id(\\d+)/workspace/uploads/:filename').get(controller.serveWorkspaceUpload);
router.route('/agent/:id(\\d+)/conversation/:conversationId(\\d+)/session-settings')
.get(
auth,
controller.getSessionSettings,
)
.patch(
auth,
controller.patchSessionSettings,
);
router
.route('/agent/:id(\\d+)/skills')
.get(auth, validate.id, controller.getSkillsConfig)
.patch(auth, validate.skillsPatch, controller.updateSkillsConfig);
router.route('/agent/:id(\\d+)')
.get(
auth,
validate.id,
controller.get,
)
.patch(
auth,
validate.update,
controller.update,
)
.delete(
auth,
validate.id,
controller.destroy,
);
router
.route('/agent/:id(\\d+)/subagents')
.get(auth, validate.id, controller.getSubagentsConfig)
.patch(auth, validate.subagentsPatch, controller.updateSubagentsConfig);
router
.route('/agent/:id(\\d+)/model-config')
.get(auth, validate.id, controller.getModelConfig)
.patch(auth, validate.modelPatch, controller.updateModelConfig);
router
.route('/agent/:id(\\d+)/budget')
.get(auth, validate.id, controller.getBudget)
.patch(auth, validate.budgetPatch, controller.updateBudget);
router
.route('/agent/:id(\\d+)/conversation/:conversationId(\\d+)/session-settings')
.get(auth, controller.getSessionSettings)
.patch(auth, controller.patchSessionSettings);
router
.route('/agent/:id(\\d+)')
.get(auth, validate.id, controller.get)
.patch(auth, validate.update, controller.update)
.delete(auth, validate.id, controller.destroy);
export default router;
+118 -13
View File
@@ -17,20 +17,27 @@ export const WORKSPACE_FILENAMES = [
export default {
sanitizeQuery: ((req, res, next) => {
req.query.page = req.query.page as number >= 0 ? req.query.page : 0;
req.query.limit = [5, 10, 20, 40, 60, 100].includes(+(req.query.limit as number)) ? req.query.limit : 40;
req.query.sortType = ['asc', 'desc'].includes(req.query.sortType as string) ? req.query.sortType : 'desc';
req.query.sortField = ['name', 'createdAt', 'updatedAt'].includes(req.query.sortField as string) ? req.query.sortField : 'createdAt';
req.query.page = (req.query.page as number) >= 0 ? req.query.page : 0;
req.query.limit = [5, 10, 20, 40, 60, 100].includes(+(req.query.limit as number))
? req.query.limit
: 40;
req.query.sortType = ['asc', 'desc'].includes(req.query.sortType as string)
? req.query.sortType
: 'desc';
req.query.sortField = ['name', 'createdAt', 'updatedAt'].includes(req.query.sortField as string)
? req.query.sortField
: 'createdAt';
return next();
}) as List,
id: validate([
param('id').isInt().withMessage('Incorrect request url'),
]),
id: validate([param('id').isInt().withMessage('Incorrect request url')]),
create: validate([
body('name').notEmpty().withMessage('Please enter the agent name')
.isLength({ min: 1, max: 100 }).withMessage('Agent name must contain between 1 and 100 characters')
body('name')
.notEmpty()
.withMessage('Please enter the agent name')
.isLength({ min: 1, max: 100 })
.withMessage('Agent name must contain between 1 and 100 characters')
.custom(async (name) => {
const agentRepo = AppDataSource.getRepository(Agent);
const existing = await agentRepo.findOneBy({ name });
@@ -40,18 +47,116 @@ export default {
update: validate([
param('id').isInt().withMessage('Incorrect request url'),
body('name').notEmpty().withMessage('Please enter the agent name')
.isLength({ min: 1, max: 100 }).withMessage('Agent name must contain between 1 and 100 characters'),
body('name')
.notEmpty()
.withMessage('Please enter the agent name')
.isLength({ min: 1, max: 100 })
.withMessage('Agent name must contain between 1 and 100 characters'),
]),
workspaceFilename: validate([
param('id').isInt().withMessage('Incorrect request url'),
param('filename').isIn([...WORKSPACE_FILENAMES]).withMessage('Invalid workspace file'),
param('filename')
.isIn([...WORKSPACE_FILENAMES])
.withMessage('Invalid workspace file'),
]),
workspacePut: validate([
param('id').isInt().withMessage('Incorrect request url'),
param('filename').isIn([...WORKSPACE_FILENAMES]).withMessage('Invalid workspace file'),
param('filename')
.isIn([...WORKSPACE_FILENAMES])
.withMessage('Invalid workspace file'),
body('content').isString().withMessage('content is required'),
]),
skillsPatch: validate([
param('id').isInt().withMessage('Incorrect request url'),
body('skills').custom((value) => {
if (value === null || value === undefined) return true;
if (!Array.isArray(value)) {
throw new Error('"skills" must be an array of strings or null.');
}
if (value.some((item) => typeof item !== 'string')) {
throw new Error('Every skill must be a string.');
}
return true;
}),
]),
subagentsPatch: validate([
param('id').isInt().withMessage('Incorrect request url'),
body().custom((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Body must be an object.');
}
const allowed = ['allowAgents', 'model', 'thinking', 'requireAgentId'];
const unknownKey = Object.keys(value).find((k) => !allowed.includes(k));
if (unknownKey) {
throw new Error(`Unknown field: ${unknownKey}`);
}
const v = value as Record<string, unknown>;
if ('allowAgents' in v && v.allowAgents !== null) {
if (!Array.isArray(v.allowAgents)) {
throw new Error('"allowAgents" must be an array or null.');
}
if (v.allowAgents.some((x) => typeof x !== 'string')) {
throw new Error('Every allowed agent id must be a string.');
}
}
if ('model' in v && v.model !== null && typeof v.model !== 'string') {
throw new Error('"model" must be a string or null.');
}
if ('thinking' in v && v.thinking !== null && typeof v.thinking !== 'string') {
throw new Error('"thinking" must be a string or null.');
}
if (
'requireAgentId' in v &&
v.requireAgentId !== null &&
typeof v.requireAgentId !== 'boolean'
) {
throw new Error('"requireAgentId" must be a boolean or null.');
}
return true;
}),
]),
modelPatch: validate([
param('id').isInt().withMessage('Incorrect request url'),
body('model').custom((value) => {
if (value === null) return true;
if (typeof value !== 'string') {
throw new Error('"model" must be a string or null.');
}
if (value.length > 200) {
throw new Error('"model" is too long.');
}
return true;
}),
]),
budgetPatch: validate([
param('id').isInt().withMessage('Incorrect request url'),
body().custom((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Body must be an object of budget fields.');
}
const allowedKeys = [
'memoryGetMaxChars',
'memoryGetDefaultLines',
'toolResultMaxChars',
'postCompactionMaxChars',
'maxSkillsPromptChars',
];
Object.entries(value).forEach(([key, entryVal]) => {
if (!allowedKeys.includes(key)) {
throw new Error(`Unknown budget field: ${key}`);
}
if (entryVal === null) return;
if (typeof entryVal !== 'number' || !Number.isInteger(entryVal) || entryVal < 0) {
throw new Error(`"${key}" must be a non-negative integer or null.`);
}
});
return true;
}),
]),
};
+110
View File
@@ -0,0 +1,110 @@
/* eslint-disable no-console */
import fs from 'fs';
import os from 'os';
import {
AgentModelConfigResponse,
AgentModelOption,
OpenclawAgentEntry,
OpenclawConfig,
} from '../../@types/openclaw';
import { ocExec } from '../openclawGateway';
import { openclawConfigPath } from './paths';
import { execErrText } from '../../utils/errors';
const CLI_OPTS = {
cwd: os.homedir(),
env: { ...process.env, NO_COLOR: '1' },
timeout: 15000,
};
function readConfig(): OpenclawConfig | null {
try {
return JSON.parse(fs.readFileSync(openclawConfigPath(), 'utf-8')) as OpenclawConfig;
} catch {
return null;
}
}
function findAgentIndex(config: OpenclawConfig | null, openclawAgentId: string): number {
const list = config?.agents?.list ?? [];
return list.findIndex((a) => a?.id === openclawAgentId);
}
function extractModel(entry: OpenclawAgentEntry | undefined): string | null {
if (!entry?.model) return null;
if (typeof entry.model === 'string') return entry.model;
if (entry.model && typeof entry.model === 'object') return entry.model.primary || null;
return null;
}
function listAvailableModels(config: OpenclawConfig | null): AgentModelOption[] {
const models = config?.agents?.defaults?.models ?? {};
return Object.entries(models).map(([key, val]) => ({
key,
alias: val && typeof val === 'object' && typeof val.alias === 'string' ? val.alias : null,
}));
}
export function getAgentModelConfig(openclawAgentId: string): AgentModelConfigResponse {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
const list = config?.agents?.list ?? [];
const entry = agentIndex >= 0 ? list[agentIndex] : undefined;
const override = extractModel(entry);
const systemDefault = config?.agents?.defaults?.model?.primary ?? null;
return {
agentId: openclawAgentId,
known: agentIndex >= 0,
override,
systemDefault,
effective: override ?? systemDefault,
available: listAvailableModels(config),
};
}
export interface SetAgentModelResult {
ok: boolean;
error?: string;
config?: AgentModelConfigResponse;
}
export function setAgentModel(
openclawAgentId: string,
modelKey: string | null
): SetAgentModelResult {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
if (agentIndex < 0) {
return { ok: false, error: `Agent "${openclawAgentId}" not found in openclaw config.` };
}
const path = `agents.list[${agentIndex}].model`;
try {
if (modelKey === null || modelKey === '') {
try {
ocExec(['config', 'unset', path], CLI_OPTS);
} catch (err) {
const stderr = execErrText(err);
if (!/not found|does not exist/i.test(stderr)) throw err;
}
} else {
const available = listAvailableModels(config).map((m) => m.key);
if (!available.includes(modelKey)) {
return {
ok: false,
error: `Model "${modelKey}" is not configured. Configure it via \`openclaw models\` first.`,
};
}
ocExec(['config', 'set', path, JSON.stringify(modelKey), '--strict-json'], CLI_OPTS);
}
} catch (err) {
const stderr = execErrText(err);
console.error(`[agent-model] failed to set ${path}:`, stderr);
return { ok: false, error: stderr || 'Failed to update agent model.' };
}
return { ok: true, config: getAgentModelConfig(openclawAgentId) };
}
+98
View File
@@ -0,0 +1,98 @@
/* eslint-disable no-console */
import fs from 'fs';
import os from 'os';
import { AgentSkillSummary, AgentSkillsResponse, OpenclawConfig } from '../../@types/openclaw';
import { ocExec } from '../openclawGateway';
import { openclawConfigPath } from './paths';
import { execErrText } from '../../utils/errors';
import { listSkills } from './skills';
const CLI_OPTS = {
cwd: os.homedir(),
env: { ...process.env, NO_COLOR: '1' },
timeout: 15000,
};
function readConfig(): OpenclawConfig | null {
try {
return JSON.parse(fs.readFileSync(openclawConfigPath(), 'utf-8')) as OpenclawConfig;
} catch {
return null;
}
}
function findAgentIndex(config: OpenclawConfig | null, openclawAgentId: string): number {
const list = config?.agents?.list ?? [];
return list.findIndex((a) => a?.id === openclawAgentId);
}
function summarizeSkills(): AgentSkillSummary[] {
try {
return listSkills().map((s) => ({
name: s.name,
description: s.description,
emoji: s.emoji,
eligible: s.eligible,
blockedByAllowlist: s.blockedByAllowlist,
source: s.source,
bundled: s.bundled,
}));
} catch {
return [];
}
}
export function getAgentSkillsConfig(openclawAgentId: string): AgentSkillsResponse {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
const entry = agentIndex >= 0 ? config?.agents?.list?.[agentIndex] : undefined;
const raw = entry?.skills;
const override = Array.isArray(raw) ? raw.map(String) : null;
return {
agentId: openclawAgentId,
known: agentIndex >= 0,
override,
available: summarizeSkills(),
};
}
export interface SetAgentSkillsResult {
ok: boolean;
error?: string;
config?: AgentSkillsResponse;
}
export function setAgentSkills(
openclawAgentId: string,
skills: string[] | null
): SetAgentSkillsResult {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
if (agentIndex < 0) {
return { ok: false, error: `Agent "${openclawAgentId}" not found in openclaw config.` };
}
const path = `agents.list[${agentIndex}].skills`;
try {
if (skills === null) {
try {
ocExec(['config', 'unset', path], CLI_OPTS);
} catch (err) {
const stderr = execErrText(err);
if (!/not found|does not exist/i.test(stderr)) throw err;
}
} else {
const normalized = Array.from(new Set(skills.map((s) => String(s).trim()).filter(Boolean)));
ocExec(['config', 'set', path, JSON.stringify(normalized), '--strict-json'], CLI_OPTS);
}
} catch (err) {
const stderr = execErrText(err);
console.error(`[agent-skills] failed to set ${path}:`, stderr);
return { ok: false, error: stderr || 'Failed to update agent skills.' };
}
return { ok: true, config: getAgentSkillsConfig(openclawAgentId) };
}
+180
View File
@@ -0,0 +1,180 @@
/* eslint-disable no-console */
import fs from 'fs';
import os from 'os';
import {
AgentModelOption,
AgentSubagentsConfig,
AgentSubagentsPatch,
AgentSubagentsResponse,
OpenclawAgentEntry,
OpenclawConfig,
OpenclawSubagentsSection,
} from '../../@types/openclaw';
import { ocExec } from '../openclawGateway';
import { openclawConfigPath } from './paths';
import { execErrText } from '../../utils/errors';
const CLI_OPTS = {
cwd: os.homedir(),
env: { ...process.env, NO_COLOR: '1' },
timeout: 15000,
};
const ALLOWED_THINKING = new Set<string>(['minimal', 'low', 'medium', 'high', 'inherit']);
function readConfig(): OpenclawConfig | null {
try {
return JSON.parse(fs.readFileSync(openclawConfigPath(), 'utf-8')) as OpenclawConfig;
} catch {
return null;
}
}
function findAgentIndex(config: OpenclawConfig | null, openclawAgentId: string): number {
const list = config?.agents?.list ?? [];
return list.findIndex((a) => a?.id === openclawAgentId);
}
function extractModel(section: OpenclawSubagentsSection | undefined): string | null {
const m = section?.model;
if (!m) return null;
if (typeof m === 'string') return m;
if (typeof m === 'object') return m.primary ?? null;
return null;
}
function normalizeConfig(entry: OpenclawAgentEntry | undefined): AgentSubagentsConfig {
const s = entry?.subagents;
return {
allowAgents: Array.isArray(s?.allowAgents) ? s!.allowAgents.map(String) : null,
model: extractModel(s),
thinking: typeof s?.thinking === 'string' ? s!.thinking : null,
requireAgentId: typeof s?.requireAgentId === 'boolean' ? s!.requireAgentId : null,
};
}
function availableAgents(config: OpenclawConfig | null, selfId: string) {
const list = config?.agents?.list ?? [];
return list
.filter((a) => a?.id && a.id !== selfId)
.map((a) => ({
id: String(a.id),
name: typeof a.name === 'string' && a.name ? a.name : null,
}));
}
function availableModels(config: OpenclawConfig | null): AgentModelOption[] {
const models = config?.agents?.defaults?.models ?? {};
return Object.entries(models).map(([key, val]) => ({
key,
alias: val && typeof val === 'object' && typeof val.alias === 'string' ? val.alias : null,
}));
}
export function getAgentSubagentsConfig(openclawAgentId: string): AgentSubagentsResponse {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
const entry = agentIndex >= 0 ? config?.agents?.list?.[agentIndex] : undefined;
return {
agentId: openclawAgentId,
known: agentIndex >= 0,
config: normalizeConfig(entry),
availableAgents: availableAgents(config, openclawAgentId),
availableModels: availableModels(config),
};
}
export interface SetAgentSubagentsResult {
ok: boolean;
error?: string;
config?: AgentSubagentsResponse;
}
type PatchHandler = (
basePath: string,
value: unknown
) => { op: 'set'; value: string } | { op: 'unset' } | { op: 'error'; error: string };
const HANDLERS: Record<keyof AgentSubagentsPatch, PatchHandler> = {
allowAgents: (_b, v) => {
if (v === null) return { op: 'unset' };
if (!Array.isArray(v)) return { op: 'error', error: '"allowAgents" must be an array.' };
const normalized = Array.from(new Set(v.map((x) => String(x).trim()).filter(Boolean)));
return { op: 'set', value: JSON.stringify(normalized) };
},
model: (_b, v) => {
if (v === null || v === '') return { op: 'unset' };
if (typeof v !== 'string') return { op: 'error', error: '"model" must be a string or null.' };
return { op: 'set', value: JSON.stringify(v) };
},
thinking: (_b, v) => {
if (v === null || v === '' || v === 'inherit') return { op: 'unset' };
if (typeof v !== 'string' || !ALLOWED_THINKING.has(v)) {
return {
op: 'error',
error: `"thinking" must be one of ${Array.from(ALLOWED_THINKING).join(', ')}.`,
};
}
return { op: 'set', value: JSON.stringify(v) };
},
requireAgentId: (_b, v) => {
if (v === null) return { op: 'unset' };
if (typeof v !== 'boolean')
return { op: 'error', error: '"requireAgentId" must be a boolean or null.' };
return { op: 'set', value: v ? 'true' : 'false' };
},
};
export function setAgentSubagents(
openclawAgentId: string,
patch: AgentSubagentsPatch
): SetAgentSubagentsResult {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
if (agentIndex < 0) {
return { ok: false, error: `Agent "${openclawAgentId}" not found in openclaw config.` };
}
const keys: (keyof AgentSubagentsPatch)[] = [
'allowAgents',
'model',
'thinking',
'requireAgentId',
];
const pending = keys.filter((k) => k in patch);
const failure = pending.reduce<{ ok: false; error: string } | null>((acc, key) => {
if (acc) return acc;
const handler = HANDLERS[key];
const outcome = handler('', patch[key] as unknown);
if (outcome.op === 'error') {
return { ok: false, error: outcome.error };
}
const path = `agents.list[${agentIndex}].subagents.${key}`;
try {
if (outcome.op === 'unset') {
try {
ocExec(['config', 'unset', path], CLI_OPTS);
} catch (err) {
const stderr = execErrText(err);
if (!/not found|does not exist/i.test(stderr)) throw err;
}
} else {
ocExec(['config', 'set', path, outcome.value, '--strict-json'], CLI_OPTS);
}
} catch (err) {
const stderr = execErrText(err);
console.error(`[agent-subagents] failed to set ${path}:`, stderr);
return { ok: false, error: stderr || `Failed to update subagents.${key}.` };
}
return null;
}, null);
if (failure) return failure;
return { ok: true, config: getAgentSubagentsConfig(openclawAgentId) };
}
+194
View File
@@ -0,0 +1,194 @@
/* eslint-disable no-console */
import fs from 'fs';
import os from 'os';
import {
AgentBudgetField,
AgentBudgetKey,
AgentBudgetPatch,
AgentBudgetResponse,
OpenclawConfig,
} from '../../@types/openclaw';
import { ocExec } from '../openclawGateway';
import { openclawConfigPath } from './paths';
import { execErrText } from '../../utils/errors';
const CLI_OPTS = {
cwd: os.homedir(),
env: { ...process.env, NO_COLOR: '1' },
timeout: 15000,
};
type Bucket = 'contextLimits' | 'skillsLimits';
interface BudgetFieldSpec {
key: AgentBudgetKey;
bucket: Bucket;
label: string;
description: string;
min: number;
max: number;
}
export const BUDGET_FIELDS: BudgetFieldSpec[] = [
{
key: 'memoryGetMaxChars',
bucket: 'contextLimits',
label: 'memory_get — max characters',
description:
'Max characters returned by memory_get before truncation. Larger values give richer excerpts at higher token cost.',
min: 1,
max: 250000,
},
{
key: 'memoryGetDefaultLines',
bucket: 'contextLimits',
label: 'memory_get — default line window',
description:
'Default number of source lines selected when memory_get omits the lines parameter (capped by max chars).',
min: 1,
max: 5000,
},
{
key: 'toolResultMaxChars',
bucket: 'contextLimits',
label: 'Tool result — max characters',
description:
'Per-tool-call result budget. Longer outputs are truncated before being persisted or injected back into the prompt.',
min: 1,
max: 250000,
},
{
key: 'postCompactionMaxChars',
bucket: 'contextLimits',
label: 'Post-compaction context — max characters',
description:
'Budget for AGENTS.md re-injection after compaction. Lower values make recovery cheaper; higher values preserve more startup guidance.',
min: 1,
max: 50000,
},
{
key: 'maxSkillsPromptChars',
bucket: 'skillsLimits',
label: 'Skills prompt — max characters',
description:
'Cap on the combined skills section injected into the agent prompt. Higher values let more skills surface at greater token cost.',
min: 0,
max: Number.MAX_SAFE_INTEGER,
},
];
function readConfig(): OpenclawConfig | null {
try {
return JSON.parse(fs.readFileSync(openclawConfigPath(), 'utf-8')) as OpenclawConfig;
} catch {
return null;
}
}
function findAgentIndex(config: OpenclawConfig | null, openclawAgentId: string): number {
const list = config?.agents?.list ?? [];
return list.findIndex((a) => a?.id === openclawAgentId);
}
function valueFromConfig(
config: OpenclawConfig | null,
agentIndex: number,
spec: BudgetFieldSpec
): { override: number | null; defaultValue: number | null } {
const list = config?.agents?.list ?? [];
const defaults = config?.agents?.defaults as
| { contextLimits?: Record<string, number>; skillsLimits?: Record<string, number> }
| undefined;
const entry = agentIndex >= 0 ? list[agentIndex] : undefined;
const entryBucket = entry?.[spec.bucket] as Record<string, number> | undefined;
const defaultBucket = defaults?.[spec.bucket];
const override =
entryBucket && typeof entryBucket[spec.key] === 'number' ? entryBucket[spec.key] : null;
const defaultValue =
defaultBucket && typeof defaultBucket[spec.key] === 'number'
? (defaultBucket[spec.key] as number)
: null;
return { override, defaultValue };
}
export function getAgentBudget(openclawAgentId: string): AgentBudgetResponse {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
const fields: AgentBudgetField[] = BUDGET_FIELDS.map((spec) => {
const { override, defaultValue } = valueFromConfig(config, agentIndex, spec);
return {
key: spec.key,
label: spec.label,
description: spec.description,
min: spec.min,
max: spec.max,
override,
default: defaultValue,
effective: override ?? defaultValue,
};
});
return { agentId: openclawAgentId, known: agentIndex >= 0, fields };
}
export interface SetAgentBudgetResult {
ok: boolean;
error?: string;
budget?: AgentBudgetResponse;
}
export function setAgentBudget(
openclawAgentId: string,
patch: AgentBudgetPatch
): SetAgentBudgetResult {
const config = readConfig();
const agentIndex = findAgentIndex(config, openclawAgentId);
if (agentIndex < 0) {
return { ok: false, error: `Agent "${openclawAgentId}" not found in openclaw config.` };
}
const specByKey = new Map(BUDGET_FIELDS.map((s) => [s.key, s]));
const entries = (Object.entries(patch) as [AgentBudgetKey, number | null | undefined][]).filter(
([key]) => specByKey.has(key)
);
const failure = entries.reduce<{ ok: false; error: string } | null>((acc, [key, rawValue]) => {
if (acc) return acc;
const spec = specByKey.get(key)!;
const path = `agents.list[${agentIndex}].${spec.bucket}.${spec.key}`;
try {
if (rawValue === null || rawValue === undefined) {
try {
ocExec(['config', 'unset', path], CLI_OPTS);
} catch (err) {
const stderr = execErrText(err);
if (!/not found|does not exist/i.test(stderr)) throw err;
}
} else {
if (!Number.isFinite(rawValue) || !Number.isInteger(rawValue)) {
return { ok: false, error: `"${key}" must be an integer.` };
}
if (rawValue < spec.min || rawValue > spec.max) {
return {
ok: false,
error: `"${key}" must be between ${spec.min} and ${spec.max}.`,
};
}
ocExec(['config', 'set', path, String(rawValue), '--strict-json'], CLI_OPTS);
}
} catch (err) {
const stderr = execErrText(err);
console.error(`[budget] failed to set ${path}:`, stderr);
return { ok: false, error: stderr || `Failed to update ${key}.` };
}
return null;
}, null);
if (failure) return failure;
return { ok: true, budget: getAgentBudget(openclawAgentId) };
}
+85 -78
View File
@@ -13,83 +13,6 @@ function isAgentEvent(msg: GwInboundMessage): msg is GwEventMessage<GwAgentEvent
return msg.type === 'event' && (msg.event === 'agent' || msg.event === 'chat');
}
function runAgentViaGateway(
agentId: string,
message: string,
sessionKey: string | null,
emitter: SseEmitter
): ChatRunHandle {
const runId = crypto.randomUUID();
const listenerKey = `agent-${runId}`;
let assistantSent = '';
let reasoningSent = '';
gateway.onEvent(listenerKey, (msg: GwInboundMessage) => {
if (!isAgentEvent(msg)) return;
const p = msg.payload;
if (p.runId !== runId) return;
if (msg.event !== 'agent' || !p.data?.delta) return;
const { stream } = p;
if (stream !== 'assistant' && stream !== 'reasoning') return;
const fullText = p.data.text;
if (fullText == null) return;
const clean = stripGatewayTags(fullText);
if (!clean || GW_RE_PARTIAL_TAG.test(clean)) return;
const alreadySent = stream === 'assistant' ? assistantSent : reasoningSent;
const sseType =
stream === 'assistant' ? 'response.output_text.delta' : 'response.thinking.delta';
if (alreadySent.length === 0 || clean.startsWith(alreadySent)) {
if (clean.length > alreadySent.length) {
const newContent = clean.substring(alreadySent.length);
if (stream === 'assistant') assistantSent = clean;
else reasoningSent = clean;
emitter.send(sseType, newContent);
}
} else {
if (stream === 'assistant') assistantSent = clean;
else reasoningSent = clean;
emitter.send(sseType, clean);
}
});
const sessionSettings = getSessionSettingsInternal(agentId, sessionKey);
const params: Record<string, unknown> = {
message,
agentId,
idempotencyKey: runId,
thinking: sessionSettings.thinkingLevel || 'medium',
};
if (sessionKey) {
const fullKey = `agent:${agentId}:${sessionKey}`;
params.sessionId = sessionKey;
params.sessionKey = fullKey;
}
gateway
.request('agent', params, { expectFinal: true, timeoutMs: 120000 })
.then(() => {
gateway.offEvent(listenerKey);
if (sessionKey) {
const thinking = extractThinkingFromJsonl(agentId, sessionKey);
if (thinking) emitter.send('response.thinking.delta', thinking);
}
emitter.done();
})
.catch((err: Error) => {
console.error('[gateway] agent error:', err.message);
gateway.offEvent(listenerKey);
emitter.error(err.message);
});
return { kill: () => gateway.offEvent(listenerKey) };
}
function runAgentWithEmitter(
agentId: string,
message: string,
@@ -213,7 +136,7 @@ function runAgentWithEmitter(
})
.join(' | ');
if (errorLines) {
emitter.send('response.output_text.delta', `[Error] ${errorLines}`);
emitter.send('response.error', errorLines);
}
}
emitter.done();
@@ -225,6 +148,90 @@ function runAgentWithEmitter(
});
}
function runAgentViaGateway(
agentId: string,
message: string,
sessionKey: string | null,
emitter: SseEmitter
): ChatRunHandle {
const runId = crypto.randomUUID();
const listenerKey = `agent-${runId}`;
let assistantSent = '';
let reasoningSent = '';
gateway.onEvent(listenerKey, (msg: GwInboundMessage) => {
if (!isAgentEvent(msg)) return;
const p = msg.payload;
if (p.runId !== runId) return;
if (msg.event !== 'agent' || !p.data?.delta) return;
const { stream } = p;
if (stream !== 'assistant' && stream !== 'reasoning') return;
const fullText = p.data.text;
if (fullText == null) return;
const clean = stripGatewayTags(fullText);
if (!clean || GW_RE_PARTIAL_TAG.test(clean)) return;
const alreadySent = stream === 'assistant' ? assistantSent : reasoningSent;
const sseType =
stream === 'assistant' ? 'response.output_text.delta' : 'response.thinking.delta';
if (alreadySent.length === 0 || clean.startsWith(alreadySent)) {
if (clean.length > alreadySent.length) {
const newContent = clean.substring(alreadySent.length);
if (stream === 'assistant') assistantSent = clean;
else reasoningSent = clean;
emitter.send(sseType, newContent);
}
} else {
if (stream === 'assistant') assistantSent = clean;
else reasoningSent = clean;
emitter.send(sseType, clean);
}
});
const sessionSettings = getSessionSettingsInternal(agentId, sessionKey);
const params: Record<string, unknown> = {
message,
agentId,
idempotencyKey: runId,
thinking: sessionSettings.thinkingLevel || 'medium',
};
if (sessionKey) {
const fullKey = `agent:${agentId}:${sessionKey}`;
params.sessionId = sessionKey;
params.sessionKey = fullKey;
}
gateway
.request('agent', params, { expectFinal: true, timeoutMs: 120000 })
.then(() => {
gateway.offEvent(listenerKey);
if (sessionKey) {
const thinking = extractThinkingFromJsonl(agentId, sessionKey);
if (thinking) emitter.send('response.thinking.delta', thinking);
}
emitter.done();
})
.catch((err: Error) => {
console.error('[gateway] agent error:', err.message);
gateway.offEvent(listenerKey);
const msg = err.message || '';
if (agentId !== 'main' && /unknown agent|agent .* not found|no such agent/i.test(msg)) {
console.warn(`[gateway] agent "${agentId}" unknown, retrying via CLI.`);
runAgentWithEmitter(agentId, message, sessionKey, emitter);
return;
}
emitter.error(msg);
});
return { kill: () => gateway.offEvent(listenerKey) };
}
// eslint-disable-next-line import/prefer-default-export
export async function runChat(
agentId: string,
+4
View File
@@ -30,6 +30,10 @@ export {
copyFileToWorkspace,
} from './workspace';
export { getAgentModel, getAgentModelsForOpenclawIds } from './config';
export { getAgentBudget, setAgentBudget, BUDGET_FIELDS } from './budget';
export { getAgentModelConfig, setAgentModel } from './agentModel';
export { getAgentSkillsConfig, setAgentSkills } from './agentSkills';
export { getAgentSubagentsConfig, setAgentSubagents } from './agentSubagents';
export { listPlugins, togglePlugin } from './plugins';
export { listSkills } from './skills';
export { listChannels, addChannel, removeChannel } from './channels';
+10 -3
View File
@@ -22,11 +22,18 @@ export function createSseEmitter(res: Response): SseEmitter {
res.end();
},
error(msg) {
const text = msg || 'Agent run failed.';
if (!res.headersSent) {
res.status(500).json({ error: msg });
} else {
res.end();
res.status(500).json({ error: text });
return;
}
try {
res.write(`data: ${JSON.stringify({ type: 'response.error', delta: text })}\n\n`);
res.write('data: [DONE]\n\n');
} catch {
/* best effort */
}
res.end();
},
};
}
+178 -1
View File
@@ -46,6 +46,109 @@ export interface SessionSettingsResponse {
settings: Partial<SessionSettings>;
}
export type AgentBudgetKey =
| 'memoryGetMaxChars'
| 'memoryGetDefaultLines'
| 'toolResultMaxChars'
| 'postCompactionMaxChars'
| 'maxSkillsPromptChars';
export interface AgentBudgetField {
key: AgentBudgetKey;
label: string;
description: string;
min: number;
max: number;
override: number | null;
default: number | null;
effective: number | null;
}
export interface AgentBudgetResponse {
agentId: string;
known: boolean;
fields: AgentBudgetField[];
}
export type AgentBudgetPatch = Partial<Record<AgentBudgetKey, number | null>>;
export interface AgentBudgetMutationResponse {
ok: boolean;
error?: string;
budget?: AgentBudgetResponse;
}
export interface AgentModelOption {
key: string;
alias: string | null;
}
export interface AgentModelConfigResponse {
agentId: string;
known: boolean;
override: string | null;
systemDefault: string | null;
effective: string | null;
available: AgentModelOption[];
}
export interface AgentModelConfigMutationResponse {
ok: boolean;
error?: string;
config?: AgentModelConfigResponse;
}
export interface AgentSkillSummary {
name: string;
description: string;
emoji: string;
eligible: boolean;
blockedByAllowlist: boolean;
source: string;
bundled: boolean;
}
export interface AgentSkillsResponse {
agentId: string;
known: boolean;
override: string[] | null;
available: AgentSkillSummary[];
}
export interface AgentSkillsMutationResponse {
ok: boolean;
error?: string;
config?: AgentSkillsResponse;
}
export interface AgentSubagentsConfig {
allowAgents: string[] | null;
model: string | null;
thinking: string | null;
requireAgentId: boolean | null;
}
export interface AgentSubagentsResponse {
agentId: string;
known: boolean;
config: AgentSubagentsConfig;
availableAgents: { id: string; name: string | null }[];
availableModels: AgentModelOption[];
}
export interface AgentSubagentsPatch {
allowAgents?: string[] | null;
model?: string | null;
thinking?: string | null;
requireAgentId?: boolean | null;
}
export interface AgentSubagentsMutationResponse {
ok: boolean;
error?: string;
config?: AgentSubagentsResponse;
}
export const WORKSPACE_TAB_FILES = [
{ label: 'AGENTS', file: 'AGENTS.md' },
{ label: 'SOUL', file: 'SOUL.md' },
@@ -67,7 +170,10 @@ export const agentsApi = baseApi.injectEndpoints({
query: (id) => `/agent/${id}`,
providesTags: ['Agent'],
}),
createAgent: build.mutation<Agent, { name: string; openclawAgentId?: string; interactive?: boolean }>({
createAgent: build.mutation<
Agent,
{ name: string; openclawAgentId?: string; interactive?: boolean }
>({
query: (body) => ({
url: '/agent',
method: 'POST',
@@ -145,6 +251,69 @@ export const agentsApi = baseApi.injectEndpoints({
{ type: 'SessionSettings', id: conversationId },
],
}),
getAgentBudget: build.query<AgentBudgetResponse, string>({
query: (agentId) => `/agent/${agentId}/budget`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentBudget', id: agentId }],
}),
updateAgentBudget: build.mutation<
AgentBudgetMutationResponse,
{ agentId: string; patch: AgentBudgetPatch }
>({
query: ({ agentId, patch }) => ({
url: `/agent/${agentId}/budget`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (_res, _err, { agentId }) => [{ type: 'AgentBudget', id: agentId }],
}),
getAgentModelConfig: build.query<AgentModelConfigResponse, string>({
query: (agentId) => `/agent/${agentId}/model-config`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentModelConfig', id: agentId }],
}),
updateAgentModelConfig: build.mutation<
AgentModelConfigMutationResponse,
{ agentId: string; model: string | null }
>({
query: ({ agentId, model }) => ({
url: `/agent/${agentId}/model-config`,
method: 'PATCH',
body: { model },
}),
invalidatesTags: (_res, _err, { agentId }) => [
{ type: 'AgentModelConfig', id: agentId },
'Agent',
],
}),
getAgentSkills: build.query<AgentSkillsResponse, string>({
query: (agentId) => `/agent/${agentId}/skills`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentSkills', id: agentId }],
}),
updateAgentSkills: build.mutation<
AgentSkillsMutationResponse,
{ agentId: string; skills: string[] | null }
>({
query: ({ agentId, skills }) => ({
url: `/agent/${agentId}/skills`,
method: 'PATCH',
body: { skills },
}),
invalidatesTags: (_res, _err, { agentId }) => [{ type: 'AgentSkills', id: agentId }],
}),
getAgentSubagents: build.query<AgentSubagentsResponse, string>({
query: (agentId) => `/agent/${agentId}/subagents`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentSubagents', id: agentId }],
}),
updateAgentSubagents: build.mutation<
AgentSubagentsMutationResponse,
{ agentId: string; patch: AgentSubagentsPatch }
>({
query: ({ agentId, patch }) => ({
url: `/agent/${agentId}/subagents`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (_res, _err, { agentId }) => [{ type: 'AgentSubagents', id: agentId }],
}),
}),
});
@@ -160,4 +329,12 @@ export const {
useSaveWorkspaceFileMutation,
useGetSessionSettingsQuery,
usePatchSessionSettingsMutation,
useGetAgentBudgetQuery,
useUpdateAgentBudgetMutation,
useGetAgentModelConfigQuery,
useUpdateAgentModelConfigMutation,
useGetAgentSkillsQuery,
useUpdateAgentSkillsMutation,
useGetAgentSubagentsQuery,
useUpdateAgentSubagentsMutation,
} = agentsApi;
@@ -0,0 +1 @@
export { default as AgentBudgets } from './ui/AgentBudgets';
@@ -0,0 +1,322 @@
import { useMemo, useState } from 'react';
import {
Box,
Button,
Chip,
CircularProgress,
IconButton,
InputAdornment,
Stack,
TextField,
Tooltip,
Typography,
Alert,
} from '@mui/material';
import { RestartAlt } from '@mui/icons-material';
import {
useGetAgentBudgetQuery,
useUpdateAgentBudgetMutation,
type AgentBudgetField,
type AgentBudgetKey,
type AgentBudgetPatch,
} from '../../../../entities/agent';
interface AgentBudgetsProps {
agentId: string;
}
type DraftMap = Partial<Record<AgentBudgetKey, string>>;
function formatPlaceholder(field: AgentBudgetField): string {
if (field.default != null) return `default ${field.default.toLocaleString()}`;
return 'inherits default';
}
function parseDraft(value: string | undefined): number | null | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (trimmed === '') return null;
const num = Number(trimmed);
if (!Number.isFinite(num) || !Number.isInteger(num)) return undefined;
return num;
}
export default function AgentBudgets({ agentId }: AgentBudgetsProps) {
const { data, isLoading, isError, refetch } = useGetAgentBudgetQuery(agentId, { skip: !agentId });
const [update, { isLoading: saving, error: saveError }] = useUpdateAgentBudgetMutation();
const [drafts, setDrafts] = useState<DraftMap>({});
const [fieldErrors, setFieldErrors] = useState<Partial<Record<AgentBudgetKey, string>>>({});
// Reseed drafts whenever the server-provided data identity changes. Done
// during render (the React-recommended alternative to a sync setState inside
// useEffect) so we skip an extra commit cycle.
const [prevData, setPrevData] = useState(data);
if (data !== prevData) {
setPrevData(data);
if (data) {
const next: DraftMap = {};
data.fields.forEach((f) => {
next[f.key] = f.override == null ? '' : String(f.override);
});
setDrafts(next);
setFieldErrors({});
}
}
const patch = useMemo<AgentBudgetPatch>(() => {
if (!data) return {};
const result: AgentBudgetPatch = {};
data.fields.forEach((f) => {
const current = f.override == null ? '' : String(f.override);
const draft = drafts[f.key] ?? '';
if (draft === current) return;
const parsed = parseDraft(draft);
if (parsed === undefined) return;
result[f.key] = parsed;
});
return result;
}, [data, drafts]);
const dirty = Object.keys(patch).length > 0;
function validate(): boolean {
if (!data) return false;
const errs: Partial<Record<AgentBudgetKey, string>> = {};
data.fields.forEach((f) => {
const draft = drafts[f.key] ?? '';
if (draft.trim() === '') return;
const parsed = parseDraft(draft);
if (parsed === undefined || parsed === null) {
errs[f.key] = 'Must be an integer.';
return;
}
if (parsed < f.min) errs[f.key] = `Must be ≥ ${f.min.toLocaleString()}.`;
else if (parsed > f.max && f.max < Number.MAX_SAFE_INTEGER)
errs[f.key] = `Must be ≤ ${f.max.toLocaleString()}.`;
});
setFieldErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSave() {
if (!validate()) return;
if (!dirty) return;
try {
await update({ agentId, patch }).unwrap();
} catch (err) {
console.error('Save agent budgets failed:', err);
}
}
function handleReset() {
if (!data) return;
const next: DraftMap = {};
data.fields.forEach((f) => {
next[f.key] = f.override == null ? '' : String(f.override);
});
setDrafts(next);
setFieldErrors({});
}
function handleResetField(key: AgentBudgetKey) {
setDrafts((prev) => ({ ...prev, [key]: '' }));
setFieldErrors((prev) => Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key)));
}
if (isLoading && !data) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} />
</Box>
);
}
if (isError || !data) {
return (
<Alert
severity="error"
action={
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
}
>
Could not load agent budgets.
</Alert>
);
}
const rpcError =
saveError && typeof saveError === 'object' && 'data' in saveError
? ((saveError as { data?: { error?: string } }).data?.error ?? 'Failed to save budgets.')
: null;
return (
<Box sx={{ width: '100%', minWidth: 0 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={1}
sx={{ mb: 1 }}
>
<Box>
<Typography variant="subtitle2" fontWeight={700}>
Per-agent budgets
</Typography>
<Typography variant="caption" color="text.secondary">
Character budgets that bound injected context and tool output for this agent. Leave a
field empty to inherit the system default.
</Typography>
</Box>
{!data.known && (
<Chip
size="small"
color="warning"
variant="outlined"
label="Agent missing from openclaw config"
/>
)}
</Stack>
{rpcError && (
<Alert severity="error" sx={{ mb: 1 }}>
{rpcError}
</Alert>
)}
<Stack spacing={1.25}>
{data.fields.map((field) => {
const draft = drafts[field.key] ?? '';
const hasOverride = draft.trim() !== '';
const err = fieldErrors[field.key];
return (
<Box
key={field.key}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.25,
bgcolor: 'background.paper',
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={1.25}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
gap: 1,
flexWrap: 'wrap',
rowGap: 0,
}}
>
<Typography variant="body2" fontWeight={600}>
{field.label}
</Typography>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.7rem',
}}
>
{field.effective != null && (
<>
<Box component="span" sx={{ color: 'text.primary', fontWeight: 600 }}>
{field.effective.toLocaleString()}
</Box>
<Box component="span" sx={{ opacity: 0.55, mx: 0.75 }}>
·
</Box>
</>
)}
{field.default != null && (
<>
default {field.default.toLocaleString()}
<Box component="span" sx={{ opacity: 0.55, mx: 0.75 }}>
·
</Box>
</>
)}
range {field.min.toLocaleString()}
{field.max < Number.MAX_SAFE_INTEGER ? `${field.max.toLocaleString()}` : '+'}
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.25 }}
>
{field.description}
</Typography>
</Box>
<TextField
size="small"
type="number"
value={draft}
onChange={(e) => {
const v = e.target.value;
setDrafts((prev) => ({ ...prev, [field.key]: v }));
setFieldErrors((prev) =>
Object.fromEntries(Object.entries(prev).filter(([k]) => k !== field.key))
);
}}
placeholder={formatPlaceholder(field)}
error={Boolean(err)}
helperText={err}
slotProps={{
input: {
endAdornment: hasOverride ? (
<InputAdornment position="end">
<Tooltip title="Clear override (use default)">
<IconButton
size="small"
edge="end"
onClick={() => handleResetField(field.key)}
aria-label={`Reset ${field.key} to default`}
>
<RestartAlt fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
) : null,
},
htmlInput: {
min: field.min,
max: field.max < Number.MAX_SAFE_INTEGER ? field.max : undefined,
step: 1,
inputMode: 'numeric',
},
}}
sx={{ width: { xs: '100%', sm: 220 } }}
/>
</Stack>
</Box>
);
})}
</Stack>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1.5 }}>
<Button size="small" onClick={handleReset} disabled={!dirty || saving}>
Reset
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={!dirty || saving || !data.known}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,2 @@
export { default as AgentModelConfig } from './ui/AgentModelConfig';
export { default as AgentModelPicker } from './ui/AgentModelPicker';
@@ -0,0 +1,230 @@
import { useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
Typography,
} from '@mui/material';
import {
useGetAgentModelConfigQuery,
useUpdateAgentModelConfigMutation,
} from '../../../../entities/agent';
interface AgentModelConfigProps {
agentId: string;
}
const INHERIT_VALUE = '__inherit__';
export default function AgentModelConfig({ agentId }: AgentModelConfigProps) {
const { data, isLoading, isError, refetch } = useGetAgentModelConfigQuery(agentId, {
skip: !agentId,
});
const [update, { isLoading: saving, error: saveError }] = useUpdateAgentModelConfigMutation();
const [selection, setSelection] = useState<string>(INHERIT_VALUE);
const [prevData, setPrevData] = useState(data);
if (data !== prevData) {
setPrevData(data);
if (data) setSelection(data.override ?? INHERIT_VALUE);
}
const dirty = data ? selection !== (data.override ?? INHERIT_VALUE) : false;
async function handleSave() {
try {
await update({
agentId,
model: selection === INHERIT_VALUE ? null : selection,
}).unwrap();
} catch (err) {
console.error('Save agent model failed:', err);
}
}
function handleReset() {
if (!data) return;
setSelection(data.override ?? INHERIT_VALUE);
}
if (isLoading && !data) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} />
</Box>
);
}
if (isError || !data) {
return (
<Alert
severity="error"
action={
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
}
>
Could not load agent model configuration.
</Alert>
);
}
const rpcError =
saveError && typeof saveError === 'object' && 'data' in saveError
? ((saveError as { data?: { error?: string } }).data?.error ?? 'Failed to save model.')
: null;
const systemDefaultLabel = data.systemDefault ?? 'unset';
return (
<Box sx={{ width: '100%', minWidth: 0 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={1}
sx={{ mb: 1 }}
>
<Box>
<Typography variant="subtitle2" fontWeight={700}>
Model
</Typography>
<Typography variant="caption" color="text.secondary">
Override the primary model for this agent. Leave as "Inherit default" to use the system
default.
</Typography>
</Box>
{!data.known && (
<Chip
size="small"
color="warning"
variant="outlined"
label="Agent missing from openclaw config"
/>
)}
</Stack>
{rpcError && (
<Alert severity="error" sx={{ mb: 1 }}>
{rpcError}
</Alert>
)}
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'background.paper',
}}
>
<Stack spacing={1.25}>
<FormControl fullWidth size="small" disabled={!data.known}>
<InputLabel id="agent-model-select-label">Primary model</InputLabel>
<Select
labelId="agent-model-select-label"
label="Primary model"
value={selection}
onChange={(e) => setSelection(String(e.target.value))}
>
<MenuItem value={INHERIT_VALUE}>
<Box component="span">
Inherit default{' '}
<Box
component="span"
sx={{
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.72rem',
}}
>
({systemDefaultLabel})
</Box>
</Box>
</MenuItem>
{data.available.length === 0 && (
<MenuItem value="" disabled>
No models configured add via <code>openclaw models</code>
</MenuItem>
)}
{data.available.map((m) => (
<MenuItem key={m.key} value={m.key}>
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'baseline',
gap: 1,
minWidth: 0,
}}
>
<Box
component="span"
sx={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.8rem',
}}
>
{m.key}
</Box>
{m.alias && (
<Box
component="span"
sx={{
color: 'text.secondary',
fontSize: '0.72rem',
}}
>
alias {m.alias}
</Box>
)}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.7rem',
}}
>
<Box component="span" sx={{ color: 'text.primary', fontWeight: 600 }}>
effective {data.effective ?? '—'}
</Box>
<Box component="span" sx={{ opacity: 0.55, mx: 0.75 }}>
·
</Box>
system default {systemDefaultLabel}
</Typography>
</Stack>
</Box>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1.5 }}>
<Button size="small" onClick={handleReset} disabled={!dirty || saving}>
Reset
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={!dirty || saving || !data.known}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,197 @@
import { useState } from 'react';
import {
Box,
ButtonBase,
CircularProgress,
Divider,
Menu,
MenuItem,
Tooltip,
Typography,
} from '@mui/material';
import { ExpandMore, Check } from '@mui/icons-material';
import {
useGetAgentModelConfigQuery,
useUpdateAgentModelConfigMutation,
} from '../../../../entities/agent';
interface AgentModelPickerProps {
agentId: string;
}
function shortenModel(key: string | null): string {
if (!key) return 'no model';
const slash = key.indexOf('/');
return slash >= 0 ? key.slice(slash + 1) : key;
}
export default function AgentModelPicker({ agentId }: AgentModelPickerProps) {
const { data, isLoading } = useGetAgentModelConfigQuery(agentId, { skip: !agentId });
const [update, { isLoading: saving }] = useUpdateAgentModelConfigMutation();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
if (isLoading && !data) {
return <CircularProgress size={12} sx={{ ml: 0.25 }} />;
}
if (!data) return null;
const effective = data.effective;
const override = data.override;
const label = shortenModel(effective);
async function handlePick(model: string | null) {
setAnchor(null);
if (!data?.known) return;
if ((override ?? null) === (model ?? null)) return;
try {
await update({ agentId, model }).unwrap();
} catch (err) {
console.error('Agent model update failed:', err);
}
}
const canEdit = data.known && !saving;
return (
<>
<Tooltip
title={
data.known
? effective
? `Model: ${effective}${override ? '' : ' (inherited default)'}`
: 'Pick a model'
: 'Agent not in openclaw config'
}
placement="bottom-start"
>
<Box
component="span"
sx={{ display: 'inline-flex', alignItems: 'center', minWidth: 0, maxWidth: '100%' }}
>
<ButtonBase
onClick={(e) => canEdit && setAnchor(e.currentTarget)}
disabled={!canEdit}
focusRipple
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.35,
px: 0.6,
py: 0.15,
ml: -0.6,
borderRadius: 0.75,
maxWidth: '100%',
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.7rem',
lineHeight: 1.3,
opacity: data.known ? 1 : 0.6,
cursor: canEdit ? 'pointer' : 'default',
'&:hover': canEdit ? { bgcolor: 'action.hover', color: 'text.primary' } : undefined,
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: 1,
},
}}
>
<Box
component="span"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{label}
</Box>
{!override && effective && (
<Box component="span" sx={{ opacity: 0.55, fontSize: '0.65rem' }}>
(default)
</Box>
)}
{saving ? (
<CircularProgress size={10} sx={{ ml: 0.25 }} />
) : (
<ExpandMore sx={{ fontSize: 14, opacity: canEdit ? 0.7 : 0.3 }} />
)}
</ButtonBase>
</Box>
</Tooltip>
<Menu
anchorEl={anchor}
open={Boolean(anchor)}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: { sx: { minWidth: 260, maxWidth: 360 } } }}
>
<MenuItem onClick={() => handlePick(null)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
<Box sx={{ width: 16, flexShrink: 0 }}>
{override === null && <Check sx={{ fontSize: 16 }} />}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2">Inherit default</Typography>
{data.systemDefault && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.68rem',
}}
>
{data.systemDefault}
</Typography>
)}
</Box>
</Box>
</MenuItem>
<Divider />
{data.available.length === 0 && (
<MenuItem disabled>
<Typography variant="caption" color="text.secondary">
No models configured. Run <code>openclaw models</code>.
</Typography>
</MenuItem>
)}
{data.available.map((m) => {
const isActive = override === m.key;
return (
<MenuItem key={m.key} selected={isActive} onClick={() => handlePick(m.key)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
<Box sx={{ width: 16, flexShrink: 0 }}>
{isActive && <Check sx={{ fontSize: 16 }} />}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.82rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{m.key}
</Typography>
{m.alias && (
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontSize: '0.68rem' }}
>
alias {m.alias}
</Typography>
)}
</Box>
</Box>
</MenuItem>
);
})}
</Menu>
</>
);
}
@@ -0,0 +1 @@
export { default as AgentSkills } from './ui/AgentSkills';
@@ -0,0 +1,366 @@
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControlLabel,
InputAdornment,
List,
ListItem,
ListItemText,
Stack,
Switch,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { Search } from '@mui/icons-material';
import {
useGetAgentSkillsQuery,
useUpdateAgentSkillsMutation,
type AgentSkillSummary,
} from '../../../../entities/agent';
interface AgentSkillsProps {
agentId: string;
}
function skillEmoji(s: AgentSkillSummary): string {
return s.emoji || '🧩';
}
function sameSet(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sorted1 = [...a].sort();
const sorted2 = [...b].sort();
return sorted1.every((x, i) => x === sorted2[i]);
}
export default function AgentSkills({ agentId }: AgentSkillsProps) {
const { data, isLoading, isError, refetch } = useGetAgentSkillsQuery(agentId, {
skip: !agentId,
});
const [update, { isLoading: saving, error: saveError }] = useUpdateAgentSkillsMutation();
const [inherit, setInherit] = useState(true);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [query, setQuery] = useState('');
const [prevData, setPrevData] = useState(data);
if (data !== prevData) {
setPrevData(data);
if (data) {
if (data.override === null) {
setInherit(true);
setEnabled(new Set());
} else {
setInherit(false);
setEnabled(new Set(data.override));
}
}
}
const filtered = useMemo(() => {
const skills = data?.available ?? [];
if (!query.trim()) return skills;
const q = query.trim().toLowerCase();
return skills.filter(
(s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
);
}, [data, query]);
const dirty = useMemo(() => {
if (!data) return false;
if (inherit) return data.override !== null;
if (data.override === null) return true;
return !sameSet(data.override, [...enabled]);
}, [data, inherit, enabled]);
function toggleSkill(name: string, on: boolean) {
setEnabled((prev) => {
const next = new Set(prev);
if (on) next.add(name);
else next.delete(name);
return next;
});
}
function enableAll() {
setEnabled(new Set((data?.available ?? []).map((s) => s.name)));
}
function disableAll() {
setEnabled(new Set());
}
async function handleSave() {
try {
await update({
agentId,
skills: inherit ? null : [...enabled],
}).unwrap();
} catch (err) {
console.error('Save agent skills failed:', err);
}
}
function handleReset() {
if (!data) return;
if (data.override === null) {
setInherit(true);
setEnabled(new Set());
} else {
setInherit(false);
setEnabled(new Set(data.override));
}
}
if (isLoading && !data) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} />
</Box>
);
}
if (isError || !data) {
return (
<Alert
severity="error"
action={
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
}
>
Could not load agent skills.
</Alert>
);
}
const rpcError =
saveError && typeof saveError === 'object' && 'data' in saveError
? ((saveError as { data?: { error?: string } }).data?.error ?? 'Failed to save skills.')
: null;
const disabled = inherit || !data.known || saving;
const activeCount = enabled.size;
const totalCount = data.available.length;
return (
<Box sx={{ width: '100%', minWidth: 0 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={1}
sx={{ mb: 1 }}
>
<Box>
<Typography variant="subtitle2" fontWeight={700}>
Skills allowlist
</Typography>
<Typography variant="caption" color="text.secondary">
Pick which skills this agent is allowed to use. Inherit to follow system defaults. An
explicit list replaces the defaults rather than merging.
</Typography>
</Box>
{!data.known && (
<Chip
size="small"
color="warning"
variant="outlined"
label="Agent missing from openclaw config"
/>
)}
</Stack>
{rpcError && (
<Alert severity="error" sx={{ mb: 1 }}>
{rpcError}
</Alert>
)}
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.paper',
overflow: 'hidden',
}}
>
<Box sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<FormControlLabel
control={
<Switch
checked={inherit}
onChange={(_, v) => setInherit(v)}
disabled={!data.known || saving}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600}>
Inherit defaults
</Typography>
<Typography variant="caption" color="text.secondary">
When off, only the skills you toggle on below are allowed.
</Typography>
</Box>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1}
alignItems={{ xs: 'stretch', sm: 'center' }}
sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}
>
<TextField
size="small"
placeholder="Search skills…"
value={query}
onChange={(e) => setQuery(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search fontSize="small" />
</InputAdornment>
),
}}
/>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.7rem',
whiteSpace: 'nowrap',
px: { xs: 0, sm: 1 },
}}
>
{inherit ? 'inherit' : `${activeCount} / ${totalCount} on`}
</Typography>
<Stack direction="row" spacing={0.5}>
<Button size="small" onClick={enableAll} disabled={disabled}>
All
</Button>
<Button size="small" onClick={disableAll} disabled={disabled}>
None
</Button>
</Stack>
</Stack>
<List dense disablePadding sx={{ maxHeight: 520, overflow: 'auto' }}>
{filtered.length === 0 && (
<ListItem>
<ListItemText
primary={
<Typography variant="body2" color="text.secondary">
No skills match.
</Typography>
}
/>
</ListItem>
)}
{filtered.map((s) => {
const on = enabled.has(s.name);
return (
<ListItem
key={s.name}
divider
sx={{
alignItems: 'flex-start',
py: 1,
opacity: disabled ? 0.55 : 1,
}}
secondaryAction={
<Switch
edge="end"
size="small"
checked={on}
onChange={(_, v) => toggleSkill(s.name, v)}
disabled={disabled}
inputProps={{ 'aria-label': `Toggle ${s.name}` }}
/>
}
>
<Box sx={{ fontSize: '1.15rem', lineHeight: 1.2, pr: 1, pt: 0.25 }}>
{skillEmoji(s)}
</Box>
<ListItemText
primary={
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" fontWeight={600}>
{s.name}
</Typography>
{!s.eligible && (
<Tooltip title="Skill is not eligible on this host (missing deps).">
<Chip
size="small"
color="warning"
variant="outlined"
label="ineligible"
sx={{
height: 18,
'& .MuiChip-label': { px: 0.75, fontSize: '0.65rem' },
}}
/>
</Tooltip>
)}
{s.bundled && (
<Chip
size="small"
variant="outlined"
label="bundled"
sx={{ height: 18, '& .MuiChip-label': { px: 0.75, fontSize: '0.65rem' } }}
/>
)}
</Stack>
}
secondary={
s.description ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{s.description}
</Typography>
) : null
}
sx={{ my: 0, pr: 4 }}
/>
</ListItem>
);
})}
</List>
</Box>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1.5 }}>
<Button size="small" onClick={handleReset} disabled={!dirty || saving}>
Reset
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={!dirty || saving || !data.known}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1 @@
export { default as AgentSubagents } from './ui/AgentSubagents';
@@ -0,0 +1,464 @@
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
FormControlLabel,
InputAdornment,
InputLabel,
List,
ListItem,
ListItemText,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { Search } from '@mui/icons-material';
import {
useGetAgentSubagentsQuery,
useUpdateAgentSubagentsMutation,
type AgentSubagentsPatch,
} from '../../../../entities/agent';
interface AgentSubagentsProps {
agentId: string;
}
const THINKING_OPTIONS: { value: string; label: string }[] = [
{ value: 'inherit', label: 'Inherit' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
];
const INHERIT_MODEL = '__inherit__';
function sameSet(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const s1 = [...a].sort();
const s2 = [...b].sort();
return s1.every((x, i) => x === s2[i]);
}
export default function AgentSubagents({ agentId }: AgentSubagentsProps) {
const { data, isLoading, isError, refetch } = useGetAgentSubagentsQuery(agentId, {
skip: !agentId,
});
const [update, { isLoading: saving, error: saveError }] = useUpdateAgentSubagentsMutation();
const [restrict, setRestrict] = useState(false);
const [allowed, setAllowed] = useState<Set<string>>(new Set());
const [model, setModel] = useState<string>(INHERIT_MODEL);
const [thinking, setThinking] = useState<string>('inherit');
const [requireAgentId, setRequireAgentId] = useState<boolean | null>(null);
const [query, setQuery] = useState('');
const [prevData, setPrevData] = useState(data);
if (data !== prevData) {
setPrevData(data);
if (data) {
if (data.config.allowAgents === null) {
setRestrict(false);
setAllowed(new Set());
} else {
setRestrict(true);
setAllowed(new Set(data.config.allowAgents));
}
setModel(data.config.model ?? INHERIT_MODEL);
setThinking(data.config.thinking ?? 'inherit');
setRequireAgentId(data.config.requireAgentId);
}
}
const filteredAgents = useMemo(() => {
const list = data?.availableAgents ?? [];
if (!query.trim()) return list;
const q = query.trim().toLowerCase();
return list.filter(
(a) => a.id.toLowerCase().includes(q) || (a.name ? a.name.toLowerCase().includes(q) : false)
);
}, [data, query]);
const patch = useMemo<AgentSubagentsPatch>(() => {
if (!data) return {};
const result: AgentSubagentsPatch = {};
const currentAllow = data.config.allowAgents;
const nextAllow = restrict ? [...allowed] : null;
const changedAllow =
currentAllow === null
? nextAllow !== null
: nextAllow === null || !sameSet(currentAllow, nextAllow);
if (changedAllow) result.allowAgents = nextAllow;
const currentModel = data.config.model;
const nextModel = model === INHERIT_MODEL ? null : model;
if ((currentModel ?? null) !== (nextModel ?? null)) result.model = nextModel;
const currentThinking = data.config.thinking;
const nextThinking = thinking === 'inherit' ? null : thinking;
if ((currentThinking ?? null) !== (nextThinking ?? null)) result.thinking = nextThinking;
const currentRequire = data.config.requireAgentId;
if ((currentRequire ?? null) !== (requireAgentId ?? null))
result.requireAgentId = requireAgentId;
return result;
}, [data, restrict, allowed, model, thinking, requireAgentId]);
const dirty = Object.keys(patch).length > 0;
function toggleAgent(id: string, on: boolean) {
setAllowed((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}
function enableAll() {
setAllowed(new Set((data?.availableAgents ?? []).map((a) => a.id)));
}
function disableAll() {
setAllowed(new Set());
}
async function handleSave() {
try {
await update({ agentId, patch }).unwrap();
} catch (err) {
console.error('Save subagents failed:', err);
}
}
function handleReset() {
if (!data) return;
if (data.config.allowAgents === null) {
setRestrict(false);
setAllowed(new Set());
} else {
setRestrict(true);
setAllowed(new Set(data.config.allowAgents));
}
setModel(data.config.model ?? INHERIT_MODEL);
setThinking(data.config.thinking ?? 'inherit');
setRequireAgentId(data.config.requireAgentId);
}
if (isLoading && !data) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} />
</Box>
);
}
if (isError || !data) {
return (
<Alert
severity="error"
action={
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
}
>
Could not load subagent configuration.
</Alert>
);
}
const rpcError =
saveError && typeof saveError === 'object' && 'data' in saveError
? ((saveError as { data?: { error?: string } }).data?.error ?? 'Failed to save.')
: null;
const listDisabled = !restrict || !data.known || saving;
const activeCount = allowed.size;
const totalCount = data.availableAgents.length;
return (
<Box sx={{ width: '100%', minWidth: 0 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={1}
sx={{ mb: 1 }}
>
<Box>
<Typography variant="subtitle2" fontWeight={700}>
Subagents
</Typography>
<Typography variant="caption" color="text.secondary">
Controls which child agents this one may spawn and what defaults those spawns use.
</Typography>
</Box>
{!data.known && (
<Chip
size="small"
color="warning"
variant="outlined"
label="Agent missing from openclaw config"
/>
)}
</Stack>
{rpcError && (
<Alert severity="error" sx={{ mb: 1 }}>
{rpcError}
</Alert>
)}
<Stack spacing={1.25}>
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.paper',
overflow: 'hidden',
}}
>
<Box sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<FormControlLabel
control={
<Switch
checked={restrict}
onChange={(_, v) => setRestrict(v)}
disabled={!data.known || saving}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600}>
Restrict to specific child agents
</Typography>
<Typography variant="caption" color="text.secondary">
When off, this agent can spawn any configured agent.
</Typography>
</Box>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1}
alignItems={{ xs: 'stretch', sm: 'center' }}
sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}
>
<TextField
size="small"
placeholder="Search agents…"
value={query}
onChange={(e) => setQuery(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search fontSize="small" />
</InputAdornment>
),
}}
/>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.7rem',
whiteSpace: 'nowrap',
px: { xs: 0, sm: 1 },
}}
>
{restrict ? `${activeCount} / ${totalCount} on` : 'unrestricted'}
</Typography>
<Stack direction="row" spacing={0.5}>
<Button size="small" onClick={enableAll} disabled={listDisabled}>
All
</Button>
<Button size="small" onClick={disableAll} disabled={listDisabled}>
None
</Button>
</Stack>
</Stack>
<List dense disablePadding sx={{ maxHeight: 420, overflow: 'auto' }}>
{filteredAgents.length === 0 && (
<ListItem>
<ListItemText
primary={
<Typography variant="body2" color="text.secondary">
{totalCount === 0
? 'No other agents are configured in openclaw.json.'
: 'No agents match.'}
</Typography>
}
/>
</ListItem>
)}
{filteredAgents.map((a) => {
const on = allowed.has(a.id);
return (
<ListItem
key={a.id}
divider
sx={{
alignItems: 'flex-start',
py: 1,
opacity: listDisabled ? 0.55 : 1,
}}
secondaryAction={
<Switch
edge="end"
size="small"
checked={on}
onChange={(_, v) => toggleAgent(a.id, v)}
disabled={listDisabled}
inputProps={{ 'aria-label': `Toggle ${a.id}` }}
/>
}
>
<ListItemText
primary={
<Typography variant="body2" fontWeight={600}>
{a.name ?? a.id}
</Typography>
}
secondary={
a.name ? (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.68rem',
}}
>
{a.id}
</Typography>
) : null
}
sx={{ my: 0, pr: 4 }}
/>
</ListItem>
);
})}
</List>
</Box>
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25} sx={{ mb: 0.5 }}>
<FormControl fullWidth size="small" disabled={!data.known || saving}>
<InputLabel id="subagent-model-label">Default model for subagents</InputLabel>
<Select
labelId="subagent-model-label"
label="Default model for subagents"
value={model}
onChange={(e) => setModel(String(e.target.value))}
>
<MenuItem value={INHERIT_MODEL}>Inherit</MenuItem>
{data.availableModels.length === 0 && (
<MenuItem value="" disabled>
No models configured
</MenuItem>
)}
{data.availableModels.map((m) => (
<MenuItem key={m.key} value={m.key}>
<Box
component="span"
sx={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.8rem',
}}
>
{m.key}
</Box>
{m.alias && (
<Box
component="span"
sx={{ color: 'text.secondary', fontSize: '0.72rem', ml: 1 }}
>
alias {m.alias}
</Box>
)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small" disabled={!data.known || saving}>
<InputLabel id="subagent-thinking-label">Thinking level</InputLabel>
<Select
labelId="subagent-thinking-label"
label="Thinking level"
value={thinking}
onChange={(e) => setThinking(String(e.target.value))}
>
{THINKING_OPTIONS.map((o) => (
<MenuItem key={o.value} value={o.value}>
{o.label}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<FormControlLabel
control={
<Switch
checked={requireAgentId === true}
onChange={(_, v) => setRequireAgentId(v ? true : null)}
disabled={!data.known || saving}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600}>
Require explicit agent id
</Typography>
<Typography variant="caption" color="text.secondary">
When on, subagent calls must specify a target agent rather than auto-resolving.
</Typography>
</Box>
}
sx={{ alignItems: 'flex-start', m: 0, mt: 0.5 }}
/>
</Box>
</Stack>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1.5 }}>
<Button size="small" onClick={handleReset} disabled={!dirty || saving}>
Reset
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={!dirty || saving || !data.known}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</Stack>
</Box>
);
}
@@ -13,10 +13,12 @@ export interface SendMessageState {
isStreaming: boolean;
streamingText: string;
streamingThinking: string;
streamError: string | null;
pendingUserText: string;
pendingFilesPreviews: MessageFile[];
send: (text: string, files: File[]) => Promise<void>;
abort: () => void;
clearError: () => void;
}
/**
@@ -30,6 +32,7 @@ export function useSendMessage({
}: UseSendMessageArgs): SendMessageState {
const [streamingText, setStreamingText] = useState('');
const [streamingThinking, setStreamingThinking] = useState('');
const [streamError, setStreamError] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [pendingUserText, setPendingUserText] = useState('');
const [pendingFilesPreviews, setPendingFilesPreviews] = useState<MessageFile[]>([]);
@@ -42,6 +45,8 @@ export function useSendMessage({
abortRef.current = null;
}, []);
const clearError = useCallback(() => setStreamError(null), []);
const send = useCallback(
async (text: string, files: File[]) => {
const trimmed = text.trim();
@@ -59,6 +64,7 @@ export function useSendMessage({
setPendingFilesPreviews(previews);
setStreamingText('');
setStreamingThinking('');
setStreamError(null);
setIsStreaming(true);
const token = localStorage.getItem('token');
@@ -80,6 +86,14 @@ export function useSendMessage({
if (!res.ok || !res.body) {
console.error('Chat request failed:', res.status);
let msg = `Chat request failed (${res.status}).`;
try {
const body = await res.json();
if (body?.error) msg = String(body.error);
} catch {
/* ignore */
}
setStreamError(msg);
return;
}
@@ -101,6 +115,8 @@ export function useSendMessage({
} else if (event.type === 'response.thinking.delta' && event.delta) {
accThinking += event.delta;
setStreamingThinking(accThinking);
} else if (event.type === 'response.error' && event.delta) {
setStreamError(String(event.delta));
}
} catch {
/* skip */
@@ -127,6 +143,7 @@ export function useSendMessage({
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
console.error('Stream error:', err);
setStreamError(err instanceof Error ? err.message : 'Network error while streaming.');
} finally {
setIsStreaming(false);
setStreamingText('');
@@ -146,9 +163,11 @@ export function useSendMessage({
isStreaming,
streamingText,
streamingThinking,
streamError,
pendingUserText,
pendingFilesPreviews,
send,
abort,
clearError,
};
}
+4
View File
@@ -78,6 +78,10 @@ export const baseApi = createApi({
'Cron',
'Plugin',
'Skill',
'AgentBudget',
'AgentModelConfig',
'AgentSkills',
'AgentSubagents',
],
endpoints: () => ({}),
});
+2
View File
@@ -11,12 +11,14 @@ export interface ChatState {
isStreaming: boolean;
streamingText: string;
streamingThinking: string;
streamError: string | null;
pendingUserText: string;
pendingFilesPreviews: MessageFile[];
send: (text: string, files: File[]) => Promise<void>;
loadMore: () => void;
handleScroll: () => void;
clearError: () => void;
scrollContainerRef: RefObject<HTMLDivElement | null>;
messagesEndRef: RefObject<HTMLDivElement | null>;
+5
View File
@@ -42,10 +42,12 @@ export function useChat(conversationId: string | undefined): ChatState {
isStreaming,
streamingText,
streamingThinking,
streamError,
pendingUserText,
pendingFilesPreviews,
send,
abort,
clearError,
} = useSendMessage({
conversationId,
refetch,
@@ -140,6 +142,7 @@ export function useChat(conversationId: string | undefined): ChatState {
if (prevConvId !== conversationId) {
setPrevConvId(conversationId);
abort();
clearError();
if (loadMoreCursor !== undefined) setLoadMoreCursor(undefined);
}
@@ -176,11 +179,13 @@ export function useChat(conversationId: string | undefined): ChatState {
isStreaming,
streamingText,
streamingThinking,
streamError,
pendingUserText,
pendingFilesPreviews,
send,
loadMore,
handleScroll,
clearError,
scrollContainerRef,
messagesEndRef,
};
+20 -8
View File
@@ -3,6 +3,7 @@ import { Box, TextField, IconButton, Typography, CircularProgress } from '@mui/m
import { Edit, Check, Settings, TuneOutlined } from '@mui/icons-material';
import { Link } from 'react-router';
import { useGetAgentQuery, useUpdateAgentMutation } from '../../../entities/agent';
import { AgentModelPicker } from '../../../features/agent/model-config';
interface ChatHeaderProps {
agentId: string;
@@ -98,19 +99,30 @@ export default function ChatHeader({
</>
) : (
<>
<Typography
variant="h6"
fontWeight={600}
<Box
sx={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
flexDirection: 'column',
gap: 0.1,
}}
>
{agent.name}
</Typography>
<Typography
variant="h6"
fontWeight={600}
sx={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.2,
}}
>
{agent.name}
</Typography>
<AgentModelPicker agentId={agentId} />
</Box>
<IconButton
size="small"
onClick={onToggleSessionSettings}
+26 -1
View File
@@ -1,4 +1,4 @@
import { Box, Typography, CircularProgress } from '@mui/material';
import { Alert, Box, Typography, CircularProgress } from '@mui/material';
import { MessageBubble } from '../../../entities/message';
import type { ChatState } from '../model/types';
@@ -15,6 +15,7 @@ export default function MessageList({ chat }: MessageListProps) {
isStreaming,
streamingText,
streamingThinking,
streamError,
pendingUserText,
pendingFilesPreviews,
loadMore,
@@ -22,6 +23,7 @@ export default function MessageList({ chat }: MessageListProps) {
scrollContainerRef,
messagesEndRef,
handleScroll,
clearError,
} = chat;
return (
@@ -37,6 +39,29 @@ export default function MessageList({ chat }: MessageListProps) {
py: 2,
}}
>
{streamError && (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 2,
mb: 1.5,
}}
>
<Alert
severity="error"
variant="filled"
onClose={clearError}
sx={{
alignItems: 'flex-start',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{streamError}
</Alert>
</Box>
)}
{isLoading && !loadMoreCursor ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
+73 -4
View File
@@ -1,19 +1,54 @@
import { useState, type ReactElement } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Box, IconButton, Typography, CircularProgress } from '@mui/material';
import { ArrowBack } from '@mui/icons-material';
import { Box, IconButton, Typography, CircularProgress, Tab, Tabs } from '@mui/material';
import { ArrowBack, Extension, FolderOpen, Group, Tune } from '@mui/icons-material';
import { useGetAgentQuery } from '../../../entities/agent';
import { AgentBudgets } from '../../../features/agent/budgets';
import { AgentSkills } from '../../../features/agent/skills';
import { AgentSubagents } from '../../../features/agent/subagents';
import WorkspaceFileTabs from './WorkspaceFileTabs';
interface WorkspaceProps {
agentId: string;
}
type SectionId = 'files' | 'budgets' | 'skills' | 'subagents';
const SECTIONS: { id: SectionId; label: string; icon: ReactElement; caption: string }[] = [
{
id: 'files',
label: 'Workspace',
icon: <FolderOpen sx={{ fontSize: 18 }} />,
caption: 'Workspace files',
},
{
id: 'budgets',
label: 'Budgets',
icon: <Tune sx={{ fontSize: 18 }} />,
caption: 'Per-agent character budgets',
},
{
id: 'skills',
label: 'Skills',
icon: <Extension sx={{ fontSize: 18 }} />,
caption: 'Per-agent skill allowlist',
},
{
id: 'subagents',
label: 'Subagents',
icon: <Group sx={{ fontSize: 18 }} />,
caption: 'Child-agent defaults',
},
];
export default function Workspace({ agentId }: WorkspaceProps) {
const [searchParams] = useSearchParams();
const returnConv = searchParams.get('return');
const { data: agent, isLoading } = useGetAgentQuery(agentId, { skip: !agentId });
const [section, setSection] = useState<SectionId>('files');
const backHref = returnConv ? `/agent/${agentId}/chat/${returnConv}` : '/';
const activeCaption = SECTIONS.find((s) => s.id === section)?.caption ?? '';
if (isLoading && !agent) {
return (
@@ -64,11 +99,42 @@ export default function Workspace({ agentId }: WorkspaceProps) {
{agent?.name ?? 'Agent'}
</Typography>
<Typography variant="caption" color="text.secondary">
Workspace files
{activeCaption}
</Typography>
</Box>
</Box>
<Box
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
px: { xs: 1, md: 2 },
}}
>
<Tabs
value={section}
onChange={(_, v: SectionId) => setSection(v)}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
minHeight: 40,
'& .MuiTab-root': {
minHeight: 40,
textTransform: 'none',
fontWeight: 600,
fontSize: '0.82rem',
px: 1.5,
},
}}
>
{SECTIONS.map((s) => (
<Tab key={s.id} value={s.id} iconPosition="start" icon={s.icon} label={s.label} />
))}
</Tabs>
</Box>
<Box
sx={{
flex: 1,
@@ -78,7 +144,10 @@ export default function Workspace({ agentId }: WorkspaceProps) {
py: 2,
}}
>
<WorkspaceFileTabs agentId={agentId} />
{section === 'files' && <WorkspaceFileTabs agentId={agentId} />}
{section === 'budgets' && agent?._id && <AgentBudgets agentId={String(agent._id)} />}
{section === 'skills' && agent?._id && <AgentSkills agentId={String(agent._id)} />}
{section === 'subagents' && agent?._id && <AgentSubagents agentId={String(agent._id)} />}
</Box>
</Box>
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-client",
"version": "2.4.3",
"version": "2.4.4",
"description": "Web-based chat interface for OpenClaw AI agents",
"private": true,
"type": "module",