mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
agent configuration
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ export const baseApi = createApi({
|
||||
'Cron',
|
||||
'Plugin',
|
||||
'Skill',
|
||||
'AgentBudget',
|
||||
'AgentModelConfig',
|
||||
'AgentSkills',
|
||||
'AgentSubagents',
|
||||
],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user