Skip to content

Expose one shared cursor-based pagination primitive for plugin authors #320

Description

@V3RON

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

where shape
mmkv, storage, file-system, redux-devtools plugins offset / limit, hand-rolled
network-activity plugin cursor / limit, hand-rolled
tanstack-query plugin cursor + page{hasMore,nextCursor}
built-in console domain paginateSource with a validated opaque cursor

An agent that learns how console.getMessages pages learns nothing transferable about mmkv.list-entries.

The primitive is duplicated and private

paginateSource, PaginatedSource, encodeCursor, hashFilters exist in two copies:

  • packages/cli/src/commands/agent/runtime/pagination/
  • packages/middleware/src/agent/runtime/pagination/

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

packages/mmkv-plugin/src/react-native/useMMKVAgentTools.ts:87-90:

const allKeys = view.getAllKeys();
const safeOffset = Math.max(0, Math.floor(offset));
const keys = allKeys.slice(safeOffset, safeOffset + safeLimit);

Same pattern in useStorageAgentTools.ts:108-110 and useFileSystemAgentTools.ts:98-107. No sort anywhere. Two independent defects:

  1. 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.

  2. 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:

listFrom(input: { checkpoint?, order, limit, filters }): ListFromResult<TCheckpoint, TItem>
getBounds(filters): SourceBounds<TCheckpoint>

Move it to agent-shared and export it

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:

const source = createSortedArraySource({
  items: () => view.getAllKeys(),
  keyOf: (key) => key,
});

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.
  • deviceId is redundant: sessions are device-keyed, --session already 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:

  • 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.tsDEFAULT_PAGE_LIMIT = 50, MAX = 200
  • packages/cli/src/commands/agent/output-shaping.tsDEFAULT_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 (offsetcursor) 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

  • Agent sessions should auto-heal after an app relaunch #317 — session auto-heal. The cursor can drop deviceId partly because deviceId is stable across rebind there.
  • Emit columnar JSON for row-shaped agent output to cut token usage #318 — columnar output. Covers dropping the terminal page envelope and emitting next as a runnable command; this issue covers what goes in the envelope.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions