Skip to content

Commit 31dc8dd

Browse files
authored
Merge pull request #46 from evalshift/fix/native-message-shapes
fix(capture): promote every provider's native message shape; add capture sync --repair
2 parents 3a96974 + 679cd70 commit 31dc8dd

35 files changed

Lines changed: 7084 additions & 83 deletions

‎CHANGELOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `capture sync --repair` rebuilds the `inputs` and `history` of cases an
13+
earlier sync built from stringified message blocks, keeping every other
14+
field and the case's input variable name, and re-downloads exactly the
15+
captures it needs from `captures.store` even though they are promoted. It
16+
also rebuilds cases an earlier sync promoted with empty `inputs` (OpenAI
17+
`exclude_none`, Responses, LangChain), which get no warning.
18+
`capture sync` and `validate` warn when a suite still holds stringified
19+
cases; without `--repair`, sync leaves both kinds untouched. A repaired case that now
20+
duplicates another promoted case is named
21+
(`repaired case <a> duplicates <b>`) and nothing is deleted.
1222
- `evalshift init` writes the `semantic` evaluator active for every provider.
1323
Anthropic and DeepSeek have no embeddings endpoint, so the scaffold borrows
1424
OpenAI's or Gemini's — whichever key you already have — and `init --ci`
@@ -46,6 +56,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4656

4757
### Fixed
4858

59+
- `capture sync` and `capture promote` now read every wrapped provider's own
60+
message shape. Claude follow-up turns no longer get Python-repr strings
61+
(`"[{'id': 'toolu_1', … 'type': 'tool_use'}]"`) in their history: `tool_use`
62+
blocks become the assistant's `tool_calls`, `tool_result` blocks become `tool`
63+
messages, thinking is dropped, and a prompt-cached `system` prompt or a
64+
text-block user turn becomes plain text. OpenAI Chat histories with
65+
`content: null` no longer abort the whole sync with a traceback, and OpenAI
66+
Responses and LangChain captures recover their `inputs` instead of promoting
67+
them empty.
68+
- A turn whose recorded messages carry an image, a document, audio, a
69+
server-tool result or an unknown block type is skipped (`capture promote`
70+
refuses it) instead of being promoted with that content stringified or lost;
71+
`--allow-errored` does not apply.
72+
- An OpenAI `developer` message is now kept as the system prompt instead of
73+
being dropped: it merges with a leading system prompt (a Responses
74+
`instructions=` included), and a system or `developer` message after the
75+
first turn is dropped with a warning instead of aborting the sync; run
76+
`capture sync --force` to refresh cases promoted before.
77+
- A capture no valid case can be built from is skipped with the reason
78+
(`capture promote` refuses it) instead of aborting the whole `capture sync`
79+
with a validation traceback; `--allow-errored` does not apply.
4980
- A judge or embedding model with no API key no longer fails silently on every
5081
pair: it is skipped (advisory) or refused (blocking) before any call.
5182
- The bare `text-embedding-3-small` default now resolves to OpenAI, so its key

