From dc475b37ce410b58184defad9382b216f2a5f42d Mon Sep 17 00:00:00 2001 From: Username Date: Sun, 28 Jun 2026 20:03:46 +0800 Subject: [PATCH 1/3] feat: add excludeAgents and excludeSubagents for per-agent memory opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent gateways run a mix of agents with very different memory needs: a primary assistant that wants full recall, a latency-critical voice agent where every injected token and embed round-trip is pure overhead, and ephemeral subagents that should run lean. The context engine currently treats every agent identically. This adds two opt-in config keys: - excludeAgents: string[] — sessions whose agent id (parsed from the agent::... session key) is listed skip ALL memory/context work: no injection, ingestion, compaction, or daemon RPCs. - excludeSubagents: boolean — when true, every subagent session (tracked via the prepareSubagentSpawn lifecycle) skips all memory/context work. For an excluded session, assemble() is a true no-op: it returns the host's messages byte-identical with an empty systemPromptAddition (no budget-fitting, which can drop messages mid-tool-protocol and trip strict providers). bootstrap/ingest/afterTurn early-return; compact() short-circuits via session ids recorded at bootstrap (compact only receives sessionId, not sessionKey). Adds JSON schema entries and unit tests covering exclusion, the daemon-untouched guarantee, the non-excluded control paths, and subagent lifecycle teardown. Co-Authored-By: Claude Opus 4.8 --- openclaw.plugin.json | 9 +++ src/context-engine.ts | 78 +++++++++++++++++++ src/types.ts | 11 +++ test/unit/context-engine.test.ts | 129 +++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 0fabd561..5d8dabcd 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -207,6 +207,15 @@ "subagentTokenBudget": { "type": "number" }, + "excludeAgents": { + "type": "array", + "items": { "type": "string" }, + "description": "Agent ids whose sessions skip ALL LibraVDB memory/context work (no injection, ingestion, compaction, or daemon RPCs). The agent id is parsed from the session key (agent::...). Opt-in; empty by default." + }, + "excludeSubagents": { + "type": "boolean", + "description": "When true, every subagent session skips ALL LibraVDB memory/context work. Subagents are identified via the prepareSubagentSpawn lifecycle. Opt-in; defaults to false." + }, "beforeTurnEnabled": { "type": "boolean" }, diff --git a/src/context-engine.ts b/src/context-engine.ts index c216fd77..740ba0e4 100644 --- a/src/context-engine.ts +++ b/src/context-engine.ts @@ -1680,6 +1680,37 @@ export function buildContextEngineFactory( let cachedIdentity: ResolvedIdentity | null = null; let cachedSessionKey: string | undefined; + // --- Per-agent / per-subagent exclusion --- + // Sessions belonging to an excluded agent (or, when excludeSubagents is set, + // any subagent) skip ALL memory work: no injection, ingestion, compaction, or + // daemon RPCs. Subagents are tracked via the prepareSubagentSpawn lifecycle. + const excludedAgents = new Set( + (Array.isArray(cfg?.excludeAgents) ? cfg.excludeAgents : []) + .map((a) => String(a).trim()) + .filter(Boolean), + ); + const excludeSubagents = cfg?.excludeSubagents === true; + const excludedSubagentKeys = new Set(); + // compact() only receives sessionId (no sessionKey), so excluded sessions are + // recorded here at bootstrap time to let compact() short-circuit too. + const excludedSessionIds = new Set(); + const EXCLUDED_SESSION_IDS_MAX = 1000; + + function agentIdFromSessionKey(sessionKey: string | undefined): string | undefined { + const m = /^agent:([^:]+):/.exec(sessionKey ?? ""); + return m ? m[1] : undefined; + } + function isExcludedSession(sessionKey: string | undefined): boolean { + if (excludedAgents.size) { + const agentId = agentIdFromSessionKey(sessionKey); + if (agentId && excludedAgents.has(agentId)) return true; + } + if (excludeSubagents && sessionKey && excludedSubagentKeys.has(subagentKey(sessionKey))) { + return true; + } + return false; + } + function resolveUserId(args?: { userIdOverride?: string; sessionKey?: string; @@ -2316,6 +2347,14 @@ export function buildContextEngineFactory( ownsCompaction: true, async bootstrap(args: { sessionId: string; sessionKey?: string; userId?: string }) { const sessionId = requireSessionId(args.sessionId, "bootstrap"); + if (isExcludedSession(args.sessionKey)) { + if (excludedSessionIds.size >= EXCLUDED_SESSION_IDS_MAX) { + const oldest = excludedSessionIds.values().next().value; + if (oldest !== undefined) excludedSessionIds.delete(oldest); + } + excludedSessionIds.add(sessionId); + return { ok: true }; + } predictiveContextCache.delete(sessionId); postToolRecallCache.delete(sessionId); asyncIngestionQueues.delete(sessionId); @@ -2336,6 +2375,7 @@ export function buildContextEngineFactory( }, async ingest(args: { sessionId: string; sessionKey?: string; userId?: string; message: { role: string; content: unknown; id?: string }; isHeartbeat?: boolean }) { const sessionId = requireSessionId(args.sessionId, "ingest"); + if (isExcludedSession(args.sessionKey)) return { ok: true }; const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -2374,6 +2414,19 @@ export function buildContextEngineFactory( currentTokenCount?: number; }): Promise { const sessionId = requireSessionId(args.sessionId, "assemble"); + // Excluded agents/subagents: a TRUE no-op. Return the host's messages + // untouched (byte-identical, no budget-fitting — budget-fitting can drop + // messages mid-tool-protocol, which strict providers reject) with zero + // injection. Context budget stays the host's responsibility. + if (isExcludedSession(args.sessionKey)) { + const passthrough = Array.isArray(args.messages) ? args.messages : []; + return { + messages: passthrough, + estimatedTokens: approximateMessagesTokens(passthrough), + systemPromptAddition: "", + promptAuthority: PROMPT_AUTHORITY_PREASSEMBLY_MAY_OVERFLOW, + }; + } const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -2717,6 +2770,9 @@ export function buildContextEngineFactory( runtimeContext?: Record; abortSignal?: AbortSignal; }) { + if (args.sessionId && excludedSessionIds.has(args.sessionId)) { + return { ok: true, compacted: false, reason: "agent excluded" }; + } const tokenBudget = normalizeTokenBudget(args.tokenBudget) ?? normalizeTokenBudget(readRuntimeNumber(args.runtimeContext, "tokenBudget")); @@ -2766,6 +2822,9 @@ export function buildContextEngineFactory( runtimeContext?: Record; }) { const sessionId = requireSessionId(args.sessionId, "afterTurn"); + if (isExcludedSession(args.sessionKey)) { + return { ok: true, skipped: true, reason: "agent excluded" }; + } const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -2920,6 +2979,22 @@ export function buildContextEngineFactory( childSessionFile?: string; ttlMs?: number; }) { + // When subagents are excluded, mark this child session so all of its + // kernel calls (bootstrap/ingest/assemble/afterTurn/compact) no-op. No + // expansion budget is granted because the subagent has no memory to expand. + if (excludeSubagents) { + const ek = subagentKey(params.childSessionKey); + excludedSubagentKeys.add(ek); + logger.info?.( + `LibraVDB subagent memory disabled (excludeSubagents) ` + + `sessionKey=${params.childSessionKey}`, + ); + return { + rollback: () => { + excludedSubagentKeys.delete(ek); + }, + }; + } // Grant the subagent a token budget for memory expansion. // Default 8000 tokens — enough for a focused expansion, // small enough to prevent context window destruction. @@ -2945,6 +3020,7 @@ export function buildContextEngineFactory( }, async onSubagentEnded(params: { childSessionKey: string; reason: string }) { const key = subagentKey(params.childSessionKey); + excludedSubagentKeys.delete(key); const budget = subagentBudgets.get(key); if (budget) { logger.info?.( @@ -2980,6 +3056,8 @@ export function buildContextEngineFactory( postToolRecallCache.clear(); asyncIngestionQueues.clear(); triggerCache.clear(); + excludedSubagentKeys.clear(); + excludedSessionIds.clear(); }, }; } diff --git a/src/types.ts b/src/types.ts index bebfeed9..a1dd47ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,6 +98,17 @@ export interface PluginConfig { * Prevents a subagent from blowing its context window via repeated * expansions. Set to 0 to disable the cap entirely. */ subagentTokenBudget?: number; + /** Agent ids whose sessions skip ALL LibraVDB memory/context work — no + * injection, ingestion, compaction, or daemon RPCs. The agent id is parsed + * from the session key (`agent::...`). Useful for latency-critical + * agents (e.g. a voice agent) where injected tokens and embed round-trips + * are pure overhead. Opt-in; empty by default. */ + excludeAgents?: string[]; + /** When true, every subagent session skips ALL LibraVDB memory/context work. + * Subagents are identified via the prepareSubagentSpawn lifecycle. Useful + * when ephemeral subagent tasks should run lean without inheriting the + * parent's memory injection. Opt-in; defaults to false. */ + excludeSubagents?: boolean; section7CoarseTopK?: number; section7SecondPassTopK?: number; section7Theta1?: number; diff --git a/test/unit/context-engine.test.ts b/test/unit/context-engine.test.ts index 84b86f22..ff8ba2ed 100644 --- a/test/unit/context-engine.test.ts +++ b/test/unit/context-engine.test.ts @@ -2514,3 +2514,132 @@ test("context engine assemble drain handles empty queue gracefully", async () => // consecutive cursor positions, exercising the hasAllToolIdsSeen / recordToolIds // path directly. // --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Per-agent / per-subagent exclusion (excludeAgents / excludeSubagents) +// --------------------------------------------------------------------------- + +// A runtime whose getClient throws — proves an excluded session never reaches +// the daemon. +function throwingRuntime(): PluginRuntime { + return { + getClient: async () => { + throw new Error("client must not be acquired for an excluded session"); + }, + emitLifecycleHint: async () => {}, + onShutdown: () => {}, + shutdown: async () => {}, + }; +} + +test("excludeAgents: excluded agent skips all kernel work without touching the daemon", async () => { + const engine = buildContextEngineFactory(throwingRuntime(), { + userId: "fixed-user", + excludeAgents: ["fastbot"], + }); + const sessionKey = "agent:fastbot:session:s1"; + + const boot = await engine.bootstrap({ sessionId: "s1", sessionKey }); + assert.deepEqual(boot, { ok: true }); + + const ingested = await engine.ingest({ + sessionId: "s1", + sessionKey, + message: { role: "user", content: "hello" }, + }); + assert.deepEqual(ingested, { ok: true }); + + const messages = [{ role: "user", content: "hello", id: "u1" }]; + const assembled = await engine.assemble({ + sessionId: "s1", + sessionKey, + messages, + tokenBudget: 10_000, + prompt: "hello", + }); + assert.equal(assembled.systemPromptAddition, "", "no injection for excluded agent"); + assert.deepEqual(assembled.messages, messages, "messages passed through byte-identical"); + + const after = await engine.afterTurn({ + sessionId: "s1", + sessionKey, + messages, + prePromptMessageCount: 0, + }); + assert.equal(after.ok, true); + assert.equal(after.skipped, true); + + // compact() only carries sessionId; bootstrap recorded s1 as excluded so it + // short-circuits without acquiring the (throwing) client. + const compacted = await engine.compact({ + sessionId: "s1", + tokenBudget: 200_000, + currentTokenCount: 199_000, + force: true, + }); + assert.equal(compacted.compacted, false); + assert.equal(compacted.reason, "agent excluded"); +}); + +test("excludeAgents: a non-excluded agent still reaches the daemon", async () => { + const client = new FakeClient(); + const engine = buildContextEngineFactory(fakeRuntime(client), { + userId: "fixed-user", + excludeAgents: ["fastbot"], + }); + + await engine.bootstrap({ sessionId: "s2", sessionKey: "agent:main:session:s2" }); + + assert.ok( + client.calls.find((c) => c.method === "bootstrapSessionKernel"), + "non-excluded agent bootstraps via the daemon", + ); +}); + +test("excludeSubagents: a spawned subagent skips all kernel work", async () => { + const engine = buildContextEngineFactory(throwingRuntime(), { + userId: "fixed-user", + excludeSubagents: true, + }); + const childSessionKey = "agent:main:subagent:child1"; + + const handle = await engine.prepareSubagentSpawn({ + parentSessionKey: "agent:main:session:s1", + childSessionKey, + }); + + const boot = await engine.bootstrap({ sessionId: "c1", sessionKey: childSessionKey }); + assert.deepEqual(boot, { ok: true }); + + const messages = [{ role: "user", content: "subagent task", id: "u1" }]; + const assembled = await engine.assemble({ + sessionId: "c1", + sessionKey: childSessionKey, + messages, + tokenBudget: 10_000, + }); + assert.equal(assembled.systemPromptAddition, ""); + assert.deepEqual(assembled.messages, messages); + + // Lifecycle teardown must clear the exclusion marker (idempotent with rollback). + handle.rollback?.(); + await engine.onSubagentEnded({ childSessionKey, reason: "completed" }); +}); + +test("excludeSubagents off by default: a subagent is granted a normal expansion budget", async () => { + const client = new FakeClient(); + const engine = buildContextEngineFactory(fakeRuntime(client), { userId: "fixed-user" }); + const childSessionKey = "agent:main:subagent:child2"; + + const handle = await engine.prepareSubagentSpawn({ + parentSessionKey: "agent:main:session:s1", + childSessionKey, + }); + assert.equal(typeof handle.rollback, "function"); + + await engine.bootstrap({ sessionId: "c2", sessionKey: childSessionKey }); + assert.ok( + client.calls.find((c) => c.method === "bootstrapSessionKernel"), + "a non-excluded subagent still bootstraps via the daemon", + ); +}); From c43b7d4109319d2df68af1c43c35ce184b2dca0c Mon Sep 17 00:00:00 2001 From: Username Date: Mon, 6 Jul 2026 15:48:16 +0800 Subject: [PATCH 2/3] fix: fall back to sessionId for exclusion checks + clear stale markers Addresses CodeRabbit review on the per-agent memory opt-out: - isExcludedSession now falls back to the sessionId recorded at bootstrap when sessionKey is absent. sessionKey is optional on these hooks, so an excluded session could otherwise reach daemon work when the host omits it. bootstrap/ingest/assemble/afterTurn pass sessionId through. - bootstrap clears any stale excludedSessionIds marker on the non-excluded path, so a reused sessionId can no longer make compact() keep returning "agent excluded". --- src/context-engine.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/context-engine.ts b/src/context-engine.ts index 740ba0e4..4d5d2c07 100644 --- a/src/context-engine.ts +++ b/src/context-engine.ts @@ -1700,12 +1700,19 @@ export function buildContextEngineFactory( const m = /^agent:([^:]+):/.exec(sessionKey ?? ""); return m ? m[1] : undefined; } - function isExcludedSession(sessionKey: string | undefined): boolean { + function isExcludedSession(sessionKey: string | undefined, sessionId?: string): boolean { + const key = sessionKey?.trim(); + if (!key) { + // sessionKey is optional on these hooks; fall back to the sessionId + // recorded at bootstrap so an excluded session is still detected when the + // host omits sessionKey on a later hook. + return sessionId !== undefined && excludedSessionIds.has(sessionId); + } if (excludedAgents.size) { - const agentId = agentIdFromSessionKey(sessionKey); + const agentId = agentIdFromSessionKey(key); if (agentId && excludedAgents.has(agentId)) return true; } - if (excludeSubagents && sessionKey && excludedSubagentKeys.has(subagentKey(sessionKey))) { + if (excludeSubagents && excludedSubagentKeys.has(subagentKey(key))) { return true; } return false; @@ -2347,7 +2354,7 @@ export function buildContextEngineFactory( ownsCompaction: true, async bootstrap(args: { sessionId: string; sessionKey?: string; userId?: string }) { const sessionId = requireSessionId(args.sessionId, "bootstrap"); - if (isExcludedSession(args.sessionKey)) { + if (isExcludedSession(args.sessionKey, sessionId)) { if (excludedSessionIds.size >= EXCLUDED_SESSION_IDS_MAX) { const oldest = excludedSessionIds.values().next().value; if (oldest !== undefined) excludedSessionIds.delete(oldest); @@ -2355,6 +2362,9 @@ export function buildContextEngineFactory( excludedSessionIds.add(sessionId); return { ok: true }; } + // Not excluded: clear any stale marker so a reused sessionId can't keep + // making compact() return "agent excluded". + excludedSessionIds.delete(sessionId); predictiveContextCache.delete(sessionId); postToolRecallCache.delete(sessionId); asyncIngestionQueues.delete(sessionId); @@ -2375,7 +2385,7 @@ export function buildContextEngineFactory( }, async ingest(args: { sessionId: string; sessionKey?: string; userId?: string; message: { role: string; content: unknown; id?: string }; isHeartbeat?: boolean }) { const sessionId = requireSessionId(args.sessionId, "ingest"); - if (isExcludedSession(args.sessionKey)) return { ok: true }; + if (isExcludedSession(args.sessionKey, sessionId)) return { ok: true }; const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -2418,7 +2428,7 @@ export function buildContextEngineFactory( // untouched (byte-identical, no budget-fitting — budget-fitting can drop // messages mid-tool-protocol, which strict providers reject) with zero // injection. Context budget stays the host's responsibility. - if (isExcludedSession(args.sessionKey)) { + if (isExcludedSession(args.sessionKey, sessionId)) { const passthrough = Array.isArray(args.messages) ? args.messages : []; return { messages: passthrough, @@ -2822,7 +2832,7 @@ export function buildContextEngineFactory( runtimeContext?: Record; }) { const sessionId = requireSessionId(args.sessionId, "afterTurn"); - if (isExcludedSession(args.sessionKey)) { + if (isExcludedSession(args.sessionKey, sessionId)) { return { ok: true, skipped: true, reason: "agent excluded" }; } const userId = resolveUserId({ From 18b5a14ff315b5cf05b6401f5b554251271905de Mon Sep 17 00:00:00 2001 From: Username Date: Sun, 12 Jul 2026 17:14:58 +0800 Subject: [PATCH 3/3] fix: resolve compact() exclusion from sessionKey so excluded sessions survive side-table overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compact() previously trusted only the bounded excludedSessionIds set. Past EXCLUDED_SESSION_IDS_MAX cumulative excluded bootstraps, an older but still-active excluded session was evicted and its next compact() fell through to real compaction and daemon RPCs — including on the on-demand /compact path, which is not preceded by an assemble() that could refresh the marker. The host already threads sessionKey through every compaction path (timeout and overflow recovery in run.ts, and the manual /compact lane in compact.queued.ts), so compact() can resolve exclusion authoritatively from the agent id instead of the evictable side table. Declare sessionKey?: string on the compact args and route the check through isExcludedSession(); this is eviction-proof and also covers excludeSubagents (the child sessionKey recorded at prepareSubagentSpawn). The sessionId side table is retained only as a best-effort fallback for the rare case where the host cannot backfill a sessionKey, and every per-turn hook that carries the authoritative sessionKey (assemble/ingest/afterTurn) now refreshes the marker (MRU) so an id evicted while idle is re-established on the next turn. Adds two regression tests: a direct sessionKey-carrying compact() after 1001 excluded bootstraps, and the sessionKey-less fallback after eviction + refresh. --- src/context-engine.ts | 44 ++++++++++++---- test/unit/context-engine.test.ts | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/context-engine.ts b/src/context-engine.ts index 4d5d2c07..4e3b94dd 100644 --- a/src/context-engine.ts +++ b/src/context-engine.ts @@ -1691,11 +1691,30 @@ export function buildContextEngineFactory( ); const excludeSubagents = cfg?.excludeSubagents === true; const excludedSubagentKeys = new Set(); - // compact() only receives sessionId (no sessionKey), so excluded sessions are - // recorded here at bootstrap time to let compact() short-circuit too. + // Fallback exclusion marker for compact(). The host threads sessionKey through + // on every compaction path (timeout/overflow recovery and the manual /compact + // lane), so compact() resolves exclusion authoritatively from the agent id — but + // sessionKey is backfilled best-effort and can, rarely, be absent. This set lets + // compact() still short-circuit by sessionId in that case. const excludedSessionIds = new Set(); const EXCLUDED_SESSION_IDS_MAX = 1000; + // Record (or refresh) an excluded session's id so compact() can short-circuit by + // sessionId when the host omits sessionKey. Re-adding moves the id to the + // most-recently-used end (Set preserves insertion order), so the capped eviction + // below only ever discards the *least recently active* excluded session, never + // one that is still taking turns. Every per-turn hook that carries the + // authoritative sessionKey (assemble/ingest/afterTurn) refreshes the marker, so + // an id evicted while idle is re-established on the session's next turn. + function markExcludedSession(sessionId: string): void { + excludedSessionIds.delete(sessionId); + if (excludedSessionIds.size >= EXCLUDED_SESSION_IDS_MAX) { + const oldest = excludedSessionIds.values().next().value; + if (oldest !== undefined) excludedSessionIds.delete(oldest); + } + excludedSessionIds.add(sessionId); + } + function agentIdFromSessionKey(sessionKey: string | undefined): string | undefined { const m = /^agent:([^:]+):/.exec(sessionKey ?? ""); return m ? m[1] : undefined; @@ -2355,11 +2374,7 @@ export function buildContextEngineFactory( async bootstrap(args: { sessionId: string; sessionKey?: string; userId?: string }) { const sessionId = requireSessionId(args.sessionId, "bootstrap"); if (isExcludedSession(args.sessionKey, sessionId)) { - if (excludedSessionIds.size >= EXCLUDED_SESSION_IDS_MAX) { - const oldest = excludedSessionIds.values().next().value; - if (oldest !== undefined) excludedSessionIds.delete(oldest); - } - excludedSessionIds.add(sessionId); + markExcludedSession(sessionId); return { ok: true }; } // Not excluded: clear any stale marker so a reused sessionId can't keep @@ -2385,7 +2400,10 @@ export function buildContextEngineFactory( }, async ingest(args: { sessionId: string; sessionKey?: string; userId?: string; message: { role: string; content: unknown; id?: string }; isHeartbeat?: boolean }) { const sessionId = requireSessionId(args.sessionId, "ingest"); - if (isExcludedSession(args.sessionKey, sessionId)) return { ok: true }; + if (isExcludedSession(args.sessionKey, sessionId)) { + markExcludedSession(sessionId); + return { ok: true }; + } const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -2429,6 +2447,7 @@ export function buildContextEngineFactory( // messages mid-tool-protocol, which strict providers reject) with zero // injection. Context budget stays the host's responsibility. if (isExcludedSession(args.sessionKey, sessionId)) { + markExcludedSession(sessionId); const passthrough = Array.isArray(args.messages) ? args.messages : []; return { messages: passthrough, @@ -2772,6 +2791,7 @@ export function buildContextEngineFactory( }, async compact(args: { sessionId: string; + sessionKey?: string; force?: boolean; targetSize?: number; tokenBudget?: number; @@ -2780,7 +2800,12 @@ export function buildContextEngineFactory( runtimeContext?: Record; abortSignal?: AbortSignal; }) { - if (args.sessionId && excludedSessionIds.has(args.sessionId)) { + // Resolve exclusion authoritatively from sessionKey (the host threads it + // through on every compaction path, including the manual /compact lane), so + // an excluded session stays inert even if its id was evicted from the bounded + // side table. The sessionId set is only the fallback for the rare case where + // the host could not backfill a sessionKey. + if (isExcludedSession(args.sessionKey, args.sessionId)) { return { ok: true, compacted: false, reason: "agent excluded" }; } const tokenBudget = @@ -2833,6 +2858,7 @@ export function buildContextEngineFactory( }) { const sessionId = requireSessionId(args.sessionId, "afterTurn"); if (isExcludedSession(args.sessionKey, sessionId)) { + markExcludedSession(sessionId); return { ok: true, skipped: true, reason: "agent excluded" }; } const userId = resolveUserId({ diff --git a/test/unit/context-engine.test.ts b/test/unit/context-engine.test.ts index ff8ba2ed..df08d462 100644 --- a/test/unit/context-engine.test.ts +++ b/test/unit/context-engine.test.ts @@ -2581,6 +2581,92 @@ test("excludeAgents: excluded agent skips all kernel work without touching the d assert.equal(compacted.reason, "agent excluded"); }); +// Overflow the bounded excludedSessionIds side table so a target session's id is +// evicted, exercising the path where compact() can no longer rely on that set. +async function overflowExclusionSideTable( + engine: Awaited>, +): Promise { + for (let i = 0; i < 1001; i++) { + await engine.bootstrap({ + sessionId: `evictor-${i}`, + sessionKey: `agent:fastbot:session:evictor-${i}`, + }); + } +} + +test("excludeAgents: a direct compact() carrying sessionKey stays inert after the side table overflows", async () => { + // Regression for the reviewer finding: a manual/host-scheduled compact() is not + // preceded by an assemble() in the same turn, so it cannot depend on a per-turn + // refresh. It must resolve exclusion authoritatively from the sessionKey the host + // threads through — even after the target's id has been evicted from the capped + // side table — without ever acquiring the (throwing) client. + const engine = buildContextEngineFactory(throwingRuntime(), { + userId: "fixed-user", + excludeAgents: ["fastbot"], + }); + + const targetKey = "agent:fastbot:session:target"; + await engine.bootstrap({ sessionId: "target", sessionKey: targetKey }); + await overflowExclusionSideTable(engine); // evicts "target" from excludedSessionIds + + // No assemble()/ingest() first: a bare on-demand compact() that only carries the + // authoritative sessionKey must still short-circuit. + const compacted = await engine.compact({ + sessionId: "target", + sessionKey: targetKey, + tokenBudget: 200_000, + currentTokenCount: 199_000, + force: true, + }); + assert.equal(compacted.compacted, false); + assert.equal( + compacted.reason, + "agent excluded", + "an excluded agent must stay inert for a direct compact() even after the side table overflows", + ); +}); + +test("excludeAgents: an active excluded session stays inert for a sessionKey-less compact via the refreshed side table", async () => { + // Fallback path: when the host cannot backfill a sessionKey, compact() relies on + // the sessionId side table. A still-active session (one that assembles each turn) + // is re-marked by assemble(), so it survives eviction and short-circuits compact() + // even without a sessionKey. + const engine = buildContextEngineFactory(throwingRuntime(), { + userId: "fixed-user", + excludeAgents: ["fastbot"], + }); + + const targetKey = "agent:fastbot:session:target"; + await engine.bootstrap({ sessionId: "target", sessionKey: targetKey }); + await overflowExclusionSideTable(engine); // evicts "target" from excludedSessionIds + + // The active session takes a turn: assemble() re-establishes the marker from the + // authoritative sessionKey before any compaction runs. + const messages = [{ role: "user", content: "still here", id: "u1" }]; + const assembled = await engine.assemble({ + sessionId: "target", + sessionKey: targetKey, + messages, + tokenBudget: 10_000, + prompt: "still here", + }); + assert.equal(assembled.systemPromptAddition, "", "still no injection after overflow"); + + // compact() with NO sessionKey must still find the refreshed sessionId marker. + const compacted = await engine.compact({ + sessionId: "target", + tokenBudget: 200_000, + currentTokenCount: 199_000, + force: true, + }); + assert.equal(compacted.compacted, false); + assert.equal( + compacted.reason, + "agent excluded", + "a sessionKey-less compact must stay inert for an active excluded session after overflow", + ); +}); + test("excludeAgents: a non-excluded agent still reaches the daemon", async () => { const client = new FakeClient(); const engine = buildContextEngineFactory(fakeRuntime(client), {