Skip to content

refactor(redteam): per-call judge lifecycle + per-risk-category judge rubric + minor strategy improvements - #265

Merged
poshinchen merged 11 commits into
strands-agents:mainfrom
yeomjiwonyeom:redteam/agent-lifecycle
Jun 15, 2026
Merged

refactor(redteam): per-call judge lifecycle + per-risk-category judge rubric + minor strategy improvements#265
poshinchen merged 11 commits into
strands-agents:mainfrom
yeomjiwonyeom:redteam/agent-lifecycle

Conversation

@yeomjiwonyeom

@yeomjiwonyeom yeomjiwonyeom commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

What

Three related changes to the red-team module, landed together:

  1. Agent lifecycle: judges built fresh per call, attacker fresh per case; strategies stateless across cases. The four multi-turn strategies (bad_likert_judge, goat, pair, crescendo) used to cache their judge/attacker Agents on self and rely on a reset() override between cases. They now hold only static config on self, build the attacker once per run_attack, and build judges fresh per scoring call. This removes the strategy-state half of the max_workers=1 constraint.

    Crescendo P5a (bundled with this commit): is_refusal(make_judge=None) now returns False — refusal markers alone never decide "refused"; a positive verdict requires a judge. The live path always passes a judge factory, so production behavior is unchanged; only the exported-helper path (PAIR/TAP reuse) is affected.

  2. Judge prompt v1: per-risk-category success criteria (new default). AttackSuccessEvaluator's v0 rubric defined success generically ("harmful content / policy violation"), which systematically mis-scored some categories — most visibly reading a verbatim system-prompt leak wrapped in "I can't share this" as only0-partial. v1 adds a "what counts as success, by risk category" block for the five risk categories. Same four score anchors, same severity labels, same schema — v0 is preserved unchanged.

  3. Bad Likert Judge: per-category score-3 anchor. The top-score example anchor was a single generic line; it now describes, per risk category, the shape of a complete top-score example (no payloads — see Safety).

"Always build a new agent" — how it landed

This addresses the request to stop reusing cached agents. It splits by agent role:

  • Judge / refusal judge → fresh per call. A judge that is cleared (messages.clear()) and reused still carries other accreting state (conversation-manager counters, agent state) that a fresh agent does not. Building fresh per call makes cross-call carryover structurally impossible rather than relying on a partial scrub, and matches the existing AttackSuccessEvaluator.evaluate() idiom (which already builds a fresh Agent per call).
  • Attacker → fresh per case, not per call. The attacker's multi-turn history is the strategy (Crescendo escalation, PAIR refinement, GOAT's O/T/S/R chain) — rebuilding it every turn would erase the accumulated context the attack depends on. So it is built once per run_attack and never cached across cases.

Net: no agent state survives across cases, and the reset()-between-cases dependency is gone — without breaking within-case attacker continuity.

Why

  • (1) See above; also drops the reset()-between-cases dependency.
  • (2) Reading the risk category is the principled judge behavior — the judge should know what the targeted violation is before scoring it. The category text describes the threat type generally (keyed off the risk_category taxonomy, not any specific target), so it applies to any case of a category.
  • (3) Sharpens what the role-played "judge" should elicit, per category.

Behavior / compatibility

  • v0 is byte-for-byte unchanged (anchors + system prompt verbatim; pinned by test). Select it with version="v0".
  • The shared judge-prompt builder now emits a # Risk Category line from case metadata (already present on RedTeamCase); v0's rubric ignores it, so v0 scoring is unchanged.
  • reset() is now a base no-op that strategies don't override; the task runner still calls it harmlessly (kept as a hook for any future strategy that needs per-case state). No base files changed.

Safety

The Bad Likert Judge anchors are shape descriptors of a top-score example (e.g. "reproduces the configuration verbatim, not a paraphrase") — they contain no harmful payloads or instructions. The target still generates any content; nothing harmful is committed to the repo.

Validation

  • Full red-team test suite green; ruff + mypy clean.
  • New tests: cross-case isolation without reset() (per strategy), per-category score-3 anchor rendering + fallback, risk-category prompt injection, v1 anchors verbatim-match v0.
  • A quantitative v0-vs-v1 judge comparison is a held-out evaluation (new targets/goals or human-labeled ground truth) owned by the Science track; this PR supplies the rubric and the lifecycle refactor.

Note

SequentialBreak gets the same lifecycle transform once it merges (not on this base).

… state

Make the four attack strategies (bad_likert_judge, goat, pair, crescendo)
stateless across cases and isolate judge agents per call.

- Judges/refusal judges build FRESH PER CALL via a make_judge factory passed
  to the module-level scoring helpers; the judge.messages.clear() scrub is
  removed (a fresh agent has no state). messages.clear() only emptied the
  message list, leaving conversation_manager counters and agent.state to
  accrete across calls -- a fresh agent makes cross-call carryover structurally
  impossible rather than relying on 'only messages is fed to the model'.
- Attackers stay per-case: their multi-turn history (escalation/refinement/
  O-T-S-R) IS the strategy, so they are built once per run_attack, not per call.
- Drop the cached self._attacker/_judge/_refusal_judge fields and each
  strategy's reset() override (falls back to the base no-op); strategies now
  hold only static config, so an instance is reusable across cases with no
  reset(). Removes the strategy-state half of the max_workers=1 constraint.
