Tasks โ US1: Persist Strongly-Typed EvalRun Per Test Invocation (Priority: P1) ๐ฏ MVP¶
Feature: 031-eval-runs-dashboard Spec: spec.md โ User Story 1 (P1) Plan: plan.md Data model: data-model.md Research: research.md Contract: contracts/cli.md
Goal: Every holodeck test invocation writes a strongly-typed EvalRun JSON artifact to <agent_base_dir>/results/<slugified-agent-name>/<ISO-timestamp>.json. The artifact wraps the existing TestReport (extended additively with MetricResult.kind, TestResult.tool_invocations, TestResult.token_usage) and adds an EvalRunMetadata block with a redacted Agent snapshot and run provenance. Writes are atomic.
Dashboard readiness: the additive TestResult fields are not decorative โ they are the minimum runtime data required for the dashboard's Summary breakdowns (kind), Explorer tool-call panels (tool_invocations), and Compare view cost row (token_usage). See data-model.md "Dashboard Field Projections" and research.md R8 (pairing) / R9 (token capture).
Independent Test: Run holodeck test agent.yaml in a project with no prior runs โ a file appears at results/<slug>/<ts>.json โ EvalRun.model_validate_json(path.read_text()) round-trips without loss (modulo redacted secrets).
TDD discipline: For every task marked "(TDD)", write the failing test first, confirm it fails for the expected reason, then implement and make it pass. Do not implement ahead of the test.
Phase 1: Setup (US1-specific)¶
- T001 [US1] Create package directory
src/holodeck/lib/eval_run/with empty__init__.py - T002 [US1] Create empty module
src/holodeck/models/eval_run.pywith module docstring and future imports only
Phase 2: Foundational โ SecretStr migration (Blocks US1 Acceptance Scenario 3)¶
โ ๏ธ CRITICAL: The SecretStr migration is a prerequisite for the type-driven redaction rule (FR-005 rule 2). US1 AC3 cannot pass until this lands.
Tests first (TDD)¶
- T003 [P] [US1] (TDD) Add failing unit test
tests/unit/models/test_secret_fields.pyasserting thatLLMProvider.model_fields["api_key"].annotationisOptional[SecretStr](orSecretStr | None) โ expect failure until migration lands - T004 [P] [US1] (TDD) Add failing unit tests in
tests/unit/models/test_secret_fields.pyfor every field enumerated below (T006): scope is limited to fields that EXIST in the current codebase. Do NOT write tests forauth_token,aws_access_key_id,aws_secret_access_keyโ those fields do not exist yet and migrating them is out-of-scope for this feature
Implementation¶
- T005 [US1] Migrate
LLMProvider.api_keyfromstr | NonetoSecretStr | Noneinsrc/holodeck/models/llm.py:44 - T006 [US1] Migrate the following existing plain-
strsecret fields toSecretStr | None, verified present in the codebase as of this task list. Code reality-check: the model is namedDatabaseConfig(notVectorStoreConfig):DatabaseConfig.connection_string(src/holodeck/models/tool.py:212)KeywordIndexConfig.password(src/holodeck/models/tool.py:250) andKeywordIndexConfig.api_key(src/holodeck/models/tool.py:251)AzureMonitorExporterConfig.connection_string(src/holodeck/models/observability.py:229) โ handled separately in T007 for clarity- NOTE: Anthropic
auth_token, OAuth tokens, and AWS creds (aws_access_key_id,aws_secret_access_key) are NOT fields that currently exist inLLMProviderorClaudeConfig.ClaudeConfig.AuthProviderhas the ENUM valueoauth_tokenbut no actual token field. This feature will NOT add new secret fields; any future provider secret must be typedSecretStrfrom day one. Document this invariant insrc/holodeck/models/llm.pyvia a module-level comment
- T007 [US1] Migrate
AzureMonitorExporterConfig.connection_stringtoSecretStr | Noneinsrc/holodeck/models/observability.py:229 - T008 [US1] Audit MCP tool configuration in
src/holodeck/models/tool.pyfor any header/token fields currently typedstr; migrate toSecretStr | Noneonly when the field's purpose is to carry a secret. Result: N/A โMCPTool.headersandMCPTool.envaredict[str, str], not single-string secret fields. Typing a dict asSecretStris not shape-compatible; skipping per task wording ("do not invent fields") - T009 [US1] Update every reader callsite for the migrated fields above to
.get_secret_value(). Actual callsites patched (grep-driven vs. the anticipated list):src/holodeck/lib/keyword_search.py:760-761โconfig.password/config.api_keyreaders (KeywordIndexConfig)src/holodeck/lib/test_runner/executor.py:352, 452โ LLMProvider.api_key โ DeepEvalModelConfig + Azure ModelConfigsrc/holodeck/lib/tool_initializer.py:131-144, 435-463โ LLMProvider.api_key โ SK OpenAI/Azure/Anthropic services (factored intoapi_key_rawlocals)src/holodeck/lib/test_runner/agent_factory.py:665-686โ LLMProvider.api_key โ SK servicessrc/holodeck/tools/base_tool.py:121-123โ DatabaseConfig.connection_string โ dict kwargs- Not patched (no reader callsite exists): SK/Claude backends read via
tool_initializer, so no direct patch needed. Observability exporter init is still stubbed (Phase 7 TODO inlib/observability/config.py:125). MCP bridge does not read secret fields.
- T010 [US1] Project-wide grep for attribute accesses on the migrated fields โ fixture updates landed in:
test_llm_ollama.py(3 sites),test_observability.py(1 site),test_tool_models.py(2 sites),test_base_tool.py(MockDatabaseConfig),test_loader.py(5 sites including_convert_vectorstore_to_database_configround-trip),test_agent_factory_ollama.py(1 site). All updated to.get_secret_value().
Checkpoint: โ
4127 unit tests pass (1 skipped); new test_secret_fields.py (8 tests) passes.
Phase 2b: Foundational โ Runtime data shape migrations (Blocks US4/US5 dashboard rendering)¶
โ ๏ธ CRITICAL: The design handoff's dashboard architecture requires runtime data the codebase does not currently capture. Three TestResult-adjacent migrations must land in US1 so that US4/US5 can render real runs (not just seed data). Seed data already has the right shape; the codebase does not.
Migration A โ MetricResult.kind discriminator¶
Without this field, Summary's three breakdown panels (summary.js:442-458), the metric-trend kind toggle (summary.js:433-437), Explorer's evaluations-by-kind grouping (explorer.js:204-208), and Compare's case-matrix scoring (compare.js:189-196) cannot render from real EvalRun JSON files.
- T010a [P] [US1] (TDD)
tests/unit/models/test_metric_result_kind.py: assertMetricResult.model_fields["kind"].annotationisLiteral["standard","rag","geval"]; assertkindis required (no default); round-trip viamodel_dump_json()/model_validate_json()preserves it - T010b [US1] Extend
MetricResultinsrc/holodeck/models/test_result.py:35withkind: Literal["standard","rag","geval"] = Field(..., description="Metric family โ distinguishes standard NLP / RAG / GEval"). The discriminator already exists on the evaluation CONFIG side (models/evaluation.py:43,149,250) astype: Literal["standard"|"geval"|"rag"]; T010b propagates it to runtime results - T010c [US1] Code reality-check: the task text said to patch evaluator writers, but
MetricResult(...)is constructed in exactly two sites inexecutor.py(success path ~line 815, error path ~line 842) โ evaluators returndictand the executor wraps. Added helper_metric_kind(metric_config)that maps the already-existingmetric_config.typediscriminator ("standard"|"geval"|"rag") tokind. Patched both sites. - T010d [US1] Back-compat: added
src/holodeck/lib/eval_run/legacy.pyexportinginfer_metric_kind(metric_name). Names in{bleu, rouge, meteor, exact_match, f1_score}โ"standard"; values inRAGMetricTypeenum โ"rag"; everything else โ"geval". WARNING logged on every call. Tests intests/unit/lib/eval_run/test_legacy_kind_inference.py.
Migration B โ Structured tool-call capture (ToolInvocation)¶
Without this, Explorer's Conversation panel renders empty tool-call panels for real runs (the handoff's explorer.js::ToolCall:152-184 requires {name, args, result, bytes} per invocation).
Naming decision (2026-04-18 revisit): the persisted model is
ToolInvocation, notToolEvent. TwoToolEventclasses already exist (models/tool_event.pyโ streaming UI events;lib/backends/base.py::ToolEventโ backend hook events). A thirdToolEventwould be confusing.ToolInvocationunambiguously means "one completed call+result pair on disk". See research.md R8 for the per-backend pairing contract.
- T010e [P] [US1] (TDD)
tests/unit/models/test_tool_invocation.py: assertToolInvocationhas fieldsname: str,args: dict[str, Any](default{}),result: Any(defaultNone),bytes: int(ge=0),duration_ms: int | None(defaultNone),error: str | None(defaultNone), withextra="forbid". AssertTestResult.tool_invocations: list[ToolInvocation]exists withdefault_factory=list; existingtool_calls: list[str]remains for back-compat. Round-trip viamodel_dump_json()/model_validate_json()preserves all fields. - T010f [US1] Added
class ToolInvocation(BaseModel)co-located insrc/holodeck/models/test_result.py. Addedtool_invocations: list[ToolInvocation] = Field(default_factory=list, ...)onTestResult.models/tool_event.pyleft untouched to avoid naming collision with the two existingToolEventclasses. - T010g [P] [US1] (TDD)
tests/unit/lib/test_runner/test_tool_invocation_builder.py: all five cases covered (SK index pair, SK length mismatch, Claude id pair, Claude unmatched id, orphan result logs WARNING, JSON-safe coercion ofdatetimeresults). - T010h [US1] Implemented
src/holodeck/lib/test_runner/tool_invocation_builder.pyexportingpair_tool_calls(tool_calls, tool_results, backend_kind). Signature note: the task draft said(exec_result, backend_kind)but I went with the already-paired lists(tool_calls, tool_results, backend_kind)โ easier to unit-test without a fakeExecutionResult. Executor passesraw_tool_calls=list(exec_result.tool_calls)andtool_results=exec_result.tool_resultsat the call site. Correlation key: Claude's dict key is"call_id"(populated fromToolUseBlock.id/ToolResultBlock.tool_use_idbyclaude_backend.py:162-177), not"tool_use_id"as some task text said. JSON-safety viajson.loads(json.dumps(result, default=str)). - T010i [US1] Wired
pair_tool_callsintoexecutor.py. Addedraw_tool_calls: list[dict[str, Any]]alongside the existingtool_calls: list[str]so both shapes coexist. Backend-kind detection istype(backend).__name__ == "ClaudeBackend"โ keeps theAgentBackendprotocol untouched and avoids an import cycle throughBackendSelector. Wrapped thepair_tool_callscall intry/except TypeErrorto tolerate mock-based unit tests that pass non-list sentinels. - T010j [US1] Updated
TestResultdocstring to state "Readers prefertool_invocations(structured) when non-empty;tool_calls: list[str](name-only) remains for back-compat with legacy runs predating Phase 2b."
Migration C โ TestResult.token_usage for cost computation¶
Without this, Compare view's "cost" row has no real data to compute from; dashboard falls back to synthetic duration ร rate (handoff behaviour) until a pricing table ships.
- T010k [P] [US1] (TDD)
tests/unit/models/test_test_result_token_usage.py: covers default-None, round-trip when populated, and legacy-JSON loads asNone. - T010l [US1] Added
token_usage: TokenUsage | None = Field(default=None, ...)toTestResult; importedTokenUsage. - T010m [US1] Extracted
token_usage: TokenUsage | None = Noneto scope outside theif self._backend / elif self.agent_factorybranches; populated fromexec_result.token_usage(new path) orlegacy_result.token_usage(old path). Guarded the assignment withisinstance(raw, TokenUsage)to tolerate mock-based unit tests. Passed toTestResult(...)at the construction site.
Deferred โ conversation discriminated union (was Migration C in an earlier draft): the handoff's Explorer renders
test_input+agent_response+ paralleltool_invocationswithout interleaved turns. AConversationTurn = Annotated[UserTurn | AssistantTurn | ToolCallTurn | ToolResultTurn, Discriminator("role")]is future-proofing the handoff does not exercise. Revisit when multi-turn agent sessions become a first-class test-case feature.
Checkpoint for Phase 2b: โ
All unit tests green (4173 passed, 1 skipped; +46 new tests for Phase 2b). make lint passes. make type-check has no new errors from Phase 2b (pre-existing errors from the Phase 2 SecretStr migration remain and are out of scope for this phase). Manual holodeck test against a persisted EvalRun is deferred until Phase 3 wires the CLI writer (US1 T030).
Phase 3: US1 Core โ EvalRun Persistence¶
Tests first (TDD) โ write all before implementation¶
- T011 [P] [US1] (TDD)
tests/unit/models/test_eval_run.py: assertEvalRunhas requiredreport: TestReportandmetadata: EvalRunMetadatafields, enforcesextra="forbid", and raises on consistency mismatch whenreport.agent_name != metadata.agent_config.name - T012 [P] [US1] (TDD)
tests/unit/models/test_eval_run.py: round-trip test โ buildEvalRunfixture,dump = run.model_dump_json(),rehydrated = EvalRun.model_validate_json(dump), assertrehydrated == run(FR-007, AC2) - T013 [P] [US1] (TDD)
tests/unit/models/test_eval_run.py: assertEvalRunMetadatahas all required fields (agent_config,prompt_version,holodeck_version,cli_args,git_commit) and enforcesextra="forbid"; assert it does NOT carry its owncreated_atfield (timestamp source of truth isreport.timestamp) - T014 [P] [US1] (TDD)
tests/unit/lib/eval_run/test_slugify.py: cover lowercase, alphanumerics+-, consecutive-collapse, leading/trailing-strip, non-ASCII โ-, spaces โ-, empty-result raisesValueError(R4) - T015 [P] [US1] (TDD)
tests/unit/lib/eval_run/test_redactor.py: for each rule โ (a) name-allowlist (api_key,password,secret) in any nested model leaf redacted to"***"; (b)SecretStr-typed field redacted to"***"even when its name is not in the allowlist; (c) non-matching fields persisted verbatim; (d)REDACTED_FIELD_NAMESis a module-levelfrozenset(FR-005 centralisation) - T016 [P] [US1] (TDD)
tests/unit/lib/eval_run/test_writer_atomic.py: (a) happy path writes final file; (b) patchedos.replaceraising mid-write leaves no.tmpfile dangling and no partial target; (c) assertfsyncis called on the temp fd before replace; (d) collision on pre-existing target path appends a 4-hex suffix and writes anyway (FR-008, FR-009b) - T017 [P] [US1] (TDD)
tests/unit/lib/eval_run/test_metadata.py:build_eval_run_metadata(...)โ git_commit captured when in a git repo (patched subprocess),Nonewhengit rev-parsefails or times out (2s bound),holodeck_versionread viaimportlib.metadata.version,cli_argsechoessys.argv[1:] - T018 [US1] (TDD)
tests/unit/lib/eval_run/test_writer_path_resolution.py: writer resolvesresults/againstagent_base_dir(not CWD); givenagent_base_dir=/tmp/subdir, target path is/tmp/subdir/results/<slug>/<ts>.json - T019 [US1] (TDD)
tests/integration/cli/test_test_persists_eval_run.py: runholodeck test <fixture>.yamlend-to-end viaCliRunner, assert exactly one JSON file appears underresults/<slug>/, assertEvalRun.model_validate_json(path.read_text())succeeds, assert--output report.mdAND persistence both happen (FR-006, AC5) - T020 [US1] (TDD)
tests/integration/cli/test_persistence_failure_does_not_mask_exit.py: chmod the target directory to0o400(or patch writer to raise), assert test exit code is unchanged and a single WARNING line is logged (FR-009) - T021 [US1] (TDD)
tests/integration/cli/test_test_persists_eval_run.py: assert no run file is written whentest_casesis empty (contracts/cli.md "Emitted when at least one test result is produced") - T022 [US1] (TDD)
tests/integration/cli/test_test_persists_eval_run.py: with a fixture containingapi_key: ${OPENAI_API_KEY}andOPENAI_API_KEY=sk-fake-not-realin env, assert the persisted JSON contains"api_key": "***"and never contains the literal"sk-fake-not-real"(AC3)
Implementation (make the tests pass, in order)¶
- T023 [P] [US1] Implement
src/holodeck/lib/eval_run/slugify.pyper research.md R4 - T024 [P] [US1] Implement
PromptVersionas a stub insrc/holodeck/models/eval_run.py(fields only, no derivation logic yet โ US2 owns derivation); enough to satisfyEvalRuntype references - T025 [US1] Implement
EvalRunMetadataandEvalRunPydantic models insrc/holodeck/models/eval_run.pyper data-model.md (withextra="forbid"and thereport.agent_name == metadata.agent_config.nameconsistency validator) - T026 [US1] Implement
src/holodeck/lib/eval_run/redactor.pywith module-levelREDACTED_FIELD_NAMES = frozenset({"api_key", "password", "secret"}),REDACTED_PLACEHOLDER = "***", andredact(agent: Agent) -> Agentthat walks the nested Pydantic tree (two-rule policy per research.md R5) - T027 [US1] Implement
src/holodeck/lib/eval_run/metadata.pywithbuild_eval_run_metadata(agent, prompt_version, argv=None) -> EvalRunMetadata(boundedgit rev-parse HEADviasubprocess.runwithtimeout=2) - T028 [US1] Implement
src/holodeck/lib/eval_run/writer.pywith atomicwrite_eval_run(run: EvalRun, agent_base_dir: Path) -> Pathusingmkstemp+fsync+os.replaceper research.md R3, filename derived fromrun.report.timestampnormalised to ms precision, colons replaced with hyphens,.jsonextension, 4-hex collision suffix - T029 [US1] Export public API from
src/holodeck/lib/eval_run/__init__.py:write_eval_run,redact,slugify,build_eval_run_metadata,REDACTED_FIELD_NAMES - T030 [US1] Wire persistence into
src/holodeck/cli/commands/test.py: afterexecutor.run()produces aTestReportand after any--outputemission, build theEvalRun, callwrite_eval_run, emit a single informational lineEvalRun persisted: results/<slug>/<ts>.json(suppressed under--quiet), catchOSError/PermissionErrorโ log WARNING and surface a single-line CLI notice without changing exit code. Fail-loud stub guard: before callingwrite_eval_run, ifprompt_version.version == "auto-00000000"ORprompt_version.body_hash == "0" * 64, raiseConfigError("prompt_version", "PromptVersion resolver stub returned sentinel values โ US2 (frontmatter parsing) has not shipped. Refusing to persist an EvalRun with a fake version; this would silently break SC-003/SC-004."). Removes the risk of US1 reaching production without US2 - T031 [US1] Pass
agent_base_dirthrough from the CLI loader to the writer (use the existingholodeck.config.context.agent_base_dircontext variable) - T032 [US1] Invoke the US2
resolve_prompt_version(...)once its implementation lands; until then, pass a stubPromptVersionwithsource="file"|"inline",body_hash="0"*64,version="auto-00000000"so US1 can ship independently (US2 replaces the stub call)
Checkpoint: All US1 unit+integration tests green. Quickstart steps ยง2 and ยง5 reproducible.
Dependencies¶
- T001โT002 have no dependencies.
- T003โT010 (SecretStr migration) block T015 (redactor test rule 2) and T022 (integration redaction test).
- T010aโT010d (MetricResult.kind) are independent of T010eโT010m and can land in parallel.
- T010eโT010f (ToolInvocation model) block T010gโT010i (pairing builder + executor wire-in).
- T010kโT010l (TestResult.token_usage) block T010m (executor wire-in).
- T011โT022 (all TDD tests) MUST be written and failing before T023โT032 implementation.
- T023โT024 block T025.
- T025โT027 block T028 (writer uses
EvalRuntype and metadata builder). - T028โT029 block T030.
- T030 blocks T031.
- T032 can be deferred until US2 is merged; US1 ships with the stub.
Parallel Opportunities¶
# Phase 2b TDD โ all independent test files can be authored in parallel:
Task: "tests/unit/models/test_metric_result_kind.py (T010a)"
Task: "tests/unit/models/test_tool_invocation.py (T010e)"
Task: "tests/unit/lib/test_runner/test_tool_invocation_builder.py (T010g)"
Task: "tests/unit/models/test_test_result_token_usage.py (T010k)"
# Phase 3 TDD โ all independent test files:
Task: "tests/unit/models/test_eval_run.py (T011โT013)"
Task: "tests/unit/lib/eval_run/test_slugify.py (T014)"
Task: "tests/unit/lib/eval_run/test_redactor.py (T015)"
Task: "tests/unit/lib/eval_run/test_writer_atomic.py (T016)"
Task: "tests/unit/lib/eval_run/test_metadata.py (T017)"
# Implementation phase โ independent leaf modules:
Task: "slugify.py (T023)"
Task: "PromptVersion stub in eval_run.py (T024)"
Acceptance Scenario Traceability¶
| AC | Covered by |
|---|---|
AC1 (file appears at results/<slug>/<ts>.json) |
T019 |
| AC2 (round-trip) | T012 |
| AC3 (secret redaction) | T015, T022 (requires T003โT010) |
| AC4 (dir auto-created) | T016 (parent.mkdir(parents=True, exist_ok=True) inside writer), covered by T019 |
AC5 (--output + persistence coexist) |
T019 |
| AC6 (slug sanitised) | T014 |
Implementation Strategy¶
- Land Phase 2 (SecretStr migration) first โ this is non-negotiable for AC3.
- Write all TDD tests (T011โT022), confirm they fail for the expected reasons.
- Implement leaf modules (slugify, PromptVersion stub, redactor, metadata, writer) in dependency order.
- Wire into the CLI last (T030โT031).
- Run
make test-unitandmake test-integrationfiltered to the new files. - Run quickstart ยง2 and ยง5 by hand to validate end-to-end.