Skip to content

[eb-v2 review] Eval V2 core: sandbox, trace persistence, and datamodel guard fixes - #1652

Closed
chiang-daniel wants to merge 3 commits into
review/eb-v2/basefrom
review/eb-v2/eval-v2-core
Closed

chiang-daniel wants to merge 3 commits into
review/eb-v2/basefrom
review/eb-v2/eval-v2-core

Conversation

@chiang-daniel

@chiang-daniel chiang-daniel commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review-only PR — do not merge. These changes are already landed on dchiang/eb-v2-merge (the eval-builder integration branch); this PR is a focused review surface for one area of that work. Every fix carries an inline comment explaining what was broken and why the fix takes this shape. Comments marked Personal review requested are the spots that want human judgment — the rest is context your tooling can skim. Reply on the inline comments (or add new line comments); accepted feedback will be implemented on eb-v2-merge and the commit sha posted back here. When review wraps, this PR is closed, not merged — its base and head are throwaway review refs.

Surface: the V2 eval data model and the eval adapters (sandbox scorer runner, LLM judge, runner). Pure Python; about half the diff is tests. Three commits: d625c94 (landed as c22bb47) — make the EvalConfig properties dispatch real, validate EvalInput tags, rewrite the EvalRun docstring, drop a dead tool-id branch; fdeda16 (landed as 1a5f81c) — drain the scorer queue before joining so large scorer output isn't misread as a timeout, handle SystemExit from user scorers, restore single-turn full_trace trace persistence, fail the g_eval logprobs preflight loudly, classify template-authoring errors correctly, parser-based trace-reference detection, superseded-tombstone dedup parity; 551107d (landed as 1e19a18) — make the EvalRun full_trace trace gate V2-aware. Four Personal review requested comments flag the contract/invariant calls.

Note: several of these fixes dedupe when #1454 syncs into the branch — they touch the same datamodel/adapter regions, so expect to resolve to this behavior and adopt any copy updates from that PR.

🤖 Generated with Claude Code

CI note: the "Check API Schema Bindings" failure here is an artifact of this review PR's pinned snapshot — the canonical schema verification lives on dchiang/eb-v2-merge. No action needed from reviewers.

chiang-daniel and others added 3 commits August 5, 2026 00:32
…alidation, EvalRun docstring, dead tool_id branch, test comment cleanup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, fail-loud g_eval preflight, parser-based trace detection, tombstone dedup parity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…for successful full_trace task-run evals, with load-compat for historical files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR updates V2 eval persistence, validation, template analysis, and sandbox execution. It stores full traces on successful task evaluations, carries superseded skip tombstones into replacement jobs, tightens EvalRun and EvalConfig validation, refines template-variable detection, and hardens sandbox scorer result handling.

Changes

V2 eval execution and validation

