Skip to content

Commit 1b41ea4

Browse files
feat(AIC-3534): add a TypeSafe Jev judge handler (#149)
## Summary - Add `launchdarkly-ai-typesafe`, a judge handler that sends a variation's `classifiers` to Jev in one call. - Each label becomes its own `judge_results` entry, tracked on that label's `eventKey`, with the selected label as the reason. - A score is the expected rubric index divided by one less than the number of levels, then clamped to 0–1. https://launchdarkly.atlassian.net/browse/AIC-3534 ## Test plan - [x] `pytest packages/typesafe/tests packages/client/tests/test_judges.py packages/client/tests/test_conversation.py packages/client/tests/test_judge_scoring.py packages/client/tests/test_schema.py` - [ ] Run a TypeSafe judge variation and confirm one result per label, with the label value in `response` Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 64d679e commit 1b41ea4

31 files changed

Lines changed: 1646 additions & 80 deletions

‎.github/workflows/release-please.yml‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ on:
3535
- packages/langchain-agents
3636
- packages/langchain-messages
3737
- packages/opencode-agents
38+
- packages/typesafe
3839
- packages/vercel-agents
3940
- packages/vercel-messages
4041
dry_run:
@@ -73,6 +74,8 @@ jobs:
7374
package-langchain-messages-tag-name: ${{ steps.release.outputs['packages/langchain-messages--tag_name'] }}
7475
package-opencode-agents-released: ${{ steps.release.outputs['packages/opencode-agents--release_created'] }}
7576
package-opencode-agents-tag-name: ${{ steps.release.outputs['packages/opencode-agents--tag_name'] }}
77+
package-typesafe-released: ${{ steps.release.outputs['packages/typesafe--release_created'] }}
78+
package-typesafe-tag-name: ${{ steps.release.outputs['packages/typesafe--tag_name'] }}
7679
package-vercel-agents-released: ${{ steps.release.outputs['packages/vercel-agents--release_created'] }}
7780
package-vercel-agents-tag-name: ${{ steps.release.outputs['packages/vercel-agents--tag_name'] }}
7881
package-vercel-messages-released: ${{ steps.release.outputs['packages/vercel-messages--release_created'] }}
@@ -424,6 +427,37 @@ jobs:
424427
password: ${{ env.PYPI_AUTH_TOKEN }}
425428
packages-dir: packages/opencode-agents/dist/
426429

430+
release-typesafe:
431+
runs-on: ubuntu-latest
432+
needs: ['release-please']
433+
permissions:
434+
contents: read
435+
id-token: write
436+
attestations: write
437+
if: ${{ needs.release-please.outputs.package-typesafe-released == 'true' }}
438+
steps:
439+
- uses: actions/checkout@v4
440+
- uses: ./.github/actions/ci
441+
with:
442+
workspace_path: packages/typesafe
443+
- uses: ./.github/actions/build
444+
with:
445+
workspace_path: packages/typesafe
446+
- name: Attest build provenance
447+
uses: actions/attest@v4
448+
with:
449+
subject-path: 'packages/typesafe/dist/*'
450+
- uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0
451+
name: 'Get PyPI token'
452+
with:
453+
aws_assume_role: ${{ vars.AWS_ROLE_ARN }}
454+
ssm_parameter_pairs: '/production/common/releasing/pypi/token = PYPI_AUTH_TOKEN'
455+
- name: Publish to PyPI
456+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
457+
with:
458+
password: ${{ env.PYPI_AUTH_TOKEN }}
459+
packages-dir: packages/typesafe/dist/
460+
427461
release-vercel-agents:
428462
runs-on: ubuntu-latest
429463
needs: ['release-please']

‎.release-please-manifest.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
"packages/vercel-messages": "0.1.0",
1212
"packages/vercel-agents": "0.1.0",
1313
"packages/litellm-messages": "0.1.0",
14-
"packages/litellm-agents": "0.1.0"
14+
"packages/litellm-agents": "0.1.0",
15+
"packages/typesafe": "0.1.0"
1516
}

‎.sdk_metadata.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@
9090
"aiCompletionConfig": { "introduced": "0.1" }
9191
}
9292
},
93+
"python-ai-typesafe": {
94+
"name": "TypeSafe Jev AI SDK for Python",
95+
"type": "ai-provider",
96+
"path": "packages/typesafe",
97+
"languages": ["Python"],
98+
"aiSdkNames": ["launchdarkly-ai-typesafe"],
99+
"releases": { "tag-prefix": "launchdarkly-ai-typesafe-" },
100+
"features": {
101+
"aiJudgeConfig": { "introduced": "0.1" }
102+
}
103+
},
93104
"python-ai-langchain-agents": {
94105
"name": "LangChain Agents AI SDK for Python",
95106
"type": "ai-provider",

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Tier 0 — Core Client (launchdarkly-ai-server)
8989
| [`launchdarkly-ai-litellm-agents`](packages/litellm-agents/README.md) | `*` (any) | `agent` | OpenAI Agents SDK with LiteLLM models |
9090
| [`launchdarkly-ai-vercel-messages`](packages/vercel-messages/README.md) | `*` (any) | `messages` | Vercel AI SDK with AI Gateway routing |
9191
| [`launchdarkly-ai-vercel-agents`](packages/vercel-agents/README.md) | `*` (any) | `agent` | Native Vercel `ai.Agent` loop and graph adapter |
92+
| [`launchdarkly-ai-typesafe`](packages/typesafe/README.md) | TypeSafe | `messages` | TypeSafe Jev judge handler |
9293

9394
## Quick Start
9495

‎examples/register.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from launchdarkly_ai_openai_agents import create_openai_agent_handler
2323
from launchdarkly_ai_openai_messages import create_openai_messages_handler
2424
from launchdarkly_ai_server import global_registry
25+
from launchdarkly_ai_typesafe import create_typesafe_handler
2526

2627
global_registry.register(
2728
handlers=[
@@ -31,6 +32,7 @@
3132
create_claude_messages_handler(),
3233
create_langchain_messages_handler(),
3334
create_langchain_agents_handler(),
35+
create_typesafe_handler(),
3436
],
3537
tools={
3638
# LD documentation agent tools

‎packages/client/README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,28 @@ async def main():
348348
asyncio.run(main())
349349
```
350350

351+
#### Deferred judges
352+
353+
With `skip_judges=True`, `invoke()` returns `judge_tasks` instead of running them. Run each with `run_judge(task, handlers)` on your own thread or process, then track the result. A judge that scores several labels in one call (a TypeSafe judge, for example) returns `metrics`: track each entry under its own `eventKey`, and do not also track `evaluation_metric_key`.
354+
355+
```python
356+
from ldclient import Context
357+
from launchdarkly_ai_server import get_client, run_judge
358+
359+
result = await run_judge(task, handlers)
360+
context = Context.from_dict(task.user_context)
361+
if result and result.metrics:
362+
for metric in result.metrics:
363+
get_client().track(
364+
metric["eventKey"],
365+
context,
366+
{**result.track_data, "judgeConfigKey": metric["judgeConfigKey"]},
367+
metric["score"],
368+
)
369+
elif result and task.evaluation_metric_key:
370+
get_client().track(task.evaluation_metric_key, context, result.track_data, result.score)
371+
```
372+
351373
### `graph(key, **options)`
352374

353375
Runs a multi-agent workflow defined in a LaunchDarkly agent graph flag. The SDK uses a **model-driven router**: it starts at the root node, presents outgoing edges as handoff choices to the model, and follows whichever edge the model selects. The loop terminates when the model produces a final answer, a leaf is reached, a cycle is detected, or the step cap is hit.

‎packages/client/src/launchdarkly_ai_server/conversation.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class _JudgeEvalCapture:
3636
released: bool = False
3737
span: Any = None
3838
pending_end: Callable[[], None] | None = None
39+
mirrored: bool = False
3940

4041

4142
# Every tracer this SDK creates is named "@launchdarkly/ai-<package>". The processor is registered
@@ -82,7 +83,12 @@ def _conversation_id_from(ctx: otel_context.Context | None) -> str | None:
8283

8384

8485
def _record_evaluation(
85-
span: Any, name: str, score: float, explanation: str | None = None
86+
span: Any,
87+
name: str,
88+
score: float,
89+
explanation: str | None = None,
90+
*,
91+
mirror_attributes: bool = True,
8692
) -> None:
8793
"""Write the judge score as a ``gen_ai.evaluation.result`` event plus mirrored attributes.
8894
@@ -99,6 +105,10 @@ def _record_evaluation(
99105
if explanation:
100106
attrs["gen_ai.evaluation.explanation"] = explanation
101107
span.add_event("gen_ai.evaluation.result", attrs)
108+
# A classifier records one event per label. Span attributes hold a single name and
109+
# score, so only the first evaluation is mirrored. Later events keep their own attributes.
110+
if not mirror_attributes:
111+
return
102112
for key, value in attrs.items():
103113
span.set_attribute(key, value)
104114

@@ -237,9 +247,18 @@ async def with_judge_evaluation(name: str) -> AsyncIterator[RecordEvaluation]:
237247
"""
238248
capture = _JudgeEvalCapture(name=name)
239249

240-
def record(score: float, explanation: str | None = None) -> None:
250+
def record(
251+
score: float, explanation: str | None = None, name: str | None = None
252+
) -> None:
241253
if capture.span is not None:
242-
_record_evaluation(capture.span, capture.name, score, explanation)
254+
_record_evaluation(
255+
capture.span,
256+
name or capture.name,
257+
score,
258+
explanation,
259+
mirror_attributes=not capture.mirrored,
260+
)
261+
capture.mirrored = True
243262

244263
token = otel_context.attach(otel_context.set_value(_EVAL_KEY, capture))
245264
try:

‎packages/client/src/launchdarkly_ai_server/evaluations/runner.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
build_message_history,
1818
numeric_score,
1919
parse_judge_response,
20+
typesafe_judge_entries,
2021
)
2122
from ..lifecycle import extract_variation
2223
from ..trajectory import (
@@ -968,10 +969,17 @@ async def _run_ld_judge_for_result(
968969
"invalid_judge_output",
969970
"judge handler result must be a mapping",
970971
)
972+
judge_output = result.get("output", result.get("response"))
971973
try:
972-
raw_score, reason = parse_judge_response(
973-
result.get("output", result.get("response"))
974-
)
974+
entries = typesafe_judge_entries(judge_output)
975+
if entries is not None:
976+
# A criterion event is one score. The remaining labels are
977+
# available on the online judge_results path. The reason is the
978+
# question key: ingest drops a COMPLETE judge event with an
979+
# empty reason, and a Jev label has no prose verdict.
980+
raw_score, reason = entries[0]["score"], entries[0]["key"]
981+
else:
982+
raw_score, reason = parse_judge_response(judge_output)
975983
except ValueError as error:
976984
return self._criterion_error_result(
977985
base, started_clock, "invalid_judge_output", str(error)

‎packages/client/src/launchdarkly_ai_server/judge_scoring.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,55 @@ def numeric_score(score: Any) -> float | None:
6565
return value if isfinite(value) else None
6666

6767

68+
def typesafe_judge_entries(raw: Any) -> list[dict[str, Any]] | None:
69+
"""Parse a TypeSafe handler payload into question key, score, event key, and reason.
70+
71+
Returns ``None`` when ``raw`` is not a TypeSafe multi-result payload, so a
72+
classic ``{"score", "reasoning"}`` judge stays on :func:`parse_judge_response`.
73+
Raises ``ValueError`` when the payload claims to be TypeSafe but is malformed.
74+
"""
75+
parsed: Any
76+
if isinstance(raw, str):
77+
parsed = parse_json_with_possible_fences(raw)
78+
elif isinstance(raw, Mapping):
79+
parsed = raw
80+
else:
81+
parsed = None
82+
if not isinstance(parsed, Mapping) or parsed.get("kind") != "typesafe":
83+
return None
84+
results = parsed.get("results")
85+
if not isinstance(results, list) or not results:
86+
raise ValueError("TypeSafe judge output is missing results")
87+
entries: list[dict[str, Any]] = []
88+
for item in results:
89+
if (
90+
not isinstance(item, Mapping)
91+
or not isinstance(item.get("key"), str)
92+
or not item["key"]
93+
):
94+
raise ValueError("TypeSafe judge result is missing a key")
95+
score = numeric_score(item.get("score"))
96+
if score is None:
97+
raise ValueError(
98+
f"TypeSafe judge result {item['key']!r} is missing a finite score"
99+
)
100+
event_key = item.get("eventKey")
101+
if not isinstance(event_key, str) or not event_key:
102+
raise ValueError(
103+
f"TypeSafe judge result {item['key']!r} is missing an eventKey"
104+
)
105+
reason = item.get("reason")
106+
entries.append(
107+
{
108+
"key": item["key"],
109+
"score": score,
110+
"eventKey": event_key,
111+
"reason": reason if isinstance(reason, str) else "",
112+
}
113+
)
114+
return entries
115+
116+
68117
def parse_judge_response(raw: Any) -> tuple[Any, str]:
69118
"""Parse a judge model response into ``(score, reasoning)``.
70119

0 commit comments

Comments
 (0)