refactor(redteam): per-call judge lifecycle + per-risk-category judge rubric + minor strategy improvements - #265
Conversation
… 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.
|
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 Review notes
Note: I could not apply the API bar-raising checklist — the linked doc returns 404 (docs repo archived) — but this lives under Nice work on the freshness tests — building real agents and asserting the build count is exactly the right way to lock this lifecycle in. |
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>
|
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 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: Suggestion: 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>
|
Re-review of latest revision ( This revision responds to @poshinchen's "why keep v0?" — the author folded the per-category rubric into Status of my prior findings:
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>
|
Re-review of latest revision ( 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:
Both my prior findings remain as previously noted (default-pin: guarded; |
|
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>
|
@poshinchen on |
|
Re-review of latest revision ( This revision responds to @poshinchen's BLJ inline ( What it does:
Verified locally on
Status of my prior findings: default-pin still guarded; |
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>
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>
…#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>
… 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>
…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>
What
Three related changes to the red-team module, landed together:
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 onselfand rely on areset()override between cases. They now hold only static config onself, build the attacker once perrun_attack, and build judges fresh per scoring call. This removes the strategy-state half of themax_workers=1constraint.Crescendo P5a (bundled with this commit):
is_refusal(make_judge=None)now returnsFalse— 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.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.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:
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 existingAttackSuccessEvaluator.evaluate()idiom (which already builds a freshAgentper call).run_attackand 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
reset()-between-cases dependency.risk_categorytaxonomy, not any specific target), so it applies to any case of a category.Behavior / compatibility
version="v0".# Risk Categoryline from case metadata (already present onRedTeamCase); 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
reset()(per strategy), per-category score-3 anchor rendering + fallback, risk-category prompt injection, v1 anchors verbatim-match v0.Note
SequentialBreak gets the same lifecycle transform once it merges (not on this base).