diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index 41d035564a..5cbbdfce21 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -1,5 +1,6 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { LDAIAgentDefaults } from '../src/api/agents'; import { LDAIDefaults } from '../src/api/config'; import { LDAIClientImpl } from '../src/LDAIClientImpl'; import { LDClientMin } from '../src/LDClientMin'; @@ -11,6 +12,10 @@ const mockLdClient: jest.Mocked = { const testContext: LDContext = { kind: 'user', key: 'test-user' }; +beforeEach(() => { + jest.clearAllMocks(); +}); + it('returns config with interpolated messagess', async () => { const client = new LDAIClientImpl(mockLdClient); const key = 'test-flag'; @@ -129,3 +134,337 @@ it('passes the default value to the underlying client', async () => { expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); }); + +it('returns single agent config with interpolated instructions', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + enabled: true, + }; + + const mockVariation = { + model: { + name: 'example-model', + parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, + }, + provider: { + name: 'example-provider', + }, + instructions: 'You are a helpful assistant. Your name is {{name}} and your score is {{score}}', + _ldMeta: { + variationKey: 'v1', + enabled: true, + mode: 'agent', + }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const variables = { name: 'John', score: 42 }; + const result = await client.agent(key, testContext, defaultValue, variables); + + expect(result).toEqual({ + model: { + name: 'example-model', + parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, + }, + provider: { + name: 'example-provider', + }, + instructions: 'You are a helpful assistant. Your name is John and your score is 42', + tracker: expect.any(Object), + enabled: true, + }); + + // Verify tracking was called + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:single', + testContext, + key, + 1, + ); +}); + +it('includes context in variables for agent instructions interpolation', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + }; + + const mockVariation = { + instructions: 'You are a helpful assistant. Your user key is {{ldctx.key}}', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const result = await client.agent(key, testContext, defaultValue); + + expect(result.instructions).toBe('You are a helpful assistant. Your user key is test-user'); +}); + +it('handles missing metadata in agent variation', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + }; + + const mockVariation = { + model: { name: 'example-provider', parameters: { name: 'imagination' } }, + instructions: 'Hello.', + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const result = await client.agent(key, testContext, defaultValue); + + expect(result).toEqual({ + model: { name: 'example-provider', parameters: { name: 'imagination' } }, + instructions: 'Hello.', + tracker: expect.any(Object), + enabled: false, + }); +}); + +it('passes the default value to the underlying client for single agent', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'non-existent-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'default-model', parameters: { name: 'default' } }, + provider: { name: 'default-provider' }, + instructions: 'Default instructions', + enabled: true, + }; + + mockLdClient.variation.mockResolvedValue(defaultValue); + + const result = await client.agent(key, testContext, defaultValue); + + expect(result).toEqual({ + model: defaultValue.model, + instructions: defaultValue.instructions, + provider: defaultValue.provider, + tracker: expect.any(Object), + enabled: false, + }); + + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); +}); + +it('handles single agent with optional defaultValue', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + + const mockVariation = { + instructions: 'You are a helpful assistant named {{name}}.', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const variables = { name: 'Helper' }; + + // Test without providing defaultValue + const result = await client.agent(key, testContext, undefined, variables); + + expect(result).toEqual({ + instructions: 'You are a helpful assistant named Helper.', + tracker: expect.any(Object), + enabled: true, + }); + + // Verify tracking was called + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:single', + testContext, + key, + 1, + ); + + // Verify the agent was called with { enabled: false } as default + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, { enabled: false }); +}); + +it('handles single agent without any optional parameters', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'simple-agent'; + + const mockVariation = { + instructions: 'Simple instructions.', + _ldMeta: { variationKey: 'v1', enabled: false, mode: 'agent' }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + // Test with only required parameters + const result = await client.agent(key, testContext); + + expect(result).toEqual({ + instructions: 'Simple instructions.', + tracker: expect.any(Object), + enabled: false, + }); + + // Verify the agent was called with { enabled: false } as default and no variables + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, { enabled: false }); +}); + +it('returns multiple agents config with interpolated instructions', async () => { + const client = new LDAIClientImpl(mockLdClient); + + const agentConfigs = [ + { + key: 'research-agent', + defaultValue: { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a research assistant.', + enabled: true, + }, + variables: { topic: 'climate change' }, + }, + { + key: 'writing-agent', + defaultValue: { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a writing assistant.', + enabled: true, + }, + variables: { style: 'academic' }, + }, + ] as const; + + const mockVariations = { + 'research-agent': { + model: { + name: 'research-model', + parameters: { temperature: 0.3, maxTokens: 2048 }, + }, + provider: { name: 'openai' }, + instructions: 'You are a research assistant specializing in {{topic}}.', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }, + 'writing-agent': { + model: { + name: 'writing-model', + parameters: { temperature: 0.7, maxTokens: 1024 }, + }, + provider: { name: 'anthropic' }, + instructions: 'You are a writing assistant with {{style}} style.', + _ldMeta: { variationKey: 'v2', enabled: true, mode: 'agent' }, + }, + }; + + mockLdClient.variation.mockImplementation((key) => + Promise.resolve(mockVariations[key as keyof typeof mockVariations]), + ); + + const result = await client.agents(agentConfigs, testContext); + + expect(result).toEqual({ + 'research-agent': { + model: { + name: 'research-model', + parameters: { temperature: 0.3, maxTokens: 2048 }, + }, + provider: { name: 'openai' }, + instructions: 'You are a research assistant specializing in climate change.', + tracker: expect.any(Object), + enabled: true, + }, + 'writing-agent': { + model: { + name: 'writing-model', + parameters: { temperature: 0.7, maxTokens: 1024 }, + }, + provider: { name: 'anthropic' }, + instructions: 'You are a writing assistant with academic style.', + tracker: expect.any(Object), + enabled: true, + }, + }); + + // Verify tracking was called + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:multiple', + testContext, + agentConfigs.length, + agentConfigs.length, + ); +}); + +it('handles empty agent configs array', async () => { + const client = new LDAIClientImpl(mockLdClient); + + const result = await client.agents([], testContext); + + expect(result).toEqual({}); + + // Verify tracking was called with 0 agents + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:multiple', + testContext, + 0, + 0, + ); +}); + +it('handles agents with optional defaultValue', async () => { + const client = new LDAIClientImpl(mockLdClient); + + const agentConfigs = [ + { + key: 'agent-with-default', + defaultValue: { + instructions: 'You are a helpful assistant.', + enabled: true, + }, + variables: { name: 'Assistant' }, + }, + { + key: 'agent-without-default', + variables: { name: 'Helper' }, + // No defaultValue provided - should default to { enabled: false } + }, + ] as const; + + const mockVariations = { + 'agent-with-default': { + instructions: 'Hello {{name}}!', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }, + 'agent-without-default': { + instructions: 'Hi {{name}}!', + _ldMeta: { variationKey: 'v2', enabled: false, mode: 'agent' }, + }, + }; + + mockLdClient.variation.mockImplementation((key) => + Promise.resolve(mockVariations[key as keyof typeof mockVariations]), + ); + + const result = await client.agents(agentConfigs, testContext); + + expect(result).toEqual({ + 'agent-with-default': { + instructions: 'Hello Assistant!', + tracker: expect.any(Object), + enabled: true, + }, + 'agent-without-default': { + instructions: 'Hi Helper!', + tracker: expect.any(Object), + enabled: false, + }, + }); + + // Verify the agent without defaultValue was called with { enabled: false } + expect(mockLdClient.variation).toHaveBeenCalledWith('agent-without-default', testContext, { + enabled: false, + }); +}); diff --git a/packages/sdk/server-ai/src/LDAIClientImpl.ts b/packages/sdk/server-ai/src/LDAIClientImpl.ts index bca8431cce..57755a2a55 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -2,18 +2,29 @@ import * as Mustache from 'mustache'; import { LDContext } from '@launchdarkly/js-server-sdk-common'; -import { LDAIConfig, LDAIDefaults, LDMessage, LDModelConfig, LDProviderConfig } from './api/config'; +import { LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults } from './api/agents'; +import { + LDAIConfig, + LDAIConfigTracker, + LDAIDefaults, + LDMessage, + LDModelConfig, + LDProviderConfig, +} from './api/config'; import { LDAIClient } from './api/LDAIClient'; import { LDAIConfigTrackerImpl } from './LDAIConfigTrackerImpl'; import { LDClientMin } from './LDClientMin'; +type Mode = 'completion' | 'agent'; + /** - * Metadata assorted with a model configuration variation. + * Metadata associated with a model configuration variation. */ interface LDMeta { variationKey: string; enabled: boolean; version?: number; + mode?: Mode; } /** @@ -23,10 +34,24 @@ interface LDMeta { interface VariationContent { model?: LDModelConfig; messages?: LDMessage[]; + instructions?: string; provider?: LDProviderConfig; _ldMeta?: LDMeta; } +/** + * The result of evaluating a configuration. + */ +interface EvaluationResult { + tracker: LDAIConfigTracker; + enabled: boolean; + model?: LDModelConfig; + provider?: LDProviderConfig; + messages?: LDMessage[]; + instructions?: string; + mode?: string; +} + export class LDAIClientImpl implements LDAIClient { constructor(private _ldClient: LDClientMin) {} @@ -34,13 +59,13 @@ export class LDAIClientImpl implements LDAIClient { return Mustache.render(template, variables, undefined, { escape: (item: any) => item }); } - async config( + private async _evaluate( key: string, context: LDContext, defaultValue: LDAIDefaults, - variables?: Record, - ): Promise { + ): Promise { const value: VariationContent = await this._ldClient.variation(key, context, defaultValue); + const tracker = new LDAIConfigTrackerImpl( this._ldClient, key, @@ -50,24 +75,87 @@ export class LDAIClientImpl implements LDAIClient { value._ldMeta?.version ?? 1, context, ); + // eslint-disable-next-line no-underscore-dangle const enabled = !!value._ldMeta?.enabled; + + return { + tracker, + enabled, + model: value.model, + provider: value.provider, + messages: value.messages, + instructions: value.instructions, + // eslint-disable-next-line no-underscore-dangle + mode: value._ldMeta?.mode ?? 'completion', + }; + } + + private async _evaluateAgent( + key: string, + context: LDContext, + defaultValue: LDAIAgentDefaults, + variables?: Record, + ): Promise { + const { tracker, enabled, model, provider, instructions } = await this._evaluate( + key, + context, + defaultValue, + ); + + const agent: LDAIAgent = { + tracker, + enabled, + }; + + // We are going to modify the contents before returning them, so we make a copy. + // This isn't a deep copy and the application developer should not modify the returned content. + if (model) { + agent.model = { ...model }; + } + + if (provider) { + agent.provider = { ...provider }; + } + + const allVariables = { ...variables, ldctx: context }; + + if (instructions) { + agent.instructions = this._interpolateTemplate(instructions, allVariables); + } + + return agent; + } + + async config( + key: string, + context: LDContext, + defaultValue: LDAIDefaults, + variables?: Record, + ): Promise { + const { tracker, enabled, model, provider, messages } = await this._evaluate( + key, + context, + defaultValue, + ); + const config: LDAIConfig = { tracker, enabled, }; + // We are going to modify the contents before returning them, so we make a copy. // This isn't a deep copy and the application developer should not modify the returned content. - if (value.model) { - config.model = { ...value.model }; + if (model) { + config.model = { ...model }; } - if (value.provider) { - config.provider = { ...value.provider }; + if (provider) { + config.provider = { ...provider }; } const allVariables = { ...variables, ldctx: context }; - if (value.messages) { - config.messages = value.messages.map((entry: any) => ({ + if (messages) { + config.messages = messages.map((entry: any) => ({ ...entry, content: this._interpolateTemplate(entry.content, allVariables), })); @@ -75,4 +163,51 @@ export class LDAIClientImpl implements LDAIClient { return config; } + + async agent( + key: string, + context: LDContext, + defaultValue?: LDAIAgentDefaults, + variables?: Record, + ): Promise { + // Track agent usage + this._ldClient.track('$ld:ai:agent:function:single', context, key, 1); + + // Use provided defaultValue or fallback to { enabled: false } + const resolvedDefaultValue = defaultValue ?? { enabled: false }; + + return this._evaluateAgent(key, context, resolvedDefaultValue, variables); + } + + async agents( + agentConfigs: T, + context: LDContext, + ): Promise> { + // Track multiple agents usage + this._ldClient.track( + '$ld:ai:agent:function:multiple', + context, + agentConfigs.length, + agentConfigs.length, + ); + + const agents = {} as Record; + + await Promise.all( + agentConfigs.map(async (config) => { + // Use provided defaultValue or fallback to { enabled: false } + const defaultValue = config.defaultValue ?? { enabled: false }; + + const agent = await this._evaluateAgent( + config.key, + context, + defaultValue, + config.variables, + ); + agents[config.key as T[number]['key']] = agent; + }), + ); + + return agents; + } } diff --git a/packages/sdk/server-ai/src/api/LDAIClient.ts b/packages/sdk/server-ai/src/api/LDAIClient.ts index 4bf5f617e0..bbf71c359f 100644 --- a/packages/sdk/server-ai/src/api/LDAIClient.ts +++ b/packages/sdk/server-ai/src/api/LDAIClient.ts @@ -1,5 +1,6 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults } from './agents'; import { LDAIConfig, LDAIDefaults } from './config/LDAIConfig'; /** @@ -63,4 +64,96 @@ export interface LDAIClient { defaultValue: LDAIDefaults, variables?: Record, ): Promise; + + /** + * Retrieves and processes a single AI Config agent based on the provided key, LaunchDarkly context, + * and variables. This includes the model configuration and the customized instructions. + * + * @param key The key of the AI Config agent. + * @param context The LaunchDarkly context object that contains relevant information about the + * current environment, user, or session. This context may influence how the configuration is + * processed or personalized. + * @param defaultValue A fallback value containing model configuration and instructions. If not + * provided, defaults to { enabled: false }. + * @param variables A map of key-value pairs representing dynamic variables to be injected into + * the instructions. The keys correspond to placeholders within the template, and the values + * are the corresponding replacements. + * + * @returns An AI agent with customized `instructions` and a `tracker`. If the configuration + * cannot be accessed from LaunchDarkly, then the return value will include information from the + * `defaultValue`. The returned `tracker` can be used to track AI operation metrics (latency, token usage, etc.). + * + * @example + * ``` + * const key = "research_agent"; + * const context = {...}; + * const variables = { topic: 'climate change' }; + * + * // With explicit defaultValue + * const agent = await client.agent(key, context, { + * enabled: true, + * instructions: 'You are a research assistant.', + * }, variables); + * + * // Without defaultValue (defaults to { enabled: false }) + * const simpleAgent = await client.agent(key, context, undefined, variables); + * // or even simpler: + * const simpleAgent2 = await client.agent(key, context); + * + * const researchResult = agent.instructions; // Interpolated instructions + * agent.tracker.trackSuccess(); + * ``` + */ + agent( + key: string, + context: LDContext, + defaultValue?: LDAIAgentDefaults, + variables?: Record, + ): Promise; + + /** + * Retrieves and processes multiple AI Config agents based on the provided agent configurations + * and LaunchDarkly context. This includes the model configuration and the customized instructions. + * + * @param agentConfigs An array of agent configurations, each containing the agent key, default configuration, + * and variables for instructions interpolation. + * @param context The LaunchDarkly context object that contains relevant information about the + * current environment, user, or session. This context may influence how the configuration is + * processed or personalized. + * + * @returns A map of agent keys to their respective AI agents with customized `instructions` and `tracker`. + * If a configuration cannot be accessed from LaunchDarkly, then the return value will include information + * from the respective `defaultValue`. The returned `tracker` can be used to track AI operation metrics + * (latency, token usage, etc.). + * + * @example + * ``` + * const agentConfigs: LDAIAgentConfig[] = [ + * { + * key: 'research_agent', + * defaultValue: { enabled: true, instructions: 'You are a research assistant.' }, + * variables: { topic: 'climate change' } + * }, + * { + * key: 'writing_agent', + * defaultValue: { enabled: true, instructions: 'You are a writing assistant.' }, + * variables: { style: 'academic' } + * }, + * { + * key: 'simple_agent', + * variables: { name: 'Helper' } + * // defaultValue is optional, will default to { enabled: false } + * } + * ] as const; + * const context = {...}; + * + * const agents = await client.agents(agentConfigs, context); + * const researchResult = agents["research_agent"].instructions; // Interpolated instructions + * agents["research_agent"].tracker.trackSuccess(); + * ``` + */ + agents( + agentConfigs: T, + context: LDContext, + ): Promise>; } diff --git a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts new file mode 100644 index 0000000000..ddfcfc6d64 --- /dev/null +++ b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts @@ -0,0 +1,44 @@ +import { LDAIConfig } from '../config'; + +/** + * AI Config agent and tracker. + */ +export interface LDAIAgent extends Omit { + /** + * Instructions for the agent. + */ + instructions?: string; +} + +/** + * Configuration for a single agent request. + */ +export interface LDAIAgentConfig { + /** + * The agent key to retrieve. + */ + key: string; + + /** + * Default configuration for the agent. If not provided, defaults to { enabled: false }. + */ + defaultValue?: LDAIAgentDefaults; + + /** + * Variables for instructions interpolation. + */ + variables?: Record; +} + +/** + * Default value for an agent configuration. This is the same as the LDAIAgent, but it does not include + * a tracker and `enabled` is optional. + */ +export type LDAIAgentDefaults = Omit & { + /** + * Whether the agent configuration is enabled. + * + * @default false + */ + enabled?: boolean; +}; diff --git a/packages/sdk/server-ai/src/api/agents/index.ts b/packages/sdk/server-ai/src/api/agents/index.ts new file mode 100644 index 0000000000..f68fcd9a24 --- /dev/null +++ b/packages/sdk/server-ai/src/api/agents/index.ts @@ -0,0 +1 @@ +export * from './LDAIAgent'; diff --git a/packages/sdk/server-ai/src/api/index.ts b/packages/sdk/server-ai/src/api/index.ts index c6c70867bb..cd6333b027 100644 --- a/packages/sdk/server-ai/src/api/index.ts +++ b/packages/sdk/server-ai/src/api/index.ts @@ -1,3 +1,4 @@ export * from './config'; +export * from './agents'; export * from './metrics'; export * from './LDAIClient';