The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
8.2 KiB
Sandboxed eval authoring reference
Field-level contracts for eval authors. Paths are relative to products/insights_ai/eval_harness/ unless noted.
When anything here disagrees with the code, the code wins — the sources of truth are config.py, base.py, and log_parser.py.
SandboxedEvalCase (config.py)
| Field | Type | Meaning |
|---|---|---|
name |
str |
Case name. Also the --eval <substr> filter target and the per-case log filename. Unique within the suite. |
prompt |
str |
The natural-language task given to the agent. |
repo_fixture |
str = "" |
Informational only (tracking). |
expected |
dict = {} |
Per-scorer expected values, keyed by each scorer's _name(). A scorer reads only its own sub-dict; a missing key means default behavior or self-skip. |
metadata |
dict = {} |
Arbitrary tracking/filtering metadata. |
setup |
Callable[[CustomPromptSandboxContext], dict] | None |
The seed hook. Excluded from serialization (callables don't survive Braintrust's JSON round-trip); the runner re-binds it from the original case by name. |
There is no per-case skills field (skills are built once per run and available globally in every sandbox — assert skill outcomes, not attachment) and no per-case timeout (--case-timeout is run-level, counted from sandbox acquisition).
Seeder contract
def seed_my_domain(context: CustomPromptSandboxContext) -> dict[str, Any]: ...
- Synchronous, plain Django ORM /
sync_execute; the harness runs it inasyncio.to_thread. - Runs once per case: after the isolated team/user is minted, before the prompt is dispatched.
contextis a frozen dataclass; seeders in practice usecontext.team_idandcontext.user_id.- The return dict becomes
output["seed"]for every scorer of that case. - Raising marks the case as an infra error (excluded from averages) rather than scoring 0.
Existing seeders and what they return:
| Seeder | Returns |
|---|---|
seeders/insight.py::seed_insight_noise |
{"noise_count", "lookup_insights": [{id, short_id, name}]} |
seeders/survey.py::seed_survey_feature_flags |
{"feature_flags_by_key": {...}} |
data_warehouse/seeder.py::seed_warehouse_schema |
per-needle metadata incl. queryable (False when object storage creds are absent — dependent scorers self-skip) |
error_tracking/seeders.py::seed_error_tracking_issues |
{"lookup_issues": [{id, name}]} |
experiments/seeders.py::seed_* |
seeded experiment ids, flag keys, split signatures |
seeders/common.py provides make_name_providers(seed) (seeded mimesis bundle, DEFAULT_NAME_SEED = 42) and LOOKUP_PREFIX = "[lookup]" — a substring guaranteed absent from generated noise, so a prompt can name a lookup entity unambiguously.
The output dict scorers receive
AgentArtifacts.model_dump() (config.py) merged with runner extras (base.py):
- From
AgentArtifacts:exit_code,stdout,stderr,git_diff,files_changed,test_exit_code,test_output,lint_exit_code,lint_output,duration_seconds,pr_url. - Added by the runner:
last_message(final assistant text),messages(parsed message list),raw_log(the raw JSONL — whatLogParserconsumes),seed(the setup hook's return dict),prompt.
Error semantics: a per-case timeout produces a scored-0 agent failure (exit_code=1, stderr says timeout); any other exception (demo copy, seeder, provisioning) re-raises as an errored case excluded from score averages.
LogParser (log_parser.py)
LogParser.cached(raw_log, initial_prompt="")— memoized; always use this over the constructor so one case's log is parsed once across scorers.get_tool_calls(name=None) -> list[ToolCall]— chronological tool calls (excludes theSkilltool), optionally filtered by normalized name.get_skill_calls(name=None)/was_skill_called(name)—Skilltool invocations (SkillCall(name, args, call_id, output, is_error, position)).get_user_prompt()— first user text, falling back toinitial_prompt.get_final_agent_message()— last assistant text block, orNone.normalize_tool_name(name)— stripsmcp__<server>__<tool>down to<tool>.INFO_SYNTHETIC_PREFIX = "__info__:"— synthetic call name unifyingexec {command: "info <tool>"}and Claude Code'sToolSearch(select:...)as "tool schema loaded".
ToolCall fields: name (normalized; exec unwrapped to the inner tool), input: dict, output: str, is_error (also True when unpaired), call_id, position (chronological index), raw_name, is_exec_unwrapped, requested_output_format ("json" / "optimized" / None).
Judge scorer shape
from typing import Any
from braintrust import Score
from products.insights_ai.eval_harness.scorers import BINARY_CHOICE_SCORES, JUDGE_MODEL, JudgedScorer
class SaysHello(JudgedScorer):
def __init__(self, **kwargs):
super().__init__(
name="says_hello",
prompt_template="...did the agent greet the user?...\n<msg>{{output.last_message}}</msg>\nAnswer `yes` or `no`.",
choice_scores=BINARY_CHOICE_SCORES,
model=JUDGE_MODEL,
max_completion_tokens=128,
**kwargs,
)
def _prepare(self, output, expected) -> dict[str, Any] | Score:
if not output or not output.get("last_message"):
return Score(name=self._name(), score=0.0, metadata={"reason": "No final message"})
return {"output": {"last_message": output["last_message"]}}
_prepare returns template variables — {{output.*}} / {{expected.*}} in prompt_template resolve against the returned "output" / "expected" values, not the raw case output. Shared constants: BINARY_CHOICE_SCORES (yes/no), GRADED_ALIGNMENT_CHOICE_SCORES (six-bucket scale), JUDGE_MODEL.
Determinism discipline
The dataset is byte-for-byte reproducible under the fixed EVAL_SEED (products/insights_ai/eval_harness/data_setup.py), and noise generators use fixed seeds.
- Reference events, properties, flags, and insight names exactly as listed in the Hedgebox dataset reference (
products/insights_ai/evals/AGENTS.md). - Prefer relative date ranges (
-30d,-8w,-6m) and shape-based assertions; never hard-code absolute counts — they drift when the simulation changes. - Mirror seeded insights with
filterTestAccounts=True; use theaccountgroup type (index 0) for group math. - Share constants between synthesizer/seeder, prompts, and scorers by importing them (the warehouse needle pattern) instead of restating strings.