Skip to content

Commit b23991a

Browse files
nlemoffclaude
andcommitted
feat(offline_tests): add concurrency for offline agent test execution (JUD-9600)
Add a `concurrency: int = 1` option to `client.offline_tests.run()`, threaded through `OfflineTestRunner.run()` into `run_agent()`. Above 1, agent calls run on a ThreadPoolExecutor with contextvars.copy_context() propagation (the active tracer lives in a ContextVar, so worker threads need the copied context to record spans at all). At the default of 1 the agent still runs sequentially on the calling thread, preserving behavior for thread-affine agents. Replace the order-dependent example->trace correlation (slicing the shared `captured` list) with in-call capture: a functools.wraps probe reads the root span's trace id from inside the observed call. The capture is guarded so it records nothing when the offline tracer failed to activate (nested inside a live root span) or when the entrypoint is a generator whose observed span never exports. Also: fail fast on entrypoint/example field mismatches by building all kwargs before any agent call; cancel queued examples on interrupt or error instead of draining the dataset; validate concurrency on no-agent paths too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0e6fac2 commit b23991a

3 files changed

Lines changed: 377 additions & 78 deletions

File tree

src/judgeval/offline_tests/offline_test_runner.py

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import contextvars
5+
import functools
46
import inspect
57
import time
8+
from concurrent.futures import ThreadPoolExecutor, as_completed
69
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict, cast
710

