refactor(agents): remove per-intent hard truncation, keep 2000-char safety net

Hard-coded INTENT_MAX_ANSWER_LENGTH limits caused mid-sentence truncation and
content loss. Length control now relies on prompt + schema description + LLM-Judge
(3 layers). Only a 2000-char safety net remains for extreme edge cases.

Also simplified followUp: non-question followUp is now always appended (prevents
model content split from silently dropping text).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-07 10:32:53 -08:00
parent b55cd4bc1e
commit db8617dda8
2 changed files with 13 additions and 38 deletions

View File

@ -32,7 +32,6 @@ import {
getToolsForClaudeAPI,
} from '../tools/coordinator-tools';
import {
INTENT_MAX_ANSWER_LENGTH,
MAX_FOLLOWUP_LENGTH,
smartTruncate,
} from '../schemas/coordinator-response.schema';
@ -351,48 +350,39 @@ export async function* agentLoop(
.map(b => b.text)
.join('');
// ---- Structured Output 解析:从 JSON 中提取 answer强制截断yield ----
// ---- Structured Output 解析:从 JSON 中提取 answer + followUp ----
// 长度控制由 提示词 + Schema描述 + LLM-Judge 三层负责
// 这里只做 JSON 解析 + 安全网2000字极端情况保护
if (params.outputConfig && responseText) {
try {
const parsed = JSON.parse(responseText);
if (parsed.answer) {
// 按 intent 强制截断 answer
const maxLen = INTENT_MAX_ANSWER_LENGTH[parsed.intent] || 300;
const originalLen = parsed.answer.length;
const answer = smartTruncate(parsed.answer, maxLen);
if (originalLen > maxLen) {
logger.debug(
`[Turn ${currentTurn + 1}] Answer truncated: ${originalLen}${answer.length} chars (intent=${parsed.intent}, limit=${maxLen})`,
const SAFETY_NET = 2000; // 极端情况安全网,正常不会触发
let answer = parsed.answer;
if (answer.length > SAFETY_NET) {
answer = smartTruncate(answer, SAFETY_NET);
logger.warn(
`[Turn ${currentTurn + 1}] Answer hit safety net: ${parsed.answer.length}${answer.length} chars (intent=${parsed.intent})`,
);
}
yield { type: 'text', content: answer, timestamp: Date.now() };
// followUp 处理:
// 1. 含 /? → 作为跟进问题输出
// 2. answer 未以句末标点结束 → followUp 可能是 answer 的延续部分,追加输出
// 3. 其余情况 → 过滤掉(内部策略备注等)
// followUp→ 跟进问题;否则直接追加(模型可能将内容分到 followUp
if (parsed.followUp) {
const isQuestion = /|\?/.test(parsed.followUp);
const answerEndsClean = /[。!?;!?]$/.test(answer.replace(/\.{3}$/, ''));
if (isQuestion) {
if (/|\?/.test(parsed.followUp)) {
const followUp = smartTruncate(parsed.followUp, MAX_FOLLOWUP_LENGTH);
yield { type: 'text', content: '\n\n' + followUp, timestamp: Date.now() };
} else if (!answerEndsClean) {
// answer 被截断或模型将内容分到 followUp → 追加(不换行)
logger.debug(`[Turn ${currentTurn + 1}] followUp appended as answer continuation`);
} else {
// 非问题的 followUp 直接追加(防止内容丢失)
yield { type: 'text', content: parsed.followUp, timestamp: Date.now() };
}
}
} else {
// JSON 合法但缺少 answer 字段 → yield 原始文本
yield { type: 'text', content: responseText, timestamp: Date.now() };
}
logger.debug(`[Turn ${currentTurn + 1}] Structured output intent: ${parsed.intent}`);
} catch {
// JSON 解析失败 → 回退到原始文本
logger.warn(`[Turn ${currentTurn + 1}] Structured output parse failed, falling back to raw text`);
yield { type: 'text', content: responseText, timestamp: Date.now() };
}

View File

@ -23,21 +23,6 @@ export const CoordinatorResponseSchema = z.object({
export type CoordinatorResponse = z.infer<typeof CoordinatorResponseSchema>;
// ============================================================
// Intent-based Answer Length Limits (中文字符数)
// ============================================================
/** 每种意图对应的最大 answer 长度(字符数)— 程序级硬约束 */
export const INTENT_MAX_ANSWER_LENGTH: Record<string, number> = {
factual_question: 250, // 1-3句直接给答案
yes_no_question: 150, // 1-2句结论+理由
comparison_question: 300, // 2-3句推荐+理由
assessment_request: 500, // 按需但有上限
objection_expression: 350, // 共情+事实+引导3个组件各需空间
detailed_consultation: 600, // 复杂咨询允许较长
casual_chat: 100, // 1句
};
/** followUp 问题最大长度 */
export const MAX_FOLLOWUP_LENGTH = 80;