Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@ function scriptedModel() {
);
}

// Same scripted tool-call model, but wraps doGenerate to record the raw params
// (including the message window) every call receives, so a test can prove which
// conversation window actually reached the model (#1808).
function recordedModel() {
const received: any[] = [];
const captured = scriptedModel();
const original = captured.doGenerate.bind(captured);
captured.doGenerate = (async (params: any) => {
received.push(params);
return original(params);
}) as any;
(captured as any).__received = received;
return captured;
}

// Return the full stringified conversational payload of each model call a
// recorded model received. Exact AI-SDK message part shapes vary by version,
// but the whole `prompt` is what reaches the model — stringifying it gives a
// discriminating, stable read for the note/compression assertions without
// coupling to a specific part structure.
function receivedMessageTexts(model: any): string[] {
const received: any[] = model?.__received ?? [];
const texts: string[] = [];
for (const params of received) {
const prompt =
params?.prompt ?? params?.input?.messages ?? params?.messages;
if (prompt !== undefined) {
texts.push(JSON.stringify(prompt));
}
}
return texts;
}

const mockResolve = resolveModelConfig as jest.Mock;
const wireVal = (model: any) => ({
model,
Expand Down Expand Up @@ -106,6 +139,24 @@ function noCriticalLedger(): ProgressLedger {
};
}

// A ledger that reports pending work (so CompletionGatePolicy.prepareStep()
// injects a steering note on every step) yet has NO critical pending once done
// is called — so the gate honors the finalize instead of vetoing the run
// forever. This lets the note-injection and compression coexist on one step,
// which is exactly the #1808 scenario.
function debtLedger(): ProgressLedger {
return {
markFromToolCall: () => undefined,
summary: () => ({
totalTargets: 2,
pendingTargets: 0,
criticalTotal: 0,
criticalPending: 0,
}),
debtNote: () => 'review the pending critical finding',
};
}

const ctx: ToolContext = { runId: 'compress-e2e-1' };

function specWithContextWindow(
Expand All @@ -131,6 +182,29 @@ function specWithContextWindow(
};
}

// Compression + a policy that injects a steering note in the SAME prepareStep
// (#1808): the regression that let an injectNote discard the compressed window
// and resend the uncompressed conversation to the model.
function specWithDebtNoteAndContextWindow(
contextWindowTokens: number,
): AgentSpec {
return {
id: 'generalist',
systemPrompt: 'review the diff',
tools: new InMemoryToolRegistry([readFileTool, doneTool]),
policies: [
new CompressionPolicy(
new ContextWindowCompressor(contextWindowTokens, {}),
),
new CompletionGatePolicy(debtLedger(), {
doneToolName: 'submitResult',
}),
],
maxSteps: 20,
resultToolName: 'submitResult',
};
}

describe('AiSdkAgentRunner + CompressionPolicy (context compression e2e)', () => {
// RED before the fix: a tiny context window forces compression on the
// second step; the compressed `tool` message reaches generateText as a
Expand Down Expand Up @@ -225,4 +299,63 @@ describe('AiSdkAgentRunner + CompressionPolicy (context compression e2e)', () =>
expect(state.artifacts).toHaveLength(1);
expect(state.artifacts[0]).toMatchObject({ type: 'submitResult' });
});

// REGRESSION (issue #1808): when compression and a steering-note
// injection fire in the SAME prepareStep, the note must be appended to the
// COMPRESSED window — never to the uncompressed conversation. The old code
// rebuilt the message list from `(msgs ?? messages)` in the injectNote
// branch, discarding `merged.messages`, so the model received the huge raw
// tool result and the context clamp was bypassed.
it('appends a steering note to the compressed window, not the raw conversation (#1808)', async () => {
let model: any;
mockResolve.mockImplementation(() => {
if (!model) {
model = recordedModel();
}
return wireVal(model);
});
const runner = new AiSdkAgentRunner(undefined);

// Tiny window → compression always fires; debt ledger → CompletionGate
// injects a steering note on every prepareStep.
const state = await runner.run(
specWithDebtNoteAndContextWindow(1),
{ prompt: 'go' },
ctx,
);

// Both directives actually fired (else the test wouldn't exercise the bug).
expect(
state.trace.some((e) => e.kind === 'context.compress'),
).toBe(true);
expect(
state.trace.some((e) => e.kind === 'progress.debt'),
).toBe(true);

// The windows the model actually received across its calls.
const received = receivedMessageTexts(model);

// The steering note reached the model...
expect(
received.some((text) =>
text.includes('review the pending critical finding'),
),
).toBe(true);

// ...but the model saw the COMPRESSED window: no full-size raw tool
// result baked into a later call. The bug resent the whole BIG_RESULT;
// the fix sends the truncated/compressed form, so the marker line of the
// raw result must not appear verbatim in a window that also carries the
// note.
for (const text of received) {
if (text.includes('review the pending critical finding')) {
expect(text).not.toContain('const value1599 =');
}
}

// And the run completed cleanly, producing a result artifact.
expect(state.status).not.toBe('error');
expect(state.artifacts).toHaveLength(1);
expect(state.artifacts[0]).toMatchObject({ type: 'submitResult' });
});
});
13 changes: 11 additions & 2 deletions libs/agent-harness/infrastructure/ai-sdk/ai-sdk-agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,25 @@ export class AiSdkAgentRunner implements AgentRunner {
out.activeTools = merged.activeTools;
}
// injectNote -> trailing message (cache-prefix friendly)
// The base conversation is the compressed window when a policy
// provided one (merged.messages); the steering note is APPENDED to
// it, never substituted for it. Previously the note branch rebuilt
// from the uncompressed original (msgs ?? messages), so a
// concurrent compression was discarded and the hard per-request
// context clamp was bypassed (#1808).
const baseMessages = merged.messages
? merged.messages.map(toModelMessage)
: ((msgs ?? messages) as ModelMessage[]);
if (merged.injectNote) {
out.messages = [
...(msgs ?? messages),
...baseMessages,
{
role: merged.injectNote.role,
content: merged.injectNote.content,
},
];
} else if (merged.messages) {
out.messages = merged.messages.map(toModelMessage);
out.messages = baseMessages;
}
// HARD invariant: the conversation array must NEVER contain a
// system-role message — Google Gemini rejects any system message that
Expand Down
Loading