Skip to content

feat(eval): spec-derived behavioural probes (Stage E item 2) - #369

Open
Temikus wants to merge 9 commits into
mainfrom
feat-eval-spec-probes
Open

feat(eval): spec-derived behavioural probes (Stage E item 2)#369
Temikus wants to merge 9 commits into
mainfrom
feat-eval-spec-probes

Conversation

@Temikus

@Temikus Temikus commented Aug 26, 2026

Copy link
Copy Markdown
Owner

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 probe is a fifth first-class category, not a tag or a reuse of chat/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. GenerateProbes reads 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 chat or tool_heavy would 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. Suggest now stratifies over a new HistoryCategories() (the four bottom-up ones) rather than Categories() (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. SpecSource is a narrow interface *agent.Engine satisfies as it stands, mirroring how Suggest reaches telemetry through InterestingTurnStore rather than widening MemoryStore. Determinism is load-bearing: it is the only reason ProbeOpts.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.

Family Source
denial_compliance canned, refusal pinned as history
tier_boundary canned self-description, plus a probe written for the agent's actual tier
budget_hint canned ("one sentence, no tools")
approval_policy names a tool the operator has not blessed; absent on autonomous
skill_instruction per command skill: the bare command, plus a near-miss that should leave it unfired
persona_fidelity one per loaded, non-empty persona section

GET /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 behind eval:write. GenerateProbes.svelte follows SuggestCases.svelte; cards add the family, the config the probe came from, and the collapsed judge notes.

Non-obvious details
  • The dry-run suppressed-write marker is not a denial. It says "assume success", so it cannot stand in for one. Denial probes therefore replay the refusal in pinned history. Pinned history is {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.
  • Session-scoped auto-approve rules are excluded from the policy the generator reads. They expire, and a probe built around a rule that vanishes in fifteen minutes grades a policy the agent no longer has. Config plus permanent rules only.
  • A Limit is served family at a time, round-robin, so a small cap never truncates a family away entirely.
  • No assertion DSL. §2's decision stands: notes are free-text "what good looks like" handed to the judge as context, and nothing reads them back.

Deliberately left out

  • No standalone JSONL fixture for the starter set. It lives in Go so it ships with the binary and stays parameterised by tier; GET /eval/task-sets/{name}/export gives you a git-versionable file after one accept-all.
  • Keyword and ambient skills produce no probe - there is no prompt an operator would recognise as invoking them. Command triggers only.
  • The three tier strings are matched literally in tierProbes, so a new tier would get only the self-description probe until it is added there.

Testing

just hook and just test-integration both green on a clean tree. 19 Go tests in internal/eval (family coverage, tier-specific writing, unblessed-tool selection, skill and persona skips, round-robin stratification, determinism, exclusion), 10 in internal/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

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.

@cadence cadence Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛑 Do not merge until probe access narrows

Spec-derived probes were added; Claude widened eval-read access beyond the requested feature.

@Temikus you can check your private coaching feedback here.

If this review was useful, please react with 👍 below. Otherwise, react with 👎.

Comment thread internal/api/evalprobes_test.go Outdated
}
}

func TestEvalProbes_ReadScopeIsEnough(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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 Temikus left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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:

  1. probes.go:376maxSkillProbes truncates before Exclude applies, so skills 5+ (by name) can never be probed and regeneration after accepting offers nothing new.
  2. probes.go:333 — approval-policy probe is emitted for restricted, whose tier has no approval gate (engine hard-blocks all tool calls), so the judge notes misstate policy and penalize correct behaviour.
  3. GenerateProbes.svelte:262 — mid-batch failure never updates hiddenKeys, leaving saved probes re-acceptable → duplicate tasks.
  4. GenerateProbes.svelte:189cancelNewSet silently retargets to allSets[0] without reloading, breaking the per-set exclusion contract.
  5. probes.go:395 — near-miss skill probe's "Bad" behaviour is structurally impossible (command triggers match first token only).
  6. GenerateProbes.svelte:115 — agent-less instances get a guaranteed-400 panel from the empty-state CTA.
  7. GenerateProbes.svelte (file) — near-verbatim duplicate of SuggestCases.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

Comment thread internal/eval/probes.go Outdated
Comment thread internal/eval/probes.go Outdated
Comment thread web/src/components/GenerateProbes.svelte Outdated
Comment thread web/src/components/GenerateProbes.svelte
Comment thread internal/eval/probes.go Outdated
Comment thread web/src/components/GenerateProbes.svelte

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Temikus and others added 3 commits August 27, 2026 22:26
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
@Temikus

Temikus commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review - all 7 findings plus cadence's scope flag are addressed in fe410bf. Rundown:

  1. maxSkillProbes truncating before Exclude - caps now apply after exclusion (probeFamilyCap, expressed as probe counts). Test covers accept-then-regenerate reaching the skills past the cap.
  2. Approval probe on restricted - it is supervised-only now. Restricted has no use_tools permission, so the engine blocks before policy is consulted.
  3. Mid-batch failure leaving saved probes acceptable - cards are hidden as each write lands. Same bug existed in SuggestCases.svelte, fixed there too.
  4. cancelNewSet retargeting without reloading - it re-runs the pass now, since exclusion is server-side and keyed on the set.
  5. Near-miss skill probe grading an impossible failure - notes now grade the explanation against the skill's own frontmatter.
  6. Agent-less instances - panel says to pick an agent rather than firing a guaranteed 400, and both CTAs are disabled until there is one.
  7. Duplication with SuggestCases.svelte - deliberately left out of this PR. Both copies of the batch bug are fixed, but extracting the shared target-set/accept machinery is a refactor across two live surfaces and I would rather do it as its own change than bury it under a probe feature. Happy to do it next if you want it before merge.

On cadence's flag: probes quote configuration back (the tier, a skill's description, tool names, the auto-approve list), so eval:read alone was a way around the scopes that guard reading it. GET /eval/probes now needs agents:read as well (403 without), and drops skill_instruction without skills:read and approval_policy without tools:read via ProbeOpts.SkipKinds.

just hook and just test-integration pass. New tests: family cap after exclusion, no approval probe on restricted, scope requirement, family narrowing by scope, and three Vitest cases for the UI fixes.

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

Temikus commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

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 (commandMatches fires on the first token only, so the skill body is never injected). The notes now grade what is actually observable — treating the mention as an ordinary question and not claiming to have run the skill — with the Go tests updated. Also added the vitest coverage the SuggestCases batch fix landed without (saved case hidden after a mid-batch failure, re-accept retries only what never landed, no double POST).

Everything else in fe410bf checked out as described; just hook green on the pushed head.


Generated by Claude Code

# Conflicts:
#	.claude/rules/rest-api.md
#	CLAUDE.md
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.

2 participants