You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Agent tool pagination is implemented four different ways across the codebase, the shared primitive that should prevent that is duplicated in two private copies and exported to nobody, and the most common of the four conventions — offset/limit over a live collection — silently skips rows.
Proposal: settle on one pagination model (cursor/keyset), move the existing primitive into @rozenite/agent-shared, export it for plugin authors, and add the one adapter that makes the simple cases a one-liner.
diff shows the divergence is currently formatting only (different prettier settings), so consolidation is mechanical today. It will not stay that way.
Neither copy is exported from agent-shared, agent-sdk, or agent-bridge, so a plugin author has no way to reach it. That is why six plugins hand-rolled their own.
Motivation: offset pagination over a live app is incorrect
Same pattern in useStorageAgentTools.ts:108-110 and useFileSystemAgentTools.ts:98-107. No sort anywhere. Two independent defects:
Order is undefined.getAllKeys() ordering is an implementation detail of the underlying store. Nothing guarantees two calls agree, so "page 2" is not a well-defined concept.
Live mutation shifts indices. The app keeps running while the agent pages. Agent reads offset 0..99; the app deletes any key before index 100; agent reads offset 100..199 — the entry formerly at index 100 is now at 99 and is silently skipped. No error, no gap marker. Insertions cause silent duplicates the same way.
Keyset pagination fixes both by construction: sort by a stable unique key, checkpoint on the last key returned, resume at "first key strictly greater than checkpoint". A deletion behind the cursor is harmless; an insertion lands in its correct position.
Every source in question has a natural stable key — MMKV/storage keys by name, files by path, redux actions by seq, network requests by id, console messages by seq — so there is no case here that needs offsets.
Proposal
One model
Cursor/keyset only. Drop offset from the agent tool surface.
The existing PaginatedSource<TCheckpoint, TItem, TFilters> interface is already the right shape and needs no redesign:
agent-shared is already the common dependency of agent-bridge (plugin-side), agent-sdk, cli, and middleware, and it already hosts defineAgentToolContract. A paginated tool needs both, so they belong in one import. This also collapses the two duplicate copies before they diverge semantically.
Add an adapter for the trivial case
The reason six plugins hand-rolled .slice() is that implementing listFrom by hand looks harder than not. It should be one call:
The adapter owns sorting, checkpoint resolution, hasMore, and bounds. The author writes no pagination logic and gets correct behaviour under concurrent mutation for free.
Surface total
getBounds can already supply it; the envelope just never exposes it. Without a magnitude, hasMore: true forces the agent to choose blind between walking pages and stopping early — and those are very different decisions depending on whether the answer is 62 or 4,000. With total, one call is often enough to decide to narrow the filter instead of walking 80 pages.
Note this is orthogonal to the pagination model — an exact count is as cheap for keyset as for offset, which is why dropping offset costs nothing here.
Sub-point: make the cursor token readable
Not a separate model change — the same keyset semantics with a cheaper encoding.
Today encodeCursor emits base64url(JSON) of {v, t, d, p, f, o}. Measured on a realistic getMessages cursor with filters: 180 chars, of which the full sha1 filtersHash is 40 (30% of the decoded payload) and deviceId is 36.
filtersHash guards against "you changed filters mid-walk" (validateCursorContext). It is a mistake detector, not a security boundary — 8 chars is ample.
A readable composite — 8891~a3f2 — is ~13 chars, and gives the agent three things base64 does not:
it can see whether it is advancing or looping; two opaque blobs look identical, 8891 → 8941 does not
the position survives context compaction as meaning ("read up to seq 8891") rather than noise
"everything newer than what I already saw" needs no decode step
The usual objection to a readable cursor is that callers will do arithmetic on it (cursor + 50), which is wrong for keyset. The guard is exactly what prevents that: a hand-constructed token fails loudly because the caller cannot compute the guard. Readability and safety are not in tension here — the guard is what buys the readability.
Crucially, none of this reaches plugin authors: they return a typed TCheckpoint and the framework encodes it, so the wire format stays a framework-internal decision that can change again later without touching a single plugin.
Also expose position as a first-class envelope field so the common case needs no parsing at all.
Also worth unifying: the two limit regimes
packages/cli/src/commands/agent/runtime/pagination/limits.ts — DEFAULT_PAGE_LIMIT = 50, MAX = 200
packages/cli/src/commands/agent/output-shaping.ts — DEFAULT_PAGE_LIMIT = 20, MAX = 100
Same -n, --limit flag, different ceilings depending on whether you are listing domains or calling a tool. Pick one pair.
Suggested scope
Move pagination/ into packages/agent-shared/src/ and export paginateSource, PaginatedSource, PageEnvelope, PageRequest
Delete the duplicate copies in packages/cli/ and packages/middleware/, re-importing from agent-shared
Add createSortedArraySource for stable keyed collections
Add total to PageEnvelope, sourced from getBounds / an explicit count
Shorten filtersHash to 8 chars and drop deviceId from the cursor payload
Replace the base64 token with a readable <position>~<guard> composite, and add position to the envelope
Migrate mmkv, storage, file-system, redux-devtools from offset to cursor
Migrate network-activity and tanstack-query onto the shared primitive
Unify the two DEFAULT_PAGE_LIMIT / MAX_PAGE_LIMIT pairs
Document the primitive in website/src/docs/agent/making-your-plugin-agent-enabled.mdx
Tests: concurrent mutation during a walk (the skip bug above), unsorted source input, stale checkpoint, guard mismatch, total accuracy
Breaking changes
Tool argument shapes change for four plugins (offset → cursor) and the cursor token format changes for everyone. Both are agent-facing rather than app-facing — no application code calls these — but the plugin packages need a coordinated release.
Migrating the four offset plugins is a bug fix, not just a consistency change, so it is worth doing even though it breaks their tool signatures.
Separate bug, not covered here: packages/agent-sdk/src/pagination.ts:139 does merged.page = { ...next.page } on every iteration, so a reset: true raised at page 2 of a 5-page auto-paginated walk is overwritten by page 3 and vanishes from the final envelope — the caller gets a result that silently discarded accumulated pages. Worth its own issue.
Summary
Agent tool pagination is implemented four different ways across the codebase, the shared primitive that should prevent that is duplicated in two private copies and exported to nobody, and the most common of the four conventions — offset/limit over a live collection — silently skips rows.
Proposal: settle on one pagination model (cursor/keyset), move the existing primitive into
@rozenite/agent-shared, export it for plugin authors, and add the one adapter that makes the simple cases a one-liner.Current state
Four conventions
mmkv,storage,file-system,redux-devtoolspluginsoffset/limit, hand-rollednetwork-activityplugincursor/limit, hand-rolledtanstack-queryplugincursor+page{hasMore,nextCursor}consoledomainpaginateSourcewith a validated opaque cursorAn agent that learns how
console.getMessagespages learns nothing transferable aboutmmkv.list-entries.The primitive is duplicated and private
paginateSource,PaginatedSource,encodeCursor,hashFiltersexist in two copies:packages/cli/src/commands/agent/runtime/pagination/packages/middleware/src/agent/runtime/pagination/diffshows the divergence is currently formatting only (different prettier settings), so consolidation is mechanical today. It will not stay that way.Neither copy is exported from
agent-shared,agent-sdk, oragent-bridge, so a plugin author has no way to reach it. That is why six plugins hand-rolled their own.Motivation: offset pagination over a live app is incorrect
packages/mmkv-plugin/src/react-native/useMMKVAgentTools.ts:87-90:Same pattern in
useStorageAgentTools.ts:108-110anduseFileSystemAgentTools.ts:98-107. No sort anywhere. Two independent defects:Order is undefined.
getAllKeys()ordering is an implementation detail of the underlying store. Nothing guarantees two calls agree, so "page 2" is not a well-defined concept.Live mutation shifts indices. The app keeps running while the agent pages. Agent reads
offset 0..99; the app deletes any key before index 100; agent readsoffset 100..199— the entry formerly at index 100 is now at 99 and is silently skipped. No error, no gap marker. Insertions cause silent duplicates the same way.Keyset pagination fixes both by construction: sort by a stable unique key, checkpoint on the last key returned, resume at "first key strictly greater than checkpoint". A deletion behind the cursor is harmless; an insertion lands in its correct position.
Every source in question has a natural stable key — MMKV/storage keys by name, files by path, redux actions by seq, network requests by id, console messages by seq — so there is no case here that needs offsets.
Proposal
One model
Cursor/keyset only. Drop offset from the agent tool surface.
The existing
PaginatedSource<TCheckpoint, TItem, TFilters>interface is already the right shape and needs no redesign:Move it to
agent-sharedand export itagent-sharedis already the common dependency ofagent-bridge(plugin-side),agent-sdk,cli, andmiddleware, and it already hostsdefineAgentToolContract. A paginated tool needs both, so they belong in one import. This also collapses the two duplicate copies before they diverge semantically.Add an adapter for the trivial case
The reason six plugins hand-rolled
.slice()is that implementinglistFromby hand looks harder than not. It should be one call:The adapter owns sorting, checkpoint resolution,
hasMore, and bounds. The author writes no pagination logic and gets correct behaviour under concurrent mutation for free.Surface
totalgetBoundscan already supply it; the envelope just never exposes it. Without a magnitude,hasMore: trueforces the agent to choose blind between walking pages and stopping early — and those are very different decisions depending on whether the answer is 62 or 4,000. Withtotal, one call is often enough to decide to narrow the filter instead of walking 80 pages.Note this is orthogonal to the pagination model — an exact count is as cheap for keyset as for offset, which is why dropping offset costs nothing here.
Sub-point: make the cursor token readable
Not a separate model change — the same keyset semantics with a cheaper encoding.
Today
encodeCursoremitsbase64url(JSON)of{v, t, d, p, f, o}. Measured on a realisticgetMessagescursor with filters: 180 chars, of which the full sha1filtersHashis 40 (30% of the decoded payload) anddeviceIdis 36.filtersHashguards against "you changed filters mid-walk" (validateCursorContext). It is a mistake detector, not a security boundary — 8 chars is ample.deviceIdis redundant: sessions are device-keyed,--sessionalready pins it, and per Agent sessions should auto-heal after an app relaunch #317 the deviceId survives rebind.A readable composite —
8891~a3f2— is ~13 chars, and gives the agent three things base64 does not:8891 → 8941does notThe usual objection to a readable cursor is that callers will do arithmetic on it (
cursor + 50), which is wrong for keyset. The guard is exactly what prevents that: a hand-constructed token fails loudly because the caller cannot compute the guard. Readability and safety are not in tension here — the guard is what buys the readability.Crucially, none of this reaches plugin authors: they return a typed
TCheckpointand the framework encodes it, so the wire format stays a framework-internal decision that can change again later without touching a single plugin.Also expose
positionas a first-class envelope field so the common case needs no parsing at all.Also worth unifying: the two limit regimes
packages/cli/src/commands/agent/runtime/pagination/limits.ts—DEFAULT_PAGE_LIMIT = 50,MAX = 200packages/cli/src/commands/agent/output-shaping.ts—DEFAULT_PAGE_LIMIT = 20,MAX = 100Same
-n, --limitflag, different ceilings depending on whether you are listing domains or calling a tool. Pick one pair.Suggested scope
pagination/intopackages/agent-shared/src/and exportpaginateSource,PaginatedSource,PageEnvelope,PageRequestpackages/cli/andpackages/middleware/, re-importing fromagent-sharedcreateSortedArraySourcefor stable keyed collectionstotaltoPageEnvelope, sourced fromgetBounds/ an explicit countfiltersHashto 8 chars and dropdeviceIdfrom the cursor payload<position>~<guard>composite, and addpositionto the envelopemmkv,storage,file-system,redux-devtoolsfromoffsetto cursornetwork-activityandtanstack-queryonto the shared primitiveDEFAULT_PAGE_LIMIT/MAX_PAGE_LIMITpairswebsite/src/docs/agent/making-your-plugin-agent-enabled.mdxtotalaccuracyBreaking changes
Tool argument shapes change for four plugins (
offset→cursor) and the cursor token format changes for everyone. Both are agent-facing rather than app-facing — no application code calls these — but the plugin packages need a coordinated release.Migrating the four offset plugins is a bug fix, not just a consistency change, so it is worth doing even though it breaks their tool signatures.
Related
deviceIdpartly because deviceId is stable across rebind there.pageenvelope and emittingnextas a runnable command; this issue covers what goes in the envelope.packages/agent-sdk/src/pagination.ts:139doesmerged.page = { ...next.page }on every iteration, so areset: trueraised at page 2 of a 5-page auto-paginated walk is overwritten by page 3 and vanishes from the final envelope — the caller gets a result that silently discarded accumulated pages. Worth its own issue.