Skip to content

Commit de02b4a

Browse files
fix(runtime): restore the session ID after compaction drops it
The turn reminder omits `YOUR SESSION ID` while earlier history already shows it. When automatic compaction summarizes that history away, the model would lose its own session ID for the rest of the turn. A stateless post-compaction hook now appends a hidden session identity reminder when no visible user message or identity reminder after the latest compaction carries the ID (before the current user message on initial compaction, at the tail mid-turn). The preparation check counts that reminder too, so the next turn does not repeat the ID. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d8e0411 commit de02b4a

7 files changed

Lines changed: 197 additions & 37 deletions

File tree

‎packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts‎

Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '../canonical-user-input.js';
2222
import { createBackgroundTaskHostMetadata } from '../history/background/host-metadata.js';
2323
import { assertAgentHostCapabilityAvailable } from '../empty-dependencies.js';
24+
import { messagesCarrySessionId } from '../compaction/session-identity.js';
2425
import {
2526
prepareLocalInlineMedia,
2627
type LocalInlineMediaCandidate,
@@ -211,7 +212,7 @@ export class LocalTurnInputPreparer {
211212
promptText,
212213
turnId: input.lease.turnId,
213214
...(input.desktopCapabilities ? { desktopCapabilities: input.desktopCapabilities } : {}),
214-
...(input.history && historyCarriesSessionId(input.history, input.session.sessionId)
215+
...(input.history && messagesCarrySessionId(input.history, input.session.sessionId)
215216
? { sessionIdInContext: true }
216217
: {}),
217218
});
@@ -555,42 +556,6 @@ function isImageAttachment(attachment: AgentHostInputAttachment): boolean {
555556
);
556557
}
557558

