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