-
Notifications
You must be signed in to change notification settings - Fork 909
FEAT: Add GitHub Copilot SDK target and native judge retries #2828
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
ba4f41b
0b95524
fcc1c37
c78d3d4
f449249
a8b0958
1311146
e545808
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |
| EmptyResponseException, | ||
| InvalidJsonException, | ||
| ScorerLLMResponseBlockedException, | ||
| pyrit_json_retry, | ||
| ) | ||
| from pyrit.models import ( | ||
| Acquisition, | ||
|
|
@@ -83,6 +84,7 @@ async def _run_llm_scoring_async( | |
| category: Sequence[str] | str | None = None, | ||
| objective: str | None = None, | ||
| normalizer: PromptNormalizer | None = None, | ||
| fresh_conversation_per_attempt: bool = False, | ||
| observation_metadata: Mapping[str, str] | None = None, | ||
| requires_message_piece_evidence: bool = False, | ||
| judgment_replay_identifier: Mapping[str, object] | None = None, | ||
|
|
@@ -93,13 +95,13 @@ async def _run_llm_scoring_async( | |
| This is the shared LLM evaluation mechanism: it optionally sets a system prompt on the target, sends | ||
| the value to be scored (forwarding ``response_handler.json_response_config`` so targets that | ||
| support structured output can enforce it), and delegates parsing and validation to | ||
| ``response_handler``. The round-trip is routed through a ``PromptNormalizer`` via | ||
| ``send_json_with_retry_async`` so the scorer's question and the target's answer are persisted | ||
| to memory (a full audit trail, and a real conversation an attack can link as a SCORE-type | ||
| related conversation) and so JSON retries roll memory back to a clean baseline between attempts | ||
| instead of replaying the target's own malformed reply. It is intentionally stateless and | ||
| independent of any particular ``Scorer`` so that scorers can compose it without inheriting LLM | ||
| machinery. | ||
| ``response_handler``. The round-trip uses a ``PromptNormalizer`` so the scorer's question and | ||
| the target's answer are persisted to memory (a full audit trail, and a real conversation an | ||
| attack can link as a SCORE-type related conversation). The default editable-history path rolls | ||
| memory back between JSON attempts; ``fresh_conversation_per_attempt`` keeps malformed judge | ||
| exchanges in separate conversations instead of replaying native history. It is intentionally | ||
| stateless and independent of any particular ``Scorer`` so scorers can compose it without | ||
| inheriting LLM machinery. | ||
|
|
||
| The round-trip owns only the transport; the ``ResponseHandler`` owns the response contract — | ||
| the optional response schema and turning raw text into a validated ``UnvalidatedScore``. | ||
|
|
@@ -127,8 +129,10 @@ async def _run_llm_scoring_async( | |
| objective (str | None): Transitional objective context for direct helper callers. | ||
| Defaults to None. | ||
| normalizer (PromptNormalizer | None): Normalizer used to send the scoring round-trip | ||
| and whose memory is rolled back between JSON retries. Injectable for testing; | ||
| defaults to a fresh ``PromptNormalizer()`` when not supplied. | ||
| and resolve scorer evidence. Injectable for testing; defaults to a fresh | ||
| ``PromptNormalizer()`` when not supplied. | ||
| fresh_conversation_per_attempt (bool): Use a new conversation for each JSON retry when | ||
| target history cannot be rolled back. Defaults to False. | ||
| observation_metadata (Mapping[str, str] | None): Scorer-specific state required to | ||
| reconstruct the response parser during replay. Defaults to None. | ||
| requires_message_piece_evidence (bool): Whether the rendered request reads fields that a | ||
|
|
@@ -193,7 +197,7 @@ async def _run_llm_scoring_async( | |
| observation_scorable is not None and scored_evidence_digest is not None and has_required_evidence | ||
| ) | ||
|
|
||
| if system_prompt is not None: | ||
| if system_prompt is not None and not fresh_conversation_per_attempt: | ||
| chat_target.set_system_prompt( | ||
| system_prompt=system_prompt, | ||
| conversation_id=conversation_id, | ||
|
|
@@ -273,17 +277,51 @@ def _parse(response: Message) -> UnvalidatedScore: | |
| objective=expectation.objective if expectation else None, | ||
| ) | ||
|
|
||
| # Route the round-trip through the normalizer so the scorer Q&A is persisted and JSON retries | ||
| # replay on a clean history. | ||
| # Editable targets retry on a rolled-back conversation; non-editable judges opt into fresh sessions. | ||
| try: | ||
| unvalidated_score = await send_json_with_retry_async( | ||
| normalizer=resolved_normalizer, | ||
| target=chat_target, | ||
| message=scorer_llm_request, | ||
| conversation_id=conversation_id, | ||
| parse=_parse, | ||
| on_response=_capture_response, | ||
| ) | ||
| if fresh_conversation_per_attempt: | ||
| first_attempt = True | ||
|
|
||
| @pyrit_json_retry | ||
| async def _fresh_attempt_async() -> UnvalidatedScore: | ||
| nonlocal first_attempt | ||
| attempt_conversation_id = conversation_id if first_attempt else str(uuid.uuid4()) | ||
| attempt_message = scorer_llm_request if first_attempt else scorer_llm_request.duplicate() | ||
| if system_prompt is not None: | ||
| chat_target.set_system_prompt( | ||
| system_prompt=system_prompt, | ||
| conversation_id=attempt_conversation_id, | ||
| ) | ||
| first_attempt = False | ||
| response = await resolved_normalizer.send_prompt_async( | ||
| message=attempt_message, | ||
| conversation_id=attempt_conversation_id, | ||
| target=chat_target, | ||
| ) | ||
| if not response: | ||
| raise ValueError(f"No response received for conversation ID: {attempt_conversation_id}") | ||
| _capture_response(response) | ||
| try: | ||
| return _parse(response) | ||
| except InvalidJsonException: | ||
|
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.
|
||
| try: | ||
| await chat_target.reset_conversation_async(conversation_id=attempt_conversation_id) | ||
| except Exception as reset_error: | ||
| raise RuntimeError( | ||
| "Could not release the malformed judge session; refusing another retry." | ||
| ) from reset_error | ||
| raise | ||
|
|
||
| unvalidated_score: UnvalidatedScore = await _fresh_attempt_async() | ||
| else: | ||
| unvalidated_score = await send_json_with_retry_async( | ||
| normalizer=resolved_normalizer, | ||
| target=chat_target, | ||
| message=scorer_llm_request, | ||
| conversation_id=conversation_id, | ||
| parse=_parse, | ||
| on_response=_capture_response, | ||
| ) | ||
| except ScorerLLMResponseBlockedException as error: | ||
| if terminal_response is not None and can_collect_observation and _has_observation_collection(): | ||
| observation = _build_judgment_observation( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,10 +20,11 @@ | |
| SeedPrompt, | ||
| UnvalidatedScore, | ||
| ) | ||
| from pyrit.prompt_target import CHAT_TARGET_REQUIREMENTS, PromptTarget | ||
| from pyrit.prompt_target import PromptTarget | ||
| from pyrit.score.llm_scoring import _parse_judgment_observation, _run_llm_scoring_async | ||
| from pyrit.score.observation.execution import _ObservationEvidence | ||
| from pyrit.score.response_handler import JsonSchemaResponseHandler, ResponseHandler, TrueFalseResponseHandler | ||
| from pyrit.score.scorer import _SelfContainedJudgeTargetRequirements | ||
| from pyrit.score.scorer_prompt_validator import ScorerPromptValidator | ||
| from pyrit.score.system_prompt import _render_system_prompt_template | ||
| from pyrit.score.true_false.true_false_score_aggregator import ( | ||
|
|
@@ -153,7 +154,7 @@ class SelfAskTrueFalseScorer(MessageTrueFalseScorer): | |
| _DEFAULT_VALIDATOR: ScorerPromptValidator = ScorerPromptValidator( | ||
| supported_data_types=["text", "image_path"], | ||
| ) | ||
| TARGET_REQUIREMENTS = CHAT_TARGET_REQUIREMENTS | ||
| TARGET_REQUIREMENTS = _SelfContainedJudgeTargetRequirements() | ||
|
|
||
| def __init__( | ||
| self, | ||
|
|
@@ -169,8 +170,9 @@ def __init__( | |
| Initialize the SelfAskTrueFalseScorer. | ||
|
|
||
| Args: | ||
| chat_target (PromptTarget | None): The chat target used for scoring. Must satisfy | ||
| CHAT_TARGET_REQUIREMENTS. | ||
| chat_target (PromptTarget | None): The chat target used for scoring. Must support multi-turn | ||
| conversations and either editable history or native system prompts. Noneditable targets | ||
| are supported for text scoring only. | ||
| system_prompt (SeedPrompt | str | None): The scoring system prompt. A ``SeedPrompt`` | ||
| (e.g. rendered via ``render_true_false_system_prompt``) is used verbatim and may | ||
| carry a ``response_json_schema``; a ``str`` is used as-is; ``None`` falls back to the | ||
|
|
@@ -305,9 +307,17 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st | |
| The category is configured from the TrueFalseQuestionPath. | ||
| The score_value is True or False based on which description fits best. | ||
| Metadata can be configured to provide additional information. | ||
|
|
||
| Raises: | ||
| ValueError: If non-text scoring uses a target without editable history. | ||
| """ | ||
| # Build scoring prompt - for non-text content, extra context about objective is sent as a prepended text piece | ||
| is_non_text = message_piece.converted_value_data_type != "text" | ||
| if is_non_text and not self._prompt_target.capabilities.supports_editable_history: | ||
| raise ValueError( | ||
| "non-text scoring requires editable history; fresh-conversation retries support text only." | ||
|
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. should we be checking input modalities on the target here ? |
||
| ) | ||
|
|
||
| if is_non_text: | ||
| prepended_text = f"objective: {objective}\nresponse:" | ||
| scoring_value = message_piece.converted_value | ||
|
|
@@ -328,6 +338,9 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st | |
| judgment_replay_identifier=self._get_judgment_replay_identifier(), | ||
| prepended_text=prepended_text, | ||
| category=self._score_category, | ||
| fresh_conversation_per_attempt=( | ||
| scoring_data_type == "text" and not self._prompt_target.capabilities.supports_editable_history | ||
| ), | ||
| ) | ||
|
|
||
| return [self._convert_score(unvalidated_score)] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't we derive this from chat_target ?