Message model and cross-turn memory
Cross-turn memory’s source of truth is persisted session messages, not temporary variables inside one Processor turn. Agent Forge describes a full conversation as Message (one user or assistant message) plus Part (fragments inside that message). Stream increments enter the event bus first, then project into storage for the next scheduling turn and UI subscribers.
Message and Part
Section titled “Message and Part”Message carries metadata: role, parent/child links, model, finish state, and related fields. Part is a fragment inside the message body; one Message may hold many Parts.
| Part type | Meaning |
|---|---|
| text | Natural language (including synthetic prompts) |
| tool | Tool call progress and results |
| reasoning | Reasoning text |
| file / agent | Attachments, @agent references |
| compaction / subtask | Compaction tasks, subtask markers |
| step-start / step-finish | Step boundaries, tokens, snapshots |
| patch | File-change summary for this step |
Message is the envelope; Part is the paragraphs and attachments inside it. When troubleshooting, locate by Part type: missing deltas usually means text/reasoning deltas never reached the client; stuck tools mean watching tool Part state transitions.
Writes: event-driven projection
Section titled “Writes: event-driven projection”Session updates go through a unified write entry: after updating a Message or Part, a sync event fires and the projection layer writes storage (conflicting updates to the same record overwrite). Processor keeps updating Parts during the model stream; some deltas go only to the bus and do not rewrite the full row every time; complete blocks land at boundaries such as text-end.
flowchart LR Up[updateMessage / updatePart] --> Ev[Sync event] Ev --> Proj[Projection write] Proj --> Store[(session / message / part)] Ev -.-> Bus[Bus] Bus --> SSE[SSE client]Which increments stay bus-only
Section titled “Which increments stay bus-only”Not every stream event deserves a database write. Processor splits by whether the content has formed a complete block:
| Stream event | How it is written | Why |
|---|---|---|
| text-start / reasoning-start | New empty Part, full row | Subscribers need to know the block exists |
| text-delta / reasoning-delta | Bus deltas only | Per-character writes cause heavy write amplification; subscribers stitch fragments |
| text-end / reasoning-end | Full row (with trailing trim and plugin post-processing) | Finalized at the boundary, so later reads get a complete block |
| Tool state transitions (pending → running → completed / error) | Full row | Args, output, and error must be replayable |
| step-start / step-finish | Full row (snapshot, usage, cost) | Billing and troubleshooting anchor; must persist |
| patch | Full row | File-change summary for this step |
Per-chunk tool-input deltas are neither stored nor broadcast; the events are kept but not applied. Tool results are still written as full rows.
The cost of this split: if the process dies between two boundaries, the last unfinished text may exist only on the bus. Cleanup closes unfinished text / reasoning and tool states, completing the confirmed content into full blocks. If you only poll the database, you will not see per-character increments; subscribe to SSE for that.
Storage shape: session metadata, message rows, part rows. Default is SQLite; with Postgres configured, the same message model applies with tenant isolation. Clients need not care about dialect; they only need Message / Part semantics.
Reads and filterCompacted
Section titled “Reads and filterCompacted”Full history can be read to the end of a session via pagination cursors (each page has a limit; continue while more exists). It is not “always keep only the latest 50.” Before sending to the model, runLoop uses filterCompacted for a compact view: scan newest to oldest; on hitting a boundary — an assistant message with a successful summary whose parent user message carries a compaction Part — drop older raw text, then reverse to oldest→newest.
flowchart TD All[Full session messages] --> Page[Paginate to end] Page --> Filter[filterCompacted] Filter --> Bound{Hit compaction boundary?} Bound -->|yes| Cut[Drop raw text before boundary] Bound -->|no| Keep[Keep visible history] Cut --> View[Model-facing view] Keep --> ViewLarge windows can still be large; the compaction boundary decides how old raw text the model “cannot see.” The summary itself stays near the boundary so semantics can continue. Details: runLoop and context compaction.
Layers of cross-turn memory
Section titled “Layers of cross-turn memory”| Layer | Lifetime | Role |
|---|---|---|
| Processor context | Inside one process | Current tool-call, text buffers, whether compaction is needed |
| Message / Part store | Persisted across turns | Real memory and troubleshooting timeline |
| runLoop locals | One runLoop | Step count, structured-output scratch, and similar |
Each turn rebuilds from storage with filterCompacted; prior in-memory context is not the cross-turn truth. After process restart or replica switch, as long as storage is readable, the session can continue.
Verification checks
Section titled “Verification checks”- Streaming UI: open the event subscription before POST; relying only on the sync HTTP response yields a full blob. See HTTP streaming and SSE.
- “Lost” history: first separate “missing in storage” from “removed from the model view by
filterCompacted.” The former is write/projection; the latter is compaction boundaries and summary messages.
Related reading: