feat(agent): skill invocation — load and execute skills from catalog (#128) (#143)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #143.
This commit is contained in:
@@ -2,16 +2,18 @@ import { Global, Module } from '@nestjs/common';
|
|||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { RoutingService } from './routing.service.js';
|
import { RoutingService } from './routing.service.js';
|
||||||
|
import { SkillLoaderService } from './skill-loader.service.js';
|
||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule],
|
imports: [CoordModule, McpClientModule, SkillsModule],
|
||||||
providers: [ProviderService, RoutingService, AgentService],
|
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
||||||
controllers: [ProvidersController, SessionsController],
|
controllers: [ProvidersController, SessionsController],
|
||||||
exports: [AgentService, ProviderService, RoutingService],
|
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { EmbeddingService } from '../memory/embedding.service.js';
|
|||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { McpClientService } from '../mcp-client/mcp-client.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 { createBrainTools } from './tools/brain-tools.js';
|
||||||
import { createCoordTools } from './tools/coord-tools.js';
|
import { createCoordTools } from './tools/coord-tools.js';
|
||||||
import { createMemoryTools } from './tools/memory-tools.js';
|
import { createMemoryTools } from './tools/memory-tools.js';
|
||||||
@@ -38,6 +39,8 @@ export interface AgentSession {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
promptCount: number;
|
promptCount: number;
|
||||||
channels: Set<string>;
|
channels: Set<string>;
|
||||||
|
/** System prompt additions injected from enabled prompt-type skills. */
|
||||||
|
skillPromptAdditions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -55,6 +58,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
|
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
|
||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
|
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||||
) {
|
) {
|
||||||
const fileBaseDir = process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
const fileBaseDir = process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
||||||
const gitDefaultCwd = process.env['AGENT_GIT_CWD'] ?? 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})`,
|
`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
|
// Combine static tools with dynamically discovered MCP client tools
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
const allCustomTools = [...this.customTools, ...mcpTools];
|
const allCustomTools = [...this.customTools, ...skillMetaTools, ...mcpTools];
|
||||||
if (mcpTools.length > 0) {
|
if (mcpTools.length > 0) {
|
||||||
this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`);
|
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(),
|
createdAt: Date.now(),
|
||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
channels: new Set(),
|
channels: new Set(),
|
||||||
|
skillPromptAdditions: promptAdditions,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
|
|||||||
59
apps/gateway/src/agent/skill-loader.service.ts
Normal file
59
apps/gateway/src/agent/skill-loader.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export { createFileTools } from './file-tools.js';
|
|||||||
export { createGitTools } from './git-tools.js';
|
export { createGitTools } from './git-tools.js';
|
||||||
export { createShellTools } from './shell-tools.js';
|
export { createShellTools } from './shell-tools.js';
|
||||||
export { createWebTools } from './web-tools.js';
|
export { createWebTools } from './web-tools.js';
|
||||||
|
export { createSkillTools } from './skill-tools.js';
|
||||||
|
|||||||
180
apps/gateway/src/agent/tools/skill-tools.ts
Normal file
180
apps/gateway/src/agent/tools/skill-tools.ts
Normal 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) : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user