← Migration

Migration to v0.2.0

LLMConfig.max_cost removed; attacker budget moves to Controller(task_cost_cap_usd=...)

LLMConfig is now pure access (model, api_base, api_key); its max_cost field is removed. The attacker’s per-task cost cap is now a Controller parameter, task_cost_cap_usd (USD, None = unlimited). A fresh LLMClient is built per task, so it caps the attacker’s cumulative spend per task and resets each task (a full run costs up to about num_tasks * task_cost_cap_usd).

Judges and other direct LLMClient users are no longer budgeted through LLMConfig. A judge built from a plain LLMConfig is now unlimited; a caller that wants a cap passes it to the client directly: LLMClient(config, cost_cap_usd=...).

Persisted output (SCHEMA_VERSION bumped 23): the llm_config block no longer carries max_cost; the attacker cap is written as a top-level task_cost_cap_usd in both the claim-level file and each per-task detail file.

Migration:

Per-task scope: scope and read_only may be a ScopeResolver (additive)

Controller(scope=...) and Controller(read_only=...) now each also accept a ScopeResolver (Callable[[Task], Scope], exported as superred.core.ScopeResolver), resolved once per task (independently of each other), in addition to the classic fixed Scope. This is purely additive: passing a frozenset behaves exactly as before.

from superred.core import ScopeResolver

controller = Controller(
    scope=lambda task: frozenset({tag_for(task)}),  # resolver, called once per task
    scope_label="per-goal",                          # required in this mode
    ...,
)

What’s new:

Read-only access via a read_only Controller argument (replaces ReadOnly/ScopeSpec)

scope keeps its original meaning, the read & write surface (visible AND injectable). A new optional read_only argument adds tags that are visible but not injectable. read_only defaults to empty, so the whole scope is read & write, identical to the previous behavior; existing Controller(scope=...) calls are unaffected. Access level is expressed by which of the two plain Scope sets a tag lands in; the short-lived ReadOnly, ScopeSpec, and resolve_scope symbols are removed.

# See the whole system subtree, inject only the prompt:
controller = Controller(
    scope=frozenset({prompt_tag}),     # read & write
    read_only=frozenset({system_tag}), # visible only
    ...,
)

A read_only tag already covered by scope has no effect (read & write overrules: only scope drives injection, so it stays injectable). scope and read_only cannot both be empty. ThreatModelResult gains a read_only: Scope field, and persisted JSON carries read_only (replacing read_only_scope); runs with read-only tags get a __ro_{read_only} filename component.

At optimizer.initialize(), the controllables list now means exactly “the surfaces the optimizer can inject into” (filtered by the read & write scope). A read-only controllable, visible but not injectable, is no longer in that list; it is re-presented in observables (as an ObservableValue with content=None). For an all-read & write run this changes nothing (no read-only controllables exist).

Controller is now one threat model: target_factory, single scope, single llm_config

The controller was previously a fan-out: it iterated the Cartesian product of scopes × llm_configs and returned a ControllerResult wrapping a list of ThreatModelResult. It now models exactly one threat model, a single (scope, llm_config) combination, and returns a ThreatModelResult directly. Sweeping multiple threat models is the caller’s job.

The same change replaces target: Target with target_factory: TargetFactory so each task in the claim gets its own target instance and tasks run concurrently up to target_factory.concurrency.

# Before
controller = Controller(
    optimizer_factory=lambda: MyOptimizer(),
    target=MyTarget(api_key="sk-..."),
    security_claim=claim,
    llm_configs=[cfg_a, cfg_b],          # list, optional
)
result = await controller.run(scopes=[scope_a, scope_b])   # Cartesian product
task_results = result.threat_model_results[0].task_results  # one ThreatModelResult per (scope, cfg)

# After
from superred.core.controller import Controller, TargetFactory

target_factory = TargetFactory(
    create=lambda: MyTarget(api_key="sk-..."),
    concurrency=8,                       # default 1 (sequential, old per-task behavior)
)
controller = Controller(
    optimizer_factory=lambda: MyOptimizer(),
    target_factory=target_factory,
    security_claim=claim,
    scope=scope_a,                       # required, single Scope
    llm_config=cfg_a,                    # optional, single LLMConfig
)
tmr = await controller.run()             # -> ThreatModelResult (not ControllerResult)
task_results = tmr.task_results

# Multiple threat models = multiple controllers at the caller:
import asyncio, itertools
results = await asyncio.gather(*(
    Controller(
        scope=s, llm_config=c,
        optimizer_factory=..., target_factory=target_factory,
        security_claim=claim,
    ).run()
    for s, c in itertools.product([scope_a, scope_b], [cfg_a, cfg_b])
))

What changed:

Migration:

  1. Wrap target construction: target=MyTarget(...)target_factory=TargetFactory(create=lambda: MyTarget(...)). For tests, use TargetFactory.singleton(my_target) (concurrency=1).
  2. Move scope/llm_config into the constructor: drop run(scopes=[s]), add scope=s and llm_config=cfg to Controller(...).
  3. Replace result.threat_model_results[0] with the direct return.
  4. For sweeps, build one Controller per (scope, llm_config) combination and gather them at the experiment level.
  5. For real targets that can serve parallel requests (chatbots wrapping API calls), bump concurrency= to match the deployed rate limit.

Optimizer.initialize() signature change

The llm_client parameter on Optimizer.initialize() is now required (LLMClient, not LLMClient | None). The base class stores the client, subclasses must call super().initialize(...) for self.llm to work.

# Before
async def initialize(
    self,
    goal: Goal,
    controllables: list[Controllable],
    observables: list[ObservableValue],
    llm_client: LLMClient | None = None,
) -> None: ...

# After
async def initialize(
    self,
    goal: Goal,
    controllables: list[Controllable],
    observables: list[ObservableValue],
    llm_client: LLMClient,
) -> None: ...

Impact: All existing Optimizer subclasses must update their initialize() signature to accept llm_client: LLMClient (required, not optional) and call super().initialize(...).

Migration: Change llm_client: LLMClient | None = None to llm_client: LLMClient and add a super() call.

class MyOptimizer(Optimizer):
    async def initialize(
        self, goal, controllables, observables, llm_client,
    ) -> None:
        await super().initialize(goal, controllables, observables, llm_client)
        # self.llm is now available
        ...

Controller llm_config is now required

The llm_config parameter on Controller is now required (no longer optional). LLM access is part of the threat model and must always be specified.

Migration: Pass llm_config=LLMConfig(...) to the Controller constructor.

LLMUsage is no longer optional on result types

RunResult.llm_usage and TaskResult.llm_usage are now LLMUsage (not LLMUsage | None). They are always present since llm_config is required.

Migration: Remove is not None checks around llm_usage access.

New core dependency: litellm

The superred package now depends on litellm>=1.0. This is pulled in automatically via pip. No action needed unless you pin dependencies, add litellm to your pins.

Cost-based budget enforcement

Budget enforcement is based on USD cost computed via litellm.completion_cost(), instead of the previous max_calls/max_input_tokens/max_output_tokens fields. LLMUsage now tracks calls: int and cost: float only (token fields removed). The cost cap is set via Controller(task_cost_cap_usd=...) for the attacker (see the entry above); other LLMClient users pass cost_cap_usd.

Migration: Replace max_calls=N / max_input_tokens=N / max_output_tokens=N budgets with a USD cap via Controller(task_cost_cap_usd=X.XX). Remove any references to input_tokens or output_tokens on LLMUsage.

Controller takes optimizer_factory instead of optimizer

A fresh Optimizer is now built per task via optimizer_factory, which replaces the old optimizer: Optimizer constructor arg. Combined with the threat-model collapse above, the migration is:

# Before
controller = Controller(
    optimizer=my_optimizer,
    target=target,
    security_claim=claim,
    security_domain_tag=external_tag,
    llm_config=llm_config,
)

# After (see also the target_factory / scope / llm_config section above)
controller = Controller(
    optimizer_factory=lambda: MyOptimizer(),
    target_factory=TargetFactory(create=lambda: MyTarget(...)),
    security_claim=claim,
    scope=frozenset({external_tag}),
    llm_config=llm_config,
)

New types: Scope, scope_includes, ThreatModelResult, OptimizerFactory

All are exported from superred.core and superred.core.types.

Feedback scoping: RunEndEvent change, evaluation order, include_feedback

Three interrelated changes to how feedback flows to the optimizer:

1. RunEndEvent carries evaluation, not trajectory

RunEndEvent.trajectory has been replaced with RunEndEvent.evaluation: EvaluationResult | None (default None). The optimizer no longer receives the trajectory through RunEndEvent, use self.current_trajectory instead (available via _dispatch).

# Before
if isinstance(event, RunEndEvent):
    for entry in event.trajectory.snapshot():
        ...

# After
if isinstance(event, RunEndEvent):
    # Read evaluation directly from the event:
    if event.evaluation is not None:
        score = event.evaluation.primary_score.value
    # Or from the trajectory:
    if self.current_trajectory is not None:
        for entry in self.current_trajectory.snapshot():
            ...

2. RunEndEvent is now persisted to the trajectory

RunEndEvent is persisted to the trajectory (previously it was not). Its security_domain is set from the active scope (required for trajectory validation). Evaluation happens before RunEndEvent is sent. The order is: target.run()evaluate()RunEndEvent (with evaluation) → trajectory.close().

3. Controller include_feedback flag

Controller.__init__ accepts include_feedback: bool = True. When True, RunEndEvent.evaluation carries the filtered EvaluationResult; when False, evaluation is None. The optimizer reads feedback from event.evaluation on RunEndEvent, or from past trajectories (since RunEndEvent is persisted).

Impact: Optimizers that accessed RunEndEvent.trajectory must switch to self.current_trajectory or event.evaluation. FeedbackEvent has been removed entirely, remove any imports or isinstance checks for it. Read feedback from event.evaluation on RunEndEvent or from the trajectory instead.

Migration:

  1. Replace event.trajectory on RunEndEvent with self.current_trajectory or event.evaluation.
  2. Remove all FeedbackEvent imports and handlers, the type no longer exists.
  3. To read feedback, use event.evaluation on RunEndEvent or query past trajectories.

Target.cleanup() renamed to reset_ephemeral_state()

The Target ABC lifecycle method cleanup() is now reset_ephemeral_state(), with a sharper contract: it resets only ephemeral (per-run) state, leaving durable state intact. Durable state (for example a memory bank accumulated by a memory-injection attack) persists across runs within a task and is discarded only when the controller obtains a fresh instance from the TargetFactory between tasks. Resources and identity live for the instance’s lifetime and are released in teardown().

Behavior is unchanged: the controller still calls the method after each run’s evaluation, and once more post-task. Only the name and the documented contract change.

# Before
class MyTarget(Target):
    async def cleanup(self) -> None:
        self._last_response = ""

# After
class MyTarget(Target):
    async def reset_ephemeral_state(self) -> None:
        self._last_response = ""   # reset ephemeral state only; leave durable state intact

Impact: Every Target subclass must rename its cleanup() override to reset_ephemeral_state(). The method is abstract, so a subclass that still defines cleanup() is no longer instantiable (TypeError at construction).

Migration: Rename async def cleanup to async def reset_ephemeral_state. If the old method reset state that an attack should be able to accumulate across runs (for example a memory store), move that state out of the method so it survives between runs.