diff --git a/packages/agent-modules/system-reminder/src/blocks.ts b/packages/agent-modules/system-reminder/src/blocks.ts
index 11b88d7df..05ad1746c 100644
--- a/packages/agent-modules/system-reminder/src/blocks.ts
+++ b/packages/agent-modules/system-reminder/src/blocks.ts
@@ -217,7 +217,9 @@ export function buildSlimAgentContextBlock(env: AgentEnv): string {
lines.push(` agentName: ${env.agentName}`);
}
lines.push(` SESSION ROLE: ${sessionRole}`);
- lines.push(` YOUR SESSION ID: ${env.sessionId}`);
+ if (!env.sessionIdInContext) {
+ lines.push(` YOUR SESSION ID: ${env.sessionId}`);
+ }
if (env.parentSessionId) {
lines.push(` PARENT SESSION: ${env.parentSessionId}`);
lines.push(buildParentResultDeliveryLine(env, isCloud));
diff --git a/packages/agent-modules/system-reminder/src/types.ts b/packages/agent-modules/system-reminder/src/types.ts
index 9c4a8c479..3eee5a40f 100644
--- a/packages/agent-modules/system-reminder/src/types.ts
+++ b/packages/agent-modules/system-reminder/src/types.ts
@@ -134,6 +134,13 @@ export interface AgentEnv {
userConfiguredName?: string | undefined;
agentRole: string;
sessionId: string;
+ /**
+ * Host-verified fact that the model-visible context already carries
+ * `YOUR SESSION ID` (e.g. an earlier user message since the latest
+ * compaction). When true, the slim per-turn block omits the line to save
+ * tokens. Absent/false keeps the line (cloud and legacy callers).
+ */
+ sessionIdInContext?: boolean | undefined;
/** Session type — 0=Branch, 1=Root (formerly Main). */
sessionType?: number | undefined;
/** Parent session ID — set when this is a child session. */
diff --git a/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts b/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts
index d2189a91c..bc6a4e8ba 100644
--- a/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts
+++ b/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts
@@ -132,6 +132,22 @@ describe("createV1AgentHostProductCapabilities", () => {
turnId: "turn-1",
deferTelemetry: true,
});
+ await product.inputPreparation.reminders.buildSystem({
+ session: { sessionId: "session-1" },
+ agent: { agentName: "mavis", resourceAgentName: "main" },
+ agentConfig: {},
+ promptText: "again",
+ turnId: "turn-2",
+ sessionIdInContext: true,
+ } as never);
+ expect(hosted.reminders.buildSystem).toHaveBeenLastCalledWith({
+ session: { sessionId: "session-1" },
+ resourceAgentName: "main",
+ promptText: "again",
+ turnId: "turn-2",
+ deferTelemetry: true,
+ sessionIdInContext: true,
+ });
expect(product.executor.fileChanges.begin).toBe(hosted.fileChanges.begin);
expect(product.runner.contextUsage).toBe(hosted.contextUsage);
expect(product.runner.logger).toBe(hosted.runnerLogger);
diff --git a/packages/local-runtime-v2/src/compat/v1/agent-host.ts b/packages/local-runtime-v2/src/compat/v1/agent-host.ts
index f7e638d2b..c793567f0 100644
--- a/packages/local-runtime-v2/src/compat/v1/agent-host.ts
+++ b/packages/local-runtime-v2/src/compat/v1/agent-host.ts
@@ -195,6 +195,7 @@ export function createV1AgentHostProductCapabilities(
promptText: input.promptText,
turnId: input.turnId,
deferTelemetry: true,
+ ...(input.sessionIdInContext ? { sessionIdInContext: true } : {}),
...(model ? { model } : {}),
});
},
diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts
index 0df3a6188..f07718332 100644
--- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts
+++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts
@@ -45,6 +45,7 @@ export class NativeLocalTurnExecutionPreparationSource<
...(input.execution.desktopCapabilities
? { desktopCapabilities: input.execution.desktopCapabilities }
: {}),
+ history: (input.execution.runnerHistory ?? input.execution.history)?.messages ?? [],
});
const browserAssets = [...(prepared.browserAssets ?? [])];
const context = createToolContext(
diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts
index ba864a217..cac924e71 100644
--- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts
+++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts
@@ -86,6 +86,12 @@ export interface LocalTurnReminderFacts {
readonly promptText: string;
readonly turnId: string;
readonly desktopCapabilities?: AgentHostTurnCapabilityView;
+ /**
+ * True when a user message in the model-visible history since the latest
+ * compaction already contains this session's ID, so per-turn reminders may
+ * omit it. False on the first turn and after a compaction dropped it.
+ */
+ readonly sessionIdInContext?: boolean;
}) => Promise<{
readonly content: string;
readonly diagnostic?: unknown;
@@ -135,6 +141,8 @@ export interface LocalTurnInputPreparationRequest {
readonly immediateSendBatch?: AgentHostExecutionRequest['immediateSendBatch'];
readonly provenance: AgentHostTurnProvenance;
readonly desktopCapabilities?: AgentHostTurnCapabilityView;
+ /** Provider-facing history before this turn's user message. */
+ readonly history?: readonly unknown[];
}
export interface PreparedLocalTurnInput {
@@ -203,6 +211,9 @@ export class LocalTurnInputPreparer {
promptText,
turnId: input.lease.turnId,
...(input.desktopCapabilities ? { desktopCapabilities: input.desktopCapabilities } : {}),
+ ...(input.history && historyCarriesSessionId(input.history, input.session.sessionId)
+ ? { sessionIdInContext: true }
+ : {}),
});
const system = validateSystemReminder(systemValue);
const systemBlock = optionalReminderBlock('system', system.content);
@@ -544,6 +555,42 @@ function isImageAttachment(attachment: AgentHostInputAttachment): boolean {
);
}
+/**
+ * Whether a user message after the latest compaction summary (or anywhere when
+ * the session was never compacted) already shows the model this session's ID.
+ * Durable user messages carry the turn's ``, so the first
+ * turn's full agent-context keeps the ID visible until compaction drops it.
+ */
+export function historyCarriesSessionId(messages: readonly unknown[], sessionId: string): boolean {
+ if (!sessionId) return false;
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ if (!isRecord(message)) continue;
+ // Native summaries and legacy (archon) compaction markers both start a
+ // fresh model-visible context; anything before them was summarized away.
+ if (message.role === 'compactionSummary' || Object.hasOwn(message, 'archonCompaction')) {
+ return false;
+ }
+ if (message.role !== 'user') continue;
+ if (userMessageText(message.content).includes(sessionId)) return true;
+ }
+ return false;
+}
+
+function userMessageText(content: unknown): string {
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) return '';
+ return content
+ .map((part: unknown) =>
+ isRecord(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : '',
+ )
+ .join('\n');
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
function validateBackgroundReminder(
value: Awaited>,
): Awaited> {
diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts
index 57bd119de..ec3a89c4c 100644
--- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts
+++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts
@@ -64,6 +64,7 @@ import type {
AgentHostFileChangeObservation,
LocalTurnExecutionInput,
} from "../runner/contracts.js";
+import { createExecutionBudgetReminder } from "../runner/execution-budget-reminder.js";
import type { AgentEventDelivery } from "../events/contracts.js";
import type { CanonicalHistoryStore } from "../history/contracts.js";
import { copyCanonicalHistoryForPiCompatibility } from "../history/canonical-history-validation.js";
@@ -4281,13 +4282,130 @@ describe("LocalRuntimeTurnExecutor input and tool safety", () => {
});
});
+describe("LocalRuntimeTurnExecutor session id reminder context", () => {
+ const withSessionId = (sessionId: string) =>
+ `\n\n YOUR SESSION ID: ${sessionId}\n\n\n\nhi`;
+ it.each([
+ ["first turn (empty history)", () => [], false],
+ [
+ "an earlier user message still shows the id",
+ (sessionId: string) => [
+ { role: "user", content: withSessionId(sessionId), timestamp: 1 },
+ { role: "user", content: "later turn", timestamp: 2 },
+ ],
+ true,
+ ],
+ [
+ "text-part user content shows the id",
+ (sessionId: string) => [
+ {
+ role: "user",
+ content: [{ type: "text", text: withSessionId(sessionId) }],
+ timestamp: 1,
+ },
+ ],
+ true,
+ ],
+ [
+ "compaction summarized the id away",
+ (sessionId: string) => [
+ { role: "user", content: withSessionId(sessionId), timestamp: 1 },
+ { role: "compactionSummary", summary: "earlier work" },
+ { role: "user", content: "kept turn", timestamp: 2 },
+ ],
+ false,
+ ],
+ [
+ "a legacy compaction marker hides earlier ids",
+ (sessionId: string) => [
+ { role: "user", content: withSessionId(sessionId), timestamp: 1 },
+ {
+ role: "user",
+ content: "summary",
+ archonCompaction: { schemaVersion: 1, summary: "s" },
+ timestamp: 2,
+ },
+ ],
+ false,
+ ],
+ [
+ "a kept message after compaction shows the id",
+ (sessionId: string) => [
+ { role: "compactionSummary", summary: "earlier work" },
+ { role: "user", content: withSessionId(sessionId), timestamp: 2 },
+ ],
+ true,
+ ],
+ ] as const)(
+ "tells the system reminder whether the session id is in context: %s",
+ async (_label, history, expected) => {
+ const buildSystem = vi.fn(async () => ({ content: "" }));
+ const base = executionInput();
+ const sessionId = base.session.sessionId;
+ await new LocalRuntimeTurnExecutor({
+ ...options(async (runInput) => {
+ await runInput.eventWriter.pushRuntime(
+ terminalEvent(RuntimeEventStatus.COMPLETED),
+ );
+ }),
+ executionPreparation: new NativeLocalTurnExecutionPreparationSource(
+ new LocalTurnInputPreparer({
+ reminders: {
+ buildBackground: vi.fn(async () => ({
+ tasks: [],
+ undeliveredTotal: 0,
+ terminalTotal: 0,
+ })),
+ buildSystem,
+ confirmBackgroundTaskReads: vi.fn(async () => []),
+ },
+ }),
+ { beforeToolCall: vi.fn(async () => undefined) } as never,
+ ),
+ }).execute(
+ executionInput({
+ history: { revision: "r1", messages: history(sessionId) as never },
+ }),
+ );
+ expect(buildSystem).toHaveBeenCalledOnce();
+ const call = (buildSystem.mock.calls[0] as unknown[])[0] as {
+ sessionIdInContext?: boolean;
+ };
+ if (expected) expect(call.sessionIdInContext).toBe(true);
+ else expect(call).not.toHaveProperty("sessionIdInContext");
+ },
+ );
+});
+
describe("LocalRuntimeTurnExecutor execution budget reminders", () => {
it.each([
- { durationMs: 600_000, nearDeadlineMs: 60_000 },
- { durationMs: 120_000, nearDeadlineMs: 30_000 },
- ])(
- "reminds a budgeted run ($durationMs ms) without changing history or the system prompt",
- async ({ durationMs, nearDeadlineMs }) => {
+ {
+ durationMs: 600_000,
+ // [elapsed ms since start, expected reminder text or undefined]
+ steps: [
+ [0, "about 10 minutes of 10 minutes total. Model generation"],
+ [1_000, undefined],
+ [299_999, undefined],
+ [300_000, "about 5 minutes of 10 minutes total."],
+ [400_000, undefined],
+ [450_000, "about 2 minutes of 10 minutes total."],
+ [479_999, undefined],
+ [480_000, "about 120 seconds of 10 minutes total."],
+ [485_000, "about 110 seconds of 10 minutes total."],
+ [595_000, "under 10 seconds of 10 minutes total."],
+ ],
+ },
+ {
+ durationMs: 120_000,
+ steps: [
+ [0, "about 120 seconds of 120 seconds total. Model generation"],
+ [1, "about 110 seconds of 120 seconds total."],
+ [90_000, "about 30 seconds of 120 seconds total."],
+ ],
+ },
+ ] as const)(
+ "reminds a budgeted run ($durationMs ms) only at thresholds without changing history or the system prompt",
+ async ({ durationMs, steps }) => {
let nowMs = 1_000;
const executorOptions = options(async (runInput) => {
const messages = [
@@ -4305,53 +4423,30 @@ describe("LocalRuntimeTurnExecutor execution budget reminders", () => {
model: runInput.llm.model,
thinkingLevel: "off" as const,
};
- const initial = await hook?.(hookInput);
- expect(initial).toMatchObject({
- type: "replaceRequestMessages",
- reason: "execution-budget-context",
- messages: [
- messages[0],
- {
- role: "user",
- content: expect.stringContaining(`${durationMs / 1_000} seconds`),
- },
- ],
- });
- nowMs = 1_000 + durationMs - nearDeadlineMs - 1;
- expect(
- await hook?.({ ...hookInput, phase: "iteration" }),
- ).toMatchObject({
- type: "replaceRequestMessages",
- messages: [
- messages[0],
- { content: expect.stringContaining("Execution time remaining") },
- ],
- });
- nowMs += 1;
- expect(
- await hook?.({ ...hookInput, phase: "iteration" }),
- ).toMatchObject({
- type: "replaceRequestMessages",
- messages: [
- messages[0],
- {
- role: "user",
- content: expect.stringContaining(
- `${nearDeadlineMs / 1_000} seconds`,
- ),
- },
- ],
- });
- nowMs += 1;
- expect(
- await hook?.({ ...hookInput, phase: "iteration" }),
- ).toMatchObject({
- type: "replaceRequestMessages",
- messages: [
- messages[0],
- { content: expect.stringContaining("Execution time remaining") },
- ],
- });
+ for (const [elapsedMs, expected] of steps) {
+ nowMs = 1_000 + elapsedMs;
+ const decision = await hook?.({
+ ...hookInput,
+ phase: elapsedMs === 0 ? "initial" : "iteration",
+ });
+ if (expected === undefined) {
+ expect(decision).toBeUndefined();
+ continue;
+ }
+ expect(decision).toMatchObject({
+ type: "replaceRequestMessages",
+ reason: "execution-budget-context",
+ messages: [
+ messages[0],
+ {
+ role: "user",
+ content: expect.stringContaining(
+ `Execution time remaining: ${expected}`,
+ ),
+ },
+ ],
+ });
+ }
nowMs = 1_000 + durationMs;
expect(
await hook?.({ ...hookInput, phase: "iteration" }),
@@ -4382,6 +4477,61 @@ describe("LocalRuntimeTurnExecutor execution budget reminders", () => {
},
);
+ it("retries a threshold reminder that admission rejected", async () => {
+ let nowMs = 1_000;
+ let admit = false;
+ const hook = createExecutionBudgetReminder(
+ 1_000 + 1_800_000,
+ () => nowMs,
+ () => admit,
+ )!;
+ const messages = [{ role: "user" as const, content: "hello", timestamp: 1 }];
+ const hookInput = {
+ sessionId: "session-1",
+ turnId: "turn-1",
+ phase: "initial" as const,
+ messages,
+ canonicalMessages: messages,
+ model: {} as never,
+ thinkingLevel: "off" as const,
+ };
+ expect(await hook(hookInput)).toBeUndefined();
+ admit = true;
+ nowMs = 2_000;
+ expect(await hook(hookInput)).toMatchObject({
+ messages: [
+ messages[0],
+ {
+ content:
+ "Execution time remaining: about 29 minutes of 30 minutes total. Model generation, tools, and waiting all count against it.",
+ },
+ ],
+ });
+ nowMs = 1_000 + 1_800_000 - 899_000;
+ expect(await hook(hookInput)).toMatchObject({
+ messages: [
+ messages[0],
+ {
+ content:
+ "Execution time remaining: about 14 minutes of 30 minutes total.",
+ },
+ ],
+ });
+ // Jumping straight past 25% and 10% emits once for the deepest threshold.
+ nowMs = 1_000 + 1_800_000 - 179_000;
+ expect(await hook(hookInput)).toMatchObject({
+ messages: [
+ messages[0],
+ {
+ content:
+ "Execution time remaining: about 2 minutes of 30 minutes total.",
+ },
+ ],
+ });
+ nowMs += 1_000;
+ expect(await hook(hookInput)).toBeUndefined();
+ });
+
it("samples budget context while preserving existing request observers", async () => {
let nowMs = 1_000;
const existingObserver = vi.fn();
@@ -4416,14 +4566,18 @@ describe("LocalRuntimeTurnExecutor execution budget reminders", () => {
expect.objectContaining({ remaining_ms: 600_000 }),
"execution budget context sampled",
);
+ // 99 seconds later no threshold is crossed, so no tokens are spent.
nowMs = 100_000;
+ expect(
+ await runInput.hooks!.beforeLlmCallHook!.at(-2)!(input),
+ ).toBeUndefined();
+ expect(logger.info).toHaveBeenCalledOnce();
+ nowMs = 301_000;
const next = await runInput.hooks!.beforeLlmCallHook!.at(-2)!(input);
if (next?.type !== "replaceRequestMessages")
throw new Error("Expected time context");
expect(next.messages.at(-1)).toMatchObject({
- content: expect.stringContaining(
- "previous model request was prepared: 99 seconds",
- ),
+ content: expect.stringContaining("about 5 minutes of 10 minutes total."),
});
await runInput.eventWriter.pushRuntime(
terminalEvent(RuntimeEventStatus.COMPLETED),
@@ -4676,7 +4830,7 @@ describe("LocalRuntimeTurnExecutor budget with durable reminders", () => {
).toHaveLength(keepsRequestContext ? 1 : 0);
if (keepsRequestContext) {
expect(requestContext).toContain(
- "Execution time remaining at request preparation: 120 seconds.",
+ "Execution time remaining: about 120 seconds of 120 seconds total.",
);
}
expect(canAppend).toHaveBeenCalledOnce();
diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts
index c327cd28f..f6e25a3a9 100644
--- a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts
+++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts
@@ -1,11 +1,25 @@
import type { PiBeforeLlmCallHook, PiTurnRunnerLogger } from '@mavis/agent-core/pi-turn-runner';
import type { ExecutionBudgetReminderAdmission } from '../execution/contracts.js';
+/** Remaining-budget fractions that each emit one reminder when first crossed. */
+const BUDGET_FRACTION_THRESHOLDS = [0.5, 0.25, 0.1] as const;
+/** Inside this window every request carries a (coarse) reminder. */
+const FINAL_WINDOW_MS = 120_000;
+const FINAL_WINDOW_LEVEL = BUDGET_FRACTION_THRESHOLDS.length + 1;
+
/**
* Request-only context; the caller's existing cancellation timer remains
* authoritative. `replaceRequestMessages` keeps the marker out of durable
* history, so it must be registered before any hook that returns
* `appendMessage` — `runBeforeLLM` ends the pipeline on a successful append.
+ *
+ * To avoid spending tokens on every model request, the reminder is emitted
+ * only on the first request (announces the budget), when the remaining budget
+ * first drops to ≤50%, ≤25% and ≤10% of the total, and on every request inside
+ * the final two minutes. The total budget is measured from hook creation
+ * (turn preparation), since requests carry no start time. Precision is whole
+ * minutes outside the final window and 10-second steps inside it, so the text
+ * does not churn between otherwise identical requests.
*/
export function createExecutionBudgetReminder(
executionDeadlineAtMs: number | undefined,
@@ -14,28 +28,24 @@ export function createExecutionBudgetReminder(
logger?: Pick,
): PiBeforeLlmCallHook | undefined {
if (executionDeadlineAtMs === undefined || !canAppend) return undefined;
- let previousSampleAtMs: number | undefined;
+ const totalMs = Math.max(0, executionDeadlineAtMs - nowMs());
+ let emittedLevel: number | undefined;
return (input) => {
try {
if (input.signal?.aborted) return undefined;
const now = nowMs();
const remainingMs = executionDeadlineAtMs - now;
if (remainingMs <= 0) return undefined;
- const elapsedMs =
- previousSampleAtMs !== undefined && now >= previousSampleAtMs
- ? now - previousSampleAtMs
- : undefined;
- previousSampleAtMs = now;
- const elapsed =
- elapsedMs !== undefined
- ? ` Since the previous model request was prepared: ${Math.floor(elapsedMs / 1_000)} seconds (model generation, tools, and waiting).`
- : ' This budget includes model generation, tools, and waiting.';
+ const level = budgetLevel(remainingMs, totalMs);
+ const first = emittedLevel === undefined;
+ if (!first && level !== FINAL_WINDOW_LEVEL && level <= emittedLevel!) return undefined;
const marker = {
role: 'user' as const,
- content: `Execution time remaining at request preparation: ${Math.ceil(remainingMs / 1_000)} seconds.${elapsed}`,
+ content: formatBudgetReminder(remainingMs, totalMs, first),
timestamp: now,
};
if (!canAppend(input, marker)) return undefined;
+ emittedLevel = level;
try {
logger?.info?.(
{
@@ -43,7 +53,8 @@ export function createExecutionBudgetReminder(
turn_id: input.turnId,
sampled_at_ms: now,
remaining_ms: remainingMs,
- elapsed_since_previous_request_ms: elapsedMs ?? null,
+ total_ms: totalMs,
+ budget_level: level,
},
'execution budget context sampled',
);
@@ -60,3 +71,28 @@ export function createExecutionBudgetReminder(
}
};
}
+
+/** 0 = above 50%; 1..3 = crossed 50/25/10%; FINAL_WINDOW_LEVEL = final two minutes. */
+function budgetLevel(remainingMs: number, totalMs: number): number {
+ if (remainingMs <= FINAL_WINDOW_MS) return FINAL_WINDOW_LEVEL;
+ let level = 0;
+ BUDGET_FRACTION_THRESHOLDS.forEach((fraction, index) => {
+ if (remainingMs <= totalMs * fraction) level = index + 1;
+ });
+ return level;
+}
+
+function formatBudgetReminder(remainingMs: number, totalMs: number, first: boolean): string {
+ const remaining =
+ remainingMs > FINAL_WINDOW_MS
+ ? `about ${Math.floor(remainingMs / 60_000)} minutes`
+ : remainingMs >= 10_000
+ ? `about ${Math.floor(remainingMs / 10_000) * 10} seconds`
+ : 'under 10 seconds';
+ const total =
+ totalMs > FINAL_WINDOW_MS
+ ? `${Math.round(totalMs / 60_000)} minutes`
+ : `${Math.max(10, Math.round(totalMs / 10_000) * 10)} seconds`;
+ const scope = first ? ' Model generation, tools, and waiting all count against it.' : '';
+ return `Execution time remaining: ${remaining} of ${total} total.${scope}`;
+}
diff --git a/packages/local-runtime/src/api/host-memory.ts b/packages/local-runtime/src/api/host-memory.ts
index bdc68f625..3f7f8dda3 100644
--- a/packages/local-runtime/src/api/host-memory.ts
+++ b/packages/local-runtime/src/api/host-memory.ts
@@ -18,6 +18,16 @@ import { LocalMemoryFacade } from '../memory/local-memory-facade.js';
import type { LocalMemoryBusEmitter } from '../memory/local-memory-orchestration.js';
import type { CliSunsetNoticeEvaluator } from '../memory/cli-sunset-notice.js';
+/**
+ * Per-turn reminder clock: `Date#toString()` without seconds (e.g.
+ * `Fri Oct 09 2026 13:20 GMT+0800 (China Standard Time)`). Minute precision
+ * keeps the zone/offset the model relies on while avoiding a value that is
+ * unique to every second.
+ */
+export function formatReminderDate(nowMs: number): string {
+ return new Date(nowMs).toString().replace(/(\d{2}:\d{2}):\d{2}/u, '$1');
+}
+
export function createLocalMemorySubsystem(input: {
configGetter: () => LocalRuntimeConfig;
nowMs: () => number;
@@ -47,7 +57,7 @@ export function createLocalMemorySubsystem(input: {
const memory = input.configGetter().memory;
return memory?.enabled !== false && memory?.proactive === true;
},
- formatDate: () => new Date(input.nowMs()).toString(),
+ formatDate: () => formatReminderDate(input.nowMs()),
emitBusEvent: input.emitBusEvent,
nowMs: input.nowMs,
...(input.cliSunsetNotice ? { cliSunsetNotice: input.cliSunsetNotice } : {}),
diff --git a/packages/local-runtime/src/api/hosted-agent-capabilities.ts b/packages/local-runtime/src/api/hosted-agent-capabilities.ts
index 5ebf53826..9cdc3cd4e 100644
--- a/packages/local-runtime/src/api/hosted-agent-capabilities.ts
+++ b/packages/local-runtime/src/api/hosted-agent-capabilities.ts
@@ -289,6 +289,8 @@ export function createHostedAgentCapabilities(
readonly promptText: string;
readonly turnId: string;
readonly deferTelemetry?: boolean;
+ /** Model-visible context already carries this session's ID. */
+ readonly sessionIdInContext?: boolean;
readonly model?: {
readonly providerID: string;
readonly modelID: string;
@@ -305,6 +307,7 @@ export function createHostedAgentCapabilities(
writeEnabled: input.session.memoryPolicy?.writeEnabled !== false,
}),
environmentInSystemPrompt: true,
+ ...(input.sessionIdInContext ? { sessionIdInContext: true } : {}),
};
const reminderMessage = host.localDataCollector.toMessage(
input.promptText,
diff --git a/packages/local-runtime/src/memory/local-data-collector.ts b/packages/local-runtime/src/memory/local-data-collector.ts
index d6113eb77..6d9fa0ab4 100644
--- a/packages/local-runtime/src/memory/local-data-collector.ts
+++ b/packages/local-runtime/src/memory/local-data-collector.ts
@@ -52,6 +52,8 @@ export type LocalAgentFactsReader = {
export type LocalReminderSessionInfo = SessionInfo & {
environmentInSystemPrompt?: boolean;
+ /** See `AgentEnv.sessionIdInContext`. */
+ sessionIdInContext?: boolean;
parentSessionId?: string | null;
rootSessionId?: string;
isDefaultWorkspace?: boolean;
@@ -221,6 +223,7 @@ export class LocalDataCollector {
agentName,
agentRole,
sessionId: session.sessionId,
+ ...(session.sessionIdInContext ? { sessionIdInContext: true } : {}),
sessionType,
parentSessionId: session.parentSessionId ?? undefined,
taskResultDelivery: session.taskResultDelivery,
diff --git a/packages/local-runtime/test/unit/turn-reminder-context.test.ts b/packages/local-runtime/test/unit/turn-reminder-context.test.ts
new file mode 100644
index 000000000..a83964070
--- /dev/null
+++ b/packages/local-runtime/test/unit/turn-reminder-context.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import {
+ agentContextProvider,
+ buildAgentContextBlock,
+ buildSlimAgentContextBlock,
+ type AgentEnv,
+} from '@mavis/system-reminder';
+
+import { formatReminderDate } from '../../src/api/host-memory.js';
+
+const env: AgentEnv = {
+ environmentInSystemPrompt: true,
+ workspaceDir: '/w',
+ agentConfigDir: '/d/agents/mavis',
+ agentName: 'mavis',
+ agentRole: 'orchestrator',
+ sessionId: 'sess-123',
+ platform: 'linux',
+ scene: 'local',
+ date: 'Fri Oct 09 2026 13:20 GMT+0800 (China Standard Time)',
+};
+
+describe('per-turn agent-context trimming', () => {
+ it('formats the reminder clock to minute precision', () => {
+ const ms = Date.UTC(2026, 9, 9, 5, 20, 45, 123);
+ const formatted = formatReminderDate(ms);
+ expect(formatted).toBe(new Date(ms).toString().replace(':20:45', ':20'));
+ expect(formatted).not.toMatch(/\d{2}:\d{2}:\d{2}/u);
+ expect(formatted).toMatch(/2026 \d{2}:\d{2} GMT[+-]\d{4}/u);
+ });
+
+ it('keeps the session id in the slim block unless the host says it is in context', () => {
+ expect(buildSlimAgentContextBlock(env)).toContain('YOUR SESSION ID: sess-123');
+ expect(buildSlimAgentContextBlock({ ...env, sessionIdInContext: false })).toContain(
+ 'YOUR SESSION ID: sess-123',
+ );
+ const trimmed = buildSlimAgentContextBlock({ ...env, sessionIdInContext: true });
+ expect(trimmed).not.toContain('sess-123');
+ expect(trimmed).toContain('SESSION ROLE: root');
+ expect(trimmed).toContain(`date: ${env.date}`);
+ });
+
+ it('always keeps the session id in the first-turn full block', () => {
+ expect(buildAgentContextBlock({ ...env, sessionIdInContext: true })).toContain(
+ 'YOUR SESSION ID: sess-123',
+ );
+ expect(
+ agentContextProvider({ env: { ...env, sessionIdInContext: true }, turnCount: 1 } as never),
+ ).toContain('YOUR SESSION ID: sess-123');
+ expect(
+ agentContextProvider({ env: { ...env, sessionIdInContext: true }, turnCount: 2 } as never),
+ ).not.toContain('sess-123');
+ });
+
+ it('leaves cloud scene blocks unchanged when the flag is absent', () => {
+ const cloud = { ...env, scene: 'cloud' as const };
+ expect(buildSlimAgentContextBlock(cloud)).toContain('YOUR SESSION ID: sess-123');
+ });
+});
diff --git a/release/public-source.json b/release/public-source.json
index 7539f8cd2..a24bd22ea 100644
--- a/release/public-source.json
+++ b/release/public-source.json
@@ -2734,6 +2734,7 @@
"packages/local-runtime/test/unit/thread-goal/host-integration-testkit.ts",
"packages/local-runtime/test/unit/thread-goal/host-integration-verifier-charge-rearm.test.ts",
"packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts",
+ "packages/local-runtime/test/unit/turn-reminder-context.test.ts",
"packages/mcode-tools-host/README.md",
"packages/mcode-tools-host/package.json",
"packages/mcode-tools-host/src/contracts.ts",
diff --git a/test/vitest-suites.json b/test/vitest-suites.json
index a30ece859..bfb7be47c 100644
--- a/test/vitest-suites.json
+++ b/test/vitest-suites.json
@@ -73,6 +73,7 @@
"packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts",
"packages/local-runtime/test/unit/child-bash-lifecycle.test.ts",
"packages/local-runtime/test/unit/local-background-task-task-output-polling-reminder.test.ts",
+ "packages/local-runtime/test/unit/turn-reminder-context.test.ts",
"packages/mcode-tools-host/test/unit/resource-manifest.test.ts",
"packages/tui/test/unit/headless-config.test.ts",
"packages/tui/test/unit/runtime-data-dir.test.ts",