11from __future__ import annotations
22
33import asyncio
4+ import contextvars
5+ import functools
46import inspect
57import time
8+ from concurrent .futures import ThreadPoolExecutor , as_completed
69from typing import Any , Callable , Dict , List , Optional , Tuple , TypedDict , cast
710
811import 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 (
0 commit comments