Skip to content

Commit ad2ac08

Browse files
ctawiahcursoragent
andauthored
feat: AICONF config types & LDAIClient methods (AIC-2663) (#173)
**Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** [AIC-2663](https://launchdarkly.atlassian.net/browse/AIC-2663) — Step 3: AICONF config types & `LDAIClient` methods. Part of epic [AIC-2629](https://launchdarkly.atlassian.net/browse/AIC-2629). > **Stacked on #172** (`feat/AIC-2695/ai-sdk-vendor-mustache`), which is stacked on #171. It builds on the data model + parser (#171) and the vendored-Mustache `Interpolator` (#172). Base retargets up the stack as each merges. **Describe the solution you've provided** Implements the public AI Config types and the `LDAIClient` retrieval methods that evaluate a flag, validate its mode, interpolate prompt templates, and return a typed config. The behavior mirrors the JS reference the spec points to. - **Config types** — `.NET`-style two-hierarchy design (`AIConfig` / `AIConfigDefault` bases): `AICompletionConfig`(+messages), `AIAgentConfig`(+instructions), `AIJudgeConfig`(+evaluationMetricKey) result types plus parallel `*Default` builder types, and `AIAgentConfigRequest` for batch agent retrieval. A generic base builder keeps the `*Default` builders DRY. - **`LDAIClient` methods** — `completionConfig`, `agentConfig`, `agentConfigs`, `judgeConfig`: - serialize the caller default to a flag value (tagged with the requested `mode`) and pass it through `jsonValueVariation`, so an absent flag yields the default and the eval event records the correct default; - **mode validation**: a mismatch logs a single warning and returns a disabled config of the requested type — never a config that would NPE the caller; - interpolate messages/instructions via the vendored-Mustache `Interpolator`, exposing the context as `{{ldctx}}`; - fire the spec'd usage events (`$ld:ai:usage:completion-config`, `:agent-config`, `:agent-configs`, `:judge-config`) and emit `$ld:ai:sdk:info` once at construction, **guarded** so an uninitialized client can't throw from the constructor. - `evaluationMetricKey` resolves to the first non-blank entry (handled by the #171 parser). **Key decisions** - **Synchronous API (no `CompletableFuture`).** Matches the core Java server SDK's blocking style and sidesteps Android API-level/threading concerns. `variation` is in-memory after init, so `agentConfigs` fan-out parallelism buys ~nothing while adding real complexity. Resolves the ticket's async-surface question. - **No per-field merge** of missing model/provider/instructions from the default (matches JS). - **Tracking deferred to Step 4 (AIC-2664).** `LDAIConfigTracker` is a placeholder interface with an internal no-op; configs expose `createTracker()` so Step 4 fills in behavior without reshaping the public config types. - **Construction**: `new LDAIClientImpl(ldClient)` (interface `LDAIClient` is provided for mocking/DI). A second constructor accepts an `LDLogger`. **Thread-safety** — `LDAIClientImpl` holds only the thread-safe base client, a logger, and one shared `Interpolator` (thread-safe template cache); every returned config is immutable. **Tests** — `LDAIClientImplTest` (17 cases) covers usage events, SDK-info emission + constructor guard, typed retrieval, interpolation/`ldctx`, mode-mismatch (disabled + single warning, no NPE), default semantics (absent → default; no per-field merge), and `agentConfigs` ordering/count. `LDValueConverterTest` extended for the new inverse conversion. `./gradlew clean build` green (checkstyle + Javadoc + all tests). **Describe alternatives you've considered** - **Single unified config type** (default == result) — rejected; the ticket and sibling SDKs intentionally separate the caller-supplied default from the retrieved result (which carries the key + tracker + interpolated content). - **Async `CompletableFuture` surface** — rejected for a server SDK (see decision above). **Additional context** `AISdkInfo.VERSION` is currently a constant kept in step with `gradle.properties`; wiring it to a generated/build-time version can be a follow-up. Made with [Cursor](https://cursor.com) [AIC-2663]: https://launchdarkly.atlassian.net/browse/AIC-2663?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [AIC-2629]: https://launchdarkly.atlassian.net/browse/AIC-2629?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New public SDK API on the flag-evaluation path; incorrect defaults, mode handling, or interpolation would affect how apps load AI configs in production, though scope is additive with guarded telemetry. > > **Overview** > Adds the **public AI Config retrieval surface** for the Java server AI SDK: immutable `AICompletionConfig`, `AIAgentConfig`, and `AIJudgeConfig` types (plus matching `*Default` builders and `AIAgentConfigRequest` for batch agents), wired through **`LDAIClient` / `LDAIClientImpl`**. > > `LDAIClientImpl` evaluates flags via `jsonValueVariation` with a null sentinel, parses variations, **validates mode** (mismatch → disabled config + warning), and **interpolates** messages/instructions (including `{{ldctx}}`). Absent flags return the caller default with interpolation and **no per-field merge** from defaults when a variation is present. It emits **`$ld:ai:sdk:info`** at construction (failure-safe) and **`$ld:ai:usage:*`** metrics per method; **`LDAIConfigTracker`** is a placeholder with a no-op implementation until a later step. > > The README now documents constructing `LDAIClientImpl` from `LDClient` and calling `completionConfig`. **`LDAIClientImplTest`** covers usage events, interpolation, mode mismatch, default semantics, and ordered `agentConfigs`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ac6b827. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 32732ea commit ad2ac08

16 files changed

Lines changed: 1666 additions & 2 deletions

lib/sdk/server-ai/README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,33 @@ This library has a minimum Java version of 8.
1818
This module is part of the [`java-core`](https://github.com/launchdarkly/java-core) monorepo and is
1919
published to Maven Central as `com.launchdarkly:launchdarkly-java-server-sdk-ai`.
2020

21-
Full usage documentation, including AI Config retrieval, tracking, and manual judge evaluation, will be
22-
added as the SDK is built out (see epic AIC-2629).
21+
Construct an `LDAIClient` from an initialized server-side `LDClient`, then retrieve a typed config:
22+
23+
```java
24+
LDClient ldClient = new LDClient(sdkKey);
25+
LDAIClient aiClient = new LDAIClientImpl(ldClient);
26+
27+
Map<String, Object> variables = new HashMap<>();
28+
variables.put("username", "Sandy");
29+
30+
AICompletionConfig config = aiClient.completionConfig(
31+
"my-ai-config-key",
32+
context,
33+
AICompletionConfigDefault.disabled(), // fallback when the flag is absent
34+
variables);
35+
36+
if (config.isEnabled()) {
37+
// config.getModel(), config.getProvider(), and config.getMessages() (already interpolated)
38+
// are ready to pass to your model provider.
39+
}
40+
```
41+
42+
The companion `agentConfig`/`agentConfigs` and `judgeConfig` methods retrieve agent and judge
43+
configs respectively. Within a prompt message or agent instruction, the evaluation context is
44+
available as `{{ldctx}}` (for example `{{ldctx.key}}`).
45+
46+
Metric tracking and manual judge evaluation will be added as the SDK is built out (see epic
47+
AIC-2629).
2348

2449
## Internal API convention
2550

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.launchdarkly.sdk.server.ai;
2+
3+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode;
4+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration;
5+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model;
6+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider;
7+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool;
8+
9+
import java.util.Collections;
10+
import java.util.Map;
11+
import java.util.function.Supplier;
12+
13+
/**
14+
* A retrieved agent AI Config. This is the result of {@link LDAIClient#agentConfig} (and each entry
15+
* returned by {@link LDAIClient#agentConfigs}).
16+
* <p>
17+
* The {@link #getInstructions() instructions} have already had their template interpolated with the
18+
* supplied variables and evaluation context. Instances are immutable.
19+
*/
20+
public final class AIAgentConfig extends AIConfig {
21+
private final String instructions;
22+
private final JudgeConfiguration judgeConfiguration;
23+
private final Map<String, Tool> tools;
24+
25+
AIAgentConfig(
26+
String key,
27+
boolean enabled,
28+
Model model,
29+
Provider provider,
30+
String instructions,
31+
JudgeConfiguration judgeConfiguration,
32+
Map<String, Tool> tools,
33+
Supplier<LDAIConfigTracker> trackerFactory) {
34+
super(key, enabled, Mode.AGENT, model, provider, trackerFactory);
35+
this.instructions = instructions;
36+
this.judgeConfiguration = judgeConfiguration;
37+
this.tools = tools == null ? null : Collections.unmodifiableMap(tools);
38+
}
39+
40+
/**
41+
* Returns the interpolated agent instructions.
42+
*
43+
* @return the instructions, or {@code null} if none were specified
44+
*/
45+
public String getInstructions() {
46+
return instructions;
47+
}
48+
49+
/**
50+
* Returns the judge configuration referencing judges that may evaluate this config.
51+
*
52+
* @return the judge configuration, or {@code null} if none was specified
53+
*/
54+
public JudgeConfiguration getJudgeConfiguration() {
55+
return judgeConfiguration;
56+
}
57+
58+
/**
59+
* Returns the root-level tools map keyed by tool name.
60+
*
61+
* @return an unmodifiable map of tools, or {@code null} if none were specified
62+
*/
63+
public Map<String, Tool> getTools() {
64+
return tools;
65+
}
66+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package com.launchdarkly.sdk.server.ai;
2+
3+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration;
4+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool;
5+
6+
import java.util.Collections;
7+
import java.util.LinkedHashMap;
8+
import java.util.Map;
9+
10+
/**
11+
* A caller-supplied default for {@link LDAIClient#agentConfig} (and {@link LDAIClient#agentConfigs}),
12+
* returned (as an {@link AIAgentConfig}) when the flag is absent or cannot be evaluated.
13+
* <p>
14+
* Build instances with {@link #builder()}. Instances are immutable.
15+
*/
16+
public final class AIAgentConfigDefault extends AIConfigDefault {
17+
private final String instructions;
18+
private final JudgeConfiguration judgeConfiguration;
19+
private final Map<String, Tool> tools;
20+
21+
private AIAgentConfigDefault(Builder builder) {
22+
super(builder);
23+
this.instructions = builder.instructions;
24+
this.judgeConfiguration = builder.judgeConfiguration;
25+
this.tools = builder.tools == null
26+
? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.tools));
27+
}
28+
29+
/**
30+
* Returns the default agent instructions.
31+
*
32+
* @return the instructions, or {@code null} if none were specified
33+
*/
34+
public String getInstructions() {
35+
return instructions;
36+
}
37+
38+
/**
39+
* Returns the default judge configuration.
40+
*
41+
* @return the judge configuration, or {@code null} if none was specified
42+
*/
43+
public JudgeConfiguration getJudgeConfiguration() {
44+
return judgeConfiguration;
45+
}
46+
47+
/**
48+
* Returns the default root-level tools map.
49+
*
50+
* @return an unmodifiable map of tools, or {@code null} if none were specified
51+
*/
52+
public Map<String, Tool> getTools() {
53+
return tools;
54+
}
55+
56+
/**
57+
* Creates a new builder.
58+
*
59+
* @return a new {@link Builder}
60+
*/
61+
public static Builder builder() {
62+
return new Builder();
63+
}
64+
65+
/**
66+
* Returns a disabled default, suitable as a fallback that causes callers to skip the model.
67+
*
68+
* @return a disabled {@link AIAgentConfigDefault}
69+
*/
70+
public static AIAgentConfigDefault disabled() {
71+
return builder().enabled(false).build();
72+
}
73+
74+
/**
75+
* Builder for {@link AIAgentConfigDefault}.
76+
*/
77+
public static final class Builder extends AbstractBuilder<Builder> {
78+
private String instructions;
79+
private JudgeConfiguration judgeConfiguration;
80+
private Map<String, Tool> tools;
81+
82+
private Builder() {
83+
}
84+
85+
@Override
86+
protected Builder self() {
87+
return this;
88+
}
89+
90+
/**
91+
* Sets the default agent instructions.
92+
*
93+
* @param instructions the instructions; may be {@code null}
94+
* @return this builder
95+
*/
96+
public Builder instructions(String instructions) {
97+
this.instructions = instructions;
98+
return this;
99+
}
100+
101+
/**
102+
* Sets the default judge configuration.
103+
*
104+
* @param judgeConfiguration the judge configuration; may be {@code null}
105+
* @return this builder
106+
*/
107+
public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) {
108+
this.judgeConfiguration = judgeConfiguration;
109+
return this;
110+
}
111+
112+
/**
113+
* Sets the default root-level tools map. The map is copied defensively.
114+
*
115+
* @param tools the tools; may be {@code null}
116+
* @return this builder
117+
*/
118+
public Builder tools(Map<String, Tool> tools) {
119+
this.tools = tools;
120+
return this;
121+
}
122+
123+
/**
124+
* Builds the immutable {@link AIAgentConfigDefault}.
125+
*
126+
* @return a new {@link AIAgentConfigDefault}
127+
*/
128+
public AIAgentConfigDefault build() {
129+
return new AIAgentConfigDefault(this);
130+
}
131+
}
132+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.launchdarkly.sdk.server.ai;
2+
3+
import java.util.Collections;
4+
import java.util.HashMap;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
8+
/**
9+
* A single agent request passed to {@link LDAIClient#agentConfigs}, pairing an agent key with its
10+
* own default and interpolation variables.
11+
* <p>
12+
* Build instances with {@link #builder(String)}. Instances are immutable.
13+
*/
14+
public final class AIAgentConfigRequest {
15+
private final String key;
16+
private final AIAgentConfigDefault defaultValue;
17+
private final Map<String, Object> variables;
18+
19+
private AIAgentConfigRequest(Builder builder) {
20+
this.key = builder.key;
21+
this.defaultValue = builder.defaultValue;
22+
this.variables = builder.variables == null
23+
? null : Collections.unmodifiableMap(new HashMap<>(builder.variables));
24+
}
25+
26+
/**
27+
* Returns the agent key to retrieve.
28+
*
29+
* @return the agent key, never {@code null}
30+
*/
31+
public String getKey() {
32+
return key;
33+
}
34+
35+
/**
36+
* Returns the default for this agent.
37+
*
38+
* @return the default, or {@code null} if a disabled default should be used
39+
*/
40+
public AIAgentConfigDefault getDefaultValue() {
41+
return defaultValue;
42+
}
43+
44+
/**
45+
* Returns the interpolation variables for this agent's instructions.
46+
*
47+
* @return an unmodifiable map of variables, or {@code null} if none were specified
48+
*/
49+
public Map<String, Object> getVariables() {
50+
return variables;
51+
}
52+
53+
/**
54+
* Creates a new builder for a request with the given agent key.
55+
*
56+
* @param key the agent key; must not be {@code null}
57+
* @return a new {@link Builder}
58+
* @throws NullPointerException if {@code key} is {@code null}
59+
*/
60+
public static Builder builder(String key) {
61+
return new Builder(Objects.requireNonNull(key, "key"));
62+
}
63+
64+
/**
65+
* Builder for {@link AIAgentConfigRequest}.
66+
*/
67+
public static final class Builder {
68+
private final String key;
69+
private AIAgentConfigDefault defaultValue;
70+
private Map<String, Object> variables;
71+
72+
private Builder(String key) {
73+
this.key = key;
74+
}
75+
76+
/**
77+
* Sets the default for this agent.
78+
*
79+
* @param defaultValue the default; may be {@code null}
80+
* @return this builder
81+
*/
82+
public Builder defaultValue(AIAgentConfigDefault defaultValue) {
83+
this.defaultValue = defaultValue;
84+
return this;
85+
}
86+
87+
/**
88+
* Sets the interpolation variables for this agent's instructions. The map is copied defensively.
89+
*
90+
* @param variables the variables; may be {@code null}
91+
* @return this builder
92+
*/
93+
public Builder variables(Map<String, Object> variables) {
94+
this.variables = variables;
95+
return this;
96+
}
97+
98+
/**
99+
* Builds the immutable {@link AIAgentConfigRequest}.
100+
*
101+
* @return a new {@link AIAgentConfigRequest}
102+
*/
103+
public AIAgentConfigRequest build() {
104+
return new AIAgentConfigRequest(this);
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)