Skip to content

Report model refusals as stream events, not swallowed - #1046

Open
sroussey wants to merge 2 commits into
mainfrom
claude/peaceful-ride-7s8gv1
Open

sroussey wants to merge 2 commits into
mainfrom
claude/peaceful-ride-7s8gv1

Conversation

@sroussey

@sroussey sroussey commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds comprehensive support for reporting model refusals across the AI provider ecosystem. OpenAI-shaped providers stream refusals in two ways: delta.refusal strings and finish_reason: "content_filter" endings with empty bodies. Previously these were silently dropped; now they're emitted as refusal stream events on a reserved output port, allowing downstream consumers to distinguish between "the model said no" and "the model gave an empty answer."

Key Changes

  • New createChatRefusalReader() helper (@workglow/ai/provider-utils): Reads OpenAI-shaped chat completion choices and emits refusal events. Handles both explicit delta.refusal strings and bare content_filter endings (emitting a generic refusal message when no text was provided).

  • Refusal support in agent rounds: AgentRoundContext now includes onRefusal(refusal, category?) to allow run-fns to report refusals. Failed attempts' refusals are retracted before retry via text-retract events on the reserved refusal port.

  • Stream event types: Added StreamRefusal type to @workglow/task-graph for the new event shape { type: "refusal", refusal: string, category?: string }.

  • Provider implementations updated: All OpenAI-shaped providers (DeepSeek, OpenRouter, xAI, Hugging Face Inference, LlamaCpp Server) now use createChatRefusalReader() instead of manually tracking refusal state.

  • Test conformance suite: New refusalBlock() assertion in @workglow/test-contract validates that providers correctly report stated refusals, content-filtered responses, and ordinary answers without false refusals.

  • Stream accumulation: StreamProcessor and StreamEventAccumulator handle refusal retractions on the reserved refusal port, allowing retried rounds to take back earlier attempts' refusals.

Implementation Details

  • Refusals stream on a reserved REFUSAL_OUTPUT_KEY output port (not a declared data port), so retractions don't need routing or accumulator logic beyond text manipulation.
  • A bare content_filter finish reason with no delta.refusal text emits CONTENT_FILTER_REFUSAL_TEXT ("The response was withheld by the provider's content filter.") as a generic refusal with category "output-refusal".
  • Text streamed before a refusal arrives is left as-is; the refusal event signals the model's intent, and consumers detect it on the reserved output.
  • Refusal category is optional and provider-specific (e.g., "output-refusal" for content-filtered responses).

… let a retry take refusal text back

An OpenAI-shaped stream can end in a refusal two ways, and the hand-rolled
text loops caught neither in HF Inference or llamacpp-server: delta.refusal was
read only by the tool-calling helper, and finish_reason "content_filter" with
an empty body completed as a normal, empty answer.

- createChatRefusalReader (provider-utils) reads one choice: it emits
  delta.refusal and, when a stream ends in content_filter with no refusal text,
  one generic refusal. accumulateOpenAIChatStream, HFI's three text run-fns,
  llamacpp-server's text and tool-calling run-fns, and the nine xAI, DeepSeek
  and OpenRouter text loops all use it.
- A text-retract naming the reserved refusal port takes back refusal text a
  failed attempt streamed (StreamProcessor, StreamEventAccumulator), so a
  retried call does not append to it or keep it after the retry succeeds.
- Text streamed before a refusal is left as streamed.

Ollama's wire format has no refusal signal (done_reason is a plain string), so
there is nothing to map there.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYW6CtC4bjAFbnDsJb
…pin them in the tool-turn contract

- AgentRoundAttempt forwards a round's refusal as it arrives and, like the
  round's text, takes a failed attempt's back with a text-retract on the
  refusal port before the retry, so an agent turn reports the refusal of the
  round that stood. AgentRoundContext gains onRefusal for a host round runner.
  AgentTask's output now carries refusal where it was dropped before.
- runToolTurnConformance gains an optional refusal block: a stated refusal, a
  reply withheld by the content filter, and an ordinary answer with none. The
  chat-completions harnesses write both shapes; the local providers whose wire
  has no refusal field opt out with a reason.
- The xAI, DeepSeek and OpenRouter structured-generation run-fns read refusals
  through createChatRefusalReader too, so a content_filter ending is reported.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYW6CtC4bjAFbnDsJb

@mergestorm-vortex mergestorm-vortex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto land this PR in Mergestorm

Review progress ██████████ 46/46 files

Approve — reviewed files look good at 5571de9.

✅ All clear — nothing to fix.


Mergestorm: 30 free reviews a month

This branch has not been deployed

No deployments
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.

2 participants