feat: add two-layer thinking block validation (proactive + reactive) (#248)
- Add thinking-block-validator hook for proactive prevention before API calls - Enhance session-recovery to include previous thinking content - Fix hook registration to actually invoke the validator Addresses extended thinking errors with Claude Opus/Sonnet 4.5 using tool calls. Related: https://github.com/vercel/ai/issues/7729 Related: https://github.com/sst/opencode/issues/2599
This commit is contained in:
@@ -59,7 +59,8 @@
|
|||||||
"agent-usage-reminder",
|
"agent-usage-reminder",
|
||||||
"non-interactive-env",
|
"non-interactive-env",
|
||||||
"interactive-bash-session",
|
"interactive-bash-session",
|
||||||
"empty-message-sanitizer"
|
"empty-message-sanitizer",
|
||||||
|
"thinking-block-validator"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const HookNameSchema = z.enum([
|
|||||||
"non-interactive-env",
|
"non-interactive-env",
|
||||||
"interactive-bash-session",
|
"interactive-bash-session",
|
||||||
"empty-message-sanitizer",
|
"empty-message-sanitizer",
|
||||||
|
"thinking-block-validator",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const AgentOverrideConfigSchema = z.object({
|
export const AgentOverrideConfigSchema = z.object({
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ export { createKeywordDetectorHook } from "./keyword-detector";
|
|||||||
export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
||||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||||
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||||
|
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||||
|
|||||||
@@ -223,6 +223,41 @@ export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most recent thinking content from previous assistant messages
|
||||||
|
* Following Anthropic's recommendation to include thinking blocks from previous turns
|
||||||
|
*/
|
||||||
|
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
|
||||||
|
const messages = readMessages(sessionID)
|
||||||
|
|
||||||
|
// Find the index of the current message
|
||||||
|
const currentIndex = messages.findIndex(m => m.id === beforeMessageID)
|
||||||
|
if (currentIndex === -1) return ""
|
||||||
|
|
||||||
|
// Search backwards through previous assistant messages
|
||||||
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i]
|
||||||
|
if (msg.role !== "assistant") continue
|
||||||
|
|
||||||
|
// Look for thinking parts in this message
|
||||||
|
const parts = readParts(msg.id)
|
||||||
|
for (const part of parts) {
|
||||||
|
if (THINKING_TYPES.has(part.type)) {
|
||||||
|
// Found thinking content - return it
|
||||||
|
// Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property
|
||||||
|
const thinking = (part as { thinking?: string; text?: string }).thinking
|
||||||
|
const reasoning = (part as { thinking?: string; text?: string }).text
|
||||||
|
const content = thinking || reasoning
|
||||||
|
if (content && content.trim().length > 0) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||||
const partDir = join(PART_STORAGE, messageID)
|
const partDir = join(PART_STORAGE, messageID)
|
||||||
|
|
||||||
@@ -230,13 +265,16 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
|
|||||||
mkdirSync(partDir, { recursive: true })
|
mkdirSync(partDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to get thinking content from previous turns (Anthropic's recommendation)
|
||||||
|
const previousThinking = findLastThinkingContent(sessionID, messageID)
|
||||||
|
|
||||||
const partId = `prt_0000000000_thinking`
|
const partId = `prt_0000000000_thinking`
|
||||||
const part = {
|
const part = {
|
||||||
id: partId,
|
id: partId,
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
type: "thinking",
|
type: "thinking",
|
||||||
thinking: "",
|
thinking: previousThinking || "[Continuing from previous reasoning]",
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
170
src/hooks/thinking-block-validator/index.ts
Normal file
170
src/hooks/thinking-block-validator/index.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Proactive Thinking Block Validator Hook
|
||||||
|
*
|
||||||
|
* Prevents "Expected thinking/redacted_thinking but found tool_use" errors
|
||||||
|
* by validating and fixing message structure BEFORE sending to Anthropic API.
|
||||||
|
*
|
||||||
|
* This hook runs on the "experimental.chat.messages.transform" hook point,
|
||||||
|
* which is called before messages are converted to ModelMessage format and
|
||||||
|
* sent to the API.
|
||||||
|
*
|
||||||
|
* Key differences from session-recovery hook:
|
||||||
|
* - PROACTIVE (prevents error) vs REACTIVE (fixes after error)
|
||||||
|
* - Runs BEFORE API call vs AFTER API error
|
||||||
|
* - User never sees the error vs User sees error then recovery
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Message, Part } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
|
interface MessageWithParts {
|
||||||
|
info: Message
|
||||||
|
parts: Part[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessagesTransformHook = {
|
||||||
|
"experimental.chat.messages.transform"?: (
|
||||||
|
input: Record<string, never>,
|
||||||
|
output: { messages: MessageWithParts[] }
|
||||||
|
) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model has extended thinking enabled
|
||||||
|
* Uses patterns from think-mode/switcher.ts for consistency
|
||||||
|
*/
|
||||||
|
function isExtendedThinkingModel(modelID: string): boolean {
|
||||||
|
if (!modelID) return false
|
||||||
|
const lower = modelID.toLowerCase()
|
||||||
|
|
||||||
|
// Check for explicit thinking/high variants (always enabled)
|
||||||
|
if (lower.includes("thinking") || lower.endsWith("-high")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for thinking-capable models (claude-4 family, claude-3)
|
||||||
|
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
|
||||||
|
return (
|
||||||
|
lower.includes("claude-sonnet-4") ||
|
||||||
|
lower.includes("claude-opus-4") ||
|
||||||
|
lower.includes("claude-3")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message has tool parts (tool_use)
|
||||||
|
*/
|
||||||
|
function hasToolParts(parts: Part[]): boolean {
|
||||||
|
if (!parts || parts.length === 0) return false
|
||||||
|
|
||||||
|
return parts.some((part: Part) => {
|
||||||
|
const type = part.type as string
|
||||||
|
return type === "tool" || type === "tool_use"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message starts with a thinking/reasoning block
|
||||||
|
*/
|
||||||
|
function startsWithThinkingBlock(parts: Part[]): boolean {
|
||||||
|
if (!parts || parts.length === 0) return false
|
||||||
|
|
||||||
|
const firstPart = parts[0]
|
||||||
|
const type = firstPart.type as string
|
||||||
|
return type === "thinking" || type === "reasoning"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most recent thinking content from previous assistant messages
|
||||||
|
*/
|
||||||
|
function findPreviousThinkingContent(
|
||||||
|
messages: MessageWithParts[],
|
||||||
|
currentIndex: number
|
||||||
|
): string {
|
||||||
|
// Search backwards from current message
|
||||||
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i]
|
||||||
|
if (msg.info.role !== "assistant") continue
|
||||||
|
|
||||||
|
// Look for thinking parts
|
||||||
|
if (!msg.parts) continue
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
const type = part.type as string
|
||||||
|
if (type === "thinking" || type === "reasoning") {
|
||||||
|
const thinking = (part as any).thinking || (part as any).text
|
||||||
|
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
|
||||||
|
return thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend a thinking block to a message's parts array
|
||||||
|
*/
|
||||||
|
function prependThinkingBlock(
|
||||||
|
message: MessageWithParts,
|
||||||
|
thinkingContent: string
|
||||||
|
): void {
|
||||||
|
if (!message.parts) {
|
||||||
|
message.parts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create synthetic thinking part
|
||||||
|
const thinkingPart = {
|
||||||
|
type: "thinking" as const,
|
||||||
|
id: `prt_0000000000_synthetic_thinking`,
|
||||||
|
sessionID: (message.info as any).sessionID || "",
|
||||||
|
messageID: message.info.id,
|
||||||
|
thinking: thinkingContent,
|
||||||
|
synthetic: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend to parts array
|
||||||
|
message.parts.unshift(thinkingPart as unknown as Part)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and fix assistant messages that have tool_use but no thinking block
|
||||||
|
*/
|
||||||
|
export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||||
|
return {
|
||||||
|
"experimental.chat.messages.transform": async (_input, output) => {
|
||||||
|
const { messages } = output
|
||||||
|
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the model info from the last user message
|
||||||
|
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
||||||
|
const modelID = (lastUserMessage?.info as any)?.modelID || ""
|
||||||
|
|
||||||
|
// Only process if extended thinking might be enabled
|
||||||
|
if (!isExtendedThinkingModel(modelID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all assistant messages
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i]
|
||||||
|
|
||||||
|
// Only check assistant messages
|
||||||
|
if (msg.info.role !== "assistant") continue
|
||||||
|
|
||||||
|
// Check if message has tool parts but doesn't start with thinking
|
||||||
|
if (hasToolParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||||
|
// Find thinking content from previous turns
|
||||||
|
const previousThinking = findPreviousThinkingContent(messages, i)
|
||||||
|
|
||||||
|
// Prepend thinking block with content from previous turn or placeholder
|
||||||
|
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
|
||||||
|
|
||||||
|
prependThinkingBlock(msg, thinkingContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
createNonInteractiveEnvHook,
|
createNonInteractiveEnvHook,
|
||||||
createInteractiveBashSessionHook,
|
createInteractiveBashSessionHook,
|
||||||
createEmptyMessageSanitizerHook,
|
createEmptyMessageSanitizerHook,
|
||||||
|
createThinkingBlockValidatorHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||||
import {
|
import {
|
||||||
@@ -319,6 +320,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer")
|
const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer")
|
||||||
? createEmptyMessageSanitizerHook()
|
? createEmptyMessageSanitizerHook()
|
||||||
: null;
|
: null;
|
||||||
|
const thinkingBlockValidator = isHookEnabled("thinking-block-validator")
|
||||||
|
? createThinkingBlockValidatorHook()
|
||||||
|
: null;
|
||||||
|
|
||||||
const backgroundManager = new BackgroundManager(ctx);
|
const backgroundManager = new BackgroundManager(ctx);
|
||||||
|
|
||||||
@@ -365,6 +369,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
input: Record<string, never>,
|
input: Record<string, never>,
|
||||||
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
||||||
) => {
|
) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await thinkingBlockValidator?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
|
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user