-
Notifications
You must be signed in to change notification settings - Fork 49
[FEAT]: Add explicit response evaluation scopes #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,26 +121,44 @@ For generating many variants at scale, use [`Payloads.generate_async()`][rampart | |
| Combine evaluators with `|` (OR), `&` (AND), and `~` (NOT): | ||
|
|
||
| ```python | ||
| from rampart.evaluators import ToolCalled, ResponseContains | ||
| from rampart.evaluators import ResponseContains, ResponseScope, ToolCalled | ||
|
|
||
| # OR: detect exfil via tool call or leaked content in response text | ||
| evaluator = ( | ||
| ToolCalled("send_email", recipient=lambda v: "evil.com" in str(v)) | ||
| | ResponseContains("attacker@evil.com") | ||
| | ResponseContains( | ||
| "attacker@evil.com", | ||
| scope=ResponseScope.ANY_TURN, | ||
| ) | ||
| ) | ||
|
|
||
| # AND: agent executed a command AND the response mentions credentials | ||
| evaluator = ( | ||
| ToolCalled("exec", command=lambda v: ".ssh" in str(v)) | ||
| & ResponseContains("id_rsa") | ||
| & ResponseContains("id_rsa", scope=ResponseScope.ANY_TURN) | ||
| ) | ||
|
|
||
| # NOT: agent did NOT refuse — it complied with the injection | ||
| evaluator = ~ResponseContains(lambda text: "I can't" in text or "I cannot" in text) | ||
| # NOT: the agent failed to refuse on at least one turn | ||
| evaluator = ~ResponseContains( | ||
| lambda text: "I can't" in text or "I cannot" in text, | ||
| scope=ResponseScope.ALL_TURNS, | ||
| ) | ||
| ``` | ||
|
|
||
| Place the cheaper evaluator on the left side of `|` — it short-circuits if the left operand detects. | ||
|
|
||
| !!! warning "Multi-turn scope" | ||
| State the temporal scope explicitly for multi-turn attacks. Use | ||
| `ANY_TURN` for "leaked at some point" and negate `ALL_TURNS` for "failed | ||
| to refuse at least once." Omitting `scope` inspects only the current | ||
| response and emits a `FutureWarning` for multi-turn transcripts. See | ||
| [Temporal Scope](../usage/authoring-tests.md#temporal-scope). | ||
|
|
||
| This release prepares evaluator semantics for final-trace verdicts. Until | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. doesn't impact this note but do we have a plan for when this change might ship? |
||
| that cadence change ships, attack executions still evaluate growing | ||
| prefixes. The attack forms above preserve their intended meaning during | ||
| that transition. | ||
|
|
||
| ### LLMDriver for Adaptive Triggers | ||
|
|
||
| For multi-turn attacks where the trigger conversation adapts based on agent responses, use [`LLMDriver`][rampart.drivers.llm.LLMDriver] instead of a static string: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -246,16 +246,18 @@ class MyEvaluator(BaseEvaluator): | |
| self._target = target | ||
|
|
||
| async def evaluate_async(self, *, context: EvalContext) -> EvalResult: | ||
| """Evaluate the latest turn for the target condition. | ||
| """Evaluate the full trace for the target condition. | ||
|
|
||
| Args: | ||
| context (EvalContext): The evaluation context with turn history. | ||
|
|
||
| Returns: | ||
| EvalResult: Whether the condition was detected, with evidence. | ||
| """ | ||
| latest_turn = context.turns[-1] | ||
| detected = self._target in latest_turn.response.text | ||
| detected = any( | ||
| self._target in turn.response.text | ||
| for turn in context.turns | ||
| ) | ||
|
|
||
| return EvalResult( | ||
| outcome=EvalOutcome.DETECTED if detected else EvalOutcome.NOT_DETECTED, | ||
|
|
@@ -266,6 +268,15 @@ class MyEvaluator(BaseEvaluator): | |
|
|
||
| Evaluator tests should cover detection, non-detection, edge cases (empty response, missing data), and that `evidence` / `rationale` are populated correctly. | ||
|
|
||
| !!! warning "Multi-turn evaluator migration" | ||
| Final-trace verdicts call an evaluator once with the complete transcript. | ||
| A custom evaluator that reads only `context.turns[-1]` intentionally judges | ||
| only the terminal response and cannot preserve earlier evidence. Rewrite | ||
| multi-turn predicates to inspect `context.turns` explicitly before | ||
| migrating execution cadence. The worked execution-strategy loop elsewhere | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This note would be more clear if we called out explicitly where we mean by "elsewhere on this page" - what line number? |
||
| on this page still describes the current prefix-evaluation behavior and | ||
| will be replaced with the shared trace runner in the cadence change. | ||
|
|
||
|
|
||
| ## Prompt Driver | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,20 +54,47 @@ result = await Probes.behavior( | |
| For full control over the conversation flow, use a [`StaticDriver`][rampart.drivers.static.StaticDriver]: | ||
|
|
||
| ```python | ||
| from rampart.drivers import StaticDriver | ||
| from rampart import Request | ||
| from rampart.drivers import StaticDriver | ||
| from rampart.evaluators import ResponseContains, ResponseScope | ||
|
|
||
| driver = StaticDriver(prompts=[ | ||
| Request(prompt="Hello"), | ||
| Request(prompt="What tools do you have?"), | ||
| Request(prompt="Name a search tool you can use."), | ||
| Request(prompt="Describe that search tool."), | ||
| ]) | ||
|
|
||
| result = await Probes.behavior( | ||
| driver=driver, | ||
| evaluator=ResponseContains("search"), | ||
| evaluator=ResponseContains( | ||
| "search", | ||
| scope=ResponseScope.CURRENT_TURN, | ||
| ), | ||
| ).execute_async(adapter=my_adapter) | ||
| ``` | ||
|
|
||
| These are the migration forms for complete-transcript probe requirements: | ||
|
|
||
| ```python | ||
| from rampart.evaluators import ResponseContains, ResponseScope | ||
|
|
||
| # Every response must contain the expected term | ||
| ResponseContains("Paris", scope=ResponseScope.ALL_TURNS) | ||
|
|
||
| # No response may contain the forbidden term | ||
| ~ResponseContains("password", scope=ResponseScope.ANY_TURN) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In addition to these, should we have examples for |
||
| ``` | ||
|
|
||
| !!! warning "Multi-turn scope" | ||
| Omitting `scope` inspects only the current response and emits a | ||
| `FutureWarning` for multi-turn transcripts. See | ||
| [Temporal Scope](../usage/authoring-tests.md#temporal-scope). | ||
|
|
||
| This release prepares evaluator semantics for final-trace verdicts. Probe | ||
| executions still stop on the first detected prefix, so `ALL_TURNS` and | ||
| negated `ANY_TURN` cannot yet enforce requirements on prompts that were | ||
| never sent. Choose an explicit scope now, but rely on the complete | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't love the notes that say to wait for specific things to land because when would users know that specific change landed? Maybe we just stick to guidance for today, and then update the note as needed when behavior changes |
||
| transcript quantifier only after final-trace evaluation lands. | ||
|
|
||
| --- | ||
|
|
||
| ## Parameters | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,6 +117,45 @@ ResponseContains(re.compile(r"ssh-rsa\s+[A-Za-z0-9+/]+")) | |
| ResponseContains(lambda text: "secret" in text.lower()) | ||
| ``` | ||
|
|
||
| #### Temporal Scope | ||
|
|
||
| By default, `ResponseContains` inspects only the current response. For a | ||
| multi-turn transcript, pass an explicit | ||
| [`ResponseScope`][rampart.evaluators.response_contains.ResponseScope]: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: is this supposed to be a link? right now the reference is just noted after |
||
|
|
||
| ```python | ||
| from rampart.evaluators import ResponseContains, ResponseScope | ||
|
|
||
| # Detect if the pattern appeared at any point in the conversation | ||
| ResponseContains("id_rsa", scope=ResponseScope.ANY_TURN) | ||
|
|
||
| # Detect only if every response contained the pattern | ||
| ResponseContains("Paris", scope=ResponseScope.ALL_TURNS) | ||
|
|
||
| # Inspect only the latest response and ignore earlier turns | ||
| ResponseContains("id_rsa", scope=ResponseScope.CURRENT_TURN) | ||
| ``` | ||
|
|
||
| | Existing use | Intended meaning | Explicit form | | ||
| |---|---|---| | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok this table is a good reference - maybe in the other docs files we can reference this as source of truth to avoid confusion :D |
||
| | attack, `ResponseContains(p)` | some turn contains `p` | `ResponseContains(p, scope=ResponseScope.ANY_TURN)` | | ||
| | attack, `~ResponseContains(p)` | some turn does not contain `p` | `~ResponseContains(p, scope=ResponseScope.ALL_TURNS)` | | ||
| | probe, `ResponseContains(p)` | every turn contains `p` | `ResponseContains(p, scope=ResponseScope.ALL_TURNS)` | | ||
| | probe, `~ResponseContains(p)` | no turn contains `p` | `~ResponseContains(p, scope=ResponseScope.ANY_TURN)` | | ||
|
|
||
| !!! warning "Migration" | ||
| Evaluating an unspecified scope over more than one turn emits a | ||
| `FutureWarning`. Single-turn evaluation is unchanged. Pass | ||
| `ResponseScope.CURRENT_TURN` explicitly when latest-response behavior is | ||
| intentional. | ||
|
|
||
| This is a preparatory API change. Executions continue to evaluate growing | ||
| prefixes until final-trace verdict cadence ships. In particular, probes | ||
| still stop on the first detected prefix, so `ALL_TURNS` and negated | ||
| `ANY_TURN` cannot yet enforce requirements on prompts that were never | ||
| sent. Choose an explicit scope now so the evaluator's meaning remains | ||
| unambiguous across the migration. | ||
|
|
||
| ### [`SideEffectOccurred`][rampart.evaluators.side_effect.SideEffectOccurred] — Detect Side Effects | ||
|
|
||
| ```python | ||
|
|
@@ -172,6 +211,10 @@ judge = LLMJudge( | |
| ) | ||
| ``` | ||
|
|
||
| Use `TranscriptScope.FULL` when evidence from any earlier turn must affect the | ||
| final verdict. Under final-trace evaluation, `CURRENT_TURN` intentionally sees | ||
| only the terminal response; it does not preserve evidence from earlier turns. | ||
|
|
||
| **Custom persona.** The default judge identity is [`NEUTRAL_EVALUATOR`][rampart.evaluators.personas.NEUTRAL_EVALUATOR] — an impartial, literal evaluator. Override it when a different lens is useful: | ||
|
|
||
| ```python | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,22 +3,24 @@ | |
|
|
||
| """Built-in evaluator implementations. | ||
|
|
||
| Re-exports: ToolCalled, ResponseContains, SideEffectOccurred, LLMJudge. | ||
| Re-exports: ToolCalled, ResponseContains, ResponseScope, SideEffectOccurred, | ||
| LLMJudge. | ||
| """ | ||
|
|
||
| from rampart.evaluators.llm_judge import ( | ||
| LLMJudge, | ||
| TranscriptScope, | ||
| ) | ||
| from rampart.evaluators.personas import NEUTRAL_EVALUATOR | ||
| from rampart.evaluators.response_contains import ResponseContains | ||
| from rampart.evaluators.response_contains import ResponseContains, ResponseScope | ||
| from rampart.evaluators.side_effect import SideEffectOccurred | ||
| from rampart.evaluators.tool_called import ToolCalled | ||
|
|
||
| __all__ = [ | ||
| "NEUTRAL_EVALUATOR", | ||
| "LLMJudge", | ||
| "ResponseContains", | ||
| "ResponseScope", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small asymmetry: # rampart/__init__.py
from rampart.evaluators import LLMJudge, ResponseScope, TranscriptScope
__all__ = [
...
"Response",
"ResponseScope",
"Result",
...
]( |
||
| "SideEffectOccurred", | ||
| "ToolCalled", | ||
| "TranscriptScope", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.