Layer / File(s) Summary
Eval data contracts and validation
libs/core/kiln_ai/datamodel/eval.py, libs/core/kiln_ai/datamodel/tool_id.py, libs/core/kiln_ai/datamodel/test_eval_model.py, libs/core/kiln_ai/adapters/eval/test_v2_datamodel_contracts.py, app/web_ui/src/lib/api_schema.d.ts
EvalConfig.properties now keeps legacy dictionaries as dictionaries and parses V2 property dictionaries into typed models by discriminator. EvalRun validation now requires traces for new non-skipped full-trace task-run records, keeps historical trace-less records loadable, and separates V1-only output checks. EvalInput.tags now reject empty or space-containing values. The schema docs and tests now describe and verify these rules.
Trace persistence and replacement jobs
libs/core/kiln_ai/adapters/eval/eval_runner.py, libs/core/kiln_ai/adapters/eval/test_eval_runner.py
Task-run recollection now carries non-terminal persisted skips as superseded tombstones into new EvalJob records. Successful V2 full_trace task evaluations now serialize and save task_run_trace for both EvalInput-based and TaskRun-based flows. Tests verify trace persistence and tombstone attachment.
Template analysis and judge behavior
libs/core/kiln_ai/adapters/eval/eval_utils/v2_eval_helpers.py, libs/core/kiln_ai/adapters/eval/v2_eval_llm_judge.py, libs/core/kiln_ai/adapters/eval/v2_eval_tool_call_check.py, libs/core/kiln_ai/adapters/eval/test_v2_eval_helpers.py, libs/core/kiln_ai/adapters/eval/test_v2_eval_llm_judge.py
references_trace now uses Jinja parsing to detect real trace references and ignores quoted strings, keys, attributes, similar names, and invalid syntax. Template rendering now reports unknown top-level variables as extraction_failed. G-Eval now rejects non-built-in models before scoring unless logprob support can be verified. The tool-call checker now returns a boolean result directly. Tests cover these paths.
Sandbox worker result handling
libs/core/kiln_ai/adapters/eval/sandbox_worker.py, libs/core/kiln_ai/adapters/eval/test_sandbox_worker.py
The sandbox worker now catches SystemExit from scorer code and returns a structured error with exit details. Result collection now polls the queue before join, detects silent child exit, performs a final grace read, preserves large payload handling, and force-reaps children that stay alive. Tests cover large stdout and sys.exit() at scorer and module scope.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • Kiln-AI/Kiln#1601: This PR extends the same recoverable-skip tombstone handling in eval_runner.py.
  • Kiln-AI/Kiln#1581: Both PRs change V2 task-evaluation trace persistence in eval_runner.py.
  • Kiln-AI/Kiln#1626: Both PRs modify V2 EvalInput and TaskRun bookkeeping around persisted traces in eval_runner.py.

Suggested reviewers: scosman, sfierro

Poem

I hopped through traces in neat little rows,
and tucked saved paths where fresh data grows.
A tombstone moved with the next job in line,
while templates learned what names are not fine.
When scorers cried sys.exit(), I stayed bright—
🐇 I carried the error safely home tonight.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description explains the review scope and changes, but it omits the required Related Issues, CLA confirmation, and Checklists sections. Add the required template sections, confirm the CLA, list related issues or state none, and complete the test checklist.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main Eval V2 core changes involving sandbox handling, trace persistence, and datamodel guards.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch review/eb-v2/eval-v2-core

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

📊 Coverage Report

Overall Coverage: 93%

Diff: origin/review/eb-v2/base...HEAD

  • libs/core/kiln_ai/adapters/eval/eval_runner.py (90.0%): Missing lines 274
  • libs/core/kiln_ai/adapters/eval/eval_utils/v2_eval_helpers.py (100%)
  • libs/core/kiln_ai/adapters/eval/sandbox_worker.py (90.6%): Missing lines 152,161-162
  • libs/core/kiln_ai/adapters/eval/v2_eval_llm_judge.py (100%)
  • libs/core/kiln_ai/adapters/eval/v2_eval_tool_call_check.py (100%)
  • libs/core/kiln_ai/datamodel/eval.py (100%)
  • libs/core/kiln_ai/datamodel/tool_id.py (100%)

Summary

  • Total: 89 lines
  • Missing: 4 lines
  • Coverage: 95%

Line-by-line

View line-by-line diff coverage

libs/core/kiln_ai/adapters/eval/eval_runner.py

Lines 270-278

  270                 if (
  271                     run.task_run_config_id is None
  272                     or run.task_run_config_id not in already_run[eval_config.id]
  273                 ):
! 274                     continue
  275                 if self._counts_as_already_run(run, eval_config):
  276                     already_run[eval_config.id][run.task_run_config_id].add(
  277                         run.dataset_id
  278                     )

libs/core/kiln_ai/adapters/eval/sandbox_worker.py

Lines 148-156

  148                 p.join(timeout=5)
  149                 raise RuntimeError(f"Code eval scorer timed out after {timeout}s")
  150             if p.exitcode not in (0, None):
  151                 raise RuntimeError(f"Scorer crashed (exit code {p.exitcode})")
