@outputai/llm 0.1.12-next.ecd4dda.0 → 0.1.13-next.04243eb.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/llm",
3
- "version": "0.1.12-next.ecd4dda.0",
3
+ "version": "0.1.13-next.04243eb.0",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -9,20 +9,20 @@
9
9
  "./src"
10
10
  ],
11
11
  "dependencies": {
12
- "@ai-sdk/amazon-bedrock": "4.0.83",
13
- "@ai-sdk/anthropic": "3.0.64",
14
- "@ai-sdk/azure": "3.0.49",
15
- "@ai-sdk/google-vertex": "4.0.95",
16
- "@ai-sdk/openai": "3.0.48",
17
- "@ai-sdk/perplexity": "3.0.26",
12
+ "@ai-sdk/amazon-bedrock": "4.0.89",
13
+ "@ai-sdk/anthropic": "3.0.66",
14
+ "@ai-sdk/azure": "3.0.51",
15
+ "@ai-sdk/google-vertex": "4.0.102",
16
+ "@ai-sdk/openai": "3.0.50",
17
+ "@ai-sdk/perplexity": "3.0.27",
18
18
  "@exalabs/ai-sdk": "2.0.1",
19
19
  "@perplexity-ai/ai-sdk": "0.1.2",
20
20
  "@tavily/ai-sdk": "0.4.1",
21
- "ai": "6.0.141",
21
+ "ai": "6.0.146",
22
22
  "decimal.js": "10.6.0",
23
23
  "gray-matter": "4.0.3",
24
24
  "liquidjs": "10.25.2",
25
- "@outputai/core": "0.1.12-next.ecd4dda.0"
25
+ "@outputai/core": "0.1.13-next.04243eb.0"
26
26
  },
27
27
  "license": "Apache-2.0",
28
28
  "publishConfig": {
package/src/agent.js ADDED
@@ -0,0 +1,100 @@
1
+ import { ValidationError } from '@outputai/core';
2
+ import { resolveInvocationDir } from '@outputai/core/sdk_utils';
3
+ import { ToolLoopAgent as AIToolLoopAgent, stepCountIs } from 'ai';
4
+ import { hydratePromptTemplate, loadAiSdkOptionsFromPrompt } from './ai_sdk.js';
5
+ import { startTrace, endTraceWithError, traceStreamCallbacks } from './trace_utils.js';
6
+ import { wrapInOutputResponse } from './response_utils.js';
7
+ import { ROLE, isRole, getContent } from './message_utils.js';
8
+
9
+ export { skill } from './skill.js';
10
+
11
+ export const createMemoryConversationStore = () => {
12
+ const messages = [];
13
+ return {
14
+ getMessages: () => messages,
15
+ addMessages: newMessages => messages.push( ...newMessages )
16
+ };
17
+ };
18
+
19
+ export class Agent extends AIToolLoopAgent {
20
+ _prompt;
21
+ _modelId;
22
+ _initialMessages;
23
+ _store;
24
+
25
+ constructor( {
26
+ prompt, promptDir, variables = {}, skills = [], tools = {},
27
+ stopWhen, maxSteps = 10, conversationStore, ...rest
28
+ } ) {
29
+ if ( !prompt ) {
30
+ throw new ValidationError( 'Agent requires a prompt' );
31
+ }
32
+
33
+ // Must be captured synchronously — Temporal async activity execution
34
+ // breaks the call stack, so resolveInvocationDir() fails if called lazily.
35
+ const resolvedPromptDir = promptDir ?? resolveInvocationDir();
36
+
37
+ const { loadedPrompt, tools: mergedTools } =
38
+ hydratePromptTemplate( prompt, variables, resolvedPromptDir, skills, tools );
39
+
40
+ const { messages: allMessages, ...constructorOptions } = loadAiSdkOptionsFromPrompt( loadedPrompt );
41
+
42
+ // Extract system messages as `instructions` for the ToolLoopAgent constructor
43
+ // and keep user messages for generate() calls — avoids provider errors
44
+ // with multiple system messages during multi-step tool loops
45
+ const systemContent = allMessages.filter( isRole( ROLE.SYSTEM ) ).map( getContent ).join( '\n\n' );
46
+
47
+ super( {
48
+ ...constructorOptions,
49
+ ...( systemContent ? { instructions: systemContent } : {} ),
50
+ ...( Object.keys( mergedTools ).length > 0 ? { tools: mergedTools } : {} ),
51
+ stopWhen: stopWhen ?? stepCountIs( maxSteps ),
52
+ ...rest
53
+ } );
54
+
55
+ this._prompt = prompt;
56
+ this._modelId = loadedPrompt.config.model;
57
+ this._initialMessages = allMessages.filter( isRole( ROLE.USER ) );
58
+ this._store = conversationStore ?? null;
59
+ }
60
+
61
+ async _preSendHook( userMessages ) {
62
+ const priorMessages = this._store ? await this._store.getMessages() : [];
63
+ return [ ...this._initialMessages, ...priorMessages, ...userMessages ];
64
+ }
65
+
66
+ async _postSendHook( userMessages, result ) {
67
+ if ( this._store ) {
68
+ await this._store.addMessages( [ ...userMessages, ...( result.response?.messages ?? [] ) ] );
69
+ }
70
+ }
71
+
72
+ async generate( { messages: userMessages = [], ...callOptions } = {} ) {
73
+ const traceId = startTrace( 'Agent.generate', { prompt: this._prompt } );
74
+ try {
75
+ const messages = await this._preSendHook( userMessages );
76
+ const result = await super.generate( { messages, ...callOptions } );
77
+ const wrapped = await wrapInOutputResponse( result, { traceId, modelId: this._modelId } );
78
+ await this._postSendHook( userMessages, wrapped );
79
+ return wrapped;
80
+ } catch ( error ) {
81
+ endTraceWithError( traceId, error );
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ async stream( { messages: userMessages = [], onFinish, onError, ...callOptions } = {} ) {
87
+ const traceId = startTrace( 'Agent.stream', { prompt: this._prompt } );
88
+ try {
89
+ const messages = await this._preSendHook( userMessages );
90
+ return super.stream( {
91
+ messages,
92
+ ...callOptions,
93
+ ...traceStreamCallbacks( traceId, this._modelId, { onFinish, onError } )
94
+ } );
95
+ } catch ( error ) {
96
+ endTraceWithError( traceId, error );
97
+ throw error;
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,304 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ // ─── Mocks ────────────────────────────────────────────────────────────────────
7
+
8
+ const state = vi.hoisted( () => ( { promptDir: '' } ) );
9
+
10
+ vi.mock( '@outputai/core/sdk_utils', () => ( {
11
+ resolveInvocationDir: () => state.promptDir
12
+ } ) );
13
+
14
+ const superGenerateImpl = vi.fn();
15
+ const superStreamImpl = vi.fn();
16
+ const superConstructorSpy = vi.fn();
17
+
18
+ vi.mock( 'ai', () => {
19
+ class MockToolLoopAgent {
20
+ constructor( options ) {
21
+ superConstructorSpy( options );
22
+ }
23
+
24
+ async generate( ...args ) {
25
+ return superGenerateImpl( ...args );
26
+ }
27
+
28
+ stream( ...args ) {
29
+ return superStreamImpl( ...args );
30
+ }
31
+ }
32
+ return {
33
+ ToolLoopAgent: MockToolLoopAgent,
34
+ stepCountIs: vi.fn( n => ( { _stepCount: n } ) ),
35
+ tool: vi.fn( def => def )
36
+ };
37
+ } );
38
+
39
+ const hydratePromptTemplateImpl = vi.fn();
40
+ const loadAiSdkOptionsImpl = vi.fn();
41
+ vi.mock( './ai_sdk.js', () => ( {
42
+ hydratePromptTemplate: ( ...args ) => hydratePromptTemplateImpl( ...args ),
43
+ loadAiSdkOptionsFromPrompt: ( ...args ) => loadAiSdkOptionsImpl( ...args )
44
+ } ) );
45
+
46
+ const startTraceImpl = vi.fn( () => 'trace-id' );
47
+ const endTraceWithErrorImpl = vi.fn();
48
+ const traceStreamCallbacksImpl = vi.fn( () => ( {} ) );
49
+ vi.mock( './trace_utils.js', () => ( {
50
+ startTrace: ( ...args ) => startTraceImpl( ...args ),
51
+ endTraceWithError: ( ...args ) => endTraceWithErrorImpl( ...args ),
52
+ traceStreamCallbacks: ( ...args ) => traceStreamCallbacksImpl( ...args )
53
+ } ) );
54
+
55
+ const wrapInOutputResponseImpl = vi.fn( response => response );
56
+ vi.mock( './response_utils.js', () => ( {
57
+ wrapInOutputResponse: ( ...args ) => wrapInOutputResponseImpl( ...args )
58
+ } ) );
59
+
60
+ vi.mock( './skill.js', () => ( {
61
+ skill: vi.fn( ( { name, description, instructions } ) => ( { name, description: description ?? name, instructions } ) ),
62
+ buildLoadSkillTool: vi.fn( skills => ( { _loadSkillTool: true, skills } ) )
63
+ } ) );
64
+
65
+ // ─── Defaults ─────────────────────────────────────────────────────────────────
66
+
67
+ const defaultMessages = [ { role: 'user', content: 'test message' } ];
68
+ const defaultPromptMeta = {
69
+ config: { model: 'claude-sonnet-4-6' },
70
+ messages: defaultMessages,
71
+ promptFileDir: '/mock/dir'
72
+ };
73
+
74
+ const importSut = () => import( './agent.js' );
75
+
76
+ beforeEach( () => {
77
+ state.promptDir = mkdtempSync( join( tmpdir(), 'agent-test-' ) );
78
+ vi.clearAllMocks();
79
+
80
+ hydratePromptTemplateImpl.mockReturnValue( {
81
+ loadedPrompt: defaultPromptMeta,
82
+ allVariables: {},
83
+ tools: {}
84
+ } );
85
+ loadAiSdkOptionsImpl.mockReturnValue( {
86
+ model: { _modelId: 'claude-sonnet-4-6' },
87
+ messages: defaultMessages
88
+ } );
89
+ superGenerateImpl.mockResolvedValue( { text: 'response', response: { messages: [] } } );
90
+ superStreamImpl.mockReturnValue( { textStream: 'stream' } );
91
+ } );
92
+
93
+ // ─── Tests ────────────────────────────────────────────────────────────────────
94
+
95
+ describe( 'skill()', () => {
96
+ it( 'creates a skill object with name, description, instructions', async () => {
97
+ const { skill } = await importSut();
98
+ const s = skill( { name: 'my_skill', description: 'Does stuff', instructions: '# Do stuff\nStep 1' } );
99
+ expect( s ).toEqual( { name: 'my_skill', description: 'Does stuff', instructions: '# Do stuff\nStep 1' } );
100
+ } );
101
+ } );
102
+
103
+ describe( 'Agent — construction', () => {
104
+ it( 'throws ValidationError when prompt is missing', async () => {
105
+ const { Agent } = await importSut();
106
+ expect( () => new Agent( {} ) ).toThrow( /requires a prompt/ );
107
+ } );
108
+
109
+ it( 'constructs successfully with a prompt', async () => {
110
+ const { Agent } = await importSut();
111
+ expect( () => new Agent( { prompt: 'test@v1' } ) ).not.toThrow();
112
+ } );
113
+
114
+ it( 'calls AIToolLoopAgent constructor once at construction time', async () => {
115
+ const { Agent } = await importSut();
116
+ new Agent( { prompt: 'test@v1' } );
117
+ expect( superConstructorSpy ).toHaveBeenCalledTimes( 1 );
118
+ } );
119
+
120
+ it( 'passes model and stopWhen to AIToolLoopAgent constructor', async () => {
121
+ const { Agent } = await importSut();
122
+ new Agent( { prompt: 'test@v1' } );
123
+ expect( superConstructorSpy ).toHaveBeenCalledWith( expect.objectContaining( {
124
+ model: expect.objectContaining( { _modelId: 'claude-sonnet-4-6' } ),
125
+ stopWhen: expect.objectContaining( { _stepCount: 10 } )
126
+ } ) );
127
+ } );
128
+
129
+ it( 'uses resolveInvocationDir when promptDir not provided', async () => {
130
+ const { Agent } = await importSut();
131
+ new Agent( { prompt: 'test@v1' } );
132
+ expect( hydratePromptTemplateImpl ).toHaveBeenCalledWith( 'test@v1', {}, state.promptDir, [], {} );
133
+ } );
134
+
135
+ it( 'uses explicitly provided promptDir', async () => {
136
+ const explicitDir = mkdtempSync( join( tmpdir(), 'explicit-' ) );
137
+ const { Agent } = await importSut();
138
+ new Agent( { prompt: 'test@v1', promptDir: explicitDir } );
139
+ expect( hydratePromptTemplateImpl ).toHaveBeenCalledWith( 'test@v1', {}, explicitDir, [], {} );
140
+ } );
141
+
142
+ it( 'passes construction-time variables to hydratePromptTemplate', async () => {
143
+ const { Agent } = await importSut();
144
+ new Agent( { prompt: 'test@v1', variables: { persona: 'writer' } } );
145
+ expect( hydratePromptTemplateImpl ).toHaveBeenCalledWith(
146
+ 'test@v1', { persona: 'writer' }, state.promptDir, [], {}
147
+ );
148
+ } );
149
+ } );
150
+
151
+ describe( 'Agent.generate() — messages', () => {
152
+ it( 'passes initialMessages from construction', async () => {
153
+ const { Agent } = await importSut();
154
+ const agent = new Agent( { prompt: 'test@v1' } );
155
+ await agent.generate();
156
+ expect( superGenerateImpl ).toHaveBeenCalledWith( expect.objectContaining( {
157
+ messages: defaultMessages
158
+ } ) );
159
+ } );
160
+
161
+ it( 'appends extra messages after initial messages', async () => {
162
+ const { Agent } = await importSut();
163
+ const agent = new Agent( { prompt: 'test@v1' } );
164
+ const extraMsg = { role: 'assistant', content: 'prior turn' };
165
+ await agent.generate( { messages: [ extraMsg ] } );
166
+ expect( superGenerateImpl ).toHaveBeenCalledWith( {
167
+ messages: [ ...defaultMessages, extraMsg ]
168
+ } );
169
+ } );
170
+ } );
171
+
172
+ describe( 'Agent.generate() — reuse', () => {
173
+ it( 'does not call AIToolLoopAgent constructor again on subsequent generate() calls', async () => {
174
+ const { Agent } = await importSut();
175
+ const agent = new Agent( { prompt: 'test@v1' } );
176
+ expect( superConstructorSpy ).toHaveBeenCalledTimes( 1 );
177
+
178
+ await agent.generate();
179
+ await agent.generate();
180
+ await agent.generate();
181
+
182
+ expect( superConstructorSpy ).toHaveBeenCalledTimes( 1 );
183
+ } );
184
+ } );
185
+
186
+ describe( 'Agent.generate() — conversation store', () => {
187
+ it( 'does not use store when none provided (stateless)', async () => {
188
+ const { Agent } = await importSut();
189
+ const agent = new Agent( { prompt: 'test@v1' } );
190
+ await agent.generate();
191
+ // No error, no store interaction — just prompt messages
192
+ expect( superGenerateImpl ).toHaveBeenCalledWith( {
193
+ messages: defaultMessages
194
+ } );
195
+ } );
196
+
197
+ it( 'loads prior messages from store before calling super.generate', async () => {
198
+ const priorMessages = [ { role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' } ];
199
+ const store = {
200
+ getMessages: vi.fn( () => priorMessages ),
201
+ addMessages: vi.fn()
202
+ };
203
+
204
+ const { Agent } = await importSut();
205
+ const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
206
+ await agent.generate( { messages: [ { role: 'user', content: 'new msg' } ] } );
207
+
208
+ expect( store.getMessages ).toHaveBeenCalled();
209
+ expect( superGenerateImpl ).toHaveBeenCalledWith( {
210
+ messages: [ ...defaultMessages, ...priorMessages, { role: 'user', content: 'new msg' } ]
211
+ } );
212
+ } );
213
+
214
+ it( 'appends user messages and response messages to store after generate()', async () => {
215
+ const responseMessages = [ { role: 'assistant', content: 'reply' } ];
216
+ superGenerateImpl.mockResolvedValue( { text: 'reply', response: { messages: responseMessages } } );
217
+ const store = {
218
+ getMessages: vi.fn( () => [] ),
219
+ addMessages: vi.fn()
220
+ };
221
+
222
+ const { Agent } = await importSut();
223
+ const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
224
+ await agent.generate( { messages: [ { role: 'user', content: 'ask' } ] } );
225
+
226
+ expect( store.addMessages ).toHaveBeenCalledWith( [
227
+ { role: 'user', content: 'ask' },
228
+ { role: 'assistant', content: 'reply' }
229
+ ] );
230
+ } );
231
+
232
+ it( 'supports async store methods', async () => {
233
+ const store = {
234
+ getMessages: vi.fn( async () => [] ),
235
+ addMessages: vi.fn( async () => {} )
236
+ };
237
+
238
+ const { Agent } = await importSut();
239
+ const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
240
+ await agent.generate();
241
+
242
+ expect( store.getMessages ).toHaveBeenCalled();
243
+ expect( store.addMessages ).toHaveBeenCalled();
244
+ } );
245
+ } );
246
+
247
+ describe( 'createMemoryConversationStore()', () => {
248
+ it( 'starts with empty messages', async () => {
249
+ const { createMemoryConversationStore } = await importSut();
250
+ const store = createMemoryConversationStore();
251
+ expect( store.getMessages() ).toEqual( [] );
252
+ } );
253
+
254
+ it( 'accumulates messages across addMessages calls', async () => {
255
+ const { createMemoryConversationStore } = await importSut();
256
+ const store = createMemoryConversationStore();
257
+ store.addMessages( [ { role: 'user', content: 'hi' } ] );
258
+ store.addMessages( [ { role: 'assistant', content: 'hello' } ] );
259
+ expect( store.getMessages() ).toEqual( [
260
+ { role: 'user', content: 'hi' },
261
+ { role: 'assistant', content: 'hello' }
262
+ ] );
263
+ } );
264
+ } );
265
+
266
+ describe( 'Agent.stream()', () => {
267
+ it( 'uses pre-rendered messages when no variables provided', async () => {
268
+ const { Agent } = await importSut();
269
+ const agent = new Agent( { prompt: 'test@v1' } );
270
+ await agent.stream();
271
+ expect( superStreamImpl ).toHaveBeenCalledWith( {
272
+ messages: defaultMessages
273
+ } );
274
+ } );
275
+
276
+ it( 'loads prior messages from store', async () => {
277
+ const priorMessages = [ { role: 'user', content: 'old' } ];
278
+ const store = {
279
+ getMessages: vi.fn( () => priorMessages ),
280
+ addMessages: vi.fn()
281
+ };
282
+
283
+ const { Agent } = await importSut();
284
+ const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
285
+ await agent.stream( { messages: [ { role: 'user', content: 'new' } ] } );
286
+
287
+ expect( superStreamImpl ).toHaveBeenCalledWith( {
288
+ messages: [ ...defaultMessages, ...priorMessages, { role: 'user', content: 'new' } ]
289
+ } );
290
+ } );
291
+
292
+ it( 'does not auto-append to store', async () => {
293
+ const store = {
294
+ getMessages: vi.fn( () => [] ),
295
+ addMessages: vi.fn()
296
+ };
297
+
298
+ const { Agent } = await importSut();
299
+ const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
300
+ await agent.stream();
301
+
302
+ expect( store.addMessages ).not.toHaveBeenCalled();
303
+ } );
304
+ } );
package/src/ai_sdk.js CHANGED
@@ -1,19 +1,13 @@
1
- import { Tracing, emitEvent } from '@outputai/core/sdk_activity_integration';
2
1
  import { loadModel, loadTools } from './ai_model.js';
3
2
  import * as AI from 'ai';
3
+ import { stepCountIs } from 'ai';
4
4
  import { validateGenerateTextArgs, validateStreamTextArgs } from './validations.js';
5
5
  import { loadPrompt } from './prompt_loader.js';
6
- import { extractSourcesFromSteps } from './source_extraction.js';
7
- import { calculateLLMCallCost } from './cost/index.js';
8
-
9
- // Starts the LLM trace, with the start event
10
- const startTrace = ( name, details ) => {
11
- const traceId = `${name}-${Date.now()}`;
12
- Tracing.addEventStart( { kind: 'llm', name, id: traceId, details } );
13
- return traceId;
14
- };
6
+ import { buildSystemSkillsVar, buildLoadSkillTool, loadPromptSkills, loadColocatedSkills } from './skill.js';
7
+ import { startTrace, endTraceWithError, traceStreamCallbacks } from './trace_utils.js';
8
+ import { wrapInOutputResponse } from './response_utils.js';
15
9
 
16
- const loadAiSdkOptionsFromPrompt = prompt => {
10
+ export const loadAiSdkOptionsFromPrompt = prompt => {
17
11
  const options = {
18
12
  model: loadModel( prompt ),
19
13
  messages: prompt.messages,
@@ -36,87 +30,66 @@ const loadAiSdkOptionsFromPrompt = prompt => {
36
30
  return options;
37
31
  };
38
32
 
39
- /**
40
- * Use an LLM model to generate text.
41
- *
42
- * Accepts additional AI SDK options (tools, maxRetries, seed, etc.) that are passed through
43
- * to the underlying provider. Options from the prompt file can be overridden at call time.
44
- *
45
- * @param {object} args - Generation arguments
46
- * @param {string} args.prompt - Prompt file name
47
- * @param {Record<string, string | number>} [args.variables] - Variables to interpolate
48
- * @param {object} [args.tools] - AI SDK tools the model can call
49
- * @param {'auto'|'none'|'required'|object} [args.toolChoice] - Tool selection strategy
50
- * @param {number} [args.maxRetries] - Max retry attempts (default: 2)
51
- * @param {number} [args.seed] - Seed for deterministic output
52
- * @param {AbortSignal} [args.abortSignal] - Signal to abort the request
53
- * @throws {ValidationError} If the prompt config is invalid (e.g., snake_case fields)
54
- * @throws {FatalError} If the prompt file is not found or template rendering fails
55
- * @returns {Promise<GenerateTextResult>} AI SDK response with text, toolCalls, and metadata
56
- */
57
- export async function generateText( { prompt, variables, promptDir, ...extraAiSdkOptions } ) {
58
- validateGenerateTextArgs( { prompt, variables } );
59
- const loadedPrompt = promptDir ? loadPrompt( prompt, variables, promptDir ) : loadPrompt( prompt, variables );
60
- const traceId = startTrace( 'generateText', { prompt, variables, loadedPrompt } );
33
+ export const hydratePromptTemplate = ( prompt, variables, promptDir, callerSkills, callerTools = {} ) => {
34
+ const meta = loadPrompt( prompt, variables, promptDir );
35
+
36
+ // Resolve skills: explicit frontmatter paths > colocated auto-discovery
37
+ const hasExplicitSkills = meta.config.skills && meta.promptFileDir;
38
+ const frontmatterSkills = hasExplicitSkills ?
39
+ loadPromptSkills( meta.config.skills, meta.promptFileDir ) :
40
+ [];
41
+ const autoSkills = !hasExplicitSkills && meta.promptFileDir ?
42
+ loadColocatedSkills( meta.promptFileDir ) :
43
+ [];
44
+ const resolvedSkills = [ ...frontmatterSkills, ...autoSkills, ...callerSkills ];
45
+
46
+ const tools = resolvedSkills.length > 0 ?
47
+ { load_skill: buildLoadSkillTool( resolvedSkills ), ...callerTools } :
48
+ callerTools;
49
+
50
+ const skillsMessage = resolvedSkills.length > 0 ?
51
+ { role: 'system', content: buildSystemSkillsVar( resolvedSkills ) } :
52
+ null;
53
+
54
+ if ( skillsMessage ) {
55
+ // Merge into existing system message to avoid provider errors with multiple system messages
56
+ const systemMsg = meta.messages.find( m => m.role === 'system' );
57
+ if ( systemMsg ) {
58
+ systemMsg.content = `${systemMsg.content}\n\n${skillsMessage.content}`;
59
+ } else {
60
+ meta.messages.unshift( skillsMessage );
61
+ }
62
+ }
63
+
64
+ return { loadedPrompt: meta, allVariables: variables, tools };
65
+ };
66
+
67
+ export async function generateText( { prompt, variables, promptDir, skills = [], maxSteps = 10, ...extraAiSdkOptions } ) {
68
+ const callerSkills = typeof skills === 'function' ? await skills( variables ) : skills;
69
+ const { loadedPrompt, allVariables, tools } =
70
+ hydratePromptTemplate( prompt, variables, promptDir, callerSkills, extraAiSdkOptions.tools );
71
+ const hasTools = Object.keys( tools ).length > 0;
72
+
73
+ validateGenerateTextArgs( { prompt, variables: allVariables } );
74
+
75
+ const traceId = startTrace( 'generateText', { prompt, variables: allVariables, loadedPrompt } );
61
76
  const { model: modelId } = loadedPrompt.config;
62
77
 
63
78
  try {
64
79
  const response = await AI.generateText( {
65
80
  ...loadAiSdkOptionsFromPrompt( loadedPrompt ),
66
- ...extraAiSdkOptions
67
- } );
68
- const { text: result, totalUsage: usage, providerMetadata } = response;
69
- const sourcesFromTools = extractSourcesFromSteps( response.steps );
70
- const cost = await calculateLLMCallCost( { usage, modelId } );
71
-
72
- emitEvent( 'llm:call_cost', { modelId, cost, usage } );
73
- Tracing.addEventEnd( { id: traceId, details: { result, usage, cost, providerMetadata, sourcesFromTools } } );
74
-
75
- // Creates a proxy over the response from AI SDK to add
76
- // - result: a shortcut for the actual SDK response (eg. .text);
77
- // - sources: a way to retrieve the computed sources;
78
- // It uses proxies instead of spreading the response object because AI SDK uses getters
79
- // (and they don't deconstruct properly in JS).
80
- return new Proxy( response, {
81
- get( target, prop, receiver ) {
82
- if ( prop === 'result' ) {
83
- return target.text;
84
- }
85
- if ( prop === 'sources' && sourcesFromTools.length > 0 ) {
86
- const responseSources = Array.isArray( target[prop] ) ? target[prop] : [];
87
- const byUrl = new Map( [ ...sourcesFromTools, ...responseSources ].map( s => [ s.url, s ] ) );
88
- return [ ...byUrl.values() ];
89
- }
90
- return Reflect.get( target, prop, receiver );
91
- }
81
+ ...extraAiSdkOptions,
82
+ ...( hasTools ? { tools } : {} ),
83
+ ...( hasTools && !extraAiSdkOptions.stopWhen ? { stopWhen: stepCountIs( maxSteps ) } : {} )
92
84
  } );
85
+ return wrapInOutputResponse( response, { traceId, modelId } );
93
86
  } catch ( error ) {
94
- Tracing.addEventError( { id: traceId, details: error } );
87
+ endTraceWithError( traceId, error );
95
88
  throw error;
96
89
  }
97
90
  }
98
91
 
99
- /**
100
- * Use an LLM model to stream text generation.
101
- *
102
- * Accepts additional AI SDK options (tools, onChunk, onFinish, onError, etc.) that are passed
103
- * through to the underlying provider. Options from the prompt file can be overridden at call time.
104
- *
105
- * @param {object} args - Generation arguments
106
- * @param {string} args.prompt - Prompt file name
107
- * @param {Record<string, string | number>} [args.variables] - Variables to interpolate
108
- * @param {object} [args.tools] - AI SDK tools the model can call
109
- * @param {'auto'|'none'|'required'|object} [args.toolChoice] - Tool selection strategy
110
- * @param {number} [args.maxRetries] - Max retry attempts (default: 2)
111
- * @param {AbortSignal} [args.abortSignal] - Signal to abort the request
112
- * @param {Function} [args.onChunk] - Callback for each stream chunk
113
- * @param {Function} [args.onFinish] - Callback when stream finishes (called after internal tracing)
114
- * @param {Function} [args.onError] - Callback when stream errors (called after internal tracing)
115
- * @throws {ValidationError} If required arguments are missing or prompt file has invalid config
116
- * @throws {FatalError} If the prompt file is not found or template rendering fails
117
- * @returns {AIStreamTextResult} AI SDK stream result with textStream, fullStream, and metadata promises (synchronous)
118
- */
119
- export function streamText( { prompt, variables, onFinish: userOnFinish, onError: userOnError, ...restOptions } ) {
92
+ export function streamText( { prompt, variables, onFinish, onError, ...restOptions } ) {
120
93
  validateStreamTextArgs( { prompt, variables } );
121
94
  const loadedPrompt = loadPrompt( prompt, variables );
122
95
  const traceId = startTrace( 'streamText', { prompt, variables, loadedPrompt } );
@@ -126,20 +99,10 @@ export function streamText( { prompt, variables, onFinish: userOnFinish, onError
126
99
  return AI.streamText( {
127
100
  ...loadAiSdkOptionsFromPrompt( loadedPrompt ),
128
101
  ...restOptions,
129
- async onFinish( response ) {
130
- const { text: result, totalUsage: usage, providerMetadata } = response;
131
- const cost = await calculateLLMCallCost( { usage, modelId } );
132
- emitEvent( 'llm:call_cost', { modelId, cost, usage } );
133
- Tracing.addEventEnd( { id: traceId, details: { result, usage, cost, providerMetadata } } );
134
- userOnFinish?.( response );
135
- },
136
- onError( event ) {
137
- Tracing.addEventError( { id: traceId, details: event.error } );
138
- userOnError?.( event );
139
- }
102
+ ...traceStreamCallbacks( traceId, modelId, { onFinish, onError } )
140
103
  } );
141
104
  } catch ( error ) {
142
- Tracing.addEventError( { id: traceId, details: error } );
105
+ endTraceWithError( traceId, error );
143
106
  throw error;
144
107
  }
145
108
  }