~/blogsearch-budgets-and-session-identity.md
cchu@nycu:~/blog$ cat search-budgets-and-session-identity.md
2026.10.136 min[ai-agents][mcp][context-engineering][testing]

Ten Agents, One Search Budget

A keyed search throttle passes its tests, while a last-event identity fallback can still merge callers into one budget.

I replayed twenty search calls against context-mode's flood guard: ten actors, two calls each. With ten distinct keys, the guard blocked zero calls. With one shared key, it blocked twelve. The same policy produced both results. The caller identity decided who could keep searching.

That distinction matters when several coding agents share an MCP server. A search budget belongs to the conversation receiving the results. Counting requests in one server process gives you a different boundary. Even after you add a map keyed by agent, you still need to carry the right key from the caller to that map.

I checked context-mode 1.0.169 at revision 98cd9ab0ed417b769dab873f427c5684f6d46d35, on Windows with Node 24.18.1. The project has executable TypeScript adapters, an FTS5 content store, session persistence, and tests. My subject here is its search guard and the server's identity lookup. I did not measure model accuracy or its advertised context savings.

Ten callers either retain ten independent budgets or collapse into one counter when the identity path returns the same key.

The Guard Has a Small, Testable Contract

The maintainers use SQLite FTS5 and BM25 to retrieve chunks from indexed tool output. Around ctx_search, they enforce a progressive throttle. At the server defaults, one key gets a 60,000 ms window:

Call number within the windowHandler behavior
1–3At most two results per query, even though the requested limit defaults to three
4–8One result per query
9 onwardError response asking the caller to batch its work

The guard counts calls. One call can contain several queries, so this policy does not allocate tokens or count each query. The handler has a separate output-size check. You cannot turn these thresholds into a token-cost estimate.

The class exposes record(key, now). Each map entry stores a count and a window start. It increments the count and returns softCapped and blocked; the server applies those decisions to its results. This separation made the policy easy to exercise without starting a database-backed MCP server.

From the pinned checkout I ran:

npm install --ignore-scripts --no-audit --no-fund
./node_modules/.bin/vitest.cmd run tests/core/search-flood-guard.test.ts
Test Files  1 passed (1)
     Tests  5 passed (5)
  Duration  1.84s

The install added 192 packages. I skipped lifecycle scripts, including native-addon setup, because this test file imports the pure guard. That installation is sufficient for these tests; it does not establish that the complete server starts on this machine. The dependency ranges resolved Vitest 4.1.11 in this run.

The five upstream tests cover a single actor's hard block, distinct-key fan-out, an unaffected second actor, expiry, and the soft cap. They supply actor names themselves. They do not verify how a transport identifies those actors.

The Identity Path Can Merge Callers

The server calls currentAttribution() to choose the guard key. That function first reads an AsyncLocalStorage override. If the override carries a session ID, it returns that ID. Otherwise it checks an environment variable, then calls resolveSessionIdFromSessionDB(). If no identity resolves, the guard uses __default__.

The database fallback executes:

SELECT session_id FROM session_events
ORDER BY created_at DESC LIMIT 1

It caches the returned ID for two seconds. The query identifies the latest event writer in the selected project database. It receives no request-specific caller ID. Two callers consulting that database can therefore get the same result, even when their own sessions differ.

I tested the two source functions in isolation. The downloadable probe extracts their unchanged bodies from the pinned server.ts, removes TypeScript syntax with Node's parser, and binds controlled dependencies. It uses a real in-memory SQLite table, a simulated clock, and Node's real AsyncLocalStorage. It replaces the database loader and path helpers; it does not import the server module with its startup side effects.

I inserted an agent-a event, resolved its identity, inserted a newer agent-b event, and checked both sides of the cache boundary:

{"initial":"agent-a","newerEventDuringCache":"agent-a","atCacheExpiry":"agent-b","databaseOpens":2}
{"concurrentOverrideIds":["agent-a","agent-b"]}

At simulated time 1,500 ms, the lookup retained the ID cached at 1,000 ms. At 3,000 ms, the cache expired and the query returned the newer event writer. Two concurrent asynchronous contexts with explicit overrides retained their separate IDs.

For a second fixture, I inserted events for ten actors with increasing timestamps. Ten identity lookups returned one distinct key, actor-9. Feeding that key into the actual guard reproduced the shared-budget result:

{"inferredDistinctKeys":1,"key":"actor-9","inferredFanout":{"blocked":12,"softCapped":5,"keys":1}}

This is a controlled source-function result. I did not reproduce a live Claude Code fan-out over stdio. The test establishes what the fallback does when callers share this database and supply no explicit override or environment identity. It does not establish that every client takes that path.

Explicit asynchronous context reaches a separate bucket; database fallback takes the latest writer through a two-second cache.

The OpenCode adapter passes toolCtx.sessionID through withProjectDirOverride. That path has the information the guard needs. A comment about per-agent accounting in the server cannot establish the same guarantee for a path that infers identity from recent events.

Two More Boundaries in the Counter

I added deterministic checks around expiry and capacity. The window resets when elapsed time is greater than 60,000 ms. At exactly 60,000 ms, an exhausted actor remains in its old window:

{"boundaryMs":60000,"count":9,"blocked":true,"afterMs":60001,"afterCount":1}

Despite the source's “rolling window” wording, this implementation counts from a bucket's start time. It does not retain individual request timestamps and expire them one by one. That choice gives a cheap counter, with reset behavior you can test at one boundary.

The class also bounds its map to 4,096 keys by default. When a new key exceeds capacity, it removes the entry with the oldest window start. I used the constructor's capacity parameter to run the same branch with two keys: exhaust a, add b and c, then re-enter as a.

{"evictionMaxKeys":2,"reentryCount":1,"blocked":false}

The source documents this as fail-open behavior. It bounds memory at the cost of forgetting an evicted actor's budget. This fixture confirms that tradeoff; it does not show a client can mint arbitrary identities or bypass the default capacity in a deployed system.

To replay all probe assertions with Node 24.18.1:

git clone https://github.com/mksglu/context-mode context-mode
git -C context-mode checkout 98cd9ab0ed417b769dab873f427c5684f6d46d35
node identity-probe.mjs ./context-mode

The probe needs no installed dependencies. Node emits an experimental-feature warning for stripTypeScriptTypes. Its final line is:

PASS: guard boundaries and isolated source-function identity checks

For this revision, I can verify the keyed counter and the explicit asynchronous identity path. I can also demonstrate that latest-event inference merges callers in a shared fixture. The missing integration evidence is whether a given host carries each caller's session identity through its real request path. A focused transport test must assert that identity before checking which searches get blocked.