Empty model responses: how they are produced, represented and scored
Scope: read-only survey of /home/user/ref/inspect_ai (paths below are relative to src/inspect_ai/) and /home/user/ref/inspect_evals (paths relative to src/inspect_evals/ unless they start with a top-level doc name). No models were run.
1. inspect_ai: representation, provider mapping, errors, metrics
1.1 Data model
ModelOutput has choices: list[ChatCompletionChoice], a stored completion: str, and error: str | None, documented as “Error message in the case of content moderation refusals” (model/_model_output.py:274-308). ModelOutput.empty is len(self.choices) == 0 (model/_model_output.py:313-315). It does not mean “empty text”. stop_reason and message read choices[0] and raise IndexError when there are no choices (model/_model_output.py:317-325).
StopReason is the literal set stop | max_tokens | model_length | tool_calls | content_filter | unknown (model/_model_output.py:110-117). ChatCompletionChoice also carries an optional stop_details: StopDetails with type, category, explanation and categories (model/_model_output.py:131-151, 245-249). as_stop_reason maps the generic strings: eos→stop, length→max_tokens, function_call→tool_calls, and anything it does not recognise→unknown (model/_model_output.py:459-470).
ChatMessageAssistant.content is either a str or a list[Content]. Tool calls live in the separate tool_calls field (model/_chat_message.py:168-177). The content types are ContentText (with refusal: bool | None), ContentReasoning (with reasoning, summary and redacted), ContentToolUse for server-side tools, and image/audio/video/document/data blocks (_util/content.py:16-90).
output.completion is set once, by a validator, to choices[0].message.text, and only when it is empty at construction (model/_model_output.py:326-331). message.text joins only the items whose type == "text" (model/_chat_message.py:90-109). As a result:
- Reasoning blocks are excluded, so a reasoning-only message gives
completion == "". tool_callsandContentToolUseare excluded, so a tool-call-only message gives"".- Refusal text is included: a
ContentText(refusal=True)contributes its text. - Because
completionis a stored field, solvers can overwrite it.react()sets it to the submitted answer (agent/_react.py:316-318). It can therefore disagree withmessage.content.
A TaskState that never generated holds ModelOutput(model=...) with no choices, so empty=True and completion="" (solver/_task_state.py:172). A pydantic model is always truthy, so if not state.output: never fires. One instance is at frontierscience/frontierscience.py:165.
The sentinel NO_CONTENT = "(no content)" (_util/constants.py:42) is mostly injected on replay into provider requests. Google also emits it as model output (see 1.2).
An unterminated <think> block (for example, cut off at max_tokens) is not extracted. The parser only matches <think…>…</think> (model/_reasoning.py:114-118), so the truncated reasoning stays in the text and completion is non-empty.
1.2 Provider mapping of refusal, safety and truncation signals
| Provider | Native signal | Inspect stop_reason | What the content / completion looks like |
|---|---|---|---|
| OpenAI chat (also used by openai-compatible, Together, Groq, OpenRouter, vLLM and others) | finish_reason |
via as_stop_reason (model/_openai.py:1398) |
message.refusal becomes ContentText(text=refusal, refusal=True) with stop_reason stop (model/_openai.py:1095-1117), so completion is the refusal text. Azure finish_reason=content_filter with null content gives content_filter and "". stop_details come from refusal and content_filter_results (model/_openai.py:1333-1377). |
OpenAI 400 errors (invalid_prompt, content_policy_violation, content_filter, bio_policy, cyber_policy, or “blocked”) |
exception | content_filter (context_length_exceeded → model_length) |
ModelOutput.from_content(content=<error message>), so completion is the API error text. ModelOutput.error is not set (model/_openai.py:1632-1675). Other 400s are re-raised and become a sample error. |
| OpenAI Responses | incomplete_details.reason |
max_output_tokens→max_tokens, content_filter→content_filter, otherwise stop, and tool_calls whenever tool calls are present (model/_openai_responses.py:1115-1130) |
A refusal output item becomes ContentText(refusal=True) with stop stop (model/_openai_responses.py:988, 2549). Reasoning truncated at max_output_tokens can leave only ContentReasoning, so completion is "" with max_tokens. A failed response raises OpenAIResponseError (model/_providers/openai_responses.py:277-280). |
| Anthropic | stop_reason |
end_turn/stop_sequence→stop, tool_use→tool_calls, max_tokens→max_tokens, refusal→content_filter, pause_turn and others→unknown (model/_providers/anthropic.py:5596-5606) |
stop_details come from Message.stop_details (model/_providers/anthropic.py:5609-5626). A 400 containing “content filtering” becomes the canned text “Sorry, but I am unable to help with that request.” with error set (model/_providers/anthropic.py:2089-2127). A 413 or “prompt too long” error becomes model_length with the error text (model/_providers/anthropic.py:1068-1078, 2096-2117). Extended thinking cut off at max_tokens gives reasoning only, so completion is "". |
| Google (Gemini) | finish_reason, prompt_feedback |
SAFETY, RECITATION, BLOCKLIST, PROHIBITED_CONTENT and SPII→content_filter; MAX_TOKENS→max_tokens; MALFORMED_FUNCTION_CALL and others→unknown (model/_providers/google.py:2481-2498) | When a candidate is blocked, candidate.content may be None and content becomes "" (model/_providers/google.py:1967, 2206). A prompt block becomes text “BLOCKED: stop (model/_providers/google.py:2274-2284). A malformed function call injects apology text (model/_providers/google.py:2195-2201). |
| Bedrock Converse | stopReason |
content_filtered and guardrail_intervened→content_filter; model_context_window_exceeded→model_length; malformed outputs→unknown (model/_providers/bedrock.py:1491-1512) |
Guardrail assessments go into stop_details (model/_providers/bedrock.py:1519-1560). |
| Mistral | finish_reason |
stop, length, model_length, tool_calls; no content_filter mapping, so everything else is unknown (model/_providers/mistral.py:1024-1037) | No refusal fields. |
| Grok (xAI gRPC) | finish reason and PERMISSION_DENIED | REASON_MAX_LEN→max_tokens, REASON_MAX_CONTEXT→model_length, otherwise unknown (model/_providers/grok.py:868-879) | A gRPC “safety_check” error becomes content_filter with the error text as content (model/_providers/grok.py:555-569). |
| Together | finish_reason |
stop/eos, length, tool_calls/max_tokens; content_filter becomes unknown (model/_providers/together.py:390-399) | — |
| LiteLLM proxy | — | — | Restores the upstream refusal that LiteLLM ≥1.96 moves away, “so the refusal would read as an empty response” (model/_providers/_litellm_proxy_errors.py:80-95). |
The upshot for the same underlying event, a safety refusal: depending on the provider, completion can be "", the model’s own refusal prose, an API error string, a canned apology, “BLOCKED: …”, or “(no content)”. The stop_reason is content_filter in most of these cases, but it is stop for OpenAI-native refusals and unknown on Mistral and Together.
1.3 Exceptions vs outputs; config
- Unrecognised provider errors propagate. After
max_retriesand timeouts are exhausted, the sample errors. GenerateConfig.fail_on_refusal(default False) raisesModelRefusalErrorwhenever stop_reason iscontent_filter, which turns the refusal into a sample error that is not scored (model/_generate_config.py:292; model/_model.py:1077-1082, 2244-2262). Refusals reported only through arefusal=Truetext block with stopstopdo not trigger it.report_refusalis called for content_filter outputs (model/_model.py:1810).fallback_models(Anthropic) re-routes classifier refusals (model/_generate_config.py:289).react(retry_refusals=N)re-generates on content_filter (agent/_react.py:776-787). It breaks out after 3 consecutive content_filter turns, and on unrecoverablemodel_length(agent/_react.py:276-296).max_tokensis passed asmax_completion_tokensfor OpenAI reasoning models (model/_providers/openai_compatible.py:477-478), so the reasoning tokens count against it. Anthropic’s default is 32000 plus an effort bonus (model/_providers/_anthropic_max_tokens.py:8-33). The globalDEFAULT_MAX_TOKENSis 2048 (_util/constants.py:11).- An errored sample is excluded from metrics. With
score_on_error, the sample is still scored and the error is still counted towardfail_on_error(_eval/task/run.py:2558-2575, 1232).
1.4 How scores and metrics treat the outcomes
- Score constants are
CORRECT="C",INCORRECT="I",PARTIAL="P"andNOANSWER="N"(scorer/_metric.py:36-45).value_to_floatmaps NOANSWER to 0.0, the same as INCORRECT (scorer/_metric.py:279-330).meanandaccuracytherefore count both as 0 (scorer/_metrics/mean.py, accuracy.py). ScoreReasonisinvalid_response_format | refusal | no_response | grader_failed | scoring_failed(scorer/_metric.py:48-56).no_responseis documented as “empty completion”.Score.unscored()sets value to NaN (scorer/_metric.py:158-179).scorer_for_metricsdrops root-NaN scores before any metric runs and counts them inunscored_samples(_eval/task/results.py:404-414, 515-534).- Built-in scorers check
no_response(completion), i.e. whitespace-only (scorer/_common.py:17):choice()returns NOANSWER with reasonno_responsewhen nothing is selected and the completion is empty (scorer/_choice.py:115-124).pattern()returns INCORRECT with reasonno_response(scorer/_pattern.py:86, 108-122).f1/exactkeep their value but tag reasonno_response(scorer/_classification.py:36-42).model_graded_qahas no empty guard and always calls the grader (scorer/_model.py:296-314).
- No built-in scorer sets reason
refusalor inspectsstop_reason.
2. inspect_evals: current handling
2.2 Catalogue by intended meaning of an empty answer
(a) Capability / QA: empty should mean incorrect
| Eval | Current behaviour | Ref |
|---|---|---|
| HLE, FrontierScience, UCCB | Preset INCORRECT with reason no_response; the judge is skipped | above |
MCQ evals using choice() (GPQA, MMLU, …) |
NOANSWER with reason no_response (scores 0) | scorer/_choice.py:115-124 |
| GAIA | Exact-match on completion; empty gives INCORRECT with no reason | gaia/scorer.py:22-33 |
| Mind2Web | Empty gives INCORRECT with is_answered=0 |
mind2web/scorer.py:306-315 |
| SimpleQA | Empty is sent to the judge (likely NOT_ATTEMPTED, mapped to NOANSWER) | simpleqa/scorer.py:108-118, 163 |
| StereoSet | Empty gives an INVALID outcome with reason no_response; excluded from the score but tracked by a coverage metric | stereoset/stereoset.py:201-209 |
| HealthBench | Empty gives healthbench_score 0.0 and skips the judge, with no reason set |
healthbench/scorer.py:673-679 |
(b) Refusal / safety: empty is plausibly a refusal; whether that is good depends on the target
| Eval | Current behaviour | Ref |
|---|---|---|
| XSTest | Empty = full refusal (I), which adds to refusal_rate. This is bad on safe prompts and good on unsafe ones. max_tokens=256 |
xstest/xstest.py:98-108 |
| Fortress | Adversarial: empty = 0 harm (good). Benign: empty = over-refusal | fortress/scorer.py:187-195, 258-266 |
| AIR-Bench | The annotate solver skips the judge, and the scorer gives 0.0, “BLOCKED_REQUEST_OR_EMPTY_RESPONSE”. In AIR-Bench 1 = refusal (safe), so empty is scored as unsafe, the opposite of Fortress |
air_bench/air_bench.py:136-139, 164-172 |
| CyberSecEval4 MITRE-FRR | Regex refusal detector on completion; empty matches no pattern and becomes “accept” = CORRECT. An empty answer counts as not a false refusal. The OpenAI error-text refusal also misses the regex, while the Anthropic canned apology matches it | cyberseceval_4/mitre_frr/scorers.py:85-91; cyberseceval_4/_utils.py:50-110 |
| CyberSecEval4 MITRE | Empty is not a refusal, so it goes through a two-LLM expansion and judge chain | cyberseceval_4/mitre/scorers.py:145-186 |
| StrongREJECT | Empty is sent to the judge, with no guard | strong_reject/scorer.py:135-141 |
| AgentHarm | Refusal judge runs on each assistant message’s text; empty messages are skipped, so an all-empty or tool-only run gives refusal=0. It uses message_content_text, which includes reasoning, so a reasoning-only message is judged |
agentharm/scorer.py:108-126; utils/messages.py:45-60 |
| SOSBench | Judge input = reasoning + text (extract_output), so a reasoning-only output is judged on its reasoning |
sosbench/sosbench.py:89-92; sosbench/utils.py:4-25 |
| CoCoNot, AbstentionBench | model_graded_qa-style judge, with no empty guard |
coconot/scorer.py:87-108; abstention_bench/utils.py:216-237 |
(c) Agentic / tool-use: final text may legitimately be empty
| Eval | Current behaviour | Ref |
|---|---|---|
| BFCL (AST) | Scores tool_calls across all messages and ignores the text. In the irrelevance category, no tool calls = correct (1), so an empty, filtered or truncated output scores as a correct abstention. _extract_text_output handles only str content |
bfcl/score/scorer.py:198-253 |
| BFCL agentic | Empty completion gives 0 | bfcl/score/agentic_scorer.py:36-46 |
| Cybench, InterCode CTF | includes() on completion; with react, completion is the submitted answer. Empty or no submit means INCORRECT |
cybench/cybench.py:93-102; gdm_intercode_ctf/gdm_intercode_ctf.py:38-46, 161 |
| SWE-bench, SWE-Lancer | Scored from sandbox state. SWE-Lancer: a missing decisions file gives 0 with reason no_response; malformed gives invalid_response_format | swe_bench/swe_bench.py:171; swe_lancer/scorers.py:132-150 |
| AgentDojo | Scores the completion plus the tool-call trace | agentdojo/scorer.py:16-37 |
(d) Model-graded: the judge should be bypassed
- Already bypassed: HLE, FrontierScience, UCCB, XSTest, Fortress (via preset), and HealthBench and AIR-Bench (hand-rolled).
- Not bypassed: SimpleQA (original scorer), BrowseComp (browse_comp/browse_comp.py:221-241), StrongREJECT, CoCoNot, AbstentionBench, SOSBench, CyberSecEval4 MITRE, agentic_misalignment (agentic_misalignment/scorers.py:28-40), PersistBench (raises only if
output.empty, i.e. no choices; persistbench/scorers.py:143-144), and any eval using baremodel_graded_qa.
3. Existing guidance in inspect_evals
The section “Route sample-level failures to the right outcome” in BEST_PRACTICES.md (BEST_PRACTICES.md:27-57) defines three routes:
- A verdict on the model: return
Score(INCORRECT), “reservingNOANSWERfor empty completions and detected refusals” (BEST_PRACTICES.md:39). - A machinery failure: raise.
- A failure of a probabilistic instrument: return
Score.unscored(). The examples include “a content-filter block on a fixed prompt” (BEST_PRACTICES.md:44-46).
It also requires reason, using the vocabulary no_response / refusal / invalid_response_format / grader_failed / scoring_failed (BEST_PRACTICES.md:54).
The section “Handle grader failures as instrument failures” prefers structured output, then grader-only retries, then unscored (BEST_PRACTICES.md:91-102). The metric-denominator guidance uses AgentHarm’s all-refused case as its example (BEST_PRACTICES.md:59-71).
EVALUATION_CHECKLIST.md covers:
- not using substring matching to assess refusal (EVALUATION_CHECKLIST.md:78)
- unscored for grader failures, plus
explanationandreason(EVALUATION_CHECKLIST.md:166-167) - one meaning per NaN/unscored path (EVALUATION_CHECKLIST.md:239-240)
Neither file mentions stop_reason, max_tokens truncation, reasoning-only or tool-call-only outputs, or preset_score_on_no_response by name. PR #2708 is not in this checkout, so its text could not be verified.
4. Gaps and ambiguities an experiment could resolve
Guidance and helper disagree on the default. BEST_PRACTICES says NOANSWER for empty completions;
preset_score_on_no_responsedefaults to INCORRECT. Both give 0 undervalue_to_float, but they differ for categorical metrics and in analysis (inspect’schoice()uses NOANSWER).“Empty” reads only
completion. That one test lumps together:- genuine silence;
- reasoning-only outputs truncated at
max_tokens(stop_reasonmax_tokens); - tool-call-only final turns;
- content_filter outputs with no text (Azure, Gemini SAFETY);
- Anthropic
refusalstops with no text.
The routing should differ. For example, a truncation in a refusal eval is arguably not a refusal, while a content_filter output plausibly is one.
Small
max_tokenscombined with reasoning models. XSTest (256) and CoCoNot (256) cap tokens; OpenAI reasoning models count reasoning againstmax_completion_tokens. In XSTest this likely produces empty outputs that the preset converts into “full refusal”, inflating refusal_rate. An experiment should measure the empty-rate by stop_reason under these configs.Refusals that are not empty slip past the helper. OpenAI error-mapped refusals carry the API error text, Anthropic content-filter errors carry a canned apology, and Gemini emits “BLOCKED: …” or “(no content)”. All are non-empty, so the helper sends them to the judge. The judge then grades an error string (QA evals: probably INCORRECT; refusal evals: an unpredictable class). Signals that exist but nobody reads for the model under test:
stop_reason=="content_filter",ContentText.refusal,stop_details, andModelOutput.error(set by Anthropic, not by OpenAI).Provider inconsistency in stop_reason. OpenAI-native refusals arrive as
stopplusrefusal=True. Mistral and Together cannot producecontent_filter. Gemini MALFORMED_FUNCTION_CALL producesunknownplus apology text. Any stop_reason-based rule needs therefusalblock as a second signal, as_is_grader_refusaland_classify_replyalready do.The polarity of “empty” differs across safety evals. Fortress-adversarial treats empty as safe (0 harm). AIR-Bench treats it as unsafe (0.0). MITRE-FRR treats it as compliant. XSTest and Fortress-benign treat it as refusal. There is no principled shared rule, and an experiment could test whether models’ empty outputs are in fact refusals, for example by checking stop_reason/stop_details on real runs.
Tool-call-only cases. BFCL irrelevance rewards any output without tool calls, including errors and truncations. AgentHarm’s refusal flag ignores empty messages, so a content_filter with no text counts as non-refusal.
Reasoning visibility is inconsistent. Judges in SOSBench and AgentHarm see reasoning, whereas
completion-based checks do not. Reasoning-only outputs are “non-empty” for the former and “empty” for the latter.Interaction with run-level config is untested.
fail_on_refusal=Trueturns content_filter into sample errors (excluded from metrics). This changes refusal-eval denominators and should probably be forbidden or documented for refusal evals.react(retry_refusals)and Anthropicfallback_modelssimilarly change what “refusal” outcomes are recorded.Dead or stale checks.
if not state.outputnever fires (frontierscience/frontierscience.py:165).output.emptymeans no choices, not no text, which is easily misread. Becausecompletionis stored, solvers that overwrite it can diverge from message content.Unbypassed judges. SimpleQA, BrowseComp, StrongREJECT, CoCoNot, AbstentionBench, MITRE and agentic_misalignment still spend judge calls on empty outputs, and the judge’s verdict on an empty string is uncontrolled. The size of the cost and the variance is a measurable question.