fix(agents): graceful recovery from structured output validation errors

## Problem
SDK's Zod validation for `output_config` occasionally fails with:
  "Failed to parse structured output: invalid_value at path [intent]"
This crashes the entire response — user sees nothing despite model
generating a valid answer.

## Root Cause
The Anthropic SDK validates streamed structured output against the Zod
schema (CoordinatorResponseSchema) after streaming completes. When the
model returns an intent value not in the z.enum() (rare but happens),
the SDK throws during stream iteration or finalMessage().

## Fix
1. Catch "Failed to parse structured output" errors in both:
   - Stream iteration catch block (for-await loop)
   - stream.finalMessage() catch block
2. Recover by extracting accumulated text from assistantBlocks
3. Manual JSON.parse (skips Zod validation — intent enum mismatch
   doesn't affect user-facing content)
4. Yield parsed.answer + parsed.followUp normally

## Also Included (from previous commit)
- Removed INTENT_MAX_ANSWER_LENGTH hard truncation (弊大于利)
- Only 2000-char safety net remains for extreme edge cases
- followUp: non-question content always appended (prevents content loss)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-07 10:42:52 -08:00
parent db8617dda8
commit 7af8c4d8de
1 changed files with 68 additions and 0 deletions

View File

@ -288,6 +288,43 @@ export async function* agentLoop(
} }
} catch (error) { } catch (error) {
const errMsg = error instanceof Error ? error.message : String(error); const errMsg = error instanceof Error ? error.message : String(error);
// ---- Structured Output 验证失败恢复 ----
// SDK 的 Zod 验证可能失败(如 intent 枚举值不匹配),
// 但模型的 answer 文本通常是正确的。从已累积的 blocks 中提取内容。
if (errMsg.includes('Failed to parse structured output') && assistantBlocks.length > 0) {
logger.warn(`Structured output validation failed, recovering from accumulated text`);
const accumulatedText = assistantBlocks
.filter(b => b.type === 'text' && 'text' in b)
.map(b => (b as any).text)
.join('');
if (accumulatedText) {
try {
// 手动 JSON.parse 跳过 Zod 验证 — intent 值不影响用户看到的内容
const parsed = JSON.parse(accumulatedText);
if (parsed.answer) {
yield { type: 'text', content: parsed.answer, timestamp: Date.now() };
if (parsed.followUp) {
if (/|\?/.test(parsed.followUp)) {
yield { type: 'text', content: '\n\n' + parsed.followUp, timestamp: Date.now() };
} else {
yield { type: 'text', content: parsed.followUp, timestamp: Date.now() };
}
}
logger.debug(`[Turn ${currentTurn + 1}] Recovered intent: ${parsed.intent} (validation bypassed)`);
} else {
yield { type: 'text', content: accumulatedText, timestamp: Date.now() };
}
} catch {
// JSON 也解析不了 → 原始文本兜底
yield { type: 'text', content: accumulatedText, timestamp: Date.now() };
}
}
return; // 流已结束,无法继续正常流程
}
// 其他流错误 → 向用户报错
logger.error(`Stream processing error: ${errMsg}`); logger.error(`Stream processing error: ${errMsg}`);
yield { yield {
type: 'error', type: 'error',
@ -304,6 +341,37 @@ export async function* agentLoop(
finalMessage = await stream.finalMessage(); finalMessage = await stream.finalMessage();
} catch (error) { } catch (error) {
const errMsg = error instanceof Error ? error.message : String(error); const errMsg = error instanceof Error ? error.message : String(error);
// finalMessage 也可能因 structured output 验证失败而抛错
if (errMsg.includes('Failed to parse structured output') && assistantBlocks.length > 0) {
logger.warn(`finalMessage structured output validation failed, recovering`);
const accumulatedText = assistantBlocks
.filter(b => b.type === 'text' && 'text' in b)
.map(b => (b as any).text)
.join('');
if (accumulatedText) {
try {
const parsed = JSON.parse(accumulatedText);
if (parsed.answer) {
yield { type: 'text', content: parsed.answer, timestamp: Date.now() };
if (parsed.followUp) {
if (/|\?/.test(parsed.followUp)) {
yield { type: 'text', content: '\n\n' + parsed.followUp, timestamp: Date.now() };
} else {
yield { type: 'text', content: parsed.followUp, timestamp: Date.now() };
}
}
} else {
yield { type: 'text', content: accumulatedText, timestamp: Date.now() };
}
} catch {
yield { type: 'text', content: accumulatedText, timestamp: Date.now() };
}
}
return;
}
logger.error(`Failed to get final message: ${errMsg}`); logger.error(`Failed to get final message: ${errMsg}`);
yield { yield {
type: 'error', type: 'error',