Skip to content

fix(core): let retry hooks handle content-filter finishes - #60836

Open
dhaern wants to merge 2 commits into
anomalyco:devfrom
dhaern:fix/content-filter-retry-hook
Open

dhaern wants to merge 2 commits into
anomalyco:devfrom
dhaern:fix/content-filter-retry-hook

Conversation

@dhaern

@dhaern dhaern commented Oct 31, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #60822

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A step that ends with finish: "content-filter" sets providerFailed, so step.ts never builds llmError and the session.retry hook is skipped. The same refusal sent back as an HTTP ContentPolicy error does reach the hook, which is why a fallback plugin can recover one but not the other, and why a refused subagent job ends as error. In v1 both cases ended up on the same path (prompt.ts turned a content-filter finish into ContentFilterError and published Session.Event.Error, like any API error), so plugins could treat them the same way.

This isn't tied to one provider. content-filter is the normalized finish the protocols already produce for Anthropic refusal, OpenAI Chat and Responses content_filter, Gemini safety blocks, Bedrock guardrails and Mistral, so all of them go through the same branch. The change wraps that finish in a ContentPolicyError and sends it through the retry decision like other provider failures. The hook receives the same provider.content-filter error that step.failed carries today. ContentPolicy isn't retryable, so the proposal stays retry: false and nothing changes without a hook. If a hook switches the model and returns retry: true, the runner reuses the existing Retry outcome when no output was produced, or Continue when part of the answer was already streamed, the same way it handles a mid-stream rate limit. It's 10 lines in step.ts.

How did you verify your code works?

Added two tests in session-runner.test.ts, modeled on "retries an unknown finish before output" and "continues after a mid-stream rate limit": a hook turns a content-filter finish into a retry before output and into a continuation after partial output. Both fail without the change. The existing content-filter tests pass unchanged, bun test test/session-runner.test.ts is green and bun run typecheck in packages/core is clean.

I also ran it on v2.2.4 with the patch cherry-picked and a local OpenAI-compatible stub returning finish_reason: "content_filter", using oh-my-opencode-slim's retry hook. A root session and a background subagent both switched model and finished on the fallback, and the parent got state="completed" instead of state="error". With no fallback configured the step still fails as before.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #60586

Public API: session.retry plugin hook now also fires for content-filter finishes (default retry: false; backwards compatible)

@dhaern
dhaern force-pushed the fix/content-filter-retry-hook branch from 8a5fb34 to 4101e4a Compare October 31, 2026 07:33

@opencodejr opencodejr 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.

The fix looks correct to me and I found nothing blocking. I ran the two new tests against the base: both fail there with StepFailedError, and the session.retry hook is never called. With this PR, all 245 tests in session-runner.test.ts pass. Type-checking packages/core with tsc -b tsconfig.tests.json is clean. I couldn't get an end-to-end TUI check working, because the simulated model's first response went to the session title.

I also checked the paths around the change. Without a hook, behaviour is unchanged: the proposal stays retry: false and the content-filter failure is still recorded and reported once, as before. If a hook retries before any output, the step returns before step.failed is published. If a hook continues after partial output, the existing Continue path is used.

Public API: no signatures change. One behaviour change for plugins: the session.retry hook now also fires for content-filter finishes, with error type provider.content-filter and a default decision of retry: false. This is backwards compatible, but a hook that sets retry: true for every error will now also retry refusals.

Related: #60586

@opencodejr opencodejr 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.

The new commit only re-wraps lines in step.ts; the behaviour is unchanged. I re-ran session-runner.test.ts (245 pass) and the packages/core type check (clean). The public API impact is the same as before: the session.retry hook now also fires for content-filter finishes, with a default decision of retry: false. This is backwards compatible. I have no new concerns.

Offer content-filter finishes to retry hooks as content-policy failures while preserving the default terminal behavior. Reuse the existing retry or continuation path when a hook overrides the decision.
@dhaern
dhaern force-pushed the fix/content-filter-retry-hook branch from 4101e4a to 41905e2 Compare October 31, 2026 07:43
@dhaern dhaern changed the title fix(session): let retry hooks handle content-filter finishes fix(core): let retry hooks handle content-filter finishes Oct 31, 2026

@opencodejr opencodejr 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.

The new commit only renames the commit message and doesn't change any code, so my previous review still holds and I have no new concerns. As before, the session.retry hook now also fires for content-filter finishes, with a default decision of retry: false. This is backwards compatible.

@GiuseppeBellamacina GiuseppeBellamacina 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.

Question on step.ts: the content-filter failure is rebuilt as a ContentPolicyError carrying only the message, and the retry path is gated on !recorded.outputStarted. Does outputStarted cover tool calls that already ran before the content-filter finish? If not, a hook-driven retry could run those tools again.

@dhaern

dhaern commented Oct 31, 2026

Copy link
Copy Markdown
Author

@GiuseppeBellamacina yep, that's covered. outputStarted flips to true on tool-input-start, tool-call and tool-input-error, plus whenever text or reasoning starts (publish-llm-event.ts:441-494), and a tool can only run after its tool-call event, so by the time anything has executed in that step the flag is already set and the retry branch at step.ts:215 just gets skipped. The hook still sees the content-filter error, but the step fails the same way it does today and nothing runs twice. Basically a hook-driven retry only ever replays a step that hadn't put anything out yet, same deal as the other provider errors.

@dhaern

dhaern commented Nov 1, 2026

Copy link
Copy Markdown
Author

@rekram1-node you added the session.retry hook in #45999, could you take a look?

@mahammedmahieousman

mahammedmahieousman commented Nov 1, 2026 via email

Copy link
Copy Markdown

…ry-hook

# Conflicts:
#	packages/core/src/session/runner/step.ts

@opencodejr opencodejr 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.

The new commit only merges dev into the branch; the fix is unchanged and still applies cleanly after the reworked overflow handling. With the merge, session-runner.test.ts passes (246 tests), including the compaction content-filter test added on dev, and the packages/core type check is clean. The public API impact is the same as before: the session.retry hook now also fires for content-filter finishes, with a default decision of retry: false. This is backwards compatible. I have no new concerns.

@mahammedmahieousman

mahammedmahieousman commented Nov 1, 2026 via email

Copy link
Copy Markdown

@rekram1-node

Copy link
Copy Markdown
Contributor

Yeah good idea

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.

session.retry hook never fires on content-filter finishes

4 participants