‎DOCS.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ captures:
173173
store: s3://acme-evals/support-agent
174174
```
175175
176-
and `capture sync` and `capture list` fetch what is new before they read the local directory — captures are immutable and toolsets content-addressed, so only objects missing locally are downloaded, captures already promoted are skipped (so `capture clean` never causes a re-download), and each file is written atomically. Bucket keys are untrusted: one not shaped like the SDK's layout — a suite directory containing `:`, `/`, `\`, NUL or `..`, or starting or ending with `.` or a space, included — is counted as malformed and never written (a key with a trailing newline included), and no key can place a file outside `.evalshift/`; folder-marker keys ending in `/` (the S3 console's "Create folder", ADLS directories) are skipped without being counted. One summary line reports it (`fetched 12 capture(s) and 1 toolset(s) from s3://…`; with `capture list --json` it goes to stderr, so stdout stays pure JSON). `--since 24h` limits a fetch to recently written captures, `--offline` skips it, and `capture fetch` does only that step. `promote`, `diff` and `clean` stay local. Install the matching client library — `pip install boto3`, `google-cloud-storage` or `azure-storage-blob azure-identity` — and authenticate with the provider's normal chain (an OIDC role in CI, `aws sso login` / `gcloud auth application-default login` / `az login` locally); credentials never go in the URI, and a value containing `@` or `?` fails to load; no rejected value is echoed back, at most its scheme. A missing client library exits 1 naming the `pip install <package>` to run; a list or download failure exits 1, so `sync` promotes nothing from a partial fetch, with a hint naming the provider's credential chain (`aws sso login` / `AWS_PROFILE` / the CI OIDC role, `gcloud auth application-default login`, `az login`); a local write failure names the file and asks you to check free disk space and permissions instead. If `evalshift.yaml` cannot be read or does not validate, `sync` and `list` skip the fetch and work on the local directory as before, warning only when the file has a top-level `captures:` key. Full field reference: [`captures`](docs/configuration.md#captures).
176+
and `capture sync` and `capture list` fetch what is new before they read the local directory — captures are immutable and toolsets content-addressed, so only objects missing locally are downloaded, captures already promoted are skipped (so `capture clean` never causes a re-download; `capture sync --repair` alone re-fetches the promoted captures its rebuilds need, past `--since` too), and each file is written atomically. Bucket keys are untrusted: one not shaped like the SDK's layout — a suite directory containing `:`, `/`, `\`, NUL or `..`, or starting or ending with `.` or a space, included — is counted as malformed and never written (a key with a trailing newline included), and no key can place a file outside `.evalshift/`; folder-marker keys ending in `/` (the S3 console's "Create folder", ADLS directories) are skipped without being counted. One summary line reports it (`fetched 12 capture(s) and 1 toolset(s) from s3://…`; with `capture list --json` it goes to stderr, so stdout stays pure JSON). `--since 24h` limits a fetch to recently written captures, `--offline` skips it, and `capture fetch` does only that step. `promote`, `diff` and `clean` stay local. Install the matching client library — `pip install boto3`, `google-cloud-storage` or `azure-storage-blob azure-identity` — and authenticate with the provider's normal chain (an OIDC role in CI, `aws sso login` / `gcloud auth application-default login` / `az login` locally); credentials never go in the URI, and a value containing `@` or `?` fails to load; no rejected value is echoed back, at most its scheme. A missing client library exits 1 naming the `pip install <package>` to run; a list or download failure exits 1, so `sync` promotes nothing from a partial fetch, with a hint naming the provider's credential chain (`aws sso login` / `AWS_PROFILE` / the CI OIDC role, `gcloud auth application-default login`, `az login`); a local write failure names the file and asks you to check free disk space and permissions instead. If `evalshift.yaml` cannot be read or does not validate, `sync` and `list` skip the fetch and work on the local directory as before, warning only when the file has a top-level `captures:` key. Full field reference: [`captures`](docs/configuration.md#captures).
177177
178178
**`capture sync`** is the workhorse. Per suite it:
179179

@@ -183,7 +183,7 @@ and `capture sync` and `capture list` fetch what is new before they read the loc
183183
4. Carries the recorded generation settings onto the promoted example: the first `model_call`'s `metadata["generation_config"]` is copied verbatim to the example's `generation_config`, so the replay dispatches under the temperature, structured-output, and tool-choice settings the production call actually used. `capture sync` appends `wired generation config for N case(s)` to its summary line when any promoted capture carried one.
184184
5. Records what the captured run cost on the case file as `cost_usd` (its `model_call` events summed) plus `cost_source`. The SDK never prices anything — a `model_call`'s `cost_usd` is `0.0` unless the app's own instrumentation set it, and the provider client wrappers record tokens but no cost by design — so each event with tokens and no cost is priced at promotion from litellm's price table for its own `model_id` (aliases and provider prefixes resolve through the registry first). `cost_source` is `"recorded"` when every non-zero part came from the capture (a recorded cost is never re-estimated), `"estimated"` when any part was priced here, and absent with `cost_usd: 0.0` when nothing was priced — no tokens, or a model litellm does not price. An unpriced local / self-hosted model is the normal case, not a failure: it stays at 0 silently, with no warning. The example in `golden.jsonl` never carries the figure; it is provenance of the capture, not something a replay reproduces.
185185
6. Skips captures whose replayed content duplicates an already-promoted case or an earlier capture in the same run — duplicate examples inflate *n* and corrupt the paired statistics (`--keep-duplicates` opts out). Dedup is seeded from the cases already in the suite dir, so it holds across repeated syncs.
186-
7. Skips captures whose turn recorded an `error` event — a turn that died before the agent acted is not ground truth, and promoting it would assert `expected_no_tools: true` on a question that needed a tool. `--allow-errored` promotes it anyway (still never asserting `expected_no_tools`). `capture promote` exits non-zero on the same condition. Separately and unconditionally — `--allow-errored` does not help — a capture whose first `model_call` has no `toolset_ref` is refused: the SDK did not record what tools were offered, so there is nothing to carry, and re-capturing with a current `evalshift-sdk` is the only fix.
186+
7. Skips captures whose turn recorded an `error` event — a turn that died before the agent acted is not ground truth, and promoting it would assert `expected_no_tools: true` on a question that needed a tool. `--allow-errored` promotes it anyway (still never asserting `expected_no_tools`). `capture promote` exits non-zero on the same condition. Separately and unconditionally — `--allow-errored` does not help — a capture whose first `model_call` has no `toolset_ref` is refused: the SDK did not record what tools were offered, so there is nothing to carry, and re-capturing with a current `evalshift-sdk` is the only fix. Also unconditionally, a capture whose recorded messages carry content a text replay can't carry — an image, a document, audio, a server-tool result, an unknown block type — is skipped and counted on its own (`capture promote` refuses it): a replay without it is not ground truth. Last, as a safety net, a capture whose case fails the suite schema's own validation in a way none of these rules anticipates is skipped with pydantic's first error and counted on its own (`skipped N capture(s) that could not form a valid case`; `capture promote` refuses it) instead of aborting the sync; hand-write that case or report the capture.
187187
8. Warns when two captures claim the same `(conversation_id, turn_index)` (a retried turn), and when a promoted turn contains a failed tool result (`error`, or `{"success": false}`). Both stay warnings — see [Agent evals → What does not belong in a golden suite](docs/agents.md).
188188
9. Writes `.evalshift/suites/<suite>/golden.jsonl` and rewrites the managed `suites:` block in `evalshift.yaml` (between the `>>> evalshift suites` markers).
189189
10. After the write (or after printing the block for you to paste), checks the CI pin: if a workflow under `.github/workflows/` uses `evalshift/evalshift-action` with an `evalshift-version` older than this CLI, with no pin at all, or with pins that are all newer than this CLI, it prints a warning naming the workflow and job plus the fix — the exact `evalshift-version: "<this version>"` line to set, or, for a newer pin, `pip install -U evalshift` locally. Advisory only — sync never edits a workflow and the exit code is unchanged. See [Pin drift](#pin-drift).
@@ -658,7 +658,7 @@ Rules and limitations:
658658

659659
- `history` may contain at most one `system` message, and it must be first. `history: null` means single-turn; `history: []` is a conversational example with no prefix.
660660
- Re-driving a whole conversation (letting the candidate generate turn 1 and feeding *its own* reply into turn 2) is deliberately unsupported — it breaks the paired-comparison contract.
661-
- Captures recorded with the SDK's messages-list convention recover `history` **verbatim**; captures that only recorded a bare string get history **reconstructed** from sibling turn captures (an approximation: assistant replies come from final outputs, intermediate tool exchanges are absent). Prefer the messages-list convention in your instrumentation.
661+
- Captures recorded with the SDK's messages-list convention recover `history` from that list, translated from the provider's own message shape (Claude blocks, OpenAI Chat and Responses, LangChain roles) into the replay shape with text and tool calls kept; thinking is dropped, an OpenAI `developer` message merges into the leading system prompt (a system or `developer` message after the first turn is dropped with a warning — a replay carries one leading system prompt), and a turn carrying an image or other non-text content is skipped. Captures that only recorded a bare string get history **reconstructed** from sibling turn captures (an approximation: assistant replies come from final outputs, intermediate tool exchanges are absent). Prefer the messages-list convention in your instrumentation. Cases an earlier sync promoted with stringified message blocks are flagged by `capture sync` and `validate`; `capture sync --repair` rebuilds them, along with cases an earlier sync left with empty `inputs` (those get no warning). It takes their `inputs` and `history` from the capture, so it replaces hand edits to those two fields (a redaction, say) — review the diff before committing; it leaves a turn as it is, naming the missing turns, when its history was reconstructed from sibling turns and one of those turns' capture is gone. A plain sync rebuilds a stringified case offline to recognise a capture that duplicates it, but a case left with empty `inputs` can't be rebuilt without its capture: on such suites run `--repair` before a plain sync, or a duplicate capture may be promoted (the duplicate warning after `--repair` names it) — see [Provider message shapes](docs/conversations.md#provider-message-shapes).
662662
- History carries the **agent loop**: an `assistant` turn may have `tool_calls` (`{id, name, arguments}`), and a `tool` message carries that call's result keyed by `tool_call_id` (required — an unpairable result is a load-time error). They are dispatched in the OpenAI wire shape and translated per provider by LiteLLM, so the candidate model sees the tool results production saw. A promoted `tool` message with no recorded id is keyed, in order, to the preceding assistant turn's still-unanswered call (how id-less providers such as Gemini pair them), with a warning; only a result with no call to answer gets a positional `_pos<N>` id.
663663
- The report shows a `turn N` badge on conversational examples.
664664

@@ -910,7 +910,7 @@ All `run` flags, plus `--gate` · `--policy-gate` · `--open` · `--push` · `--
910910
**`evalshift capture list [suite]`** — `--json` · `-c/--config` · `--offline`
911911
**`evalshift capture fetch`** — `--suite` · `--since <30m|24h|7d|ISO>` · `-c/--config` (needs `captures.store`)
912912
**`evalshift capture promote <capture-id>`** — `--as <case-id>` · `--suite` · `--input-var` (default `input`) · `--tag` (repeatable) · `--strict-args` · `--names-only` · `--tool-count` · `--rounds {first,all}` (default `first`) · `--allow-errored` · `-f/--force`
913-
**`evalshift capture sync`** — `--suite` · `--input-var` · `--tag` · `--strict-args` · `--names-only` · `--tool-count` · `--rounds {first,all}` · `--allow-errored` · `-c/--config` · `-f/--force` · `--write/--print` (default write) · `--keep-duplicates` · `--since` · `--offline`
913+
**`evalshift capture sync`** — `--suite` · `--input-var` · `--tag` · `--strict-args` · `--names-only` · `--tool-count` · `--rounds {first,all}` · `--allow-errored` · `-c/--config` · `-f/--force` · `--write/--print` (default write) · `--keep-duplicates` · `--since` · `--offline` · `--repair`
914914
**`evalshift capture clean [suite]`** — `--promoted` (default) · `--all` · `-y/--yes`
915915
**`evalshift capture diff <cap-a> <cap-b>`**
916916

@@ -928,7 +928,7 @@ All `run` flags, plus `--gate` · `--policy-gate` · `--open` · `--push` · `--
928928

929929
### Hidden debug commands
930930

931-
**`evalshift validate`** — load config + suite + prompts, cross-check compatibility. `-s/--suite` · `-c/--config`. After the success line, prints the [Pin drift](#pin-drift) warning if a workflow pins an older or newer CLI, or none at all (advisory; exit code unchanged, and a no-op in CI where the running CLI is the pin).
931+
**`evalshift validate`** — load config + suite + prompts, cross-check compatibility. `-s/--suite` · `-c/--config`. After the success line, prints the [Pin drift](#pin-drift) warning if a workflow pins an older or newer CLI, or none at all (advisory; exit code unchanged, and a no-op in CI where the running CLI is the pin). It also warns when the suite's examples carry message blocks an earlier `capture sync` stringified, pointing at `capture sync --repair` (advisory; see [Provider message shapes](docs/conversations.md#provider-message-shapes)).
932932
**`evalshift test-call`** — one live smoke-test call. `-m/--model` (required) · `-p/--prompt` · `-t/--temperature` (0–2, default 0) · `--max-tokens` (1–8192, default 256) · `--tools <file>` (prints a ToolTrace)
933933

934934
---

‎docs/configuration.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,8 +1097,9 @@ key or connection string); the error names at most its scheme. Under the prefix
10971097

10981098
What the fetch does: it lists the bucket, downloads only objects missing locally (captures
10991099
are immutable and toolsets content-addressed, so "exists" means "current"), skips captures
1100-
already promoted into a suite (so `capture clean` never causes a re-download), and writes
1101-
each file atomically. `--since 24h` (or `7d`, `30m`, an ISO date) limits a fetch to recently
1100+
already promoted into a suite (so `capture clean` never causes a re-download;
1101+
`capture sync --repair` alone re-fetches the promoted captures its rebuilds need, past `--since`
1102+
too), and writes each file atomically. `--since 24h` (or `7d`, `30m`, an ISO date) limits a fetch to recently
11021103
written captures; `--offline` skips it and works with what is already local. Retention in the bucket
11031104
is yours to set with a lifecycle rule; the SDK's `max_captures` does not apply there.
11041105

@@ -1154,7 +1155,12 @@ content duplicates an already-promoted case (or an earlier capture in the
11541155
same run) are skipped too — duplicate examples inflate *n* and corrupt the
11551156
paired statistics (`--keep-duplicates` opts out). The dedup set is seeded
11561157
from the cases already in the suite dir, so re-syncing after recording more
1157-
captures can't slip a duplicate past it. Useful flags: `--input-var`
1158+
captures can't slip a duplicate past it. Captures whose messages carry an
1159+
image or other non-text content are skipped too (see
1160+
[Provider message shapes](conversations.md#provider-message-shapes)), as is
1161+
a capture no valid case can be built from, and
1162+
`--repair` rebuilds cases an earlier sync built from stringified message
1163+
blocks. Useful flags: `--input-var`
11581164
(default `input`), `--suite <name>` to filter to one suite, `--tag`,
11591165
`--names-only`, `--tool-count`, `--strict-args`, `--rounds first|all`
11601166
(`first`, the default, scores single-shot replay against round 1; `all`

0 commit comments

Comments
 (0)