From 2116b9cebd9d2caa96e91889cb22dd4795c7d3e0 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Mon, 31 Aug 2026 18:26:25 +0300 Subject: [PATCH] feat(rancher): create_agent and update_agent tools with knowledge binding (CLEAN-51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin agent promised agent creation and knowledge binding but had no shortcuts for either. The creation sequence (seed → skills → promote-before- deploy → deploy) moves from the controller into AgentDeployService.createAgent so the REST endpoint and the new tool share one implementation; update_agent replaces knowledgeIds wholesale and reminds about the restart. SOUL.md now prefers the shortcuts with http as fallback. Co-Authored-By: Claude Opus 4.7 --- .../slices/agent/agent/agent.controller.ts | 41 +----- .../slices/agent/agent/data/agent.mapper.ts | 3 +- .../agent/domain/agentDeploy.service.spec.ts | 119 +++++++++++++++++ .../agent/agent/domain/agentDeploy.service.ts | 45 ++++++- api/src/slices/agent/agent/dtos/agent.dto.ts | 2 +- .../agent/agent/dtos/agentMetrics.dto.ts | 5 +- api/src/slices/agent/agent/dtos/index.ts | 5 +- api/src/slices/rancher/rancher.tool.ts | 124 ++++++++++++++++++ rancher/.agent/SOUL.md | 32 +++-- 9 files changed, 315 insertions(+), 61 deletions(-) create mode 100644 api/src/slices/agent/agent/domain/agentDeploy.service.spec.ts diff --git a/api/src/slices/agent/agent/agent.controller.ts b/api/src/slices/agent/agent/agent.controller.ts index 09449075..a9275eb7 100644 --- a/api/src/slices/agent/agent/agent.controller.ts +++ b/api/src/slices/agent/agent/agent.controller.ts @@ -313,44 +313,9 @@ export class AgentController { @Roles(UserRoleTypes.Owner, UserRoleTypes.Admin) @ApiOperation({ summary: 'Create and deploy a new agent. Admin or Owner.' }) async create(@Body() dto: CreateAgentDto) { - const agent = await this.agentGateway.create(dto); - try { - const copied = await this.fileGateway.seedFromTemplate( - agent.id, - agent.templateId, - ); - if (copied > 0) { - this.logger.log( - `Seeded ${copied} files into agent ${agent.id} from template ${agent.templateId}`, - ); - } - } catch (err) { - this.logger.warn( - `Template seed skipped for agent ${agent.id}: ${(err as Error).message}`, - ); - } - await this.agentDeployService.syncSkillsFromTemplate( - agent.id, - agent.templateId, - ); - // Promote BEFORE the first deploy so the workflow boots the pod with - // RANCH_ADMIN=true on the first try — avoids the race of "create deploys - // non-admin → promote cancels + redeploys" where the cancel sometimes - // doesn't replace the running pod fast enough. - if (dto.isAdmin === true) { - const previous = await this.agentGateway.findAdmin(); - if (previous && previous.id !== agent.id) { - await this.agentGateway.setAdmin(previous.id, false); - await this.agentDeployService.detachAndCancelWorkflow( - previous.id, - previous.workflowId, - ); - await this.deploy(previous.id); - } - await this.agentGateway.setAdmin(agent.id, true); - } - await this.deploy(agent.id); - return this.agentGateway.findById(agent.id); + // Full sequence (seed → skills → promote-before-deploy → deploy) lives in + // AgentDeployService.createAgent, shared with the rancher create_agent tool. + return this.agentDeployService.createAgent(dto, { isAdmin: dto.isAdmin }); } @Put(':id') diff --git a/api/src/slices/agent/agent/data/agent.mapper.ts b/api/src/slices/agent/agent/data/agent.mapper.ts index a66eb440..c10aa0d1 100644 --- a/api/src/slices/agent/agent/data/agent.mapper.ts +++ b/api/src/slices/agent/agent/data/agent.mapper.ts @@ -15,8 +15,7 @@ export class AgentMapper { workflowId: record.workflowId, firstDeployedAt: record.firstDeployedAt, lastDeployStartedAt: record.lastDeployStartedAt, - launchContext: - record.lastLaunchContext as IAgentData['launchContext'], + launchContext: record.lastLaunchContext as IAgentData['launchContext'], lastPullAt: record.lastPullAt, lastSyncAt: record.lastSyncAt, config: record.config as unknown as Record, diff --git a/api/src/slices/agent/agent/domain/agentDeploy.service.spec.ts b/api/src/slices/agent/agent/domain/agentDeploy.service.spec.ts new file mode 100644 index 00000000..03570e47 --- /dev/null +++ b/api/src/slices/agent/agent/domain/agentDeploy.service.spec.ts @@ -0,0 +1,119 @@ +import { AgentDeployService } from './agentDeploy.service'; +import { IAgentGateway } from './agent.gateway'; +import { IAgentData } from './agent.types'; +import { IFileGateway } from '#/agent/file/domain'; + +const agent = (over: Partial = {}): IAgentData => + ({ + id: 'agent-new', + templateId: 'tpl-1', + workflowId: null, + isAdmin: false, + ...over, + }) as IAgentData; + +// Mocks live on a plain record (not behind the gateway abstract types) so +// expect(mocks.x) doesn't trip @typescript-eslint/unbound-method. +function build(over: { seedFromTemplate?: jest.Mock; findAdmin?: jest.Mock }) { + const mocks = { + create: jest.fn().mockResolvedValue(agent()), + findById: jest.fn().mockResolvedValue(agent()), + findAdmin: over.findAdmin ?? jest.fn().mockResolvedValue(null), + setAdmin: jest.fn().mockResolvedValue(agent()), + seedFromTemplate: over.seedFromTemplate ?? jest.fn().mockResolvedValue(3), + }; + const agents = { + create: mocks.create, + findById: mocks.findById, + findAdmin: mocks.findAdmin, + setAdmin: mocks.setAdmin, + } as unknown as IAgentGateway; + const files = { + seedFromTemplate: mocks.seedFromTemplate, + } as unknown as IFileGateway; + + const service = new AgentDeployService( + agents, + {} as never, // templateGateway — unused by createAgent + {} as never, // workflowService — reached only via mocked methods below + {} as never, // authService + files, + {} as never, // skillGateway + {} as never, // podGateway + {} as never, // deployTracker + ); + // createAgent orchestrates; the heavy submethods have their own flows. + const deploy = jest + .spyOn(service, 'deploy') + .mockResolvedValue(undefined as never); + const syncSkills = jest + .spyOn(service, 'syncSkillsFromTemplate') + .mockResolvedValue(undefined as never); + const detach = jest + .spyOn(service, 'detachAndCancelWorkflow') + .mockResolvedValue(undefined as never); + return { service, mocks, deploy, syncSkills, detach }; +} + +describe('AgentDeployService.createAgent', () => { + it('creates, seeds template files, syncs skills, deploys — in order', async () => { + const { service, mocks, deploy, syncSkills } = build({}); + const result = await service.createAgent({ + name: 'a', + templateId: 'tpl-1', + }); + + expect(mocks.create).toHaveBeenCalledWith({ + name: 'a', + templateId: 'tpl-1', + }); + expect(mocks.seedFromTemplate).toHaveBeenCalledWith('agent-new', 'tpl-1'); + expect(syncSkills).toHaveBeenCalledWith('agent-new', 'tpl-1'); + expect(deploy).toHaveBeenCalledWith('agent-new'); + expect(result).toEqual(agent()); + // seed must precede deploy — the pod pulls S3 at boot. + const seedOrder = mocks.seedFromTemplate.mock.invocationCallOrder[0]; + const deployOrder = deploy.mock.invocationCallOrder[0]; + expect(seedOrder).toBeLessThan(deployOrder); + }); + + it('survives a failing template seed (best-effort) and still deploys', async () => { + const { service, deploy } = build({ + seedFromTemplate: jest.fn().mockRejectedValue(new Error('s3 down')), + }); + await expect( + service.createAgent({ name: 'a', templateId: 'tpl-1' }), + ).resolves.toEqual(agent()); + expect(deploy).toHaveBeenCalledWith('agent-new'); + }); + + it('does not touch admin flags when isAdmin is not requested', async () => { + const { service, mocks } = build({}); + await service.createAgent({ name: 'a', templateId: 'tpl-1' }); + expect(mocks.findAdmin).not.toHaveBeenCalled(); + expect(mocks.setAdmin).not.toHaveBeenCalled(); + }); + + it('promotes BEFORE the first deploy and demotes+redeploys the previous admin', async () => { + const previous = agent({ id: 'agent-old', workflowId: 'wf-9' }); + const { service, mocks, deploy, detach } = build({ + findAdmin: jest.fn().mockResolvedValue(previous), + }); + await service.createAgent( + { name: 'a', templateId: 'tpl-1' }, + { isAdmin: true }, + ); + + expect(mocks.setAdmin).toHaveBeenCalledWith('agent-old', false); + expect(detach).toHaveBeenCalledWith('agent-old', 'wf-9'); + expect(deploy).toHaveBeenCalledWith('agent-old'); + expect(mocks.setAdmin).toHaveBeenCalledWith('agent-new', true); + // Promote-before-deploy invariant: the new agent's first deploy must see + // RANCH_ADMIN=true (no promote-then-redeploy race). + const promoteOrder = mocks.setAdmin.mock.invocationCallOrder[1]; + const newDeployOrder = deploy.mock.invocationCallOrder.find( + (_, i) => deploy.mock.calls[i][0] === 'agent-new', + ); + expect(promoteOrder).toBeLessThan(newDeployOrder as number); + }); +}); diff --git a/api/src/slices/agent/agent/domain/agentDeploy.service.ts b/api/src/slices/agent/agent/domain/agentDeploy.service.ts index 0194120f..168656d4 100644 --- a/api/src/slices/agent/agent/domain/agentDeploy.service.ts +++ b/api/src/slices/agent/agent/domain/agentDeploy.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { IAgentGateway } from './agent.gateway'; +import { IAgentData, ICreateAgentData } from './agent.types'; import { IFileGateway } from '#/agent/file/domain'; import { ITemplateGateway } from '#/agent/template/domain'; import { WorkflowService } from '#/workflow/domain/workflow.service'; @@ -58,6 +59,47 @@ export class AgentDeployService { await this.deploy(agentId); } + // Full creation sequence, shared by the REST controller and the rancher + // create_agent tool: DB row → template file seed (best-effort) → skills + // sync → optional admin promotion → first deploy. Promotion happens BEFORE + // the first deploy so the workflow boots the pod with RANCH_ADMIN=true on + // the first try — avoids the "create deploys non-admin → promote cancels + + // redeploys" race where the cancel sometimes doesn't replace the running + // pod fast enough. + async createAgent( + input: ICreateAgentData, + opts: { isAdmin?: boolean } = {}, + ): Promise { + const agent = await this.agentGateway.create(input); + try { + const copied = await this.fileGateway.seedFromTemplate( + agent.id, + agent.templateId, + ); + if (copied > 0) { + this.logger.log( + `Seeded ${copied} files into agent ${agent.id} from template ${agent.templateId}`, + ); + } + } catch (err) { + this.logger.warn( + `Template seed skipped for agent ${agent.id}: ${(err as Error).message}`, + ); + } + await this.syncSkillsFromTemplate(agent.id, agent.templateId); + if (opts.isAdmin === true) { + const previous = await this.agentGateway.findAdmin(); + if (previous && previous.id !== agent.id) { + await this.agentGateway.setAdmin(previous.id, false); + await this.detachAndCancelWorkflow(previous.id, previous.workflowId); + await this.deploy(previous.id); + } + await this.agentGateway.setAdmin(agent.id, true); + } + await this.deploy(agent.id); + return this.agentGateway.findById(agent.id); + } + // Detach the workflow id from the agent row BEFORE cancelling it. A // concurrent GET /agents/:id (syncStatus) polls the workflow referenced by // the DB row; without this ordering it can catch the just-cancelled @@ -136,7 +178,8 @@ export class AgentDeployService { this.deployTracker.mark(agentId); // 'initial' iff this agent has never been deployed — persisted so the UI // can tell a first start from a restart even after a page reload. - const launchContext = agent.firstDeployedAt === null ? 'initial' : 'restart'; + const launchContext = + agent.firstDeployedAt === null ? 'initial' : 'restart'; // Mark deploying BEFORE submitting the workflow. Submit + getStatus take // seconds — long enough for the pod to come up and AgentStatusService to // flip status to 'running'. If we wrote status here after submit we'd diff --git a/api/src/slices/agent/agent/dtos/agent.dto.ts b/api/src/slices/agent/agent/dtos/agent.dto.ts index 987c3c46..9db6c89f 100644 --- a/api/src/slices/agent/agent/dtos/agent.dto.ts +++ b/api/src/slices/agent/agent/dtos/agent.dto.ts @@ -22,7 +22,7 @@ export class AgentDto { nullable: true, type: String, description: - "Human-readable reason accompanying status='failed' (e.g. \"startup did not produce a running agent within 5 minutes\", \"ImagePullBackOff\"). Null for all other statuses and for failures recorded before this field existed.", + 'Human-readable reason accompanying status=\'failed\' (e.g. "startup did not produce a running agent within 5 minutes", "ImagePullBackOff"). Null for all other statuses and for failures recorded before this field existed.', }) statusReason: string | null; diff --git a/api/src/slices/agent/agent/dtos/agentMetrics.dto.ts b/api/src/slices/agent/agent/dtos/agentMetrics.dto.ts index 785113ba..b4082596 100644 --- a/api/src/slices/agent/agent/dtos/agentMetrics.dto.ts +++ b/api/src/slices/agent/agent/dtos/agentMetrics.dto.ts @@ -4,7 +4,10 @@ export class AgentPodMetricsDto { @ApiProperty({ example: 234, description: 'Current CPU usage in millicores' }) cpuMilli: number; - @ApiProperty({ example: 471859200, description: 'Current memory usage in bytes' }) + @ApiProperty({ + example: 471859200, + description: 'Current memory usage in bytes', + }) memBytes: number; @ApiProperty({ example: 2000, description: 'CPU limit in millicores' }) diff --git a/api/src/slices/agent/agent/dtos/index.ts b/api/src/slices/agent/agent/dtos/index.ts index 84dc6bbe..818394a2 100644 --- a/api/src/slices/agent/agent/dtos/index.ts +++ b/api/src/slices/agent/agent/dtos/index.ts @@ -9,7 +9,4 @@ export { AgentNodeMetricsDto, AgentPodMetricsDto, } from './agentMetrics.dto'; -export { - ClusterCapacityDto, - NodeCapacityDto, -} from './clusterCapacity.dto'; +export { ClusterCapacityDto, NodeCapacityDto } from './clusterCapacity.dto'; diff --git a/api/src/slices/rancher/rancher.tool.ts b/api/src/slices/rancher/rancher.tool.ts index 08d45aee..df800680 100644 --- a/api/src/slices/rancher/rancher.tool.ts +++ b/api/src/slices/rancher/rancher.tool.ts @@ -116,6 +116,130 @@ export class RancherTool { }); } + @Tool({ + name: 'create_agent', + description: + 'Create and deploy a new agent from a template. Seeds template files, ' + + 'syncs skills and starts the first deploy — the agent will appear as ' + + '"deploying" and boot shortly. Optionally bind knowledge bases right ' + + 'away via knowledgeIds.', + parameters: z.object({ + name: z.string().describe('Human-readable agent name'), + templateId: z.string().describe('Template id — pick from list_templates'), + llmCredentialId: z + .string() + .optional() + .describe('LLM credential id — pick from list_llms'), + knowledgeIds: z + .array(z.string()) + .optional() + .describe('Knowledge base ids to bind (GET /knowledges for the list)'), + isAdmin: z + .boolean() + .optional() + .describe( + 'Promote to Ranch admin (single-admin invariant: demotes the current one)', + ), + }), + }) + async createAgent( + { + name, + templateId, + llmCredentialId, + knowledgeIds, + isAdmin, + }: { + name: string; + templateId: string; + llmCredentialId?: string; + knowledgeIds?: string[]; + isAdmin?: boolean; + }, + _context: unknown, + httpRequest: Request & { user?: IAuthTokenPayload }, + ) { + this.requireOwner(httpRequest); + // Friendly precheck — a bad templateId would otherwise surface as an + // opaque foreign-key error from the DB layer. + const template = await this.templates.findById(templateId); + if (!template) { + return ok({ + error: `Template ${templateId} not found — pick one from list_templates`, + }); + } + const created = await this.agentDeploy.createAgent( + { name, templateId, llmCredentialId, knowledgeIds }, + { isAdmin }, + ); + return ok({ + ok: true, + agent: created, + message: + 'Agent created — first deploy started, it will boot shortly. ' + + 'Knowledge bindings (if any) are baked into this deploy.', + }); + } + + @Tool({ + name: 'update_agent', + description: + 'Update an agent: rename, switch LLM credential, or bind knowledge ' + + 'bases. knowledgeIds REPLACES the full list (fetch current via ' + + 'get_agent first). Binding/credential changes apply on the next ' + + 'restart — offer restart_agent.', + parameters: z.object({ + id: z.string(), + name: z.string().optional(), + llmCredentialId: z + .string() + .nullable() + .optional() + .describe('LLM credential id; null detaches the credential'), + knowledgeIds: z + .array(z.string()) + .optional() + .describe( + 'FULL desired list of knowledge base ids (replaces, not appends)', + ), + }), + }) + async updateAgent( + { + id, + name, + llmCredentialId, + knowledgeIds, + }: { + id: string; + name?: string; + llmCredentialId?: string | null; + knowledgeIds?: string[]; + }, + _context: unknown, + httpRequest: Request & { user?: IAuthTokenPayload }, + ) { + this.requireOwner(httpRequest); + const existing = await this.agents.findById(id); + if (!existing) return ok({ error: `Agent ${id} not found` }); + const updated = await this.agents.update(id, { + name, + llmCredentialId, + knowledgeIds, + }); + const needsRestart = + knowledgeIds !== undefined || llmCredentialId !== undefined; + return ok({ + ok: true, + agent: updated, + ...(needsRestart && { + notice: + 'Saved. Knowledge/credential changes apply on the next start — ' + + 'a restart is required. Tell the user and offer restart_agent.', + }), + }); + } + @Tool({ name: 'set_agent_admin', description: diff --git a/rancher/.agent/SOUL.md b/rancher/.agent/SOUL.md index e9a3b102..eb82a717 100644 --- a/rancher/.agent/SOUL.md +++ b/rancher/.agent/SOUL.md @@ -43,7 +43,8 @@ If `RANCH_API_TOKEN` is unset → tell the operator to redeploy you with `isAdmi ### Optional shortcuts: `ranch_*` MCP tools When deployed with the Ranch MCP server attached, a set of `ranch_*` tools -appears (`list_agents`, `get_agent`, `restart_agent`, `set_agent_admin`, +appears (`list_agents`, `get_agent`, `create_agent`, `update_agent`, +`restart_agent`, `set_agent_admin`, `list_templates`, `get_template`, `set_template_skills`, `list_skills`, `update_skill`, `list_skill_agents`, `redeploy_skill_agents`, `list_llms`, `list_agent_files`, `read_agent_file`, `write_agent_file`, @@ -160,21 +161,24 @@ LLM credential, not a transient blip. ## Creating Agents & Binding Knowledge -There are **no `ranch_*` shortcuts** for creating an agent or binding -knowledge bases (`create_agent`, `update_agent` do not exist as tools). That -is NOT inability — both are one `http` call: - -1. Create: `POST /agents` body `{ name, templateId, llmCredentialId?, knowledgeIds? }`. - Pick `templateId` from `list_templates`, `llmCredentialId` from `list_llms`, - knowledge ids from GET `/knowledges`. -2. Bind knowledge to an existing agent: `PUT /agents/{id}` body - `{ knowledgeIds: [...] }` — send the FULL desired list (it replaces, not - appends: fetch current ids via GET `/agents/{id}` first). -3. Binding applies on the next (re)start — offer `restart_agent`. +Preferred: the `create_agent` / `update_agent` shortcuts (when visible in +your tool list). Fallback: the same operations are one `http` call each. + +1. Create: `create_agent({ name, templateId, llmCredentialId?, knowledgeIds?, isAdmin? })` + — seeds template files, syncs skills, starts the first deploy. HTTP: + `POST /agents` with the same body. Pick `templateId` from + `list_templates`, `llmCredentialId` from `list_llms`, knowledge ids from + GET `/knowledges`. +2. Bind knowledge to an existing agent: + `update_agent({ id, knowledgeIds: [...] })` — send the FULL desired list + (it replaces, not appends: fetch current ids via `get_agent` first). + HTTP: `PUT /agents/{id}` body `{ knowledgeIds: [...] }`. +3. Binding/credential changes apply on the next (re)start — offer + `restart_agent` (a fresh `create_agent` already deploys with the bindings). **Never narrate these without the call.** "Соберу агента и привяжу базу -знаний" followed by no `http` POST/PUT is a lie under Core Principle #6. Do -the calls in the same turn, or say plainly you are not doing it and point the +знаний" followed by no tool call is a lie under Core Principle #6. Do the +calls in the same turn, or say plainly you are not doing it and point the operator to the admin UI (Agents → New / Agent → Knowledge). ---