feat(agent): skill invocation — load and execute skills from catalog (#128)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Adds SkillLoaderService, skill_list and skill_invoke meta-tools, and
session-level skill injection from the DB catalog. Enabled prompt-type
skills inject system prompt additions into new sessions; tool and
workflow skills are invocable via skill_invoke.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 13:36:21 -05:00
parent 54b821d8bd
commit 43b834125a
5 changed files with 263 additions and 4 deletions

View File

@@ -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 {}

View File

@@ -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<string>;
/** 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);

View File

@@ -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<LoadedSkills> {
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<string, unknown>;
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 };
}
}

View File

@@ -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';

View File

@@ -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<string, unknown>;
};
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<string, unknown>;
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, unknown>): 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<string, unknown>)[part];
} else {
value = undefined;
break;
}
}
return value !== undefined && value !== null ? String(value) : '';
});
}