! 152             raise RuntimeError("Scorer process exited without returning results")
  153     finally:
  154         q.close()
  155         q.join_thread()

Lines 157-166

  157     p.join(timeout=5)
  158     if p.is_alive():
  159         # Result already in hand; a child that won't exit on its own (e.g.
  160         # user code left non-daemon threads running) is force-reaped.
! 161         p.kill()
! 162         p.join(timeout=5)
  163     elif p.exitcode not in (0, None) and "ok" not in result:
  164         raise RuntimeError(f"Scorer crashed (exit code {p.exitcode})")
  165 
  166     return result



# already_run[eval_config_id][run_config_id][dataset_id]
already_run: Dict[ID_TYPE, Dict[ID_TYPE, Set[ID_TYPE]]] = {}
superseded: Dict[Tuple[ID_TYPE, ID_TYPE, ID_TYPE], List[EvalRun]] = {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task-run-eval collector was missing the superseded-tombstone dedup map its sibling collectors already build, so it couldn't recognise runs superseded by a newer config and carry their tombstones onto the job. This adds the same map — keyed by (eval_config, run_config, dataset) — and threads superseded_tombstones onto each EvalJob, bringing this collector to parity with the others.

eval_task_input = EvalTaskInput.from_eval_input(job.item, run_output)
result = await evaluator.evaluate(eval_task_input)

# Like the legacy runner, only successful task-run-eval records of

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the single-turn V2 lanes, a full_trace eval scored the conversation but never persisted the trace onto the EvalRun — the V1 validator was exempting V2 records from the trace requirement, so paid generations lost the conversation unrecoverably and the record couldn't be re-scored. This restores parity with the legacy lanes: a successful task-run eval of a full_trace eval serializes and stores the trace. (The same block appears on both the eval_input and dataset lanes.) The gate that let this slip silently is tightened separately in 551107df4.

another variable) does not.
"""
try:
# Same parse compile_expression performs, wrapped into a template node

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

references_trace decided whether an expression reads the trace variable with a word-boundary regex, so trace appearing as data — a quoted string, a dict key, an attribute of another variable — falsely counted as a trace reference. This uses Jinja's own parser and undeclared-variable analysis, so only a genuine variable reference to trace counts. Invalid expressions return False here; the extraction path owns reporting their syntax errors.

"stderr": captured_stderr.getvalue(),
}
)
except SystemExit as e:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A sys.exit() in user scorer code raises SystemExit, which the broad except Exception didn't catch — so the child died before queueing a result and the user saw a generic "process exited without returning results" with no clue why. This catches SystemExit and queues an explanatory error naming the exit code. KeyboardInterrupt is left to propagate on purpose: it means the operator interrupted the process group, not that the user's code failed.

q: multiprocessing.queues.Queue, # type: ignore[type-arg]
timeout: float,
) -> dict[str, Any] | None:
"""Wait for the child's result dict; None on timeout or silent child death.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runner joined the scorer process before reading its result queue. When a scorer returns more than the OS pipe buffer holds (~64KB), the child's queue feeder thread blocks until the parent reads, so the child never exits, the join times out, and a perfectly good run is reported as a timeout — which also stalls the batch behind the global lock. This inverts the wait: poll the queue in short slices (so a silently-dead child is noticed well before the full timeout), then reap the process. See the flag on the wait shape.

let a child that died without reporting be noticed well before the full
timeout.
"""
deadline = time.monotonic() + timeout

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal review requested: The wait replaces join-first with a queue-first poll in 0.1s slices until the deadline, then force-reaps the process (with a final grace read to catch a result flushed between the empty read and the liveness check). Does the 0.1s slice / force-reap-after-grace shape look right to you, or would you prefer a different poll granularity or a hard join timeout as a backstop?

except UndefinedError as e:
# A typo'd template variable and genuinely missing reference data
# both raise UndefinedError; only the latter is a data problem.
unknown = _unknown_template_variables(props.prompt_template, namespace)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A typo'd variable in the judge prompt template and a genuinely-missing reference-data key both raise Jinja's UndefinedError, and both were labeled missing_reference_key — so a template-authoring bug masqueraded as absent data. This distinguishes them by parsing the template for top-level names that aren't EvalTaskInput fields: unknown names are an authoring error (extraction_failed, naming them), everything else stays a data problem.

if props.g_eval:
model_provider = built_in_models_from_provider(provider, model_name)
if model_provider is not None and not model_provider.supports_logprobs:
if model_provider is None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The g_eval preflight only rejected models it could confirm lack logprobs. A model absent from the built-in list returned None and slipped through — then the paid judge call failed deterministically downstream, after spending. This fails the preflight loudly when the model can't be verified, before the call, with a message telling the author to pick a built-in logprobs model or turn off G-Eval.

)

@model_validator(mode="after")
def validate_tags(self) -> Self:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EvalInput.tags were unvalidated, so an empty or space-containing tag could be written. Every tag filter treats those as unselectable, so the item silently disappeared from filtered views with no error at write time. This mirrors the TaskRun tag rules (no empty strings, no spaces) and rejects them at creation where the author can fix it.

def validate_tags(self) -> Self:
# Empty or space-containing tags can't be selected by tag filters, so
# reject them at creation instead of silently dropping the item later.
for tag in self.tags:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal review requested: EvalInput.tags are now validated like TaskRun.tags (reject empty and space-containing), which is a deliberate tightening of your schema. Existing data with such tags would fail on the next revalidating write. Confirm you want the TaskRun rules applied to EvalInput, and that a write-time (not load-time) tightening is the right posture.

class EvalRun(KilnParentedModel):
"""
The results of running an eval on a single dataset item.
The result of running an eval on a single item, stored as a child of the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EvalRun docstring still described a V1-only world, but the branch had already added the eval_input_id source and skipped-item semantics that contradicted it — so the class's own documentation was actively wrong about when task_run_config_id and the two input sources apply. Rewritten to state the real contract: the two eval purposes, the mutually-exclusive dataset_id / eval_input_id sources, and that output/scores may be absent only when skipped.


@model_validator(mode="after")
def validate_output_fields(self) -> Self:
def validate_output_fields(self, info: ValidationInfo) -> Self:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal review requested: I chose enforcement over exemption for the full_trace trace gate: single-shot no-trace runs are held to it, and an MCP+full_trace pairing that produces no trace now fails at save, matching V1. The argument is V1 parity — V1 already required the trace for exactly this shape. Your call to ratify: is holding V2 single-shot full_trace runs to the trace requirement correct, or is there a legitimate V2 full_trace flow that produces no trace and should be exempt?

and self.task_run_trace is None
and not self.loaded_from_file(info)
):
raise ValueError("full_trace task run eval runs should include trace")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old validate_output_fields returned early for every V2 config, which is exactly why the missing-trace bug above could persist silently — the alarm was disconnected for V2. This makes the full_trace-requires-trace gate apply to both V1 and V2 writers of a scored, non-skipped task-run eval, so a writer that drops the trace now fails loudly instead of saving an un-re-scorable record. Historical files predating the gate are exempt via loaded_from_file so they still load. See the flag — enforcement was chosen over exemption here.

