@outputai/llm 0.6.1-dev.aab2335.0 → 0.6.1-next.0d08ff5.0
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 +2 -2
- package/src/agent.js +15 -9
- package/src/agent.spec.js +295 -214
- package/src/ai_model.js +79 -36
- package/src/ai_model.spec.js +31 -13
- package/src/ai_sdk.js +55 -79
- package/src/ai_sdk.spec.js +464 -611
- package/src/ai_sdk_options.js +61 -0
- package/src/ai_sdk_options.spec.js +164 -0
- package/src/cost/index.js +1 -1
- package/src/index.d.ts +230 -175
- package/src/index.js +2 -2
- package/src/prompt/escape.js +65 -0
- package/src/prompt/escape.spec.js +159 -0
- package/src/{load_content.js → prompt/load_content.js} +1 -22
- package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
- package/src/prompt/loader.js +49 -0
- package/src/prompt/loader.spec.js +274 -0
- package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
- package/src/prompt/parser.js +19 -0
- package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
- package/src/prompt/prepare_text.js +27 -0
- package/src/prompt/prepare_text.spec.js +141 -0
- package/src/{skill.js → prompt/skill.js} +19 -0
- package/src/prompt/skill.spec.js +172 -0
- package/src/{prompt_validations.js → prompt/validations.js} +32 -6
- package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
- package/src/utils/__fixtures__/image_response.json +38 -0
- package/src/utils/__fixtures__/stream_response.json +294 -0
- package/src/utils/__fixtures__/text_response.json +201 -0
- package/src/utils/error_handler.js +65 -0
- package/src/utils/error_handler.spec.js +195 -0
- package/src/utils/image.js +10 -0
- package/src/utils/image.spec.js +20 -0
- package/src/utils/response_wrappers.js +46 -19
- package/src/utils/response_wrappers.spec.js +130 -70
- package/src/utils/source_extraction.js +17 -27
- package/src/utils/trace.js +2 -3
- package/src/utils/trace.spec.js +9 -13
- package/src/validations.js +54 -2
- package/src/validations.spec.js +166 -0
- package/src/parser.js +0 -28
- package/src/prompt_loader.js +0 -80
- package/src/prompt_loader.spec.js +0 -358
- package/src/skill.d.ts +0 -49
|
@@ -11,7 +11,7 @@ model: claude-3-5-sonnet-20241022
|
|
|
11
11
|
<system>You are a helpful assistant.</system>
|
|
12
12
|
<user>Hello!</user>`;
|
|
13
13
|
|
|
14
|
-
const result = parsePrompt( raw );
|
|
14
|
+
const result = parsePrompt( { name: 'test', raw } );
|
|
15
15
|
|
|
16
16
|
expect( result.config ).toEqual( {
|
|
17
17
|
provider: 'anthropic',
|
|
@@ -26,39 +26,58 @@ model: claude-3-5-sonnet-20241022
|
|
|
26
26
|
role: 'user',
|
|
27
27
|
content: 'Hello!'
|
|
28
28
|
} );
|
|
29
|
+
expect( result.instructions ).toBeNull();
|
|
29
30
|
} );
|
|
30
31
|
|
|
31
|
-
it( '
|
|
32
|
+
it( 'parses raw instructions when no message blocks are present', () => {
|
|
32
33
|
const raw = `---
|
|
33
|
-
provider:
|
|
34
|
-
model:
|
|
34
|
+
provider: openai
|
|
35
|
+
model: gpt-image-1
|
|
35
36
|
---
|
|
36
37
|
|
|
38
|
+
Generate a cinematic image of a NASCAR race at sunset.`;
|
|
39
|
+
|
|
40
|
+
const result = parsePrompt( { name: 'image_prompt', raw } );
|
|
41
|
+
|
|
42
|
+
expect( result ).toEqual( {
|
|
43
|
+
config: {
|
|
44
|
+
provider: 'openai',
|
|
45
|
+
model: 'gpt-image-1'
|
|
46
|
+
},
|
|
47
|
+
messages: [],
|
|
48
|
+
instructions: 'Generate a cinematic image of a NASCAR race at sunset.'
|
|
49
|
+
} );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
it( 'trims raw instructions', () => {
|
|
53
|
+
const raw = `---
|
|
54
|
+
provider: openai
|
|
55
|
+
model: gpt-image-1
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
Generate a poster.
|
|
59
|
+
|
|
37
60
|
`;
|
|
38
61
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
const result = parsePrompt( { name: 'image_prompt', raw } );
|
|
63
|
+
|
|
64
|
+
expect( result.instructions ).toBe( 'Generate a poster.' );
|
|
42
65
|
} );
|
|
43
66
|
|
|
44
|
-
it( 'throws error when
|
|
67
|
+
it( 'throws error when content is empty', () => {
|
|
45
68
|
const raw = `---
|
|
46
69
|
provider: anthropic
|
|
47
70
|
model: claude-3-5-sonnet-20241022
|
|
48
71
|
---
|
|
49
72
|
|
|
50
|
-
|
|
73
|
+
`;
|
|
51
74
|
|
|
52
75
|
expect( () => {
|
|
53
|
-
parsePrompt( raw );
|
|
54
|
-
} ).toThrow( /
|
|
55
|
-
expect( () => {
|
|
56
|
-
parsePrompt( raw );
|
|
57
|
-
} ).toThrow( /Expected format/ );
|
|
76
|
+
parsePrompt( { name: 'empty_prompt', raw } );
|
|
77
|
+
} ).toThrow( /Prompt "empty_prompt" has no content after frontmatter/ );
|
|
58
78
|
} );
|
|
59
79
|
|
|
60
|
-
it( '
|
|
61
|
-
// Frontmatter uses canonical format: providerOptions.thinking.budgetTokens
|
|
80
|
+
it( 'parses providerOptions with budgetTokens in camelCase', () => {
|
|
62
81
|
const raw = `---
|
|
63
82
|
provider: anthropic
|
|
64
83
|
model: claude-sonnet-4-20250514
|
|
@@ -72,22 +91,19 @@ providerOptions:
|
|
|
72
91
|
|
|
73
92
|
<user>Test</user>`;
|
|
74
93
|
|
|
75
|
-
const result = parsePrompt( raw );
|
|
94
|
+
const result = parsePrompt( { name: 'test', raw } );
|
|
76
95
|
|
|
77
96
|
expect( result.config.provider ).toBe( 'anthropic' );
|
|
78
97
|
expect( result.config.model ).toBe( 'claude-sonnet-4-20250514' );
|
|
79
98
|
expect( result.config.temperature ).toBe( 0.7 );
|
|
80
99
|
expect( result.config.maxTokens ).toBe( 64000 );
|
|
81
|
-
|
|
82
|
-
// Now expects canonical schema format in front-matter
|
|
83
100
|
expect( result.config.providerOptions ).toBeDefined();
|
|
84
101
|
expect( result.config.providerOptions.thinking ).toBeDefined();
|
|
85
102
|
expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
|
|
86
103
|
expect( result.config.providerOptions.thinking.budgetTokens ).toBe( 1500 );
|
|
87
104
|
} );
|
|
88
105
|
|
|
89
|
-
it( '
|
|
90
|
-
// Parser extracts fields as-is from frontmatter; validation happens later
|
|
106
|
+
it( 'parses snake_case fields as-is so validation can catch them later', () => {
|
|
91
107
|
const raw = `---
|
|
92
108
|
provider: anthropic
|
|
93
109
|
model: claude-sonnet-4-20250514
|
|
@@ -101,22 +117,51 @@ providerOptions:
|
|
|
101
117
|
|
|
102
118
|
<user>Test</user>`;
|
|
103
119
|
|
|
104
|
-
const result = parsePrompt( raw );
|
|
120
|
+
const result = parsePrompt( { name: 'test', raw } );
|
|
105
121
|
|
|
106
|
-
// Parser extracts snake_case fields as-is
|
|
107
122
|
expect( result.config.provider ).toBe( 'anthropic' );
|
|
108
123
|
expect( result.config.model ).toBe( 'claude-sonnet-4-20250514' );
|
|
109
124
|
expect( result.config.temperature ).toBe( 0.7 );
|
|
110
|
-
// snake_case preserved here because we're only calling parsePrompt, not validatePrompt
|
|
111
125
|
expect( result.config.max_tokens ).toBe( 64000 );
|
|
112
|
-
// camelCase not set because we only call parsePrompt
|
|
113
126
|
expect( result.config.maxTokens ).toBeUndefined();
|
|
114
|
-
|
|
115
|
-
// Snake_case fields in nested objects also preserved
|
|
116
127
|
expect( result.config.providerOptions ).toBeDefined();
|
|
117
128
|
expect( result.config.providerOptions.thinking ).toBeDefined();
|
|
118
129
|
expect( result.config.providerOptions.thinking.type ).toBe( 'enabled' );
|
|
119
|
-
expect( result.config.providerOptions.thinking.budget_tokens ).toBe( 1500 );
|
|
120
|
-
expect( result.config.providerOptions.thinking.budgetTokens ).toBeUndefined();
|
|
130
|
+
expect( result.config.providerOptions.thinking.budget_tokens ).toBe( 1500 );
|
|
131
|
+
expect( result.config.providerOptions.thinking.budgetTokens ).toBeUndefined();
|
|
132
|
+
} );
|
|
133
|
+
|
|
134
|
+
it( 'parses supported message roles', () => {
|
|
135
|
+
const raw = `---
|
|
136
|
+
provider: anthropic
|
|
137
|
+
model: claude-3-5-sonnet-20241022
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
<system>System message</system>
|
|
141
|
+
<assistant>Assistant message</assistant>
|
|
142
|
+
<tool>Tool message</tool>
|
|
143
|
+
<user>User message</user>`;
|
|
144
|
+
|
|
145
|
+
const result = parsePrompt( { name: 'test', raw } );
|
|
146
|
+
|
|
147
|
+
expect( result.messages ).toEqual( [
|
|
148
|
+
{
|
|
149
|
+
role: 'system',
|
|
150
|
+
content: 'System message'
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
role: 'assistant',
|
|
154
|
+
content: 'Assistant message'
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
role: 'tool',
|
|
158
|
+
content: 'Tool message'
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
role: 'user',
|
|
162
|
+
content: 'User message'
|
|
163
|
+
}
|
|
164
|
+
] );
|
|
165
|
+
expect( result.instructions ).toBeNull();
|
|
121
166
|
} );
|
|
122
167
|
} );
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { loadPrompt } from './loader.js';
|
|
2
|
+
import { buildSystemSkillsVar, buildLoadSkillTool, resolvePromptSkills } from './skill.js';
|
|
3
|
+
|
|
4
|
+
export const prepareTextPrompt = ( { prompt, variables, promptDir, skills, tools } ) => {
|
|
5
|
+
const loadedPrompt = loadPrompt( prompt, variables, promptDir );
|
|
6
|
+
|
|
7
|
+
const resolvedSkills = resolvePromptSkills( loadedPrompt, skills );
|
|
8
|
+
|
|
9
|
+
const result = { loadedPrompt, tools: tools ? { ...tools } : null };
|
|
10
|
+
|
|
11
|
+
if ( resolvedSkills.length > 0 ) {
|
|
12
|
+
result.tools = {
|
|
13
|
+
load_skill: buildLoadSkillTool( resolvedSkills ),
|
|
14
|
+
...( result.tools ?? {} )
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const skillsMessage = { role: 'system', content: buildSystemSkillsVar( resolvedSkills ) };
|
|
18
|
+
const systemMessage = loadedPrompt.messages.find( m => m.role === 'system' );
|
|
19
|
+
if ( systemMessage ) {
|
|
20
|
+
systemMessage.content = `${systemMessage.content}\n\n${skillsMessage.content}`;
|
|
21
|
+
} else {
|
|
22
|
+
loadedPrompt.messages.unshift( skillsMessage );
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return result;
|
|
27
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const loadPromptImpl = vi.fn();
|
|
4
|
+
const resolvePromptSkillsImpl = vi.fn();
|
|
5
|
+
const buildLoadSkillToolImpl = vi.fn();
|
|
6
|
+
const buildSystemSkillsVarImpl = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock( './loader.js', () => ( {
|
|
9
|
+
loadPrompt: ( ...args ) => loadPromptImpl( ...args )
|
|
10
|
+
} ) );
|
|
11
|
+
|
|
12
|
+
vi.mock( './skill.js', () => ( {
|
|
13
|
+
resolvePromptSkills: ( ...args ) => resolvePromptSkillsImpl( ...args ),
|
|
14
|
+
buildLoadSkillTool: ( ...args ) => buildLoadSkillToolImpl( ...args ),
|
|
15
|
+
buildSystemSkillsVar: ( ...args ) => buildSystemSkillsVarImpl( ...args )
|
|
16
|
+
} ) );
|
|
17
|
+
|
|
18
|
+
const importSut = async () => import( './prepare_text.js' );
|
|
19
|
+
|
|
20
|
+
const makePrompt = messages => ( {
|
|
21
|
+
name: 'test@v1',
|
|
22
|
+
config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
|
|
23
|
+
messages,
|
|
24
|
+
promptFileDir: '/prompts'
|
|
25
|
+
} );
|
|
26
|
+
|
|
27
|
+
describe( 'prepareTextPrompt', () => {
|
|
28
|
+
beforeEach( () => {
|
|
29
|
+
vi.resetModules();
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
resolvePromptSkillsImpl.mockReturnValue( [] );
|
|
32
|
+
buildLoadSkillToolImpl.mockReturnValue( { type: 'load-skill-tool' } );
|
|
33
|
+
buildSystemSkillsVarImpl.mockReturnValue( 'Available skills:\n- copy: Copy guidance' );
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
it( 'loads and returns a prompt with caller tools when no skills resolve', async () => {
|
|
37
|
+
const loadedPrompt = makePrompt( [ { role: 'user', content: 'Hello' } ] );
|
|
38
|
+
const tools = { search: { type: 'search-tool' } };
|
|
39
|
+
loadPromptImpl.mockReturnValue( loadedPrompt );
|
|
40
|
+
|
|
41
|
+
const { prepareTextPrompt } = await importSut();
|
|
42
|
+
const result = prepareTextPrompt( {
|
|
43
|
+
prompt: 'test@v1',
|
|
44
|
+
variables: { name: 'Ada' },
|
|
45
|
+
promptDir: '/workflow',
|
|
46
|
+
skills: [],
|
|
47
|
+
tools
|
|
48
|
+
} );
|
|
49
|
+
|
|
50
|
+
expect( loadPromptImpl ).toHaveBeenCalledWith( 'test@v1', { name: 'Ada' }, '/workflow' );
|
|
51
|
+
expect( resolvePromptSkillsImpl ).toHaveBeenCalledWith( loadedPrompt, [] );
|
|
52
|
+
expect( result ).toEqual( {
|
|
53
|
+
loadedPrompt,
|
|
54
|
+
tools
|
|
55
|
+
} );
|
|
56
|
+
expect( result.tools ).not.toBe( tools );
|
|
57
|
+
expect( buildLoadSkillToolImpl ).not.toHaveBeenCalled();
|
|
58
|
+
expect( buildSystemSkillsVarImpl ).not.toHaveBeenCalled();
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
it( 'returns null tools when no caller tools or skills resolve', async () => {
|
|
62
|
+
const loadedPrompt = makePrompt( [ { role: 'user', content: 'Hello' } ] );
|
|
63
|
+
loadPromptImpl.mockReturnValue( loadedPrompt );
|
|
64
|
+
|
|
65
|
+
const { prepareTextPrompt } = await importSut();
|
|
66
|
+
const result = prepareTextPrompt( {
|
|
67
|
+
prompt: 'test@v1',
|
|
68
|
+
variables: {},
|
|
69
|
+
skills: []
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
expect( result ).toEqual( {
|
|
73
|
+
loadedPrompt,
|
|
74
|
+
tools: null
|
|
75
|
+
} );
|
|
76
|
+
expect( buildLoadSkillToolImpl ).not.toHaveBeenCalled();
|
|
77
|
+
expect( buildSystemSkillsVarImpl ).not.toHaveBeenCalled();
|
|
78
|
+
} );
|
|
79
|
+
|
|
80
|
+
it( 'adds load_skill and prepends a system message when skills resolve without an existing system message', async () => {
|
|
81
|
+
const loadedPrompt = makePrompt( [ { role: 'user', content: 'Hello' } ] );
|
|
82
|
+
const resolvedSkills = [ { name: 'copy', description: 'Copy guidance', instructions: '# Copy' } ];
|
|
83
|
+
loadPromptImpl.mockReturnValue( loadedPrompt );
|
|
84
|
+
resolvePromptSkillsImpl.mockReturnValue( resolvedSkills );
|
|
85
|
+
|
|
86
|
+
const { prepareTextPrompt } = await importSut();
|
|
87
|
+
const result = prepareTextPrompt( {
|
|
88
|
+
prompt: 'test@v1',
|
|
89
|
+
variables: {},
|
|
90
|
+
skills: resolvedSkills
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
expect( buildLoadSkillToolImpl ).toHaveBeenCalledWith( resolvedSkills );
|
|
94
|
+
expect( buildSystemSkillsVarImpl ).toHaveBeenCalledWith( resolvedSkills );
|
|
95
|
+
expect( result.tools.load_skill ).toEqual( { type: 'load-skill-tool' } );
|
|
96
|
+
expect( loadedPrompt.messages ).toEqual( [
|
|
97
|
+
{ role: 'system', content: 'Available skills:\n- copy: Copy guidance' },
|
|
98
|
+
{ role: 'user', content: 'Hello' }
|
|
99
|
+
] );
|
|
100
|
+
} );
|
|
101
|
+
|
|
102
|
+
it( 'merges skill instructions into an existing system message', async () => {
|
|
103
|
+
const loadedPrompt = makePrompt( [
|
|
104
|
+
{ role: 'system', content: 'You are concise.' },
|
|
105
|
+
{ role: 'user', content: 'Hello' }
|
|
106
|
+
] );
|
|
107
|
+
const resolvedSkills = [ { name: 'copy', description: 'Copy guidance', instructions: '# Copy' } ];
|
|
108
|
+
loadPromptImpl.mockReturnValue( loadedPrompt );
|
|
109
|
+
resolvePromptSkillsImpl.mockReturnValue( resolvedSkills );
|
|
110
|
+
|
|
111
|
+
const { prepareTextPrompt } = await importSut();
|
|
112
|
+
prepareTextPrompt( {
|
|
113
|
+
prompt: 'test@v1',
|
|
114
|
+
variables: {},
|
|
115
|
+
skills: resolvedSkills
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
expect( loadedPrompt.messages ).toEqual( [
|
|
119
|
+
{ role: 'system', content: 'You are concise.\n\nAvailable skills:\n- copy: Copy guidance' },
|
|
120
|
+
{ role: 'user', content: 'Hello' }
|
|
121
|
+
] );
|
|
122
|
+
} );
|
|
123
|
+
|
|
124
|
+
it( 'allows caller tools to override the generated load_skill tool', async () => {
|
|
125
|
+
const loadedPrompt = makePrompt( [ { role: 'user', content: 'Hello' } ] );
|
|
126
|
+
const resolvedSkills = [ { name: 'copy', description: 'Copy guidance', instructions: '# Copy' } ];
|
|
127
|
+
const callerLoadSkill = { type: 'caller-load-skill-tool' };
|
|
128
|
+
loadPromptImpl.mockReturnValue( loadedPrompt );
|
|
129
|
+
resolvePromptSkillsImpl.mockReturnValue( resolvedSkills );
|
|
130
|
+
|
|
131
|
+
const { prepareTextPrompt } = await importSut();
|
|
132
|
+
const result = prepareTextPrompt( {
|
|
133
|
+
prompt: 'test@v1',
|
|
134
|
+
variables: {},
|
|
135
|
+
skills: resolvedSkills,
|
|
136
|
+
tools: { load_skill: callerLoadSkill }
|
|
137
|
+
} );
|
|
138
|
+
|
|
139
|
+
expect( result.tools.load_skill ).toBe( callerLoadSkill );
|
|
140
|
+
} );
|
|
141
|
+
} );
|
|
@@ -80,6 +80,25 @@ export const loadColocatedSkills = promptDir => {
|
|
|
80
80
|
return loadPromptSkills( [ './skills/' ], promptDir );
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Resolve all skills available to a prompt.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} prompt - Loaded prompt object
|
|
87
|
+
* @param {{ name: string, description: string, instructions: string }[]} [callerSkills] - Inline caller-provided skills
|
|
88
|
+
* @returns {{ name: string, description: string, instructions: string }[]}
|
|
89
|
+
*/
|
|
90
|
+
export const resolvePromptSkills = ( prompt, callerSkills = [] ) => {
|
|
91
|
+
const hasExplicitSkills = prompt.config.skills && prompt.promptFileDir;
|
|
92
|
+
const frontmatterSkills = hasExplicitSkills ?
|
|
93
|
+
loadPromptSkills( prompt.config.skills, prompt.promptFileDir ) :
|
|
94
|
+
[];
|
|
95
|
+
const autoSkills = !hasExplicitSkills && prompt.promptFileDir ?
|
|
96
|
+
loadColocatedSkills( prompt.promptFileDir ) :
|
|
97
|
+
[];
|
|
98
|
+
|
|
99
|
+
return [ ...frontmatterSkills, ...autoSkills, ...callerSkills ];
|
|
100
|
+
};
|
|
101
|
+
|
|
83
102
|
/**
|
|
84
103
|
* Build the skills system message content listing available skills.
|
|
85
104
|
*
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { FatalError, ValidationError } from '@outputai/core';
|
|
6
|
+
|
|
7
|
+
const aiMocks = vi.hoisted( () => ( {
|
|
8
|
+
tool: vi.fn( def => ( { ...def, _tool: true } ) )
|
|
9
|
+
} ) );
|
|
10
|
+
|
|
11
|
+
vi.mock( 'ai', () => ( {
|
|
12
|
+
tool: ( ...args ) => aiMocks.tool( ...args )
|
|
13
|
+
} ) );
|
|
14
|
+
|
|
15
|
+
const importSut = async () => import( './skill.js' );
|
|
16
|
+
|
|
17
|
+
const makeTempDir = () => mkdtempSync( join( tmpdir(), 'skill-test-' ) );
|
|
18
|
+
|
|
19
|
+
describe( 'skill', () => {
|
|
20
|
+
it( 'creates an inline skill with a default description', async () => {
|
|
21
|
+
const { skill } = await importSut();
|
|
22
|
+
|
|
23
|
+
expect( skill( { name: 'writer', instructions: '# Writer' } ) ).toEqual( {
|
|
24
|
+
name: 'writer',
|
|
25
|
+
description: 'writer',
|
|
26
|
+
instructions: '# Writer'
|
|
27
|
+
} );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
it( 'throws when name or instructions are missing', async () => {
|
|
31
|
+
const { skill } = await importSut();
|
|
32
|
+
|
|
33
|
+
expect( () => skill( { instructions: '# Missing name' } ) ).toThrow( ValidationError );
|
|
34
|
+
expect( () => skill( { name: 'missing_instructions' } ) ).toThrow( ValidationError );
|
|
35
|
+
} );
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
describe( 'skill file loading', () => {
|
|
39
|
+
it( 'loads a skill file using frontmatter metadata', async () => {
|
|
40
|
+
const dir = makeTempDir();
|
|
41
|
+
const filePath = join( dir, 'copy.md' );
|
|
42
|
+
writeFileSync( filePath, `---
|
|
43
|
+
name: copywriter
|
|
44
|
+
description: Writes concise copy
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
# Copy
|
|
48
|
+
Write clearly.
|
|
49
|
+
` );
|
|
50
|
+
|
|
51
|
+
const { loadSkillFile } = await importSut();
|
|
52
|
+
|
|
53
|
+
expect( loadSkillFile( filePath ) ).toEqual( {
|
|
54
|
+
name: 'copywriter',
|
|
55
|
+
description: 'Writes concise copy',
|
|
56
|
+
instructions: '# Copy\nWrite clearly.'
|
|
57
|
+
} );
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
it( 'uses the markdown filename when frontmatter omits name and description', async () => {
|
|
61
|
+
const dir = makeTempDir();
|
|
62
|
+
const filePath = join( dir, 'research.md' );
|
|
63
|
+
writeFileSync( filePath, '# Research\nSearch carefully.\n' );
|
|
64
|
+
|
|
65
|
+
const { loadSkillFile } = await importSut();
|
|
66
|
+
|
|
67
|
+
expect( loadSkillFile( filePath ) ).toEqual( {
|
|
68
|
+
name: 'research',
|
|
69
|
+
description: 'research',
|
|
70
|
+
instructions: '# Research\nSearch carefully.'
|
|
71
|
+
} );
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
it( 'loads skills from a file path and a directory path, sorting directory markdown files', async () => {
|
|
75
|
+
const promptDir = makeTempDir();
|
|
76
|
+
const skillsDir = join( promptDir, 'skills' );
|
|
77
|
+
mkdirSync( skillsDir );
|
|
78
|
+
writeFileSync( join( promptDir, 'single.md' ), '# Single' );
|
|
79
|
+
writeFileSync( join( skillsDir, 'b.md' ), '# B' );
|
|
80
|
+
writeFileSync( join( skillsDir, 'a.md' ), '# A' );
|
|
81
|
+
writeFileSync( join( skillsDir, 'ignore.txt' ), '# Ignored' );
|
|
82
|
+
|
|
83
|
+
const { loadPromptSkills } = await importSut();
|
|
84
|
+
const result = loadPromptSkills( [ './single.md', './skills' ], promptDir );
|
|
85
|
+
|
|
86
|
+
expect( result.map( s => s.name ) ).toEqual( [ 'single', 'a', 'b' ] );
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
it( 'throws FatalError for a missing skill path', async () => {
|
|
90
|
+
const { loadPromptSkills } = await importSut();
|
|
91
|
+
|
|
92
|
+
expect( () => loadPromptSkills( './missing.md', makeTempDir() ) ).toThrow( FatalError );
|
|
93
|
+
} );
|
|
94
|
+
|
|
95
|
+
it( 'loads colocated skills and returns an empty array when no skills directory exists', async () => {
|
|
96
|
+
const promptDir = makeTempDir();
|
|
97
|
+
const skillsDir = join( promptDir, 'skills' );
|
|
98
|
+
mkdirSync( skillsDir );
|
|
99
|
+
writeFileSync( join( skillsDir, 'style.md' ), '# Style' );
|
|
100
|
+
|
|
101
|
+
const { loadColocatedSkills } = await importSut();
|
|
102
|
+
|
|
103
|
+
expect( loadColocatedSkills( promptDir ).map( s => s.name ) ).toEqual( [ 'style' ] );
|
|
104
|
+
expect( loadColocatedSkills( makeTempDir() ) ).toEqual( [] );
|
|
105
|
+
} );
|
|
106
|
+
} );
|
|
107
|
+
|
|
108
|
+
describe( 'resolvePromptSkills', () => {
|
|
109
|
+
it( 'uses explicit prompt skills and skips colocated auto-discovery', async () => {
|
|
110
|
+
const promptDir = makeTempDir();
|
|
111
|
+
const explicitDir = join( promptDir, 'explicit' );
|
|
112
|
+
const autoDir = join( promptDir, 'skills' );
|
|
113
|
+
mkdirSync( explicitDir );
|
|
114
|
+
mkdirSync( autoDir );
|
|
115
|
+
writeFileSync( join( explicitDir, 'explicit.md' ), '# Explicit' );
|
|
116
|
+
writeFileSync( join( autoDir, 'auto.md' ), '# Auto' );
|
|
117
|
+
const callerSkill = { name: 'caller', description: 'Caller', instructions: '# Caller' };
|
|
118
|
+
|
|
119
|
+
const { resolvePromptSkills } = await importSut();
|
|
120
|
+
const result = resolvePromptSkills( {
|
|
121
|
+
config: { skills: [ './explicit' ] },
|
|
122
|
+
promptFileDir: promptDir
|
|
123
|
+
}, [ callerSkill ] );
|
|
124
|
+
|
|
125
|
+
expect( result.map( s => s.name ) ).toEqual( [ 'explicit', 'caller' ] );
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
it( 'auto-discovers colocated skills when prompt config has no explicit skills', async () => {
|
|
129
|
+
const promptDir = makeTempDir();
|
|
130
|
+
const skillsDir = join( promptDir, 'skills' );
|
|
131
|
+
mkdirSync( skillsDir );
|
|
132
|
+
writeFileSync( join( skillsDir, 'auto.md' ), '# Auto' );
|
|
133
|
+
|
|
134
|
+
const { resolvePromptSkills } = await importSut();
|
|
135
|
+
const result = resolvePromptSkills( { config: {}, promptFileDir: promptDir } );
|
|
136
|
+
|
|
137
|
+
expect( result.map( s => s.name ) ).toEqual( [ 'auto' ] );
|
|
138
|
+
} );
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
describe( 'skill prompt helpers', () => {
|
|
142
|
+
it( 'builds the system skills message', async () => {
|
|
143
|
+
const { buildSystemSkillsVar } = await importSut();
|
|
144
|
+
|
|
145
|
+
expect( buildSystemSkillsVar( [
|
|
146
|
+
{ name: 'copy', description: 'Copywriting' },
|
|
147
|
+
{ name: 'research', description: 'Research' }
|
|
148
|
+
] ) ).toBe(
|
|
149
|
+
'Available skills (use load_skill to get full instructions):\n' +
|
|
150
|
+
'- copy: Copywriting\n' +
|
|
151
|
+
'- research: Research'
|
|
152
|
+
);
|
|
153
|
+
} );
|
|
154
|
+
|
|
155
|
+
it( 'builds a load_skill tool that returns instructions or an available-skills message', async () => {
|
|
156
|
+
const skills = [
|
|
157
|
+
{ name: 'copy', description: 'Copywriting', instructions: '# Copy' },
|
|
158
|
+
{ name: 'research', description: 'Research', instructions: '# Research' }
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const { buildLoadSkillTool } = await importSut();
|
|
162
|
+
const result = buildLoadSkillTool( skills );
|
|
163
|
+
|
|
164
|
+
expect( aiMocks.tool ).toHaveBeenCalledWith( expect.objectContaining( {
|
|
165
|
+
description: 'Get detailed instructions for a named skill',
|
|
166
|
+
inputSchema: expect.any( Object ),
|
|
167
|
+
execute: expect.any( Function )
|
|
168
|
+
} ) );
|
|
169
|
+
expect( result.execute( { name: 'copy' } ) ).toBe( '# Copy' );
|
|
170
|
+
expect( result.execute( { name: 'missing' } ) ).toBe( 'Skill "missing" not found. Available: copy, research' );
|
|
171
|
+
} );
|
|
172
|
+
} );
|
|
@@ -7,24 +7,50 @@ export const promptSchema = z.object( {
|
|
|
7
7
|
model: z.string(),
|
|
8
8
|
temperature: z.number().optional(),
|
|
9
9
|
maxTokens: z.number().optional(),
|
|
10
|
-
|
|
10
|
+
n: z.number().int().positive().optional(),
|
|
11
|
+
maxImagesPerCall: z.number().int().positive().optional(),
|
|
12
|
+
size: z.string().regex( /^\d+x\d+$/ ).optional(),
|
|
13
|
+
aspectRatio: z.string().regex( /^\d+:\d+$/ ).optional(),
|
|
14
|
+
seed: z.number().int().optional(),
|
|
15
|
+
skills: z.union( [ z.string().min( 1 ), z.array( z.string().min( 1 ) ) ] ).optional(),
|
|
16
|
+
tools: z.record( z.string(), z.object( {} ).loose() ).optional(),
|
|
11
17
|
providerOptions: z.object( {
|
|
12
18
|
thinking: z.object( {
|
|
13
19
|
type: z.enum( [ 'enabled', 'disabled' ] ),
|
|
14
20
|
budgetTokens: z.number().optional()
|
|
15
|
-
} ).
|
|
16
|
-
} ).
|
|
17
|
-
} ).
|
|
21
|
+
} ).loose().optional()
|
|
22
|
+
} ).loose().optional()
|
|
23
|
+
} ).loose(),
|
|
18
24
|
messages: z.array(
|
|
19
25
|
z.object( {
|
|
20
26
|
role: z.string(),
|
|
21
27
|
content: z.string()
|
|
22
28
|
} ).strict()
|
|
23
|
-
)
|
|
24
|
-
|
|
29
|
+
),
|
|
30
|
+
instructions: z.string().trim().min( 1 ).nullable().optional()
|
|
31
|
+
} ).strict().superRefine( ( prompt, ctx ) => {
|
|
32
|
+
const hasMessages = prompt.messages.length > 0;
|
|
33
|
+
const hasInstructions = !!prompt.instructions;
|
|
34
|
+
if ( !hasMessages && !hasInstructions ) {
|
|
35
|
+
ctx.addIssue( {
|
|
36
|
+
code: 'custom',
|
|
37
|
+
path: [ 'messages', 'instructions' ],
|
|
38
|
+
message: 'Prompt must include either message blocks or plain instructions.'
|
|
39
|
+
} );
|
|
40
|
+
}
|
|
41
|
+
if ( hasMessages && hasInstructions ) {
|
|
42
|
+
ctx.addIssue( {
|
|
43
|
+
code: 'custom',
|
|
44
|
+
path: [ 'messages', 'instructions' ],
|
|
45
|
+
message: 'Prompt cannot include both message blocks and plain instructions.'
|
|
46
|
+
} );
|
|
47
|
+
}
|
|
48
|
+
} );
|
|
25
49
|
|
|
26
50
|
const SNAKE_CASE_WARNINGS = {
|
|
27
51
|
max_tokens: 'maxTokens',
|
|
52
|
+
max_images_per_call: 'maxImagesPerCall',
|
|
53
|
+
aspect_ratio: 'aspectRatio',
|
|
28
54
|
budget_tokens: 'budgetTokens',
|
|
29
55
|
top_p: 'topP',
|
|
30
56
|
top_k: 'topK',
|