Skip to content

FIX: degrade an unreadable Responses output to an empty marker - #2906

Merged
hannahwestra25 merged 12 commits into
microsoft:mainfrom
feiiiiii5:fix/response-target-no-readable-output
Oct 8, 2026
Merged

hannahwestra25 merged 12 commits into
microsoft:mainfrom
feiiiiii5:fix/response-target-no-readable-output

Conversation

@feiiiiii5

@feiiiiii5 Chen Yufeiyang (feiiiiii5) commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

OpenAIResponseTarget reports success for a completed Responses call whose output carries nothing PyRIT can read. With a built-in tool enabled (image_generation, code_interpreter, file_search, …) the API answers with sections such as image_generation_call, which _parse_response_output_section skips. The target then returns a Message whose only piece is the reasoning dump, with response_error="none", so the attack loop records that JSON as the model's answer.

has_visible_response is already computed while looping over the sections, but was only consulted on the truncated path. It is now consulted on every path: when nothing visible was produced, a graceful empty marker piece (response_error="empty") is appended via build_empty_truncated_response, matching what OpenAIChatTarget and LiteLLMChatTarget already do on truncation. Reasoning pieces are retained after it for memory and debugging, and a readable section sitting next to an unmodelled one is still returned unchanged.

Degrading rather than raising is deliberate. EmptyResponseException is in pyrit_target_retry's retry set, so raising would re-send a request whose outcome is deterministic, and an exception would also skip the _capture_response_metadata call below it, losing token counts for a response the provider already billed. A genuinely empty output still raises EmptyResponseException from _response_adapter.validate(), which runs before this method.

The completed path also logs a warning, since the empty marker is otherwise the operator's only signal that a run is producing nothing readable. The truncated path stays quiet: hitting the token cap is an expected outcome and was already silent.

Tests and Documentation

Five cases in tests/unit/prompt_target/target/test_openai_response_target.py:

  • a completed response of reasoning plus an unmodelled section returns an empty marker with the reasoning piece retained — fails on main
  • the reasoning-only shape does the same — fails on main
  • the completed path logs a warning — fails on main
  • a readable message next to an unmodelled section is still returned
  • the truncated path appends the same marker without warning

The _construct_message_from_response_async docstring no longer scopes the empty-piece fallback to truncation.

pytest tests/unit/prompt_target/target/test_openai_response_target.py -q gives 114 passed. ruff check and ruff format --check are clean on both files, and pre-commit run --files passes on the test file.

_construct_message_from_response_async tracks has_visible_response but
only consulted it on the truncated path, so a completed response whose
output PyRIT cannot read came back as a successful Message holding the
reasoning dump. With a built-in tool enabled (image_generation,
code_interpreter, file_search, ...) the Responses API returns sections
such as image_generation_call, which _parse_response_output_section
skips with `return None`; the run then scored the reasoning JSON as the
model's answer with response_error="none".

OpenAIChatTarget already raises EmptyResponseException when a response
that is not truncated yields no content. Do the same here, and keep
returning the message when a readable section is present next to an
unmodelled one.
@hannahwestra25 hannahwestra25 self-assigned this Oct 1, 2026
Comment thread pyrit/prompt_target/openai/openai_response_target.py Outdated
`_send_model_request_async` is wrapped in `@pyrit_target_retry`, which
retries `RateLimitError | EmptyResponseException | RateLimitException`.
The check added in this branch raised `EmptyResponseException` for a
response that *completed* with no section PyRIT models, so every retry
reproduced the same shape: ten billed calls plus backoff before the
agentic loop gave up, and the tool messages it had collected were dropped
with the exception.

`doc/contributing/9_exception.md` scopes retry to rate limits and parse
failures, so raise `PyritException` instead. The docstring now says so
rather than naming an exception that is no longer raised.