description="This is used to determine the type of eval to run.",
)
properties: V2EvalConfigProperties | dict[str, Any] | None = Field(
properties: dict[str, Any] | V2EvalConfigProperties | None = Field(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal review requested: The legacy/V2 properties-parsing contract is now explicit: the union lists dict first so any raw dict stays a plain dict, and V2 configs are parsed into the typed union by a module-level TypeAdapter in the before-validator. This is your data model — please sign off on the mechanism (field-order-as-contract plus TypeAdapter dispatch) rather than relying on discriminated-union fallback ordering.

# configs store properties as an untyped dict, so we shallow-copy and
# re-assign it here to force Pydantic to accept the dict branch of the union.
def dispatch_properties_parsing(cls, data: Any) -> Any:
# The union lists dict first, so a raw dict always stays a plain dict

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old dispatch validator was a no-op: it shallow-copied the properties dict back onto itself, and legacy dicts only loaded by luck of the union's fallback ordering. A legacy config whose properties happened to carry a colliding "type" key was unloadable — the discriminated union would try to parse it as a typed V2 shape and reject the whole file. This makes the dispatch real: legacy dicts stay plain dicts (the union lists dict first), and V2 configs are parsed into the typed union up front via a module-level TypeAdapter. The field order flip on properties (dict first) is part of the same contract — see the flag.

raise ValueError(
f"Invalid code tool ID: {id}. Expected format: 'kiln_tool::code::<code_tool_id>'."
)
# Raises ValueError on malformed IDs; the extracted ID isn't needed here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead defensive branch: this guarded if not ct_id: raise, but code_tool_id_from_tool_id already raises on a malformed id, so the extracted value is never falsy by the time the check runs — the branch could never fire. Removed, keeping the raising call for its validation side effect and noting that the return value isn't used here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
libs/core/kiln_ai/adapters/eval/v2_eval_llm_judge.py (1)

157-168: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider moving the g_eval logprobs preflight to __init__.

Raising here is correct behavior. The placement costs repeated work. evaluate() runs once per eval item, so a misconfigured g_eval judge raises on every item. EvalRunner.run_job re-raises, and AsyncJobRunner retries with max_retries=2, so one config error produces three deterministic failures per dataset item.

Both g_eval checks depend only on props.model_name, props.model_provider, and props.g_eval. All three are fixed at construction. __init__ already validates model_provider the same way, so the check fits the existing pattern and fails once instead of per item.

♻️ Proposed move to the constructor
         if self.properties.model_provider not in ModelProviderName.__members__:
             raise ValueError(
                 f"Invalid model provider: {self.properties.model_provider}"
             )
+        if self.properties.g_eval:
+            self._assert_logprobs_supported(self.properties)
+
+    `@staticmethod`
+    def _assert_logprobs_supported(props: LlmJudgeProperties) -> None:
+        provider = ModelProviderName(props.model_provider)
+        model_provider = built_in_models_from_provider(provider, props.model_name)
+        if model_provider is None:
+            raise ValueError(
+                f"g_eval=True requires logprobs support, but model "
+                f"'{props.model_name}' is not a built-in model for provider "
+                f"'{props.model_provider}', so logprobs support can't be "
+                f"verified. Use a built-in model that supports logprobs, "
+                f"or disable G-Eval for this judge."
+            )
+        if not model_provider.supports_logprobs:
+            raise ValueError(
+                f"g_eval=True requires logprobs support, but provider "
+                f"'{props.model_provider}' for model '{props.model_name}' does not "
+                f"support logprobs"
+            )

Then delete the if props.g_eval: block from evaluate(). The tests in libs/core/kiln_ai/adapters/eval/test_v2_eval_llm_judge.py lines 365-427 would need the built_in_models_from_provider patch to wrap LlmJudgeEval(cfg) instead of adapter.evaluate(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/core/kiln_ai/adapters/eval/v2_eval_llm_judge.py` around lines 157 - 168,
Move the g_eval logprobs preflight from evaluate() into the LlmJudgeEval
constructor, using the fixed props.model_name, props.model_provider, and
props.g_eval values and preserving both built-in-model and supports_logprobs
validations. Remove the duplicate if props.g_eval block from evaluate(), and
update the affected tests so built_in_models_from_provider is patched while
constructing LlmJudgeEval.
libs/core/kiln_ai/adapters/eval/eval_utils/v2_eval_helpers.py (1)

29-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use Jinja2’s Environment.parse for undeclared-variable inspection.

jinja2.parser.Parser, state="variable", and parse_expression() are low-level/internal Jinja APIs, while meta.find_undeclared_variables is intended for the AST from Environment.parse. Use return "trace" in meta.find_undeclared_variables(_expression_env.parse("{{ " + expression + " }}")) to remove the internal-API coupling and avoid breaking on a Jinja minor upgrade.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/core/kiln_ai/adapters/eval/eval_utils/v2_eval_helpers.py` around lines
29 - 46, Update references_trace to use _expression_env.parse with the
expression wrapped as a Jinja output statement, then pass that template AST
directly to meta.find_undeclared_variables. Remove the direct Parser,
nodes.Template, and parse_expression usage while preserving the existing
invalid-expression handling and trace-reference result.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libs/core/kiln_ai/adapters/eval/eval_runner.py`:
- Around line 659-674: The shared trace-persistence logic around trace_json must
align with EvalRun validation: for successful full-trace fresh-generation
records, treat a missing or empty eval_task_input.trace as missing_trace and
mark the record skipped instead of producing task_run_trace=None. Update the
shared condition used by the single-shot and TaskRun paths, while preserving
serialized trace persistence for non-empty traces and file-loaded history
behavior.

---

Nitpick comments:
In `@libs/core/kiln_ai/adapters/eval/eval_utils/v2_eval_helpers.py`:
- Around line 29-46: Update references_trace to use _expression_env.parse with
the expression wrapped as a Jinja output statement, then pass that template AST
directly to meta.find_undeclared_variables. Remove the direct Parser,
nodes.Template, and parse_expression usage while preserving the existing
invalid-expression handling and trace-reference result.

In `@libs/core/kiln_ai/adapters/eval/v2_eval_llm_judge.py`:
- Around line 157-168: Move the g_eval logprobs preflight from evaluate() into
the LlmJudgeEval constructor, using the fixed props.model_name,
props.model_provider, and props.g_eval values and preserving both built-in-model
and supports_logprobs validations. Remove the duplicate if props.g_eval block
from evaluate(), and update the affected tests so built_in_models_from_provider
is patched while constructing LlmJudgeEval.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b64b200c-e474-48a4-915b-ecdfd9695c20

📥 Commits

Reviewing files that changed from the base of the PR and between b27d50e and 551107d.

📒 Files selected for processing (14)
  • app/web_ui/src/lib/api_schema.d.ts
  • libs/core/kiln_ai/adapters/eval/eval_runner.py
  • libs/core/kiln_ai/adapters/eval/eval_utils/v2_eval_helpers.py
  • libs/core/kiln_ai/adapters/eval/sandbox_worker.py
  • libs/core/kiln_ai/adapters/eval/test_eval_runner.py
  • libs/core/kiln_ai/adapters/eval/test_sandbox_worker.py
  • libs/core/kiln_ai/adapters/eval/test_v2_datamodel_contracts.py
  • libs/core/kiln_ai/adapters/eval/test_v2_eval_helpers.py
  • libs/core/kiln_ai/adapters/eval/test_v2_eval_llm_judge.py
  • libs/core/kiln_ai/adapters/eval/v2_eval_llm_judge.py
  • libs/core/kiln_ai/adapters/eval/v2_eval_tool_call_check.py
  • libs/core/kiln_ai/datamodel/eval.py
  • libs/core/kiln_ai/datamodel/test_eval_model.py
  • libs/core/kiln_ai/datamodel/tool_id.py

Comment on lines +659 to +674
# Like the legacy runner, only successful task-run-eval records of
# a full_trace eval carry the serialized trace (single-shot
# generations don't always produce one).
trace_json: str | None = None
if (
result.skipped_reason is None
and self.eval.evaluation_data_type == EvalDataType.full_trace
and eval_task_input.trace
):
trace_json = json.dumps(
eval_task_input.trace,
indent=2,
ensure_ascii=False,
default=_trace_json_default,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the EvalRun full-trace validator to confirm the exact gate conditions.
set -euo pipefail

fd -t f 'eval.py' libs/core/kiln_ai/datamodel --exec ast-grep outline {} --items all

rg -n -C 20 'full_trace task run eval runs should include trace' libs/core/kiln_ai/datamodel/eval.py

# Show every validator that reads task_run_trace.
rg -n -C 15 'task_run_trace' libs/core/kiln_ai/datamodel/eval.py

Repository: Kiln-AI/Kiln

Length of output: 11417


🏁 Script executed:

#!/bin/bash
# Inspect EvalRun creation paths around eval_runner.py and related test behavior.
set -euo pipefail

wc -l libs/core/kiln_ai/adapters/eval/eval_runner.py libs/core/kiln_ai/datamodel/test_eval_model.py
ast-grep outline libs/core/kiln_ai/adapters/eval/eval_runner.py --view compact | sed -n '1,260p'
sed -n '620,740p' libs/core/kiln_ai/adapters/eval/eval_runner.py
rg -n -C 12 'missing_trace|skipped_reason|make_eval_run|EvalRun\\(' libs/core/kiln_ai/adapters/eval/eval_runner.py
test -f libs/core/kiln_ai/datamodel/test_eval_model.py && rg -n -C 16 'full_trace task run eval runs should include trace|task_run_trace=None|missing_trace' libs/core/kiln_ai/datamodel/test_eval_model.py

Repository: Kiln-AI/Kiln

Length of output: 422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '620,740p' libs/core/kiln_ai/adapters/eval/eval_runner.py
rg -n -C 12 'missing_trace|skipped_reason|EvalRun\\(' libs/core/kiln_ai/adapters/eval/eval_runner.py
rg -n -C 16 'full_trace task run eval runs should include trace|task_run_trace=None|missing_trace' libs/core/kiln_ai/datamodel/test_eval_model.py

python3 - <<'PY'
from pathlib import Path
p = Path("libs/core/kiln_ai/adapters/eval/eval_runner.py")
text = p.read_text()
idx_trace = text.find('trace_json: str | None = None')
idx_task_json = text.find('task_json')
idx_eval_run = text.find('EvalRun(')
print("trace_json assignment index:", idx_trace)
print("task_json index:", idx_task_json)
print("EvalRun call index:", idx_eval_run)
print("EvalRun call appears after task_json?", idx_eval_run > idx_task_json)
print("EvalRun call appears after trace_json assignment?", idx_eval_run > idx_trace)
print("includes trace_json in EvalRun call?", "trace_json" in text[idx_trace:idx_eval_run].split("trace_json: str | None", 1)[-1].rsplit("EvalRun(", 1)[1] if idx_trace!=-1 and idx_eval_run!=-1 and idx_task_json!=-1 else "unknown")
PY

Repository: Kiln-AI/Kiln

Length of output: 5735


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 'missing_trace|skipped_reason|EvalRun\(' libs/core/kiln_ai/adapters/eval/eval_runner.py
rg -n -C 16 'full_trace task run eval runs should include trace|task_run_trace=None|missing_trace' libs/core/kiln_ai/datamodel/test_eval_model.py

python3 - <<'PY'
from pathlib import Path
p = Path("libs/core/kiln_ai/adapters/eval/eval_runner.py")
text = p.read_text()
idx_trace = text.find('trace_json: str | None = None')
idx_task_json = text.find('task_json')
idx_eval_run = text.find('EvalRun(')
trace_block = text[idx_trace:idx_task_json if idx_task_json != -1 else idx_eval_run]
print("trace_json block:")
print(trace_block)
print("EvalRun task_run_trace argument in EvalRun call:", "task_run_trace=trace_json" in text[idx_eval_run:text.find(")", idx_eval_run)])
PY

Repository: Kiln-AI/Kiln

Length of output: 28227


🌐 Web query:

pydantic BaseModel truthiness truthy dict list empty None truthiness __bool__

💡 Result:

In Python, Pydantic BaseModel instances are always truthy by default [1]. This is because they do not implement bool or len [1]. 1. Truthiness of BaseModel: Because Pydantic BaseModel does not define a custom bool or len method, an instance of a BaseModel will evaluate to True in a boolean context, regardless of whether the model's fields are empty, None, or contain data [1]. 2. Truthiness of Fields: The truthiness of individual fields within the model follows standard Python rules [2]. For example, a list field will be falsy if it is empty ([]) and truthy if it contains elements [3][2]. A None value is always falsy [3]. 3. Customizing Truthiness: If you require a Pydantic model to evaluate to False when certain conditions are met (e.g., when all fields are empty or None), you must explicitly implement the bool magic method in your model class [1]. Example of custom truthiness: class MyModel(BaseModel): data: list[str] = [] def bool(self) -> bool: # Evaluate to False if 'data' is empty return bool(self.data) If you are dealing with empty data structures or None values and want to treat them as "missing" or "empty" during validation or business logic, it is recommended to use Pydantic validators (e.g., @field_validator) or Annotated types with constraints (e.g., min_length) rather than relying on the truthiness of the model instance itself [4][2][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import ast

source = Path("libs/core/kiln_ai/adapters/eval/eval_runner.py").read_text()
tree = ast.parse(source)

def find_eval_task_input_declare_assign(node):
    for n in ast.walk(node):
        if isinstance(n, ast.Assign):
            for target in n.targets:
                if isinstance(target, ast.Name) and target.id == "eval_task_input":
                    value = n.value
                else:
                    continue
                if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute):
                    attr = value.func.attr
                    # inspect the condition on result.skipped_reason / full_trace / eval_task_input.trace
                    # We'll locate relevant nodes and print source slices.

print("AST check: look for EvalTaskInput.from_task_run calls with if result.skipped_reason is None and ...", end=" ")
hits = []
for n in ast.walk(ast.parse(source)):
    if isinstance(n, ast.If):
        s = ast.get_source_segment(source, n.test) or ""
        if "result.skipped_reason is None" in s and "EvaluationDataType.full_trace" in s or "EvalDataType.full_trace" in s:
            hits.append((n.lineno, n.col_offset, ast.get_source_segment(source, n)))
print(len(hits))
for lineno, col, s in hits:
    print(f"\nline {lineno}: {s.replace(chr(10), ' ')}")
PY

# Also inspect relevant trace construction in the fresh multi-turn synthetic path and trace data shape.
sed -n '840,910p' libs/core/kiln_ai/adapters/eval/eval_runner.py
rg -n -C 8 'class EvalTaskInput|from_task_run|from_eval_input' libs/core/kiln_ai/datamodel/eval.py

Repository: Kiln-AI/Kiln

Length of output: 6967


Align full-trace trace-persistence with the EvalRun validator.

EvalRun rejects successful full-trace task-run records with task_run_trace=None, except for file-loaded history. The single-shot and TaskRun fresh-generation paths set trace_json only when eval_task_input.trace is truthy, so an empty-trace successful V2 run is rejected. Update the shared condition to persist a missing_trace skip when needed instead of relying on separate per-lane logic.

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 667-672: use jsonify instead of json.dumps for JSON output
Context: json.dumps(
eval_task_input.trace,
indent=2,
ensure_ascii=False,
default=_trace_json_default,
)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/core/kiln_ai/adapters/eval/eval_runner.py` around lines 659 - 674, The
shared trace-persistence logic around trace_json must align with EvalRun
validation: for successful full-trace fresh-generation records, treat a missing
or empty eval_task_input.trace as missing_trace and mark the record skipped
instead of producing task_run_trace=None. Update the shared condition used by
the single-shot and TaskRun paths, while preserving serialized trace persistence
for non-empty traces and file-loaded history behavior.

@chiang-daniel

Copy link
Copy Markdown
Contributor Author

Superseded. The branch is now split into seven review-only views with the current tip; this area is inside them. Closing so there is one set to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant