Skip to content

Commit 12c0b7a

Browse files
authored
chore: Add Agent and Judge field parsers to ConfigFactory (#279)
## Summary Adds the **parsing primitives** for agent and judge config fields to `ConfigFactory`, plus a small visibility fix on `LdAiConfigBase`. The only new public type is `ToolConfig`. Four new internal static helpers on `ConfigFactory`: - `ParseInstructions(LdValue)` — returns the string at `instructions`, or null if missing/wrong-typed (used by agent configs). - `ParseTools(LdValue)` — reads the `tools` object as a `IReadOnlyDictionary<string, ToolConfig>`, keyed by the wire-format property name. Returns an empty dictionary for missing/wrong-typed inputs (tolerant, consistent with the existing parsers). - `ParseJudgeConfiguration(LdValue)` — reads the `judgeConfiguration.judges[]` array into a `JudgeConfigurationData` of `(key, samplingRate)` entries. Returns null when the block is missing, an empty list when `judges` is missing/wrong-typed. - `ParseEvaluationMetricKey(LdValue)` — returns the string at `evaluationMetricKey`, or null. `ToolConfig` is the only new public type. It is a sealed record with an internal constructor and lives in `LaunchDarkly.Sdk.Server.Ai.Config` alongside `Message`, `ModelConfig`, and `ProviderConfig` — same convention as the rest of the data shapes. `JudgeConfigurationData` and `JudgeEntry` are internal-only; they're shaped to fit how the judge sampling logic will consume them. ## Visibility fix on `LdAiConfigBase` `VariationKey` and `Version` on `LdAiConfigBase` are now `internal` rather than `public`. The doc comment on both already said *"This field meant for internal LaunchDarkly usage"* — they were inadvertently exposed. This is a breaking change for any caller that was reading them off a concrete config (`LdAiCompletionConfig`, etc.), but no public API was ever expected to depend on them. ## Test plan - [ ] `dotnet build` succeeds across `netstandard2.0`, `net462`, `net8.0` - [ ] `dotnet test --framework net8.0` passes - [ ] New `ConfigFactoryParserTest` covers each parser: - `ParseTools_ReturnsTwoEntries_WithCorrectCustomParameters` - `ParseJudgeConfiguration_ReturnsTwoEntries_WithCorrectKeysAndSamplingRates` - `ParseEvaluationMetricKey_ReturnsParsedString` - `ParseInstructions_ReturnsRawUninterpolatedString` - `ParseAllFields_WithNoNewFields_ReturnsNullOrEmptyWithoutError` (tolerant fallback for missing blocks) - [ ] Reviewer confirms `ToolConfig` shape matches the cross-SDK pattern <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Public API break on LdAiConfig.VariationKey/Version for external callers; new parsers are isolated but judge/tool shapes will underpin later agent/judge behavior. > > **Overview** > Adds **internal `ConfigFactory` parsers** for upcoming agent/judge config modes: `instructions`, root-level `tools`, `judgeConfiguration.judges`, and `evaluationMetricKey`, with tolerant skipping of malformed entries and **no wiring into build paths yet** (step 1 of 6). > > Introduces public **`LdAiConfigTypes.Tool`** and **`JudgeConfiguration`** (+ nested `Judge`) to hold parsed wire data; adds **`ConfigFactoryParserTest`** for happy paths, malformed inputs, and missing fields. > > **Breaking visibility change:** `LdAiConfig.VariationKey` and `Version` are now **`internal`** (still used inside the SDK for trackers), aligning with their “internal LaunchDarkly usage” docs. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e7858b0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 81f8345 commit 12c0b7a

4 files changed

Lines changed: 338 additions & 2 deletions

File tree

pkgs/sdk/server-ai/src/Config/ConfigFactory.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,52 @@ private static IReadOnlyDictionary<string, LdValue> LdValueObjectToDictionary(Ld
195195
return result;
196196
}
197197

198+
internal static string ParseInstructions(LdValue instructionsValue)
199+
{
200+
return instructionsValue.Type == LdValueType.String ? instructionsValue.AsString : null;
201+
}
202+
203+
internal static IReadOnlyDictionary<string, LdAiConfigTypes.Tool> ParseTools(LdValue toolsValue)
204+
{
205+
if (toolsValue.Type != LdValueType.Object) return ImmutableDictionary<string, LdAiConfigTypes.Tool>.Empty;
206+
var result = ImmutableDictionary.CreateBuilder<string, LdAiConfigTypes.Tool>();
207+
foreach (var kv in toolsValue.Dictionary)
208+
{
209+
var tool = kv.Value;
210+
if (tool.Type != LdValueType.Object) continue;
211+
result[kv.Key] = new LdAiConfigTypes.Tool(
212+
tool.Get("name").AsString ?? "",
213+
tool.Get("description").AsString,
214+
tool.Get("type").AsString,
215+
LdValueObjectToDictionary(tool.Get("parameters")),
216+
LdValueObjectToDictionary(tool.Get("customParameters")));
217+
}
218+
return result.ToImmutable();
219+
}
220+
221+
internal static LdAiConfigTypes.JudgeConfiguration ParseJudgeConfiguration(LdValue judgeConfigurationValue)
222+
{
223+
if (judgeConfigurationValue.Type != LdValueType.Object) return null;
224+
var judgesArray = judgeConfigurationValue.Get("judges");
225+
if (judgesArray.Type != LdValueType.Array) return new LdAiConfigTypes.JudgeConfiguration(new List<LdAiConfigTypes.JudgeConfiguration.Judge>());
226+
var entries = new List<LdAiConfigTypes.JudgeConfiguration.Judge>();
227+
for (var i = 0; i < judgesArray.Count; i++)
228+
{
229+
var j = judgesArray.Get(i);
230+
if (j.Type != LdValueType.Object) continue;
231+
entries.Add(new LdAiConfigTypes.JudgeConfiguration.Judge(
232+
j.Get("key").AsString ?? "",
233+
j.Get("samplingRate").AsDouble));
234+
}
235+
return new LdAiConfigTypes.JudgeConfiguration(entries);
236+
}
237+
238+
internal static string ParseEvaluationMetricKey(LdValue value)
239+
{
240+
var emk = value.Get("evaluationMetricKey");
241+
return emk.Type == LdValueType.String ? emk.AsString : null;
242+
}
243+
198244
private static LdAiConfigTypes.Role ParseRole(string roleString)
199245
{
200246
// The wire format uses capitalized "User" / "System" / "Assistant"; Enum.TryParse with

pkgs/sdk/server-ai/src/Config/LdAiConfig.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ public abstract class LdAiConfig
3333
/// <summary>
3434
/// This field meant for internal LaunchDarkly usage.
3535
/// </summary>
36-
public string VariationKey { get; }
36+
internal string VariationKey { get; }
3737

3838
/// <summary>
3939
/// This field meant for internal LaunchDarkly usage.
4040
/// </summary>
41-
public int Version { get; }
41+
internal int Version { get; }
4242

4343
/// <summary>
4444
/// Factory that produces a tracker for the config. The factory is mode-agnostic — it

pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,87 @@ internal ProviderConfig(string name)
9898
Name = name;
9999
}
100100
}
101+
102+
/// <summary>
103+
/// Represents a tool available to the model in agent mode.
104+
/// </summary>
105+
public sealed record Tool
106+
{
107+
/// <summary>
108+
/// The name of the tool.
109+
/// </summary>
110+
public readonly string Name;
111+
112+
/// <summary>
113+
/// A description of the tool.
114+
/// </summary>
115+
public readonly string Description;
116+
117+
/// <summary>
118+
/// The type of the tool.
119+
/// </summary>
120+
public readonly string Type;
121+
122+
/// <summary>
123+
/// The tool's built-in parameters provided by LaunchDarkly.
124+
/// </summary>
125+
public readonly IReadOnlyDictionary<string, LdValue> Parameters;
126+
127+
/// <summary>
128+
/// The tool's custom parameters provided by the user.
129+
/// </summary>
130+
public readonly IReadOnlyDictionary<string, LdValue> CustomParameters;
131+
132+
internal Tool(
133+
string name,
134+
string description,
135+
string type,
136+
IReadOnlyDictionary<string, LdValue> parameters,
137+
IReadOnlyDictionary<string, LdValue> customParameters)
138+
{
139+
Name = name;
140+
Description = description;
141+
Type = type;
142+
Parameters = parameters?.ToImmutableDictionary() ?? ImmutableDictionary<string, LdValue>.Empty;
143+
CustomParameters = customParameters?.ToImmutableDictionary() ?? ImmutableDictionary<string, LdValue>.Empty;
144+
}
145+
}
146+
147+
/// <summary>
148+
/// Configuration for the judges associated with an AI Config.
149+
/// </summary>
150+
public sealed class JudgeConfiguration
151+
{
152+
/// <summary>
153+
/// Represents a single judge entry within a <see cref="JudgeConfiguration"/>.
154+
/// </summary>
155+
public sealed class Judge
156+
{
157+
/// <summary>
158+
/// The key identifying this judge.
159+
/// </summary>
160+
public string Key { get; }
161+
162+
/// <summary>
163+
/// The fraction of requests that this judge evaluates, in the range [0, 1].
164+
/// </summary>
165+
public double SamplingRate { get; }
166+
167+
internal Judge(string key, double samplingRate)
168+
{
169+
Key = key;
170+
SamplingRate = samplingRate;
171+
}
172+
}
173+
174+
/// <summary>
175+
/// The list of judges.
176+
/// </summary>
177+
public IReadOnlyList<Judge> Judges { get; }
178+
179+
internal JudgeConfiguration(IReadOnlyList<Judge> judges)
180+
{
181+
Judges = judges;
182+
}
183+
}
101184
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
using System.Collections.Generic;
2+
using LaunchDarkly.Sdk.Server.Ai.Config;
3+
using Xunit;
4+
5+
namespace LaunchDarkly.Sdk.Server.Ai;
6+
7+
public class ConfigFactoryParserTest
8+
{
9+
[Fact]
10+
public void ParseTools_ReturnsTwoEntries_WithCorrectCustomParameters()
11+
{
12+
var toolsValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
13+
{
14+
["search"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
15+
{
16+
["name"] = LdValue.Of("search"),
17+
["description"] = LdValue.Of("Searches the web"),
18+
["type"] = LdValue.Of("function"),
19+
["parameters"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
20+
{
21+
["query"] = LdValue.Of("string")
22+
}),
23+
["customParameters"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
24+
{
25+
["timeout"] = LdValue.Of(30)
26+
})
27+
}),
28+
["calculator"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
29+
{
30+
["name"] = LdValue.Of("calculator"),
31+
["description"] = LdValue.Of("Performs arithmetic"),
32+
["type"] = LdValue.Of("function"),
33+
["parameters"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>()),
34+
["customParameters"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
35+
{
36+
["precision"] = LdValue.Of(10)
37+
})
38+
})
39+
});
40+
41+
// Build a root-level payload that also contains model.parameters.tools to confirm
42+
// they remain separate and are not mixed with the root tools map.
43+
var rootValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
44+
{
45+
["tools"] = toolsValue,
46+
["model"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
47+
{
48+
["parameters"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
49+
{
50+
["tools"] = LdValue.ArrayOf(LdValue.Of("some-tool"))
51+
})
52+
})
53+
});
54+
55+
var tools = ConfigFactory.ParseTools(rootValue.Get("tools"));
56+
57+
Assert.Equal(2, tools.Count);
58+
Assert.True(tools.ContainsKey("search"));
59+
Assert.True(tools.ContainsKey("calculator"));
60+
61+
Assert.Equal(30, tools["search"].CustomParameters["timeout"].AsInt);
62+
Assert.Equal(10, tools["calculator"].CustomParameters["precision"].AsInt);
63+
Assert.Equal("string", tools["search"].Parameters["query"].AsString);
64+
65+
// model.parameters.tools (opaque array) must be unaffected by root tools parsing.
66+
var modelTools = rootValue.Get("model").Get("parameters").Get("tools");
67+
Assert.Equal(LdValueType.Array, modelTools.Type);
68+
Assert.Equal(1, modelTools.Count);
69+
}
70+
71+
[Fact]
72+
public void ParseJudgeConfiguration_ReturnsTwoEntries_WithCorrectKeysAndSamplingRates()
73+
{
74+
var rootValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
75+
{
76+
["judgeConfiguration"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
77+
{
78+
["judges"] = LdValue.ArrayOf(
79+
LdValue.ObjectFrom(new Dictionary<string, LdValue>
80+
{
81+
["key"] = LdValue.Of("judge-relevance"),
82+
["samplingRate"] = LdValue.Of(0.5)
83+
}),
84+
LdValue.ObjectFrom(new Dictionary<string, LdValue>
85+
{
86+
["key"] = LdValue.Of("judge-coherence"),
87+
["samplingRate"] = LdValue.Of(1.0)
88+
})
89+
)
90+
})
91+
});
92+
93+
var judgeConfig = ConfigFactory.ParseJudgeConfiguration(rootValue.Get("judgeConfiguration"));
94+
95+
Assert.NotNull(judgeConfig);
96+
Assert.Equal(2, judgeConfig.Judges.Count);
97+
Assert.Equal("judge-relevance", judgeConfig.Judges[0].Key);
98+
Assert.Equal(0.5, judgeConfig.Judges[0].SamplingRate);
99+
Assert.Equal("judge-coherence", judgeConfig.Judges[1].Key);
100+
Assert.Equal(1.0, judgeConfig.Judges[1].SamplingRate);
101+
}
102+
103+
[Fact]
104+
public void ParseEvaluationMetricKey_ReturnsParsedString()
105+
{
106+
var rootValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
107+
{
108+
["evaluationMetricKey"] = LdValue.Of("$ld:ai:judge:relevance")
109+
});
110+
111+
var key = ConfigFactory.ParseEvaluationMetricKey(rootValue);
112+
113+
Assert.Equal("$ld:ai:judge:relevance", key);
114+
}
115+
116+
[Fact]
117+
public void ParseInstructions_ReturnsRawUninterpolatedString()
118+
{
119+
var rootValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
120+
{
121+
["instructions"] = LdValue.Of("You are a helpful assistant specializing in {{topic}}")
122+
});
123+
124+
var instructions = ConfigFactory.ParseInstructions(rootValue.Get("instructions"));
125+
126+
Assert.Equal("You are a helpful assistant specializing in {{topic}}", instructions);
127+
}
128+
129+
[Fact]
130+
public void ParseTools_SkipsNonObjectValues()
131+
{
132+
var toolsValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
133+
{
134+
["good"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
135+
{
136+
["name"] = LdValue.Of("good"),
137+
["description"] = LdValue.Of("A valid tool"),
138+
["type"] = LdValue.Of("function"),
139+
["parameters"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>()),
140+
["customParameters"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>())
141+
}),
142+
["bad-string"] = LdValue.Of("not-an-object"),
143+
["bad-number"] = LdValue.Of(42),
144+
["bad-array"] = LdValue.ArrayOf(LdValue.Of("x"))
145+
});
146+
147+
var tools = ConfigFactory.ParseTools(toolsValue);
148+
149+
Assert.Single(tools);
150+
Assert.True(tools.ContainsKey("good"));
151+
}
152+
153+
[Fact]
154+
public void ParseJudgeConfiguration_SkipsNonObjectElements()
155+
{
156+
var rootValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
157+
{
158+
["judgeConfiguration"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
159+
{
160+
["judges"] = LdValue.ArrayOf(
161+
LdValue.ObjectFrom(new Dictionary<string, LdValue>
162+
{
163+
["key"] = LdValue.Of("judge-relevance"),
164+
["samplingRate"] = LdValue.Of(0.5)
165+
}),
166+
LdValue.Of("not-an-object"),
167+
LdValue.Of(99),
168+
LdValue.ArrayOf(LdValue.Of("nested"))
169+
)
170+
})
171+
});
172+
173+
var judgeConfig = ConfigFactory.ParseJudgeConfiguration(rootValue.Get("judgeConfiguration"));
174+
175+
Assert.NotNull(judgeConfig);
176+
Assert.Single(judgeConfig.Judges);
177+
Assert.Equal("judge-relevance", judgeConfig.Judges[0].Key);
178+
Assert.Equal(0.5, judgeConfig.Judges[0].SamplingRate);
179+
}
180+
181+
[Fact]
182+
public void ParseAllFields_WithNoNewFields_ReturnsNullOrEmptyWithoutError()
183+
{
184+
var rootValue = LdValue.ObjectFrom(new Dictionary<string, LdValue>
185+
{
186+
["_ldMeta"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
187+
{
188+
["enabled"] = LdValue.Of(true),
189+
["variationKey"] = LdValue.Of("v1")
190+
}),
191+
["model"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
192+
{
193+
["name"] = LdValue.Of("some-model")
194+
})
195+
});
196+
197+
var instructions = ConfigFactory.ParseInstructions(rootValue.Get("instructions"));
198+
var tools = ConfigFactory.ParseTools(rootValue.Get("tools"));
199+
var judgeConfig = ConfigFactory.ParseJudgeConfiguration(rootValue.Get("judgeConfiguration"));
200+
var evaluationMetricKey = ConfigFactory.ParseEvaluationMetricKey(rootValue);
201+
202+
Assert.Null(instructions);
203+
Assert.Empty(tools);
204+
Assert.Null(judgeConfig);
205+
Assert.Null(evaluationMetricKey);
206+
}
207+
}

0 commit comments

Comments
 (0)