The test asserts `type(excinfo.value) is PyritException` and that it is
not an `EmptyResponseException`. Asserting only `PyritException` would
not have caught this, since `EmptyResponseException` subclasses it --
the weaker assertion passes on the old code too.

Reported by @hannahwestra25.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Right, and I checked the mechanism before changing it: _send_model_request_async is decorated @pyrit_target_retry, and pyrit_target_retry retries RateLimitError | EmptyResponseException | RateLimitException (exception_classes.py:403-405), with doc/contributing/9_exception.md scoping retry to rate limits and parse failures. A section type we do not model comes back identically on every attempt, so the ten attempts only re-bill the same outcome.

Done in 1de72b6c — PyritException instead, verbatim from your suggestion, and the docstring updated since it named an exception that is no longer raised.

One thing worth flagging because my first attempt got it wrong: asserting only pytest.raises(PyritException) does not catch this, because EmptyResponseException subclasses BadRequestException subclasses PyritException. I confirmed that, and the weaker assertion passed on the pre-fix head. The test now pins the actual behaviour:

with pytest.raises(PyritException) as excinfo:
    ...
assert not isinstance(excinfo.value, EmptyResponseException)
assert type(excinfo.value) is PyritException

which fails on 21187667 and passes here.

pytest tests/unit/prompt_target/target/test_openai_response_target.py -q   108 passed
ruff check / ruff format --check on both changed files                    clean

One asymmetry I did not change, because it is outside this PR and may be deliberate: openai_chat_target.py:334 raises EmptyResponseException for the same "completed with nothing readable" condition, so the chat and Responses targets now disagree on whether that is retryable. Your reasoning would apply there too, but it is a behaviour change for a target this PR does not touch, so I would rather you decide than have me widen the diff — happy to send it as a one-line follow-up if you want the two aligned.

Comment thread pyrit/prompt_target/openai/openai_response_target.py Outdated
Raising made the batch fail and skipped metadata capture, and the proposed
PyritException was retried by pyrit_target_retry on a deterministic outcome.
Append the graceful empty marker instead, keep reasoning pieces, and pin the
shape with a reasoning-only regression test.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Agreed — went with the append-marker shape instead of raising. The completed-but-unreadable path now appends build_empty_truncated_response(...).message_pieces[0], keeps any reasoning pieces, and leaves _capture_response_metadata on its normal path. Raises: docstring removed, the test now asserts message_pieces[0].response_error == "empty" with the reasoning piece retained, and I added the reasoning-only regression case you described. test_openai_response_target.py is 109/109 green locally. Chat target stays as the flagged follow-up; I'd rather align it to this graceful path than the reverse.

Comment thread pyrit/prompt_target/openai/openai_response_target.py
Comment thread pyrit/prompt_target/openai/openai_response_target.py Outdated
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Thanks hannahwestra25, both applied:

  • d15ae9fb adds the logger.warning for the completed-but-unreadable case, keeping the warning off the truncated path so truncation stays quiet.
  • 19c660c8 aligns the docstring with the suggestions: empty sections tolerated on truncation, partial tool/function calls skipped there, and a graceful empty text piece appended whenever no visible response was produced, truncated or completed.

pytest tests/unit/prompt_target/target/test_openai_response_target.py -q: 112 passed. ruff check clean.

The completed path warns and the truncated path stays quiet, but neither outcome was asserted, so dropping the warning or the truncation gate would both regress silently.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@hannahwestra25 hannahwestra25 changed the title FIX: raise when a completed Responses output has nothing readable FIX: degrade an unreadable Responses output to an empty marker Oct 8, 2026
Comment thread pyrit/prompt_target/openai/openai_response_target.py Outdated
Comment thread pyrit/prompt_target/openai/openai_response_target.py Outdated
@hannahwestra25
hannahwestra25 added this pull request to the merge queue Oct 8, 2026
Merged via the queue into microsoft:main with commit 3d279a8 Oct 8, 2026
75 of 78 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants