fix(opencode): surface content-filter finish reason as visible error (#31745)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
parent
a1dee8b273
commit
e2527db3c7
6 changed files with 110 additions and 0 deletions
|
|
@ -52,6 +52,9 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
responseBody: Schema.optional(Schema.String),
|
responseBody: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
|
export const ContentFilterError = NamedError.create("ContentFilterError", {
|
||||||
|
message: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
|
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
|
|
@ -388,6 +391,7 @@ const AssistantErrorSchema = Schema.Union([
|
||||||
AbortedError.EffectSchema,
|
AbortedError.EffectSchema,
|
||||||
StructuredOutputError.EffectSchema,
|
StructuredOutputError.EffectSchema,
|
||||||
ContextOverflowError.EffectSchema,
|
ContextOverflowError.EffectSchema,
|
||||||
|
ContentFilterError.EffectSchema,
|
||||||
APIError.EffectSchema,
|
APIError.EffectSchema,
|
||||||
]).annotate({ discriminator: "name" })
|
]).annotate({ discriminator: "name" })
|
||||||
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
|
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
|
||||||
|
|
|
||||||
|
|
@ -1355,6 +1355,18 @@ export const layer = Layer.effect(
|
||||||
|
|
||||||
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
|
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
|
||||||
if (finished && !handle.message.error) {
|
if (finished && !handle.message.error) {
|
||||||
|
// Surface any content-filter finish (e.g. Anthropic stop_reason:
|
||||||
|
// refusal) as an error. These turns may have produced no visible
|
||||||
|
// 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") {
|
||||||
|
handle.message.error = new SessionV1.ContentFilterError({
|
||||||
|
message: "The response was blocked by the provider's content filter",
|
||||||
|
}).toObject()
|
||||||
|
yield* sessions.updateMessage(handle.message)
|
||||||
|
yield* events.publish(Session.Event.Error, { sessionID, error: handle.message.error })
|
||||||
|
return "break" as const
|
||||||
|
}
|
||||||
if (format.type === "json_schema") {
|
if (format.type === "json_schema") {
|
||||||
handle.message.error = new SessionV1.StructuredOutputError({
|
handle.message.error = new SessionV1.StructuredOutputError({
|
||||||
message: "Model did not produce structured output",
|
message: "Model did not produce structured output",
|
||||||
|
|
|
||||||
|
|
@ -493,6 +493,14 @@ export class Reply {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentFilter() {
|
||||||
|
this.#finish = "content_filter"
|
||||||
|
this.#hang = false
|
||||||
|
this.#error = undefined
|
||||||
|
this.#reset = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
toolCalls() {
|
toolCalls() {
|
||||||
this.#finish = "tool_calls"
|
this.#finish = "tool_calls"
|
||||||
this.#hang = false
|
this.#hang = false
|
||||||
|
|
|
||||||
|
|
@ -506,6 +506,52 @@ it.instance("loop calls LLM and returns assistant message", () =>
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.instance("loop surfaces content-filter finishes as session errors", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
|
const events = yield* EventV2Bridge.Service
|
||||||
|
const prompt = yield* SessionPrompt.Service
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const chat = yield* sessions.create({ title: "Pinned" })
|
||||||
|
const errors: NonNullable<SessionV1.Assistant["error"]>[] = []
|
||||||
|
const expected = {
|
||||||
|
name: "ContentFilterError",
|
||||||
|
data: { message: "The response was blocked by the provider's content filter" },
|
||||||
|
} satisfies NonNullable<SessionV1.Assistant["error"]>
|
||||||
|
const off = yield* events.listen((event) => {
|
||||||
|
if (event.type !== Session.Event.Error.type) return Effect.void
|
||||||
|
const data = event.data as typeof Session.Event.Error.data.Type
|
||||||
|
if (data.sessionID === chat.id && data.error) errors.push(data.error)
|
||||||
|
return Effect.void
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* prompt.prompt({
|
||||||
|
sessionID: chat.id,
|
||||||
|
agent: "build",
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: "hello" }],
|
||||||
|
})
|
||||||
|
yield* llm.push(reply().text("partial response").contentFilter())
|
||||||
|
|
||||||
|
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||||
|
const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: result.info.id })
|
||||||
|
yield* off
|
||||||
|
|
||||||
|
expect(yield* llm.hits).toHaveLength(1)
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
expect(stored.info.role).toBe("assistant")
|
||||||
|
if (result.info.role === "assistant" && stored.info.role === "assistant") {
|
||||||
|
expect(result.info.finish).toBe("content-filter")
|
||||||
|
expect(result.info.error).toEqual(expected)
|
||||||
|
expect(stored.info.error).toEqual(result.info.error)
|
||||||
|
expect(errors).toContainEqual(expected)
|
||||||
|
}
|
||||||
|
expect(result.parts).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ type: "text", text: "partial response" })]),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.instance("loop stops provider overflow instead of auto-compacting when disabled", () =>
|
it.instance("loop stops provider overflow instead of auto-compacting when disabled", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig((url) => ({
|
const { llm } = yield* useServerConfig((url) => ({
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,13 @@ export type ContextOverflowError = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ContentFilterError = {
|
||||||
|
name: "ContentFilterError"
|
||||||
|
data: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiError = {
|
export type ApiError = {
|
||||||
name: "APIError"
|
name: "APIError"
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -339,6 +346,7 @@ export type AssistantMessage = {
|
||||||
| MessageAbortedError
|
| MessageAbortedError
|
||||||
| StructuredOutputError
|
| StructuredOutputError
|
||||||
| ContextOverflowError
|
| ContextOverflowError
|
||||||
|
| ContentFilterError
|
||||||
| ApiError
|
| ApiError
|
||||||
parentID: string
|
parentID: string
|
||||||
modelID: string
|
modelID: string
|
||||||
|
|
@ -1249,6 +1257,7 @@ export type GlobalEvent = {
|
||||||
| MessageAbortedError
|
| MessageAbortedError
|
||||||
| StructuredOutputError
|
| StructuredOutputError
|
||||||
| ContextOverflowError
|
| ContextOverflowError
|
||||||
|
| ContentFilterError
|
||||||
| ApiError
|
| ApiError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4920,6 +4929,7 @@ export type EventSessionError = {
|
||||||
| MessageAbortedError
|
| MessageAbortedError
|
||||||
| StructuredOutputError
|
| StructuredOutputError
|
||||||
| ContextOverflowError
|
| ContextOverflowError
|
||||||
|
| ContentFilterError
|
||||||
| ApiError
|
| ApiError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14375,6 +14375,27 @@
|
||||||
"required": ["name", "data"],
|
"required": ["name", "data"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"ContentFilterError": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["ContentFilterError"]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["message"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "data"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"APIError": {
|
"APIError": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -14468,6 +14489,9 @@
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/ContextOverflowError"
|
"$ref": "#/components/schemas/ContextOverflowError"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ContentFilterError"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/APIError"
|
"$ref": "#/components/schemas/APIError"
|
||||||
}
|
}
|
||||||
|
|
@ -17399,6 +17423,9 @@
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/ContextOverflowError"
|
"$ref": "#/components/schemas/ContextOverflowError"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ContentFilterError"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/APIError"
|
"$ref": "#/components/schemas/APIError"
|
||||||
}
|
}
|
||||||
|
|
@ -29058,6 +29085,9 @@
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/ContextOverflowError"
|
"$ref": "#/components/schemas/ContextOverflowError"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ContentFilterError"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/APIError"
|
"$ref": "#/components/schemas/APIError"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue