feat(middleware): rework agent console buffer, previews, and cursors - #432
Merged
Conversation
Make the agent `console` domain hold up on long sessions and render the
logs that actually matter.
Storage and reads:
- Replace the array-with-splice buffer with a real ring buffer, so an
append costs the same whether it is empty or full instead of shifting
5000 elements once the buffer fills.
- Rewrite the read path to seek to its start position and stop once the
page is full, replacing a filter + sort + copy + reverse of the whole
buffer on every call.
- Extract `CircularBuffer<T>` as a general, log-agnostic collection.
Rendering:
- Read the CDP object preview Hermes already sends, so `{ userId: 42 }`
renders as `{userId: 42}` rather than `Object`, and arrays show their
elements with an explicit overflow marker.
- Cap every rendered value; Hermes applies no length limit, so a single
logged string can arrive at megabyte scale.
- Prefer an Error's description over its preview, which repeats the whole
stack, and store each entry once instead of twice.
Cursors:
- Give every item a `cursor` and accept `before`/`after` bounds, so an
agent can find an error under a filter and then read around it.
- Reduce a cursor to an opaque position, no longer bound to the filters
or order of the request that produced it.
BREAKING CHANGE: `argsPreview` is removed from `getMessages` and cursors
from an older session are no longer accepted.
Claude-Session: https://claude.ai/code/session_01UQ7D5i9m3b8aMXhjLLkiSQ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Reworks the agent
consoledomain so it holds up over a long session and renders the logs that actually matter.Storage and reads. The per-device buffer was an array that
splice(0, n)-ed on overflow, so every log past the 5000th shifted 5000 elements. It is now a real ring buffer, and an append costs the same whether it is empty or full. Reads used to filter, sort, copy, and reverse the entire buffer on every call — five passes over 5000 entries to return 50. A read now seeks straight to its start position and stops once the page is full, materialising only the rows it returns. The ring buffer is extracted asCircularBuffer<T>, a general collection with nothing about logs in it.Rendering. Hermes sends a CDP object preview with every
console.log, and the code was ignoring it — soconsole.log({ userId: 42 })was stored as the single wordObject. It now renders{userId: 42}, and arrays render their elements with an explicit…when the runtime truncated the property list. Every rendered value is length-capped: Hermes applies no limit of its own, and a single logged string arrives whole (confirmed at 1MB over the wire). Each entry is also stored once instead of twice —textwas the join ofargsPreviewand both were retained.Cursors.
getMessagesitems now carry acursor, and the tool acceptsbeforeandafterbounds. An agent can find an error underlevels: ["error"]and then read the entries surrounding it in one follow-up call. A cursor is now an opaque position and nothing more.Related Issue
Context
On dropping the cursor context. Cursors previously carried a filters hash and a sort order, and rejected any mismatch. That guard is what made "find the error, then read around it" impossible: a cursor from a filtered query could not be replayed against an unfiltered one. The guard also was not buying much — it protects offset pagination, where position 50 is meaningless if the filter changes underneath you, but these positions are absolute indices over an append-only log, so reusing one under a different query is well defined rather than corrupting. Removing it took a cursor from 143 characters to 6 (~96%), which matters now that every row carries one.
The last thing removed was the tool/device binding. A cursor from one device replayed against another now resolves in the target device's own position space and returns a well-formed but likely unintended slice, silently, rather than erroring. That is a real regression in diagnosability. It takes a deliberate mistake to hit and the damage is bounded, and 137 bytes on every row was a certain cost against an unlikely one — but it is a trade, not a free win. There is a test pinning the actual behaviour.
On
cursorvsbefore/after. In the matching direction these are now the same code path and return identical bytes. They differ in thatbefore/afterselect a side whileorderselects a direction, so{before, order: "asc"}reads the older side oldest-first, whichcursorcannot express; and only the bounds can describe a two-sided range or survive paging inside one.Rendering decisions, from captured device frames. An
Error's preview repeats the entire stack in itsstackproperty, so errors deliberately usedescriptionand skip the preview — reading it would store the stack twice. Hermes reportsMapandSetasclassName: "Object"with no properties and noentries, indistinguishable from{}, so an empty preview falls back to the description rather than claiming the value is empty.NaN/Infinity/-0/BigInt arrive asunserializableValuebut always with a description, so they already rendered correctly and are unchanged.Breaking.
argsPreviewis removed fromgetMessages— it only ever repeatedtextand was never in the default projection. Cursors issued by an older session are not accepted by this version. Both are noted in the changeset.hashFiltersmoved topagination/filters-hash.ts; the React domain keeps its own cursor implementation and only borrowed that helper, and its semantics are untouched.Testing
Automated:
pnpm typecheck:all— 63/63 packagespnpm lint:all— 62/62 packagespnpm format:all— clean for every file touched here (three pre-existing failures elsewhere in the repo were left alone)pnpm --filter @rozenite/middleware test— 134 passedpnpm --filter rozenite test— 113 passedpnpm --filter @rozenite/agent-sdk test— 42 passedpnpm --filter @rozenite/agent-shared test— 16 passedNew coverage:
circular-buffer.test.ts(wraparound, capacity of one, evicted-index lookup, both walk directions, clear-then-refill),console-extract.test.ts(fixtures taken verbatim from realRuntime.consoleAPICalledframes off a Hermes device — object and array previews, overflow, errors, Map/Set, unserializable numbers, and each truncation cap), and eight cases inconsole-log-store.test.tscovering the anchor workflow, two-sided ranges, paging inside a bounded range, and cursor interchangeability.Not verified on a device: the rendering changes are covered by captured frames rather than a live session, so a manual pass over the panel output is worth doing before release.