@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 +9 -9
- package/src/agent.js +100 -0
- package/src/agent.spec.js +304 -0
- package/src/ai_sdk.js +56 -93
- package/src/ai_sdk.spec.js +204 -3
- package/src/index.d.ts +94 -1
- package/src/index.js +1 -0
- package/src/load_content.js +36 -13
- package/src/message_utils.js +3 -0
- package/src/prompt_loader.js +7 -11
- package/src/prompt_loader.spec.js +12 -12
- package/src/prompt_loader_validation.spec.js +6 -6
- package/src/response_utils.js +21 -0
- package/src/skill.d.ts +49 -0
- package/src/skill.js +109 -0
- package/src/trace_utils.js +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/llm",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
13
|
-
"@ai-sdk/anthropic": "3.0.
|
|
14
|
-
"@ai-sdk/azure": "3.0.
|
|
15
|
-
"@ai-sdk/google-vertex": "4.0.
|
|
16
|
-
"@ai-sdk/openai": "3.0.
|
|
17
|
-
"@ai-sdk/perplexity": "3.0.
|
|
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.
|
|
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.
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
endTraceWithError( traceId, error );
|
|
143
106
|
throw error;
|
|
144
107
|
}
|
|
145
108
|
}
|