@outputai/llm 0.11.0 → 0.11.1-next.2223fa5.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 (69) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +105 -83
  3. package/src/agent.spec.js +312 -225
  4. package/src/ai_provider.js +2 -2
  5. package/src/ai_sdk_options.js +93 -39
  6. package/src/ai_sdk_options.spec.js +314 -154
  7. package/src/consts.js +6 -0
  8. package/src/generate.js +81 -0
  9. package/src/generate.spec.js +466 -0
  10. package/src/index.d.ts +222 -242
  11. package/src/index.js +3 -4
  12. package/src/prompt/content.js +70 -0
  13. package/src/prompt/content.spec.js +208 -0
  14. package/src/prompt/{escape.js → interpolations.js} +11 -18
  15. package/src/prompt/interpolations.spec.js +109 -0
  16. package/src/prompt/loader.full.spec.js +293 -0
  17. package/src/prompt/loader.js +45 -39
  18. package/src/prompt/loader.spec.js +186 -289
  19. package/src/prompt/markup/attributes.js +52 -0
  20. package/src/prompt/markup/attributes.spec.js +132 -0
  21. package/src/prompt/markup/nodes.js +71 -0
  22. package/src/prompt/markup/nodes.spec.js +333 -0
  23. package/src/prompt/markup/tokenizer.js +3 -0
  24. package/src/prompt/markup/tokenizer.spec.js +149 -0
  25. package/src/prompt/markup/tokens.js +26 -0
  26. package/src/prompt/markup/tokens.spec.js +250 -0
  27. package/src/prompt/validations.js +98 -67
  28. package/src/prompt/validations.spec.js +204 -47
  29. package/src/{prompt/load_content.js → utils/file.js} +12 -15
  30. package/src/utils/file.spec.js +89 -0
  31. package/src/utils/models.js +15 -0
  32. package/src/utils/models.spec.js +119 -0
  33. package/src/utils/skills.js +74 -0
  34. package/src/utils/skills.spec.js +168 -0
  35. package/src/utils/sources.js +48 -0
  36. package/src/utils/sources.spec.js +122 -0
  37. package/src/utils/stream.js +22 -0
  38. package/src/utils/stream.spec.js +55 -0
  39. package/src/utils/tools.js +47 -0
  40. package/src/utils/tools.spec.js +167 -0
  41. package/src/utils/wrap.js +148 -0
  42. package/src/utils/wrap.spec.js +359 -0
  43. package/src/validations.js +149 -27
  44. package/src/validations.spec.js +451 -53
  45. package/src/ai_model.js +0 -60
  46. package/src/ai_model.spec.js +0 -259
  47. package/src/ai_sdk.js +0 -92
  48. package/src/ai_sdk.spec.js +0 -564
  49. package/src/prompt/block_options.js +0 -58
  50. package/src/prompt/block_options.spec.js +0 -71
  51. package/src/prompt/blocks.js +0 -47
  52. package/src/prompt/blocks.spec.js +0 -63
  53. package/src/prompt/escape.spec.js +0 -159
  54. package/src/prompt/load_content.spec.js +0 -83
  55. package/src/prompt/loader_validation.spec.js +0 -128
  56. package/src/prompt/parser.js +0 -16
  57. package/src/prompt/parser.spec.js +0 -186
  58. package/src/prompt/prepare_text.js +0 -27
  59. package/src/prompt/prepare_text.spec.js +0 -141
  60. package/src/prompt/skill.js +0 -128
  61. package/src/prompt/skill.spec.js +0 -172
  62. package/src/utils/message.js +0 -3
  63. package/src/utils/message.spec.js +0 -29
  64. package/src/utils/response_wrappers.js +0 -100
  65. package/src/utils/response_wrappers.spec.js +0 -240
  66. package/src/utils/source_extraction.js +0 -53
  67. package/src/utils/source_extraction.spec.js +0 -194
  68. package/src/utils/trace.js +0 -19
  69. package/src/utils/trace.spec.js +0 -112
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const getProvider = vi.hoisted( () => vi.fn() );
4
+
5
+ vi.mock( '../ai_provider.js', () => ( {
6
+ getProvider
7
+ } ) );
8
+
9
+ import { loadImageModel, loadTextModel } from './models.js';
10
+
11
+ afterEach( () => {
12
+ vi.clearAllMocks();
13
+ } );
14
+
15
+ describe( 'loadTextModel', () => {
16
+ it( 'loads a text model using the prompt provider and model', () => {
17
+ const provider = vi.fn( model => ( { type: 'text-model', model } ) );
18
+ getProvider.mockReturnValue( provider );
19
+
20
+ const result = loadTextModel( {
21
+ config: {
22
+ provider: 'openai',
23
+ model: 'gpt-4o-mini'
24
+ }
25
+ } );
26
+
27
+ expect( getProvider ).toHaveBeenCalledWith( 'openai' );
28
+ expect( provider ).toHaveBeenCalledWith( 'gpt-4o-mini' );
29
+ expect( result ).toEqual( {
30
+ type: 'text-model',
31
+ model: 'gpt-4o-mini'
32
+ } );
33
+ } );
34
+
35
+ it( 'propagates provider lookup errors', () => {
36
+ getProvider.mockImplementation( () => {
37
+ throw new Error( 'Unsupported provider "missing"' );
38
+ } );
39
+
40
+ expect( () => loadTextModel( {
41
+ config: {
42
+ provider: 'missing',
43
+ model: 'model'
44
+ }
45
+ } ) ).toThrow( 'Unsupported provider "missing"' );
46
+ } );
47
+ } );
48
+
49
+ describe( 'loadImageModel', () => {
50
+ it( 'loads an image model using provider.image', () => {
51
+ const textProvider = vi.fn();
52
+ textProvider.image = vi.fn( model => ( { type: 'image-model', model } ) );
53
+ getProvider.mockReturnValue( textProvider );
54
+
55
+ const result = loadImageModel( {
56
+ config: {
57
+ provider: 'openai',
58
+ model: 'gpt-image-1'
59
+ }
60
+ } );
61
+
62
+ expect( getProvider ).toHaveBeenCalledWith( 'openai' );
63
+ expect( textProvider.image ).toHaveBeenCalledWith( 'gpt-image-1' );
64
+ expect( textProvider ).not.toHaveBeenCalled();
65
+ expect( result ).toEqual( {
66
+ type: 'image-model',
67
+ model: 'gpt-image-1'
68
+ } );
69
+ } );
70
+
71
+ it( 'falls back to provider.imageModel', () => {
72
+ const provider = vi.fn();
73
+ provider.imageModel = vi.fn( model => ( { type: 'legacy-image-model', model } ) );
74
+ getProvider.mockReturnValue( provider );
75
+
76
+ const result = loadImageModel( {
77
+ config: {
78
+ provider: 'custom',
79
+ model: 'image-v1'
80
+ }
81
+ } );
82
+
83
+ expect( provider.imageModel ).toHaveBeenCalledWith( 'image-v1' );
84
+ expect( result ).toEqual( {
85
+ type: 'legacy-image-model',
86
+ model: 'image-v1'
87
+ } );
88
+ } );
89
+
90
+ it( 'prefers provider.image when both image factories exist', () => {
91
+ const provider = vi.fn();
92
+ provider.image = vi.fn( model => ( { type: 'image', model } ) );
93
+ provider.imageModel = vi.fn( model => ( { type: 'imageModel', model } ) );
94
+ getProvider.mockReturnValue( provider );
95
+
96
+ const result = loadImageModel( {
97
+ config: {
98
+ provider: 'custom',
99
+ model: 'image-v1'
100
+ }
101
+ } );
102
+
103
+ expect( provider.image ).toHaveBeenCalledWith( 'image-v1' );
104
+ expect( provider.imageModel ).not.toHaveBeenCalled();
105
+ expect( result.type ).toBe( 'image' );
106
+ } );
107
+
108
+ it( 'throws a clear error when the provider does not support image models', () => {
109
+ getProvider.mockReturnValue( vi.fn() );
110
+
111
+ expect( () => loadImageModel( {
112
+ name: 'foo',
113
+ config: {
114
+ provider: 'azure',
115
+ model: 'gpt-image-1'
116
+ }
117
+ } ) ).toThrow( 'Provider "azure" used by prompt "foo" does not support image models.' );
118
+ } );
119
+ } );
@@ -0,0 +1,74 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { resolve, basename } from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { ValidationError } from '@outputai/core';
5
+
6
+ /**
7
+ * @typedef Skill
8
+ * @property {string} name
9
+ * @property {string} description
10
+ * @property {string} instructions
11
+ */
12
+
13
+ /**
14
+ * Recursively load skill paths. Explicit files may use any extension; directories load `.md` files only.
15
+ * Frontmatter may provide `name` and `description`; body becomes the instructions.
16
+ *
17
+ * @param {string[]} paths
18
+ * @param {boolean} markdownFilesOnly
19
+ * @returns {Skills[]}
20
+ */
21
+ const recursiveLoadSkillPaths = ( paths, markdownFilesOnly ) => {
22
+ const loaded = [];
23
+ for ( const path of paths ) {
24
+ if ( !existsSync( path ) ) {
25
+ throw new ValidationError( `Skill path "${path}" not found` );
26
+ }
27
+ const stats = lstatSync( path );
28
+ if ( stats.isSymbolicLink() ) {
29
+ continue;
30
+ }
31
+ if ( stats.isDirectory() ) {
32
+ loaded.push( ...recursiveLoadSkillPaths(
33
+ readdirSync( path ).sort().map( f => resolve( path, f ) ),
34
+ true
35
+ ) );
36
+ continue;
37
+ }
38
+ if ( markdownFilesOnly && !path.endsWith( '.md' ) ) {
39
+ continue;
40
+ }
41
+
42
+ const raw = readFileSync( path, 'utf-8' );
43
+ const { data, content } = matter( raw );
44
+ loaded.push( {
45
+ name: data.name ?? basename( path, '.md' ),
46
+ description: data.description ?? basename( path, '.md' ),
47
+ instructions: content.trim()
48
+ } );
49
+ }
50
+ return loaded;
51
+ };
52
+
53
+ /**
54
+ * Recursively load skill files without following symbolic links.
55
+ *
56
+ * @param {string[]} paths
57
+ * @returns {Skills[]}
58
+ */
59
+ export const recursiveLoadSkillFile = paths => recursiveLoadSkillPaths( paths, false );
60
+
61
+ /**
62
+ * Resolve all skills available to a prompt.
63
+ *
64
+ * @param {object} prompt - Loaded prompt object
65
+ * @returns {{ name: string, description: string, instructions: string }[]}
66
+ */
67
+ export const loadSkills = prompt => {
68
+ const baseDir = prompt.fileDir;
69
+
70
+ // skills referenced in the prompt config
71
+ const promptSkillPaths = prompt.config.skills.map( f => resolve( baseDir, f ) );
72
+
73
+ return recursiveLoadSkillFile( promptSkillPaths );
74
+ };
@@ -0,0 +1,168 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ValidationError } from '@outputai/core';
6
+ import { loadSkills, recursiveLoadSkillFile } from './skills.js';
7
+
8
+ const dirs = [];
9
+
10
+ const tempDir = () => {
11
+ const dir = mkdtempSync( join( tmpdir(), 'skills-' ) );
12
+ dirs.push( dir );
13
+ return dir;
14
+ };
15
+
16
+ const writeSkill = ( dir, filename, body ) => {
17
+ writeFileSync( join( dir, filename ), body );
18
+ };
19
+
20
+ afterEach( () => {
21
+ for ( const dir of dirs.splice( 0 ) ) {
22
+ rmSync( dir, { recursive: true, force: true } );
23
+ }
24
+ } );
25
+
26
+ describe( 'recursiveLoadSkillFile', () => {
27
+ it( 'returns an empty array for no paths', () => {
28
+ expect( recursiveLoadSkillFile( [] ) ).toEqual( [] );
29
+ } );
30
+
31
+ it( 'throws ValidationError when a path is missing', () => {
32
+ const missing = join( tempDir(), 'gone.md' );
33
+
34
+ expect( () => recursiveLoadSkillFile( [ missing ] ) ).toThrow( ValidationError );
35
+ expect( () => recursiveLoadSkillFile( [ missing ] ) ).toThrow( `Skill path "${missing}" not found` );
36
+ } );
37
+
38
+ it( 'loads an explicitly listed non-md file', () => {
39
+ const dir = tempDir();
40
+ writeSkill( dir, 'notes.txt', 'not a skill' );
41
+
42
+ expect( recursiveLoadSkillFile( [ join( dir, 'notes.txt' ) ] ) ).toEqual( [
43
+ { name: 'notes.txt', description: 'notes.txt', instructions: 'not a skill' }
44
+ ] );
45
+ } );
46
+
47
+ it( 'loads a markdown file and defaults name/description to the basename', () => {
48
+ const dir = tempDir();
49
+ writeSkill( dir, 'writer.md', '# Writer\n\nDo the thing.\n' );
50
+
51
+ expect( recursiveLoadSkillFile( [ join( dir, 'writer.md' ) ] ) ).toEqual( [ {
52
+ name: 'writer',
53
+ description: 'writer',
54
+ instructions: '# Writer\n\nDo the thing.'
55
+ } ] );
56
+ } );
57
+
58
+ it( 'uses frontmatter name and description when present', () => {
59
+ const dir = tempDir();
60
+ writeSkill( dir, 'file.md', [
61
+ '---',
62
+ 'name: copywriter',
63
+ 'description: Writes marketing copy',
64
+ '---',
65
+ '',
66
+ 'Use a confident tone.'
67
+ ].join( '\n' ) );
68
+
69
+ expect( recursiveLoadSkillFile( [ join( dir, 'file.md' ) ] ) ).toEqual( [ {
70
+ name: 'copywriter',
71
+ description: 'Writes marketing copy',
72
+ instructions: 'Use a confident tone.'
73
+ } ] );
74
+ } );
75
+
76
+ it( 'returns an empty array for an empty directory', () => {
77
+ expect( recursiveLoadSkillFile( [ tempDir() ] ) ).toEqual( [] );
78
+ } );
79
+
80
+ it( 'loads markdown files from a directory in sorted order and skips other files', () => {
81
+ const dir = tempDir();
82
+ writeSkill( dir, 'z.md', 'zeta' );
83
+ writeSkill( dir, 'a.md', 'alpha' );
84
+ writeSkill( dir, 'readme.txt', 'ignore' );
85
+
86
+ expect( recursiveLoadSkillFile( [ dir ] ) ).toEqual( [
87
+ { name: 'a', description: 'a', instructions: 'alpha' },
88
+ { name: 'z', description: 'z', instructions: 'zeta' }
89
+ ] );
90
+ } );
91
+
92
+ it( 'recurses into nested directories', () => {
93
+ const dir = tempDir();
94
+ const nested = join( dir, 'nested' );
95
+ mkdirSync( nested );
96
+ writeSkill( dir, 'root.md', 'root' );
97
+ writeSkill( nested, 'child.md', 'child' );
98
+
99
+ expect( recursiveLoadSkillFile( [ dir ] ) ).toEqual( [
100
+ { name: 'child', description: 'child', instructions: 'child' },
101
+ { name: 'root', description: 'root', instructions: 'root' }
102
+ ] );
103
+ } );
104
+
105
+ it( 'loads files from a directory whose name ends in .md', () => {
106
+ const dir = tempDir();
107
+ const nested = join( dir, 'nested.md' );
108
+ mkdirSync( nested );
109
+ writeSkill( nested, 'child.md', 'child' );
110
+
111
+ expect( recursiveLoadSkillFile( [ nested ] ) ).toEqual( [
112
+ { name: 'child', description: 'child', instructions: 'child' }
113
+ ] );
114
+ } );
115
+
116
+ it( 'does not follow directory symlinks', () => {
117
+ const dir = tempDir();
118
+ const target = tempDir();
119
+ writeSkill( target, 'external.md', 'external' );
120
+ symlinkSync( target, join( dir, 'linked' ), 'dir' );
121
+
122
+ expect( recursiveLoadSkillFile( [ dir ] ) ).toEqual( [] );
123
+ } );
124
+ } );
125
+
126
+ describe( 'loadSkills', () => {
127
+ it( 'returns an empty array when the prompt lists no skills', () => {
128
+ expect( loadSkills( { fileDir: tempDir(), config: { skills: [] } } ) ).toEqual( [] );
129
+ } );
130
+
131
+ it( 'resolves skill paths relative to the prompt fileDir', () => {
132
+ const fileDir = tempDir();
133
+ const skillsDir = join( fileDir, 'skills' );
134
+ mkdirSync( skillsDir );
135
+ writeSkill( skillsDir, 'reviewer.md', [
136
+ '---',
137
+ 'name: reviewer',
138
+ 'description: Reviews drafts',
139
+ '---',
140
+ 'Check the brief.'
141
+ ].join( '\n' ) );
142
+
143
+ expect( loadSkills( {
144
+ fileDir,
145
+ config: { skills: [ 'skills/reviewer.md' ] }
146
+ } ) ).toEqual( [ {
147
+ name: 'reviewer',
148
+ description: 'Reviews drafts',
149
+ instructions: 'Check the brief.'
150
+ } ] );
151
+ } );
152
+
153
+ it( 'loads a directory listed in prompt config', () => {
154
+ const fileDir = tempDir();
155
+ const skillsDir = join( fileDir, 'skills' );
156
+ mkdirSync( skillsDir );
157
+ writeSkill( skillsDir, 'one.md', 'first' );
158
+
159
+ expect( loadSkills( {
160
+ fileDir,
161
+ config: { skills: [ 'skills' ] }
162
+ } ) ).toEqual( [ {
163
+ name: 'one',
164
+ description: 'one',
165
+ instructions: 'first'
166
+ } ] );
167
+ } );
168
+ } );
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /** Builds the final source shape */
4
+ const buildSource = ( { url, title } ) => {
5
+ const trimmedUrl = url.trim();
6
+ return {
7
+ type: 'source',
8
+ sourceType: 'url',
9
+ id: createHash( 'sha256' ).update( trimmedUrl ).digest( 'hex' ).slice( 0, 16 ),
10
+ url: trimmedUrl,
11
+ title: title ?? ''
12
+ };
13
+ };
14
+
15
+ /** Return value it is array, otherwise return [] */
16
+ const asArray = v => Array.isArray( v ) ? v : [];
17
+
18
+ /**
19
+ * Extracts source URLs from search tool results embedded in AI SDK step data.
20
+ *
21
+ * Detects any tool result containing a `results[]` array whose items have a `url` string field.
22
+ * This covers perplexitySearch, tavilySearch, exaSearch, and any future tool with the same shape.
23
+ *
24
+ * @param {Array} steps - AI SDK response steps (response.steps)
25
+ * @returns {Array<{ type: string, sourceType: string, id: string, url: string, title: string }>}
26
+ */
27
+ const extractSourcesFromSteps = steps =>
28
+ asArray( steps )
29
+ .flatMap( step => asArray( step?.toolResults ) )
30
+ .flatMap( toolResult => asArray( toolResult?.output?.results ) )
31
+ .filter( item => typeof item?.url === 'string' && !!item.url.trim() )
32
+ .map( v => buildSource( v ) );
33
+
34
+ /**
35
+ * Extract sources from tools usage and final response, deduplicate, and return.
36
+ *
37
+ * Dedup key is `url` when set, otherwise `id` (document sources have `id` and no `url`).
38
+ * Response sources are preferred over tools when the key matches.
39
+ *
40
+ * @param {object} response AI SDK response
41
+ * @returns {object[]} Merged sources
42
+ */
43
+ export const extractSources = response => {
44
+ const { steps, sources: sourcesFromResponse } = response;
45
+ const sourcesFromTools = extractSourcesFromSteps( steps );
46
+ const allSources = sourcesFromTools.concat( asArray( sourcesFromResponse ) );
47
+ return new Map( allSources.map( s => [ s.url ?? s.id, s ] ) ).values().toArray();
48
+ };
@@ -0,0 +1,122 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { extractSources } from './sources.js';
4
+
5
+ const hashedId = url => createHash( 'sha256' ).update( url ).digest( 'hex' ).slice( 0, 16 );
6
+
7
+ const fromSteps = ( steps, sources = [] ) => extractSources( { steps, sources } );
8
+
9
+ const searchStep = results => ( {
10
+ toolResults: [ { output: { results } } ]
11
+ } );
12
+
13
+ describe( 'extractSources', () => {
14
+ it( 'returns empty when steps and sources are missing or empty', () => {
15
+ expect( extractSources( {} ) ).toEqual( [] );
16
+ expect( fromSteps( undefined ) ).toEqual( [] );
17
+ expect( fromSteps( null ) ).toEqual( [] );
18
+ expect( fromSteps( [] ) ).toEqual( [] );
19
+ expect( fromSteps( [ { text: 'hello' } ] ) ).toEqual( [] );
20
+ } );
21
+
22
+ it( 'treats non-array response sources as empty', () => {
23
+ const steps = [ searchStep( [ { url: 'https://u.test', title: 'T' } ] ) ];
24
+ expect( extractSources( { steps, sources: undefined } ) ).toHaveLength( 1 );
25
+ expect( extractSources( { steps, sources: {} } ) ).toHaveLength( 1 );
26
+ } );
27
+
28
+ it( 'skips tool results that are not a results array with urls', () => {
29
+ const steps = [ {
30
+ toolResults: [
31
+ { output: { answer: 'some text' } },
32
+ { output: { results: [] } },
33
+ { output: { results: [ { title: 'no url' } ] } },
34
+ { output: { results: [ { url: ' ' } ] } },
35
+ { output: 'plain string' }
36
+ ]
37
+ } ];
38
+ expect( fromSteps( steps ) ).toEqual( [] );
39
+ } );
40
+
41
+ it( 'extracts url and title from search-shaped results', () => {
42
+ const sources = fromSteps( [ searchStep( [
43
+ { url: 'https://example.com/1', title: 'Example 1', snippet: 'text' },
44
+ { url: 'https://example.com/2', title: 'Example 2' }
45
+ ] ) ] );
46
+
47
+ expect( sources ).toHaveLength( 2 );
48
+ expect( sources[0] ).toEqual( {
49
+ type: 'source',
50
+ sourceType: 'url',
51
+ id: hashedId( 'https://example.com/1' ),
52
+ url: 'https://example.com/1',
53
+ title: 'Example 1'
54
+ } );
55
+ expect( sources[1].url ).toBe( 'https://example.com/2' );
56
+ } );
57
+
58
+ it( 'keeps the last tool hit when urls repeat', () => {
59
+ const steps = [
60
+ searchStep( [ { url: 'https://dup.com', title: 'First' } ] ),
61
+ searchStep( [ { url: 'https://dup.com', title: 'Second' } ] )
62
+ ];
63
+ const sources = fromSteps( steps );
64
+ expect( sources ).toHaveLength( 1 );
65
+ expect( sources[0].title ).toBe( 'Second' );
66
+ } );
67
+
68
+ it( 'keeps a search hit among mixed tool results', () => {
69
+ const steps = [ {
70
+ toolResults: [
71
+ { output: { calculation: 42 } },
72
+ { output: { results: [ { url: 'https://real.com', title: 'Real' } ] } },
73
+ { output: 'plain string' }
74
+ ]
75
+ } ];
76
+ const sources = fromSteps( steps );
77
+ expect( sources ).toHaveLength( 1 );
78
+ expect( sources[0].url ).toBe( 'https://real.com' );
79
+ } );
80
+
81
+ it( 'defaults title to empty string when missing', () => {
82
+ expect( fromSteps( [ searchStep( [ { url: 'https://notitle.com' } ] ) ] )[0].title ).toBe( '' );
83
+ } );
84
+
85
+ it( 'trims urls for storage and id', () => {
86
+ const sources = fromSteps( [ searchStep( [ { url: ' https://stable.com/path ', title: 'S' } ] ) ] );
87
+ expect( sources[0].url ).toBe( 'https://stable.com/path' );
88
+ expect( sources[0].id ).toBe( hashedId( 'https://stable.com/path' ) );
89
+ expect( sources[0].id ).toHaveLength( 16 );
90
+ } );
91
+
92
+ it( 'lets response sources win on the same url', () => {
93
+ const url = 'https://shared.test';
94
+ const sources = extractSources( {
95
+ steps: [ searchStep( [ { url, title: 'from-tool' } ] ) ],
96
+ sources: [ { url, title: 'from-response', type: 'source', sourceType: 'url', id: 'b' } ]
97
+ } );
98
+
99
+ expect( sources ).toHaveLength( 1 );
100
+ expect( sources[0].title ).toBe( 'from-response' );
101
+ } );
102
+
103
+ it( 'keeps document sources that have id and no url', () => {
104
+ const docA = { type: 'source', sourceType: 'document', id: 'doc-a', mediaType: 'application/pdf', title: 'A' };
105
+ const docB = { type: 'source', sourceType: 'document', id: 'doc-b', mediaType: 'application/pdf', title: 'B' };
106
+
107
+ expect( extractSources( { sources: [ docA, docB ] } ) ).toEqual( [ docA, docB ] );
108
+ } );
109
+
110
+ it( 'keeps url and document sources together', () => {
111
+ const url = 'https://page.test';
112
+ const doc = { type: 'source', sourceType: 'document', id: 'doc-1', title: 'Doc' };
113
+ const sources = extractSources( {
114
+ steps: [ searchStep( [ { url, title: 'from-tool' } ] ) ],
115
+ sources: [ doc ]
116
+ } );
117
+
118
+ expect( sources ).toHaveLength( 2 );
119
+ expect( sources[0].url ).toBe( url );
120
+ expect( sources[1] ).toBe( doc );
121
+ } );
122
+ } );
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Consumes a streaming result until it completes. Throws on abort or error parts.
3
+ * Callers must not throw from the AI SDK `onError` callback; that errors the stream before these parts are delivered.
4
+ *
5
+ * @param {object} stream - AI SDK stream result with `fullStream`
6
+ * @param {AbortSignal} [abortSignal] - Used to recover the original abort reason
7
+ */
8
+ export const drainStream = async ( stream, abortSignal ) => {
9
+ for await ( const part of stream.fullStream ) {
10
+ if ( part.type === 'abort' ) {
11
+ const reason = abortSignal?.reason;
12
+ throw reason instanceof Error ?
13
+ reason :
14
+ new Error( part.reason ?? 'Streaming generation aborted.', { cause: reason } );
15
+ }
16
+ if ( part.type === 'error' ) {
17
+ throw part.error instanceof Error ?
18
+ part.error :
19
+ new Error( part.error ? String( part.error ) : 'Streaming generation failed.', { cause: part.error } );
20
+ }
21
+ }
22
+ };
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { drainStream } from './stream.js';
3
+
4
+ const asyncParts = parts => ( {
5
+ async *[Symbol.asyncIterator]() {
6
+ yield* parts;
7
+ }
8
+ } );
9
+
10
+ const streamOf = parts => ( { fullStream: asyncParts( parts ) } );
11
+
12
+ describe( 'drainStream', () => {
13
+ it( 'consumes fullStream parts without throwing', async () => {
14
+ await expect( drainStream( streamOf( [ { type: 'text-delta', text: 'hi' } ] ) ) ).resolves.toBeUndefined();
15
+ } );
16
+
17
+ it( 'throws the abort signal reason when it is an Error', async () => {
18
+ const abortController = new AbortController();
19
+ const abortReason = new Error( 'Cancelled by caller' );
20
+ abortController.abort( abortReason );
21
+
22
+ await expect( drainStream(
23
+ streamOf( [ { type: 'abort', reason: abortReason.message } ] ),
24
+ abortController.signal
25
+ ) ).rejects.toBe( abortReason );
26
+ } );
27
+
28
+ it( 'throws part.reason when the abort signal reason is not an Error', async () => {
29
+ const abortController = new AbortController();
30
+ abortController.abort( 'cancelled' );
31
+
32
+ await expect( drainStream(
33
+ streamOf( [ { type: 'abort', reason: 'from-part' } ] ),
34
+ abortController.signal
35
+ ) ).rejects.toMatchObject( { message: 'from-part', cause: 'cancelled' } );
36
+ } );
37
+
38
+ it( 'throws a generic abort error when no reason is available', async () => {
39
+ await expect( drainStream( streamOf( [ { type: 'abort' } ] ) ) ).rejects.toThrow( 'Streaming generation aborted.' );
40
+ } );
41
+
42
+ it( 'throws the provider error when the error part is an Error', async () => {
43
+ const error = new Error( 'Provider failed' );
44
+
45
+ await expect( drainStream( streamOf( [ { type: 'error', error } ] ) ) ).rejects.toBe( error );
46
+ } );
47
+
48
+ it( 'wraps a non-Error error part', async () => {
49
+ await expect( drainStream( streamOf( [ { type: 'error', error: 'no model' } ] ) ) ).rejects.toThrow( 'no model' );
50
+ } );
51
+
52
+ it( 'throws a generic stream error when the error part has no value', async () => {
53
+ await expect( drainStream( streamOf( [ { type: 'error', error: null } ] ) ) ).rejects.toThrow( 'Streaming generation failed.' );
54
+ } );
55
+ } );
@@ -0,0 +1,47 @@
1
+ import { tool as createTool } from 'ai';
2
+ import { ValidationError, z } from '@outputai/core';
3
+ import { getProvider } from '../ai_provider.js';
4
+
5
+ /**
6
+ * Build the `load_skill` AI SDK tool that the LLM calls to retrieve full skill instructions.
7
+ *
8
+ * @param {Skill[]} skills
9
+ * @returns {import('ai').Tool}
10
+ */
11
+ export const buildLoadSkillTool = skills => createTool( {
12
+ description: 'Get detailed instructions for a named skill',
13
+ inputSchema: z.object( { name: z.string().describe( 'Name of the skill to load' ) } ),
14
+ execute: ( { name } ) => {
15
+ const sk = skills.find( s => s.name === name );
16
+ if ( !sk ) {
17
+ return `Skill "${name}" not found. Available: ${skills.map( s => s.name ).join( ', ' )}`;
18
+ }
19
+ return sk.instructions;
20
+ }
21
+ } );
22
+
23
+ const buildProviderError = ( prompt, suffix ) => `Provider "${prompt.config.provider}" used by prompt "${prompt.name}" does not support ${suffix}.`;
24
+
25
+ /** Load tools from a prompt provider. */
26
+ export const loadPromptTools = prompt => {
27
+ const promptToolNames = Object.keys( prompt.config.tools ?? {} );
28
+ if ( promptToolNames.length === 0 ) {
29
+ return null;
30
+ }
31
+
32
+ const { tools } = getProvider( prompt.config.provider );
33
+ if ( !tools ) {
34
+ throw new ValidationError( buildProviderError( prompt, 'provider-specific tools' ) );
35
+ }
36
+
37
+ const missingTools = promptToolNames.filter( t => !Object.hasOwn( tools, t ) || typeof tools[t] !== 'function' );
38
+ if ( missingTools.length > 0 ) {
39
+ throw new ValidationError( buildProviderError( prompt, `these tools: ${missingTools.join( ', ' )}` ) );
40
+ }
41
+
42
+ const loadedTools = {};
43
+ for ( const name of promptToolNames ) {
44
+ loadedTools[name] = tools[name]( prompt.config.tools[name] );
45
+ }
46
+ return loadedTools;
47
+ };