@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.
@@ -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';
@@ -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 content = loadContent( name, join( dir, entry.name ) );
37
- if ( content ) {
38
- return content;
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;
@@ -0,0 +1,3 @@
1
+ export const ROLE = { SYSTEM: 'system', USER: 'user', ASSISTANT: 'assistant', TOOL: 'tool' };
2
+ export const isRole = role => msg => msg.role === role;
3
+ export const getContent = msg => msg.content;
@@ -1,6 +1,6 @@
1
1
  import { parsePrompt } from './parser.js';
2
2
  import { Liquid } from 'liquidjs';
3
- import { loadContent } from './load_content.js';
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 promptContent = dir ? loadContent( `${name}.prompt`, dir ) : loadContent( `${name}.prompt` );
27
- if ( !promptContent ) {
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, promptContent, values );
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
- loadContent: vi.fn()
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 { loadContent } from './load_content.js';
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
- loadContent.mockReturnValue( promptContent );
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( result );
36
+ expect( validatePrompt ).toHaveBeenCalledWith( expect.objectContaining( { name: 'test' } ) );
37
37
  } );
38
38
 
39
39
  it( 'throws error when prompt file not found', () => {
40
- loadContent.mockReturnValue( null );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent: vi.fn()
5
+ loadContentWithDir: vi.fn()
6
6
  } ) );
7
7
 
8
- import { loadContent } from './load_content.js';
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
- loadContent.mockReturnValue( promptContent );
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
+ };