@outputai/llm 0.1.11 → 0.1.12-dev.d521efb.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 +2 -2
- 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/src/ai_sdk.spec.js
CHANGED
|
@@ -20,7 +20,9 @@ vi.mock( './ai_model.js', () => ( {
|
|
|
20
20
|
|
|
21
21
|
const aiFns = {
|
|
22
22
|
generateText: vi.fn(),
|
|
23
|
-
streamText: vi.fn()
|
|
23
|
+
streamText: vi.fn(),
|
|
24
|
+
tool: vi.fn( def => def ),
|
|
25
|
+
stepCountIs: vi.fn( n => ( { type: 'stepCount', count: n } ) )
|
|
24
26
|
};
|
|
25
27
|
vi.mock( 'ai', () => ( aiFns ) );
|
|
26
28
|
|
|
@@ -35,6 +37,17 @@ vi.mock( './prompt_loader.js', () => ( {
|
|
|
35
37
|
loadPrompt: ( ...values ) => loadPromptImpl( ...values )
|
|
36
38
|
} ) );
|
|
37
39
|
|
|
40
|
+
const loadPromptSkillsImpl = vi.fn();
|
|
41
|
+
const loadColocatedSkillsImpl = vi.fn().mockReturnValue( [] );
|
|
42
|
+
vi.mock( './skill.js', async importOriginal => {
|
|
43
|
+
const original = await importOriginal();
|
|
44
|
+
return {
|
|
45
|
+
...original,
|
|
46
|
+
loadPromptSkills: ( ...args ) => loadPromptSkillsImpl( ...args ),
|
|
47
|
+
loadColocatedSkills: ( ...args ) => loadColocatedSkillsImpl( ...args )
|
|
48
|
+
};
|
|
49
|
+
} );
|
|
50
|
+
|
|
38
51
|
const extractSourcesFromStepsImpl = vi.fn().mockReturnValue( [] );
|
|
39
52
|
vi.mock( './source_extraction.js', () => ( {
|
|
40
53
|
extractSourcesFromSteps: ( ...args ) => extractSourcesFromStepsImpl( ...args )
|
|
@@ -62,9 +75,13 @@ const cost = 'calculate cost';
|
|
|
62
75
|
beforeEach( () => {
|
|
63
76
|
emitEventSpy.mockReset();
|
|
64
77
|
loadModelImpl.mockReset().mockReturnValue( 'MODEL' );
|
|
65
|
-
loadPromptImpl.mockReset().mockReturnValue( basePrompt );
|
|
78
|
+
loadPromptImpl.mockReset().mockReturnValue( { ...basePrompt, messages: [ ...basePrompt.messages ] } );
|
|
66
79
|
extractSourcesFromStepsImpl.mockReset().mockReturnValue( [] );
|
|
67
80
|
calculateLLMCallCostImpl.mockReset().mockResolvedValue( cost );
|
|
81
|
+
aiFns.tool.mockReset().mockImplementation( def => def );
|
|
82
|
+
aiFns.stepCountIs.mockReset().mockImplementation( n => ( { type: 'stepCount', count: n } ) );
|
|
83
|
+
loadPromptSkillsImpl.mockReset().mockReturnValue( [] );
|
|
84
|
+
loadColocatedSkillsImpl.mockReset().mockReturnValue( [] );
|
|
68
85
|
|
|
69
86
|
const defaultUsage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
|
|
70
87
|
aiFns.generateText.mockReset().mockResolvedValue( {
|
|
@@ -96,7 +113,7 @@ describe( 'ai_sdk', () => {
|
|
|
96
113
|
const result = await generateText( { prompt: 'test_prompt@v1' } );
|
|
97
114
|
|
|
98
115
|
expect( validators.validateGenerateTextArgs ).toHaveBeenCalledWith( { prompt: 'test_prompt@v1' } );
|
|
99
|
-
expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined );
|
|
116
|
+
expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined, undefined );
|
|
100
117
|
expect( tracingSpies.addEventStart ).toHaveBeenCalledTimes( 1 );
|
|
101
118
|
expect( tracingSpies.addEventEnd ).toHaveBeenCalledTimes( 1 );
|
|
102
119
|
expect( tracingSpies.addEventEnd ).toHaveBeenCalledWith(
|
|
@@ -605,6 +622,190 @@ describe( 'ai_sdk', () => {
|
|
|
605
622
|
);
|
|
606
623
|
} );
|
|
607
624
|
|
|
625
|
+
it( 'generateText: loads frontmatter skills from prompt config using promptFileDir', async () => {
|
|
626
|
+
const frontmatterSkill = { name: 'fm_skill', description: 'FM', instructions: '# FM' };
|
|
627
|
+
loadPromptImpl.mockReturnValue( {
|
|
628
|
+
...basePrompt,
|
|
629
|
+
promptFileDir: '/some/prompt/dir',
|
|
630
|
+
config: { ...basePrompt.config, skills: [ './skills/' ] }
|
|
631
|
+
} );
|
|
632
|
+
loadPromptSkillsImpl.mockReturnValue( [ frontmatterSkill ] );
|
|
633
|
+
const { generateText } = await importSut();
|
|
634
|
+
await generateText( { prompt: 'test_prompt@v1' } );
|
|
635
|
+
|
|
636
|
+
expect( loadPromptSkillsImpl ).toHaveBeenCalledWith( [ './skills/' ], '/some/prompt/dir' );
|
|
637
|
+
const callArgs = aiFns.generateText.mock.calls[0][0];
|
|
638
|
+
expect( callArgs.tools ).toHaveProperty( 'load_skill' );
|
|
639
|
+
} );
|
|
640
|
+
|
|
641
|
+
it( 'generateText: merges frontmatter skills with caller-provided skills', async () => {
|
|
642
|
+
const frontmatterSkill = { name: 'fm_skill', description: 'FM', instructions: '# FM' };
|
|
643
|
+
const callerSkill = { name: 'caller_skill', description: 'Caller', instructions: '# Caller' };
|
|
644
|
+
loadPromptImpl.mockReturnValue( {
|
|
645
|
+
...basePrompt,
|
|
646
|
+
messages: [ ...basePrompt.messages ],
|
|
647
|
+
promptFileDir: '/some/prompt/dir',
|
|
648
|
+
config: { ...basePrompt.config, skills: [ './skills/' ] }
|
|
649
|
+
} );
|
|
650
|
+
loadPromptSkillsImpl.mockReturnValue( [ frontmatterSkill ] );
|
|
651
|
+
const { generateText } = await importSut();
|
|
652
|
+
await generateText( { prompt: 'test_prompt@v1', skills: [ callerSkill ] } );
|
|
653
|
+
|
|
654
|
+
// Skills system message appended, loadPrompt called only once
|
|
655
|
+
expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
|
|
656
|
+
const callArgs = aiFns.generateText.mock.calls[0][0];
|
|
657
|
+
const loadSkillResult = callArgs.tools.load_skill.execute( { name: 'caller_skill' } );
|
|
658
|
+
expect( loadSkillResult ).toBe( '# Caller' );
|
|
659
|
+
} );
|
|
660
|
+
|
|
661
|
+
it( 'generateText: skips frontmatter skill loading when no config.skills', async () => {
|
|
662
|
+
const { generateText } = await importSut();
|
|
663
|
+
await generateText( { prompt: 'test_prompt@v1' } );
|
|
664
|
+
|
|
665
|
+
expect( loadPromptSkillsImpl ).not.toHaveBeenCalled();
|
|
666
|
+
} );
|
|
667
|
+
|
|
668
|
+
it( 'generateText: skips frontmatter skill loading when no promptFileDir', async () => {
|
|
669
|
+
loadPromptImpl.mockReturnValue( {
|
|
670
|
+
...basePrompt,
|
|
671
|
+
config: { ...basePrompt.config, skills: [ './skills/' ] }
|
|
672
|
+
// no promptFileDir
|
|
673
|
+
} );
|
|
674
|
+
const { generateText } = await importSut();
|
|
675
|
+
await generateText( { prompt: 'test_prompt@v1' } );
|
|
676
|
+
|
|
677
|
+
expect( loadPromptSkillsImpl ).not.toHaveBeenCalled();
|
|
678
|
+
const callArgs = aiFns.generateText.mock.calls[0][0];
|
|
679
|
+
expect( callArgs.tools ).toBeUndefined();
|
|
680
|
+
} );
|
|
681
|
+
|
|
682
|
+
it( 'generateText: appends skills system message when skills present', async () => {
|
|
683
|
+
const frontmatterSkill = { name: 'fm_skill', description: 'FM skill', instructions: '# FM' };
|
|
684
|
+
loadPromptImpl.mockReturnValue( {
|
|
685
|
+
...basePrompt,
|
|
686
|
+
messages: [ ...basePrompt.messages ],
|
|
687
|
+
promptFileDir: '/dir',
|
|
688
|
+
config: { ...basePrompt.config, skills: [ './skills/' ] }
|
|
689
|
+
} );
|
|
690
|
+
loadPromptSkillsImpl.mockReturnValue( [ frontmatterSkill ] );
|
|
691
|
+
const { generateText } = await importSut();
|
|
692
|
+
await generateText( { prompt: 'test_prompt@v1', variables: { topic: 'AI' } } );
|
|
693
|
+
|
|
694
|
+
// Single loadPrompt call — no two-pass render
|
|
695
|
+
expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
|
|
696
|
+
// Skills system message inserted into messages
|
|
697
|
+
const callArgs = aiFns.generateText.mock.calls[0][0];
|
|
698
|
+
const skillsMsg = callArgs.messages.find( m => m.role === 'system' && m.content.includes( 'fm_skill' ) );
|
|
699
|
+
expect( skillsMsg ).toBeDefined();
|
|
700
|
+
} );
|
|
701
|
+
|
|
702
|
+
it( 'generateText: appends skills message and load_skill tool when skills provided', async () => {
|
|
703
|
+
const skills = [
|
|
704
|
+
{ name: 'research', description: 'Research approach', instructions: '# Research\nDo research' }
|
|
705
|
+
];
|
|
706
|
+
loadPromptImpl.mockReturnValue( { ...basePrompt, messages: [ ...basePrompt.messages ] } );
|
|
707
|
+
const { generateText } = await importSut();
|
|
708
|
+
await generateText( { prompt: 'test_prompt@v1', skills } );
|
|
709
|
+
|
|
710
|
+
expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
|
|
711
|
+
const callArgs = aiFns.generateText.mock.calls[0][0];
|
|
712
|
+
expect( callArgs.tools ).toHaveProperty( 'load_skill' );
|
|
713
|
+
const skillsMsg = callArgs.messages.find( m => m.role === 'system' && m.content.includes( 'research' ) );
|
|
714
|
+
expect( skillsMsg ).toBeDefined();
|
|
715
|
+
} );
|
|
716
|
+
|
|
717
|
+
it( 'generateText: does not inject _system_skills or load_skill when skills is empty', async () => {
|
|
718
|
+
const { generateText } = await importSut();
|
|
719
|
+
await generateText( { prompt: 'test_prompt@v1', skills: [] } );
|
|
720
|
+
|
|
721
|
+
expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined, undefined );
|
|
722
|
+
const callArgs = aiFns.generateText.mock.calls[0][0];
|
|
723
|
+
expect( callArgs.tools ).toBeUndefined();
|
|
724
|
+
expect( callArgs.stopWhen ).toBeUndefined();
|
|
725
|
+
} );
|
|
726
|
+
|
|
727
|
+
it( 'generateText: load_skill execute returns instructions for known skill', async () => {
|
|
728
|
+
const skills = [
|
|
729
|
+
{ name: 'research', description: 'Research', instructions: '# Research\nDetailed steps' }
|
|
730
|
+
];
|
|
731
|
+
const { generateText } = await importSut();
|
|
732
|
+
await generateText( { prompt: 'test_prompt@v1', skills } );
|
|
733
|
+
|
|
734
|
+
const { tools } = aiFns.generateText.mock.calls[0][0];
|
|
735
|
+
const result = tools.load_skill.execute( { name: 'research' } );
|
|
736
|
+
expect( result ).toBe( '# Research\nDetailed steps' );
|
|
737
|
+
} );
|
|
738
|
+
|
|
739
|
+
it( 'generateText: load_skill execute returns error for unknown skill', async () => {
|
|
740
|
+
const skills = [
|
|
741
|
+
{ name: 'research', description: 'Research', instructions: '# Research' }
|
|
742
|
+
];
|
|
743
|
+
const { generateText } = await importSut();
|
|
744
|
+
await generateText( { prompt: 'test_prompt@v1', skills } );
|
|
745
|
+
|
|
746
|
+
const { tools } = aiFns.generateText.mock.calls[0][0];
|
|
747
|
+
const result = tools.load_skill.execute( { name: 'unknown' } );
|
|
748
|
+
expect( result ).toMatch( /not found/ );
|
|
749
|
+
expect( result ).toContain( 'research' );
|
|
750
|
+
} );
|
|
751
|
+
|
|
752
|
+
it( 'generateText: sets stopWhen via maxSteps when skills present', async () => {
|
|
753
|
+
const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
|
|
754
|
+
const { generateText } = await importSut();
|
|
755
|
+
await generateText( { prompt: 'test_prompt@v1', skills, maxSteps: 5 } );
|
|
756
|
+
|
|
757
|
+
expect( aiFns.generateText ).toHaveBeenCalledWith(
|
|
758
|
+
expect.objectContaining( { stopWhen: { type: 'stepCount', count: 5 } } )
|
|
759
|
+
);
|
|
760
|
+
} );
|
|
761
|
+
|
|
762
|
+
it( 'generateText: defaults maxSteps to 10 when skills present', async () => {
|
|
763
|
+
const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
|
|
764
|
+
const { generateText } = await importSut();
|
|
765
|
+
await generateText( { prompt: 'test_prompt@v1', skills } );
|
|
766
|
+
|
|
767
|
+
expect( aiFns.generateText ).toHaveBeenCalledWith(
|
|
768
|
+
expect.objectContaining( { stopWhen: { type: 'stepCount', count: 10 } } )
|
|
769
|
+
);
|
|
770
|
+
} );
|
|
771
|
+
|
|
772
|
+
it( 'generateText: merges skill tools with user-provided tools', async () => {
|
|
773
|
+
const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
|
|
774
|
+
const userTools = { calculator: { description: 'A calculator' } };
|
|
775
|
+
const { generateText } = await importSut();
|
|
776
|
+
await generateText( { prompt: 'test_prompt@v1', skills, tools: userTools } );
|
|
777
|
+
|
|
778
|
+
const { tools } = aiFns.generateText.mock.calls[0][0];
|
|
779
|
+
expect( tools ).toHaveProperty( 'load_skill' );
|
|
780
|
+
expect( tools ).toHaveProperty( 'calculator' );
|
|
781
|
+
} );
|
|
782
|
+
|
|
783
|
+
it( 'generateText: calls skill function with variables and uses resolved skills', async () => {
|
|
784
|
+
const resolvedSkill = { name: 'dynamic', description: 'Dynamic skill', instructions: '# Dynamic' };
|
|
785
|
+
const skillsFn = vi.fn().mockResolvedValue( [ resolvedSkill ] );
|
|
786
|
+
const vars = { topic: 'AI' };
|
|
787
|
+
const { generateText } = await importSut();
|
|
788
|
+
await generateText( { prompt: 'test_prompt@v1', variables: vars, skills: skillsFn } );
|
|
789
|
+
|
|
790
|
+
expect( skillsFn ).toHaveBeenCalledWith( vars );
|
|
791
|
+
expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
|
|
792
|
+
const callArgs = aiFns.generateText.mock.calls[0][0];
|
|
793
|
+
expect( callArgs.tools ).toHaveProperty( 'load_skill' );
|
|
794
|
+
const skillsMsg = callArgs.messages.find( m => m.role === 'system' && m.content.includes( 'dynamic' ) );
|
|
795
|
+
expect( skillsMsg ).toBeDefined();
|
|
796
|
+
} );
|
|
797
|
+
|
|
798
|
+
it( 'generateText: preserves caller stopWhen when skills present', async () => {
|
|
799
|
+
const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
|
|
800
|
+
const customStop = { type: 'custom' };
|
|
801
|
+
const { generateText } = await importSut();
|
|
802
|
+
await generateText( { prompt: 'test_prompt@v1', skills, stopWhen: customStop } );
|
|
803
|
+
|
|
804
|
+
expect( aiFns.generateText ).toHaveBeenCalledWith(
|
|
805
|
+
expect.objectContaining( { stopWhen: customStop } )
|
|
806
|
+
);
|
|
807
|
+
} );
|
|
808
|
+
|
|
608
809
|
it( 'generateText: includes sourcesFromTools in trace details', async () => {
|
|
609
810
|
const extracted = [
|
|
610
811
|
{ type: 'source', sourceType: 'url', id: 'abc', url: 'https://t.com', title: 'T' }
|
package/src/index.d.ts
CHANGED
|
@@ -279,7 +279,13 @@ export function generateText<
|
|
|
279
279
|
args: {
|
|
280
280
|
prompt: string,
|
|
281
281
|
variables?: Record<string, string | number | boolean>,
|
|
282
|
-
promptDir?: string
|
|
282
|
+
promptDir?: string,
|
|
283
|
+
/**
|
|
284
|
+
* Skill packages to provide to the LLM. Injects `{{ _system_skills }}` and adds the `load_skill` tool.
|
|
285
|
+
* Can be a static array or a function that receives the resolved variables and returns skills.
|
|
286
|
+
*/
|
|
287
|
+
skills?: import( './skill.js' ).Skill[] |
|
|
288
|
+
( ( variables?: Record<string, string | number | boolean> ) => import( './skill.js' ).Skill[] | Promise<import( './skill.js' ).Skill[]> )
|
|
283
289
|
} & GenerateTextAiSdkOptions<Tools, Output>
|
|
284
290
|
): Promise<GenerateTextResult<Tools, Output>>;
|
|
285
291
|
|
|
@@ -307,3 +313,90 @@ export function streamText<
|
|
|
307
313
|
variables?: Record<string, string | number | boolean>
|
|
308
314
|
} & StreamTextAiSdkOptions<Tools, Output>
|
|
309
315
|
): AIStreamTextResult<Tools, Output>;
|
|
316
|
+
|
|
317
|
+
export { skill } from './skill.js';
|
|
318
|
+
export type { Skill, SkillsArg } from './skill.js';
|
|
319
|
+
|
|
320
|
+
/** Pluggable conversation store for multi-turn Agent interactions. */
|
|
321
|
+
export interface ConversationStore {
|
|
322
|
+
getMessages(): import( 'ai' ).ModelMessage[] | Promise<import( 'ai' ).ModelMessage[]>;
|
|
323
|
+
addMessages( messages: import( 'ai' ).ModelMessage[] ): void | Promise<void>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Create an in-memory conversation store backed by a closure array. */
|
|
327
|
+
export function createMemoryConversationStore(): ConversationStore;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Agent extends AI SDK's ToolLoopAgent with Output.ai prompt file rendering
|
|
331
|
+
* and the skill system.
|
|
332
|
+
*
|
|
333
|
+
* @example Workflow step — variables per call, stateless
|
|
334
|
+
* ```ts
|
|
335
|
+
* const reviewer = new Agent({
|
|
336
|
+
* prompt: 'reviewer@v1',
|
|
337
|
+
* output: Output.object({ schema: z.object({ summary: z.string() }) }),
|
|
338
|
+
* maxSteps: 5
|
|
339
|
+
* });
|
|
340
|
+
* const result = await reviewer.generate();
|
|
341
|
+
* ```
|
|
342
|
+
*
|
|
343
|
+
* @example Interactive — fixed setup, conversation history
|
|
344
|
+
* ```ts
|
|
345
|
+
* const chatbot = new Agent({
|
|
346
|
+
* prompt: 'chatbot@v1',
|
|
347
|
+
* conversationStore: createMemoryConversationStore()
|
|
348
|
+
* });
|
|
349
|
+
* const r1 = await chatbot.generate({ messages: [{ role: 'user', content: 'Hello' }] });
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
export declare class Agent extends import( 'ai' ).ToolLoopAgent {
|
|
353
|
+
constructor( params: {
|
|
354
|
+
/** Prompt file name (e.g. 'my_agent@v1') */
|
|
355
|
+
prompt: string;
|
|
356
|
+
/** Override the stack-resolved prompt directory */
|
|
357
|
+
promptDir?: string;
|
|
358
|
+
/** Variables to render the prompt template at construction time */
|
|
359
|
+
variables?: Record<string, unknown>;
|
|
360
|
+
/** Static skill packages made available to the LLM */
|
|
361
|
+
skills?: import( './skill.js' ).Skill[];
|
|
362
|
+
/** AI SDK tools available during the reasoning loop */
|
|
363
|
+
tools?: ToolSet;
|
|
364
|
+
/** Maximum tool-loop iterations when stopWhen is not specified (default: 10) */
|
|
365
|
+
maxSteps?: number;
|
|
366
|
+
/** Custom stop condition(s) — overrides maxSteps */
|
|
367
|
+
stopWhen?: import( 'ai' ).StopCondition | import( 'ai' ).StopCondition[];
|
|
368
|
+
/** Structured output specification */
|
|
369
|
+
output?: import( 'ai' ).Output<unknown, unknown>;
|
|
370
|
+
/** Pluggable conversation store — opt-in, stateless by default */
|
|
371
|
+
conversationStore?: ConversationStore;
|
|
372
|
+
/** Callback after each step */
|
|
373
|
+
onStepFinish?: import( 'ai' ).GenerateTextOnStepFinishCallback<ToolSet>;
|
|
374
|
+
/** Customize each step before execution */
|
|
375
|
+
prepareStep?: import( 'ai' ).PrepareStepFunction<ToolSet>;
|
|
376
|
+
/** Generation temperature (overrides prompt file value) */
|
|
377
|
+
temperature?: number;
|
|
378
|
+
/** Top-p sampling */
|
|
379
|
+
topP?: number;
|
|
380
|
+
/** Top-k sampling */
|
|
381
|
+
topK?: number;
|
|
382
|
+
/** Random seed for deterministic output */
|
|
383
|
+
seed?: number;
|
|
384
|
+
/** Maximum retry attempts (default: 2) */
|
|
385
|
+
maxRetries?: number;
|
|
386
|
+
} );
|
|
387
|
+
|
|
388
|
+
/** Run the agent and return when complete. */
|
|
389
|
+
generate( options?: {
|
|
390
|
+
messages?: import( 'ai' ).ModelMessage[];
|
|
391
|
+
abortSignal?: AbortSignal;
|
|
392
|
+
onStepFinish?: import( 'ai' ).GenerateTextOnStepFinishCallback<ToolSet>;
|
|
393
|
+
} ): Promise<import( 'ai' ).GenerateTextResult<ToolSet, import( 'ai' ).Output<unknown, unknown>>>;
|
|
394
|
+
|
|
395
|
+
/** Stream the agent's response. */
|
|
396
|
+
stream( options?: {
|
|
397
|
+
messages?: import( 'ai' ).ModelMessage[];
|
|
398
|
+
abortSignal?: AbortSignal;
|
|
399
|
+
onStepFinish?: import( 'ai' ).StreamTextOnStepFinishCallback<ToolSet>;
|
|
400
|
+
experimental_transform?: import( 'ai' ).StreamTextTransform<ToolSet> | import( 'ai' ).StreamTextTransform<ToolSet>[];
|
|
401
|
+
} ): Promise<import( 'ai' ).StreamTextResult<ToolSet, import( 'ai' ).Output<unknown, unknown>>>;
|
|
402
|
+
};
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { generateText, streamText } from './ai_sdk.js';
|
|
2
|
+
export { Agent, createMemoryConversationStore, skill } from './agent.js';
|
|
2
3
|
export { loadPrompt } from './prompt_loader.js';
|
|
3
4
|
export { registerProvider, getRegisteredProviders } from './ai_model.js';
|
|
4
5
|
export { tavilySearch, tavilyExtract, tavilyCrawl, tavilyMap } from '@tavily/ai-sdk';
|
package/src/load_content.js
CHANGED
|
@@ -19,25 +19,48 @@ const loadFile = path => {
|
|
|
19
19
|
}
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
* Recursively search for a file by its name and load its content.
|
|
24
|
-
*
|
|
25
|
-
* @param {string} name - Name of the file load its content
|
|
26
|
-
* @param {string} [dir] - The directory to search for the file, defaults to invocation directory
|
|
27
|
-
* @returns {string | null} - File content or null if not found
|
|
28
|
-
*/
|
|
29
|
-
export const loadContent = ( name, dir = resolveInvocationDir() ) => {
|
|
22
|
+
const findContent = ( name, dir ) => {
|
|
30
23
|
for ( const entry of scanDir( dir ) ) {
|
|
31
24
|
if ( entry.name === name ) {
|
|
32
|
-
return loadFile( join( dir, entry.name ) );
|
|
25
|
+
return { dir, content: loadFile( join( dir, entry.name ) ) };
|
|
33
26
|
}
|
|
34
|
-
|
|
35
27
|
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
return
|
|
28
|
+
const result = findContent( name, join( dir, entry.name ) );
|
|
29
|
+
if ( result ) {
|
|
30
|
+
return result;
|
|
39
31
|
}
|
|
40
32
|
}
|
|
41
33
|
}
|
|
42
34
|
return null;
|
|
43
35
|
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Recursively search for a file by its name and load its content.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} name - Name of the file load its content
|
|
41
|
+
* @param {string} [dir] - The directory to search for the file, defaults to invocation directory
|
|
42
|
+
* @returns {string | null} - File content or null if not found
|
|
43
|
+
*/
|
|
44
|
+
export const loadContent = ( name, dir = resolveInvocationDir() ) =>
|
|
45
|
+
findContent( name, dir )?.content ?? null;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recursively search for a file by name and return the directory containing it.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} name - File name to find
|
|
51
|
+
* @param {string} [dir] - Directory to search, defaults to invocation directory
|
|
52
|
+
* @returns {string | null} - Directory path containing the file, or null if not found
|
|
53
|
+
*/
|
|
54
|
+
export const findContentDir = ( name, dir = resolveInvocationDir() ) =>
|
|
55
|
+
findContent( name, dir )?.dir ?? null;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recursively search for a file by name and return both its content and containing directory.
|
|
59
|
+
* More efficient than calling loadContent + findContentDir separately (single scan).
|
|
60
|
+
*
|
|
61
|
+
* @param {string} name - File name to find
|
|
62
|
+
* @param {string} [dir] - Directory to search, defaults to invocation directory
|
|
63
|
+
* @returns {{ content: string, dir: string } | null}
|
|
64
|
+
*/
|
|
65
|
+
export const loadContentWithDir = ( name, dir = resolveInvocationDir() ) =>
|
|
66
|
+
findContent( name, dir ) ?? null;
|
package/src/prompt_loader.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parsePrompt } from './parser.js';
|
|
2
2
|
import { Liquid } from 'liquidjs';
|
|
3
|
-
import {
|
|
3
|
+
import { loadContentWithDir } from './load_content.js';
|
|
4
4
|
import { validatePrompt } from './prompt_validations.js';
|
|
5
5
|
import { FatalError } from '@outputai/core';
|
|
6
6
|
|
|
@@ -20,26 +20,22 @@ const renderPrompt = ( name, content, values ) => {
|
|
|
20
20
|
* @param {string} name - Name of the prompt file (without .prompt extension)
|
|
21
21
|
* @param {Record<string, string | number | boolean>} [values] - Variables to interpolate
|
|
22
22
|
* @param {string} [dir] - Directory to search for the prompt file (defaults to stack-resolved invocation dir)
|
|
23
|
-
* @returns {Prompt} Loaded and rendered prompt object
|
|
23
|
+
* @returns {Prompt} Loaded and rendered prompt object, including promptFileDir
|
|
24
24
|
*/
|
|
25
25
|
export const loadPrompt = ( name, values = {}, dir ) => {
|
|
26
|
-
const
|
|
27
|
-
if ( !
|
|
26
|
+
const found = loadContentWithDir( `${name}.prompt`, dir );
|
|
27
|
+
if ( !found ) {
|
|
28
28
|
throw new FatalError( `Prompt ${name} not found.` );
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const renderedContent = renderPrompt( name,
|
|
31
|
+
const renderedContent = renderPrompt( name, found.content, values );
|
|
32
32
|
|
|
33
33
|
const { config, messages } = parsePrompt( renderedContent );
|
|
34
34
|
|
|
35
|
-
const prompt = {
|
|
36
|
-
name,
|
|
37
|
-
config,
|
|
38
|
-
messages
|
|
39
|
-
};
|
|
35
|
+
const prompt = { name, config, messages };
|
|
40
36
|
|
|
41
37
|
validatePrompt( prompt );
|
|
42
38
|
|
|
43
|
-
return prompt;
|
|
39
|
+
return { ...prompt, promptFileDir: found.dir };
|
|
44
40
|
};
|
|
45
41
|
|
|
@@ -3,14 +3,14 @@ import { loadPrompt } from './prompt_loader.js';
|
|
|
3
3
|
|
|
4
4
|
// Mock dependencies that perform I/O or validation
|
|
5
5
|
vi.mock( './load_content.js', () => ( {
|
|
6
|
-
|
|
6
|
+
loadContentWithDir: vi.fn()
|
|
7
7
|
} ) );
|
|
8
8
|
|
|
9
9
|
vi.mock( './prompt_validations.js', () => ( {
|
|
10
10
|
validatePrompt: vi.fn()
|
|
11
11
|
} ) );
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { loadContentWithDir } from './load_content.js';
|
|
14
14
|
import { validatePrompt } from './prompt_validations.js';
|
|
15
15
|
|
|
16
16
|
describe( 'loadPrompt', () => {
|
|
@@ -25,7 +25,7 @@ model: claude-3-5-sonnet-20241022
|
|
|
25
25
|
---
|
|
26
26
|
<user>Hello {{ name }}!</user>`;
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
29
29
|
|
|
30
30
|
const result = loadPrompt( 'test', { name: 'World' } );
|
|
31
31
|
|
|
@@ -33,11 +33,11 @@ model: claude-3-5-sonnet-20241022
|
|
|
33
33
|
expect( result.config ).toEqual( { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' } );
|
|
34
34
|
expect( result.messages ).toHaveLength( 1 );
|
|
35
35
|
expect( result.messages[0].content ).toBe( 'Hello World!' );
|
|
36
|
-
expect( validatePrompt ).toHaveBeenCalledWith(
|
|
36
|
+
expect( validatePrompt ).toHaveBeenCalledWith( expect.objectContaining( { name: 'test' } ) );
|
|
37
37
|
} );
|
|
38
38
|
|
|
39
39
|
it( 'throws error when prompt file not found', () => {
|
|
40
|
-
|
|
40
|
+
loadContentWithDir.mockReturnValue( null );
|
|
41
41
|
|
|
42
42
|
expect( () => {
|
|
43
43
|
loadPrompt( 'nonexistent' );
|
|
@@ -60,7 +60,7 @@ temperature: 0.7
|
|
|
60
60
|
|
|
61
61
|
<user>Hello</user>`;
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
64
64
|
|
|
65
65
|
const result = loadPrompt( 'test', {
|
|
66
66
|
provider_name: 'anthropic',
|
|
@@ -80,7 +80,7 @@ model: {{ model }}
|
|
|
80
80
|
|
|
81
81
|
<user>Tell me about {{ topic }}</user>`;
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
84
84
|
|
|
85
85
|
const result = loadPrompt( 'test', {
|
|
86
86
|
provider: 'openai',
|
|
@@ -101,7 +101,7 @@ model: claude-3-5-sonnet-20241022
|
|
|
101
101
|
|
|
102
102
|
<user>Hello</user>`;
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
105
105
|
|
|
106
106
|
const result = loadPrompt( 'test', {} );
|
|
107
107
|
|
|
@@ -119,7 +119,7 @@ temperature: 0.7
|
|
|
119
119
|
|
|
120
120
|
<user>Hello</user>`;
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
123
123
|
|
|
124
124
|
const result = loadPrompt( 'test', {
|
|
125
125
|
base_model: 'claude-sonnet',
|
|
@@ -139,7 +139,7 @@ temperature: 0.7
|
|
|
139
139
|
|
|
140
140
|
<user>Hello</user>`;
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
143
143
|
|
|
144
144
|
const result = loadPrompt( 'test', {} );
|
|
145
145
|
|
|
@@ -154,7 +154,7 @@ model: claude-3-5-sonnet-20241022
|
|
|
154
154
|
|
|
155
155
|
<user>{% if debug %}Debug mode enabled{% else %}Debug mode disabled{% endif %}</user>`;
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
158
158
|
|
|
159
159
|
const result = loadPrompt( 'test', { debug: true } );
|
|
160
160
|
|
|
@@ -169,7 +169,7 @@ model: claude-3-5-sonnet-20241022
|
|
|
169
169
|
|
|
170
170
|
<user>{% if enabled %}Feature enabled{% else %}Feature disabled{% endif %}</user>`;
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
173
173
|
|
|
174
174
|
const result = loadPrompt( 'test', { enabled: false } );
|
|
175
175
|
|
|
@@ -2,10 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
import { loadPrompt } from './prompt_loader.js';
|
|
3
3
|
|
|
4
4
|
vi.mock( './load_content.js', () => ( {
|
|
5
|
-
|
|
5
|
+
loadContentWithDir: vi.fn()
|
|
6
6
|
} ) );
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { loadContentWithDir } from './load_content.js';
|
|
9
9
|
|
|
10
10
|
describe( 'loadPrompt - validation with real schema', () => {
|
|
11
11
|
beforeEach( () => {
|
|
@@ -26,7 +26,7 @@ providerOptions:
|
|
|
26
26
|
|
|
27
27
|
<user>Hello</user>`;
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
30
30
|
|
|
31
31
|
const result = loadPrompt( 'test', {} );
|
|
32
32
|
|
|
@@ -44,7 +44,7 @@ max_tokens: 64000
|
|
|
44
44
|
|
|
45
45
|
<user>Hello</user>`;
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
48
48
|
|
|
49
49
|
// Config uses passthrough, so max_tokens is accepted (though ignored by SDK)
|
|
50
50
|
expect( () => {
|
|
@@ -64,7 +64,7 @@ options:
|
|
|
64
64
|
|
|
65
65
|
<user>Hello</user>`;
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
68
68
|
|
|
69
69
|
// Config uses passthrough, so 'options' passes through (though not used)
|
|
70
70
|
expect( () => {
|
|
@@ -84,7 +84,7 @@ providerOptions:
|
|
|
84
84
|
|
|
85
85
|
<user>Hello</user>`;
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
loadContentWithDir.mockReturnValue( { content: promptContent, dir: '/mock/dir' } );
|
|
88
88
|
|
|
89
89
|
// budget_tokens is silently stripped from thinking (unknown field), not rejected
|
|
90
90
|
expect( () => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { extractSourcesFromSteps } from './source_extraction.js';
|
|
2
|
+
import { endTraceWithSuccess } from './trace_utils.js';
|
|
3
|
+
|
|
4
|
+
export const wrapInOutputResponse = async ( response, { traceId, modelId } ) => {
|
|
5
|
+
const sourcesFromTools = extractSourcesFromSteps( response.steps );
|
|
6
|
+
await endTraceWithSuccess( traceId, modelId, response, { sourcesFromTools } );
|
|
7
|
+
|
|
8
|
+
return new Proxy( response, {
|
|
9
|
+
get( target, prop, receiver ) {
|
|
10
|
+
if ( prop === 'result' ) {
|
|
11
|
+
return target.text;
|
|
12
|
+
}
|
|
13
|
+
if ( prop === 'sources' && sourcesFromTools.length > 0 ) {
|
|
14
|
+
const responseSources = Array.isArray( target[prop] ) ? target[prop] : [];
|
|
15
|
+
const byUrl = new Map( [ ...sourcesFromTools, ...responseSources ].map( s => [ s.url, s ] ) );
|
|
16
|
+
return [ ...byUrl.values() ];
|
|
17
|
+
}
|
|
18
|
+
return Reflect.get( target, prop, receiver );
|
|
19
|
+
}
|
|
20
|
+
} );
|
|
21
|
+
};
|