Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/src/v1/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,8 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String })
export const ContentFilterError = NamedError.create("ContentFilterError", {
message: Schema.String,
category: Schema.optional(Schema.String),
explanation: Schema.optional(Schema.String),
})
20 changes: 19 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { Usage, type LLMEvent } from "@opencode-ai/llm"
import { Usage, type LLMEvent, type ProviderMetadata } from "@opencode-ai/llm"

const DOOM_LOOP_THRESHOLD = 3
export type Result = "compact" | "stop" | "continue"

export interface Handle {
readonly message: SessionV1.Assistant
readonly contentFilter: { category?: string; explanation?: string } | undefined
readonly updateToolCall: (
toolCallID: string,
update: (part: SessionV1.ToolPart) => SessionV1.ToolPart,
Expand Down Expand Up @@ -72,10 +73,20 @@ interface ProcessorContext extends Input {
needsCompaction: boolean
currentText: SessionV1.TextPart | undefined
reasoningMap: Record<string, SessionV1.ReasoningPart>
contentFilter: { category?: string; explanation?: string } | undefined
}

type StreamEvent = LLMEvent

export function readContentFilter(metadata: ProviderMetadata | undefined) {
const details = metadata?.["anthropic"]?.["stopDetails"]
if (!isRecord(details)) return undefined
const category = typeof details["category"] === "string" ? details["category"] : undefined
const explanation = typeof details["explanation"] === "string" ? details["explanation"] : undefined
if (!category && !explanation) return undefined
return { ...(category ? { category } : {}), ...(explanation ? { explanation } : {}) }
}

export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}

const layer = Layer.effect(
Expand Down Expand Up @@ -111,6 +122,7 @@ const layer = Layer.effect(
needsCompaction: false,
currentText: undefined,
reasoningMap: {},
contentFilter: undefined,
}
let aborted = false

Expand Down Expand Up @@ -441,6 +453,7 @@ const layer = Layer.effect(
metadata: value.providerMetadata,
})
ctx.assistantMessage.finish = value.reason
if (value.reason === "content-filter") ctx.contentFilter = readContentFilter(value.providerMetadata)
ctx.assistantMessage.cost += usage.cost
ctx.assistantMessage.tokens = usage.tokens
yield* session.updatePart({
Expand Down Expand Up @@ -532,6 +545,8 @@ const layer = Layer.effect(
return

case "finish":
if (!ctx.contentFilter && value.reason === "content-filter")
ctx.contentFilter = readContentFilter(value.providerMetadata)
return
}
})
Expand Down Expand Up @@ -686,6 +701,9 @@ const layer = Layer.effect(
get message() {
return ctx.assistantMessage
},
get contentFilter() {
return ctx.contentFilter
},
updateToolCall,
completeToolCall,
process,
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1299,8 +1299,13 @@ const layer = Layer.effect(
// output at all — previously the session went idle silently — or
// partial text that was cut off by the provider's filter.
if (handle.message.finish === "content-filter") {
const filter = handle.contentFilter
handle.message.error = new SessionV1.ContentFilterError({
message: "The response was blocked by the provider's content filter",
message: filter?.category
? `Response refused by provider (${filter.category})${filter.explanation ? `: ${filter.explanation}` : ""}`
: "The response was blocked by the provider's content filter",
category: filter?.category,
explanation: filter?.explanation,
}).toObject()
yield* sessions.updateMessage(handle.message)
yield* events.publish(Session.Event.Error, { sessionID, error: handle.message.error })
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ function fake(
get message() {
return msg
},
contentFilter: undefined,
updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/test/session/content-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { SessionProcessor } from "../../src/session/processor"

describe("readContentFilter", () => {
test("extracts category and explanation from anthropic stopDetails", () => {
const metadata = {
anthropic: {
stopDetails: { type: "refusal", category: "reasoning_extraction", explanation: "declined per policy" },
},
}
expect(SessionProcessor.readContentFilter(metadata)).toEqual({
category: "reasoning_extraction",
explanation: "declined per policy",
})
})

test("keeps only the fields that are present", () => {
const metadata = { anthropic: { stopDetails: { type: "refusal", category: "cyber" } } }
expect(SessionProcessor.readContentFilter(metadata)).toEqual({ category: "cyber" })
})

test("returns undefined when the refusal has no named category or explanation", () => {
const metadata = { anthropic: { stopDetails: { type: "refusal", category: null, explanation: null } } }
expect(SessionProcessor.readContentFilter(metadata)).toBeUndefined()
})

test("returns undefined without anthropic stopDetails", () => {
expect(SessionProcessor.readContentFilter(undefined)).toBeUndefined()
expect(SessionProcessor.readContentFilter({})).toBeUndefined()
expect(SessionProcessor.readContentFilter({ anthropic: {} })).toBeUndefined()
expect(SessionProcessor.readContentFilter({ openai: { stopDetails: { category: "cyber" } } })).toBeUndefined()
})
})
Loading