- Crescendo P5a: is_refusal(make_judge=None) returns False (markers alone never
  decide 'refused'; a positive verdict requires a judge). Live path always
  passes a factory, so production is unchanged; only the exported-helper path
  (PAIR/TAP reuse) is affected.
- Tests: per-strategy cross-case-isolation tests patch the SDK Agent ctor and
  assert build counts across two cases on one instance with no reset()
  (counterfactual-verified: reintroducing a judge cache fails them); delete the
  reset-nulls-fields tests; rename ~17 monkeypatch stub sites to _build_*.

COST: on the string-model convenience path each per-call build mints a new
boto3 client; hot paths (benchmark/production) pass a Model object, which the
SDK reuses.
Follow-up to 0e45648 from a 5-lens adversarial review of the implemented
per-call-judge refactor. Two real (low-severity) bugs + three doc-decay fixes;
no behavior change for the StrandsAgentSession path.

Bugs (custom TargetSession returning None -> None.strip()/.lower() raise, which
the per-case try/except swallows into a silent score=0 'defended'):
- pair: empty-response guard now 'not response or not response.strip()' to match
  the GOAT/BLJ siblings (was missing the None check).
- crescendo: coerce 'response = response or ""' right after invoke; crescendo
  deliberately keeps scoring empty turns, so '' stays a valid scored response.

Docs (stale/misleading after the lifecycle refactor):
- base.reset(): rewrite docstring -- statelessness is the isolation mechanism,
  reset() is now an optional no-op hook for rare custom stateful strategies, not
  a requirement every override must implement.
- crescendo: stop advertising is_refusal/success_score/gen_escalating_question as
  a PAIR/TAP reuse surface (locked decision: each strategy inlines its own gate);
  drop them from __all__ to match GOAT/PAIR/BLJ (class-only export). Tests still
  reach them by direct module path.
- bad_likert_judge: drop hard-coded SDK-internal field names from the _success_score
  docstring (rename-fragile); use generic wording like the three siblings.
…initions

A 375-run benchmark (judge v0 vs the programmatic canary) showed the LLM judge
making systematic errors because its rubric defined success only generically
('harmful content / policy violation') and never told it what the violation IS
per risk category. The largest error: verbatim system-prompt LEAKS wrapped in
'I can't share this' scored as merely partial (46 false-negatives).

