Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0cdc24a
feat: Add bounded Scenario result hierarchy projections (#3145)
Nimit3418 Oct 13, 2026
a7367c0
fix: address all review feedback on bounded scenario hierarchy (#3167)
Nimit3418 Oct 14, 2026
5fde2ca
fix: resolve import NameError, O(N^2) loop, retry consistency, and co…
Nimit3418 Oct 14, 2026
689eef9
fix: resolve remaining edge cases on executed_turns, piece limits, an…
Nimit3418 Oct 14, 2026
f33a5b9
fix: harden related conversation handling against malformed metadata …
Nimit3418 Oct 15, 2026
5d04b4f
fix: use real conversation statistics and safely handle unavailable m…
Nimit3418 Oct 15, 2026
0e7abda
fix: apply SQL piece budgeting and reconcile diagnostic retries with …
Nimit3418 Oct 15, 2026
e4d0ca0
fix: bound cold delta hydration, truncate unresolved child IDs, and b…
Nimit3418 Oct 16, 2026
6af0e8d
fix: decouple run-wide aggregates from bounded hierarchy and enforce …
Nimit3418 Oct 16, 2026
7c489c5
fix: resolve run-scoped roots for truncated deltas and chunk conversa…
Nimit3418 Oct 16, 2026
04d713e
fix: enforce exact cold-warm parity, targeted parent resolution, and …
Nimit3418 Oct 17, 2026
fbf3301
fix: bound parent queries, handle cyclic discovery, and preserve plan…
Nimit3418 Oct 17, 2026
544ccbf
fix: exclude known rows from parent queries, handle multi-layer roots…
Nimit3418 Oct 18, 2026
a1b1108
fix: page parent queries, guard unverified roots, and preserve legacy…
Nimit3418 Oct 18, 2026
dfb3ce3
fix: lightweight recount projection, guard boundary roots, and bound …
Nimit3418 Oct 19, 2026
1d14bd2
fix: keyset-page parent discovery across exclusion fallbacks and pres…
Nimit3418 Oct 20, 2026
294f3de
fix: account for unprocessed page rows in parent query has_more contr…
Nimit3418 Oct 20, 2026
606d4a2
merge: sync upstream/main and resolve scenario progress and memory co…
Nimit3418 Oct 20, 2026
625b470
fix: preserve keyset delta polling, completed units aggregation, and …
Nimit3418 Oct 20, 2026
c639b99
fix: preserve child-error links in lightweight recount and align raw …
Nimit3418 Oct 22, 2026
6a5bd47
refactor: separate scenario hierarchy browsing from accounting
romanlutz Oct 26, 2026
56dc1a7
refactor: remove duplicate hierarchy conversation previews
romanlutz Oct 26, 2026
d4b33b6
fix: keep GUI conversation reads scoped to main and pruned
romanlutz Oct 27, 2026
4653a4d
Merge main into scenario hierarchy projections
romanlutz Oct 27, 2026
8a09e73
Merge latest main before hierarchy CI
romanlutz Oct 27, 2026
c8317ec
Merge main and isolate media reference tests
romanlutz Oct 27, 2026
8bca67a
Merge main's Windows CI test guards
romanlutz Oct 27, 2026
0daf5dd
Merge main and stabilize multimodal test timestamps
romanlutz Oct 27, 2026
5e6b831
Merge main's social-bias dataset prompt-role fix
romanlutz Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ the original launch configuration is available. It skips completed objectives
with non-error outcomes, including `failure`. Retrying an unsuccessful technique
is a separate setting in `technique.execute`.

Compound-attack hierarchy browsing is a separate GUI follow-up. The
[backend hierarchy API](../../pyrit/backend/README.md#scenario-result-hierarchy)
provides paged roots and ordered child expansion without clipping run statistics.
Conversation reads expose main and pruned conversations only; adversarial and
preparation contexts remain internal. Transcript bodies load only for a selected
saved conversation.

![History page with saved attacks](images/attack_history.png)

## Analytics
Expand Down
4 changes: 2 additions & 2 deletions pyrit/analytics/scenario_statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ class ScenarioAttempt:
attempt_id: str
total_retries: int
result_role: AttackResultRole | None
attempt_index: int | None
child_attack_result_ids: tuple[str, ...]
attempt_index: int | None = None
child_attack_result_ids: tuple[str, ...] = ()

@property
def attack_result_id(self) -> str:
Expand Down
65 changes: 65 additions & 0 deletions pyrit/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,71 @@ Missing IDs, standalone conversations, and internal conversations return 404.
Saving from the scanner creates a copy through `POST /api/attacks/save-conversation`;
it does not change the source or send to a target.

For on-demand saved-result browsing, use
`GET /api/attacks/{attack_result_id}/conversations?limit=50` to page main and pruned
references in saved-reference order. This reads counts
and previews, not conversation bodies. The limit is 1-200; follow
`pagination.next_cursor` while `pagination.has_more` is true.

Load only the selected conversation with
`GET /api/attacks/{attack_result_id}/messages?conversation_id={conversation_id}&limit=100`.
Here the limit is 1-500 **message pieces**, not turns or bytes. Results use the
canonical message, piece, and score views, ordered by sequence, timestamp, and ID.
A page can split a multimodal turn: `partial_message_sequences` identifies partial
turns. Append pieces by ID to their turn across pages, and follow
`pagination.next_cursor`. A page does not infer a final target-response status
from an incomplete transcript.

Message cursors belong to one attack and conversation. Reference cursors also
validate the saved ordering and timestamp; restart reference paging if the saved
attack changes. Invalid or mismatched cursors return 400. Passing a cursor requires
a limit. Omitting both preserves the existing active-conversation listing and
full-transcript behavior.
Both paged and unpaged reads expose only main and pruned conversations.
Adversarial and preparation contexts are excluded from lists, reference totals,
and transcript reads, including hierarchy node expansion.

### Scenario result hierarchy

These read-only APIs provide the backend foundation for compound-attack browsing.
The hierarchy GUI is separate work; the current scanner still shows flat executions.

`GET /api/scenarios/runs/{scenario_result_id}/hierarchy?max_nodes=1000` returns a
bounded tree preview grouped by atomic attack group. Roots are saved results with
no incoming child reference from the same run, not results inferred from their
attempt indexes. The node budget is 1-5000, with at most 500 roots per page and
bounded descendant queries through depth 10. `has_more` and `next_cursor` refer
only to **root continuation**. `truncated` also covers deferred descendants,
depth limits, and other results not represented by the preview.

Run/group completion, outcomes, producer counts, and raw error/retry diagnostics
use the shared exact accounting path independently of the selected page. Exact
statistics may scan lightweight summaries; a small tree request does not hydrate
the full progress snapshot, attack results, or conversation bodies.

Expand one result with
`GET /api/scenarios/runs/{scenario_result_id}/hierarchy/nodes/{attack_result_id}?limit=200`.
The limit is 1-500 saved child-reference positions. Follow `next_children_cursor`
using `children_cursor`; `children_count` is the full reference count and
`child_offset` locates this page. `child_index` remains the absolute position under
the immediate parent. Missing, malformed, duplicate, cyclic, or cross-run references
remain explicit and consume positions, so even a wholly unresolved page can advance.
Child cursors validate the run, node, timestamp, and saved reference ordering;
restart child paging after those references change.

Tree previews leave conversation metadata deferred: `conversations` is empty,
`conversations_count` is unknown, and `conversations_truncated` is true when saved
references exist. The node's `conversation_id` still links to its main conversation.
Node expansion returns up to 50 main/pruned conversation summaries, the exact reference
count, and a clipping flag. These use the same metadata model as the generic attack API.
Use the saved-conversation APIs above for reference continuation and all transcript
bodies. There is no hierarchy-specific transcript format or conversation-preview mode.

An entirely rootless saved graph, such as a closed cycle, returns a bounded
`diagnostics.rootless_result_ids` sample with its total count and clipping flag.
It does not invent lineage or promote a cycle member to a true root. The sampled
IDs remain available through the single-node and saved-conversation APIs.

### Techniques

| Endpoint | Result |
Expand Down
18 changes: 12 additions & 6 deletions pyrit/backend/mappers/attack_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import asyncio
import logging
import time
import uuid
Expand Down Expand Up @@ -53,6 +54,7 @@
_LEGACY_MANUAL_ATTACK_PLACEHOLDER = "Manual attack via GUI"

if TYPE_CHECKING:
from pyrit.memory import MemoryInterface
from pyrit.models.conversation_stats import ConversationStats
from pyrit.models.results.attack_result import AttackScoringInputs

Expand Down Expand Up @@ -334,7 +336,9 @@ async def _resolve_and_sign_media_async(*, value: str | None, data_type: str) ->
Returns:
The resolved (and signed, if a blob) URL, or ``None`` when *value* is empty.
"""
resolved = _resolve_media_url(value=value, data_type=data_type)
if not value or data_type not in MEDIA_PATH_DATA_TYPES:
return None
resolved = await asyncio.to_thread(_resolve_media_url, value=value, data_type=data_type)
if resolved and _is_azure_blob_url(resolved):
return await _sign_blob_url_async(blob_url=resolved)
return resolved
Expand Down Expand Up @@ -376,6 +380,7 @@ def _score_lookup_key(*, piece: MessagePiece) -> str:
async def _fetch_scores_by_piece_async(
*,
pyrit_messages: list[Message],
memory: MemoryInterface | None = None,
) -> dict[str, list[Score]]:
"""
Batch-fetch scores for every piece in ``pyrit_messages`` and group by piece id.
Expand All @@ -390,8 +395,8 @@ async def _fetch_scores_by_piece_async(
if not score_lookup_ids:
return {}

memory = CentralMemory.get_memory_instance()
fetched = await memory.get_prompt_scores_async(prompt_ids=score_lookup_ids)
source = memory if memory is not None else CentralMemory.get_memory_instance()
fetched = await source.get_prompt_scores_async(prompt_ids=score_lookup_ids)

grouped: dict[str, list[Score]] = {}
for score in fetched:
Expand All @@ -403,6 +408,7 @@ async def pyrit_messages_to_dto_async(
pyrit_messages: list[Message],
*,
objective_score_id: uuid.UUID | str | None = None,
memory: MemoryInterface | None = None,
) -> list[MessageView]:
"""
Translate PyRIT messages to backend MessageView responses.
Expand All @@ -414,15 +420,15 @@ async def pyrit_messages_to_dto_async(
- Local files -> ``/api/media?path=...`` (served by the media endpoint)
- Azure Blob Storage files -> signed URLs with SAS tokens

Scores are fetched from ``CentralMemory`` (``MessagePiece`` no longer carries
them) via a single batched ``get_prompt_scores`` call and attached to their
Scores are fetched from the supplied memory, or ``CentralMemory`` by default
(``MessagePiece`` no longer carries them), in one batched read and attached to their
originating piece. When ``objective_score_id`` is provided, the matching
score is marked as the attack's canonical objective score.

Returns:
List of MessageView responses for the API.
"""
scores_by_piece = await _fetch_scores_by_piece_async(pyrit_messages=pyrit_messages)
scores_by_piece = await _fetch_scores_by_piece_async(pyrit_messages=pyrit_messages, memory=memory)

messages: list[MessageView] = []
for msg in pyrit_messages:
Expand Down
14 changes: 5 additions & 9 deletions pyrit/backend/models/attacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
AttackResult,
ChatMessageRole,
ConversationReference,
ConversationSummary,
Message,
MessagePiece,
PromptDataType,
Expand Down Expand Up @@ -356,6 +357,8 @@ class ConversationMessagesResponse(BaseModel):
"None when the conversation does not end with a target response."
),
)
pagination: PaginationInfo | None = None
partial_message_sequences: list[int] = Field(default_factory=list)


# ============================================================================
Expand Down Expand Up @@ -578,15 +581,6 @@ def _validate_update(self) -> "UpdateAttackRequest":
# ============================================================================


class ConversationSummary(BaseModel):
"""Summary of a conversation (message count, preview, timestamp)."""

conversation_id: str = Field(..., description="Unique conversation identifier")
message_count: int = Field(0, description="Number of messages in this conversation")
last_message_preview: str | None = Field(None, description="Preview of the last message")
created_at: datetime | None = Field(None, description="Timestamp of the first message")


class AttackConversationsResponse(BaseModel):
"""Response listing all conversations belonging to an attack."""

Expand All @@ -595,6 +589,8 @@ class AttackConversationsResponse(BaseModel):
conversations: list[ConversationSummary] = Field(
default_factory=list, description="All conversations including main"
)
pagination: PaginationInfo | None = None
total_count: int | None = None


class CreateConversationRequest(BaseModel):
Expand Down
28 changes: 20 additions & 8 deletions pyrit/backend/routes/attacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,11 +390,14 @@ async def get_conversation_messages( # pyrit-async-suffix-exempt
conversation_id: str = Query(
..., max_length=MAX_IDENTIFIER_LENGTH, description="The conversation_id whose messages to return"
),
limit: int | None = Query(None, ge=1, le=500, description="Optional saved message-piece page size"),
cursor: CursorStr | None = Query(None, description="Opaque message-piece continuation cursor"),
) -> ConversationMessagesResponse:
"""
Get all messages for a conversation belonging to an attack.
Get saved messages for a main or pruned conversation belonging to an attack.

Returns prepended conversation and all messages in order.
Omitting limit preserves the existing full-conversation response. Paged reads can
split a multimodal turn; append pieces by their IDs and turn number across pages.

Returns:
ConversationMessagesResponse: All messages for the conversation.
Expand All @@ -405,6 +408,8 @@ async def get_conversation_messages( # pyrit-async-suffix-exempt
messages = await service.get_conversation_messages_async(
attack_result_id=attack_result_id,
conversation_id=conversation_id,
limit=limit,
cursor=cursor,
)
except ValueError as e:
raise HTTPException(
Expand All @@ -425,24 +430,31 @@ async def get_conversation_messages( # pyrit-async-suffix-exempt
"/{attack_result_id}/conversations",
response_model=AttackConversationsResponse,
responses={
400: {"model": ProblemDetail, "description": "Invalid conversation cursor"},
404: {"model": ProblemDetail, "description": "Attack not found"},
},
)
async def get_conversations(
async def get_conversations( # pyrit-async-suffix-exempt
attack_result_id: IdentifierStr,
) -> AttackConversationsResponse: # pyrit-async-suffix-exempt
limit: int | None = Query(None, ge=1, le=200, description="Optional saved conversation-reference page size"),
cursor: CursorStr | None = Query(None, description="Opaque conversation-reference continuation cursor"),
) -> AttackConversationsResponse:
"""
Get all conversations belonging to an attack.
Get main and pruned conversations belonging to an attack.

Returns the main conversation and all related conversations with
message counts and preview text.
Without a limit, preserve the existing active-conversation list and timestamp
ordering. Paged reads use stored-reference order, with message counts and
preview text. Both paths exclude adversarial and preparation contexts.

Returns:
AttackConversationsResponse: All conversations for the attack.
"""
service = get_attack_service()

result = await service.get_conversations_async(attack_result_id=attack_result_id)
try:
result = await service.get_conversations_async(attack_result_id=attack_result_id, limit=limit, cursor=cursor)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
Expand Down
84 changes: 83 additions & 1 deletion pyrit/backend/routes/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@
get_scenario_run_service,
)
from pyrit.backend.services.scenario_service import get_scenario_service
from pyrit.models import ScenarioQueueSnapshot, ScenarioResult, ScenarioRunProgress, ScenarioRunState
from pyrit.models import (
ScenarioHierarchyNode,
ScenarioHierarchyProjection,
ScenarioQueueSnapshot,
ScenarioResult,
ScenarioRunProgress,
ScenarioRunState,
)
from pyrit.models.catalog import (
RegisteredScenario,
RunScenarioRequest,
Expand Down Expand Up @@ -377,6 +384,81 @@ async def get_scenario_run_progress( # pyrit-async-suffix-exempt
return progress


@router.get(
"/runs/{scenario_result_id}/hierarchy",
response_model=ScenarioHierarchyProjection,
responses={
400: {"model": ProblemDetail, "description": "Invalid hierarchy cursor"},
404: {"model": ProblemDetail, "description": "Run not found"},
},
)
async def get_scenario_run_hierarchy( # pyrit-async-suffix-exempt
*,
scenario_result_id: IdentifierStr,
max_nodes: int = Query(1000, ge=1, le=5000, description="Maximum nodes to assemble in the hierarchy tree"),
cursor: CursorStr | None = Query(None, description="Opaque root-page continuation cursor"),
) -> ScenarioHierarchyProjection:
"""
Get the bounded result hierarchy projection for a scenario run.

Returns:
ScenarioHierarchyProjection: Bounded hierarchy projection connecting orchestration parents,
target-facing children, and generic read-only conversations.
"""
service = get_scenario_run_service()
try:
hierarchy = await service.get_run_hierarchy_async(
scenario_result_id=scenario_result_id, max_nodes=max_nodes, cursor=cursor
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if hierarchy is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Scenario run '{scenario_result_id}' not found",
)
return hierarchy


@router.get(
"/runs/{scenario_result_id}/hierarchy/nodes/{attack_result_id}",
response_model=ScenarioHierarchyNode,
responses={
400: {"model": ProblemDetail, "description": "Invalid child cursor"},
404: {"model": ProblemDetail, "description": "Node or run not found"},
},
)
async def get_scenario_run_hierarchy_node( # pyrit-async-suffix-exempt
*,
scenario_result_id: IdentifierStr,
attack_result_id: IdentifierStr,
limit: int = Query(200, ge=1, le=500, description="Maximum ordered child references in this page"),
children_cursor: CursorStr | None = Query(None, description="Opaque ordered-child continuation cursor"),
) -> ScenarioHierarchyNode:
"""
Get single-node hierarchy details and generic conversation projections without full-run hydration.

Returns:
ScenarioHierarchyNode: The target node with resolved immediate children and conversations.
"""
service = get_scenario_run_service()
try:
node = await service.get_run_hierarchy_node_async(
scenario_result_id=scenario_result_id,
attack_result_id=attack_result_id,
max_immediate_children=limit,
children_cursor=children_cursor,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if node is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Hierarchy node '{attack_result_id}' not found in scenario run '{scenario_result_id}'",
)
return node


@router.post(
"/runs/{scenario_result_id}/cancel",
response_model=ScenarioRunSummary,
Expand Down
Loading
Loading