kiln_ai.adapters.eval.eval_runner
1import logging 2from dataclasses import dataclass 3from typing import Any, AsyncGenerator, Dict, List, Literal, Set 4 5from kiln_ai.adapters.adapter_registry import load_skills_for_task 6from kiln_ai.adapters.chat.chat_formatter import ( 7 chat_strategy_for_run, 8 is_two_message_cot_strategy, 9) 10from kiln_ai.adapters.eval.base_eval import BaseEval, BaseV2EvalBridge 11from kiln_ai.adapters.eval.registry import legacy_eval_adapter_from_type 12from kiln_ai.adapters.eval.trace_index import TraceIndex, TraceKey, trace_key 13from kiln_ai.adapters.ml_model_list import built_in_models_from_provider 14from kiln_ai.adapters.model_adapters.base_adapter import SkillsDict 15from kiln_ai.adapters.prompt_builders import prompt_builder_from_id 16from kiln_ai.adapters.provider_tools import kiln_model_provider_from 17from kiln_ai.adapters.retry_classification import ( 18 is_retryable_error, 19 unwrap_kiln_run_error, 20) 21from kiln_ai.datamodel.basemodel import ID_TYPE, generate_model_id 22from kiln_ai.datamodel.datamodel_enums import ModelProviderName 23from kiln_ai.datamodel.dataset_filters import ( 24 DatasetFilterId, 25 dataset_filter_from_id, 26) 27from kiln_ai.datamodel.eval import ( 28 Eval, 29 EvalConfig, 30 EvalConfigType, 31 EvalDataType, 32 EvalInput, 33 EvalRun, 34 EvalScores, 35 EvalTaskInput, 36 MultiTurnSyntheticEvalInputData, 37 SkippedReason, 38 V2EvalResult, 39) 40from kiln_ai.datamodel.eval_splits import ( 41 ItemKey, 42 ResolvedSplit, 43 eval_run_item_key, 44 item_key, 45) 46from kiln_ai.datamodel.run_config import ( 47 KilnAgentRunConfigProperties, 48 as_kiln_agent_run_config, 49) 50from kiln_ai.datamodel.task import Task, TaskRunConfig 51from kiln_ai.datamodel.task_run import EvalItemSource, TaskRun, Usage 52from kiln_ai.datamodel.usage import MessageUsage 53from kiln_ai.synthetic_user import drive_case_for_eval 54from kiln_ai.synthetic_user.drive_loop import DriveCaseResult 55from kiln_ai.synthetic_user.models import ( 56 TAG_SU_ENDED_CONVERSATION, 57 SyntheticUserDriverConfig, 58) 59from kiln_ai.utils.async_job_runner import AsyncJobRunner, Progress, RetryableError 60from kiln_ai.utils.git_sync_protocols import SaveContext, default_save_context 61from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam, serialize_trace 62from kiln_ai.utils.slow_operation import log_if_slow 63 64logger = logging.getLogger(__name__) 65 66 67@dataclass 68class EvalJob: 69 item: TaskRun | EvalInput 70 type: Literal["task_run_eval", "eval_config_eval"] 71 eval_config: EvalConfig 72 task_run_config: TaskRunConfig | None = None 73 74 75def _calibration_item(job: EvalJob) -> TaskRun | None: 76 """The golden TaskRun a calibration job scores, or None if this isn't calibration. 77 78 Calibration generates nothing: the human-rated dataset item *is* what gets scored 79 (functional spec 4.5). So the trace is known before the job starts, and stays known 80 even for a job that is skipped before it reaches the judge — which is why both the 81 scoring path and the skip path ask here rather than each deciding for themselves. 82 """ 83 if job.type != "eval_config_eval": 84 return None 85 if not isinstance(job.item, TaskRun): 86 raise ValueError("Calibration items are always TaskRuns") 87 return job.item 88 89 90def _has_text_content(content: Any) -> bool: 91 """Whether a message's content carries any text. 92 93 Content is either a plain string or a list of content parts, and both shapes reach 94 here — traces are written by adapters and read back through pydantic. 95 """ 96 if isinstance(content, str): 97 return bool(content.strip()) 98 if isinstance(content, list): 99 return any( 100 isinstance(part, dict) and bool(str(part.get("text") or "").strip()) 101 for part in content 102 ) 103 return False 104 105 106def conversation_health_problem( 107 trace: list[ChatCompletionMessageParam] | None, 108 required_turns: int, 109) -> str | None: 110 """Why `trace` is not a complete conversation of `required_turns` turns, or None when 111 it is complete. 112 113 Structural completeness only, never error-freeness: a conversation whose tool calls 114 failed is a legitimate thing to evaluate (judging how an agent handles errors is a 115 first-class eval), so error-bearing tool messages say nothing about health here. What 116 it does catch is a conversation that stopped short, which would otherwise be judged 117 as if the agent had simply finished. 118 """ 119 messages = trace or [] 120 user_turns = sum(1 for message in messages if message.get("role") == "user") 121 if user_turns != required_turns: 122 return f"expected {required_turns} user turns, found {user_turns}" 123 if not messages: 124 return "the conversation is empty" 125 last_role = messages[-1].get("role") 126 if last_role != "assistant": 127 return f"the conversation ends with a '{last_role}' message, not an assistant reply" 128 if not _has_text_content(messages[-1].get("content")): 129 return "the final assistant message has no text content" 130 return None 131 132 133def _splits_a_turn_into_two_messages( 134 properties: KilnAgentRunConfigProperties, task: Task 135) -> bool: 136 """Whether this run config answers one turn with two user-role messages. 137 138 A chain of thought prompt on a model with no reasoning step of its own is served by 139 asking the model to think, then sending a second user message asking for the final 140 answer. That extra message is indistinguishable from a real user turn, so a driven 141 conversation both miscounts its turns and feeds the injected message back to the 142 synthetic user as if the user had written it. 143 144 The strategy is resolved from the same prompt builder and model provider the adapter 145 reads, so the answer here is the one the drive would get. Built-in models are read 146 straight from the model table rather than through the credentialed provider lookup: 147 this preflight runs before any paid call and must give the same answer on a machine 148 with no provider keys, and the two fields it needs are static entries in that table. 149 Custom, fine-tuned and litellm models are not in the table, so they keep the 150 credentialed lookup. A config that cannot be resolved answers False: the drive 151 fails on the identical lookup before it spends anything, and one unresolvable 152 config must not block the run configs beside it. 153 """ 154 try: 155 cot_prompt = prompt_builder_from_id( 156 properties.prompt_id, task 157 ).chain_of_thought_prompt() 158 provider = None 159 if cot_prompt: 160 provider = built_in_models_from_provider( 161 properties.model_provider_name, properties.model_name 162 ) or kiln_model_provider_from( 163 properties.model_name, properties.model_provider_name 164 ) 165 except Exception as error: 166 logger.warning( 167 "Could not resolve the chat strategy for model '%s' with prompt '%s' (%s); " 168 "leaving its multi-turn compatibility to the per-job checks", 169 properties.model_name, 170 properties.prompt_id, 171 error, 172 ) 173 return False 174 return is_two_message_cot_strategy( 175 chat_strategy_for_run( 176 cot_prompt=cot_prompt, 177 tuned_chat_strategy=provider.tuned_chat_strategy if provider else None, 178 reasoning_capable=provider.reasoning_capable if provider else False, 179 ) 180 ) 181 182 183def no_golden_set_message(eval: Eval) -> str: 184 """Why judge comparison can't run without a golden set. One wording, two raisers. 185 186 Shared with the API layer so the 4xx a user sees and the ValueError a library caller 187 sees say the same thing (functional spec 9, architecture 4.3). 188 """ 189 return ( 190 f"Eval '{eval.id}' has no golden set configured. Comparing judges scores them " 191 "against a set of human-rated dataset items, so the eval needs one before a " 192 "comparison can run." 193 ) 194 195 196class EvalRunner: 197 """ 198 Runs an eval. Async execution is supported to make it faster when using remote/fast model providers. 199 200 Can run an eval in 2 modes: 201 1) eval_config_eval: evaluate an eval config (judge quality) using existing dataset 202 items. Scoped by the eval's golden filter, so its items are always TaskRuns. 203 2) task_run_eval: evaluate a range of task run configs, generating new run output. 204 Scoped by the `split` it is given, whose items may come from either store. 205 Multi-turn synthetic EvalInputs are re-driven as a full conversation per run 206 config using the drive config stamped on each item; stored multi-turn TaskRun 207 chains are skipped. 208 """ 209 210 def __init__( 211 self, 212 eval_configs: List[EvalConfig], 213 run_configs: List[TaskRunConfig] | None, 214 eval_run_type: Literal["eval_config_eval", "task_run_eval"], 215 split: ResolvedSplit | None = None, 216 save_context: SaveContext | None = None, 217 ): 218 if len(eval_configs) == 0: 219 raise ValueError("Eval runner requires at least one eval config") 220 target_eval = eval_configs[0].parent_eval() 221 if target_eval is None: 222 raise ValueError("Eval config requires a parent eval") 223 for eval_config in eval_configs: 224 parent_eval = eval_config.parent_eval() 225 if parent_eval is None: 226 raise ValueError("Eval config requires a parent eval") 227 if parent_eval.id != target_eval.id: 228 raise ValueError("All eval configs must have the same parent eval") 229 230 target_task = target_eval.parent_task() 231 if target_task is None: 232 raise ValueError("Eval config requires a (grand)parent task") 233 234 # Both modes settle *what defines* their item scope here rather than at collect 235 # time, but to different depths, and the difference is deliberate. task_run_eval 236 # takes a ResolvedSplit: the items themselves, fixed now, so a split cannot be 237 # accepted and then quietly re-resolved to something else. eval_config_eval takes 238 # the golden filter id and still applies it to self.task.runs() at collect time, 239 # so items added after construction are picked up — golden is TaskRun-only by 240 # definition, so there is no source ambiguity for that to hide. 241 self.golden_filter_id: DatasetFilterId | None = None 242 if eval_run_type == "task_run_eval": 243 if run_configs is None or len(run_configs) == 0: 244 raise ValueError("Task run eval requires run configs") 245 for run_config in run_configs: 246 parent_task = run_config.parent_task() 247 if parent_task is None: 248 raise ValueError("All run configs must have a parent task") 249 if parent_task.id != target_task.id: 250 raise ValueError( 251 "Run config is not for the same task as the eval configs" 252 ) 253 if split is None: 254 raise ValueError("Task run eval requires a resolved split") 255 if split.eval_id != target_eval.id: 256 raise ValueError( 257 f"Split '{split.name}' was resolved from eval '{split.eval_id}', not from " 258 f"eval '{target_eval.id}' whose configs are being run" 259 ) 260 else: 261 if run_configs is not None: 262 raise ValueError("Mode 'eval_config_eval' does not support run configs") 263 if split is not None: 264 raise ValueError( 265 "Mode 'eval_config_eval' does not support a split: it is scoped by the eval's golden filter" 266 ) 267 if target_eval.eval_configs_filter_id is None: 268 raise ValueError(no_golden_set_message(target_eval)) 269 self.golden_filter_id = target_eval.eval_configs_filter_id 270 271 self.eval_run_type = eval_run_type 272 self.eval_configs = eval_configs 273 self.run_configs = run_configs 274 self.split = split 275 self.task = target_task 276 self.eval = target_eval 277 self._skills: SkillsDict = self._preload_skills() 278 self._save_context: SaveContext = save_context or default_save_context 279 # Live, not precomputed like `already_run`: a trace persisted by one job has to be 280 # visible to the next, whether that next job is running concurrently under a 281 # different eval config or is this job's own retry (functional spec 4.2, 4.3). 282 self._trace_index = TraceIndex(self.task) 283 284 def collect_tasks(self) -> List[EvalJob]: 285 if self.eval_run_type == "eval_config_eval": 286 if self.golden_filter_id is None: 287 raise ValueError(no_golden_set_message(self.eval)) 288 return self.collect_tasks_for_eval_config_eval(self.golden_filter_id) 289 return self.collect_tasks_for_task_run_eval() 290 291 def collect_tasks_for_eval_config_eval( 292 self, eval_configs_filter_id: DatasetFilterId 293 ) -> List[EvalJob]: 294 """ 295 Collect all jobs for this run, excluding any that have already been run. 296 297 This variant is used for mode "eval_config_eval", using existing dataset run data (input/output). 298 299 The tasks: 300 - should be in the eval config set filter 301 - should not have already been run for this eval config + dataset item pair 302 """ 303 filter = dataset_filter_from_id(eval_configs_filter_id) 304 305 # already_run[eval_config_id][dataset_id] 306 already_run: Dict[ID_TYPE, Set[ID_TYPE]] = {} 307 for eval_config in self.eval_configs: 308 already_run[eval_config.id] = set() 309 for run in eval_config.runs(readonly=True): 310 # Only calibration records mark a golden item done: the same eval 311 # config also accumulates task_run_eval records, and a golden 312 # TaskRun that was scored as a test item must still be calibrated. 313 if not run.eval_config_eval: 314 continue 315 already_run[eval_config.id].add(run.dataset_id) 316 317 return [ 318 EvalJob( 319 item=task_run, 320 eval_config=eval_config, 321 type="eval_config_eval", 322 ) 323 for task_run in self.task.runs(readonly=True) 324 if filter(task_run) 325 for eval_config in self.eval_configs 326 if task_run.id not in already_run[eval_config.id] 327 ] 328 329 def collect_tasks_for_task_run_eval(self) -> List[EvalJob]: 330 """ 331 Collect all jobs for this run, excluding any that have already been run. 332 333 This variant is used for mode "task_run_eval", generating new run output using existing dataset item input. 334 335 The tasks: 336 - are the items of the split this runner was given, from whichever store backs it 337 - should not have already been run for this eval config + run config + dataset item 338 """ 339 if self.split is None: 340 raise ValueError("Task run eval requires a resolved split") 341 342 # already_run[eval_config_id][run_config_id][item_key] 343 already_run: Dict[ID_TYPE, Dict[ID_TYPE, Set[ItemKey]]] = {} 344 for eval_config in self.eval_configs: 345 already_run[eval_config.id] = { 346 run_config.id: set() for run_config in self.run_configs or [] 347 } 348 for run in eval_config.runs(readonly=True): 349 # Scopes the dedupe to the run configs actually being evaluated: an eval 350 # config accumulates runs for every run config ever compared against it. 351 # The `is not None` is not redundant with the membership test — ID_TYPE is 352 # `str | None`, so a run config whose file carries a null id would make 353 # None a real key and fold every calibration record into its set. 354 if ( 355 run.task_run_config_id is not None 356 and run.task_run_config_id in already_run[eval_config.id] 357 ): 358 already_run[eval_config.id][run.task_run_config_id].add( 359 eval_run_item_key(run) 360 ) 361 362 return [ 363 EvalJob( 364 item=item, 365 task_run_config=run_config, 366 type="task_run_eval", 367 eval_config=eval_config, 368 ) 369 for item in self.split.items 370 for eval_config in self.eval_configs 371 for run_config in self.run_configs or [] 372 if (self.split.source, item.id) 373 not in already_run[eval_config.id][run_config.id] 374 ] 375 376 def validate_multi_turn_drive_readiness( 377 self, check_run_configs: bool = True 378 ) -> None: 379 """Fail fast on config problems every multi-turn re-drive job would 380 hit: no item carrying a synthetic-user drive config, a stamped 381 config with an unknown model provider, run configs that aren't 382 Kiln agent configs, or run configs that answer in two messages per 383 turn. Callers can invoke this before starting a batch 384 so the user gets one clear error up front instead of one opaque 385 error per job. The per-job checks stay as the backstop for 386 standalone runner use. 387 388 A partially stamped split is NOT an error here: the stamped items 389 can still run, and each unstamped item records a per-item skip 390 naming the fix. Only an entirely unstamped split fails up front, 391 because nothing could run and one clear error beats a page of 392 identical skips. 393 394 `check_run_configs=False` limits validation to the drive configs — 395 for callers running a fleet the user didn't hand-pick, where one 396 incompatible run config shouldn't block every other config's jobs. 397 398 Raises ValueError listing every problem; no-op unless re-drive jobs 399 can actually occur (task_run_eval over a split containing multi-turn 400 synthetic items — stored-TaskRun sources never re-drive, and a 401 single-turn split has nothing to drive). 402 """ 403 if self.eval_run_type != "task_run_eval" or self.split is None: 404 return 405 multi_turn_items = [ 406 item.data 407 for item in self.split.items 408 if isinstance(item, EvalInput) 409 and isinstance(item.data, MultiTurnSyntheticEvalInputData) 410 ] 411 if not multi_turn_items: 412 return 413 problems: list[str] = [] 414 stamped_configs = [ 415 data.drive_config 416 for data in multi_turn_items 417 if data.drive_config is not None 418 ] 419 if not stamped_configs: 420 problems.append( 421 "none of this eval's multi-turn items has a synthetic user " 422 "configuration (create a new batch to mint items that carry one)" 423 ) 424 for provider in sorted({cfg.model_provider for cfg in stamped_configs}): 425 try: 426 ModelProviderName(provider) 427 except ValueError: 428 problems.append( 429 "a multi-turn item's synthetic-user drive config has " 430 f"unknown model provider '{provider}'" 431 ) 432 if check_run_configs: 433 for run_config in self.run_configs or []: 434 try: 435 agent_properties = as_kiln_agent_run_config( 436 run_config.run_config_properties 437 ) 438 except ValueError: 439 problems.append( 440 f"run config '{run_config.name}' is not a Kiln agent " 441 "config, so it can't hold a multi-turn conversation" 442 ) 443 continue 444 if _splits_a_turn_into_two_messages(agent_properties, self.task): 445 problems.append( 446 f"run config '{run_config.name}' uses a chain of thought " 447 "prompt with a model that has no reasoning step of its own, " 448 "so it answers in two messages per turn and can't hold a " 449 "multi-turn conversation (pick a reasoning-capable model, or " 450 "a prompt without thinking instructions)" 451 ) 452 if problems: 453 raise ValueError( 454 "Cannot re-drive this eval's multi-turn conversations: " 455 + "; ".join(problems) 456 + "." 457 ) 458 459 def _preload_skills(self) -> SkillsDict: 460 """Collect all skill IDs from run configs and bulk-load them once.""" 461 if self.run_configs is None: 462 return {} 463 merged: SkillsDict = {} 464 for rc in self.run_configs: 465 skills = load_skills_for_task(self.task, rc.run_config_properties) 466 merged.update(skills) 467 return merged 468 469 async def run(self, concurrency: int = 25) -> AsyncGenerator[Progress, None]: 470 """ 471 Runs the configured eval run with parallel workers and yields progress updates. 472 """ 473 jobs = self.collect_tasks() 474 475 runner = AsyncJobRunner( 476 concurrency=concurrency, 477 jobs=jobs, 478 run_job_fn=self.run_job, 479 max_retries=2, 480 ) 481 async for progress in runner.run(): 482 yield progress 483 484 async def run_job(self, job: EvalJob) -> bool: 485 try: 486 if job.eval_config.config_type == EvalConfigType.v2: 487 return await self._run_v2_job(job) 488 else: 489 return await self._run_legacy_job(job) 490 except RetryableError as e: 491 # Already classified by whoever raised it: this is a deliberate, expected 492 # ask for another attempt (an unusable generation the retry replaces), not a 493 # failure. Re-raised untouched, and logged without a stacktrace so a 494 # self-healing event doesn't read as a crash in the logs. 495 logger.warning(f"Retrying eval job for dataset item {job.item.id}: {e}") 496 raise 497 except Exception as e: 498 if is_retryable_error(e): 499 logger.error( 500 f"Transient error running eval job for dataset item {job.item.id}: {e}", 501 exc_info=True, 502 ) 503 # KilnRunError's own message is genericized user-facing text; keep 504 # the underlying provider detail for the developer-facing error log. 505 raise RetryableError(str(unwrap_kiln_run_error(e))) from e 506 logger.error( 507 f"Error running eval job for dataset item {job.item.id}: {e}", 508 exc_info=True, 509 ) 510 raise 511 512 async def _run_legacy_job(self, job: EvalJob) -> bool: 513 if not isinstance(job.item, TaskRun): 514 raise ValueError("Legacy eval jobs require a TaskRun item") 515 516 evaluator = legacy_eval_adapter_from_type(job.eval_config)( 517 job.eval_config, 518 job.task_run_config.run_config_properties if job.task_run_config else None, 519 skills=self._skills, 520 ) 521 if not isinstance(evaluator, BaseEval): 522 raise ValueError("Not able to create evaluator from eval config") 523 524 task_output: str | None = None 525 reference_answer: str | None = None 526 trace: str | None = None 527 scores: EvalScores | None = None 528 intermediate_outputs: Dict[str, str] | None = None 529 task_run_usage: Usage | None = None 530 if job.type == "eval_config_eval": 531 scores, intermediate_outputs = await evaluator.run_eval(job.item) 532 task_output = job.item.output.output 533 task_run_usage = job.item.usage 534 else: 535 ( 536 result_task_run, 537 scores, 538 intermediate_outputs, 539 ) = await evaluator.run_task_and_eval(job.item) 540 task_output = result_task_run.output.output 541 task_run_usage = result_task_run.usage 542 543 parent_eval = job.eval_config.parent_eval() 544 if ( 545 parent_eval 546 and parent_eval.evaluation_data_type == EvalDataType.full_trace 547 and result_task_run.trace 548 ): 549 trace = serialize_trace(result_task_run.trace) 550 551 if ( 552 parent_eval 553 and parent_eval.evaluation_data_type == EvalDataType.reference_answer 554 ): 555 reference_answer = job.item.output.output 556 557 async with self._save_context(): 558 eval_run = EvalRun( 559 parent=job.eval_config, 560 task_run_config_id=job.task_run_config.id 561 if job.task_run_config 562 else None, 563 dataset_id=job.item.id, 564 eval_config_eval=job.type == "eval_config_eval", 565 scores=scores, 566 input=job.item.input, 567 output=task_output, 568 reference_answer=reference_answer, 569 intermediate_outputs=intermediate_outputs, 570 task_run_trace=trace, 571 task_run_usage=task_run_usage, 572 ) 573 eval_run.save_to_file() 574 575 return True 576 577 async def _run_v2_job(self, job: EvalJob) -> bool: 578 from kiln_ai.adapters.eval.registry import v2_eval_adapter_from_config 579 580 try: 581 rc_props = ( 582 job.task_run_config.run_config_properties 583 if job.task_run_config 584 else None 585 ) 586 evaluator = v2_eval_adapter_from_config( 587 job.eval_config, rc_props, self._skills 588 ) 589 except NotImplementedError: 590 return await self._persist_skip( 591 job, 592 SkippedReason.type_not_available, 593 "V2 eval type not yet implemented", 594 ) 595 596 if ( 597 isinstance(job.item, EvalInput) 598 and isinstance(job.item.data, MultiTurnSyntheticEvalInputData) 599 and job.type == "task_run_eval" 600 ): 601 # Multi-turn synthetic input: re-drive the conversation fresh 602 # for this run config, then judge the new trace. The job.type 603 # guard is defensive — collect_tasks never pairs eval_config_eval 604 # with EvalInput items, because judge calibration is scoped by the 605 # golden filter and golden filters only address TaskRuns. There is 606 # no runtime handler for a hand-built job of that shape. 607 seed = ( 608 job.item.data.first_message.text if job.item.data.first_message else "" 609 ) 610 return await self._run_v2_multi_turn_synthetic_job( 611 job, evaluator, job.item, job.item.data, seed 612 ) 613 614 # Both skips come before `_resolve_trace`, so a job that can never be scored 615 # never pays for a generation. 616 if ( 617 isinstance(job.item, TaskRun) 618 and job.item.parent_task_run_id is not None 619 and job.type == "task_run_eval" 620 ): 621 return await self._persist_skip( 622 job, 623 SkippedReason.incompatible_input_shape, 624 "Stored multi-turn conversations can't be re-run for a run config", 625 ) 626 627 trace = await self._resolve_trace(job, evaluator) 628 eval_task_input = EvalTaskInput.from_trace(trace, job.item) 629 result = await evaluator.evaluate(eval_task_input) 630 return await self._persist_judgment(job, trace, result) 631 632 async def _resolve_trace( 633 self, job: EvalJob, evaluator: BaseV2EvalBridge 634 ) -> TaskRun: 635 """The TaskRun this job scores, generating it only if the task has none.""" 636 golden = _calibration_item(job) 637 if golden is not None: 638 return golden 639 640 if job.task_run_config is None: 641 raise ValueError("A task_run_eval job requires a run config") 642 643 key = trace_key(item_key(job.item), job.task_run_config.id) 644 # `TraceIndex` logs both outcomes itself — debug on reuse, info on generation 645 # (architecture 8) — so the was_generated bool has no reader here. 646 trace, _ = await self._trace_index.get_or_create( 647 key, lambda: self._generate_and_persist(job, evaluator, key) 648 ) 649 return trace 650 651 async def _generate_and_persist( 652 self, job: EvalJob, evaluator: BaseV2EvalBridge, key: TraceKey 653 ) -> TaskRun: 654 """Run the task for this job's item, and make the result durable before scoring. 655 656 Stamped from `key` rather than re-derived from the job, so the run files itself 657 under exactly the key the index filed it under. A run that disagrees is never 658 found again, and the eval regenerates it on every future run. 659 """ 660 source_type, source_id, run_config_id = key 661 trace = await evaluator.run_task(job.item, run_config_id=run_config_id) 662 if trace.id is None: 663 # `run_task` builds its adapter with allow_saving=False, and every adapter 664 # clears the id of a run it did not persist (base_adapter.py:346). The runner 665 # is the one persisting here, so it mints the id — the same thing 666 # data_gen_api.py:474 does with an unsaved adapter run. 667 # 668 # Deliberately not allow_saving=True instead: the adapter would persist the 669 # run before `eval_source` is stamped on it, so a crash in that window would 670 # leave an eval trace permanently indistinguishable from a curated dataset 671 # row — the contamination Task.runs()' default-exclude exists to prevent. 672 trace.id = generate_model_id() 673 trace.eval_source = EvalItemSource(source_type=source_type, source_id=source_id) 674 async with self._save_context(): 675 trace.save_to_file() 676 return trace 677 678 async def _persist_score( 679 self, 680 job: EvalJob, 681 *, 682 scored_run_id: ID_TYPE = None, 683 scores: EvalScores | None = None, 684 skipped_reason: str | None = None, 685 skipped_detail: str | None = None, 686 intermediate_outputs: Dict[str, str] | None = None, 687 eval_usage: Usage | None = None, 688 ) -> bool: 689 """Write one V2 score record. 690 691 The single place the item-identity fields are filled in, because `collect_tasks` 692 dedupes on exactly those: a skip record and a score record that disagreed would 693 be two identities for one job. No inline trace field is ever set — they are 694 deprecated, and the trace lives on the TaskRun (functional spec 3.2). 695 """ 696 async with self._save_context(): 697 EvalRun( 698 parent=job.eval_config, 699 task_run_config_id=job.task_run_config.id 700 if job.task_run_config 701 else None, 702 dataset_id=job.item.id if isinstance(job.item, TaskRun) else None, 703 eval_input_id=job.item.id if isinstance(job.item, EvalInput) else None, 704 eval_config_eval=job.type == "eval_config_eval", 705 scored_run_id=scored_run_id, 706 scores=scores or {}, 707 skipped_reason=skipped_reason, 708 skipped_detail=skipped_detail, 709 intermediate_outputs=intermediate_outputs, 710 eval_usage=eval_usage, 711 ).save_to_file() 712 return True 713 714 async def _persist_skip( 715 self, job: EvalJob, reason: SkippedReason, detail: str 716 ) -> bool: 717 """A job skipped before it reached the judge. 718 719 Calibration still names its trace: the golden item *is* what would have been 720 scored, it is already on disk, and a skip that happens one step later — inside 721 `evaluate()` — records it (functional spec 4.6, row 2). Where the failure 722 happened shouldn't change the record's shape, and Phase 5 migrates old 723 calibration skips to exactly this. 724 725 A scoring job genuinely has nothing to point at. Generating a trace for a job 726 that can never be scored is the spend these early skips exist to avoid. 727 """ 728 golden = _calibration_item(job) 729 return await self._persist_score( 730 job, 731 scored_run_id=golden.id if golden is not None else None, 732 skipped_reason=reason.value, 733 skipped_detail=detail, 734 ) 735 736 async def _persist_judgment( 737 self, 738 job: EvalJob, 739 trace: TaskRun, 740 result: V2EvalResult, 741 ) -> bool: 742 """The score for one item, pointing at the trace it was computed over. 743 744 Also the home of a scoring-time skip: the trace exists and this judge could not 745 score it, so the record carries both `scored_run_id` and `skipped_reason` 746 (functional spec 4.6, row 2). 747 """ 748 return await self._persist_score( 749 job, 750 scored_run_id=trace.id, 751 scores=result.scores, 752 skipped_reason=result.skipped_reason.value 753 if result.skipped_reason 754 else None, 755 skipped_detail=result.skipped_detail, 756 intermediate_outputs=result.intermediate_outputs, 757 eval_usage=result.usage, 758 ) 759 760 async def _run_v2_multi_turn_synthetic_job( 761 self, 762 job: EvalJob, 763 evaluator: BaseV2EvalBridge, 764 eval_input: EvalInput, 765 data: MultiTurnSyntheticEvalInputData, 766 seed: str, 767 ) -> bool: 768 """task_run_eval over a multi-turn synthetic input. 769 770 The run config under evaluation drives the agent while the drive 771 config stamped on the item plays the synthetic user, so each run 772 config gets its own fresh conversation — the property that makes 773 run-config comparison meaningful for multi-turn. The conversation 774 persists as one standalone TaskRun through the trace index, exactly 775 like single-turn generations: durable before scoring, and reusable 776 by every other judge over the same item and run config. 777 """ 778 drive_config = data.drive_config 779 if drive_config is None: 780 return await self._persist_skip( 781 job, 782 SkippedReason.missing_drive_config, 783 "This item has no synthetic user configuration. " 784 "Create a new batch to replace it.", 785 ) 786 787 if not seed: 788 return await self._persist_skip( 789 job, 790 SkippedReason.incompatible_input_shape, 791 "Multi-turn synthetic input has no first_message to open the conversation", 792 ) 793 794 if job.task_run_config is None: 795 raise ValueError("Task run eval requires a run config") 796 try: 797 agent_run_config = as_kiln_agent_run_config( 798 job.task_run_config.run_config_properties 799 ) 800 except ValueError as e: 801 raise ValueError( 802 "Multi-turn re-drive requires a Kiln agent run config; " 803 f"run config '{job.task_run_config.name}' is a different type" 804 ) from e 805 try: 806 su_provider = ModelProviderName(drive_config.model_provider) 807 except ValueError as e: 808 raise ValueError( 809 "Invalid synthetic-user model provider on this item's " 810 f"drive config: {drive_config.model_provider}" 811 ) from e 812 813 key = trace_key(item_key(eval_input), job.task_run_config.id) 814 815 async def drive_and_persist() -> TaskRun: 816 # No app-level timeout on the re-drive: it terminates 817 # structurally (the turn ceiling, the adapter's tool-call cap, 818 # the model client's per-request timeout). This path runs as a 819 # background job, so the watchdog log is how a pathologically 820 # slow drive gets noticed. 821 async with log_if_slow(f"eval re-drive for eval item {eval_input.id}"): 822 drive_result = await drive_case_for_eval( 823 seed_prompt=seed, 824 synthetic_user_info=data.synthetic_user_info, 825 target_task=self.task, 826 target_run_config=agent_run_config, 827 su_driver_config=SyntheticUserDriverConfig( 828 model_name=drive_config.model_name, 829 model_provider_name=su_provider, 830 ), 831 turns=drive_config.turns, 832 skills=self._skills, 833 ) 834 turns_run = len(drive_result.chain) 835 ended_by_su = drive_result.ended_early(drive_config.turns) 836 if turns_run == 1 and drive_config.turns > 1: 837 # Not enforced: discarding a paid drive over a rule the synthetic 838 # user's prompt states but cannot guarantee would cost more than it 839 # saves. Logged because a one-turn conversation is a single-turn 840 # trace wearing a multi-turn label. 841 logger.warning( 842 "The driven conversation for eval item %s ended on its first " 843 "turn — the synthetic user ended it before the agent had " 844 "answered a second message", 845 eval_input.id, 846 ) 847 leaf_trace = drive_result.chain[-1].trace if drive_result.chain else None 848 # Gated on the turns that actually ran, not the item's ceiling: the drive 849 # is still in hand, so its own chain is the exact count the leaf's trace 850 # has to match. A conversation the synthetic user ended is shorter than 851 # the ceiling and still complete, while one whose trace lost turns the 852 # chain says it ran is a broken record either way. 853 problem = conversation_health_problem(leaf_trace, turns_run) 854 if problem is not None: 855 # Persisting it would index an incomplete conversation that every judge 856 # of this item reuses from then on, so it is a failed generation rather 857 # than a cheap result. Retryable, so the job re-drives. 858 raise RetryableError( 859 f"The driven conversation for eval item {eval_input.id} is " 860 f"incomplete ({problem}), so it was not saved." 861 ) 862 return await self._persist_driven_conversation( 863 key, drive_result, seed=seed, ended_by_su=ended_by_su 864 ) 865 866 # A raising drive persists nothing, so the job's retry re-drives; a 867 # successful one is on disk before the judge sees it, so a scoring 868 # failure re-scores without paying for the conversation again. 869 trace, _ = await self._trace_index.get_or_create(key, drive_and_persist) 870 871 eval_task_input = EvalTaskInput.from_trace(trace, eval_input) 872 result = await evaluator.evaluate(eval_task_input) 873 return await self._persist_judgment(job, trace, result) 874 875 async def _persist_driven_conversation( 876 self, 877 key: TraceKey, 878 drive_result: DriveCaseResult, 879 *, 880 seed: str, 881 ended_by_su: bool, 882 ) -> TaskRun: 883 """One standalone TaskRun holding a freshly driven multi-turn conversation. 884 885 The drive itself touches no disk, and its leaf's trace already holds the 886 whole conversation — so a single childless run is the entire durable 887 record. Persisting the chain's ancestors would store partial copies of 888 the same conversation, and a child run would be invisible to the trace 889 index's childless-only seed. 890 891 Stamped from `key` rather than re-derived, for the same reason as 892 `_generate_and_persist`: the run must file itself under exactly the key 893 the index filed it under, or it is never found again. 894 """ 895 source_type, source_id, run_config_id = key 896 leaf = drive_result.chain[-1] 897 if leaf.output.source is None: 898 # Fail before anything is saved: a run without an output source can't 899 # carry the run_config_id half of its reuse key, so persisting it would 900 # strand a paid conversation on disk that the index can never serve. 901 raise ValueError( 902 "Driven conversation has no output source; cannot stamp its reuse key" 903 ) 904 # The drive's adapter runs detached from any TaskRunConfig, so the leaf's 905 # output source has no run_config_id; the index requires it as half the key. 906 output_source = leaf.output.source.model_copy( 907 update={"run_config_id": run_config_id} 908 ) 909 run = TaskRun( 910 parent=self.task, 911 # The seed, not the leaf's own input (the synthetic user's LAST message): 912 # this run stands alone, so its input is what opened the conversation. 913 input=seed, 914 input_source=leaf.input_source, 915 output=leaf.output.model_copy(update={"source": output_source}), 916 trace=leaf.trace, 917 usage=_conversation_usage(drive_result.chain), 918 # The synthetic-user driver's spend rides its own field so `usage` 919 # stays honestly assistant-only. The whole Usage, not a cost-only 920 # stub: the SU is usually a different model on a different provider 921 # from the agent, so its tokens are the half that makes the figure 922 # reconcilable. `drive_case` already returns None for a drive whose 923 # provider reported nothing. 924 synthetic_user_usage=drive_result.su_usage, 925 cumulative_usage=leaf.cumulative_usage 926 or MessageUsage.from_trace(leaf.trace), 927 eval_source=EvalItemSource(source_type=source_type, source_id=source_id), 928 tags=[TAG_SU_ENDED_CONVERSATION] if ended_by_su else [], 929 ) 930 # The drive runs with allow_saving=False, so nothing touched disk before 931 # this fully-stamped run — no crash window in which a driven conversation 932 # could persist without eval_source and pass for a curated dataset row. 933 async with self._save_context(): 934 run.save_to_file() 935 return run 936 937 938def _conversation_usage(chain: list[TaskRun]) -> Usage | None: 939 """Conversation-total assistant spend for one driven multi-turn conversation. 940 941 The leaf's own `usage` covers only its final turn, so tokens and cost come 942 from the cumulative recompute over the full trace, and latency sums each 943 turn's in-flight accumulator. None when no turn reported anything — an 944 all-None Usage would read as a report where there was none. 945 """ 946 leaf = chain[-1] 947 totals = leaf.cumulative_usage or MessageUsage.from_trace(leaf.trace) 948 latencies = [ 949 run.usage.total_llm_latency_ms 950 for run in chain 951 if run.usage is not None and run.usage.total_llm_latency_ms is not None 952 ] 953 usage = Usage( 954 input_tokens=totals.input_tokens, 955 output_tokens=totals.output_tokens, 956 total_tokens=totals.total_tokens, 957 cost=totals.cost, 958 cached_tokens=totals.cached_tokens, 959 total_llm_latency_ms=sum(latencies) if latencies else None, 960 ) 961 if all(v is None for v in usage.model_dump().values()): 962 return None 963 return usage
68@dataclass 69class EvalJob: 70 item: TaskRun | EvalInput 71 type: Literal["task_run_eval", "eval_config_eval"] 72 eval_config: EvalConfig 73 task_run_config: TaskRunConfig | None = None
107def conversation_health_problem( 108 trace: list[ChatCompletionMessageParam] | None, 109 required_turns: int, 110) -> str | None: 111 """Why `trace` is not a complete conversation of `required_turns` turns, or None when 112 it is complete. 113 114 Structural completeness only, never error-freeness: a conversation whose tool calls 115 failed is a legitimate thing to evaluate (judging how an agent handles errors is a 116 first-class eval), so error-bearing tool messages say nothing about health here. What 117 it does catch is a conversation that stopped short, which would otherwise be judged 118 as if the agent had simply finished. 119 """ 120 messages = trace or [] 121 user_turns = sum(1 for message in messages if message.get("role") == "user") 122 if user_turns != required_turns: 123 return f"expected {required_turns} user turns, found {user_turns}" 124 if not messages: 125 return "the conversation is empty" 126 last_role = messages[-1].get("role") 127 if last_role != "assistant": 128 return f"the conversation ends with a '{last_role}' message, not an assistant reply" 129 if not _has_text_content(messages[-1].get("content")): 130 return "the final assistant message has no text content" 131 return None
Why trace is not a complete conversation of required_turns turns, or None when
it is complete.
Structural completeness only, never error-freeness: a conversation whose tool calls failed is a legitimate thing to evaluate (judging how an agent handles errors is a first-class eval), so error-bearing tool messages say nothing about health here. What it does catch is a conversation that stopped short, which would otherwise be judged as if the agent had simply finished.
184def no_golden_set_message(eval: Eval) -> str: 185 """Why judge comparison can't run without a golden set. One wording, two raisers. 186 187 Shared with the API layer so the 4xx a user sees and the ValueError a library caller 188 sees say the same thing (functional spec 9, architecture 4.3). 189 """ 190 return ( 191 f"Eval '{eval.id}' has no golden set configured. Comparing judges scores them " 192 "against a set of human-rated dataset items, so the eval needs one before a " 193 "comparison can run." 194 )
Why judge comparison can't run without a golden set. One wording, two raisers.
Shared with the API layer so the 4xx a user sees and the ValueError a library caller sees say the same thing (functional spec 9, architecture 4.3).
197class EvalRunner: 198 """ 199 Runs an eval. Async execution is supported to make it faster when using remote/fast model providers. 200 201 Can run an eval in 2 modes: 202 1) eval_config_eval: evaluate an eval config (judge quality) using existing dataset 203 items. Scoped by the eval's golden filter, so its items are always TaskRuns. 204 2) task_run_eval: evaluate a range of task run configs, generating new run output. 205 Scoped by the `split` it is given, whose items may come from either store. 206 Multi-turn synthetic EvalInputs are re-driven as a full conversation per run 207 config using the drive config stamped on each item; stored multi-turn TaskRun 208 chains are skipped. 209 """ 210 211 def __init__( 212 self, 213 eval_configs: List[EvalConfig], 214 run_configs: List[TaskRunConfig] | None, 215 eval_run_type: Literal["eval_config_eval", "task_run_eval"], 216 split: ResolvedSplit | None = None, 217 save_context: SaveContext | None = None, 218 ): 219 if len(eval_configs) == 0: 220 raise ValueError("Eval runner requires at least one eval config") 221 target_eval = eval_configs[0].parent_eval() 222 if target_eval is None: 223 raise ValueError("Eval config requires a parent eval") 224 for eval_config in eval_configs: 225 parent_eval = eval_config.parent_eval() 226 if parent_eval is None: 227 raise ValueError("Eval config requires a parent eval") 228 if parent_eval.id != target_eval.id: 229 raise ValueError("All eval configs must have the same parent eval") 230 231 target_task = target_eval.parent_task() 232 if target_task is None: 233 raise ValueError("Eval config requires a (grand)parent task") 234 235 # Both modes settle *what defines* their item scope here rather than at collect 236 # time, but to different depths, and the difference is deliberate. task_run_eval 237 # takes a ResolvedSplit: the items themselves, fixed now, so a split cannot be 238 # accepted and then quietly re-resolved to something else. eval_config_eval takes 239 # the golden filter id and still applies it to self.task.runs() at collect time, 240 # so items added after construction are picked up — golden is TaskRun-only by 241 # definition, so there is no source ambiguity for that to hide. 242 self.golden_filter_id: DatasetFilterId | None = None 243 if eval_run_type == "task_run_eval": 244 if run_configs is None or len(run_configs) == 0: 245 raise ValueError("Task run eval requires run configs") 246 for run_config in run_configs: 247 parent_task = run_config.parent_task() 248 if parent_task is None: 249 raise ValueError("All run configs must have a parent task") 250 if parent_task.id != target_task.id: 251 raise ValueError( 252 "Run config is not for the same task as the eval configs" 253 ) 254 if split is None: 255 raise ValueError("Task run eval requires a resolved split") 256 if split.eval_id != target_eval.id: 257 raise ValueError( 258 f"Split '{split.name}' was resolved from eval '{split.eval_id}', not from " 259 f"eval '{target_eval.id}' whose configs are being run" 260 ) 261 else: 262 if run_configs is not None: 263 raise ValueError("Mode 'eval_config_eval' does not support run configs") 264 if split is not None: 265 raise ValueError( 266 "Mode 'eval_config_eval' does not support a split: it is scoped by the eval's golden filter" 267 ) 268 if target_eval.eval_configs_filter_id is None: 269 raise ValueError(no_golden_set_message(target_eval)) 270 self.golden_filter_id = target_eval.eval_configs_filter_id 271 272 self.eval_run_type = eval_run_type 273 self.eval_configs = eval_configs 274 self.run_configs = run_configs 275 self.split = split 276 self.task = target_task 277 self.eval = target_eval 278 self._skills: SkillsDict = self._preload_skills() 279 self._save_context: SaveContext = save_context or default_save_context 280 # Live, not precomputed like `already_run`: a trace persisted by one job has to be 281 # visible to the next, whether that next job is running concurrently under a 282 # different eval config or is this job's own retry (functional spec 4.2, 4.3). 283 self._trace_index = TraceIndex(self.task) 284 285 def collect_tasks(self) -> List[EvalJob]: 286 if self.eval_run_type == "eval_config_eval": 287 if self.golden_filter_id is None: 288 raise ValueError(no_golden_set_message(self.eval)) 289 return self.collect_tasks_for_eval_config_eval(self.golden_filter_id) 290 return self.collect_tasks_for_task_run_eval() 291 292 def collect_tasks_for_eval_config_eval( 293 self, eval_configs_filter_id: DatasetFilterId 294 ) -> List[EvalJob]: 295 """ 296 Collect all jobs for this run, excluding any that have already been run. 297 298 This variant is used for mode "eval_config_eval", using existing dataset run data (input/output). 299 300 The tasks: 301 - should be in the eval config set filter 302 - should not have already been run for this eval config + dataset item pair 303 """ 304 filter = dataset_filter_from_id(eval_configs_filter_id) 305 306 # already_run[eval_config_id][dataset_id] 307 already_run: Dict[ID_TYPE, Set[ID_TYPE]] = {} 308 for eval_config in self.eval_configs: 309 already_run[eval_config.id] = set() 310 for run in eval_config.runs(readonly=True): 311 # Only calibration records mark a golden item done: the same eval 312 # config also accumulates task_run_eval records, and a golden 313 # TaskRun that was scored as a test item must still be calibrated. 314 if not run.eval_config_eval: 315 continue 316 already_run[eval_config.id].add(run.dataset_id) 317 318 return [ 319 EvalJob( 320 item=task_run, 321 eval_config=eval_config, 322 type="eval_config_eval", 323 ) 324 for task_run in self.task.runs(readonly=True) 325 if filter(task_run) 326 for eval_config in self.eval_configs 327 if task_run.id not in already_run[eval_config.id] 328 ] 329 330 def collect_tasks_for_task_run_eval(self) -> List[EvalJob]: 331 """ 332 Collect all jobs for this run, excluding any that have already been run. 333 334 This variant is used for mode "task_run_eval", generating new run output using existing dataset item input. 335 336 The tasks: 337 - are the items of the split this runner was given, from whichever store backs it 338 - should not have already been run for this eval config + run config + dataset item 339 """ 340 if self.split is None: 341 raise ValueError("Task run eval requires a resolved split") 342 343 # already_run[eval_config_id][run_config_id][item_key] 344 already_run: Dict[ID_TYPE, Dict[ID_TYPE, Set[ItemKey]]] = {} 345 for eval_config in self.eval_configs: 346 already_run[eval_config.id] = { 347 run_config.id: set() for run_config in self.run_configs or [] 348 } 349 for run in eval_config.runs(readonly=True): 350 # Scopes the dedupe to the run configs actually being evaluated: an eval 351 # config accumulates runs for every run config ever compared against it. 352 # The `is not None` is not redundant with the membership test — ID_TYPE is 353 # `str | None`, so a run config whose file carries a null id would make 354 # None a real key and fold every calibration record into its set. 355 if ( 356 run.task_run_config_id is not None 357 and run.task_run_config_id in already_run[eval_config.id] 358 ): 359 already_run[eval_config.id][run.task_run_config_id].add( 360 eval_run_item_key(run) 361 ) 362 363 return [ 364 EvalJob( 365 item=item, 366 task_run_config=run_config, 367 type="task_run_eval", 368 eval_config=eval_config, 369 ) 370 for item in self.split.items 371 for eval_config in self.eval_configs 372 for run_config in self.run_configs or [] 373 if (self.split.source, item.id) 374 not in already_run[eval_config.id][run_config.id] 375 ] 376 377 def validate_multi_turn_drive_readiness( 378 self, check_run_configs: bool = True 379 ) -> None: 380 """Fail fast on config problems every multi-turn re-drive job would 381 hit: no item carrying a synthetic-user drive config, a stamped 382 config with an unknown model provider, run configs that aren't 383 Kiln agent configs, or run configs that answer in two messages per 384 turn. Callers can invoke this before starting a batch 385 so the user gets one clear error up front instead of one opaque 386 error per job. The per-job checks stay as the backstop for 387 standalone runner use. 388 389 A partially stamped split is NOT an error here: the stamped items 390 can still run, and each unstamped item records a per-item skip 391 naming the fix. Only an entirely unstamped split fails up front, 392 because nothing could run and one clear error beats a page of 393 identical skips. 394 395 `check_run_configs=False` limits validation to the drive configs — 396 for callers running a fleet the user didn't hand-pick, where one 397 incompatible run config shouldn't block every other config's jobs. 398 399 Raises ValueError listing every problem; no-op unless re-drive jobs 400 can actually occur (task_run_eval over a split containing multi-turn 401 synthetic items — stored-TaskRun sources never re-drive, and a 402 single-turn split has nothing to drive). 403 """ 404 if self.eval_run_type != "task_run_eval" or self.split is None: 405 return 406 multi_turn_items = [ 407 item.data 408 for item in self.split.items 409 if isinstance(item, EvalInput) 410 and isinstance(item.data, MultiTurnSyntheticEvalInputData) 411 ] 412 if not multi_turn_items: 413 return 414 problems: list[str] = [] 415 stamped_configs = [ 416 data.drive_config 417 for data in multi_turn_items 418 if data.drive_config is not None 419 ] 420 if not stamped_configs: 421 problems.append( 422 "none of this eval's multi-turn items has a synthetic user " 423 "configuration (create a new batch to mint items that carry one)" 424 ) 425 for provider in sorted({cfg.model_provider for cfg in stamped_configs}): 426 try: 427 ModelProviderName(provider) 428 except ValueError: 429 problems.append( 430 "a multi-turn item's synthetic-user drive config has " 431 f"unknown model provider '{provider}'" 432 ) 433 if check_run_configs: 434 for run_config in self.run_configs or []: 435 try: 436 agent_properties = as_kiln_agent_run_config( 437 run_config.run_config_properties 438 ) 439 except ValueError: 440 problems.append( 441 f"run config '{run_config.name}' is not a Kiln agent " 442 "config, so it can't hold a multi-turn conversation" 443 ) 444 continue 445 if _splits_a_turn_into_two_messages(agent_properties, self.task): 446 problems.append( 447 f"run config '{run_config.name}' uses a chain of thought " 448 "prompt with a model that has no reasoning step of its own, " 449 "so it answers in two messages per turn and can't hold a " 450 "multi-turn conversation (pick a reasoning-capable model, or " 451 "a prompt without thinking instructions)" 452 ) 453 if problems: 454 raise ValueError( 455 "Cannot re-drive this eval's multi-turn conversations: " 456 + "; ".join(problems) 457 + "." 458 ) 459 460 def _preload_skills(self) -> SkillsDict: 461 """Collect all skill IDs from run configs and bulk-load them once.""" 462 if self.run_configs is None: 463 return {} 464 merged: SkillsDict = {} 465 for rc in self.run_configs: 466 skills = load_skills_for_task(self.task, rc.run_config_properties) 467 merged.update(skills) 468 return merged 469 470 async def run(self, concurrency: int = 25) -> AsyncGenerator[Progress, None]: 471 """ 472 Runs the configured eval run with parallel workers and yields progress updates. 473 """ 474 jobs = self.collect_tasks() 475 476 runner = AsyncJobRunner( 477 concurrency=concurrency, 478 jobs=jobs, 479 run_job_fn=self.run_job, 480 max_retries=2, 481 ) 482 async for progress in runner.run(): 483 yield progress 484 485 async def run_job(self, job: EvalJob) -> bool: 486 try: 487 if job.eval_config.config_type == EvalConfigType.v2: 488 return await self._run_v2_job(job) 489 else: 490 return await self._run_legacy_job(job) 491 except RetryableError as e: 492 # Already classified by whoever raised it: this is a deliberate, expected 493 # ask for another attempt (an unusable generation the retry replaces), not a 494 # failure. Re-raised untouched, and logged without a stacktrace so a 495 # self-healing event doesn't read as a crash in the logs. 496 logger.warning(f"Retrying eval job for dataset item {job.item.id}: {e}") 497 raise 498 except Exception as e: 499 if is_retryable_error(e): 500 logger.error( 501 f"Transient error running eval job for dataset item {job.item.id}: {e}", 502 exc_info=True, 503 ) 504 # KilnRunError's own message is genericized user-facing text; keep 505 # the underlying provider detail for the developer-facing error log. 506 raise RetryableError(str(unwrap_kiln_run_error(e))) from e 507 logger.error( 508 f"Error running eval job for dataset item {job.item.id}: {e}", 509 exc_info=True, 510 ) 511 raise 512 513 async def _run_legacy_job(self, job: EvalJob) -> bool: 514 if not isinstance(job.item, TaskRun): 515 raise ValueError("Legacy eval jobs require a TaskRun item") 516 517 evaluator = legacy_eval_adapter_from_type(job.eval_config)( 518 job.eval_config, 519 job.task_run_config.run_config_properties if job.task_run_config else None, 520 skills=self._skills, 521 ) 522 if not isinstance(evaluator, BaseEval): 523 raise ValueError("Not able to create evaluator from eval config") 524 525 task_output: str | None = None 526 reference_answer: str | None = None 527 trace: str | None = None 528 scores: EvalScores | None = None 529 intermediate_outputs: Dict[str, str] | None = None 530 task_run_usage: Usage | None = None 531 if job.type == "eval_config_eval": 532 scores, intermediate_outputs = await evaluator.run_eval(job.item) 533 task_output = job.item.output.output 534 task_run_usage = job.item.usage 535 else: 536 ( 537 result_task_run, 538 scores, 539 intermediate_outputs, 540 ) = await evaluator.run_task_and_eval(job.item) 541 task_output = result_task_run.output.output 542 task_run_usage = result_task_run.usage 543 544 parent_eval = job.eval_config.parent_eval() 545 if ( 546 parent_eval 547 and parent_eval.evaluation_data_type == EvalDataType.full_trace 548 and result_task_run.trace 549 ): 550 trace = serialize_trace(result_task_run.trace) 551 552 if ( 553 parent_eval 554 and parent_eval.evaluation_data_type == EvalDataType.reference_answer 555 ): 556 reference_answer = job.item.output.output 557 558 async with self._save_context(): 559 eval_run = EvalRun( 560 parent=job.eval_config, 561 task_run_config_id=job.task_run_config.id 562 if job.task_run_config 563 else None, 564 dataset_id=job.item.id, 565 eval_config_eval=job.type == "eval_config_eval", 566 scores=scores, 567 input=job.item.input, 568 output=task_output, 569 reference_answer=reference_answer, 570 intermediate_outputs=intermediate_outputs, 571 task_run_trace=trace, 572 task_run_usage=task_run_usage, 573 ) 574 eval_run.save_to_file() 575 576 return True 577 578 async def _run_v2_job(self, job: EvalJob) -> bool: 579 from kiln_ai.adapters.eval.registry import v2_eval_adapter_from_config 580 581 try: 582 rc_props = ( 583 job.task_run_config.run_config_properties 584 if job.task_run_config 585 else None 586 ) 587 evaluator = v2_eval_adapter_from_config( 588 job.eval_config, rc_props, self._skills 589 ) 590 except NotImplementedError: 591 return await self._persist_skip( 592 job, 593 SkippedReason.type_not_available, 594 "V2 eval type not yet implemented", 595 ) 596 597 if ( 598 isinstance(job.item, EvalInput) 599 and isinstance(job.item.data, MultiTurnSyntheticEvalInputData) 600 and job.type == "task_run_eval" 601 ): 602 # Multi-turn synthetic input: re-drive the conversation fresh 603 # for this run config, then judge the new trace. The job.type 604 # guard is defensive — collect_tasks never pairs eval_config_eval 605 # with EvalInput items, because judge calibration is scoped by the 606 # golden filter and golden filters only address TaskRuns. There is 607 # no runtime handler for a hand-built job of that shape. 608 seed = ( 609 job.item.data.first_message.text if job.item.data.first_message else "" 610 ) 611 return await self._run_v2_multi_turn_synthetic_job( 612 job, evaluator, job.item, job.item.data, seed 613 ) 614 615 # Both skips come before `_resolve_trace`, so a job that can never be scored 616 # never pays for a generation. 617 if ( 618 isinstance(job.item, TaskRun) 619 and job.item.parent_task_run_id is not None 620 and job.type == "task_run_eval" 621 ): 622 return await self._persist_skip( 623 job, 624 SkippedReason.incompatible_input_shape, 625 "Stored multi-turn conversations can't be re-run for a run config", 626 ) 627 628 trace = await self._resolve_trace(job, evaluator) 629 eval_task_input = EvalTaskInput.from_trace(trace, job.item) 630 result = await evaluator.evaluate(eval_task_input) 631 return await self._persist_judgment(job, trace, result) 632 633 async def _resolve_trace( 634 self, job: EvalJob, evaluator: BaseV2EvalBridge 635 ) -> TaskRun: 636 """The TaskRun this job scores, generating it only if the task has none.""" 637 golden = _calibration_item(job) 638 if golden is not None: 639 return golden 640 641 if job.task_run_config is None: 642 raise ValueError("A task_run_eval job requires a run config") 643 644 key = trace_key(item_key(job.item), job.task_run_config.id) 645 # `TraceIndex` logs both outcomes itself — debug on reuse, info on generation 646 # (architecture 8) — so the was_generated bool has no reader here. 647 trace, _ = await self._trace_index.get_or_create( 648 key, lambda: self._generate_and_persist(job, evaluator, key) 649 ) 650 return trace 651 652 async def _generate_and_persist( 653 self, job: EvalJob, evaluator: BaseV2EvalBridge, key: TraceKey 654 ) -> TaskRun: 655 """Run the task for this job's item, and make the result durable before scoring. 656 657 Stamped from `key` rather than re-derived from the job, so the run files itself 658 under exactly the key the index filed it under. A run that disagrees is never 659 found again, and the eval regenerates it on every future run. 660 """ 661 source_type, source_id, run_config_id = key 662 trace = await evaluator.run_task(job.item, run_config_id=run_config_id) 663 if trace.id is None: 664 # `run_task` builds its adapter with allow_saving=False, and every adapter 665 # clears the id of a run it did not persist (base_adapter.py:346). The runner 666 # is the one persisting here, so it mints the id — the same thing 667 # data_gen_api.py:474 does with an unsaved adapter run. 668 # 669 # Deliberately not allow_saving=True instead: the adapter would persist the 670 # run before `eval_source` is stamped on it, so a crash in that window would 671 # leave an eval trace permanently indistinguishable from a curated dataset 672 # row — the contamination Task.runs()' default-exclude exists to prevent. 673 trace.id = generate_model_id() 674 trace.eval_source = EvalItemSource(source_type=source_type, source_id=source_id) 675 async with self._save_context(): 676 trace.save_to_file() 677 return trace 678 679 async def _persist_score( 680 self, 681 job: EvalJob, 682 *, 683 scored_run_id: ID_TYPE = None, 684 scores: EvalScores | None = None, 685 skipped_reason: str | None = None, 686 skipped_detail: str | None = None, 687 intermediate_outputs: Dict[str, str] | None = None, 688 eval_usage: Usage | None = None, 689 ) -> bool: 690 """Write one V2 score record. 691 692 The single place the item-identity fields are filled in, because `collect_tasks` 693 dedupes on exactly those: a skip record and a score record that disagreed would 694 be two identities for one job. No inline trace field is ever set — they are 695 deprecated, and the trace lives on the TaskRun (functional spec 3.2). 696 """ 697 async with self._save_context(): 698 EvalRun( 699 parent=job.eval_config, 700 task_run_config_id=job.task_run_config.id 701 if job.task_run_config 702 else None, 703 dataset_id=job.item.id if isinstance(job.item, TaskRun) else None, 704 eval_input_id=job.item.id if isinstance(job.item, EvalInput) else None, 705 eval_config_eval=job.type == "eval_config_eval", 706 scored_run_id=scored_run_id, 707 scores=scores or {}, 708 skipped_reason=skipped_reason, 709 skipped_detail=skipped_detail, 710 intermediate_outputs=intermediate_outputs, 711 eval_usage=eval_usage, 712 ).save_to_file() 713 return True 714 715 async def _persist_skip( 716 self, job: EvalJob, reason: SkippedReason, detail: str 717 ) -> bool: 718 """A job skipped before it reached the judge. 719 720 Calibration still names its trace: the golden item *is* what would have been 721 scored, it is already on disk, and a skip that happens one step later — inside 722 `evaluate()` — records it (functional spec 4.6, row 2). Where the failure 723 happened shouldn't change the record's shape, and Phase 5 migrates old 724 calibration skips to exactly this. 725 726 A scoring job genuinely has nothing to point at. Generating a trace for a job 727 that can never be scored is the spend these early skips exist to avoid. 728 """ 729 golden = _calibration_item(job) 730 return await self._persist_score( 731 job, 732 scored_run_id=golden.id if golden is not None else None, 733 skipped_reason=reason.value, 734 skipped_detail=detail, 735 ) 736 737 async def _persist_judgment( 738 self, 739 job: EvalJob, 740 trace: TaskRun, 741 result: V2EvalResult, 742 ) -> bool: 743 """The score for one item, pointing at the trace it was computed over. 744 745 Also the home of a scoring-time skip: the trace exists and this judge could not 746 score it, so the record carries both `scored_run_id` and `skipped_reason` 747 (functional spec 4.6, row 2). 748 """ 749 return await self._persist_score( 750 job, 751 scored_run_id=trace.id, 752 scores=result.scores, 753 skipped_reason=result.skipped_reason.value 754 if result.skipped_reason 755 else None, 756 skipped_detail=result.skipped_detail, 757 intermediate_outputs=result.intermediate_outputs, 758 eval_usage=result.usage, 759 ) 760 761 async def _run_v2_multi_turn_synthetic_job( 762 self, 763 job: EvalJob, 764 evaluator: BaseV2EvalBridge, 765 eval_input: EvalInput, 766 data: MultiTurnSyntheticEvalInputData, 767 seed: str, 768 ) -> bool: 769 """task_run_eval over a multi-turn synthetic input. 770 771 The run config under evaluation drives the agent while the drive 772 config stamped on the item plays the synthetic user, so each run 773 config gets its own fresh conversation — the property that makes 774 run-config comparison meaningful for multi-turn. The conversation 775 persists as one standalone TaskRun through the trace index, exactly 776 like single-turn generations: durable before scoring, and reusable 777 by every other judge over the same item and run config. 778 """ 779 drive_config = data.drive_config 780 if drive_config is None: 781 return await self._persist_skip( 782 job, 783 SkippedReason.missing_drive_config, 784 "This item has no synthetic user configuration. " 785 "Create a new batch to replace it.", 786 ) 787 788 if not seed: 789 return await self._persist_skip( 790 job, 791 SkippedReason.incompatible_input_shape, 792 "Multi-turn synthetic input has no first_message to open the conversation", 793 ) 794 795 if job.task_run_config is None: 796 raise ValueError("Task run eval requires a run config") 797 try: 798 agent_run_config = as_kiln_agent_run_config( 799 job.task_run_config.run_config_properties 800 ) 801 except ValueError as e: 802 raise ValueError( 803 "Multi-turn re-drive requires a Kiln agent run config; " 804 f"run config '{job.task_run_config.name}' is a different type" 805 ) from e 806 try: 807 su_provider = ModelProviderName(drive_config.model_provider) 808 except ValueError as e: 809 raise ValueError( 810 "Invalid synthetic-user model provider on this item's " 811 f"drive config: {drive_config.model_provider}" 812 ) from e 813 814 key = trace_key(item_key(eval_input), job.task_run_config.id) 815 816 async def drive_and_persist() -> TaskRun: 817 # No app-level timeout on the re-drive: it terminates 818 # structurally (the turn ceiling, the adapter's tool-call cap, 819 # the model client's per-request timeout). This path runs as a 820 # background job, so the watchdog log is how a pathologically 821 # slow drive gets noticed. 822 async with log_if_slow(f"eval re-drive for eval item {eval_input.id}"): 823 drive_result = await drive_case_for_eval( 824 seed_prompt=seed, 825 synthetic_user_info=data.synthetic_user_info, 826 target_task=self.task, 827 target_run_config=agent_run_config, 828 su_driver_config=SyntheticUserDriverConfig( 829 model_name=drive_config.model_name, 830 model_provider_name=su_provider, 831 ), 832 turns=drive_config.turns, 833 skills=self._skills, 834 ) 835 turns_run = len(drive_result.chain) 836 ended_by_su = drive_result.ended_early(drive_config.turns) 837 if turns_run == 1 and drive_config.turns > 1: 838 # Not enforced: discarding a paid drive over a rule the synthetic 839 # user's prompt states but cannot guarantee would cost more than it 840 # saves. Logged because a one-turn conversation is a single-turn 841 # trace wearing a multi-turn label. 842 logger.warning( 843 "The driven conversation for eval item %s ended on its first " 844 "turn — the synthetic user ended it before the agent had " 845 "answered a second message", 846 eval_input.id, 847 ) 848 leaf_trace = drive_result.chain[-1].trace if drive_result.chain else None 849 # Gated on the turns that actually ran, not the item's ceiling: the drive 850 # is still in hand, so its own chain is the exact count the leaf's trace 851 # has to match. A conversation the synthetic user ended is shorter than 852 # the ceiling and still complete, while one whose trace lost turns the 853 # chain says it ran is a broken record either way. 854 problem = conversation_health_problem(leaf_trace, turns_run) 855 if problem is not None: 856 # Persisting it would index an incomplete conversation that every judge 857 # of this item reuses from then on, so it is a failed generation rather 858 # than a cheap result. Retryable, so the job re-drives. 859 raise RetryableError( 860 f"The driven conversation for eval item {eval_input.id} is " 861 f"incomplete ({problem}), so it was not saved." 862 ) 863 return await self._persist_driven_conversation( 864 key, drive_result, seed=seed, ended_by_su=ended_by_su 865 ) 866 867 # A raising drive persists nothing, so the job's retry re-drives; a 868 # successful one is on disk before the judge sees it, so a scoring 869 # failure re-scores without paying for the conversation again. 870 trace, _ = await self._trace_index.get_or_create(key, drive_and_persist) 871 872 eval_task_input = EvalTaskInput.from_trace(trace, eval_input) 873 result = await evaluator.evaluate(eval_task_input) 874 return await self._persist_judgment(job, trace, result) 875 876 async def _persist_driven_conversation( 877 self, 878 key: TraceKey, 879 drive_result: DriveCaseResult, 880 *, 881 seed: str, 882 ended_by_su: bool, 883 ) -> TaskRun: 884 """One standalone TaskRun holding a freshly driven multi-turn conversation. 885 886 The drive itself touches no disk, and its leaf's trace already holds the 887 whole conversation — so a single childless run is the entire durable 888 record. Persisting the chain's ancestors would store partial copies of 889 the same conversation, and a child run would be invisible to the trace 890 index's childless-only seed. 891 892 Stamped from `key` rather than re-derived, for the same reason as 893 `_generate_and_persist`: the run must file itself under exactly the key 894 the index filed it under, or it is never found again. 895 """ 896 source_type, source_id, run_config_id = key 897 leaf = drive_result.chain[-1] 898 if leaf.output.source is None: 899 # Fail before anything is saved: a run without an output source can't 900 # carry the run_config_id half of its reuse key, so persisting it would 901 # strand a paid conversation on disk that the index can never serve. 902 raise ValueError( 903 "Driven conversation has no output source; cannot stamp its reuse key" 904 ) 905 # The drive's adapter runs detached from any TaskRunConfig, so the leaf's 906 # output source has no run_config_id; the index requires it as half the key. 907 output_source = leaf.output.source.model_copy( 908 update={"run_config_id": run_config_id} 909 ) 910 run = TaskRun( 911 parent=self.task, 912 # The seed, not the leaf's own input (the synthetic user's LAST message): 913 # this run stands alone, so its input is what opened the conversation. 914 input=seed, 915 input_source=leaf.input_source, 916 output=leaf.output.model_copy(update={"source": output_source}), 917 trace=leaf.trace, 918 usage=_conversation_usage(drive_result.chain), 919 # The synthetic-user driver's spend rides its own field so `usage` 920 # stays honestly assistant-only. The whole Usage, not a cost-only 921 # stub: the SU is usually a different model on a different provider 922 # from the agent, so its tokens are the half that makes the figure 923 # reconcilable. `drive_case` already returns None for a drive whose 924 # provider reported nothing. 925 synthetic_user_usage=drive_result.su_usage, 926 cumulative_usage=leaf.cumulative_usage 927 or MessageUsage.from_trace(leaf.trace), 928 eval_source=EvalItemSource(source_type=source_type, source_id=source_id), 929 tags=[TAG_SU_ENDED_CONVERSATION] if ended_by_su else [], 930 ) 931 # The drive runs with allow_saving=False, so nothing touched disk before 932 # this fully-stamped run — no crash window in which a driven conversation 933 # could persist without eval_source and pass for a curated dataset row. 934 async with self._save_context(): 935 run.save_to_file() 936 return run
Runs an eval. Async execution is supported to make it faster when using remote/fast model providers.
Can run an eval in 2 modes:
1) eval_config_eval: evaluate an eval config (judge quality) using existing dataset
items. Scoped by the eval's golden filter, so its items are always TaskRuns.
2) task_run_eval: evaluate a range of task run configs, generating new run output.
Scoped by the split it is given, whose items may come from either store.
Multi-turn synthetic EvalInputs are re-driven as a full conversation per run
config using the drive config stamped on each item; stored multi-turn TaskRun
chains are skipped.
211 def __init__( 212 self, 213 eval_configs: List[EvalConfig], 214 run_configs: List[TaskRunConfig] | None, 215 eval_run_type: Literal["eval_config_eval", "task_run_eval"], 216 split: ResolvedSplit | None = None, 217 save_context: SaveContext | None = None, 218 ): 219 if len(eval_configs) == 0: 220 raise ValueError("Eval runner requires at least one eval config") 221 target_eval = eval_configs[0].parent_eval() 222 if target_eval is None: 223 raise ValueError("Eval config requires a parent eval") 224 for eval_config in eval_configs: 225 parent_eval = eval_config.parent_eval() 226 if parent_eval is None: 227 raise ValueError("Eval config requires a parent eval") 228 if parent_eval.id != target_eval.id: 229 raise ValueError("All eval configs must have the same parent eval") 230 231 target_task = target_eval.parent_task() 232 if target_task is None: 233 raise ValueError("Eval config requires a (grand)parent task") 234 235 # Both modes settle *what defines* their item scope here rather than at collect 236 # time, but to different depths, and the difference is deliberate. task_run_eval 237 # takes a ResolvedSplit: the items themselves, fixed now, so a split cannot be 238 # accepted and then quietly re-resolved to something else. eval_config_eval takes 239 # the golden filter id and still applies it to self.task.runs() at collect time, 240 # so items added after construction are picked up — golden is TaskRun-only by 241 # definition, so there is no source ambiguity for that to hide. 242 self.golden_filter_id: DatasetFilterId | None = None 243 if eval_run_type == "task_run_eval": 244 if run_configs is None or len(run_configs) == 0: 245 raise ValueError("Task run eval requires run configs") 246 for run_config in run_configs: 247 parent_task = run_config.parent_task() 248 if parent_task is None: 249 raise ValueError("All run configs must have a parent task") 250 if parent_task.id != target_task.id: 251 raise ValueError( 252 "Run config is not for the same task as the eval configs" 253 ) 254 if split is None: 255 raise ValueError("Task run eval requires a resolved split") 256 if split.eval_id != target_eval.id: 257 raise ValueError( 258 f"Split '{split.name}' was resolved from eval '{split.eval_id}', not from " 259 f"eval '{target_eval.id}' whose configs are being run" 260 ) 261 else: 262 if run_configs is not None: 263 raise ValueError("Mode 'eval_config_eval' does not support run configs") 264 if split is not None: 265 raise ValueError( 266 "Mode 'eval_config_eval' does not support a split: it is scoped by the eval's golden filter" 267 ) 268 if target_eval.eval_configs_filter_id is None: 269 raise ValueError(no_golden_set_message(target_eval)) 270 self.golden_filter_id = target_eval.eval_configs_filter_id 271 272 self.eval_run_type = eval_run_type 273 self.eval_configs = eval_configs 274 self.run_configs = run_configs 275 self.split = split 276 self.task = target_task 277 self.eval = target_eval 278 self._skills: SkillsDict = self._preload_skills() 279 self._save_context: SaveContext = save_context or default_save_context 280 # Live, not precomputed like `already_run`: a trace persisted by one job has to be 281 # visible to the next, whether that next job is running concurrently under a 282 # different eval config or is this job's own retry (functional spec 4.2, 4.3). 283 self._trace_index = TraceIndex(self.task)
285 def collect_tasks(self) -> List[EvalJob]: 286 if self.eval_run_type == "eval_config_eval": 287 if self.golden_filter_id is None: 288 raise ValueError(no_golden_set_message(self.eval)) 289 return self.collect_tasks_for_eval_config_eval(self.golden_filter_id) 290 return self.collect_tasks_for_task_run_eval()
292 def collect_tasks_for_eval_config_eval( 293 self, eval_configs_filter_id: DatasetFilterId 294 ) -> List[EvalJob]: 295 """ 296 Collect all jobs for this run, excluding any that have already been run. 297 298 This variant is used for mode "eval_config_eval", using existing dataset run data (input/output). 299 300 The tasks: 301 - should be in the eval config set filter 302 - should not have already been run for this eval config + dataset item pair 303 """ 304 filter = dataset_filter_from_id(eval_configs_filter_id) 305 306 # already_run[eval_config_id][dataset_id] 307 already_run: Dict[ID_TYPE, Set[ID_TYPE]] = {} 308 for eval_config in self.eval_configs: 309 already_run[eval_config.id] = set() 310 for run in eval_config.runs(readonly=True): 311 # Only calibration records mark a golden item done: the same eval 312 # config also accumulates task_run_eval records, and a golden 313 # TaskRun that was scored as a test item must still be calibrated. 314 if not run.eval_config_eval: 315 continue 316 already_run[eval_config.id].add(run.dataset_id) 317 318 return [ 319 EvalJob( 320 item=task_run, 321 eval_config=eval_config, 322 type="eval_config_eval", 323 ) 324 for task_run in self.task.runs(readonly=True) 325 if filter(task_run) 326 for eval_config in self.eval_configs 327 if task_run.id not in already_run[eval_config.id] 328 ]
Collect all jobs for this run, excluding any that have already been run.
This variant is used for mode "eval_config_eval", using existing dataset run data (input/output).
The tasks:
- should be in the eval config set filter
- should not have already been run for this eval config + dataset item pair
330 def collect_tasks_for_task_run_eval(self) -> List[EvalJob]: 331 """ 332 Collect all jobs for this run, excluding any that have already been run. 333 334 This variant is used for mode "task_run_eval", generating new run output using existing dataset item input. 335 336 The tasks: 337 - are the items of the split this runner was given, from whichever store backs it 338 - should not have already been run for this eval config + run config + dataset item 339 """ 340 if self.split is None: 341 raise ValueError("Task run eval requires a resolved split") 342 343 # already_run[eval_config_id][run_config_id][item_key] 344 already_run: Dict[ID_TYPE, Dict[ID_TYPE, Set[ItemKey]]] = {} 345 for eval_config in self.eval_configs: 346 already_run[eval_config.id] = { 347 run_config.id: set() for run_config in self.run_configs or [] 348 } 349 for run in eval_config.runs(readonly=True): 350 # Scopes the dedupe to the run configs actually being evaluated: an eval 351 # config accumulates runs for every run config ever compared against it. 352 # The `is not None` is not redundant with the membership test — ID_TYPE is 353 # `str | None`, so a run config whose file carries a null id would make 354 # None a real key and fold every calibration record into its set. 355 if ( 356 run.task_run_config_id is not None 357 and run.task_run_config_id in already_run[eval_config.id] 358 ): 359 already_run[eval_config.id][run.task_run_config_id].add( 360 eval_run_item_key(run) 361 ) 362 363 return [ 364 EvalJob( 365 item=item, 366 task_run_config=run_config, 367 type="task_run_eval", 368 eval_config=eval_config, 369 ) 370 for item in self.split.items 371 for eval_config in self.eval_configs 372 for run_config in self.run_configs or [] 373 if (self.split.source, item.id) 374 not in already_run[eval_config.id][run_config.id] 375 ]
Collect all jobs for this run, excluding any that have already been run.
This variant is used for mode "task_run_eval", generating new run output using existing dataset item input.
The tasks:
- are the items of the split this runner was given, from whichever store backs it
- should not have already been run for this eval config + run config + dataset item
377 def validate_multi_turn_drive_readiness( 378 self, check_run_configs: bool = True 379 ) -> None: 380 """Fail fast on config problems every multi-turn re-drive job would 381 hit: no item carrying a synthetic-user drive config, a stamped 382 config with an unknown model provider, run configs that aren't 383 Kiln agent configs, or run configs that answer in two messages per 384 turn. Callers can invoke this before starting a batch 385 so the user gets one clear error up front instead of one opaque 386 error per job. The per-job checks stay as the backstop for 387 standalone runner use. 388 389 A partially stamped split is NOT an error here: the stamped items 390 can still run, and each unstamped item records a per-item skip 391 naming the fix. Only an entirely unstamped split fails up front, 392 because nothing could run and one clear error beats a page of 393 identical skips. 394 395 `check_run_configs=False` limits validation to the drive configs — 396 for callers running a fleet the user didn't hand-pick, where one 397 incompatible run config shouldn't block every other config's jobs. 398 399 Raises ValueError listing every problem; no-op unless re-drive jobs 400 can actually occur (task_run_eval over a split containing multi-turn 401 synthetic items — stored-TaskRun sources never re-drive, and a 402 single-turn split has nothing to drive). 403 """ 404 if self.eval_run_type != "task_run_eval" or self.split is None: 405 return 406 multi_turn_items = [ 407 item.data 408 for item in self.split.items 409 if isinstance(item, EvalInput) 410 and isinstance(item.data, MultiTurnSyntheticEvalInputData) 411 ] 412 if not multi_turn_items: 413 return 414 problems: list[str] = [] 415 stamped_configs = [ 416 data.drive_config 417 for data in multi_turn_items 418 if data.drive_config is not None 419 ] 420 if not stamped_configs: 421 problems.append( 422 "none of this eval's multi-turn items has a synthetic user " 423 "configuration (create a new batch to mint items that carry one)" 424 ) 425 for provider in sorted({cfg.model_provider for cfg in stamped_configs}): 426 try: 427 ModelProviderName(provider) 428 except ValueError: 429 problems.append( 430 "a multi-turn item's synthetic-user drive config has " 431 f"unknown model provider '{provider}'" 432 ) 433 if check_run_configs: 434 for run_config in self.run_configs or []: 435 try: 436 agent_properties = as_kiln_agent_run_config( 437 run_config.run_config_properties 438 ) 439 except ValueError: 440 problems.append( 441 f"run config '{run_config.name}' is not a Kiln agent " 442 "config, so it can't hold a multi-turn conversation" 443 ) 444 continue 445 if _splits_a_turn_into_two_messages(agent_properties, self.task): 446 problems.append( 447 f"run config '{run_config.name}' uses a chain of thought " 448 "prompt with a model that has no reasoning step of its own, " 449 "so it answers in two messages per turn and can't hold a " 450 "multi-turn conversation (pick a reasoning-capable model, or " 451 "a prompt without thinking instructions)" 452 ) 453 if problems: 454 raise ValueError( 455 "Cannot re-drive this eval's multi-turn conversations: " 456 + "; ".join(problems) 457 + "." 458 )
Fail fast on config problems every multi-turn re-drive job would hit: no item carrying a synthetic-user drive config, a stamped config with an unknown model provider, run configs that aren't Kiln agent configs, or run configs that answer in two messages per turn. Callers can invoke this before starting a batch so the user gets one clear error up front instead of one opaque error per job. The per-job checks stay as the backstop for standalone runner use.
A partially stamped split is NOT an error here: the stamped items can still run, and each unstamped item records a per-item skip naming the fix. Only an entirely unstamped split fails up front, because nothing could run and one clear error beats a page of identical skips.
check_run_configs=False limits validation to the drive configs —
for callers running a fleet the user didn't hand-pick, where one
incompatible run config shouldn't block every other config's jobs.
Raises ValueError listing every problem; no-op unless re-drive jobs can actually occur (task_run_eval over a split containing multi-turn synthetic items — stored-TaskRun sources never re-drive, and a single-turn split has nothing to drive).
470 async def run(self, concurrency: int = 25) -> AsyncGenerator[Progress, None]: 471 """ 472 Runs the configured eval run with parallel workers and yields progress updates. 473 """ 474 jobs = self.collect_tasks() 475 476 runner = AsyncJobRunner( 477 concurrency=concurrency, 478 jobs=jobs, 479 run_job_fn=self.run_job, 480 max_retries=2, 481 ) 482 async for progress in runner.run(): 483 yield progress
Runs the configured eval run with parallel workers and yields progress updates.
485 async def run_job(self, job: EvalJob) -> bool: 486 try: 487 if job.eval_config.config_type == EvalConfigType.v2: 488 return await self._run_v2_job(job) 489 else: 490 return await self._run_legacy_job(job) 491 except RetryableError as e: 492 # Already classified by whoever raised it: this is a deliberate, expected 493 # ask for another attempt (an unusable generation the retry replaces), not a 494 # failure. Re-raised untouched, and logged without a stacktrace so a 495 # self-healing event doesn't read as a crash in the logs. 496 logger.warning(f"Retrying eval job for dataset item {job.item.id}: {e}") 497 raise 498 except Exception as e: 499 if is_retryable_error(e): 500 logger.error( 501 f"Transient error running eval job for dataset item {job.item.id}: {e}", 502 exc_info=True, 503 ) 504 # KilnRunError's own message is genericized user-facing text; keep 505 # the underlying provider detail for the developer-facing error log. 506 raise RetryableError(str(unwrap_kiln_run_error(e))) from e 507 logger.error( 508 f"Error running eval job for dataset item {job.item.id}: {e}", 509 exc_info=True, 510 ) 511 raise