mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): reorder injected context on every user entry
The send boundary reordered only the pending entry, so request N sent [reminder, hook, catalog, ...prompt] while request N+1 rebuilt the same entry from stored history as [reminder, ...prompt, hook, catalog]. The shared provider prefix then ended at the leading reminder and every injected block was re-prefilled at full input price on every turn - the failure mode REATTACH_BOUNDARY_METADATA exists to prevent (#11627). Applying the same rule to every user entry in the request copy keeps an entry's wire shape position-independent, so request N+1 extends request N's prefix exactly. Stored history is still untouched, which keeps the transcript projection and the resume receipt intact. Pin it with a two-turn test that asserts the first turn's entry is re-sent byte-identically, and compare each request with the previous one instead of comparing the request prefix with stored history. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw25fj8od0
This commit is contained in:
1 parent
dc270e50fb
commit
96e6b0c702
4 files changed
+70
-20
No files matched your search
@@ -71,6 +71,17 @@ re-deliver already-delivered hook context every turn. Only the request copy
|
|||||||
changes, so the transcript, resume, deduplication, and rewind contracts are
|
changes, so the transcript, resume, deduplication, and rewind contracts are
|
||||||
untouched.
|
untouched.
|
||||||
|
|
||||||
|
**Every user entry, not just the pending one.** The next request re-sends the
|
||||||
|
previous turn's entry from stored history, so reordering only the pending entry
|
||||||
|
would make an entry's wire shape depend on its position: request N would send
|
||||||
|
`[reminder, hook, catalog, ...prompt]` while request N+1 rebuilds the same entry
|
||||||
|
as `[reminder, ...prompt, hook, catalog]`. The shared provider prefix would then
|
||||||
|
end at the leading reminder and the injected blocks would be re-prefilled at
|
||||||
|
full input price on every turn — the failure mode `REATTACH_BOUNDARY_METADATA`
|
||||||
|
exists to prevent (#11627). Applying the same rule to every user entry in the
|
||||||
|
request copy keeps each entry's wire shape position-independent, so request N+1
|
||||||
|
extends request N's prefix exactly. Stored history is still untouched.
|
||||||
|
|
||||||
**One pass at the send boundary, not per producer.** Reminders, hook context,
|
**One pass at the send boundary, not per producer.** Reminders, hook context,
|
||||||
IDE context and the catalog are injected by different modules on different
|
IDE context and the catalog are injected by different modules on different
|
||||||
frontends (TUI, headless, ACP, the shared agent loop). Fixing the order at the
|
frontends (TUI, headless, ACP, the shared agent loop). Fixing the order at the
|
||||||
@@ -109,7 +120,7 @@ token undercount in the always-on-context warning remain separate work
|
|||||||
durable-recovery check now asserts the request holds the same entries and
|
durable-recovery check now asserts the request holds the same entries and
|
||||||
parts as history with the catalog moved ahead of the recovery prompt.
|
parts as history with the catalog moved ahead of the recovery prompt.
|
||||||
|
|
||||||
`packages/core/src/core/llm-chat.test.ts`: 716 passed.
|
`packages/core/src/core/llm-chat.test.ts`: 720 passed.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ available skills ... no specific task."
|
|||||||
个分片重新盖章。重排已存条目会破坏展示投影,并让已投递的 hook 上下文每轮重复
|
个分片重新盖章。重排已存条目会破坏展示投影,并让已投递的 hook 上下文每轮重复
|
||||||
投递。因此只改请求副本,transcript、resume、去重与 rewind 的契约都不受影响。
|
投递。因此只改请求副本,transcript、resume、去重与 rewind 的契约都不受影响。
|
||||||
|
|
||||||
|
**重排作用于每条 user 条目,而不只是待发送的那条。** 下一轮请求会从已存历史里
|
||||||
|
重新发送上一轮的条目;只重排待发送条目会让一条条目的线上形状取决于它的位置:
|
||||||
|
第 N 轮发出 `[提醒, hook, 目录, ...正文]`,第 N+1 轮却把同一条目重建成
|
||||||
|
`[提醒, ...正文, hook, 目录]`。两次请求的公共前缀就只到开头的提醒为止,注入块
|
||||||
|
每轮都要按全价重新 prefill——这正是 `REATTACH_BOUNDARY_METADATA` 要防止的失效
|
||||||
|
模式(#11627)。对请求副本里每条 user 条目套用同一条规则,条目的线上形状就与
|
||||||
|
位置无关,第 N+1 轮请求严格扩展第 N 轮的前缀。已存历史仍然不动。
|
||||||
|
|
||||||
**在发送边界统一处理,而不是逐个生产者修改。** 提醒、hook 上下文、IDE 上下文和
|
**在发送边界统一处理,而不是逐个生产者修改。** 提醒、hook 上下文、IDE 上下文和
|
||||||
目录由不同模块在不同前端(TUI、headless、ACP、共享 agent loop)注入。在每轮主
|
目录由不同模块在不同前端(TUI、headless、ACP、共享 agent loop)注入。在每轮主
|
||||||
请求组装的唯一位置修正顺序,可以一次覆盖全部路径,避免四处并行修改后各自漂移。
|
请求组装的唯一位置修正顺序,可以一次覆盖全部路径,避免四处并行修改后各自漂移。
|
||||||
@@ -89,7 +97,7 @@ available skills ... no specific task."
|
|||||||
待发送轮次的检查改为断言目录存在且用户正文在最后;持久化恢复的检查改为断言
|
待发送轮次的检查改为断言目录存在且用户正文在最后;持久化恢复的检查改为断言
|
||||||
请求与历史持有相同的条目和分片,只是目录被移到了恢复提示词之前。
|
请求与历史持有相同的条目和分片,只是目录被移到了恢复提示词之前。
|
||||||
|
|
||||||
`packages/core/src/core/llm-chat.test.ts`:716 项通过。
|
`packages/core/src/core/llm-chat.test.ts`:720 项通过。
|
||||||
|
|
||||||
## 待定问题
|
## 待定问题
|
||||||
|
|
||||||
|
|||||||
@@ -3048,7 +3048,11 @@ describe('LlmChat', async () => {
|
|||||||
mockStream(textStream('done'));
|
mockStream(textStream('done'));
|
||||||
await sendDrain(`question ${index}`, `memory-next-${index}`);
|
await sendDrain(`question ${index}`, `memory-next-${index}`);
|
||||||
const request = requestAt(index + 1).contents as Content[];
|
const request = requestAt(index + 1).contents as Content[];
|
||||||
expect(request.slice(0, firstHistory.length)).toEqual(firstHistory);
|
// Each request extends the previous one: earlier entries are re-sent
|
||||||
|
// byte-identically, so the injected blocks stay inside the cached
|
||||||
|
// prefix instead of being re-prefilled every turn (#16259).
|
||||||
|
const previous = requestAt(index).contents as Content[];
|
||||||
|
expect(request.slice(0, previous.length)).toEqual(previous);
|
||||||
expect(snapshots(request)).toHaveLength(index + 1);
|
expect(snapshots(request)).toHaveLength(index + 1);
|
||||||
expect(snapshots(request).at(-1)?.text).toContain(next);
|
expect(snapshots(request).at(-1)?.text).toContain(next);
|
||||||
expect(snapshots(request).at(-1)?.text).toContain(
|
expect(snapshots(request).at(-1)?.text).toContain(
|
||||||
@@ -3272,6 +3276,30 @@ describe('LlmChat', async () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-sends a stored entry byte-identically on the next turn (#16259)', async () => {
|
||||||
|
mockConfig.getAutoMemoryContext = () => 'memory catalog';
|
||||||
|
const reminder = {
|
||||||
|
text: '<system-reminder>\nPlan mode is active.\n</system-reminder>',
|
||||||
|
};
|
||||||
|
const hookContext = createUserPromptSubmitContextPart('hook context');
|
||||||
|
mockStream(textStream('first answer'));
|
||||||
|
await sendDrain(
|
||||||
|
[reminder, { text: 'step 1' }, hookContext],
|
||||||
|
'prefix-turn-1',
|
||||||
|
);
|
||||||
|
const firstSent = (requestAt(0).contents as Content[]).at(-1)!;
|
||||||
|
|
||||||
|
mockStream(textStream('second answer'));
|
||||||
|
await sendDrain('follow up', 'prefix-turn-2');
|
||||||
|
|
||||||
|
const replayed = (requestAt(1).contents as Content[]).find((entry) =>
|
||||||
|
entry.parts?.some((part) => part.text === 'step 1'),
|
||||||
|
)!;
|
||||||
|
// Turn 1's entry is re-sent unchanged, so the injected blocks stay inside
|
||||||
|
// the shared provider prefix instead of being re-prefilled every turn.
|
||||||
|
expect(replayed).toEqual(firstSent);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps tool results first and the volatile request tail last', async () => {
|
it('keeps tool results first and the volatile request tail last', async () => {
|
||||||
mockConfig.getAutoMemoryContext = () => '';
|
mockConfig.getAutoMemoryContext = () => '';
|
||||||
const toolResult = fnResponse('read_file', { output: 'contents' }, 'c1');
|
const toolResult = fnResponse('read_file', { output: 'contents' }, 'c1');
|
||||||
|
|||||||
@@ -1907,9 +1907,15 @@ function isInjectedPromptContext(part: Part): boolean {
|
|||||||
* before it (#11627).
|
* before it (#11627).
|
||||||
*/
|
*/
|
||||||
function movePromptContextAheadOfUserText(contents: Content[]): Content[] {
|
function movePromptContextAheadOfUserText(contents: Content[]): Content[] {
|
||||||
const last = contents.at(-1);
|
// Every user entry takes the same shape, not only the pending one. The next
|
||||||
const parts = last?.parts;
|
// request re-sends earlier entries from history, so a position-dependent
|
||||||
if (last?.role !== 'user' || !parts || parts.length < 2) return contents;
|
// shape would cut the shared prefix and re-bill the injected blocks uncached.
|
||||||
|
return contents.map(movePromptContextAheadInEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePromptContextAheadInEntry(content: Content): Content {
|
||||||
|
const parts = content.parts;
|
||||||
|
if (content.role !== 'user' || !parts || parts.length < 2) return content;
|
||||||
const volatileStart = parts.findIndex(
|
const volatileStart = parts.findIndex(
|
||||||
(part) => part.partMetadata?.[REATTACH_BOUNDARY_METADATA] === true,
|
(part) => part.partMetadata?.[REATTACH_BOUNDARY_METADATA] === true,
|
||||||
);
|
);
|
||||||
@@ -1924,30 +1930,27 @@ function movePromptContextAheadOfUserText(contents: Content[]): Content[] {
|
|||||||
leadingEnd !== -1 &&
|
leadingEnd !== -1 &&
|
||||||
parts.slice(leadingEnd, movableEnd).some((part) => part.functionResponse)
|
parts.slice(leadingEnd, movableEnd).some((part) => part.functionResponse)
|
||||||
) {
|
) {
|
||||||
return contents;
|
return content;
|
||||||
}
|
}
|
||||||
const lastOwnIndex = parts.findLastIndex(
|
const lastOwnIndex = parts.findLastIndex(
|
||||||
(part, index) => index < movableEnd && isOwnPart(part),
|
(part, index) => index < movableEnd && isOwnPart(part),
|
||||||
);
|
);
|
||||||
if (lastOwnIndex === -1 || lastOwnIndex === movableEnd - 1) return contents;
|
if (lastOwnIndex === -1 || lastOwnIndex === movableEnd - 1) return content;
|
||||||
// Walk back over the contiguous own run only. A reminder the caller placed
|
// Walk back over the contiguous own run only. A reminder the caller placed
|
||||||
// between two of its own parts bounds the run, so it keeps its position.
|
// between two of its own parts bounds the run, so it keeps its position.
|
||||||
let firstOwnIndex = lastOwnIndex;
|
let firstOwnIndex = lastOwnIndex;
|
||||||
while (firstOwnIndex > 0 && isOwnPart(parts[firstOwnIndex - 1]!)) {
|
while (firstOwnIndex > 0 && isOwnPart(parts[firstOwnIndex - 1]!)) {
|
||||||
firstOwnIndex--;
|
firstOwnIndex--;
|
||||||
}
|
}
|
||||||
return [
|
return {
|
||||||
...contents.slice(0, -1),
|
...content,
|
||||||
{
|
parts: [
|
||||||
...last,
|
...parts.slice(0, firstOwnIndex),
|
||||||
parts: [
|
...parts.slice(lastOwnIndex + 1, movableEnd),
|
||||||
...parts.slice(0, firstOwnIndex),
|
...parts.slice(firstOwnIndex, lastOwnIndex + 1),
|
||||||
...parts.slice(lastOwnIndex + 1, movableEnd),
|
...parts.slice(movableEnd),
|
||||||
...parts.slice(firstOwnIndex, lastOwnIndex + 1),
|
],
|
||||||
...parts.slice(movableEnd),
|
};
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripThoughtPartsFromContent(content: Content): Content | null {
|
function stripThoughtPartsFromContent(content: Content): Content | null {
|
||||||
|
|||||||
Reference in new issue
Block a user