Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 3194754025 fix(ai): never empty Catty messages after tool step budget prune
applyStepBudgetGuard could prune a 3-message post-tool turn to []
under reserve pressure, causing Invalid prompt: messages must not be empty
and aborting the follow-up model call (fixes #2792, #2796).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 08:43:42 +00:00
2 changed files with 61 additions and 3 deletions

No files matched your search

@@ -251,6 +251,53 @@ test('prepareStepContext retains handle notice after step budget guard', async (
assert.match(String(notices[0]?.content), /\[step 2\]/);
});
test('prepareStepContext keeps post-tool turn messages when step budget prune would empty them', async () => {
const messages: ModelMessage[] = [
{ role: 'user', content: 'Check disk usage on prod-web-01.' },
{
role: 'assistant',
content: [{
type: 'tool-call',
toolCallId: 'call-disk',
toolName: 'terminal_execute',
input: { sessionId: 'sess-1', command: 'df -h' },
}],
},
{
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'call-disk',
toolName: 'terminal_execute',
output: { type: 'text', value: 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 100G 90G 10G 90% /' },
}],
},
];
const prepared = await prepareStepContext({
messages,
stepNumber: 1,
sessionId: 'chat-empty-guard',
chatSessionId: 'chat-empty-guard',
// Tiny window + huge reserve forces applyStepBudgetGuard into prune loop.
contextWindow: 2_000,
reservedTokens: 50_000,
maxOutputTokens: 256,
providerId: 'anthropic',
runtimeContext: createInitialCattyRuntimeContext({
chatSessionId: 'chat-empty-guard',
turnId: 'turn-empty-guard',
permissionMode: 'confirm',
scopeType: 'terminal',
}),
});
assert.ok(prepared.messages.length > 0, 'messages must not be empty after step budget prune');
const serialized = JSON.stringify(prepared.messages);
assert.match(serialized, /call-disk/);
assert.match(serialized, /tool-result|df -h|Filesystem/);
});
test('prepareStepContext never leaves orphan results from a parallel tool batch', async () => {
const toolCallIds = ['a', 'b', 'c', 'd', 'e', 'f'];
const messages: ModelMessage[] = [
+14 -3
View File
@@ -372,7 +372,8 @@ function applyStepBudgetGuard(
}
while (afterTotal >= threshold && next.length > 2) {
const pruned = pruneFirstModelMessage(next);
if (pruned.length === next.length) break;
// Never accept a prune that empties the conversation — streamText rejects [].
if (pruned.length === 0 || pruned.length === next.length) break;
next = pruned;
didAdjust = true;
afterTotal = computeTotalInputTokens({
@@ -430,6 +431,7 @@ export async function prepareStepContext(
didHandleNotice = true;
}
const preBudgetMessages = working;
const budgetGuard = applyStepBudgetGuard(working, {
contextWindow,
reservedTokens: input.reservedTokens ?? 0,
@@ -437,7 +439,9 @@ export async function prepareStepContext(
providerId: input.providerId,
protectRecentMessages: protectRecent,
});
working = budgetGuard.messages;
working = budgetGuard.messages.length > 0 || preBudgetMessages.length === 0
? budgetGuard.messages
: preBudgetMessages;
if (didHandleNotice && pendingHandles.length > 0) {
const hasHandleNotice = working.some(
(message) => message.role === 'user' && isStepHandleNoticeMessage(message.content),
@@ -467,9 +471,16 @@ export async function prepareStepContext(
providerId: input.providerId,
protectRecentMessages: protectRecent,
});
working = [...notices, ...finalGuard.messages];
const guardedBody = finalGuard.messages.length > 0 || body.length === 0
? finalGuard.messages
: body;
working = [...notices, ...guardedBody];
budgetGuard.didAdjust = budgetGuard.didAdjust || finalGuard.didAdjust;
}
// Last-resort: never hand streamText an empty messages array when the input had content.
if (working.length === 0 && input.messages.length > 0) {
working = preBudgetMessages.length > 0 ? preBudgetMessages : input.messages;
}
const didBudgetAdjust = integrity.didAdjust || stale.didAdjust || typed.didAdjust || budgetGuard.didAdjust;
const didAdjust = didBudgetAdjust || didHandleNotice;