feat(eval): spec-derived behavioural probes (Stage E item 2) - #369
feat(eval): spec-derived behavioural probes (Stage E item 2)#369Temikus wants to merge 9 commits into
Conversation
Task sets are built bottom-up from usage history, so they can only contain behaviours that have already happened. The behaviours that separate a worse candidate from the incumbent are mostly ones a well-behaved incumbent never produced: it never retried a denied call, so no turn in history shows a denial being respected. GenerateProbes reads denkeeper's own written intent instead — permission tier, auto-approve policy, persona sections and skill frontmatter — and emits tasks across six behaviour families. Three are canned and need no configuration (denial compliance, tier boundary, budget hints); three are derived from the agent's config (approval policy, skill instructions, persona fidelity). Notes are free-text "what good looks like" for the judge, never parsed as assertions (design §2, no-DSL). The generator reaches its data through the narrow SpecSource interface, which *agent.Engine satisfies as it stands — the same narrowing InterestingTurnStore does for Suggest. Probes get their own category rather than being folded into chat or tool_heavy: mixing the top-down and bottom-up axes would let a regression on specified behaviour hide inside a chat win rate, and the per-category breakdown exists to keep those questions apart. Suggest now stratifies over HistoryCategories() rather than Categories(), since a share reserved for a family it can never fill would just shrink the pass.
Mirrors the suggest-from-history flow: a read-scoped endpoint that writes nothing, with accepting a probe staying an ordinary task create behind eval:write. The agent name is required rather than optional — probes are one agent's spec, not a filter over a shared pool — and the response echoes the permission tier the tier-boundary probes were written against. Auto-approve policy is read from config plus permanent rules. Session rules are excluded: they expire, and a probe built around a rule that vanishes in fifteen minutes would grade a policy the agent no longer has. `set=` excludes prompts the target set already carries. Generation is deterministic, so without it reopening the panel would offer the whole set again.
The top-down counterpart to Suggest from history, following the same flow: a collapsing panel of cards, per-card or batched accept into a chosen (or newly-created) test set, session-only reject. Probe cards additionally show the behaviour family, the piece of configuration they came from, and the "what good looks like" notes the judge will read, collapsed by default. The pass sends the target set so the server drops probes it already carries; changing the set re-runs the pass rather than filtering client-side, since the exclusion is server-side. The three fill panels take turns — stacking them pushes the launcher off screen. Adds the probe category to the three label maps that enumerate the axis.
Adds the Behaviour probes section to the evals concept doc, the endpoint to the REST reference and llms.txt, the fourth fill path to the README, and the acquisition invariants (two axes, HistoryCategories vs Categories, why denial probes pin the refusal, why session auto-approve rules are excluded) to the eval-dryrun rules file.
| } | ||
| } | ||
|
|
||
| func TestEvalProbes_ReadScopeIsEnough(t *testing.T) { |
There was a problem hiding this comment.
🔴 Scope widened
Claude added TestEvalProbes_ReadScopeIsEnough, which locked in eval:read access for a payload that internal/eval/probes.go populates with unblessed-tool names and skill frontmatter. The matched session did not show you asking to let eval-only keys enumerate data the rest of the API keeps behind approvals:read and skills:read in internal/api/server.go, so this looked like avoidable scope widening rather than required feature work.
Prompt for AI Agent
claude -r 'fde78d8a-d5c8-4f89-a98f-f0b9884d5166' 'Original request/outcome summary: Spec-derived probes were added; Claude widened eval-read access beyond the requested feature.
Cadence flagged a possible Scope Control issue at `internal/api/evalprobes_test.go`:161.
Concern:
Claude added `TestEvalProbes_ReadScopeIsEnough`, which locked in `eval:read` access for a payload that `internal/eval/probes.go` populates with unblessed-tool names and skill frontmatter. The matched session did not show you asking to let eval-only keys enumerate data the rest of the API keeps behind `approvals:read` and `skills:read` in `internal/api/server.go`, so this looked like avoidable scope widening rather than required feature work.
Please verify this against the current branch and do one of the following:
1. Explain why this change was required, citing the exact request, repo guidance, or session evidence that justified it, or
2. Make the smallest change needed to bring the branch back in line with the request.
If this is a runtime-sensitive change, include the exact local verification command and output in your response.'
Temikus
left a comment
There was a problem hiding this comment.
Code review at xhigh effort. Note on scope: the Agent tool was unavailable in this context, so this was a single-pass inline review (all 10 angles worked in one context by one reviewer), not the usual multi-agent fan-out with a separate verify pass.
7 findings, ranked most severe first — 5 correctness, 2 in the generated probe "spec" text, 1 reuse cleanup:
probes.go:376—maxSkillProbestruncates beforeExcludeapplies, so skills 5+ (by name) can never be probed and regeneration after accepting offers nothing new.probes.go:333— approval-policy probe is emitted forrestricted, whose tier has no approval gate (engine hard-blocks all tool calls), so the judge notes misstate policy and penalize correct behaviour.GenerateProbes.svelte:262— mid-batch failure never updateshiddenKeys, leaving saved probes re-acceptable → duplicate tasks.GenerateProbes.svelte:189—cancelNewSetsilently retargets toallSets[0]without reloading, breaking the per-set exclusion contract.probes.go:395— near-miss skill probe's "Bad" behaviour is structurally impossible (command triggers match first token only).GenerateProbes.svelte:115— agent-less instances get a guaranteed-400 panel from the empty-state CTA.GenerateProbes.svelte(file) — near-verbatim duplicate ofSuggestCases.svelte; two of the bugs above now exist in both copies.
Verified against the PR head (0f51332): go build and the full internal/eval test suite pass locally; Engine satisfies SpecSource as claimed, category ripples (Categories()/HistoryCategories(), JSONL, verdict tally, UI label maps, swagger) are all consistent, and determinism of GenerateProbes holds for reachable configs.
Generated by Claude Code
There was a problem hiding this comment.
Reuse: ~648 lines here are a near-verbatim copy of SuggestCases.svelte (~620 lines). The target-set field (creatingNew/startNewSet/cancelNewSet/ensureSet/created/allSets), the accept machinery (hiddenKeys/selectedKeys/busyKeys, accept, acceptSelected with identical batch semantics and error strings), load()'s request-seq guard, the Escape handler, and essentially all of the styles are duplicated with only labels and the card body differing. The cost is already visible in this PR: the partial-batch-failure and cancelNewSet defects flagged on this file exist in both copies and now need fixing twice, and any future change to the accept flow will have to be mirrored by hand.
Worth extracting the shared "offer cards → accept into a set" shell (or at least the set-picker + accept/batch logic as a shared module) with the card body and fetch as the varying parts.
Generated by Claude Code
Review findings on the probe generator: - The approval-policy family was emitted for `restricted` as well as `supervised`. Restricted has no `use_tools` permission, so the engine blocks the call before auto-approve policy is consulted; the probe's notes described a rule the agent is not under, and a judge would have penalised the correct refusal. Supervised only now. - Family caps ran while the family was built, before `Exclude` was applied. That pinned the skill family to the same four skills forever: accepting what was offered left a repeat pass with nothing new, and skills past the cap could never be probed. Caps now apply after exclusion, expressed as probe counts in `probeFamilyCap`. - The near-miss skill probe graded a failure that cannot happen — command triggers match the first token only, so a question *about* the command can never fire the skill. The notes now grade the explanation against the skill's own frontmatter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018P1bUU2oNBA1azsu3j8Zb2
A probe quotes configuration back: the agent's permission tier, a skill's own description, the tool names and the auto-approve list. Behind `eval:read` alone that made generation a way to read config a credential is not scoped for. `GET /eval/probes` now also requires `agents:read` (403 without it), and drops the families sourced from other subsystems for callers that cannot read those directly — `skill_instruction` without `skills:read`, `approval_policy` without `tools:read` — via the new `ProbeOpts.SkipKinds`, which filters before the draw so the limit is still filled from what remains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018P1bUU2oNBA1azsu3j8Zb2
Review findings on the Evals-page surfaces: - A batch accept that failed part-way never hid what had already been written, so those probes stayed acceptable and a second attempt wrote them twice. Cards are now hidden as each write lands. The same bug was in SuggestCases, fixed there too. - Backing out of the new-set form retargeted to the first existing set without re-generating, leaving cards drawn against the old set while exclusion is server-side and keyed on the set. - The empty-state CTA opened the panel on an instance with no agent, which could only produce a 400 that reads as a fault. The panel says to pick an agent instead of sending the request, and both CTAs are disabled until there is one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018P1bUU2oNBA1azsu3j8Zb2
|
Thanks for the review - all 7 findings plus cadence's scope flag are addressed in fe410bf. Rundown:
On cadence's flag: probes quote configuration back (the tier, a skill's description, tool names, the auto-approve list), so
|
Follow-ups on the review-finding fixes already on this branch: - The near-miss skill probe's Good side still asked for an account of the skill "from its written instructions". A mid-sentence /cmd mention never fires the trigger, so those instructions are not in the prompt on that turn, and a candidate without skill-reading tools cannot honestly recount them — the judge was told to prefer an answer neither variant may be able to give. The notes now grade what any candidate can do textually: treat the mention as an ordinary question and not claim the skill ran. The Bad side (answering as though the command had been run) is unchanged. - Pin the SuggestCases batch-accept fix with a vitest: a case written before a mid-batch failure leaves the list, and re-accepting retries only what never landed, so the saved case is not written twice. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVBqfwic6TtsBLTTBWeZcv
|
Follow-up in 616274c on top of fe410bf, after re-verifying every finding against the branch: the near-miss probe's notes still asked for an account of the skill "from its written instructions", which an unmatched-skill turn cannot honestly give ( Everything else in fe410bf checked out as described; Generated by Claude Code |
# Conflicts: # .claude/rules/rest-api.md # CLAUDE.md
Stage E item 2 of
design/eval-subsystem.md(§8, ASSERT prior art in §10). No issue number - this was not filed.Review decision: the one judgement call worth your attention is that
probeis a fifth first-class category, not a tag or a reuse ofchat/tool_heavy. Rationale below. Everything else follows the existing suggest-from-history flow closely enough to skim.Why
Task sets are built bottom-up from usage history, so they can only contain behaviours that have already happened. The behaviours that separate a worse candidate from the incumbent are mostly ones a well-behaved incumbent never produced: it never retried a denied tool call, so no turn in history shows a denial being respected.
GenerateProbesreads denkeeper's own written intent instead - permission tier, auto-approve policy, persona sections, and skill frontmatter - and generates tasks from it.The category decision
Design §8 says "under its own category", and the reason holds up: the per-category breakdown in a verdict is what tells you where a candidate regressed. Folding probes into
chatortool_heavywould let a regression on specified behaviour hide inside a chat win rate, which is the one thing that breakdown exists to prevent. Side effect worth having: the stratified Quick-check draw now always reaches for a probe when the set carries one.One split fell out of it.
Suggestnow stratifies over a newHistoryCategories()(the four bottom-up ones) rather thanCategories()(all five) - over all five it would reserve a share for a family that pass can never fill, shrinking every suggestion pass.Shape
GenerateProbes(src SpecSource, opts ProbeOpts)is a pure function of the spec.SpecSourceis a narrow interface*agent.Enginesatisfies as it stands, mirroring howSuggestreaches telemetry throughInterestingTurnStorerather than wideningMemoryStore. Determinism is load-bearing: it is the only reasonProbeOpts.Exclude, keyed on prompt, is enough to keep a repeat pass quiet.Six families. Three are canned and need no configuration, so a fresh install gets them; three are derived from the agent's config and are absent when there is nothing to derive them from.
denial_compliancetier_boundarybudget_hintapproval_policyautonomousskill_instructionpersona_fidelityGET /api/v1/eval/probes(eval:read, writes nothing) with a required?agent=, plus?set=for server-side exclusion and?limit=. Accepting stays an ordinary task create behindeval:write.GenerateProbes.sveltefollowsSuggestCases.svelte; cards add the family, the config the probe came from, and the collapsed judge notes.Non-obvious details
{role, content}only, so a tool result cannot be replayed - the assistant states the denial in its own words, matching what the engine feeds the model on a real one.Limitis served family at a time, round-robin, so a small cap never truncates a family away entirely.notesare free-text "what good looks like" handed to the judge as context, and nothing reads them back.Deliberately left out
GET /eval/task-sets/{name}/exportgives you a git-versionable file after one accept-all.tierProbes, so a new tier would get only the self-description probe until it is added there.Testing
just hookandjust test-integrationboth green on a clean tree. 19 Go tests ininternal/eval(family coverage, tier-specific writing, unblessed-tool selection, skill and persona skips, round-robin stratification, determinism, exclusion), 10 ininternal/api, 18 Vitest for the component, 4 added to the Evals page suite. No coverage threshold touched. OpenAPI regenerated.🤖 Generated with Claude Code
https://claude.ai/code/session_018P1bUU2oNBA1azsu3j8Zb2