@outputai/llm 0.6.1-dev.daae905.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.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +15 -9
  3. package/src/agent.spec.js +295 -214
  4. package/src/ai_model.js +79 -36
  5. package/src/ai_model.spec.js +31 -13
  6. package/src/ai_sdk.js +55 -79
  7. package/src/ai_sdk.spec.js +464 -611
  8. package/src/ai_sdk_options.js +61 -0
  9. package/src/ai_sdk_options.spec.js +164 -0
  10. package/src/cost/index.js +1 -1
  11. package/src/index.d.ts +230 -175
  12. package/src/index.js +2 -2
  13. package/src/prompt/escape.js +65 -0
  14. package/src/prompt/escape.spec.js +159 -0
  15. package/src/{load_content.js → prompt/load_content.js} +1 -22
  16. package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
  17. package/src/prompt/loader.js +49 -0
  18. package/src/prompt/loader.spec.js +274 -0
  19. package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
  20. package/src/prompt/parser.js +19 -0
  21. package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
  22. package/src/prompt/prepare_text.js +27 -0
  23. package/src/prompt/prepare_text.spec.js +141 -0
  24. package/src/{skill.js → prompt/skill.js} +19 -0
  25. package/src/prompt/skill.spec.js +172 -0
  26. package/src/{prompt_validations.js → prompt/validations.js} +32 -6
  27. package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
  28. package/src/utils/__fixtures__/image_response.json +38 -0
  29. package/src/utils/__fixtures__/stream_response.json +294 -0
  30. package/src/utils/__fixtures__/text_response.json +201 -0
  31. package/src/utils/error_handler.js +65 -0
  32. package/src/utils/error_handler.spec.js +195 -0
  33. package/src/utils/image.js +10 -0
  34. package/src/utils/image.spec.js +20 -0
  35. package/src/utils/response_wrappers.js +46 -19
  36. package/src/utils/response_wrappers.spec.js +130 -70
  37. package/src/utils/source_extraction.js +17 -27
  38. package/src/utils/trace.js +2 -3
  39. package/src/utils/trace.spec.js +9 -13
  40. package/src/validations.js +54 -2
  41. package/src/validations.spec.js +166 -0
  42. package/src/parser.js +0 -28
  43. package/src/prompt_loader.js +0 -80
  44. package/src/prompt_loader.spec.js +0 -358
  45. 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( 'throws error when content is empty', () => {
32
+ it( 'parses raw instructions when no message blocks are present', () => {
32
33
  const raw = `---
33
- provider: anthropic
34
- model: claude-3-5-sonnet-20241022
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
- expect( () => {
40
- parsePrompt( raw );
41
- } ).toThrow( /no content after frontmatter/ );
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 no valid message blocks found', () => {
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
- This is just plain text without any message tags.`;
73
+ `;
51
74
 
52
75
  expect( () => {
53
- parsePrompt( raw );
54
- } ).toThrow( /No valid message blocks found/ );
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( 'should use providerOptions with budgetTokens in camelCase', () => {
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( 'should parse snake_case fields as-is (validation catches them later)', () => {
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 ); // snake_case preserved
120
- expect( result.config.providerOptions.thinking.budgetTokens ).toBeUndefined(); // camelCase not set
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
- tools: z.record( z.string(), z.object( {} ).passthrough() ).optional(),
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
- } ).passthrough().optional()
16
- } ).passthrough().optional()
17
- } ).passthrough(),
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
- } ).strict();
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',