pi-refusal-guard

A Claude safety-classifier refusal shouldn't kill your turn — rescue, retarget the server-side fallback chain, and see what's tripping.

Packages

Package details

extension

Install pi-refusal-guard from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-refusal-guard
Package
pi-refusal-guard
Version
0.2.1
Published
Jul 25, 2026
Downloads
483/mo · 483/wk
Author
loneexile
License
MIT
Types
extension
Size
29.5 KB
Dependencies
0 dependencies · 0 peers
Pi manifest JSON
{
  "extensions": [
    "./extensions"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-refusal-guard

A Claude safety-classifier refusal shouldn't kill your turn.

An extension for Pi / OMP (Oh My Pi).

The problem

Claude Fable 5, Mythos 5 and Opus 5 run real-time safety classifiers. When one declines, the API returns HTTP 200 with stop_reason: "refusal" and a stop_details.category — most often cyber, which Anthropic documents as firing on benign cybersecurity work.

omp classifies that as a retryable error, but the retry only proceeds if a fallback model was actually applied. With no fallback chain configured, the chain ends without even emitting a retry: the assistant turn has no content, and the agent just stops. Ask a security question, watch the harness die.

Other providers do the same thing under different names — Google's promptFeedback.blockReason, OpenAI's content_filter — and land in the same place: a turn with nothing in it. See Provider coverage.

What this adds

Rescue A refused turn that produced no output and that nothing else recovered gets one continuation carrying a reframing note, instead of a silent dead stop.
Telemetry Every refusal is appended to a JSONL log. /refusals reports which categories and models are tripping, and how often a fallback saved the turn.
Retarget Rewrites Anthropic's server-side fallbacks chain so you choose the target models instead of the hardcoded default.

The rescue note reframes the task honestly — it states which category fired and asks the agent to restate the work in concrete defensive terms or say plainly what it cannot do. It does not try to defeat the classifier.

Two deliberate limits on the rescue:

  • Empty refusals only. Anthropic can refuse mid-stream, after the model has already emitted text or started a tool call. That turn is not a silent stop — you can see what happened — and re-prompting risks duplicating work or stranding a tool call. Those are logged as partial and left alone. Thinking blocks don't count as output, so a think-then-refuse turn is still rescued.
  • One rescue until real output. The counter is cleared by a turn that actually produces something, not by a turn boundary — a rescue continuation may open a turn of its own, so a turn-scoped guard would reset itself and let a persistently-refusing model loop.

How a refusal flows

Three layers. The first two are omp's own and handle most refusals; this extension covers what falls through.

flowchart TD
    CLS{"Classifier declined?"}
    DONE(["Turn completes normally"])

    subgraph BUILTIN["omp built-ins - turn these on first"]
        L1{"Layer 1: server-side fallback<br/>to claude-opus-4-8"}
        L2{"Layer 2: retry.fallbackChains"}
    end

    subgraph GUARD["Layer 3: pi-refusal-guard"]
        PART{"Output already emitted?"}
        CAP{"Rescued since the<br/>last real output?"}
        RESCUE["Continue with a<br/>reframing note"]
    end

    LEAVE(["Left alone - logged partial"])
    DEAD(["Dead stop - logged dead"])

    CLS -->|no| DONE
    CLS -->|"yes, stop_reason refusal"| L1
    L1 -->|"a fallback answered"| DONE
    L1 -->|"ineligible, or still refused"| L2
    L2 -->|"a fallback answered"| DONE
    L2 -->|"no chain, or still refused"| PART
    PART -->|yes| LEAVE
    PART -->|no| CAP
    CAP -->|yes| DEAD
    CAP -->|no| RESCUE
    RESCUE --> DONE

Without the extension, everything that falls out of layer 2 ends the turn with no output and no explanation.

One asymmetry worth knowing: a layer-2 recovery is recorded, because omp surfaces the refused message before it retries. A layer-1 recovery is not — Anthropic retries inside a single API call and returns one message whose stop reason is already normal, so no client-side tool can see that a refusal happened. If you want /refusals to show the full picture, lean on retry.fallbackChains.

Provider coverage

Claude is the case this was built for, but the rescue and the log are not Anthropic-specific.

Provider Detected how Category recorded
Anthropic stopDetails.type is refusal or sensitive the real one — cyber, bio, frontier_llm, …
Google omp's ContentBlocked error flag, set from promptFeedback.blockReason content-blocked
OpenAI (Responses) same flag, set from incomplete: content_filter content-blocked
OpenAI (chat completions) error text, since that path reports finish_reason: content_filter with no flag content-blocked
Anything else omp flags as content-blocked the same flag content-blocked

Only Anthropic reports a named category, so only Claude refusals give /refusals a meaningful category breakdown. Everything else is recorded with its provider error text as the explanation.

Retarget is Anthropic-only and always will be — fallbacks is a parameter of Anthropic's server-side-fallback beta, with no equivalent elsewhere. Rescue and telemetry are the cross-provider parts.

Install

# Pi
pi install npm:pi-refusal-guard

# OMP (Oh My Pi)
omp plugin install npm:pi-refusal-guard

Straight from git, without npm:

pi install git:github.com/LoneExile/pi-refusal-guard

Or drop extensions/refusal-guard.ts into ~/.pi/agent/extensions/ or ~/.omp/agent/extensions/.

Restart the agent (or open a new session) after installing.

Configure first (this matters)

omp already ships two fallback mechanisms. Turn them on — this extension complements them, it does not replace them.

# ~/.omp/agent/config.yml
providers:
  anthropic:
    # Server-side: one round trip, Anthropic retries the refused request on
    # claude-opus-4-8. Only applies to Fable 5 / Mythos 5.
    serverSideFallback: true

retry:
  modelFallback: true
  fallbackChains:
    # Client-side: covers Opus 5 too, and any provider.
    anthropic/claude-fable-5:
      - anthropic/claude-opus-4-8
    anthropic/claude-mythos-5:
      - anthropic/claude-opus-4-8
    anthropic/claude-opus-5:
      - anthropic/claude-opus-4-8

With those set, most refusals are handled before this extension is needed. It covers what is left: the refusal that the whole chain declined, and the question of what is tripping in the first place.

Settings

All optional, read from the environment at load. Each is accepted under either prefix — OMP_REFUSAL_* or PI_REFUSAL_* — so the same package configures cleanly on either harness.

Variable Default Effect
…_REFUSAL_RESCUE on Set to off/0/false/no to disable the continuation.
…_REFUSAL_FALLBACKS (unset) Comma-separated model ids replacing the server-side chain, e.g. claude-opus-4-8,claude-sonnet-5. Max 3 (Anthropic's limit).
…_REFUSAL_LOG ~/.refusal-guard/refusals.jsonl Where refusals are recorded. One log for both harnesses, so everything lands in one place regardless of which agent hit it.

Commands

Command Effect
/refusals Report categories, models, outcomes and the five most recent refusals.
/refusals on | /refusals off Toggle the rescue for this session.
/refusals clear Delete the log.

Why retarget is rewrite-only

OMP_REFUSAL_FALLBACKS only takes effect on a request that already carries a fallbacks array — that is, when providers.anthropic.serverSideFallback is on and the model is Fable 5 or Mythos 5.

This is deliberate. The server-side-fallback beta header is assembled from the request options before the before_provider_request hook runs. A fallbacks array injected where none existed would be sent without its beta header and rejected by the API. Verified against a live request:

{"model":"claude-fable-5","fallbacks":[{"model":"claude-opus-4-8"}]}
{"model":"claude-sonnet-5","fallbacks":null}
flowchart TD
    BUILD["omp builds the Anthropic request"]
    GATE{"serverSideFallback on, and model eligible?"}
    WITH["body carries fallbacks claude-opus-4-8, headers carry the beta"]
    WITHOUT["body carries no fallbacks, headers carry no beta"]
    HOOK["before_provider_request - the extension runs here"]
    ASK{"OMP_REFUSAL_FALLBACKS set, and the body already has a chain?"}
    SWAP["Swap in your chain - the beta header is already there"]
    SKIP["Leave the body untouched"]
    SEND["Request sent"]

    BUILD --> GATE
    GATE -->|yes| WITH
    GATE -->|no| WITHOUT
    WITH --> HOOK
    WITHOUT --> HOOK
    HOOK --> ASK
    ASK -->|yes| SWAP
    ASK -->|no| SKIP
    SWAP --> SEND
    SKIP --> SEND

The hook runs after the headers are fixed. So: enable the built-in flag to get the header and the default chain, and this swaps in the models you picked.

Development

npm install
npm run typecheck
npm test

Tests drive the extension through a fake ExtensionAPI — no omp install, no network.

License

MIT