811
import orjson
@@ -413,16 +416,22 @@ def run_agent(
413416
examples: List[Dict[str, Any]],
414417
progress: Optional[Progress] = None,
415418
field_mapping: Optional[Dict[str, str]] = None,
419+
concurrency: int = 1,
416420
) -> Dict[str, str]:
417421
"""Call the agent entrypoint once per dataset example.
418422
419423
Each call is wrapped in the `OfflineTracer` machinery so it
420424
produces a dedicated offline trace; the resulting trace IDs are
421425
returned keyed by example ID. Entrypoint/example field mismatches
422-
raise immediately; runtime errors inside the agent are recorded on
423-
the trace and logged, and the loop continues. The previously
424-
active tracer (if any) is restored once the loop finishes, so
425-
subsequent `@observe` spans do not route to the offline endpoint.
426+
raise before any agent call. With `concurrency` > 1 that many
427+
examples run at a time on worker threads (each call in a copy of
428+
the current context, so the offline tracer stays active and
429+
traces stay isolated per call); at 1 the agent runs sequentially
430+
on the calling thread as before. Runtime errors inside the agent
431+
are recorded on the trace and logged, and the remaining examples
432+
still run. The previously active tracer (if any) is restored once
433+
the loop finishes, so subsequent `@observe` spans do not route to
434+
the offline endpoint.
426435
427436
Before returning, the offline tracer is force-flushed and its
428437
provider shut down, so every agent trace is exported by the time
@@ -431,35 +440,79 @@ def run_agent(
431440
from judgeval.trace.judgment_tracer_provider import JudgmentTracerProvider
432441
from judgeval.trace.offline_tracer import OfflineTracer
433442

443+
if concurrency < 1:
444+
raise ValueError(f"concurrency must be >= 1, got {concurrency}")
445+
446+
# Fail fast on entrypoint/example mismatches before any agent call
447+
# (and before any traces are exported).
448+
kwargs_per_example = [
449+
build_agent_kwargs(agent_function, example.get("data") or {}, field_mapping)
450+
for example in examples
451+
]
452+
434453
proxy = JudgmentTracerProvider.get_instance()
435454
previous_tracer = proxy.get_active_tracer()
436455

437-
captured: List[Example] = []
456+
# The dataset list is required by OfflineTracer.create but unused
457+
# here: example→trace correlation happens inside each observed call.
438458
tracer = OfflineTracer.create(
439459
project_name=self._project_name,
440460
api_key=self._client.api_key,
441461
organization_id=self._client.organization_id,
442462
api_url=self._client.base_url,
443463
set_active=True,
444-
dataset=captured,
464+
dataset=[],
445465
)
446466
try:
447-
wrapped = tracer.observe(agent_function, span_type="agent")
448467
is_async = inspect.iscoroutinefunction(agent_function)
468+
# An observed generator entrypoint is returned uniterated, so its
469+
# span never ends and no trace is exported — record nothing for
470+
# it rather than attach a trace id that won't exist server-side.
471+
records_traces = not (
472+
inspect.isgeneratorfunction(agent_function)
473+
or inspect.isasyncgenfunction(agent_function)
474+
)
449475

450476
task = None
451477
if progress is not None:
452478
task = progress.add_task(
453479
f"Running agent over {len(examples)} example(s)...", total=None
454480
)
455481

456-
agent_traces: Dict[str, str] = {}
457-
for index, example in enumerate(examples):
482+
def invoke(
483+
example: Dict[str, Any], kwargs: Dict[str, Any]
484+
) -> Tuple[str, Optional[str]]:
458485
example_id = example.get("example_id") or ""
459-
data = example.get("data") or {}
460-
kwargs = build_agent_kwargs(agent_function, data, field_mapping)
461486

462-
before = len(captured)
487+
# Capture the offline trace id from inside the observed
488+
# call: correlation by example, safe under concurrency.
489+
holder: Dict[str, str] = {}
490+
491+
def record_trace_id() -> None:
492+
# If activation was refused (run() nested inside a live
493+
# root span) spans route to the live tracer; don't attach
494+
# a foreign trace id.
495+
if not records_traces or proxy.get_active_tracer() is not tracer:
496+
return
497+
ids = tracer._get_current_trace_and_span_id()
498+
if ids:
499+
holder["trace_id"] = ids[0]
500+
501+
probe: AgentFunction
502+
if is_async:
503+
504+
@functools.wraps(agent_function)
505+
async def probe(**kw: Any) -> Any:
506+
record_trace_id()
507+
return await agent_function(**kw)
508+
else:
509+
510+
@functools.wraps(agent_function)
511+
def probe(**kw: Any) -> Any:
512+
record_trace_id()
513+
return agent_function(**kw)
514+
515+
wrapped = tracer.observe(probe, span_type="agent")
463516
try:
464517
if is_async:
465518
asyncio.run(wrapped(**kwargs))
@@ -469,18 +522,40 @@ def run_agent(
469522
judgeval_logger.error(
470523
f"Agent entrypoint raised for example {example_id}: {exc}"
471524
)
525+
return example_id, holder.get("trace_id")
472526

473-
for produced in captured[before:]:
474-
offline_trace_id = produced._properties.get("offline_trace_id")
475-
if example_id and offline_trace_id:
476-
agent_traces[example_id] = offline_trace_id
477-
break
527+
agent_traces: Dict[str, str] = {}
478528

529+
def collect(result: Tuple[str, Optional[str]], completed: int) -> None:
530+
example_id, trace_id = result
531+
if example_id and trace_id:
532+
agent_traces[example_id] = trace_id
479533
if progress is not None and task is not None:
480534
progress.update(
481535
task,
482-
description=f"Running agent... ({index + 1}/{len(examples)})",
536+
description=f"Running agent... ({completed}/{len(examples)})",
483537
)
538+
539+
if concurrency == 1:
540+
# Stay on the calling thread so thread-affine agents
541+
# (signals, sqlite handles, ...) keep working by default.
542+
for index, example in enumerate(examples):
543+
collect(invoke(example, kwargs_per_example[index]), index + 1)
544+
else:
545+
# copy_context() carries the active-tracer ContextVar into
546+
# the worker threads; without it no spans would be recorded.
547+
pool = ThreadPoolExecutor(max_workers=concurrency)
548+
try:
549+
futures = [
550+
pool.submit(contextvars.copy_context().run, invoke, ex, kw)
551+
for ex, kw in zip(examples, kwargs_per_example)
552+
]
553+
for completed, future in enumerate(as_completed(futures), 1):
554+
collect(future.result(), completed)
555+
finally:
556+
# Drop still-queued examples on interrupt/error instead
557+
# of draining the whole dataset before surfacing it.
558+
pool.shutdown(wait=True, cancel_futures=True)
484559
finally:
485560
tracer.force_flush()
486561
proxy.restore_active(previous_tracer)
@@ -757,19 +832,23 @@ def run(
757832
timeout_seconds: int = 600,
758833
run_name: Optional[str] = None,
759834
field_mapping: Optional[Dict[str, str]] = None,
835+
concurrency: int = 1,
760836
) -> OfflineTestResult:
761837
"""Execute the full offline-test lifecycle for a test config.
762838
763839
When ``agent_function`` is omitted, no agent is invoked: the judges
764840
score each example's existing trace (the dataset's trace-typed
765841
column / ``offline_trace_id``). When provided, the agent is run once
766-
per example first and the judges score the resulting agent trace.
842+
per example first (up to ``concurrency`` examples at a time) and the
843+
judges score the resulting agent trace.
767844
"""
768845
if assert_test and pass_condition_fn is None:
769846
raise ValueError(
770847
"assert_test=True requires a pass_condition_fn to decide "
771848
"whether each row passes."
772849
)
850+
if concurrency < 1:
851+
raise ValueError(f"concurrency must be >= 1, got {concurrency}")
773852

774853
console = Console()
775854
console.print("\n[bold cyan]Starting Offline Test[/bold cyan]")
@@ -803,7 +882,7 @@ def run(
803882
agent_traces: Dict[str, str] = {}
804883
if agent_function is not None and examples:
805884
agent_traces = self.run_agent(
806-
agent_function, examples, progress, field_mapping
885+
agent_function, examples, progress, field_mapping, concurrency
807886
)
808887

809888
prepared = self.create_test_run(

src/judgeval/offline_tests/offline_tests_factory.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ def run(
227227
timeout_seconds: int = 600,
228228
run_name: Optional[str] = None,
229229
field_mapping: Optional[Dict[str, str]] = None,
230+
concurrency: int = 1,
230231
) -> Optional[OfflineTestResult]:
231232
"""Run an offline test for a test config.
232233
@@ -279,15 +280,23 @@ def run(
279280
field ``question``). Unmapped parameters fall back to the field
280281
of the same name. Example fields the agent does not declare are
281282
ignored.
283+
concurrency: Maximum number of examples the agent runs over at
284+
a time. Defaults to 1 (sequential, on the calling thread).
285+
Above 1, calls run on worker threads; an async agent runs
286+
each example under its own event loop, so create per-call
287+
async clients inside the agent rather than sharing a
288+
module-level one across calls. Only affects the agent
289+
execution loop; judge scoring happens server-side and is
290+
unaffected.
282291
283292
Returns:
284293
An `OfflineTestResult`, or `None` if the project is not
285294
resolved or the config cannot be found.
286295
287296
Raises:
288297
ValueError: If `assert_test` is set without `pass_condition_fn`,
289-
or if `dataset_version` does not match any version of the
290-
config's dataset.
298+
if `dataset_version` does not match any version of the
299+
config's dataset, or if `concurrency` is less than 1.
291300
TypeError: If the agent entrypoint cannot accept an example's
292301
fields.
293302
JudgmentValidationError: If the server rejects the run (e.g.
@@ -321,4 +330,5 @@ def run(
321330
timeout_seconds=timeout_seconds,
322331
run_name=run_name,
323332
field_mapping=field_mapping,
333+
concurrency=concurrency,
324334
)

0 commit comments

Comments
 (0)