558-
/**
559-
* Whether a user message after the latest compaction summary (or anywhere when
560-
* the session was never compacted) already shows the model this session's ID.
561-
* Durable user messages carry the turn's `<system-reminder>`, so the first
562-
* turn's full agent-context keeps the ID visible until compaction drops it.
563-
*/
564-
export function historyCarriesSessionId(messages: readonly unknown[], sessionId: string): boolean {
565-
if (!sessionId) return false;
566-
for (let index = messages.length - 1; index >= 0; index -= 1) {
567-
const message = messages[index];
568-
if (!isRecord(message)) continue;
569-
// Native summaries and legacy (archon) compaction markers both start a
570-
// fresh model-visible context; anything before them was summarized away.
571-
if (message.role === 'compactionSummary' || Object.hasOwn(message, 'archonCompaction')) {
572-
return false;
573-
}
574-
if (message.role !== 'user') continue;
575-
if (userMessageText(message.content).includes(sessionId)) return true;
576-
}
577-
return false;
578-
}
579-
580-
function userMessageText(content: unknown): string {
581-
if (typeof content === 'string') return content;
582-
if (!Array.isArray(content)) return '';
583-
return content
584-
.map((part: unknown) =>
585-
isRecord(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : '',
586-
)
587-
.join('\n');
588-
}
589-
590-
function isRecord(value: unknown): value is Record<string, unknown> {
591-
return typeof value === 'object' && value !== null;
592-
}
593-
594559
function validateBackgroundReminder(
595560
value: Awaited<ReturnType<LocalTurnReminderFacts['buildBackground']>>,
596561
): Awaited<ReturnType<LocalTurnReminderFacts['buildBackground']>> {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const SESSION_IDENTITY_REMINDER_CUSTOM_TYPE = 'session_identity_reminder';
2+
3+
/**
4+
* Whether a user message or session identity reminder after the latest
5+
* compaction summary (or anywhere when never compacted) shows `sessionId`.
6+
*/
7+
export function messagesCarrySessionId(messages: readonly unknown[], sessionId: string): boolean {
8+
if (!sessionId) return false;
9+
for (let index = messages.length - 1; index >= 0; index -= 1) {
10+
const message = messages[index];
11+
if (!isRecord(message)) continue;
12+
// Native summaries and legacy (archon) compaction markers both start a
13+
// fresh model-visible context; anything before them was summarized away.
14+
if (message.role === 'compactionSummary' || Object.hasOwn(message, 'archonCompaction')) {
15+
return false;
16+
}
17+
const carriesId =
18+
message.role === 'user' ||
19+
(message.role === 'custom' && message.customType === SESSION_IDENTITY_REMINDER_CUSTOM_TYPE);
20+
if (carriesId && messageText(message.content).includes(sessionId)) return true;
21+
}
22+
return false;
23+
}
24+
25+
function messageText(content: unknown): string {
26+
if (typeof content === 'string') return content;
27+
if (!Array.isArray(content)) return '';
28+
return content
29+
.map((part: unknown) =>
30+
isRecord(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : '',
31+
)
32+
.join('\n');
33+
}
34+
35+
function isRecord(value: unknown): value is Record<string, unknown> {
36+
return typeof value === 'object' && value !== null;
37+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
2+
import type { Api, Model } from '@earendil-works/pi-ai';
3+
import type { PiBeforeLlmCallHookInput } from '@mavis/agent-core/pi-turn-runner';
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { validateCanonicalHistoryMessages } from '../../agent-host/history/canonical-history-validation.js';
7+
import { ContextUsageAnchorState } from '../../compaction/execution/usage-anchor.js';
8+
import { messagesCarrySessionId } from '../../agent-host/compaction/session-identity.js';
9+
import { createSessionIdentityReminderHook } from './session-identity-reminder.js';
10+
11+
const SESSION_ID = 'ses_identity_1';
12+
const MODEL = {
13+
id: 'test-model',
14+
name: 'test-model',
15+
api: 'anthropic-messages',
16+
provider: 'test-provider',
17+
baseUrl: 'https://example.invalid',
18+
reasoning: false,
19+
input: ['text'],
20+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
21+
contextWindow: 200_000,
22+
maxTokens: 8_192,
23+
} as Model<Api>;
24+
25+
const hook = createSessionIdentityReminderHook(new ContextUsageAnchorState());
26+
27+
function message(value: Record<string, unknown>): AgentMessage {
28+
return value as unknown as AgentMessage;
29+
}
30+
31+
function user(text: string, timestamp: number): AgentMessage {
32+
return message({ role: 'user', content: [{ type: 'text', text }], timestamp });
33+
}
34+
35+
const idBearingUser = user(
36+
`<system-reminder>\n YOUR SESSION ID: ${SESSION_ID}\n</system-reminder>\nfirst ask`,
37+
1,
38+
);
39+
const checkpoint = message({
40+
role: 'compactionSummary',
41+
summary: '## Goal\nKeep going',
42+
tokensBefore: 100,
43+
timestamp: 2,
44+
});
45+
const currentUserWithoutId = user('<system-reminder>\n date: today\n</system-reminder>\nnext', 3);
46+
47+
function hookInput(
48+
messages: AgentMessage[],
49+
phase: 'initial' | 'iteration' = 'iteration',
50+
): PiBeforeLlmCallHookInput {
51+
return {
52+
sessionId: SESSION_ID,
53+
turnId: 'turn-1',
54+
phase,
55+
messages,
56+
canonicalMessages: messages,
57+
model: MODEL,
58+
thinkingLevel: 'off',
59+
} as PiBeforeLlmCallHookInput;
60+
}
61+
62+
describe('Session identity reminder hook', () => {
63+
it('stays silent while the model-visible context still shows the session ID', async () => {
64+
expect(await hook(hookInput([idBearingUser, currentUserWithoutId]))).toBeUndefined();
65+
});
66+
67+
it('restores the ID before the current user when initial compaction summarized it away', async () => {
68+
const decision = await hook(hookInput([checkpoint, currentUserWithoutId], 'initial'));
69+
70+
expect(decision).toMatchObject({
71+
type: 'appendMessage',
72+
reason: 'session_identity_reminder',
73+
placement: 'before-current-user',
74+
message: {
75+
role: 'custom',
76+
customType: 'session_identity_reminder',
77+
display: false,
78+
content: expect.stringContaining(`YOUR SESSION ID: ${SESSION_ID}`),
79+
},
80+
});
81+
if (decision?.type !== 'appendMessage') throw new Error('Expected an append decision');
82+
expect(() => validateCanonicalHistoryMessages([decision.message])).not.toThrow();
83+
});
84+
85+
it('appends at the tail mid-turn and does not repeat once the marker is in context', async () => {
86+
const decision = await hook(hookInput([checkpoint, currentUserWithoutId]));
87+
if (decision?.type !== 'appendMessage') throw new Error('Expected an append decision');
88+
expect(decision).not.toHaveProperty('placement');
89+
90+
expect(
91+
await hook(hookInput([checkpoint, currentUserWithoutId, decision.message])),
92+
).toBeUndefined();
93+
});
94+
});
95+
96+
describe('messagesCarrySessionId', () => {
97+
it('only counts user messages or identity markers after the latest compaction', () => {
98+
expect(messagesCarrySessionId([idBearingUser, currentUserWithoutId], SESSION_ID)).toBe(true);
99+
expect(
100+
messagesCarrySessionId([idBearingUser, checkpoint, currentUserWithoutId], SESSION_ID),
101+
).toBe(false);
102+
expect(
103+
messagesCarrySessionId(
104+
[
105+
message({
106+
role: 'assistant',
107+
content: [{ type: 'text', text: SESSION_ID }],
108+
timestamp: 4,
109+
}),
110+
],
111+
SESSION_ID,
112+
),
113+
).toBe(false);
114+
expect(messagesCarrySessionId([idBearingUser], '')).toBe(false);
115+
});
116+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { PiBeforeLlmCallHook } from '@mavis/agent-core/pi-turn-runner';
2+
3+
import {
4+
SESSION_IDENTITY_REMINDER_CUSTOM_TYPE,
5+
messagesCarrySessionId,
6+
} from '../../agent-host/compaction/session-identity.js';
7+
import type { ContextUsageAnchorState } from '../../compaction/execution/usage-anchor.js';
8+
import { fitsReminderInFinalRequest } from './reminder-admission.js';
9+
10+
/**
11+
* The turn reminder omits the session ID while earlier history still shows it,
12+
* so compaction that summarizes that history away must restore the ID.
13+
*/
14+
export function createSessionIdentityReminderHook(
15+
usageAnchor: ContextUsageAnchorState,
16+
): PiBeforeLlmCallHook {
17+
return (input) => {
18+
if (!input.sessionId || messagesCarrySessionId(input.messages, input.sessionId)) {
19+
return undefined;
20+
}
21+
const marker = {
22+
role: 'custom' as const,
23+
customType: SESSION_IDENTITY_REMINDER_CUSTOM_TYPE,
24+
content: `<system-reminder>\nYOUR SESSION ID: ${input.sessionId}\n</system-reminder>`,
25+
display: false as const,
26+
timestamp: Date.now(),
27+
};
28+
if (!fitsReminderInFinalRequest(input, marker, usageAnchor)) return undefined;
29+
return {
30+
type: 'appendMessage',
31+
reason: SESSION_IDENTITY_REMINDER_CUSTOM_TYPE,
32+
message: marker,
33+
...(input.phase === 'initial' ? { placement: 'before-current-user' as const } : {}),
34+
};
35+
};
36+
}

‎packages/local-runtime-v2/src/service/turn-system/production-composition.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import type { MessagesFileApiPatcherLogger } from './agent-host/assembly/message
8484
import { isProductionRuntimeErrorRetryable } from './runtime-error-retry-policy.js';
8585
import { createTodoCadenceReminderHook } from './execution/reminder/todo-cadence-reminder.js';
8686
import { createBackgroundCadenceReminder } from './execution/reminder/background-cadence-reminder.js';
87+
import { createSessionIdentityReminderHook } from './execution/reminder/session-identity-reminder.js';
8788
import { fitsReminderInFinalRequest } from './execution/reminder/reminder-admission.js';
8889
import { readSessionAgentExecutionSnapshot } from './agent-host/preparation/session-agent-execution-snapshot.js';
8990
import { McpToolResultHistoryExternalizer } from './agent-host/execution/mcp-tool-result-history-externalizer.js';
@@ -444,6 +445,7 @@ export async function createLocalAgentHost<
444445
activatePluginHookSessionOwnership: options.pluginHookSessionOwnership.activate,
445446
afterCompactionBeforeLlmCallHooks: [
446447
createAutomaticContextRequestFilterHook(),
448+
createSessionIdentityReminderHook(contextUsageAnchor),
447449
createTodoCadenceReminderHook(contextUsageAnchor),
448450
],
449451
onSteeringConsumed: options.onSteeringConsumed,

‎release/public-source.json‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/vitest-suites.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
"packages/tui/test/unit/runtime-error-presentation-rejection.test.ts",
118118
"packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts",
119119
"packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts",
120+
"packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.test.ts",
120121
"packages/local-runtime-v2/src/services.test.ts",
121122
"packages/local-runtime-v2/src/application/session/session-title-policy.test.ts",
122123
"packages/local-runtime-v2/test/unit/agent/builtin/catalog.test.ts",

0 commit comments

Comments
 (0)