@studio-foundation/runner 0.3.0-beta.1 → 0.3.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/ARCHITECTURE.md +0 -53
- package/configs/agents/analyst.agent.yaml +0 -31
- package/configs/agents/code-generator.agent.yaml +0 -31
- package/configs/agents/generic.agent.yaml +0 -23
- package/src/__tests__/script-executor.test.ts +0 -180
- package/src/index.ts +0 -68
- package/src/integrations/integration-loader.test.ts +0 -88
- package/src/integrations/integration-loader.ts +0 -68
- package/src/middleware/anonymization.ts +0 -38
- package/src/plugins/index.ts +0 -4
- package/src/plugins/mcp-client.test.ts +0 -148
- package/src/plugins/mcp-client.ts +0 -128
- package/src/plugins/oauth-provider.test.ts +0 -167
- package/src/plugins/oauth-provider.ts +0 -175
- package/src/plugins/plugin-loader.test.ts +0 -114
- package/src/plugins/plugin-loader.ts +0 -90
- package/src/prompt-builder.test.ts +0 -167
- package/src/prompt-builder.ts +0 -332
- package/src/providers/anthropic.test.ts +0 -101
- package/src/providers/anthropic.ts +0 -135
- package/src/providers/mock.ts +0 -57
- package/src/providers/ollama.test.ts +0 -166
- package/src/providers/ollama.ts +0 -152
- package/src/providers/openai-responses.ts +0 -212
- package/src/providers/openai.test.ts +0 -67
- package/src/providers/openai.ts +0 -139
- package/src/providers/provider.ts +0 -54
- package/src/providers/registry.ts +0 -77
- package/src/runner.test.ts +0 -343
- package/src/runner.ts +0 -396
- package/src/script-executor.ts +0 -107
- package/src/tools/builtin/git.ts +0 -311
- package/src/tools/builtin/patch.ts +0 -257
- package/src/tools/builtin/repo-manager.ts +0 -142
- package/src/tools/builtin/search.ts +0 -108
- package/src/tools/builtin/shell.ts +0 -82
- package/src/tools/builtin/studio-run.ts +0 -73
- package/src/tools/builtin/web-search.test.ts +0 -122
- package/src/tools/builtin/web-search.ts +0 -101
- package/src/tools/errors.test.ts +0 -12
- package/src/tools/errors.ts +0 -6
- package/src/tools/plugin-loader.test.ts +0 -130
- package/src/tools/plugin-loader.ts +0 -203
- package/src/tools/skills/README.md +0 -49
- package/src/tools/skills/skill-loader.test.ts +0 -106
- package/src/tools/skills/skill-loader.ts +0 -62
- package/src/tools/tool-executor.test.ts +0 -88
- package/src/tools/tool-executor.ts +0 -84
- package/src/tools/tool-registry.ts +0 -130
- package/src/tools/yaml-executor.ts +0 -120
- package/src/utils/race-signal.test.ts +0 -50
- package/src/utils/race-signal.ts +0 -17
- package/templates/integrations/linear.integration.yaml +0 -35
- package/templates/integrations/slack.integration.yaml +0 -22
- package/templates/integrations/webhook.integration.yaml +0 -17
- package/templates/tools/git.tool.yaml +0 -80
- package/templates/tools/repo-manager.tool.yaml +0 -64
- package/templates/tools/search.tool.yaml +0 -22
- package/templates/tools/shell.tool.yaml +0 -19
- package/templates/tools/web-search.tool.yaml +0 -24
- package/tests/anonymization-middleware.test.ts +0 -61
- package/tests/anthropic.test.ts +0 -87
- package/tests/apply-patch.test.ts +0 -355
- package/tests/fixtures/tools/test-builtin.tool.yaml +0 -14
- package/tests/fixtures/tools/test-shell.tool.yaml +0 -19
- package/tests/mock-provider.test.ts +0 -104
- package/tests/openai.test.ts +0 -72
- package/tests/plugin-loader.test.ts +0 -54
- package/tests/prompt-builder.test.ts +0 -468
- package/tests/runner-anonymization.test.ts +0 -89
- package/tests/runner.test.ts +0 -885
- package/tests/studio-run.test.ts +0 -94
- package/tests/tool-executor.test.ts +0 -115
- package/tests/tool-registry.test.ts +0 -84
- package/tests/yaml-executor.test.ts +0 -76
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -7
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
// runner/src/tools/plugin-loader.ts
|
|
2
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { resolve, dirname } from 'node:path';
|
|
5
|
-
import yaml from 'js-yaml';
|
|
6
|
-
import type { ToolPluginDef, ToolCommandDef } from '@studio-foundation/contracts';
|
|
7
|
-
import type { Tool } from './tool-registry.js';
|
|
8
|
-
import { renderTemplate, executeShellCommand } from './yaml-executor.js';
|
|
9
|
-
import { createRepoManagerTools } from './builtin/repo-manager.js';
|
|
10
|
-
import { createShellTools } from './builtin/shell.js';
|
|
11
|
-
import { createSearchTools } from './builtin/search.js';
|
|
12
|
-
import { createPatchTools } from './builtin/patch.js';
|
|
13
|
-
import { createGitTools } from './builtin/git.js';
|
|
14
|
-
import { createWebSearchTools } from './builtin/web-search.js';
|
|
15
|
-
import { ToolYamlError } from './errors.js';
|
|
16
|
-
|
|
17
|
-
export interface LoadedPlugin {
|
|
18
|
-
name: string;
|
|
19
|
-
tools: Tool[];
|
|
20
|
-
promptSnippet?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Build a map of tool name → Tool from all builtin factories. */
|
|
24
|
-
function buildBuiltinMap(repoPath: string): Map<string, Tool> {
|
|
25
|
-
const map = new Map<string, Tool>();
|
|
26
|
-
const add = (tools: Tool[]) => tools.forEach(t => map.set(t.name, t));
|
|
27
|
-
add(createRepoManagerTools(repoPath));
|
|
28
|
-
add(createShellTools(repoPath));
|
|
29
|
-
add(createSearchTools(repoPath));
|
|
30
|
-
add(createPatchTools(repoPath));
|
|
31
|
-
add(createGitTools(repoPath));
|
|
32
|
-
add(createWebSearchTools());
|
|
33
|
-
return map;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Convert a ParameterDef map to a JSON Schema object for the LLM. */
|
|
37
|
-
function buildJsonSchema(
|
|
38
|
-
parameters: ToolCommandDef['parameters']
|
|
39
|
-
): Record<string, unknown> {
|
|
40
|
-
const properties: Record<string, unknown> = {};
|
|
41
|
-
const required: string[] = [];
|
|
42
|
-
|
|
43
|
-
for (const [key, def] of Object.entries(parameters ?? {})) {
|
|
44
|
-
properties[key] = {
|
|
45
|
-
type: def.type,
|
|
46
|
-
...(def.description ? { description: def.description } : {}),
|
|
47
|
-
...(def.type === 'array' && def.items ? { items: def.items } : {}),
|
|
48
|
-
};
|
|
49
|
-
if (def.required) required.push(key);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
type: 'object',
|
|
54
|
-
properties,
|
|
55
|
-
...(required.length > 0 ? { required } : {}),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Template keywords that appear as {{word}} but are not parameter names.
|
|
60
|
-
* Note: block tags like {{#if}} and {{/if}} are already excluded because
|
|
61
|
-
* '#' and '/' are not matched by [\w-]+. */
|
|
62
|
-
const TEMPLATE_KEYWORDS = new Set(['else']);
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Validate that every {{placeholder}} in a shell command template
|
|
66
|
-
* is declared in the command's parameters.
|
|
67
|
-
* Throws ToolYamlError if any undeclared placeholder is found.
|
|
68
|
-
*/
|
|
69
|
-
function validateShellTemplate(fileName: string, cmd: ToolCommandDef): void {
|
|
70
|
-
const exec = cmd.execute as { type: string; command?: string };
|
|
71
|
-
if (exec.type !== 'shell' || !exec.command) return;
|
|
72
|
-
|
|
73
|
-
const declared = new Set(Object.keys(cmd.parameters ?? {}));
|
|
74
|
-
const used = new Set<string>();
|
|
75
|
-
|
|
76
|
-
for (const match of exec.command.matchAll(/\{\{([\w-]+)\}\}/g)) {
|
|
77
|
-
const name = match[1];
|
|
78
|
-
if (!TEMPLATE_KEYWORDS.has(name)) used.add(name);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const unknown = [...used].filter(p => !declared.has(p));
|
|
82
|
-
if (unknown.length > 0) {
|
|
83
|
-
throw new ToolYamlError(
|
|
84
|
-
`${fileName} › command '${cmd.name}':\n` +
|
|
85
|
-
` template uses ${unknown.map(p => `{{${p}}}`).join(', ')} but no such parameter is declared.\n` +
|
|
86
|
-
` Declared parameters: ${[...declared].join(', ') || '(none)'}`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Create a Tool that renders the command template and runs it in a shell. */
|
|
92
|
-
function createShellTool(cmd: ToolCommandDef, repoPath: string, configsDir: string): Tool {
|
|
93
|
-
const exec = cmd.execute as { type: 'shell'; command: string; parse_output?: 'text' | 'json'; timeout_ms?: number };
|
|
94
|
-
return {
|
|
95
|
-
name: cmd.name,
|
|
96
|
-
description: cmd.description,
|
|
97
|
-
parameters: buildJsonSchema(cmd.parameters),
|
|
98
|
-
async execute(args) {
|
|
99
|
-
const rendered = renderTemplate(exec.command, args);
|
|
100
|
-
return executeShellCommand(rendered, exec.parse_output ?? 'text', repoPath, exec.timeout_ms, { STUDIO_CONFIG_DIR: configsDir });
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Load all `.tool.yaml` files from a project's tools directory.
|
|
107
|
-
* Returns an empty array if the directory does not exist.
|
|
108
|
-
*/
|
|
109
|
-
export async function loadProjectTools(
|
|
110
|
-
toolsDir: string,
|
|
111
|
-
repoPath: string
|
|
112
|
-
): Promise<LoadedPlugin[]> {
|
|
113
|
-
if (!existsSync(toolsDir)) return [];
|
|
114
|
-
|
|
115
|
-
let files: string[];
|
|
116
|
-
try {
|
|
117
|
-
files = (await readdir(toolsDir)).filter(f => f.endsWith('.tool.yaml'));
|
|
118
|
-
} catch {
|
|
119
|
-
return [];
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (files.length === 0) return [];
|
|
123
|
-
|
|
124
|
-
const configsDir = resolve(dirname(toolsDir));
|
|
125
|
-
const builtinMap = buildBuiltinMap(repoPath);
|
|
126
|
-
const plugins: LoadedPlugin[] = [];
|
|
127
|
-
|
|
128
|
-
for (const file of files.sort()) {
|
|
129
|
-
const content = await readFile(resolve(toolsDir, file), 'utf-8');
|
|
130
|
-
const def = yaml.load(content) as ToolPluginDef;
|
|
131
|
-
|
|
132
|
-
const tools: Tool[] = [];
|
|
133
|
-
for (const cmd of def.commands ?? []) {
|
|
134
|
-
if (cmd.execute.type === 'builtin') {
|
|
135
|
-
const tool = builtinMap.get(cmd.name);
|
|
136
|
-
if (tool) tools.push(tool);
|
|
137
|
-
// If unknown builtin name, skip silently (no crash)
|
|
138
|
-
} else {
|
|
139
|
-
validateShellTemplate(file, cmd);
|
|
140
|
-
tools.push(createShellTool(cmd, repoPath, configsDir));
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
plugins.push({
|
|
145
|
-
name: def.name,
|
|
146
|
-
tools,
|
|
147
|
-
promptSnippet: def.prompt_snippet,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return plugins;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const BUNDLED_TOOL_TEMPLATES_DIR = resolve(
|
|
155
|
-
__dirname,
|
|
156
|
-
'../../templates/tools'
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Names of built-in tool plugins that have bundled installable templates.
|
|
161
|
-
* Mirrors runner/templates/tools/*.tool.yaml.
|
|
162
|
-
* Note: 'patch' is a TypeScript-only builtin with no installable template and is intentionally excluded.
|
|
163
|
-
*/
|
|
164
|
-
export const BUILTIN_TOOL_NAMES = new Set([
|
|
165
|
-
'repo-manager',
|
|
166
|
-
'shell',
|
|
167
|
-
'search',
|
|
168
|
-
'git',
|
|
169
|
-
'web-search',
|
|
170
|
-
]);
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* List all tool plugins available for installation from the bundled registry.
|
|
174
|
-
* Returns an array of { name, description } objects.
|
|
175
|
-
*/
|
|
176
|
-
export async function listAvailableToolTemplates(): Promise<{ name: string; description: string }[]> {
|
|
177
|
-
let files: string[];
|
|
178
|
-
try {
|
|
179
|
-
files = (await readdir(BUNDLED_TOOL_TEMPLATES_DIR)).filter(f => f.endsWith('.tool.yaml')).sort();
|
|
180
|
-
} catch {
|
|
181
|
-
return [];
|
|
182
|
-
}
|
|
183
|
-
const result: { name: string; description: string }[] = [];
|
|
184
|
-
for (const file of files) {
|
|
185
|
-
const content = await readFile(resolve(BUNDLED_TOOL_TEMPLATES_DIR, file), 'utf-8');
|
|
186
|
-
const def = yaml.load(content) as ToolPluginDef;
|
|
187
|
-
result.push({ name: file.replace('.tool.yaml', ''), description: def.description ?? '' });
|
|
188
|
-
}
|
|
189
|
-
return result;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Return the raw YAML content of a bundled tool template by name.
|
|
194
|
-
* Returns null if the tool does not exist in the bundled registry.
|
|
195
|
-
*/
|
|
196
|
-
export async function getBundledToolTemplate(name: string): Promise<string | null> {
|
|
197
|
-
const filePath = resolve(BUNDLED_TOOL_TEMPLATES_DIR, `${name}.tool.yaml`);
|
|
198
|
-
try {
|
|
199
|
-
return await readFile(filePath, 'utf-8');
|
|
200
|
-
} catch {
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# Skills
|
|
2
|
-
|
|
3
|
-
## What are Skills?
|
|
4
|
-
|
|
5
|
-
Skills are pluggable tool packs that can be dynamically loaded into the runner. A skill is a collection of related tools packaged together, typically defined in YAML configuration files with accompanying code.
|
|
6
|
-
|
|
7
|
-
## Status
|
|
8
|
-
|
|
9
|
-
**NOT YET IMPLEMENTED** - This is a placeholder for future functionality.
|
|
10
|
-
|
|
11
|
-
## Future Implementation
|
|
12
|
-
|
|
13
|
-
Skills will allow:
|
|
14
|
-
- Loading tools from external YAML + TypeScript/JavaScript files
|
|
15
|
-
- Packaging related tools together (e.g., "database skill", "api skill")
|
|
16
|
-
- Dynamic tool discovery and registration
|
|
17
|
-
- Versioning and dependency management for tool sets
|
|
18
|
-
|
|
19
|
-
## Planned Structure
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
skills/
|
|
23
|
-
├── database/
|
|
24
|
-
│ ├── skill.yaml
|
|
25
|
-
│ └── tools.ts
|
|
26
|
-
├── api/
|
|
27
|
-
│ ├── skill.yaml
|
|
28
|
-
│ └── tools.ts
|
|
29
|
-
└── custom/
|
|
30
|
-
├── skill.yaml
|
|
31
|
-
└── tools.ts
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Where `skill.yaml` would define:
|
|
35
|
-
- Skill metadata (name, version, description)
|
|
36
|
-
- Tool definitions (names, descriptions, parameters)
|
|
37
|
-
- Dependencies on other skills or system requirements
|
|
38
|
-
|
|
39
|
-
And `tools.ts` would implement:
|
|
40
|
-
- The actual tool execution functions
|
|
41
|
-
- Any helper utilities needed by the tools
|
|
42
|
-
|
|
43
|
-
## Implementation TODO
|
|
44
|
-
|
|
45
|
-
- [ ] Design skill manifest format (YAML schema)
|
|
46
|
-
- [ ] Implement skill loader (scan directories, parse YAML, load code)
|
|
47
|
-
- [ ] Add skill validation (check required fields, validate tool signatures)
|
|
48
|
-
- [ ] Support skill dependencies and version constraints
|
|
49
|
-
- [ ] Create example skills for common use cases
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
|
2
|
-
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { loadSkills, loadSkill, validateSkillManifest } from './skill-loader.js';
|
|
5
|
-
|
|
6
|
-
const TMP = join('/tmp', '.studio-runner-skill-loader-test-' + Date.now());
|
|
7
|
-
const SKILLS_DIR = join(TMP, 'skills');
|
|
8
|
-
|
|
9
|
-
describe('loadSkill', () => {
|
|
10
|
-
beforeAll(async () => {
|
|
11
|
-
await mkdir(SKILLS_DIR, { recursive: true });
|
|
12
|
-
await writeFile(join(SKILLS_DIR, 'commit-conventions.skill.md'), '# Commit Conventions\n\nUse conventional commits.');
|
|
13
|
-
await writeFile(join(SKILLS_DIR, 'react-patterns.skill.md'), '# React Patterns\n\nPrefer hooks.');
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterAll(async () => {
|
|
17
|
-
await rm(TMP, { recursive: true, force: true });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('returns SkillContent for an existing skill file', async () => {
|
|
21
|
-
const skill = await loadSkill('commit-conventions', TMP);
|
|
22
|
-
expect(skill).toEqual({
|
|
23
|
-
name: 'commit-conventions',
|
|
24
|
-
content: '# Commit Conventions\n\nUse conventional commits.',
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('returns null and warns for a missing skill file', async () => {
|
|
29
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
30
|
-
const skill = await loadSkill('nonexistent', TMP);
|
|
31
|
-
expect(skill).toBeNull();
|
|
32
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent'));
|
|
33
|
-
warnSpy.mockRestore();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('returns null when studioDir has no skills/ subdirectory', async () => {
|
|
37
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
38
|
-
const skill = await loadSkill('commit-conventions', '/tmp/nonexistent-studio-dir-xyz');
|
|
39
|
-
expect(skill).toBeNull();
|
|
40
|
-
warnSpy.mockRestore();
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('loadSkills', () => {
|
|
45
|
-
beforeAll(async () => {
|
|
46
|
-
await mkdir(SKILLS_DIR, { recursive: true }).catch(() => {});
|
|
47
|
-
await writeFile(join(SKILLS_DIR, 'commit-conventions.skill.md'), '# Commit Conventions\n\nUse conventional commits.').catch(() => {});
|
|
48
|
-
await writeFile(join(SKILLS_DIR, 'react-patterns.skill.md'), '# React Patterns\n\nPrefer hooks.').catch(() => {});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('loads all named skills from .studio/skills/', async () => {
|
|
52
|
-
const skills = await loadSkills(['commit-conventions', 'react-patterns'], TMP);
|
|
53
|
-
expect(skills).toHaveLength(2);
|
|
54
|
-
expect(skills[0]).toEqual({ name: 'commit-conventions', content: '# Commit Conventions\n\nUse conventional commits.' });
|
|
55
|
-
expect(skills[1]).toEqual({ name: 'react-patterns', content: '# React Patterns\n\nPrefer hooks.' });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('skips missing skills and returns the ones that exist', async () => {
|
|
59
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
60
|
-
const skills = await loadSkills(['commit-conventions', 'missing-skill'], TMP);
|
|
61
|
-
expect(skills).toHaveLength(1);
|
|
62
|
-
expect(skills[0].name).toBe('commit-conventions');
|
|
63
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('missing-skill'));
|
|
64
|
-
warnSpy.mockRestore();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('returns empty array when names list is empty', async () => {
|
|
68
|
-
const skills = await loadSkills([], TMP);
|
|
69
|
-
expect(skills).toHaveLength(0);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('returns empty array when studioDir has no skills/ directory', async () => {
|
|
73
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
74
|
-
const skills = await loadSkills(['commit-conventions'], '/tmp/nonexistent-studio-xyz');
|
|
75
|
-
expect(skills).toHaveLength(0);
|
|
76
|
-
warnSpy.mockRestore();
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('validateSkillManifest', () => {
|
|
81
|
-
it('returns true for a valid skill manifest', () => {
|
|
82
|
-
expect(validateSkillManifest({ name: 'my-skill', content: '# My Skill\n\nContent here.' })).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('throws when manifest is missing name', () => {
|
|
86
|
-
expect(() => validateSkillManifest({ content: 'some content' })).toThrow();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('throws when manifest is missing content', () => {
|
|
90
|
-
expect(() => validateSkillManifest({ name: 'my-skill' })).toThrow();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('throws when manifest is not an object', () => {
|
|
94
|
-
expect(() => validateSkillManifest(null)).toThrow();
|
|
95
|
-
expect(() => validateSkillManifest('string')).toThrow();
|
|
96
|
-
expect(() => validateSkillManifest(42)).toThrow();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('throws when name is not a string', () => {
|
|
100
|
-
expect(() => validateSkillManifest({ name: 123, content: 'content' })).toThrow();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('throws when content is not a string', () => {
|
|
104
|
-
expect(() => validateSkillManifest({ name: 'skill', content: [] })).toThrow();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import type { SkillContent } from '../../plugins/plugin-loader.js';
|
|
5
|
-
|
|
6
|
-
export type { SkillContent };
|
|
7
|
-
|
|
8
|
-
/** Minimal structure required for a valid skill manifest */
|
|
9
|
-
export interface SkillManifest {
|
|
10
|
-
name: string;
|
|
11
|
-
content: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Load multiple `.skill.md` files by name from `.studio/skills/`.
|
|
16
|
-
* Missing files are skipped with a warning (non-fatal).
|
|
17
|
-
*/
|
|
18
|
-
export async function loadSkills(skillNames: string[], studioDir: string): Promise<SkillContent[]> {
|
|
19
|
-
const results: SkillContent[] = [];
|
|
20
|
-
for (const name of skillNames) {
|
|
21
|
-
const skill = await loadSkill(name, studioDir);
|
|
22
|
-
if (skill) results.push(skill);
|
|
23
|
-
}
|
|
24
|
-
return results;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Load a single `.skill.md` file by name from `<studioDir>/skills/`.
|
|
29
|
-
* Returns null if the file does not exist.
|
|
30
|
-
*/
|
|
31
|
-
export async function loadSkill(name: string, studioDir: string): Promise<SkillContent | null> {
|
|
32
|
-
const filePath = join(studioDir, 'skills', `${name}.skill.md`);
|
|
33
|
-
if (!existsSync(filePath)) {
|
|
34
|
-
console.warn(`[studio] Skill '${name}' not found at ${filePath} — skipping.`);
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
try {
|
|
38
|
-
const content = await readFile(filePath, 'utf-8');
|
|
39
|
-
return { name, content };
|
|
40
|
-
} catch {
|
|
41
|
-
console.warn(`[studio] Failed to load skill '${name}' — skipping.`);
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Validate that a skill manifest has the required structure.
|
|
48
|
-
* Throws a descriptive error if invalid.
|
|
49
|
-
*/
|
|
50
|
-
export function validateSkillManifest(manifest: unknown): boolean {
|
|
51
|
-
if (!manifest || typeof manifest !== 'object') {
|
|
52
|
-
throw new Error('Skill manifest must be an object');
|
|
53
|
-
}
|
|
54
|
-
const m = manifest as Record<string, unknown>;
|
|
55
|
-
if (typeof m.name !== 'string') {
|
|
56
|
-
throw new Error('Skill manifest must have a string "name" field');
|
|
57
|
-
}
|
|
58
|
-
if (typeof m.content !== 'string') {
|
|
59
|
-
throw new Error('Skill manifest must have a string "content" field');
|
|
60
|
-
}
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { ToolExecutor } from './tool-executor.js';
|
|
3
|
-
import { ToolRegistry } from './tool-registry.js';
|
|
4
|
-
|
|
5
|
-
function makeRegistry(paramSchema: Record<string, unknown>): ToolRegistry {
|
|
6
|
-
const registry = new ToolRegistry();
|
|
7
|
-
registry.register({
|
|
8
|
-
name: 'test-tool',
|
|
9
|
-
description: 'A test tool',
|
|
10
|
-
parameters: paramSchema,
|
|
11
|
-
execute: vi.fn().mockResolvedValue({ success: true, output: 'ok' }),
|
|
12
|
-
});
|
|
13
|
-
return registry;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const SCHEMA_WITH_REQUIRED = {
|
|
17
|
-
type: 'object',
|
|
18
|
-
properties: {
|
|
19
|
-
query: { type: 'string', description: 'Search query' },
|
|
20
|
-
},
|
|
21
|
-
required: ['query'],
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const SCHEMA_NO_REQUIRED = {
|
|
25
|
-
type: 'object',
|
|
26
|
-
properties: {
|
|
27
|
-
query: { type: 'string', description: 'Search query' },
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
describe('ToolExecutor — argument validation', () => {
|
|
32
|
-
it('returns error for missing required parameter', async () => {
|
|
33
|
-
const executor = new ToolExecutor(makeRegistry(SCHEMA_WITH_REQUIRED));
|
|
34
|
-
const result = await executor.execute({
|
|
35
|
-
id: '1',
|
|
36
|
-
name: 'test-tool',
|
|
37
|
-
arguments: {},
|
|
38
|
-
});
|
|
39
|
-
expect(result.error).toMatch(/missing required parameter.*query/);
|
|
40
|
-
expect(result.result).toBeUndefined();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('returns error for unknown parameter', async () => {
|
|
44
|
-
const executor = new ToolExecutor(makeRegistry(SCHEMA_WITH_REQUIRED));
|
|
45
|
-
const result = await executor.execute({
|
|
46
|
-
id: '2',
|
|
47
|
-
name: 'test-tool',
|
|
48
|
-
arguments: { query: 'hello', pattern: 'oops' },
|
|
49
|
-
});
|
|
50
|
-
expect(result.error).toMatch(/unknown parameter.*pattern/);
|
|
51
|
-
expect(result.error).toMatch(/declared: query/);
|
|
52
|
-
expect(result.result).toBeUndefined();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('executes successfully with correct required parameters', async () => {
|
|
56
|
-
const registry = makeRegistry(SCHEMA_WITH_REQUIRED);
|
|
57
|
-
const executor = new ToolExecutor(registry);
|
|
58
|
-
const result = await executor.execute({
|
|
59
|
-
id: '3',
|
|
60
|
-
name: 'test-tool',
|
|
61
|
-
arguments: { query: 'ramen' },
|
|
62
|
-
});
|
|
63
|
-
expect(result.error).toBeUndefined();
|
|
64
|
-
expect(result.result).toBe('ok');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('executes successfully with no required parameters and empty args', async () => {
|
|
68
|
-
const executor = new ToolExecutor(makeRegistry(SCHEMA_NO_REQUIRED));
|
|
69
|
-
const result = await executor.execute({
|
|
70
|
-
id: '4',
|
|
71
|
-
name: 'test-tool',
|
|
72
|
-
arguments: {},
|
|
73
|
-
});
|
|
74
|
-
expect(result.error).toBeUndefined();
|
|
75
|
-
expect(result.result).toBe('ok');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('executes successfully with optional parameter provided', async () => {
|
|
79
|
-
const executor = new ToolExecutor(makeRegistry(SCHEMA_NO_REQUIRED));
|
|
80
|
-
const result = await executor.execute({
|
|
81
|
-
id: '5',
|
|
82
|
-
name: 'test-tool',
|
|
83
|
-
arguments: { query: 'ramen' },
|
|
84
|
-
});
|
|
85
|
-
expect(result.error).toBeUndefined();
|
|
86
|
-
expect(result.result).toBe('ok');
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { ToolCall } from '@studio-foundation/contracts';
|
|
2
|
-
import { ToolRegistry } from './tool-registry.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Validate LLM-provided arguments against the tool's JSON Schema.
|
|
6
|
-
* Returns an error string if invalid, null if OK.
|
|
7
|
-
*/
|
|
8
|
-
function validateArgs(
|
|
9
|
-
toolName: string,
|
|
10
|
-
schema: Record<string, unknown>,
|
|
11
|
-
args: Record<string, unknown>
|
|
12
|
-
): string | null {
|
|
13
|
-
const properties = (schema.properties as Record<string, unknown> | undefined) ?? {};
|
|
14
|
-
const required = (schema.required as string[] | undefined) ?? [];
|
|
15
|
-
const declared = new Set(Object.keys(properties));
|
|
16
|
-
|
|
17
|
-
const missing = required.filter(p => !(p in args));
|
|
18
|
-
if (missing.length > 0) {
|
|
19
|
-
return `Tool ${toolName}: missing required parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const unknown = Object.keys(args).filter(p => !declared.has(p));
|
|
23
|
-
if (unknown.length > 0) {
|
|
24
|
-
return `Tool ${toolName}: unknown parameter${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')} (declared: ${[...declared].join(', ')})`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export class ToolExecutor {
|
|
31
|
-
constructor(private registry: ToolRegistry) {}
|
|
32
|
-
|
|
33
|
-
async execute(toolCall: { id: string; name: string; arguments: Record<string, unknown> }): Promise<ToolCall> {
|
|
34
|
-
const tool = this.registry.get(toolCall.name);
|
|
35
|
-
|
|
36
|
-
if (!tool) {
|
|
37
|
-
return {
|
|
38
|
-
id: toolCall.id,
|
|
39
|
-
name: toolCall.name,
|
|
40
|
-
arguments: toolCall.arguments,
|
|
41
|
-
error: `Tool not found: ${toolCall.name}`
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Validate arguments against the tool's parameter schema
|
|
46
|
-
const validationError = validateArgs(toolCall.name, tool.parameters, toolCall.arguments);
|
|
47
|
-
if (validationError) {
|
|
48
|
-
return {
|
|
49
|
-
id: toolCall.id,
|
|
50
|
-
name: toolCall.name,
|
|
51
|
-
arguments: toolCall.arguments,
|
|
52
|
-
error: validationError,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const toolResult = await tool.execute(toolCall.arguments);
|
|
58
|
-
|
|
59
|
-
if (!toolResult.success) {
|
|
60
|
-
return {
|
|
61
|
-
id: toolCall.id,
|
|
62
|
-
name: toolCall.name,
|
|
63
|
-
arguments: toolCall.arguments,
|
|
64
|
-
error: toolResult.error || 'Tool execution failed'
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
id: toolCall.id,
|
|
70
|
-
name: toolCall.name,
|
|
71
|
-
arguments: toolCall.arguments,
|
|
72
|
-
result: toolResult.output
|
|
73
|
-
};
|
|
74
|
-
} catch (error: unknown) {
|
|
75
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
76
|
-
return {
|
|
77
|
-
id: toolCall.id,
|
|
78
|
-
name: toolCall.name,
|
|
79
|
-
arguments: toolCall.arguments,
|
|
80
|
-
error: errorMessage
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|