diff --git a/api/src/@types/openclaw.ts b/api/src/@types/openclaw.ts index 073829c..81e8c15 100644 --- a/api/src/@types/openclaw.ts +++ b/api/src/@types/openclaw.ts @@ -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; + 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>; + +// ── 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 }; diff --git a/api/src/routes/agent/controller.ts b/api/src/routes/agent/controller.ts index 2019b85..3267cf4 100644 --- a/api/src/routes/agent/controller.ts +++ b/api/src/routes/agent/controller.ts @@ -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, }; diff --git a/api/src/routes/agent/doc.yaml b/api/src/routes/agent/doc.yaml index 1be2a43..1af484f 100644 --- a/api/src/routes/agent/doc.yaml +++ b/api/src/routes/agent/doc.yaml @@ -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' diff --git a/api/src/routes/agent/index.ts b/api/src/routes/agent/index.ts index f751925..389c1e5 100644 --- a/api/src/routes/agent/index.ts +++ b/api/src/routes/agent/index.ts @@ -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; diff --git a/api/src/routes/agent/validation.ts b/api/src/routes/agent/validation.ts index 1f90d23..eae3928 100644 --- a/api/src/routes/agent/validation.ts +++ b/api/src/routes/agent/validation.ts @@ -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; + 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; + }), + ]), }; diff --git a/api/src/services/openclaw/agentModel.ts b/api/src/services/openclaw/agentModel.ts new file mode 100644 index 0000000..c1a2644 --- /dev/null +++ b/api/src/services/openclaw/agentModel.ts @@ -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) }; +} diff --git a/api/src/services/openclaw/agentSkills.ts b/api/src/services/openclaw/agentSkills.ts new file mode 100644 index 0000000..d8e5aa0 --- /dev/null +++ b/api/src/services/openclaw/agentSkills.ts @@ -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) }; +} diff --git a/api/src/services/openclaw/agentSubagents.ts b/api/src/services/openclaw/agentSubagents.ts new file mode 100644 index 0000000..e005e2a --- /dev/null +++ b/api/src/services/openclaw/agentSubagents.ts @@ -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(['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 = { + 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) }; +} diff --git a/api/src/services/openclaw/budget.ts b/api/src/services/openclaw/budget.ts new file mode 100644 index 0000000..9efeb03 --- /dev/null +++ b/api/src/services/openclaw/budget.ts @@ -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; skillsLimits?: Record } + | undefined; + const entry = agentIndex >= 0 ? list[agentIndex] : undefined; + const entryBucket = entry?.[spec.bucket] as Record | 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) }; +} diff --git a/api/src/services/openclaw/chat.ts b/api/src/services/openclaw/chat.ts index 0b5abaa..26279cd 100644 --- a/api/src/services/openclaw/chat.ts +++ b/api/src/services/openclaw/chat.ts @@ -13,83 +13,6 @@ function isAgentEvent(msg: GwInboundMessage): msg is GwEventMessage { - 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 = { - 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 = { + 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, diff --git a/api/src/services/openclaw/index.ts b/api/src/services/openclaw/index.ts index c53327d..c747f95 100644 --- a/api/src/services/openclaw/index.ts +++ b/api/src/services/openclaw/index.ts @@ -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'; diff --git a/api/src/services/openclaw/sseEmitter.ts b/api/src/services/openclaw/sseEmitter.ts index f6a5d2e..0a12649 100644 --- a/api/src/services/openclaw/sseEmitter.ts +++ b/api/src/services/openclaw/sseEmitter.ts @@ -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(); }, }; } diff --git a/client/src/entities/agent/api.ts b/client/src/entities/agent/api.ts index 750d130..55a66f2 100644 --- a/client/src/entities/agent/api.ts +++ b/client/src/entities/agent/api.ts @@ -46,6 +46,109 @@ export interface SessionSettingsResponse { settings: Partial; } +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>; + +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({ + 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({ + 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({ + 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({ + 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({ + 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; diff --git a/client/src/features/agent/budgets/index.ts b/client/src/features/agent/budgets/index.ts new file mode 100644 index 0000000..28beea7 --- /dev/null +++ b/client/src/features/agent/budgets/index.ts @@ -0,0 +1 @@ +export { default as AgentBudgets } from './ui/AgentBudgets'; diff --git a/client/src/features/agent/budgets/ui/AgentBudgets.tsx b/client/src/features/agent/budgets/ui/AgentBudgets.tsx new file mode 100644 index 0000000..009181c --- /dev/null +++ b/client/src/features/agent/budgets/ui/AgentBudgets.tsx @@ -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>; + +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({}); + const [fieldErrors, setFieldErrors] = useState>>({}); + // 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(() => { + 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> = {}; + 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 ( + + + + ); + } + + if (isError || !data) { + return ( + refetch()}> + Retry + + } + > + Could not load agent budgets. + + ); + } + + const rpcError = + saveError && typeof saveError === 'object' && 'data' in saveError + ? ((saveError as { data?: { error?: string } }).data?.error ?? 'Failed to save budgets.') + : null; + + return ( + + + + + Per-agent budgets + + + Character budgets that bound injected context and tool output for this agent. Leave a + field empty to inherit the system default. + + + {!data.known && ( + + )} + + + {rpcError && ( + + {rpcError} + + )} + + + {data.fields.map((field) => { + const draft = drafts[field.key] ?? ''; + const hasOverride = draft.trim() !== ''; + const err = fieldErrors[field.key]; + return ( + + + + + + {field.label} + + + {field.effective != null && ( + <> + + {field.effective.toLocaleString()} + + + · + + + )} + {field.default != null && ( + <> + default {field.default.toLocaleString()} + + · + + + )} + range {field.min.toLocaleString()} + {field.max < Number.MAX_SAFE_INTEGER ? `–${field.max.toLocaleString()}` : '+'} + + + + {field.description} + + + + { + 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 ? ( + + + handleResetField(field.key)} + aria-label={`Reset ${field.key} to default`} + > + + + + + ) : 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 } }} + /> + + + ); + })} + + + + + + + + ); +} diff --git a/client/src/features/agent/model-config/index.ts b/client/src/features/agent/model-config/index.ts new file mode 100644 index 0000000..02c2c5a --- /dev/null +++ b/client/src/features/agent/model-config/index.ts @@ -0,0 +1,2 @@ +export { default as AgentModelConfig } from './ui/AgentModelConfig'; +export { default as AgentModelPicker } from './ui/AgentModelPicker'; diff --git a/client/src/features/agent/model-config/ui/AgentModelConfig.tsx b/client/src/features/agent/model-config/ui/AgentModelConfig.tsx new file mode 100644 index 0000000..db929f0 --- /dev/null +++ b/client/src/features/agent/model-config/ui/AgentModelConfig.tsx @@ -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(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 ( + + + + ); + } + + if (isError || !data) { + return ( + refetch()}> + Retry + + } + > + Could not load agent model configuration. + + ); + } + + 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 ( + + + + + Model + + + Override the primary model for this agent. Leave as "Inherit default" to use the system + default. + + + {!data.known && ( + + )} + + + {rpcError && ( + + {rpcError} + + )} + + + + + Primary model + + + + + + effective {data.effective ?? '—'} + + + · + + system default {systemDefaultLabel} + + + + + + + + + + ); +} diff --git a/client/src/features/agent/model-config/ui/AgentModelPicker.tsx b/client/src/features/agent/model-config/ui/AgentModelPicker.tsx new file mode 100644 index 0000000..ff1cd1f --- /dev/null +++ b/client/src/features/agent/model-config/ui/AgentModelPicker.tsx @@ -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(null); + + if (isLoading && !data) { + return ; + } + 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 ( + <> + + + 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, + }, + }} + > + + {label} + + {!override && effective && ( + + (default) + + )} + {saving ? ( + + ) : ( + + )} + + + + + setAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: { sx: { minWidth: 260, maxWidth: 360 } } }} + > + handlePick(null)}> + + + {override === null && } + + + Inherit default + {data.systemDefault && ( + + {data.systemDefault} + + )} + + + + + {data.available.length === 0 && ( + + + No models configured. Run openclaw models. + + + )} + {data.available.map((m) => { + const isActive = override === m.key; + return ( + handlePick(m.key)}> + + + {isActive && } + + + + {m.key} + + {m.alias && ( + + alias {m.alias} + + )} + + + + ); + })} + + + ); +} diff --git a/client/src/features/agent/skills/index.ts b/client/src/features/agent/skills/index.ts new file mode 100644 index 0000000..4d791a5 --- /dev/null +++ b/client/src/features/agent/skills/index.ts @@ -0,0 +1 @@ +export { default as AgentSkills } from './ui/AgentSkills'; diff --git a/client/src/features/agent/skills/ui/AgentSkills.tsx b/client/src/features/agent/skills/ui/AgentSkills.tsx new file mode 100644 index 0000000..bb2bf80 --- /dev/null +++ b/client/src/features/agent/skills/ui/AgentSkills.tsx @@ -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>(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 ( + + + + ); + } + + if (isError || !data) { + return ( + refetch()}> + Retry + + } + > + Could not load agent skills. + + ); + } + + 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 ( + + + + + Skills allowlist + + + Pick which skills this agent is allowed to use. Inherit to follow system defaults. An + explicit list replaces the defaults rather than merging. + + + {!data.known && ( + + )} + + + {rpcError && ( + + {rpcError} + + )} + + + + setInherit(v)} + disabled={!data.known || saving} + /> + } + label={ + + + Inherit defaults + + + When off, only the skills you toggle on below are allowed. + + + } + sx={{ alignItems: 'flex-start', m: 0 }} + /> + + + + setQuery(e.target.value)} + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {inherit ? 'inherit' : `${activeCount} / ${totalCount} on`} + + + + + + + + + {filtered.length === 0 && ( + + + No skills match. + + } + /> + + )} + {filtered.map((s) => { + const on = enabled.has(s.name); + return ( + toggleSkill(s.name, v)} + disabled={disabled} + inputProps={{ 'aria-label': `Toggle ${s.name}` }} + /> + } + > + + {skillEmoji(s)} + + + + {s.name} + + {!s.eligible && ( + + + + )} + {s.bundled && ( + + )} + + } + secondary={ + s.description ? ( + + {s.description} + + ) : null + } + sx={{ my: 0, pr: 4 }} + /> + + ); + })} + + + + + + + + + ); +} diff --git a/client/src/features/agent/subagents/index.ts b/client/src/features/agent/subagents/index.ts new file mode 100644 index 0000000..586cc66 --- /dev/null +++ b/client/src/features/agent/subagents/index.ts @@ -0,0 +1 @@ +export { default as AgentSubagents } from './ui/AgentSubagents'; diff --git a/client/src/features/agent/subagents/ui/AgentSubagents.tsx b/client/src/features/agent/subagents/ui/AgentSubagents.tsx new file mode 100644 index 0000000..5dc1442 --- /dev/null +++ b/client/src/features/agent/subagents/ui/AgentSubagents.tsx @@ -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>(new Set()); + const [model, setModel] = useState(INHERIT_MODEL); + const [thinking, setThinking] = useState('inherit'); + const [requireAgentId, setRequireAgentId] = useState(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(() => { + 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 ( + + + + ); + } + + if (isError || !data) { + return ( + refetch()}> + Retry + + } + > + Could not load subagent configuration. + + ); + } + + 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 ( + + + + + Subagents + + + Controls which child agents this one may spawn and what defaults those spawns use. + + + {!data.known && ( + + )} + + + {rpcError && ( + + {rpcError} + + )} + + + + + setRestrict(v)} + disabled={!data.known || saving} + /> + } + label={ + + + Restrict to specific child agents + + + When off, this agent can spawn any configured agent. + + + } + sx={{ alignItems: 'flex-start', m: 0 }} + /> + + + + setQuery(e.target.value)} + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {restrict ? `${activeCount} / ${totalCount} on` : 'unrestricted'} + + + + + + + + + {filteredAgents.length === 0 && ( + + + {totalCount === 0 + ? 'No other agents are configured in openclaw.json.' + : 'No agents match.'} + + } + /> + + )} + {filteredAgents.map((a) => { + const on = allowed.has(a.id); + return ( + toggleAgent(a.id, v)} + disabled={listDisabled} + inputProps={{ 'aria-label': `Toggle ${a.id}` }} + /> + } + > + + {a.name ?? a.id} + + } + secondary={ + a.name ? ( + + {a.id} + + ) : null + } + sx={{ my: 0, pr: 4 }} + /> + + ); + })} + + + + + + + Default model for subagents + + + + + Thinking level + + + + + setRequireAgentId(v ? true : null)} + disabled={!data.known || saving} + /> + } + label={ + + + Require explicit agent id + + + When on, subagent calls must specify a target agent rather than auto-resolving. + + + } + sx={{ alignItems: 'flex-start', m: 0, mt: 0.5 }} + /> + + + + + + + + + ); +} diff --git a/client/src/features/message/send/model/useSendMessage.ts b/client/src/features/message/send/model/useSendMessage.ts index 78cf199..2f29390 100644 --- a/client/src/features/message/send/model/useSendMessage.ts +++ b/client/src/features/message/send/model/useSendMessage.ts @@ -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; 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(null); const [isStreaming, setIsStreaming] = useState(false); const [pendingUserText, setPendingUserText] = useState(''); const [pendingFilesPreviews, setPendingFilesPreviews] = useState([]); @@ -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, }; } diff --git a/client/src/shared/api/baseApi.ts b/client/src/shared/api/baseApi.ts index 423ad9e..7377bc6 100644 --- a/client/src/shared/api/baseApi.ts +++ b/client/src/shared/api/baseApi.ts @@ -78,6 +78,10 @@ export const baseApi = createApi({ 'Cron', 'Plugin', 'Skill', + 'AgentBudget', + 'AgentModelConfig', + 'AgentSkills', + 'AgentSubagents', ], endpoints: () => ({}), }); diff --git a/client/src/widgets/chat/model/types.ts b/client/src/widgets/chat/model/types.ts index 9f78fd1..8fe340c 100644 --- a/client/src/widgets/chat/model/types.ts +++ b/client/src/widgets/chat/model/types.ts @@ -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; loadMore: () => void; handleScroll: () => void; + clearError: () => void; scrollContainerRef: RefObject; messagesEndRef: RefObject; diff --git a/client/src/widgets/chat/model/useChat.ts b/client/src/widgets/chat/model/useChat.ts index a35a6ad..c812db2 100644 --- a/client/src/widgets/chat/model/useChat.ts +++ b/client/src/widgets/chat/model/useChat.ts @@ -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, }; diff --git a/client/src/widgets/chat/ui/ChatHeader.tsx b/client/src/widgets/chat/ui/ChatHeader.tsx index 5ed3e1f..a5f58ec 100644 --- a/client/src/widgets/chat/ui/ChatHeader.tsx +++ b/client/src/widgets/chat/ui/ChatHeader.tsx @@ -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({ ) : ( <> - - {agent.name} - + + {agent.name} + + + + {streamError && ( + + + {streamError} + + + )} {isLoading && !loadMoreCursor ? ( diff --git a/client/src/widgets/workspace/ui/Workspace.tsx b/client/src/widgets/workspace/ui/Workspace.tsx index 6150505..a73404f 100644 --- a/client/src/widgets/workspace/ui/Workspace.tsx +++ b/client/src/widgets/workspace/ui/Workspace.tsx @@ -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: , + caption: 'Workspace files', + }, + { + id: 'budgets', + label: 'Budgets', + icon: , + caption: 'Per-agent character budgets', + }, + { + id: 'skills', + label: 'Skills', + icon: , + caption: 'Per-agent skill allowlist', + }, + { + id: 'subagents', + label: 'Subagents', + icon: , + 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('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'} - Workspace files + {activeCaption} + + 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) => ( + + ))} + + + - + {section === 'files' && } + {section === 'budgets' && agent?._id && } + {section === 'skills' && agent?._id && } + {section === 'subagents' && agent?._id && } ); diff --git a/package.json b/package.json index b76a281..7ca9044 100644 --- a/package.json +++ b/package.json @@ -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",