@outputai/llm 0.7.1-next.db8ddd7.0 → 0.7.1-next.ed233ce.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.7.1-next.db8ddd7.0",
3
+ "version": "0.7.1-next.ed233ce.0",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -13,7 +13,7 @@
13
13
  "gray-matter": "4.0.3",
14
14
  "liquidjs": "10.25.7",
15
15
  "undici": "8.1.0",
16
- "@outputai/core": "0.7.1-next.db8ddd7.0"
16
+ "@outputai/core": "0.7.1-next.ed233ce.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "ai": "6.0.168",
package/src/agent.js CHANGED
@@ -43,17 +43,13 @@ export class Agent extends AIToolLoopAgent {
43
43
 
44
44
  const { loadedPrompt, tools } = prepareTextPrompt( { prompt, variables, promptDir: resolvedPromptDir, skills, tools: toolsArg } );
45
45
 
46
- const { messages: allMessages, ...constructorOptions } = loadAiSdkTextOptions( loadedPrompt );
47
-
48
- // Extract system messages as `instructions` for the ToolLoopAgent constructor
49
- // and keep user messages for generate() calls — avoids provider errors
50
- // with multiple system messages during multi-step tool loops.
51
- // Pass message objects (not a string) so per-message providerOptions are preserved.
52
- const systemMessages = allMessages.filter( isRole( ROLE.SYSTEM ) );
46
+ const { system, messages, ...constructorOptions } = loadAiSdkTextOptions( loadedPrompt );
53
47
 
48
+ // loadAiSdkTextOptions routes system blocks to the `system` slot (preserving
49
+ // per-message providerOptions); pass them as the agent's `instructions`.
54
50
  super( {
55
51
  ...constructorOptions,
56
- ...( systemMessages.length > 0 ? { instructions: systemMessages } : {} ),
52
+ ...( system.length > 0 ? { instructions: system } : {} ),
57
53
  ...( tools ? { tools } : {} ),
58
54
  stopWhen: stopWhen ?? stepCountIs( maxSteps ),
59
55
  ...rest
@@ -61,7 +57,9 @@ export class Agent extends AIToolLoopAgent {
61
57
 
62
58
  this.#prompt = prompt;
63
59
  this.#modelId = loadedPrompt.config.model;
64
- this.#initialMessages = allMessages.filter( isRole( ROLE.USER ) );
60
+ // `messages` is system-free but may still hold authored <assistant>/<tool>
61
+ // blocks; seed only <user> turns into each generate()/stream() call.
62
+ this.#initialMessages = messages.filter( isRole( ROLE.USER ) );
65
63
  this.#store = conversationStore ?? null;
66
64
  }
67
65
 
@@ -80,7 +78,7 @@ export class Agent extends AIToolLoopAgent {
80
78
  const traceId = startTrace( { name: 'Agent.generate', prompt: this.#prompt } );
81
79
  try {
82
80
  const messages = await this.#fetchMessages( userMessages );
83
- const response = await super.generate( { messages, ...callOptions } );
81
+ const response = await super.generate( { messages, allowSystemInMessages: true, ...callOptions } );
84
82
  const wrapped = await wrapTextResponse( { traceId, response, modelId: this.#modelId } );
85
83
  await this.#storeMessages( userMessages, wrapped );
86
84
  return wrapped;
@@ -96,6 +94,7 @@ export class Agent extends AIToolLoopAgent {
96
94
  const messages = await this.#fetchMessages( userMessages );
97
95
  return super.stream( {
98
96
  messages,
97
+ allowSystemInMessages: true,
99
98
  ...callOptions,
100
99
  ...wrapStreamOnFinishResponse( { traceId, modelId: this.#modelId, onFinish } ),
101
100
  onError( event ) {
package/src/agent.spec.js CHANGED
@@ -113,7 +113,8 @@ const model = { id: 'MODEL' };
113
113
 
114
114
  const textOptions = {
115
115
  model,
116
- messages: loadedPrompt.messages,
116
+ system: [ { role: 'system', content: 'You are concise.' } ],
117
+ messages: [ { role: 'user', content: 'Initial user message' } ],
117
118
  providerOptions: { test: true },
118
119
  temperature: 0.3
119
120
  };
@@ -239,7 +240,8 @@ describe( 'Agent', () => {
239
240
  };
240
241
  optionMocks.loadAiSdkTextOptions.mockReturnValueOnce( {
241
242
  model,
242
- messages: [ systemMessage, { role: 'user', content: 'Hello' } ]
243
+ system: [ systemMessage ],
244
+ messages: [ { role: 'user', content: 'Hello' } ]
243
245
  } );
244
246
 
245
247
  new Agent( { prompt: 'test@v1' } );
@@ -303,7 +305,28 @@ describe( 'Agent', () => {
303
305
  await agent.generate();
304
306
 
305
307
  expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
306
- messages: [ { role: 'user', content: 'Initial user message' } ]
308
+ messages: [ { role: 'user', content: 'Initial user message' } ],
309
+ allowSystemInMessages: true
310
+ } );
311
+ } );
312
+
313
+ it( 'excludes authored assistant/tool blocks from the initial generate messages', async () => {
314
+ const { Agent } = await importSut();
315
+ optionMocks.loadAiSdkTextOptions.mockReturnValueOnce( {
316
+ model,
317
+ system: [ { role: 'system', content: 'You are concise.' } ],
318
+ messages: [
319
+ { role: 'user', content: 'Initial user message' },
320
+ { role: 'assistant', content: 'Authored assistant block' }
321
+ ]
322
+ } );
323
+ const agent = new Agent( { prompt: 'test@v1' } );
324
+
325
+ await agent.generate();
326
+
327
+ expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
328
+ messages: [ { role: 'user', content: 'Initial user message' } ],
329
+ allowSystemInMessages: true
307
330
  } );
308
331
  } );
309
332
 
@@ -326,6 +349,7 @@ describe( 'Agent', () => {
326
349
  { role: 'assistant', content: 'Stored reply' },
327
350
  callerMessage
328
351
  ],
352
+ allowSystemInMessages: true,
329
353
  maxRetries: 1
330
354
  } );
331
355
  } );
@@ -405,6 +429,7 @@ describe( 'Agent', () => {
405
429
  { role: 'assistant', content: 'Stored reply' },
406
430
  callerMessage
407
431
  ],
432
+ allowSystemInMessages: true,
408
433
  maxRetries: 1,
409
434
  onFinish: expect.any( Function ),
410
435
  onError: expect.any( Function )
package/src/ai_sdk.js CHANGED
@@ -22,6 +22,7 @@ export async function generateText( { prompt, variables, promptDir, skills = [],
22
22
  try {
23
23
  const response = await AI.generateText( {
24
24
  ...loadAiSdkTextOptions( loadedPrompt ),
25
+ allowSystemInMessages: true,
25
26
  maxRetries: 0,
26
27
  ...aiSdkArgs,
27
28
  ...( tools && { tools } ),
@@ -50,6 +51,7 @@ export function streamText( { prompt, variables, promptDir, skills = [], maxStep
50
51
  try {
51
52
  return AI.streamText( {
52
53
  ...loadAiSdkTextOptions( loadedPrompt ),
54
+ allowSystemInMessages: true,
53
55
  maxRetries: 0,
54
56
  ...aiSdkArgs,
55
57
  ...( tools && { tools } ),
@@ -190,6 +190,7 @@ describe( 'ai_sdk', () => {
190
190
  expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
191
191
  expect( aiFns.generateText ).toHaveBeenCalledWith( {
192
192
  ...textOptions,
193
+ allowSystemInMessages: true,
193
194
  maxRetries: 0,
194
195
  tools,
195
196
  temperature: 0.2,
@@ -225,6 +226,7 @@ describe( 'ai_sdk', () => {
225
226
  expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
226
227
  expect( aiFns.generateText ).toHaveBeenCalledWith( {
227
228
  ...textOptions,
229
+ allowSystemInMessages: true,
228
230
  maxRetries: 0
229
231
  } );
230
232
  } );
@@ -326,6 +328,7 @@ describe( 'ai_sdk', () => {
326
328
  expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
327
329
  expect( aiFns.streamText ).toHaveBeenCalledWith( {
328
330
  ...textOptions,
331
+ allowSystemInMessages: true,
329
332
  maxRetries: 0,
330
333
  tools,
331
334
  temperature: 0.2,
@@ -373,6 +376,7 @@ describe( 'ai_sdk', () => {
373
376
  expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
374
377
  expect( aiFns.streamText ).toHaveBeenCalledWith( {
375
378
  ...textOptions,
379
+ allowSystemInMessages: true,
376
380
  maxRetries: 0,
377
381
  onFinish: expect.any( Function ),
378
382
  onError: expect.any( Function )
@@ -1,10 +1,16 @@
1
1
  import { loadImageModel, loadTextModel, loadTools } from './ai_model.js';
2
2
  import { resolveMessageProviderOptions } from './prompt/block_options.js';
3
+ import { ROLE, isRole } from './utils/message.js';
3
4
  import { FatalError } from '@outputai/core';
4
5
 
5
6
  /**
6
7
  * Convert a loaded prompt into AI SDK text generation options.
7
8
  *
9
+ * System blocks are routed to the `system` option (as `SystemModelMessage[]`, so
10
+ * per-message providerOptions like `cacheControl` are preserved) rather than left
11
+ * in `messages` — the AI SDK flags system roles inside `messages` as a prompt
12
+ * injection risk, and `system` is the provider-recommended slot.
13
+ *
8
14
  * @param {object} prompt - Loaded prompt object
9
15
  * @returns {object} Options for AI SDK text calls
10
16
  */
@@ -12,9 +18,13 @@ export const loadAiSdkTextOptions = prompt => {
12
18
  if ( prompt.messages.length === 0 ) {
13
19
  throw new FatalError( `Prompt "${prompt.name}" has no chat-style messages. Add role-tagged blocks like <system> or <user>.` );
14
20
  }
21
+ const isSystem = isRole( ROLE.SYSTEM );
22
+ const resolvedMessages = resolveMessageProviderOptions( prompt );
23
+
15
24
  const options = {
16
25
  model: loadTextModel( prompt ),
17
- messages: resolveMessageProviderOptions( prompt ),
26
+ system: resolvedMessages.filter( isSystem ),
27
+ messages: resolvedMessages.filter( message => !isSystem( message ) ),
18
28
  providerOptions: prompt.config.providerOptions
19
29
  };
20
30
 
@@ -60,7 +60,8 @@ describe( 'ai_sdk_options', () => {
60
60
  expect( loadToolsImpl ).toHaveBeenCalledWith( prompt );
61
61
  expect( result ).toEqual( {
62
62
  model: 'MODEL',
63
- messages: prompt.messages,
63
+ system: [ { role: 'system', content: 'You are concise.' } ],
64
+ messages: [ { role: 'user', content: 'Hello' } ],
64
65
  providerOptions: prompt.config.providerOptions,
65
66
  temperature: 0.3,
66
67
  maxOutputTokens: 1000
@@ -180,13 +181,50 @@ describe( 'ai_sdk_options', () => {
180
181
  const { loadAiSdkTextOptions } = await importSut();
181
182
  const result = loadAiSdkTextOptions( prompt );
182
183
 
183
- expect( result.messages ).toEqual( [
184
+ expect( result.system ).toEqual( [
184
185
  {
185
186
  role: 'system',
186
187
  content: 'Static',
187
188
  providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } }
188
- },
189
- { role: 'user', content: 'Hello' }
189
+ }
190
+ ] );
191
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
192
+ } );
193
+
194
+ it( 'returns an empty system array when the prompt has no system block', async () => {
195
+ const prompt = {
196
+ name: 'no-system@v1',
197
+ config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
198
+ messages: [ { role: 'user', content: 'Hello' } ],
199
+ instructions: null
200
+ };
201
+
202
+ const { loadAiSdkTextOptions } = await importSut();
203
+ const result = loadAiSdkTextOptions( prompt );
204
+
205
+ expect( result.system ).toEqual( [] );
206
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
207
+ } );
208
+
209
+ it( 'groups multiple system blocks into the system option and keeps them out of messages', async () => {
210
+ const prompt = {
211
+ name: 'multi-system@v1',
212
+ config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
213
+ messages: [
214
+ { role: 'system', content: 'First' },
215
+ { role: 'system', content: 'Second' },
216
+ { role: 'user', content: 'Hello' }
217
+ ],
218
+ instructions: null
219
+ };
220
+
221
+ const { loadAiSdkTextOptions } = await importSut();
222
+ const result = loadAiSdkTextOptions( prompt );
223
+
224
+ expect( result.system ).toEqual( [
225
+ { role: 'system', content: 'First' },
226
+ { role: 'system', content: 'Second' }
190
227
  ] );
228
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
191
229
  } );
192
230
  } );