Files
stack/apps/gateway/src/agent/tools/skill-tools.ts
Jason Woltje 3bb401641e
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(agent): skill invocation — load and execute skills from catalog (#128) (#143)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-15 18:36:58 +00:00

181 lines
5.6 KiB
TypeScript

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) : '';
});
}