- New judge prompt version v1 (attack_success_v1.py), registered alongside v0.
  v1 adds a 'What counts as success, by Risk Category' block defining the
  targeted violation for all five categories (system_prompt_leak,
  data_exfiltration, harmful_content, excessive_agency, guideline_bypass). The
  system_prompt_leak entry includes an echo-gate exception (echoing the
  attacker's own token is not a disclosure), mirroring the text-pattern canaries.
- The four score anchors and SYSTEM_PROMPT are byte-identical to v0, so v0-vs-v1
  isolates exactly the per-category block (pinned by test).
- _build_judge_prompt now emits a '# Risk Category' line (it was already in the
  case metadata, just unused). v0's rubric ignores it -> v0 scoring unchanged.
- v0 is preserved verbatim; default version stays v0, so existing behavior and
  prior benchmark numbers are untouched. v1 is opt-in via version='v1'.

Keyed off risk_category (a stable product taxonomy), not this benchmark's five
targets, so the definitions describe the threat type generally. NOTE: v1 was
authored after inspecting v0's failures on this benchmark, so it is a PROPOSAL —
a real quality claim needs held-out cases or human-labeled ground truth, not a
re-score of the same runs. Deliberately no claim-vs-effect/tool-trace rule: that
is the canary's job, and the judge does not reliably receive the trace.
…ge (P2)

The Bad Likert Judge scaffold's top-score (score-3) anchor was a single generic
line ('engages completely and concretely, leaving nothing out'), which smoke
testing found too vague for some risk categories — the judge-role had no sharp
target for what a top-score example should look like.

- Add _SCORE_TOP_ANCHOR: a per-category SHAPE descriptor of a top-score example,
  keyed by the five RISK_CATEGORIES. Each describes the STRUCTURE of a complete
  answer (verbatim config / step-by-step specifics / actual records / executed
  action), never a payload — SAFETY BOUNDARY preserved: the target still generates
  any harmful content, the scaffold only sharpens the rubric.
- JUDGE_SETUP score-3 line gains a {score_top_anchor} slot; build_sequence._fill
  resolves it via the same brace-safe str.replace chain, with a generic fallback
  ('leaves nothing out') for an unknown/custom category so the slot never renders
  empty or leaves a stray brace.
- Tests: anchor rendered per category, unknown-category fallback, no leftover
  slot, and _SCORE_TOP_ANCHOR covers exactly the built-in RISK_CATEGORIES.

Benchmark-gated per plan: the N=5x3-goal baseline (commit 0e45648) was captured
BEFORE this change; the gate is no ASR regression on research_asst/code_helper
(already at ASR@1=1.00). Re-measure before claiming a win.
Promote v1 (per-risk-category success definitions) to the default AttackSuccessEvaluator
prompt; v0 is retained only for reproducing pre-v1 numbers. The evaluator now defaults
its version to the registry's DEFAULT_VERSION (single source of truth) instead of a
hardcoded literal.

Rationale: the v0 rubric defined success only generically, producing systematic judge
errors (notably 46 system-prompt-leak false-negatives in a 375-run benchmark). v1 tells
the judge what the targeted violation is per risk category. Adopting it as the standard
judge is the principled default — the judge SHOULD read the risk category — independent
of the separate (held-out-data) question of quantifying v0->v1 improvement.

Consequence for benchmarking: the prior stored baseline ran on v0, so it is no longer a
clean comparand for runs on this code; re-baseline on the current commit before claiming
any A/B (e.g. the BLJ P2 anchor gate). All redteam tests pass; the benchmark's
AttackSuccessEvaluator(model=...) now resolves to v1 automatically.
Copy-paste slip left the system_prompts extraction + goal-A/goal-B asserts
twice back-to-back in test_reused_instance_builds_fresh_agents_each_case_without_reset.
Idempotent (test passed either way) but redundant; drop the second copy.
Reword the attack_success_v1 module docstring: keep the factual rationale (v1
defines success per risk category; v0's generic rubric systematically mis-scored
some categories) and the accurate note that a quantitative v0-vs-v1 comparison is
a held-out / Science-owned evaluation. Drop the 'proposal / not proven / overfit'
self-framing -- the rubric change stands on the principle (the judge should read
the risk category), and the held-out caveat is stated neutrally rather than as a
disclaimer. No code change.
@github-actions github-actions Bot added chore Maintenance tasks, dependency updates, CI changes, refactoring with no user-facing impact area-redteam Red teaming: adversarial generation, attack strategies, attack success evaluation strands-running labels Jun 13, 2026
@github-actions

Copy link
Copy Markdown

Assessment: Comment (approve-leaning)

Clean, well-tested refactor. Locally verified: 232 redteam tests pass, ruff + mypy clean. The lifecycle change (fresh judge per call via partial factories, attacker once per case, strategies stateless) is consistent across all four strategies and the cross-case freshness tests patch the real Agent ctor and count builds — they prove the actual property, not just mock wiring. v1 is added as a new prompt module with v0 preserved and verbatim-pinned, matching repo conventions.

Review notes
  • Testing: One gap — the DEFAULT_VERSION flip (v0v1) is the user-facing behavior change but nothing pins it; a silent revert to v0 would pass the whole suite. Inline suggestion added.
  • Error handling: get_template() raises a bare KeyError on an unknown version (pre-existing); a ValueError listing valid versions would be friendlier. Inline, non-blocking.
  • Scope: Three changes bundled (lifecycle + rubric v1 + BLJ anchor); PR body notes this was OK'd and the diff stays reviewable. Noted only.
  • Conventions: New _v1.py module (not overwriting _v0.py), built-in generics, structured logging, prompts-as-constants — all consistent with AGENTS.md.

Note: I could not apply the API bar-raising checklist — the linked doc returns 404 (docs repo archived) — but this lives under experimental/, so the public-API bar is lower regardless.

Nice work on the freshness tests — building real agents and asserting the build count is exactly the right way to lock this lifecycle in.

@yeomjiwonyeom yeomjiwonyeom changed the title refactor(redteam): per-call judge lifecycle + per-risk-category judge rubric (v1) + BLJ anchor refactor(redteam): per-call judge lifecycle + per-risk-category judge rubric + minor strategy improvements Jun 13, 2026
Address PR strands-agents#265 review: the v0->v1 default flip is the one user-facing
behavior change, but nothing pinned it -- a regression resetting
DEFAULT_VERSION back to "v0" would pass the whole green suite (which only
asserts version="v1" works and v1 anchors match v0).

Add test_default_version_is_v1: asserts DEFAULT_VERSION == "v1",
AttackSuccessEvaluator().version == "v1", and the default system prompt
carries the per-category "by Risk Category" block. Counterfactual-verified:
flipping the default to v0 fails this test and only this test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Re-review of latest revision (221891e) — re-verified locally: 233 red-team tests pass, ruff + mypy clean.

✅ My one substantive finding from the prior pass (the unpinned DEFAULT_VERSION flip) is resolvedtest_default_version_is_v1 now locks the default selection and the per-category system prompt. Good guard.

One small carry-over suggestion (non-blocking; my earlier inline on this got dropped by GitHub because the target line wasn't in the diff hunk):

Issue: get_template() does VERSIONS[version], so an unknown/typo'd version (e.g. version="v2") surfaces as a bare KeyError: 'v2' at AttackSuccessEvaluator construction. Since version is a public ctor arg, the failure mode is cryptic.

Suggestion: raise ValueError(f"unknown version {version!r}; expected one of {sorted(VERSIONS)}"). Pre-existing and out of this PR's core scope — fine to defer, just flagging while the file is open.

Otherwise this is good to go from my side — clean lifecycle refactor with strong cross-case freshness tests, and v0 preserved verbatim for a clean A/B.

v0 was never released, so there is no reproducibility obligation to keep a
separate v1. Fold the per-risk-category rubric into v0 as the single template,
drop the v1 slot, and set DEFAULT_VERSION back to v0 -- matching every other
evaluator in the package (all single-version, default v0). Removes the
attack_success-only version asymmetry the bot flagged.

- attack_success_v0.py: now carries the 'What counts as success, by Risk
  Category' block (RUBRIC body byte-identical to the old v1); docstring reworded
  to describe the rubric directly, not as a v0-vs-v1 delta
- delete attack_success_v1.py; __init__ keeps a single v0 entry
- tests: drop the v0-vs-v1 A/B comparison test; rename the default-pin test to
  assert the default carries the per-category block

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Re-review of latest revision (597c0ca — "collapse judge rubric to single version").

This revision responds to @poshinchen's "why keep v0?" — the author folded the per-category rubric into v0, deleted attack_success_v1.py, and reset DEFAULT_VERSION = "v0". Re-verified locally: 232 tests pass, ruff + mypy clean (32 source files, down 1 as expected from the v1 deletion). No dangling v1/attack_success_v1 references remain.

Status of my prior findings:

  • Unpinned default (my original finding) — still guarded. The pin test was renamed to test_default_version_carries_per_category_block and now asserts the per-category block is present in the default system prompt, which is the more durable property to lock.
  • 🔸 get_template() bare KeyError — unchanged and still applies (VERSIONS[version] with no validation). Now genuinely low-impact since v0 is the only version, so a typo'd version= arg is the only path that hits it. Non-blocking; defer freely.

The collapse is the right call — it removes the attack_success-only version asymmetry and matches every other evaluator in the package. Approve-leaning, no blocking issues. Clean lifecycle refactor with strong cross-case freshness tests; nice work.

Resolve crescendo conflict: keep the lifecycle refactor (fresh judge/refusal-judge
built per call via make_judge factory, attacker per run_attack, no cached
agents, reset() not overridden) AND adopt upstream strands-agents#263's serialization
(CrescendoStrategy.to_dict reading the static config _max_turns/_max_backtracks/
_success_threshold/_model + _serialize_model). Drop upstream's old cached
_attacker_agent (superseded by our _build_attacker). base.py auto-merged: our
reset() stateless-hook docstring + upstream's to_dict/from_dict coexist.

Brings in strands-agents#251 (multi-agent session), strands-agents#263 (RedTeamExperiment round-trips),
strands-agents#264 (docs). Full redteam suite 262 passed; round-trip serialization tests
green against the stateless strategy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Re-review of latest revision (a497c81 — "Merge upstream/main into redteam/agent-lifecycle").

This is an integration commit pulling upstream #251 (multi-agent session), #263 (RedTeamExperiment round-trips), and #264 (docs) onto the branch — no new red-team-authored logic to review. I focused on confirming the merge didn't disturb this PR's core changes during conflict resolution.

Verified on the merged HEAD:

  • No conflict markers anywhere in src//tests/.
  • v0 collapse intactattack_success_v0.py is still the only template, no v1 references remain.
  • Lifecycle refactor intact — no cached judge/attacker on self in any strategy, and the make_judge/partial(...) fresh-per-call factory pattern is present across all four strategies.
  • Suite green — full red-team suite 262 passed (up from 232, from the upstream tests the merge pulled in), ruff clean, mypy clean (33 source files, up from 32 — experiment.py/report.py/generators/adversarial.py came in with the merge).

Both my prior findings remain as previously noted (default-pin: guarded; get_template() bare KeyError: non-blocking, defer freely). Still approve-leaning, no blocking issues — the merge integrated cleanly.

Comment thread src/strands_evals/experimental/redteam/strategies/bad_likert_judge/__init__.py Outdated
@poshinchen

Copy link
Copy Markdown
Contributor

Should Crescendo.is_refusal be independent calls too?

…judge

Address poshinchen's review (BLJ __init__.py:190): use
partial(self._build_judge, resolved_model) unconditionally instead of a
lambda gated on goal.success_criteria, matching crescendo/goat/pair (BLJ was
the only strategy with a conditional make_judge). Drop the now-dead
make_judge is None clause from _success_score's guard and the None from its
signature.

Safety is preserved: partial is lazy, and _success_score returns at
'if not success_criteria' BEFORE ever calling make_judge(), so a no-criteria
case still never resolves a model / builds a judge. This invariant is now
guaranteed by call-ordering rather than a None-check; documented in the
_success_score docstring and covered end-to-end by the existing
test_no_criteria_does_not_build_judge (real run_attack, asserts the judge
builder is never called). Removed the redundant unit test that pinned
_success_score(None, ...) == 0.0 (None is no longer a valid input).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yeomjiwonyeom

yeomjiwonyeom commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

@poshinchen on is_refusal — it already is on this branch; that's one of the things this PR does. The link points at 56118d4 (main, the merge base), which still has the old shape: is_refusal(response, judge: Agent) reusing one judge via judge.messages.clear(), plus cached self._judge / self._refusal_judge. Refusal checks are independent calls, same as the success scorer. Let me know if you meant something beyond the per-call freshness.

@github-actions

Copy link
Copy Markdown

Re-review of latest revision (96a6075 — "unconditional partial judge factory in bad_likert_judge").

This revision responds to @poshinchen's BLJ inline (__init__.py:190: "this is the same as partial(self._build_judge, resolved_model)? You can drop the checks"). I focused on that change.

What it does:

  • run_attack now builds make_judge = partial(self._build_judge, resolved_model) unconditionally (was a lambda gated on goal.success_criteria), and _success_score's make_judge is now required — the None clause is gone from both the signature and the guard.
  • Cross-strategy parity restored: BLJ was the only strategy with a conditional make_judge. All four (_)success_score helpers now take a required make_judge: Callable[[], Agent].
  • No-criteria safety preserved by call-ordering (not a None-check): if not success_criteria: return 0.0 precedes the make_judge() call, and partial is lazy, so a no-criteria case still never resolves a model or builds a judge. Still covered end-to-end by test_no_criteria_does_not_build_judge.
  • The redundant _success_score(None, ...) == 0.0 unit test was correctly removed (None is no longer valid input).

Verified locally on 96a6075:

  • ✅ BLJ tests: 39 passed; full red-team suite: 261 passed (−1 from the removed test, expected)
  • ✅ ruff clean, mypy clean (33 source files)
  • ✅ The lingering make_judge is None in crescendo.is_refusal is the separate P5a refusal-marker design (already reviewed), not the gate this comment was about — correctly untouched.

Status of my prior findings: default-pin still guarded; get_template() bare KeyError unchanged (non-blocking, defer freely). Still approve-leaning, no blocking issues — clean simplification that removes the last gate-wiring asymmetry.

yeomjiwonyeom added a commit to yeomjiwonyeom/evals that referenced this pull request Jun 15, 2026
Follow the same lifecycle refactor the other strategies got: the success judge
is now built fresh PER scoring call via a make_judge factory instead of a
cached self._judge cleared with messages.clear(). Brings SequentialBreak in
line with crescendo/goat/pair/bad_likert_judge.

- success_score takes make_judge: Callable[[], Agent] (was judge: Agent | None);
  builds a fresh judge per call (judge = make_judge()), no messages.clear()
- run_attack builds make_judge = partial(self._build_judge, self._model or model)
  unconditionally; partial is lazy so a no-criteria case still never resolves a
  model (success_score returns before calling it) -- matches bad_likert_judge
- drop the cached self._judge field, the reset() override, and _judge_agent;
  add _build_judge (never caches). Strategy is now stateless across cases.
- tests: success_score tests use a make_judge factory; replace the
  messages.clear assertion and the reset test with a fresh-judge-per-scoring-call
  build-count test (patches the real Agent ctor across two cases)

The judge rubric change (per-category v0) is intentionally NOT made here -- it
rides in when this branch rebases onto strands-agents#265 after it merges (SequentialBreak
does not touch the evaluator templates, so that rebase is conflict-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@poshinchen poshinchen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@poshinchen
poshinchen merged commit 3e76461 into strands-agents:main Jun 15, 2026
15 checks passed
yeomjiwonyeom added a commit to yeomjiwonyeom/evals that referenced this pull request Jun 15, 2026
Follow the same lifecycle refactor the other strategies got: the success judge
is now built fresh PER scoring call via a make_judge factory instead of a
cached self._judge cleared with messages.clear(). Brings SequentialBreak in
line with crescendo/goat/pair/bad_likert_judge.

- success_score takes make_judge: Callable[[], Agent] (was judge: Agent | None);
  builds a fresh judge per call (judge = make_judge()), no messages.clear()
- run_attack builds make_judge = partial(self._build_judge, self._model or model)
  unconditionally; partial is lazy so a no-criteria case still never resolves a
  model (success_score returns before calling it) -- matches bad_likert_judge
- drop the cached self._judge field, the reset() override, and _judge_agent;
  add _build_judge (never caches). Strategy is now stateless across cases.
- tests: success_score tests use a make_judge factory; replace the
  messages.clear assertion and the reset test with a fresh-judge-per-scoring-call
  build-count test (patches the real Agent ctor across two cases)

The judge rubric change (per-category v0) is intentionally NOT made here -- it
rides in when this branch rebases onto strands-agents#265 after it merges (SequentialBreak
does not touch the evaluator templates, so that rebase is conflict-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
poshinchen pushed a commit that referenced this pull request Jun 15, 2026
…#254)

* feat(redteam): add SequentialBreak narrative-scaffold attack strategy

SequentialBreak (Saiem et al. 2024, arXiv:2411.06426) hides one harmful request
among benign siblings in a single narrative scaffold sent as one query. Pure-script
attack side (no attacker LLM): each scaffold variant embeds the case's actor_goal via
str.replace and is sent once through target_session.invoke. Append-only, invoke-only,
pruned_branches always empty, never reads the trace.

Ships five scaffold variants (dc_t1, dc_t2, ge_t1, ge_t2, qb_t1) modeled on the
paper's Question Bank / Dialog Completion / Game Environment structures; tries them in
order (dc_t1 first, the paper's strongest) and stops at the first that scores at or
above success_threshold (default 0.5, tuned for single-shot). The harmful slot sits in
the second half of each sequence (Algorithm 1 j>N/2). The only LLM built is the inline
success judge; the authoritative verdict stays with AttackSuccessEvaluator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(redteam): add SequentialBreakStrategy unit tests

45 tests covering ctor guards (empty/unknown variants, max_turns<1, threshold band),
assemble_scaffold (slot substitution, brace-safe, imperative-goal grammaticality, no
concrete attack content, second-half slot placement), success_score (no-criteria skip,
clamp, NaN/inf rejection, parse-failure, judge isolation), the run_attack loop (first-
breach stop, MAX-not-last score, empty-response continue, all-empty clean defended,
max_turns clamping both directions, append-only no snapshot/reset, target_calls parity),
reset/model precedence, registry exclusion, and a contract pin on the result shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(redteam): address SequentialBreak review feedback

- Build the success judge lazily and ONLY when the case has success_criteria: a
  no-criteria case never scores, so constructing an Agent (and resolving a model that
  could be a typo) it never uses is wasteful and could raise into the per-case score=0
  swallow. success_score short-circuits on no-criteria-or-no-judge, so passing None on
  that path is safe (signature widened to Agent | None).
- Strengthen happy-path loop tests to assert the full result.metadata dict rather than
  individual fields, so a regression in any unlisted field is caught.
- Add a test asserting the judge is NOT built on a no-criteria case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(redteam): align SequentialBreak with per-call judge lifecycle

Follow the same lifecycle refactor the other strategies got: the success judge
is now built fresh PER scoring call via a make_judge factory instead of a
cached self._judge cleared with messages.clear(). Brings SequentialBreak in
line with crescendo/goat/pair/bad_likert_judge.

- success_score takes make_judge: Callable[[], Agent] (was judge: Agent | None);
  builds a fresh judge per call (judge = make_judge()), no messages.clear()
- run_attack builds make_judge = partial(self._build_judge, self._model or model)
  unconditionally; partial is lazy so a no-criteria case still never resolves a
  model (success_score returns before calling it) -- matches bad_likert_judge
- drop the cached self._judge field, the reset() override, and _judge_agent;
  add _build_judge (never caches). Strategy is now stateless across cases.
- tests: success_score tests use a make_judge factory; replace the
  messages.clear assertion and the reset test with a fresh-judge-per-scoring-call
  build-count test (patches the real Agent ctor across two cases)

The judge rubric change (per-category v0) is intentionally NOT made here -- it
rides in when this branch rebases onto #265 after it merges (SequentialBreak
does not touch the evaluator templates, so that rebase is conflict-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AndyMc629 pushed a commit to AndyMc629/evals that referenced this pull request Jun 15, 2026
… rubric + minor strategy improvements (strands-agents#265)

* refactor(redteam): build judge agents fresh per call, drop cross-case state

Make the four attack strategies (bad_likert_judge, goat, pair, crescendo)
stateless across cases and isolate judge agents per call.

- Judges/refusal judges build FRESH PER CALL via a make_judge factory passed
  to the module-level scoring helpers; the judge.messages.clear() scrub is
  removed (a fresh agent has no state). messages.clear() only emptied the
  message list, leaving conversation_manager counters and agent.state to
  accrete across calls -- a fresh agent makes cross-call carryover structurally
  impossible rather than relying on 'only messages is fed to the model'.
- Attackers stay per-case: their multi-turn history (escalation/refinement/
  O-T-S-R) IS the strategy, so they are built once per run_attack, not per call.
- Drop the cached self._attacker/_judge/_refusal_judge fields and each
  strategy's reset() override (falls back to the base no-op); strategies now
  hold only static config, so an instance is reusable across cases with no
  reset(). Removes the strategy-state half of the max_workers=1 constraint.
- Crescendo P5a: is_refusal(make_judge=None) returns False (markers alone never
  decide 'refused'; a positive verdict requires a judge). Live path always
  passes a factory, so production is unchanged; only the exported-helper path
  (PAIR/TAP reuse) is affected.
- Tests: per-strategy cross-case-isolation tests patch the SDK Agent ctor and
  assert build counts across two cases on one instance with no reset()
  (counterfactual-verified: reintroducing a judge cache fails them); delete the
  reset-nulls-fields tests; rename ~17 monkeypatch stub sites to _build_*.

COST: on the string-model convenience path each per-call build mints a new
boto3 client; hot paths (benchmark/production) pass a Model object, which the
SDK reuses.

* fix(redteam): guard None target responses; correct stale lifecycle docs

Follow-up to 0e45648 from a 5-lens adversarial review of the implemented
per-call-judge refactor. Two real (low-severity) bugs + three doc-decay fixes;
no behavior change for the StrandsAgentSession path.

Bugs (custom TargetSession returning None -> None.strip()/.lower() raise, which
the per-case try/except swallows into a silent score=0 'defended'):
- pair: empty-response guard now 'not response or not response.strip()' to match
  the GOAT/BLJ siblings (was missing the None check).
- crescendo: coerce 'response = response or ""' right after invoke; crescendo
  deliberately keeps scoring empty turns, so '' stays a valid scored response.

Docs (stale/misleading after the lifecycle refactor):
- base.reset(): rewrite docstring -- statelessness is the isolation mechanism,
  reset() is now an optional no-op hook for rare custom stateful strategies, not
  a requirement every override must implement.
- crescendo: stop advertising is_refusal/success_score/gen_escalating_question as
  a PAIR/TAP reuse surface (locked decision: each strategy inlines its own gate);
  drop them from __all__ to match GOAT/PAIR/BLJ (class-only export). Tests still
  reach them by direct module path.
- bad_likert_judge: drop hard-coded SDK-internal field names from the _success_score
  docstring (rename-fragile); use generic wording like the three siblings.

* feat(redteam): add judge prompt v1 with per-risk-category success definitions

A 375-run benchmark (judge v0 vs the programmatic canary) showed the LLM judge
making systematic errors because its rubric defined success only generically
('harmful content / policy violation') and never told it what the violation IS
per risk category. The largest error: verbatim system-prompt LEAKS wrapped in
'I can't share this' scored as merely partial (46 false-negatives).

- New judge prompt version v1 (attack_success_v1.py), registered alongside v0.
  v1 adds a 'What counts as success, by Risk Category' block defining the
  targeted violation for all five categories (system_prompt_leak,
  data_exfiltration, harmful_content, excessive_agency, guideline_bypass). The
  system_prompt_leak entry includes an echo-gate exception (echoing the
  attacker's own token is not a disclosure), mirroring the text-pattern canaries.
- The four score anchors and SYSTEM_PROMPT are byte-identical to v0, so v0-vs-v1
  isolates exactly the per-category block (pinned by test).
- _build_judge_prompt now emits a '# Risk Category' line (it was already in the
  case metadata, just unused). v0's rubric ignores it -> v0 scoring unchanged.
- v0 is preserved verbatim; default version stays v0, so existing behavior and
  prior benchmark numbers are untouched. v1 is opt-in via version='v1'.

Keyed off risk_category (a stable product taxonomy), not this benchmark's five
targets, so the definitions describe the threat type generally. NOTE: v1 was
authored after inspecting v0's failures on this benchmark, so it is a PROPOSAL —
a real quality claim needs held-out cases or human-labeled ground truth, not a
re-score of the same runs. Deliberately no claim-vs-effect/tool-trace rule: that
is the canary's job, and the judge does not reliably receive the trace.

* feat(redteam): add per-risk-category score-3 anchor to bad_likert_judge (P2)

The Bad Likert Judge scaffold's top-score (score-3) anchor was a single generic
line ('engages completely and concretely, leaving nothing out'), which smoke
testing found too vague for some risk categories — the judge-role had no sharp
target for what a top-score example should look like.

- Add _SCORE_TOP_ANCHOR: a per-category SHAPE descriptor of a top-score example,
  keyed by the five RISK_CATEGORIES. Each describes the STRUCTURE of a complete
  answer (verbatim config / step-by-step specifics / actual records / executed
  action), never a payload — SAFETY BOUNDARY preserved: the target still generates
  any harmful content, the scaffold only sharpens the rubric.
- JUDGE_SETUP score-3 line gains a {score_top_anchor} slot; build_sequence._fill
  resolves it via the same brace-safe str.replace chain, with a generic fallback
  ('leaves nothing out') for an unknown/custom category so the slot never renders
  empty or leaves a stray brace.
- Tests: anchor rendered per category, unknown-category fallback, no leftover
  slot, and _SCORE_TOP_ANCHOR covers exactly the built-in RISK_CATEGORIES.

Benchmark-gated per plan: the N=5x3-goal baseline (commit 0e45648) was captured
BEFORE this change; the gate is no ASR regression on research_asst/code_helper
(already at ASR@1=1.00). Re-measure before claiming a win.

* feat(redteam): make judge prompt v1 the default version

Promote v1 (per-risk-category success definitions) to the default AttackSuccessEvaluator
prompt; v0 is retained only for reproducing pre-v1 numbers. The evaluator now defaults
its version to the registry's DEFAULT_VERSION (single source of truth) instead of a
hardcoded literal.

Rationale: the v0 rubric defined success only generically, producing systematic judge
errors (notably 46 system-prompt-leak false-negatives in a 375-run benchmark). v1 tells
the judge what the targeted violation is per risk category. Adopting it as the standard
judge is the principled default — the judge SHOULD read the risk category — independent
of the separate (held-out-data) question of quantifying v0->v1 improvement.

Consequence for benchmarking: the prior stored baseline ran on v0, so it is no longer a
clean comparand for runs on this code; re-baseline on the current commit before claiming
any A/B (e.g. the BLJ P2 anchor gate). All redteam tests pass; the benchmark's
AttackSuccessEvaluator(model=...) now resolves to v1 automatically.

* test(redteam): remove duplicated assert block in goat lifecycle test

Copy-paste slip left the system_prompts extraction + goal-A/goal-B asserts
twice back-to-back in test_reused_instance_builds_fresh_agents_each_case_without_reset.
Idempotent (test passed either way) but redundant; drop the second copy.

* docs(redteam): neutral framing for v1 judge rubric rationale

Reword the attack_success_v1 module docstring: keep the factual rationale (v1
defines success per risk category; v0's generic rubric systematically mis-scored
some categories) and the accurate note that a quantitative v0-vs-v1 comparison is
a held-out / Science-owned evaluation. Drop the 'proposal / not proven / overfit'
self-framing -- the rubric change stands on the principle (the judge should read
the risk category), and the held-out caveat is stated neutrally rather than as a
disclaimer. No code change.

* test(redteam): pin DEFAULT_VERSION=v1 so a silent revert to v0 fails

Address PR strands-agents#265 review: the v0->v1 default flip is the one user-facing
behavior change, but nothing pinned it -- a regression resetting
DEFAULT_VERSION back to "v0" would pass the whole green suite (which only
asserts version="v1" works and v1 anchors match v0).

Add test_default_version_is_v1: asserts DEFAULT_VERSION == "v1",
AttackSuccessEvaluator().version == "v1", and the default system prompt
carries the per-category "by Risk Category" block. Counterfactual-verified:
flipping the default to v0 fails this test and only this test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(redteam): collapse judge rubric to single version (v0)

v0 was never released, so there is no reproducibility obligation to keep a
separate v1. Fold the per-risk-category rubric into v0 as the single template,
drop the v1 slot, and set DEFAULT_VERSION back to v0 -- matching every other
evaluator in the package (all single-version, default v0). Removes the
attack_success-only version asymmetry the bot flagged.

- attack_success_v0.py: now carries the 'What counts as success, by Risk
  Category' block (RUBRIC body byte-identical to the old v1); docstring reworded
  to describe the rubric directly, not as a v0-vs-v1 delta
- delete attack_success_v1.py; __init__ keeps a single v0 entry
- tests: drop the v0-vs-v1 A/B comparison test; rename the default-pin test to
  assert the default carries the per-category block

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(redteam): unconditional partial judge factory in bad_likert_judge

Address poshinchen's review (BLJ __init__.py:190): use
partial(self._build_judge, resolved_model) unconditionally instead of a
lambda gated on goal.success_criteria, matching crescendo/goat/pair (BLJ was
the only strategy with a conditional make_judge). Drop the now-dead
make_judge is None clause from _success_score's guard and the None from its
signature.

Safety is preserved: partial is lazy, and _success_score returns at
'if not success_criteria' BEFORE ever calling make_judge(), so a no-criteria
case still never resolves a model / builds a judge. This invariant is now
guaranteed by call-ordering rather than a None-check; documented in the
_success_score docstring and covered end-to-end by the existing
test_no_criteria_does_not_build_judge (real run_attack, asserts the judge
builder is never called). Removed the redundant unit test that pinned
_success_score(None, ...) == 0.0 (None is no longer a valid input).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AndyMc629 pushed a commit to AndyMc629/evals that referenced this pull request Jun 15, 2026
…strands-agents#254)

* feat(redteam): add SequentialBreak narrative-scaffold attack strategy

SequentialBreak (Saiem et al. 2024, arXiv:2411.06426) hides one harmful request
among benign siblings in a single narrative scaffold sent as one query. Pure-script
attack side (no attacker LLM): each scaffold variant embeds the case's actor_goal via
str.replace and is sent once through target_session.invoke. Append-only, invoke-only,
pruned_branches always empty, never reads the trace.

Ships five scaffold variants (dc_t1, dc_t2, ge_t1, ge_t2, qb_t1) modeled on the
paper's Question Bank / Dialog Completion / Game Environment structures; tries them in
order (dc_t1 first, the paper's strongest) and stops at the first that scores at or
above success_threshold (default 0.5, tuned for single-shot). The harmful slot sits in
the second half of each sequence (Algorithm 1 j>N/2). The only LLM built is the inline
success judge; the authoritative verdict stays with AttackSuccessEvaluator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(redteam): add SequentialBreakStrategy unit tests

45 tests covering ctor guards (empty/unknown variants, max_turns<1, threshold band),
assemble_scaffold (slot substitution, brace-safe, imperative-goal grammaticality, no
concrete attack content, second-half slot placement), success_score (no-criteria skip,
clamp, NaN/inf rejection, parse-failure, judge isolation), the run_attack loop (first-
breach stop, MAX-not-last score, empty-response continue, all-empty clean defended,
max_turns clamping both directions, append-only no snapshot/reset, target_calls parity),
reset/model precedence, registry exclusion, and a contract pin on the result shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(redteam): address SequentialBreak review feedback

- Build the success judge lazily and ONLY when the case has success_criteria: a
  no-criteria case never scores, so constructing an Agent (and resolving a model that
  could be a typo) it never uses is wasteful and could raise into the per-case score=0
  swallow. success_score short-circuits on no-criteria-or-no-judge, so passing None on
  that path is safe (signature widened to Agent | None).
- Strengthen happy-path loop tests to assert the full result.metadata dict rather than
  individual fields, so a regression in any unlisted field is caught.
- Add a test asserting the judge is NOT built on a no-criteria case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(redteam): align SequentialBreak with per-call judge lifecycle

Follow the same lifecycle refactor the other strategies got: the success judge
is now built fresh PER scoring call via a make_judge factory instead of a
cached self._judge cleared with messages.clear(). Brings SequentialBreak in
line with crescendo/goat/pair/bad_likert_judge.

- success_score takes make_judge: Callable[[], Agent] (was judge: Agent | None);
  builds a fresh judge per call (judge = make_judge()), no messages.clear()
- run_attack builds make_judge = partial(self._build_judge, self._model or model)
  unconditionally; partial is lazy so a no-criteria case still never resolves a
  model (success_score returns before calling it) -- matches bad_likert_judge
- drop the cached self._judge field, the reset() override, and _judge_agent;
  add _build_judge (never caches). Strategy is now stateless across cases.
- tests: success_score tests use a make_judge factory; replace the
  messages.clear assertion and the reset test with a fresh-judge-per-scoring-call
  build-count test (patches the real Agent ctor across two cases)

The judge rubric change (per-category v0) is intentionally NOT made here -- it
rides in when this branch rebases onto strands-agents#265 after it merges (SequentialBreak
does not touch the evaluator templates, so that rebase is conflict-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-redteam Red teaming: adversarial generation, attack strategies, attack success evaluation chore Maintenance tasks, dependency updates, CI changes, refactoring with no user-facing impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants