Skip to content

Commit 2fecd54

Browse files
committed
feat: implements LD API client, optimize_from_config path
1 parent ea43575 commit 2fecd54

6 files changed

Lines changed: 1085 additions & 15 deletions

File tree

packages/optimization/src/ldai_optimization/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ldai_optimization.dataclasses import (
88
AIJudgeCallConfig,
99
OptimizationContext,
10+
OptimizationFromConfigOptions,
1011
OptimizationJudge,
1112
OptimizationJudgeContext,
1213
OptimizationOptions,
@@ -20,6 +21,7 @@
2021
'AIJudgeCallConfig',
2122
'OptimizationClient',
2223
'OptimizationContext',
24+
'OptimizationFromConfigOptions',
2325
'OptimizationJudge',
2426
'OptimizationJudgeContext',
2527
'OptimizationOptions',

packages/optimization/src/ldai_optimization/client.py

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import os
77
import random
8+
import uuid
89
from typing import Any, Dict, List, Literal, Optional
910

1011
from ldai import AIAgentConfig, AIJudgeConfig, AIJudgeConfigDefault, LDAIClient
@@ -16,11 +17,17 @@
1617
AutoCommitConfig,
1718
JudgeResult,
1819
OptimizationContext,
20+
OptimizationFromConfigOptions,
1921
OptimizationJudge,
2022
OptimizationJudgeContext,
2123
OptimizationOptions,
2224
ToolDefinition,
2325
)
26+
from ldai_optimization.ld_api_client import (
27+
AgentOptimizationConfig,
28+
LDApiClient,
29+
OptimizationResultPayload,
30+
)
2431
from ldai_optimization.prompts import (
2532
build_message_history_text,
2633
build_new_variation_prompt,
@@ -38,6 +45,19 @@
3845

3946
logger = logging.getLogger(__name__)
4047

48+
# Maps SDK status strings to the API status/activity values expected by
49+
# agent_optimization_result records. Defined at module level to avoid
50+
# allocating the dict on every on_status_update invocation.
51+
_OPTIMIZATION_STATUS_MAP: Dict[str, Dict[str, str]] = {
52+
"init": {"status": "RUNNING", "activity": "PENDING"},
53+
"generating": {"status": "RUNNING", "activity": "GENERATING"},
54+
"evaluating": {"status": "RUNNING", "activity": "EVALUATING"},
55+
"generating variation": {"status": "RUNNING", "activity": "GENERATING_VARIATION"},
56+
"turn completed": {"status": "RUNNING", "activity": "COMPLETED"},
57+
"success": {"status": "PASSED", "activity": "COMPLETED"},
58+
"failure": {"status": "FAILED", "activity": "COMPLETED"},
59+
}
60+
4161

4262
class OptimizationClient:
4363
_options: OptimizationOptions
@@ -883,21 +903,149 @@ async def _generate_new_variation(
883903
)
884904

885905
async def optimize_from_config(
886-
self, agent_key: str, optimization_config_key: str
906+
self, optimization_config_key: str, options: OptimizationFromConfigOptions
887907
) -> Any:
888-
"""Optimize an agent from a configuration.
908+
"""Optimize an agent using a configuration fetched from the LaunchDarkly API.
889909
890-
:param agent_key: Identifier of the agent to optimize.
891-
:param optimization_config_key: Identifier of the optimization configuration to use.
892-
:return: Optimization result.
910+
The agent key, judge configuration, model choices, and other optimization
911+
parameters are all sourced from the remote agent optimization config. The
912+
caller only needs to provide the execution callbacks and evaluation contexts.
913+
914+
Iteration results are automatically persisted to the LaunchDarkly API so
915+
the UI can display live run progress.
916+
917+
:param optimization_config_key: Key of the agent optimization config to fetch.
918+
:param options: User-provided callbacks and evaluation contexts.
919+
:return: Optimization result (OptimizationContext from the final iteration).
893920
"""
894921
if not self._has_api_key:
895922
raise ValueError(
896923
"LAUNCHDARKLY_API_KEY is not set, so optimize_from_config is not available"
897924
)
898925

899-
self._agent_key = agent_key
900-
raise NotImplementedError
926+
assert self._api_key is not None
927+
api_client = LDApiClient(
928+
self._api_key,
929+
**({"base_url": options.base_url} if options.base_url else {}),
930+
)
931+
config = api_client.get_agent_optimization(options.project_key, optimization_config_key)
932+
933+
self._agent_key = config["aiConfigKey"]
934+
optimization_id: str = config["id"]
935+
run_id = str(uuid.uuid4())
936+
937+
context = random.choice(options.context_choices)
938+
# _get_agent_config calls _initialize_class_members_from_config internally;
939+
# _run_optimization calls it again to reset history before the loop starts.
940+
agent_config = await self._get_agent_config(self._agent_key, context)
941+
942+
optimization_options = self._build_options_from_config(
943+
config, options, api_client, optimization_id, run_id
944+
)
945+
return await self._run_optimization(agent_config, optimization_options)
946+
947+
def _build_options_from_config(
948+
self,
949+
config: AgentOptimizationConfig,
950+
options: OptimizationFromConfigOptions,
951+
api_client: LDApiClient,
952+
optimization_id: str,
953+
run_id: str,
954+
) -> OptimizationOptions:
955+
"""Map a fetched AgentOptimization config + user options into OptimizationOptions.
956+
957+
Acceptance statements and judge configs from the API are merged into a single
958+
judges dict. An on_status_update closure is injected to persist each iteration
959+
result to the LaunchDarkly API; any user-supplied on_status_update is chained
960+
after the persistence call.
961+
962+
:param config: Validated AgentOptimizationConfig from the API.
963+
:param options: User-provided options from optimize_from_config.
964+
:param api_client: Initialised LDApiClient for result persistence.
965+
:param optimization_id: UUID id of the parent agent_optimization record.
966+
:param run_id: UUID that groups all result records for this run.
967+
:return: A fully populated OptimizationOptions ready for _run_optimization.
968+
"""
969+
judges: Dict[str, OptimizationJudge] = {}
970+
971+
for i, stmt in enumerate(config["acceptanceStatements"]):
972+
key = f"acceptance-statement-{i}"
973+
judges[key] = OptimizationJudge(
974+
threshold=float(stmt.get("threshold", 0.95)),
975+
acceptance_statement=stmt["statement"],
976+
)
977+
978+
for judge in config["judges"]:
979+
judges[judge["key"]] = OptimizationJudge(
980+
threshold=float(judge.get("threshold", 0.95)),
981+
judge_key=judge["key"],
982+
)
983+
984+
if not judges and options.on_turn is None:
985+
raise ValueError(
986+
"The optimization config has no acceptance statements or judges, "
987+
"and no on_turn callback was provided. At least one is required."
988+
)
989+
990+
variable_choices: List[Dict[str, Any]] = config["variableChoices"] or [{}]
991+
user_input_options: Optional[List[str]] = config["userInputOptions"] or None
992+
993+
project_key = options.project_key
994+
config_version: int = config["version"]
995+
996+
def _persist_and_forward(
997+
status: Literal[
998+
"init",
999+
"generating",
1000+
"evaluating",
1001+
"generating variation",
1002+
"turn completed",
1003+
"success",
1004+
"failure",
1005+
],
1006+
ctx: OptimizationContext,
1007+
) -> None:
1008+
# _safe_status_update (the caller) already wraps this entire function in
1009+
# a try/except, so errors here are caught and logged without aborting the run.
1010+
mapped = _OPTIMIZATION_STATUS_MAP.get(
1011+
status, {"status": "RUNNING", "activity": "PENDING"}
1012+
)
1013+
snapshot = ctx.copy_without_history()
1014+
payload: OptimizationResultPayload = {
1015+
"run_id": run_id,
1016+
"config_optimization_version": config_version,
1017+
"status": mapped["status"],
1018+
"activity": mapped["activity"],
1019+
"iteration": snapshot.iteration,
1020+
"instructions": snapshot.current_instructions,
1021+
"parameters": snapshot.current_parameters,
1022+
"completion_response": snapshot.completion_response,
1023+
"scores": {k: v.to_json() for k, v in snapshot.scores.items()},
1024+
"user_input": snapshot.user_input,
1025+
}
1026+
api_client.post_agent_optimization_result(project_key, optimization_id, payload)
1027+
1028+
if options.on_status_update:
1029+
try:
1030+
options.on_status_update(status, ctx)
1031+
except Exception:
1032+
logger.exception("User on_status_update callback failed for status=%s", status)
1033+
1034+
return OptimizationOptions(
1035+
context_choices=options.context_choices,
1036+
max_attempts=config["maxAttempts"],
1037+
model_choices=config["modelChoices"],
1038+
judge_model=config["judgeModel"],
1039+
variable_choices=variable_choices,
1040+
handle_agent_call=options.handle_agent_call,
1041+
handle_judge_call=options.handle_judge_call,
1042+
judges=judges or None,
1043+
user_input_options=user_input_options,
1044+
on_turn=options.on_turn,
1045+
on_passing_result=options.on_passing_result,
1046+
on_failing_result=options.on_failing_result,
1047+
on_status_update=_persist_and_forward,
1048+
)
9011049

9021050
async def _execute_agent_turn(
9031051
self,

packages/optimization/src/ldai_optimization/dataclasses.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,20 @@ class OptimizationJudgeContext:
204204
variables: Dict[str, Any] = field(default_factory=dict) # variable set used during agent generation
205205

206206

207+
# Shared callback type aliases used by both OptimizationOptions and
208+
# OptimizationFromConfigOptions to avoid duplicating the full signatures.
209+
# Placed here so all referenced types (OptimizationContext, AIJudgeCallConfig,
210+
# OptimizationJudgeContext) are already defined above.
211+
HandleAgentCall = Union[
212+
Callable[[str, AIAgentConfig, OptimizationContext, Dict[str, Callable[..., Any]]], str],
213+
Callable[[str, AIAgentConfig, OptimizationContext, Dict[str, Callable[..., Any]]], Awaitable[str]],
214+
]
215+
HandleJudgeCall = Union[
216+
Callable[[str, AIJudgeCallConfig, OptimizationJudgeContext, Dict[str, Callable[..., Any]]], str],
217+
Callable[[str, AIJudgeCallConfig, OptimizationJudgeContext, Dict[str, Callable[..., Any]]], Awaitable[str]],
218+
]
219+
220+
207221
@dataclass
208222
class OptimizationOptions:
209223
"""Options for agent optimization."""
@@ -218,14 +232,8 @@ class OptimizationOptions:
218232
Dict[str, Any]
219233
] # choices of interpolated variables to be chosen at random per turn, 1 min required
220234
# Actual agent/completion (judge) calls - Required
221-
handle_agent_call: Union[
222-
Callable[[str, AIAgentConfig, OptimizationContext, Dict[str, Callable[..., Any]]], str],
223-
Callable[[str, AIAgentConfig, OptimizationContext, Dict[str, Callable[..., Any]]], Awaitable[str]],
224-
]
225-
handle_judge_call: Union[
226-
Callable[[str, AIJudgeCallConfig, OptimizationJudgeContext, Dict[str, Callable[..., Any]]], str],
227-
Callable[[str, AIJudgeCallConfig, OptimizationJudgeContext, Dict[str, Callable[..., Any]]], Awaitable[str]],
228-
]
235+
handle_agent_call: HandleAgentCall
236+
handle_judge_call: HandleJudgeCall
229237
# Criteria for pass/fail - Optional
230238
user_input_options: Optional[List[str]] = (
231239
None # optional list of user input messages to randomly select from
@@ -270,3 +278,56 @@ def __post_init__(self):
270278
raise ValueError("Either judges or on_turn must be provided")
271279
if self.judge_model is None:
272280
raise ValueError("judge_model must be provided")
281+
282+
283+
@dataclass
284+
class OptimizationFromConfigOptions:
285+
"""User-provided options for optimize_from_config.
286+
287+
Fields that come from the LaunchDarkly API (max_attempts, model_choices,
288+
judge_model, variable_choices, user_input_options, judges) are omitted here
289+
and sourced from the fetched agent optimization config instead.
290+
291+
:param project_key: LaunchDarkly project key used to build API paths.
292+
:param context_choices: One or more LD evaluation contexts to use.
293+
:param handle_agent_call: Callback that invokes the agent and returns its response.
294+
:param handle_judge_call: Callback that invokes a judge and returns its response.
295+
:param on_turn: Optional manual pass/fail callback; when provided, judge scoring is skipped.
296+
:param on_passing_result: Called with the winning OptimizationContext on success.
297+
:param on_failing_result: Called with the final OptimizationContext on failure.
298+
:param on_status_update: Called on each status transition; chained after the
299+
automatic result-persistence POST so it always runs after the record is saved.
300+
:param base_url: Base URL of the LaunchDarkly instance. Defaults to
301+
https://app.launchdarkly.com. Override to target a staging instance.
302+
"""
303+
304+
project_key: str
305+
context_choices: List[Context]
306+
handle_agent_call: HandleAgentCall
307+
handle_judge_call: HandleJudgeCall
308+
on_turn: Optional[Callable[["OptimizationContext"], bool]] = None
309+
on_passing_result: Optional[Callable[["OptimizationContext"], None]] = None
310+
on_failing_result: Optional[Callable[["OptimizationContext"], None]] = None
311+
on_status_update: Optional[
312+
Callable[
313+
[
314+
Literal[
315+
"init",
316+
"generating",
317+
"evaluating",
318+
"generating variation",
319+
"turn completed",
320+
"success",
321+
"failure",
322+
],
323+
"OptimizationContext",
324+
],
325+
None,
326+
]
327+
] = None
328+
base_url: Optional[str] = None
329+
330+
def __post_init__(self):
331+
"""Validate required options."""
332+
if len(self.context_choices) < 1:
333+
raise ValueError("context_choices must have at least 1 context")

0 commit comments

Comments
 (0)