From 3bb401641e2694ef0d11482de0bef505d5291d18 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 18:36:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(agent):=20skill=20invocation=20=E2=80=94?= =?UTF-8?q?=20load=20and=20execute=20skills=20from=20catalog=20(#128)=20(#?= =?UTF-8?q?143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- apps/gateway/src/agent/agent.module.ts | 8 +- apps/gateway/src/agent/agent.service.ts | 19 +- .../gateway/src/agent/skill-loader.service.ts | 59 ++++++ apps/gateway/src/agent/tools/index.ts | 1 + apps/gateway/src/agent/tools/skill-tools.ts | 180 ++++++++++++++++++ 5 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 apps/gateway/src/agent/skill-loader.service.ts create mode 100644 apps/gateway/src/agent/tools/skill-tools.ts diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 867ce42..1fc8d73 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -2,16 +2,18 @@ import { Global, Module } from '@nestjs/common'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { RoutingService } from './routing.service.js'; +import { SkillLoaderService } from './skill-loader.service.js'; import { ProvidersController } from './providers.controller.js'; import { SessionsController } from './sessions.controller.js'; import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; +import { SkillsModule } from '../skills/skills.module.js'; @Global() @Module({ - imports: [CoordModule, McpClientModule], - providers: [ProviderService, RoutingService, AgentService], + imports: [CoordModule, McpClientModule, SkillsModule], + providers: [ProviderService, RoutingService, SkillLoaderService, AgentService], controllers: [ProvidersController, SessionsController], - exports: [AgentService, ProviderService, RoutingService], + exports: [AgentService, ProviderService, RoutingService, SkillLoaderService], }) export class AgentModule {} diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 85852ed..bb18453 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -14,6 +14,7 @@ import { EmbeddingService } from '../memory/embedding.service.js'; import { CoordService } from '../coord/coord.service.js'; import { ProviderService } from './provider.service.js'; import { McpClientService } from '../mcp-client/mcp-client.service.js'; +import { SkillLoaderService } from './skill-loader.service.js'; import { createBrainTools } from './tools/brain-tools.js'; import { createCoordTools } from './tools/coord-tools.js'; import { createMemoryTools } from './tools/memory-tools.js'; @@ -38,6 +39,8 @@ export interface AgentSession { createdAt: number; promptCount: number; channels: Set; + /** System prompt additions injected from enabled prompt-type skills. */ + skillPromptAdditions: string[]; } @Injectable() @@ -55,6 +58,7 @@ export class AgentService implements OnModuleDestroy { @Inject(EmbeddingService) private readonly embeddingService: EmbeddingService, @Inject(CoordService) private readonly coordService: CoordService, @Inject(McpClientService) private readonly mcpClientService: McpClientService, + @Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService, ) { const fileBaseDir = process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd(); const gitDefaultCwd = process.env['AGENT_GIT_CWD'] ?? process.cwd(); @@ -98,9 +102,21 @@ export class AgentService implements OnModuleDestroy { `Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`, ); + // Load skill tools from the catalog + const { metaTools: skillMetaTools, promptAdditions } = + await this.skillLoaderService.loadForSession(); + if (skillMetaTools.length > 0) { + this.logger.log(`Attaching ${skillMetaTools.length} skill tool(s) to session ${sessionId}`); + } + if (promptAdditions.length > 0) { + this.logger.log( + `Injecting ${promptAdditions.length} skill prompt addition(s) into session ${sessionId}`, + ); + } + // Combine static tools with dynamically discovered MCP client tools const mcpTools = this.mcpClientService.getToolDefinitions(); - const allCustomTools = [...this.customTools, ...mcpTools]; + const allCustomTools = [...this.customTools, ...skillMetaTools, ...mcpTools]; if (mcpTools.length > 0) { this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`); } @@ -145,6 +161,7 @@ export class AgentService implements OnModuleDestroy { createdAt: Date.now(), promptCount: 0, channels: new Set(), + skillPromptAdditions: promptAdditions, }; this.sessions.set(sessionId, session); diff --git a/apps/gateway/src/agent/skill-loader.service.ts b/apps/gateway/src/agent/skill-loader.service.ts new file mode 100644 index 0000000..5dd42a1 --- /dev/null +++ b/apps/gateway/src/agent/skill-loader.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; +import { SkillsService } from '../skills/skills.service.js'; +import { createSkillTools } from './tools/skill-tools.js'; + +export interface LoadedSkills { + /** Meta-tools: skill_list + skill_invoke */ + metaTools: ToolDefinition[]; + /** + * System prompt additions from enabled prompt-type skills. + * Callers may prepend these to the session system prompt. + */ + promptAdditions: string[]; +} + +/** + * SkillLoaderService is responsible for: + * 1. Providing the skill meta-tools (skill_list, skill_invoke) to agent sessions. + * 2. Collecting system-prompt additions from enabled prompt-type skills. + */ +@Injectable() +export class SkillLoaderService { + private readonly logger = new Logger(SkillLoaderService.name); + + constructor(@Inject(SkillsService) private readonly skillsService: SkillsService) {} + + /** + * Load enabled skills and return tools + prompt additions for a new session. + */ + async loadForSession(): Promise { + const metaTools = createSkillTools(this.skillsService); + + let promptAdditions: string[] = []; + try { + const enabledSkills = await this.skillsService.findEnabled(); + promptAdditions = enabledSkills.flatMap((skill) => { + const config = (skill.config ?? {}) as Record; + const skillType = (config['type'] as string | undefined) ?? 'prompt'; + if (skillType === 'prompt') { + const addition = (config['prompt'] as string | undefined) ?? skill.description; + return addition ? [addition] : []; + } + return []; + }); + + this.logger.log( + `Loaded ${enabledSkills.length} enabled skill(s), ` + + `${promptAdditions.length} prompt addition(s)`, + ); + } catch (err) { + // Non-fatal: log and continue without prompt additions + this.logger.warn( + `Failed to load skill prompt additions: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { metaTools, promptAdditions }; + } +} diff --git a/apps/gateway/src/agent/tools/index.ts b/apps/gateway/src/agent/tools/index.ts index 80c98d2..70e4388 100644 --- a/apps/gateway/src/agent/tools/index.ts +++ b/apps/gateway/src/agent/tools/index.ts @@ -4,3 +4,4 @@ export { createFileTools } from './file-tools.js'; export { createGitTools } from './git-tools.js'; export { createShellTools } from './shell-tools.js'; export { createWebTools } from './web-tools.js'; +export { createSkillTools } from './skill-tools.js'; diff --git a/apps/gateway/src/agent/tools/skill-tools.ts b/apps/gateway/src/agent/tools/skill-tools.ts new file mode 100644 index 0000000..c44f112 --- /dev/null +++ b/apps/gateway/src/agent/tools/skill-tools.ts @@ -0,0 +1,180 @@ +import { Type } from '@sinclair/typebox'; +import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; +import type { SkillsService } from '../../skills/skills.service.js'; + +/** + * Creates meta-tools that allow agents to list and invoke skills from the catalog. + * + * skill_list — list all enabled skills + * skill_invoke — execute a skill by name with parameters + */ +export function createSkillTools(skillsService: SkillsService): ToolDefinition[] { + const skillList: ToolDefinition = { + name: 'skill_list', + label: 'List Skills', + description: + 'List all enabled skills available in the catalog. Returns name, description, type, and config for each skill.', + parameters: Type.Object({}), + async execute() { + const skills = await skillsService.findEnabled(); + const summary = skills.map((s) => ({ + name: s.name, + description: s.description, + version: s.version, + source: s.source, + config: s.config, + })); + return { + content: [ + { + type: 'text' as const, + text: + summary.length > 0 + ? JSON.stringify(summary, null, 2) + : 'No enabled skills found in catalog.', + }, + ], + details: undefined, + }; + }, + }; + + const skillInvoke: ToolDefinition = { + name: 'skill_invoke', + label: 'Invoke Skill', + description: + 'Invoke a skill from the catalog by name. For prompt skills, returns the prompt addition. ' + + 'For tool skills, executes the embedded logic. For workflow skills, returns the workflow steps.', + parameters: Type.Object({ + name: Type.String({ description: 'Skill name to invoke' }), + params: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: 'Parameters to pass to the skill (if applicable)', + }), + ), + }), + async execute(_toolCallId, rawParams) { + const { name, params } = rawParams as { + name: string; + params?: Record; + }; + + const skill = await skillsService.findByName(name); + if (!skill) { + return { + content: [{ type: 'text' as const, text: `Skill not found: ${name}` }], + details: undefined, + }; + } + + if (!skill.enabled) { + return { + content: [{ type: 'text' as const, text: `Skill is disabled: ${name}` }], + details: undefined, + }; + } + + const config = (skill.config ?? {}) as Record; + const skillType = (config['type'] as string | undefined) ?? 'prompt'; + + switch (skillType) { + case 'prompt': { + const promptAddition = + (config['prompt'] as string | undefined) ?? skill.description ?? ''; + return { + content: [ + { + type: 'text' as const, + text: promptAddition + ? `[Skill: ${name}] ${promptAddition}` + : `[Skill: ${name}] No prompt content defined.`, + }, + ], + details: undefined, + }; + } + + case 'tool': { + const toolLogic = config['logic'] as string | undefined; + if (!toolLogic) { + return { + content: [ + { + type: 'text' as const, + text: `[Skill: ${name}] Tool skill has no logic defined.`, + }, + ], + details: undefined, + }; + } + // Inline tool skill execution: the logic field holds a JS expression or template + // For safety, treat it as a template that can reference params + const result = renderTemplate(toolLogic, { params: params ?? {}, skill }); + return { + content: [{ type: 'text' as const, text: `[Skill: ${name}]\n${result}` }], + details: undefined, + }; + } + + case 'workflow': { + const steps = config['steps'] as unknown[] | undefined; + if (!steps || steps.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: `[Skill: ${name}] Workflow has no steps defined.`, + }, + ], + details: undefined, + }; + } + return { + content: [ + { + type: 'text' as const, + text: `[Skill: ${name}] Workflow steps:\n${JSON.stringify(steps, null, 2)}`, + }, + ], + details: undefined, + }; + } + + default: { + // Unknown type — return full config so the agent can decide what to do + return { + content: [ + { + type: 'text' as const, + text: `[Skill: ${name}] (type: ${skillType})\n${JSON.stringify(config, null, 2)}`, + }, + ], + details: undefined, + }; + } + } + }, + }; + + return [skillList, skillInvoke]; +} + +/** + * Minimal template renderer — replaces {{key}} with values from the context. + * Used for tool skill logic templates. + */ +function renderTemplate(template: string, context: Record): string { + return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_match, path: string) => { + const parts = path.split('.'); + let value: unknown = context; + for (const part of parts) { + if (value != null && typeof value === 'object') { + value = (value as Record)[part]; + } else { + value = undefined; + break; + } + } + return value !== undefined && value !== null ? String(value) : ''; + }); +}