Skip to content

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 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 typeMeaning
textNatural language (including synthetic prompts)
toolTool call progress and results
reasoningReasoning text
file / agentAttachments, @agent references
compaction / subtaskCompaction tasks, subtask markers
step-start / step-finishStep boundaries, tokens, snapshots
patchFile-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.

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]

Not every stream event deserves a database write. Processor splits by whether the content has formed a complete block:

Stream eventHow it is writtenWhy
text-start / reasoning-startNew empty Part, full rowSubscribers need to know the block exists
text-delta / reasoning-deltaBus deltas onlyPer-character writes cause heavy write amplification; subscribers stitch fragments
text-end / reasoning-endFull 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 rowArgs, output, and error must be replayable
step-start / step-finishFull row (snapshot, usage, cost)Billing and troubleshooting anchor; must persist
patchFull rowFile-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.

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 --> View

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

LayerLifetimeRole
Processor contextInside one processCurrent tool-call, text buffers, whether compaction is needed
Message / Part storePersisted across turnsReal memory and troubleshooting timeline
runLoop localsOne runLoopStep 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.

  • 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: