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",