diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 2abc160..b3c24ed 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 36e4ba8..d43621c 100644 --- a/src/context-engine.ts +++ b/src/context-engine.ts @@ -1848,6 +1848,63 @@ 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(); + // 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; + } + 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(key); + if (agentId && excludedAgents.has(agentId)) return true; + } + if (excludeSubagents && excludedSubagentKeys.has(subagentKey(key))) { + return true; + } + return false; + } + function activateCompactedProjection(sessionId: string, source: "compact" | "assemble"): void { const wasActive = compactedProjectionSessions.has(sessionId); compactedProjectionSessions.add(sessionId); @@ -1869,6 +1926,7 @@ export function buildContextEngineFactory( return compactionProjectionActive ? enforceCompactedProjectionBudgetInvariant(result, tokenBudget) : enforceTokenBudgetInvariant(result, tokenBudget); + } function resolveUserId(args?: { @@ -2511,6 +2569,13 @@ export function buildContextEngineFactory( ownsCompaction: true, async bootstrap(args: { sessionId: string; sessionKey?: string; userId?: string }) { const sessionId = requireSessionId(args.sessionId, "bootstrap"); + if (isExcludedSession(args.sessionKey, sessionId)) { + markExcludedSession(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); turnCache.invalidateSession(sessionId); @@ -2532,6 +2597,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)) { + markExcludedSession(sessionId); + return { ok: true }; + } const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -2570,7 +2639,23 @@ 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, sessionId)) { + markExcludedSession(sessionId); + const passthrough = Array.isArray(args.messages) ? args.messages : []; + return { + messages: passthrough, + estimatedTokens: approximateMessagesTokens(passthrough), + systemPromptAddition: "", + promptAuthority: PROMPT_AUTHORITY_PREASSEMBLY_MAY_OVERFLOW, + }; + } + let compactionProjectionActive = compactedProjectionSessions.has(sessionId); + const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -2950,6 +3035,7 @@ export function buildContextEngineFactory( }, async compact(args: { sessionId: string; + sessionKey?: string; force?: boolean; targetSize?: number; tokenBudget?: number; @@ -2958,6 +3044,14 @@ export function buildContextEngineFactory( runtimeContext?: Record; abortSignal?: AbortSignal; }) { + // 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 = normalizeTokenBudget(args.tokenBudget) ?? normalizeTokenBudget(readRuntimeNumber(args.runtimeContext, "tokenBudget")); @@ -3007,6 +3101,10 @@ export function buildContextEngineFactory( runtimeContext?: Record; }) { const sessionId = requireSessionId(args.sessionId, "afterTurn"); + if (isExcludedSession(args.sessionKey, sessionId)) { + markExcludedSession(sessionId); + return { ok: true, skipped: true, reason: "agent excluded" }; + } const userId = resolveUserId({ userIdOverride: args.userId, sessionKey: args.sessionKey, @@ -3202,6 +3300,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. @@ -3227,6 +3341,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?.( @@ -3263,6 +3378,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 bebfeed..a1dd47f 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 bac0e1a..90afd8a 100644 --- a/test/unit/context-engine.test.ts +++ b/test/unit/context-engine.test.ts @@ -2750,3 +2750,218 @@ 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"); +}); + +// 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), { + 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", + ); +});