Skip to content

Commit 1fb305e

Browse files
committed
fix(adapter): strip orphaned functionResponses before sending to cloud
1 parent a8971e1 commit 1fb305e

2 files changed

Lines changed: 315 additions & 6 deletions

File tree

packages/core/src/core/DeepVServerAdapter.mergeStreamContent.test.ts

Lines changed: 202 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,10 @@ describe('mergeStreamContent + finalize end-to-end (streamed local_time call)',
354354
});
355355

356356
describe('DeepVServerAdapter.cleanContents', () => {
357-
const cleanContents = (contents: any[]) => proto.cleanContents.call({}, contents);
357+
// cleanContents 内部会调用 this.removeOrphanedToolResponses,
358+
// 白盒测试需提供一个挂载了该方法的 this 上下文。
359+
const ctx = { removeOrphanedToolResponses: proto.removeOrphanedToolResponses };
360+
const cleanContents = (contents: any[]) => proto.cleanContents.call(ctx, contents);
358361

359362
it('filters out empty or whitespace-only text parts within a message', () => {
360363
const input = [
@@ -402,3 +405,201 @@ describe('DeepVServerAdapter.cleanContents', () => {
402405
expect(result[0].parts[0].text).toBe('Keep this');
403406
});
404407
});
408+
409+
/**
410+
* 单元测试:DeepVServerAdapter.removeOrphanedToolResponses
411+
*
412+
* 守护生产 bug 修复(用户实拍 easyrouter 400):
413+
* "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"
414+
*
415+
* 根因:上下文压缩/历史截断把 functionCall 切掉、却保留了它的 functionResponse,
416+
* 形成"孤儿 functionResponse"。该孤儿被客户端 sanitizeRequestContents 的
417+
* "全局 name 兜底"误判为有配对而存活,发往云端转 OpenAI 格式后变成无前驱
418+
* tool_calls 的 role:'tool' 消息 → 严格网关 (easyrouter) 报 400。
419+
*
420+
* 修复策略(对 Gemini / Claude / OpenAI 三家均无害):在发往云端的出口处,
421+
* 按"真实配对关系 + 计数消耗"删除真孤儿——
422+
* - 优先 id 精确配对(消耗,防两个 fr 抢同一个 fc)
423+
* - 回退 name 计数配对(N 个同名 fc 最多配 N 个 fr,多出的才删)
424+
* 合法会话里 fr 数 ≤ 同名 fc 数,计数永远够,不会删任何合法配对。
425+
*/
426+
describe('DeepVServerAdapter.removeOrphanedToolResponses', () => {
427+
const removeOrphans = (contents: any[]) =>
428+
proto.removeOrphanedToolResponses.call({}, contents);
429+
430+
it('removes a truly orphaned functionResponse (its functionCall was dropped)', () => {
431+
// 压缩后场景:只剩下一个 functionResponse,对应的 functionCall 已被截断丢弃
432+
const input = [
433+
{ role: 'user', parts: [{ text: 'do something' }] },
434+
{
435+
role: 'user',
436+
parts: [
437+
{ functionResponse: { id: 'read_file-123-abc', name: 'read_file', response: { ok: true } } },
438+
],
439+
},
440+
];
441+
442+
const result = removeOrphans(input);
443+
444+
// 孤儿 fr 被删除 → 该 user 消息只剩 0 个有效 part → 整条消息移除
445+
expect(result).toHaveLength(1);
446+
expect(result[0].parts[0].text).toBe('do something');
447+
});
448+
449+
it('keeps a functionResponse that has a matching functionCall by id', () => {
450+
const input = [
451+
{
452+
role: 'model',
453+
parts: [{ functionCall: { id: 'call_1', name: 'read_file', args: { path: 'a.ts' } } }],
454+
},
455+
{
456+
role: 'user',
457+
parts: [{ functionResponse: { id: 'call_1', name: 'read_file', response: { ok: true } } }],
458+
},
459+
];
460+
461+
const result = removeOrphans(input);
462+
463+
expect(result).toHaveLength(2);
464+
expect(result[1].parts[0].functionResponse.id).toBe('call_1');
465+
});
466+
467+
it('keeps a functionResponse matched by name when ids are absent (Gemini native)', () => {
468+
// Gemini 原生:functionCall 常无 id,functionResponse 靠 name 配对
469+
const input = [
470+
{
471+
role: 'model',
472+
parts: [{ functionCall: { name: 'get_weather', args: { city: 'SH' } } }],
473+
},
474+
{
475+
role: 'user',
476+
parts: [{ functionResponse: { name: 'get_weather', response: { temp: 20 } } }],
477+
},
478+
];
479+
480+
const result = removeOrphans(input);
481+
482+
expect(result).toHaveLength(2);
483+
expect(result[1].parts[0].functionResponse.name).toBe('get_weather');
484+
});
485+
486+
it('does NOT mis-delete parallel same-name calls (N functionCalls ↔ N functionResponses)', () => {
487+
// 并行同名工具调用:3 个 read_file 全无 id,3 个 fr 也全无 id(或仅 name)
488+
const input = [
489+
{
490+
role: 'model',
491+
parts: [
492+
{ functionCall: { name: 'read_file', args: { path: 'a.ts' } } },
493+
{ functionCall: { name: 'read_file', args: { path: 'b.ts' } } },
494+
{ functionCall: { name: 'read_file', args: { path: 'c.ts' } } },
495+
],
496+
},
497+
{
498+
role: 'user',
499+
parts: [
500+
{ functionResponse: { name: 'read_file', response: { c: 'A' } } },
501+
{ functionResponse: { name: 'read_file', response: { c: 'B' } } },
502+
{ functionResponse: { name: 'read_file', response: { c: 'C' } } },
503+
],
504+
},
505+
];
506+
507+
const result = removeOrphans(input);
508+
509+
// 3 个 fr 全部保留(计数 3 配 3)
510+
expect(result).toHaveLength(2);
511+
expect(result[1].parts).toHaveLength(3);
512+
expect(result[1].parts.every((p: any) => p.functionResponse?.name === 'read_file')).toBe(true);
513+
});
514+
515+
it('removes only the EXCESS orphan when fr count exceeds same-name fc count', () => {
516+
// 2 个 fc 但 3 个 fr:保留 2 个、删除 1 个多余孤儿
517+
const input = [
518+
{
519+
role: 'model',
520+
parts: [
521+
{ functionCall: { name: 'read_file', args: { path: 'a.ts' } } },
522+
{ functionCall: { name: 'read_file', args: { path: 'b.ts' } } },
523+
],
524+
},
525+
{
526+
role: 'user',
527+
parts: [
528+
{ functionResponse: { name: 'read_file', response: { c: 'A' } } },
529+
{ functionResponse: { name: 'read_file', response: { c: 'B' } } },
530+
{ functionResponse: { name: 'read_file', response: { c: 'C' } } },
531+
],
532+
},
533+
];
534+
535+
const result = removeOrphans(input);
536+
537+
expect(result).toHaveLength(2);
538+
expect(result[1].parts).toHaveLength(2);
539+
});
540+
541+
it('does not consume one functionCall for two functionResponses (id pool exhaustion)', () => {
542+
// 1 个 fc (id=call_1) 但 2 个 fr 都声称 id=call_1:只保留 1 个,另一个判为孤儿
543+
const input = [
544+
{
545+
role: 'model',
546+
parts: [{ functionCall: { id: 'call_1', name: 'read_file', args: {} } }],
547+
},
548+
{
549+
role: 'user',
550+
parts: [
551+
{ functionResponse: { id: 'call_1', name: 'read_file', response: { c: 'A' } } },
552+
{ functionResponse: { id: 'call_1', name: 'read_file', response: { c: 'B' } } },
553+
],
554+
},
555+
];
556+
557+
const result = removeOrphans(input);
558+
559+
expect(result).toHaveLength(2);
560+
expect(result[1].parts).toHaveLength(1);
561+
});
562+
563+
it('leaves contents unchanged when there are no functionResponses', () => {
564+
const input = [
565+
{ role: 'user', parts: [{ text: 'hello' }] },
566+
{ role: 'model', parts: [{ text: 'hi there' }] },
567+
];
568+
569+
const result = removeOrphans(input);
570+
571+
expect(result).toEqual(input);
572+
});
573+
574+
it('leaves a fully paired history untouched (idempotent on healthy data)', () => {
575+
const input = [
576+
{ role: 'user', parts: [{ text: 'q' }] },
577+
{ role: 'model', parts: [{ functionCall: { id: 'c1', name: 'read_file', args: {} } }] },
578+
{ role: 'user', parts: [{ functionResponse: { id: 'c1', name: 'read_file', response: {} } }] },
579+
{ role: 'model', parts: [{ text: 'answer' }] },
580+
];
581+
582+
const result = removeOrphans(input);
583+
584+
expect(result).toEqual(input);
585+
});
586+
587+
it('preserves non-functionResponse parts in the same message when removing an orphan', () => {
588+
// 同一条 user 消息里既有孤儿 fr 又有正常文本:只删 fr,保留文本
589+
const input = [
590+
{
591+
role: 'user',
592+
parts: [
593+
{ functionResponse: { id: 'ghost-999', name: 'ghost_tool', response: {} } },
594+
{ text: 'user follow-up text' },
595+
],
596+
},
597+
];
598+
599+
const result = removeOrphans(input);
600+
601+
expect(result).toHaveLength(1);
602+
expect(result[0].parts).toHaveLength(1);
603+
expect(result[0].parts[0].text).toBe('user follow-up text');
604+
});
605+
});

packages/core/src/core/DeepVServerAdapter.ts

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -539,21 +539,29 @@ export class DeepVServerAdapter implements ContentGenerator {
539539
return hasValidPart;
540540
});
541541

542+
// 🛡️ 协议安全网:删除"真孤儿 functionResponse"(找不到任何可配对 functionCall)
543+
// 修复用户实拍 easyrouter 400:
544+
// "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"
545+
// 上下文压缩/历史截断可能切掉 functionCall 却保留其 functionResponse,
546+
// 该孤儿转 OpenAI 格式后变成无前驱 tool_calls 的 role:'tool' → 严格网关 400。
547+
// 此清理对 Gemini / Claude / OpenAI 三家均无害:合法配对全部保留,仅删真孤儿。
548+
const orphanCleaned = this.removeOrphanedToolResponses(cleaned);
549+
542550
// 🔧 安全保障:确保清理后 contents 不以 model/assistant 结尾
543551
// 某些模型(如 AWS Bedrock Claude)不支持 assistant prefill,
544552
// 要求对话必须以 user 消息结尾。过滤空消息后末尾可能变成 model。
545-
if (cleaned.length > 0 && cleaned[cleaned.length - 1].role === MESSAGE_ROLES.MODEL) {
553+
if (orphanCleaned.length > 0 && orphanCleaned[orphanCleaned.length - 1].role === MESSAGE_ROLES.MODEL) {
546554
logger.warn('[cleanContents] Contents ends with model message after cleanup — appending user placeholder');
547-
cleaned.push({
555+
orphanCleaned.push({
548556
role: MESSAGE_ROLES.USER,
549557
parts: [{ text: '[Conversation continues]' }],
550558
});
551559
}
552560

553561
// 🆕 调试日志:输出整理后的历史结构,帮助诊断多轮对话中的思维块合并情况
554562
if (process.env.DEBUG || process.env.NODE_ENV === 'development' || true) { // 🌟 暂时强制开启以便在用户的终端中显示,极其利于排除故障
555-
console.log(`[cleanContents] 整理后的历史记录列表 (共 ${cleaned.length} 条):`);
556-
cleaned.forEach((c, idx) => {
563+
console.log(`[cleanContents] 整理后的历史记录列表 (共 ${orphanCleaned.length} 条):`);
564+
orphanCleaned.forEach((c, idx) => {
557565
const partTypes = (c.parts || []).map((p: any) => {
558566
if (p.text !== undefined) return `text(${p.text.length})`;
559567
if (p.functionCall) return `functionCall(${p.functionCall.name})`;
@@ -565,7 +573,107 @@ export class DeepVServerAdapter implements ContentGenerator {
565573
});
566574
}
567575

568-
return cleaned;
576+
return orphanCleaned;
577+
}
578+
579+
/**
580+
* 🛡️ 删除"真孤儿 functionResponse"——在整个 contents 里找不到任何可配对
581+
* functionCall 的 functionResponse。
582+
*
583+
* 修复生产 bug(用户实拍 easyrouter 400):
584+
* "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"
585+
*
586+
* 根因:上下文压缩/历史截断会把 functionCall 切掉、却保留其 functionResponse,
587+
* 形成孤儿。它被上游 sanitizeRequestContents 的"全局 name 兜底"误判存活,
588+
* 转 OpenAI 格式后变成无前驱 tool_calls 的 role:'tool' → 严格网关报 400。
589+
*
590+
* 配对算法(模拟服务端真实配对,以 fc 为单位按"配对槽消耗"避免误删):
591+
* 1) 先扫描所有 functionCall,每个 fc 建一个配对槽 {id?, name, consumed:false}。
592+
* 2) 按出现顺序遍历每个 functionResponse,为其寻找一个未消耗的槽:
593+
* - 优先匹配 id 相同的未消耗槽 → 命中则消耗
594+
* - 否则匹配 name 相同的未消耗槽 → 命中则消耗
595+
* - 都没有 → 真孤儿,删除
596+
* (以 fc 为单位建槽,保证一个 fc 只配 1 个 fr,id 与 name 不会各放行一个。)
597+
*
598+
* 无害性保证(对 Gemini / Claude / OpenAI 三家均成立):
599+
* - 合法会话里 fr 数 ≤ 同名 fc 数,计数永远够 → 不删任何合法配对。
600+
* - 并行同名 N 对:nameCount=N,N 个 fr 各消耗 1,全部保留 → 不误删。
601+
* - 仅当 fr 数 > 可配对 fc 数(真出现孤儿)才删多出来的那些。
602+
* - 孤儿 functionResponse 本就是三家协议的共同禁忌,删除只会让请求更合法。
603+
*/
604+
private removeOrphanedToolResponses(contents: any[]): any[] {
605+
if (!Array.isArray(contents) || contents.length === 0) return contents;
606+
607+
// 快速通道:没有任何 functionResponse 时直接返回,零开销、零改动
608+
const hasAnyFunctionResponse = contents.some(
609+
(c) => Array.isArray(c?.parts) && c.parts.some((p: any) => p?.functionResponse),
610+
);
611+
if (!hasAnyFunctionResponse) return contents;
612+
613+
// 1) 扫描所有 functionCall,每个 fc 建一个"配对槽"(含 id?/name,初始未消耗)。
614+
// 以 fc 为单位建槽是关键:一个 fc 同时有 id 和 name 时只能配 1 个 fr,
615+
// 绝不能让 id 和 name 各放行一个 fr(否则一个 fc 被算成 2 份配对额度)。
616+
const slots: Array<{ id?: string; name?: string; consumed: boolean }> = [];
617+
for (const content of contents) {
618+
if (!Array.isArray(content?.parts)) continue;
619+
for (const part of content.parts) {
620+
const fc = part?.functionCall;
621+
if (!fc) continue;
622+
slots.push({ id: fc.id, name: fc.name, consumed: false });
623+
}
624+
}
625+
626+
// 2) 遍历每条消息,为每个 functionResponse 寻找一个未消耗的配对槽
627+
let removedCount = 0;
628+
const result: any[] = [];
629+
for (const content of contents) {
630+
if (!Array.isArray(content?.parts)) {
631+
result.push(content);
632+
continue;
633+
}
634+
635+
const keptParts = content.parts.filter((part: any) => {
636+
const fr = part?.functionResponse;
637+
if (!fr) return true; // 非 functionResponse 一律保留
638+
639+
// 优先 id 精确配对:找一个未消耗、id 相同的槽
640+
if (fr.id) {
641+
const slot = slots.find((s) => !s.consumed && s.id === fr.id);
642+
if (slot) {
643+
slot.consumed = true;
644+
return true;
645+
}
646+
}
647+
// 回退 name 配对:找一个未消耗、name 相同的槽(覆盖 Gemini 无 id 及并行同名)
648+
if (fr.name) {
649+
const slot = slots.find((s) => !s.consumed && s.name === fr.name);
650+
if (slot) {
651+
slot.consumed = true;
652+
return true;
653+
}
654+
}
655+
// 真孤儿:没有任何可配对的 functionCall 槽
656+
removedCount++;
657+
logger.warn(
658+
`[removeOrphanedToolResponses] ❌ Removing orphaned functionResponse: ` +
659+
`${fr.name} (id: ${fr.id ?? 'n/a'}) — no matching functionCall found.`,
660+
);
661+
return false;
662+
});
663+
664+
// 若该消息所有 part 都被删空,则整条丢弃;否则保留(可能含其他 part)
665+
if (keptParts.length > 0) {
666+
result.push(keptParts.length === content.parts.length ? content : { ...content, parts: keptParts });
667+
}
668+
}
669+
670+
if (removedCount > 0) {
671+
logger.warn(
672+
`[removeOrphanedToolResponses] Removed ${removedCount} orphaned functionResponse(s) before sending to cloud.`,
673+
);
674+
}
675+
676+
return result;
569677
}
570678

571679
/**

0 commit comments

Comments
 (0)