Concepts
Context (Folds and Views)
Turn a conversation's event history into typed, incrementally folded state, trim it to a model's token budget with views and strategies, and compact it without losing events.
An agent that calls a model needs the conversation as model input: the user's prompts, its own earlier steps, the tool results, maybe a summary of what came before. A context fold builds that input from the network's event history. You declare how each event changes a typed state. The runtime folds the events into it, caches it, and on each read only folds the events published since the last one. A view trims a fold's state to a token budget.
- History is the source of truth. A fold is derived from durable events and can always be rebuilt from them. Nothing besides the events is persisted; even a compaction is an event in the history, the fold's checkpoint.
- Incremental. Reading a fold does not replay the whole conversation every step. The plane caches the folded state and reads only newer events from the store.
- Budgeted. A view is a function of a fold's state and a token budget: it counts tokens and applies strategies (elide old tool outputs, drop old turns) until the state fits, without separating a tool call from its result.
A fold
import { AgentNetworkEvent, ContextFold, S } from '@m4trix/core';
const UserPrompt = AgentNetworkEvent.of('user-prompt', S.Struct({ text: S.String }));
const AssistantMessage = AgentNetworkEvent.of('assistant-message', S.Struct({ text: S.String }));
const Turn = S.Struct({ role: S.Literal('user', 'assistant'), text: S.String });
const Conversation = ContextFold.of('conversation', { state: S.Array(Turn) })
.initial(() => [])
.on(UserPrompt, (turns, { payload }) => [...turns, { role: 'user', text: payload.text }])
.on(AssistantMessage, (turns, { payload }) => [
...turns,
{ role: 'assistant', text: payload.text },
]);ContextFold.of(name, options?). The name is also the accessor inctx.folds(folds.conversation()), so it must be a JS identifier: letters, digits,_and$, not starting with a digit. A literal name that isn't one is a type error as well as aContextFoldDefinitionError. The options are all optional:scope:'context'(the default) folds every run of the conversation;'run'folds only the current run, e.g. for the progress of one request. A run-scoped state is dropped when its run ends.state: an Effect schema of the state. The fold's state type is the schema's type, andinitial()and every handler must return one. Without it, the state type is whateverinitial()returns, so.initial(() => [])would infernever[]and no handler could add a turn: annotate it instead (.initial((): ReadonlyArray<typeof Turn.Type> => [])). The fold keeps the schema asfold.stateSchema, typedS.Schema<State, Encoded>(undefinedwithout one).version: a positive integer, default 1: the version of the state's shape. A fold only restores checkpoints of its own name and version, so bump it when the state (or its schema) changes in a way old checkpoints do not fit. Neither name nor version identifies the cached state: the fold object does (see Caching and consistency).
.initial(() => state)creates the state before any event, on every cold start, so return a fresh value..on(event, (state, envelope) => next)gets the typed envelope (payload,meta). The handlers define the events the fold reads, one handler per event: a second one for the same event is a type error and throwsContextFoldDefinitionError. Handlers must be pure: no I/O, no clock or randomness, and no mutation ofstate(return a new value). The cache relies on the same events always folding to the same state.- At least one event. A fold without a handler reads nothing. It throws
ContextFoldDefinitionErrorwhen an agent or aggregator declares it with.folds([...])(which runsContextFold.validate), and at the latest on its first use.
A fold is plain data and pure functions, so you can test it without a network: Conversation.applyAll(Conversation.initialState(), events). applyAll and apply take envelopes with decoded payloads, e.g. UserPrompt.makeBound(meta, { text }); in a network the runtime decodes the stored events first (see Stored events are decoded).
Reading folds in agents
Declare the folds an agent reads with .folds([...]); the logic reads each one through ctx.folds, by name:
const assistant = AgentFactory.run()
.listensTo([UserPrompt])
.emits([AssistantMessage])
.dependsOn(Llm)
.folds([Conversation])
.logic(async ({ folds, emit, layers, signal }) => {
const turns = await folds.conversation(); // the state, read-only, the trigger included
emit(AssistantMessage.make({ text: await layers.Llm.reply(turns, signal) }));
})
.produce({});EventAggregator takes .folds([...]) the same way, with folds in both emitWhen and mapToEmit:
const Progress = ContextFold.of('progress', { scope: 'run' })
.initial(() => 0)
.on(ModelStep, (steps) => steps + 1);
const stepLimit = EventAggregator.listensTo([ModelStep])
.emits([StepLimitReached])
.folds([Progress])
.emitWhen(async ({ folds }) => (await folds.progress()) >= 20)
.mapToEmit(async ({ folds, emit }) => {
emit(StepLimitReached.make({ steps: await folds.progress() }));
});A read resolves to the fold's state, typed read-only (FoldReadStateOf<typeof Fold>): in a network it is the cached state itself, shared by every reader, so derive new values instead of changing it. The type holds at runtime: the plain objects and arrays of a read state are frozen (Object.freeze, all the way down), so writing to them throws a TypeError (in strict mode, which ES modules always are). Dates, Maps, Sets, Options and class instances are left as they are: Effect caches hashes on its own data types, which freezing would break.
- A fold with a
stateschema (and every segmented fold) reads as the schema's type, as it is: Effect schema types are read-only already (unless you opt out withS.mutable), and theirDates,Options and classes stay what they are. - A fold without one reads as
DeepReadonly<State>: objects getreadonlyproperties and arrays become read-only, all the way down.Date,RegExp,Error,Promise, binary data and functions stay as they are. Other class instances are mapped like plain objects, so a class withprivatemembers would no longer match its own type: give such a fold astateschema. - Type the parameters of helpers that take a read state with
FoldReadStateOf<typeof Fold>(a helper of the mutable state type is a type error where a read state is passed: it could write to the shared state).
.folds() takes folds only, and calls accumulate. Each fold needs its own name on one agent: two folds with the same name are a type error and throw at build time. See .folds(folds) for the full reference.
Compaction
A conversation grows without bound, while a model's context window does not. To compact a fold, an agent replaces its state for good: folds.<name>.compact(next) reads the fold, calls next with the state it read and makes what next returns the fold's new state, e.g. a summary of the old turns and the newest ones kept as they are:
const Context = S.Struct({ summary: S.optional(S.String), turns: S.Array(Turn) });
const Conversation = ContextFold.of('conversation', { state: Context })
.initial(() => ({ turns: [] }))
.on(UserPrompt, (state, { payload }) => ({
...state,
turns: [...state.turns, { role: 'user', text: payload.text }],
}))
.on(AssistantMessage, (state, { payload }) => ({
...state,
turns: [...state.turns, { role: 'assistant', text: payload.text }],
}));
// In an agent with .folds([Conversation]):
const compacted = await folds.conversation.compact(async ({ summary, turns }) => ({
summary: await layers.Llm.summarize(summary, turns.slice(0, -4), signal),
turns: turns.slice(-4),
}));The accessor stays callable as before (await folds.conversation()); compact is a member of it. next gets the state read-only, as a read returns it, may be async (a model call), and returns a value of the fold's state type. compact resolves to the fold's state afterwards, the one every read returns from then on.
The checkpoint. The runtime stores the new state as the fold's checkpoint, an event it publishes on behalf of the agent (you do not declare it in .emits()):
| Name | m4trix:fold.checkpoint:<name>@<version>, e.g. m4trix:fold.checkpoint:conversation@1 (foldCheckpointEventName(fold)) |
| Payload | { fold, version, through, state } (FoldCheckpointPayload): state is the new state encoded with the fold's state schema; through is the cursor of the read next got (null when the fold had read nothing yet) |
| Durability | Stored with the run like any durable event, in publish order with the agent's emits; reported to tracers once, with channel: '' and the payload without its state ({ fold, version, through }). It does not count against maxEvents |
| Delivery | None: it goes to no channel, so no agent, subscriber, proxy, emitAndAwait waiter or SSE client (live or resumed) ever receives it (it may be large, and it is internal to the fold). Folds read it from the store; history.run() / history.context() include it (isFoldCheckpointEvent(event) tells it apart) |
| Origin | Only the runtime publishes it. An external publish of any m4trix: name (plane.publish, emitStartEvent, a proxy's publish) is refused with ReservedEventNameError, so a client-chosen name can never forge a checkpoint |
Events are never lost. A read restores the newest checkpoint and folds every event of the fold after its through on top of it again. So an event published while next ran (another agent's tool result, a new prompt) is not dropped because the summary was written without it: it lands after through, and is folded on top of the new state, in the cached state and in a rebuild alike. That makes compact safe to call at any time, also while other agents publish.
- Cold start (nothing cached, after a restart or an eviction): the newest checkpoint of the fold (
history.latest([name])), then the events after itsthrough. - Warm reads: when the new events contain a checkpoint, the read rebases on it: it restores the checkpoint and reads the events after its
through(again, if it had already folded some). Several checkpoints in one read: the newest wins. The cached state therefore always equals a cold rebuild. - Concurrent compactions are deterministic: the checkpoint appended last wins, whichever read it was computed from, and the events after its
throughare folded on top. - A checkpoint of a deleted history (the context was deleted or expired while the compaction ran, so its
throughbelongs to a history that is gone) is ignored, and so is one whosethroughthe store does not recognise (e.g. a history migrated from another store) and one that does not decode with the fold'sstateschema (reported like an event that does not decode, withreason: 'checkpoint'). The read falls back to the newest usable checkpoint, or the start. - Read-your-writes. The checkpoint goes through the invocation's emits, so a read right after
compactalready includes it. When the run ends before it is stored (or the store fails, which fails the run withstore-error),compactrejects withAgentAbortedError.
Version. Checkpoints of another version are never read, cold or warm: their name differs. Bumping a fold's version means "my state shape changed": the fold starts from scratch, or from a checkpoint the new version wrote. Starting from scratch re-folds the whole history, so the first view after a bump may be far over budget, and the next compaction summarizes all of it at once. The same happens when the newest checkpoints do not decode (a state schema changed without a version bump): they are skipped and reported (once per event plane, while it remembers them) until a new compaction writes a checkpoint that decodes.
That first compaction after a bump summarizes everything older than what it keeps: it grows with the history, not with the budget, and in a long conversation it can exceed the model's context window. Summarizing in chunks, which a tool-heavy conversation needs anyway (see Compacting with a view), bounds it too. In examples/m4trix-code's tests, eight turns at a budget of 450 tokens make a single 688-token summary request after a bump; in chunks of at most a budget, two requests of 404 and 284 tokens.
Identity: one fold per name
The cache keeps state per fold object, but a stored checkpoint can only be found by its fold's name and version. So two different fold objects that can compact must never have the same name and version: each would restore checkpoints the other wrote. The wiring check fails setup for them with fold-checkpoint-shared (e.g. a compactable fold built again per agent by a factory function). Different folds that share a name but no checkpoints (without a state schema, run-scoped, or of different versions) only get the warning fold-name-shared: they are folded and cached once each, and the read metric reports them as one. Define each fold once and share that object.
Networks that share a store share checkpoints too, per principal namespace and contextId: a fold named conversation in one network restores the conversation@1 checkpoints the other wrote. Give such networks the same fold definitions for a name, or different names (or versions).
What it needs from the store
Compaction relies on these things of the network's store, all part of its contract (runStoreContract checks them):
- JSON in, the same JSON out. Events are stored as JSON (a checkpoint's
stateis the state encoded with the fold'sstateschema); the store returns them unchanged. - Keep
channels. A checkpoint is stored withchannels: []; resumed SSE streams replay only their own channel's events, and never a checkpoint. - Positions only grow. An event appended while a cursor is out shows up after it, never before it: a read after the cursor would miss it for good.
- Stable cursors. A checkpoint persists the cursor of the read it was computed from (
through), so the store's cursor format must stay readable across its versions. - Unknown cursor ⇒
reset. A readaftera cursor the store does not recognise (another store's, a migrated history) returns the history from its start withreset: true, not an error. The checkpoint is then ignored instead of making the fold unreadable.
examples/m4trix-code/src/file-store.ts is a small store that does all of this: a JSON lines file that keeps a terminal session, compactions included, across restarts.
What can compact
- Only a context-scoped fold with a
stateschema (aContextFold.of(name, { state }), and every context-scopedSegmentedFold). The checkpoint stores the state encoded with that schema, so a fold without one has nothing to encode with. A run-scoped fold is rebuilt from its run anyway. For these foldscompactis typedFoldCannotCompact<name>, a type error to call, and rejects withFoldCompactionError(reason: 'unsupported') at runtime. - Strict encoding. The new state must encode with the schema, and a field the schema does not know is an error, not dropped, so the stored checkpoint holds exactly the state and warm and cold reads cannot differ. The encoded state must be JSON, like every stored event: use
S.Date(a string when encoded), notS.DateFromSelf. An invalid state rejectscompactwithFoldCompactionError(reason: 'invalid-state', the schema'sParseErrorascause) and publishes nothing; so does an error thrown bynext(with that error). - Agents only, in a network. Aggregators get read accessors without
compact(FoldReadAccessors). Outside a network (agent.invoke())compactrejects withFoldCompactionError('unsupported'): there is no store to publish to.
Segmented folds
For model input, a plain list of turns is not enough: a model step and the results of its tool calls belong together. Chat APIs reject a tool call without its result, so trimming must never separate them. A segmented fold keeps its state as a list of segments, each an atomic unit (a prompt, a model step with all of its tool results, an answer), plus an optional summary of what came before them (what a compaction writes):
import { SegmentedFold } from '@m4trix/core';
const Conversation = SegmentedFold.of('conversation', {
data: Entry, // a union: { kind: 'user' | 'step' | 'answer', … }
summary: S.String, // what a compaction writes in place of older segments
tag: 'kind',
})
.open(UserPrompt, ({ meta, payload }) => ({
id: `prompt:${meta.runId}`,
data: { kind: 'user', text: payload.text },
}))
.open(ModelStep, ({ payload }) => ({
id: payload.stepId,
data: { kind: 'step', text: payload.text, toolCalls: payload.toolCalls, results: [] },
}))
.attach(ToolResult, {
kind: 'step',
to: ({ payload }) => payload.stepId,
update: (step, { payload }) => ({
...step,
results: [...step.results, { callId: payload.callId, output: payload.output }],
}),
})
.open(AssistantMessage, ({ meta, payload }) => ({
id: `answer:${meta.runId}`,
data: { kind: 'answer', text: payload.text },
}));SegmentedFold.of(name, { data, summary?, tag?, scope?, version? })takes schemas instead ofinitial().datais the schema of a segment's data, typically a union with one variant per kind;summarythat of the summary (none when left out). The state isSegmentedState<Data, Summary>={ summary?: Summary; segments: readonly Segment<Data>[] }, starting with no segments, and eachSegment<T>is{ id, data }.scopeandversionwork as inContextFold.of. The state schema is derived fromdataandsummary, so every context-scoped segmented fold can compact..open(event, (envelope) => ({ id, data }))opens a segment for each event, appended as the newest..attach(event, { kind?, to, onMissing?, update })updates the newest segment with the idto(envelope): its data becomesupdate(data, envelope), in place. Returndataitself to change nothing.kindnarrows: only a segment of that kind is a target, andupdategets and must return that variant, so thestepabove needs nodata.kind === 'step' ? … : data. The kind is read from the data'stagfield:'_tag'by default (Effect'sS.TaggedStruct),'kind'here. A tag must be a field with a string literal type in every variant, otherwise it is a type error, andkindwithout such a tag is a type error too (KindNeedsSegmentTag: settag). A variant whose tag field allows several literals (kind: 'user' | 'tool') is narrowed to the one given. A segment with the id but of another kind is skipped: the kind is part of the address.onMissingis what happens when no segment matches. Either way the state stays as it was.'report'(the default) also reports the event like one that does not decode (reason: 'missing-segment'): a missing target is most often a wrongtoorkind.'ignore'says it is expected, e.g. a late tool result of a step that a compaction already summarized away. There is no option to throw: the event stays in the history, so a fold that throws on it could never be read past it again.
.on(event, (state, envelope) => state)handles an event with the whole segmented state, for whatopenandattachcannot express, such as removing or merging segments.- Each event has one handler, whichever of
open,attachandonadded it. fold.tagis the tag, typed ('kind'here).ContextStrategy.fromNewest(fold, kind)reads kinds through it.- Segment ids address segments in views (
dropped), so keep them unique: a payload id like a step id, or one derived from the event, such asprompt:${meta.runId}. SegmentOf<State>,SegmentDataOf<State>andSummaryOf<State>extract the parts of a segmented state type;SegmentDataOfKind<Data, Tag, Kind>is one variant of the data.fold.stateSchemais the schema of the whole state, derived fromdataandsummaryand typedS.Schema<State, Encoded>.
Views and ContextStrategy
A view trims a fold's state to a token budget. ContextView.of(fold, options) returns a pure function: call it with the state an agent read and the budget of that read. A view has no name and no state of its own, and it is not declared in .folds(): declare the fold, read it, and pass its state to the view.
import { ContextStrategy, ContextView } from '@m4trix/core';
const ChatView = ContextView.of(Conversation, {
strategies: [
// First, shorten the tool outputs of all but the newest four segments...
ContextStrategy.elide({
keepNewest: 4,
where: (segment) => segment.data.kind === 'step',
map: (data) =>
data.kind === 'step'
? {
...data,
results: data.results.map((r) => ({ ...r, output: `${r.output.slice(0, 200)}… [elided]` })),
}
: data,
}),
// ...then drop whole segments, oldest first, never those of the current turn.
ContextStrategy.dropOldest({ keepFrom: ContextStrategy.fromNewest(Conversation, 'user') }),
],
});
// In an agent with .folds([Conversation]):
const chat = ChatView(await folds.conversation(), { budget: 30_000 });
// → { state, tokens, budget, overBudget, dropped, fits }Calling a view counts the state's tokens and runs the strategies in order, each at most once, and only while the state is over budget, recounting after each one. The budget is any positive number of tokens (fractions are fine, e.g. window * 0.8), or Infinity for none; anything else throws a RangeError. It is given per call, so one view serves any budget: per model, from the agent's params, or smaller for a cheaper call. A view reads the state as ctx.folds returns it (read-only) and never writes to it. It returns a ViewResult:
| Field | Meaning |
|---|---|
state | The state after the strategies that ran: the state given itself when it already fit |
tokens | Tokens of state: for a segmented state, its summary plus every segment |
budget | The budget of this call (Infinity: no budget) |
overBudget | tokens > budget: the strategies could not trim enough, e.g. only the current turn is left |
dropped | Ids of the segments the strategies removed, oldest first |
fits | Nothing dropped and within budget: the view holds the whole conversation (old tool outputs may be elided). false is the moment to compact |
A view of a segmented fold also counts and cuts segments, without a budget and without running its strategies:
ChatView.count(segment): tokens of one segment, with the view's counter (cached, shared with its reads).ChatView.split(state, { keepTokens, keepFrom? }): cuts a state (the fold's state, as read) for a compaction that targets a size.keptare the newest whole segments whose tokens fitkeepTokens(a non-negative number, orInfinity),olderthe rest. Segments fromkeepFrom(segments)on are always kept, whatever their size, e.g.keepFrom: ContextStrategy.newest()keeps the newest segment. It never splits a segment, andkeptis always a run of the newest segments: it stops at the first one that does not fit. The summary does not count; a compaction summarizes it again witholder.
ChatView.fold is the fold the view reads, and the function is named after it (conversationView).
Options of a view of a segmented fold, all optional:
count(segment): tokens of one segment. The defaultapproxTokenCountis ⌈JSON length / 4⌉, a cheap, dependency-free heuristic. Inject a real tokenizer when precision matters. It must be pure: the view counts each segment once and caches the count, across calls and budgets.countSummary(summary): tokens of the summary (defaultapproxTokenCount).strategies: default none, in which case the view only measures, e.g. to decide when to compact.
The built-in strategies work on segmented state and never split a segment:
ContextStrategy.elide({ keepNewest, where?, map })transforms the data of the segments older than the newestkeepNewestthat matchwhere, e.g. truncates old tool outputs. It never removes a segment. An old segment elides to the same object on every call (it is memoized per segment), somapruns once per segment and the view counts its elided form once, even with a costly tokenizer.ContextStrategy.dropOldest({ keepFrom? })drops whole segments, oldest first, until the tokens fit, but none fromkeepFrom(segments)on.keepFromgets the segments once per call and returns an index from 0 (keep all) tosegments.length(keep none); without it any segment may go. The summary is never dropped.ContextStrategy.fromNewest(fold, kind)is akeepFrom: the index of the newest segment ofkind, read through the fold'stag.fromNewest(Conversation, 'user')keeps the current turn. Without a segment of that kind it returns 0, so every segment stays (they are all newer than it, e.g. after a compaction summarized the prompt).kindis typed by the fold:fromNewest(Conversation, 'tool')is a type error.ContextStrategy.newest(count = 1)is akeepFromtoo: the newestcountsegments.
A strategy is any pure function ContextStrategy<State>: (state, { budget, tokens, count }) => state. Return the same state when nothing changes. To write your own:
import type { ContextStrategy } from '@m4trix/core';
type Chat = FoldStateOf<typeof Conversation>;
/** Keeps only the newest 50 segments. */
const keepNewest50: ContextStrategy<Chat> = (state) =>
state.segments.length <= 50 ? state : { ...state, segments: state.segments.slice(-50) };A view of a fold without segments (a ContextFold, whatever the shape of its state: folds are told apart by their builder) has no per-segment counting. Pass measure(state), the tokens of the whole state (e.g. (state) => approxTokenCount(state)), and strategies of its read state type (FoldReadStateOf<typeof Fold>); without measure, ContextView.of(fold) is a type error that says so. Such a view has no count and split, dropped is always empty, and so fits is !overBudget. The built-in strategies are a type error there.
A view needs no cache of its own: the fold's state is cached, and the view's counter remembers the count of each segment (segments are immutable), so a call only counts the segments that are new.
Compacting with a view
For a segmented fold with a summary schema, compact also takes a policy: when to compact (the view does not fit), what to keep (split), and how to write the summary (your code):
const BUDGET = 30_000;
// In the model agent's logic (.folds([Conversation])): read, and compact first when needed.
const { state: conversation, compacted, summarized } = await folds.conversation.compact({
view: ChatView,
budget: BUDGET,
keepTokens: BUDGET / 2, // what a compaction keeps verbatim: the newest segments that fit
keepFrom: ContextStrategy.newest(), // and always the newest one, whatever its size
// `older` may be more than the budget: summarize it in chunks (see below and the full example).
summarize: ({ summary, older }) => layers.Llm.summarize(summary, older, signal),
});
const chat = ChatView(conversation, { budget: BUDGET });It reads the fold and compacts only when it helps: when view(state, { budget }) does not fit and view.split(state, { keepTokens, keepFrom }) leaves older segments. Then summarize({ summary, older, kept }) writes the new summary (of the earlier summary and the older segments; kept is context) and the new state is { summary, segments: kept }. It resolves to { compacted, state, summarized }: whether it compacted, the fold's state (after the compaction, or as read), and how many segments the summary replaced. So one call is the read too.
fits, notoverBudget. A view that dropped old segments to meet the budget is within it, but the model no longer sees part of the conversation: that is when a summary should take its place.fitsisfalsethen,overBudgetis not.- Keep less than the budget. With
keepTokenswell below the budget (half here), the summary and the next steps fit next to what was kept, so the next read fits again instead of compacting on every step. oldercomes in full: summarize it in chunks. The view decides whether the state fits after its strategies ran, with old tool outputs elided, butsummarizegets the older segments as they are, outputs in full. So in normal operation the first compaction of a tool-heavy conversation summarizes more than the budget: measured onexamples/m4trix-code's view, about 1.5 budgets at budgets of a few thousand tokens, about twice the budget at 100k. Don't sendolderto the model in one request. Cut it into chunks that fit one (view.count(segment)sizes a segment) and summarize them oldest first, each request getting the summary written so far, as the first one getssummary(see the full example). m4trix-code sends at most a budget of transcript per request (compactionChunks), with tool outputs cut to 1500 characters.- The cut and the checkpoint agree. The split is computed from the very state the checkpoint's
throughbelongs to, andsplitruns no strategy, so the kept segments are stored as they are (old tool outputs are not stored elided). Events published whilesummarizeruns are folded on top. - Only when compacting helps.
keepFrom: newest()forces only the newest segment, so a long current turn still compacts: its earlier steps, and its prompt, are summarized. Compacting cannot help when nothing is older than what a compaction keeps, e.g. the newest segment alone is over budget, or only a big summary is left besides the kept segments: thensplithas nothingolder, and the policy does not compact (without that guard it would compact on every step, each time only summarizing the summary again). The model gets the trimmed view as it is. - The summary may cover the current prompt. The view keeps the current turn (
fromNewest(Conversation, 'user')), but a compaction cuts by tokens: when the turn is long, its prompt ends up inolder. Have the summary carry the user's requests (e.g. quoted verbatim), since the model sees the prompt only through it from then on. - Invalid options reject: a
viewof another fold or asummarizethat is not a function with aTypeError, a budget orkeepTokensthe view rejects with aRangeError. A segmented fold without asummaryschema, and aContextFold, takecompact(next)only.
Caching and consistency
In a network, each event plane caches folded state, so reads are cheap, and they stay correct:
- Lazy. Nothing is folded on publish. A fold is brought up to date when an agent reads it, and only folds an agent declares are ever read.
- Incremental. A context-scoped state is cached per
(principal namespace, contextId, fold)with a cursor into the store. A read asks the store only for events after the cursor (history.context({ names, after })). A run-scoped state is cached per run and dropped when the run ends. - Up to date, not memoized. Every call reads again, so it includes the trigger event and everything stored before it, also what another agent published while you waited in
emitAndAwait. - Read-your-writes. A read also includes every event the invocation itself emitted before the read: with
emit,emitAndAwaitor its tools. Emits are stored asynchronously, so the read first waits until those are stored. It does not wait for other agents' emits, nor for transient events (never stored, so never folded), and a read with nothing pending starts right away. When one of them is never stored, because the run went over a limit, the store failed, or the run ended (or the network stopped) before it was published, the read rejects withAgentAbortedError, and so does every later read of the invocation.runStatusis the run's status ('cancelled'when the network stopped): the run is over by then, so stop as you would on an abortedsignal. - Single-flight. Concurrent reads of the same state, from one agent or several, run one at a time, each continuing from the state the previous one left, so an event is never folded twice.
- Stale cursors reset. When a context is deleted (
deleteContext) or expires in the store, a cached cursor no longer belongs to its history. The store returns the current history from its start withreset: true, and the state is rebuilt instead of continued. - Bounded. The
foldCachesetup option caps the cache per plane:{ maxEntries?: number /* 1000 */, ttl?: DurationInput /* '30 minutes' after the last read */ }, whereInfinitylifts either bound. Expiry is lazy, without timers. The bounds only cost a rebuild: an evicted state is folded again on its next read, from the newest checkpoint. - Read-only state. A read may hand out the cached state object itself, and several agents may hold it at once, so it is typed read-only (
FoldReadStateOf, see Reading folds in agents) and frozen: every read freezes the plain objects and arrays of the state it resolves to. Objects frozen by an earlier read (and stored payloads, frozen when they were appended) are not walked again, so a warm read only freezes what it added. A handler that writes to the state it gets fails the read with aTypeError; derive new values instead. Views and strategies never write to the state they get. - Identity is the fold object. Every agent given the same fold object shares its cached state; a different fold object has a state of its own, even with the same name and version. Define each fold once (e.g. export it from a module) and pass that object everywhere: a fold built again per agent, say by a factory function, is a different fold. Since checkpoints are stored by name and version, the wiring check fails setup with
fold-checkpoint-sharedwhen different compactable folds would share them, and warns withfold-name-sharedfor other folds that share a name (see Identity: one fold per name).
const network = AgentNetwork.setup(setup, {
foldCache: { maxEntries: 10_000, ttl: '1 hour' },
});Outside a network, agent.invoke({ history }) folds the given history on every read, without a cache. Without a history, each fold reads its initial state.
Stored events are decoded
The store keeps each durable event as a frozen JSON copy, its payload encoded with its event's schema (S.encode: a Date as an ISO string, S.NumberFromString as a string, an Option as { _tag, value }, a class as its fields). A read decodes every event with the schema of the event definition its handler was declared with, so handlers get typed values (a Date, an Option, a class instance) whatever the store does with the JSON: an in-memory store, or a database that hands back fresh objects, gives the same state.
- Cheap for plain schemas. A schema without transformations or declarations anywhere (
S.Structof strings, numbers, literals, arrays, refinements, ...) has the same encoded and decoded form: a payload exactly of its type is only validated, and the handler gets the stored payload itself. - Unknown fields are stripped. A stored payload with fields its schema does not know (one removed from the schema since the event was stored, or sent by a client) is decoded into a copy without them, for every schema. So the state never carries them, and a fold that keeps payloads in its state can still be compacted (a checkpoint encodes strictly).
- What JSON made of a value is what folds see. A value with a
toJSONmethod in anS.Unknownfield (aDatein a tool's database rows) is stored as whattoJSONreturns: a fold handler gets the ISO string, on a warm read and a cold one alike, while the agents that got the event live saw theDate. Give a value a schema (S.Date) where folds need it typed. The same holds for a checkpoint's state. - Deterministic. Decoding is a function of the stored payload, so warm and cold reads, on any store, fold equal state. Keep custom transformations (
S.transform) and defaults (S.optionalWith(..., { default })) pure: a default that reads the clock or a random number gives a warm read and a cold one different states. - Old history never breaks a read. A stored event whose payload no longer decodes (its schema changed since it was stored) is left out of the fold, as if it was not there, and reported: the network's
loggerwarns, and them4trix.fold.read.skippedmetric counts it, withfoldandreason: 'decode'. To keep old events in the fold, change schemas compatibly (new fields optional, or with a pure default,S.optionalWith(..., { default: () => 0 }); removed fields are stripped); for a breaking change, give the event a new name. A checkpoint that cannot be restored (reason: 'checkpoint') and an eventattachfinds no segment for (reason: 'missing-segment', see Segmented folds) are reported the same way. Each is reported once per event plane (by fold, reason and event id) while the plane remembers it: it keeps the last 1000 it reported, the least recently seen forgotten first. So a fold read again from the start does not warn again; another plane (e.g. after a restart) reports it once more. The wiring check warns at setup when an event name has different schemas (event-schema-mismatch): a payload stored with one may not decode with the one a fold uses.
Observability. The m4trix.fold.read.events metric reports the events each read folded, by fold and start: cold (nothing cached), warm (continued the cached state) or reset (rebuilt after a stale cursor). Many cold reads point at a foldCache that is too small for the traffic. m4trix.fold.read.skipped counts what reads left out (see Stored events are decoded). The wiring check warns with fold-event-unpublished when a folded event can never be published: no agent emits it and no channel that carries it is fed from outside, and with fold-name-shared when different folds share a name (the metric reports them as one).
Full example
A coding assistant, modeled on examples/m4trix-code. The model agent reads the conversation on every step, compacts it when the view no longer fits, and trims it with the view. A tools agent runs the tool calls.
import {
AgentFactory,
AgentNetwork,
AgentNetworkEvent,
ContextStrategy,
ContextView,
DependencyLayer,
type FoldStateOf,
S,
type Segment,
SegmentedFold,
} from '@m4trix/core';
/* ─── Events ─── */
const ToolCall = S.Struct({ id: S.String, name: S.String, arguments: S.String });
type ToolCall = typeof ToolCall.Type;
const UserPrompt = AgentNetworkEvent.of('user-prompt', S.Struct({ text: S.String }));
const ModelStep = AgentNetworkEvent.of(
'model-step',
S.Struct({ stepId: S.String, text: S.String, toolCalls: S.Array(ToolCall) }),
);
const ToolResult = AgentNetworkEvent.of(
'tool-result',
S.Struct({ stepId: S.String, callId: S.String, output: S.String }),
);
const ToolsDone = AgentNetworkEvent.of('tools-done', S.Struct({ stepId: S.String }));
const AssistantMessage = AgentNetworkEvent.of('assistant-message', S.Struct({ text: S.String }));
/** One segment: a prompt, a model step with its tool results, or a final answer. */
const Entry = S.Union(
S.Struct({ kind: S.Literal('user'), text: S.String }),
S.Struct({
kind: S.Literal('step'),
text: S.String,
toolCalls: S.Array(ToolCall),
results: S.Array(S.Struct({ callId: S.String, output: S.String })),
}),
S.Struct({ kind: S.Literal('answer'), text: S.String }),
);
type Entry = typeof Entry.Type;
/* ─── The fold and its view ─── */
const Conversation = SegmentedFold.of('conversation', {
data: Entry,
summary: S.String,
tag: 'kind',
})
.open(UserPrompt, ({ meta, payload }) => ({
id: `prompt:${meta.runId}`,
data: { kind: 'user', text: payload.text },
}))
.open(ModelStep, ({ payload }) => ({
id: payload.stepId,
data: { kind: 'step', text: payload.text, toolCalls: payload.toolCalls, results: [] },
}))
.attach(ToolResult, {
kind: 'step',
to: ({ payload }) => payload.stepId,
onMissing: 'ignore', // a late result of a compacted step is expected: not reported
update: (step, { payload }) => ({
...step,
results: [...step.results, { callId: payload.callId, output: payload.output }],
}),
})
.open(AssistantMessage, ({ meta, payload }) => ({
id: `answer:${meta.runId}`,
data: { kind: 'answer', text: payload.text },
}));
type Conversation = FoldStateOf<typeof Conversation>;
const BUDGET = 30_000;
const ChatView = ContextView.of(Conversation, {
strategies: [
ContextStrategy.elide({
keepNewest: 4,
map: (data) =>
data.kind === 'step'
? {
...data,
results: data.results.map((r) => ({ ...r, output: `${r.output.slice(0, 200)}… [elided]` })),
}
: data,
}),
// The current turn stays: from the newest user prompt on.
ContextStrategy.dropOldest({ keepFrom: ContextStrategy.fromNewest(Conversation, 'user') }),
],
});
/* ─── Rendering for a chat API ─── */
type ChatMessage =
| { role: 'system' | 'user' | 'assistant'; content: string }
| { role: 'assistant'; content: string; tool_calls: ReadonlyArray<ToolCall> }
| { role: 'tool'; tool_call_id: string; content: string };
function toMessages(state: Conversation): ChatMessage[] {
const messages: ChatMessage[] = [];
if (state.summary !== undefined) {
messages.push({ role: 'system', content: `Earlier in this conversation: ${state.summary}` });
}
for (const { data } of state.segments) {
if (data.kind === 'user') messages.push({ role: 'user', content: data.text });
else if (data.kind === 'answer') messages.push({ role: 'assistant', content: data.text });
else {
// A step and its results stay together: every tool call is answered.
messages.push({ role: 'assistant', content: data.text, tool_calls: data.toolCalls });
for (const call of data.toolCalls) {
const result = data.results.find((r) => r.callId === call.id);
messages.push({
role: 'tool',
tool_call_id: call.id,
content: result?.output ?? 'No result: the turn was interrupted.',
});
}
}
}
return messages;
}
/** Consecutive runs of segments of at most `tokens` each, as the view counts them (one at least). */
function chunksOf(segments: ReadonlyArray<Segment<Entry>>, tokens: number): Segment<Entry>[][] {
const chunks: Segment<Entry>[][] = [];
let current: Segment<Entry>[] = [];
let size = 0;
for (const segment of segments) {
const count = ChatView.count(segment);
if (current.length > 0 && size + count > tokens) {
chunks.push(current);
current = [];
size = 0;
}
current.push(segment);
size += count;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/* ─── Agents ─── */
const Llm = DependencyLayer.of({
name: 'Llm',
config: S.Struct({ model: S.String }),
}).define<{
complete(
messages: ChatMessage[],
signal: AbortSignal,
): Promise<{ text: string; toolCalls: ToolCall[] }>;
summarize(messages: ChatMessage[], signal: AbortSignal): Promise<string>;
}>();
/** One model call per invocation: on the prompt, then after each step's tool results. */
const model = AgentFactory.run()
.listensTo([UserPrompt, ToolsDone])
.emits([ModelStep, AssistantMessage])
.dependsOn(Llm)
.folds([Conversation])
.logic(async ({ folds, emit, layers, signal }) => {
// Reads the conversation (the trigger and every tool result before it included). When the
// view does not hold it all and compacting can help, the older segments (and an earlier
// summary) become one summary first; the newest that fit half the budget stay as they are.
const { state } = await folds.conversation.compact({
view: ChatView,
budget: BUDGET,
keepTokens: BUDGET / 2,
keepFrom: ContextStrategy.newest(),
// `older` has its tool outputs in full, often more than the budget: one request per chunk
// of at most a budget, each carrying the summary so far.
summarize: async ({ summary, older }) => {
let next = summary;
for (const chunk of chunksOf(older, BUDGET)) {
next = await layers.Llm.summarize(toMessages({ summary: next, segments: chunk }), signal);
}
return next ?? '';
},
});
const chat = ChatView(state, { budget: BUDGET });
const { text, toolCalls } = await layers.Llm.complete(toMessages(chat.state), signal);
if (toolCalls.length === 0) emit(AssistantMessage.make({ text }));
else emit(ModelStep.make({ stepId: crypto.randomUUID(), text, toolCalls }));
})
.produce({});
/** Your tool runner, e.g. a switch over `call.name`. */
declare function runTool(call: ToolCall): Promise<string>;
const tools = AgentFactory.run()
.listensTo([ModelStep])
.emits([ToolResult, ToolsDone])
.logic(async ({ triggerEvent, emit }) => {
const { stepId, toolCalls } = triggerEvent.payload;
for (const call of toolCalls) {
emit(ToolResult.make({ stepId, callId: call.id, output: await runTool(call) }));
}
emit(ToolsDone.make({ stepId })); // delivered after the results: the model sees them all
})
.produce({});
export const network = AgentNetwork.dependsOn(Llm).setup(
({ mainChannel, createChannel, proxy, registerAgent, endsOn }) => {
const client = createChannel('client').proxy(proxy.sse()); // the UI sees every step
for (const agent of [model, tools]) {
registerAgent(agent).subscribe(mainChannel).publishTo(mainChannel).publishTo(client);
}
endsOn(AssistantMessage);
},
{ foldCache: { maxEntries: 10_000, ttl: '1 hour' } },
);How it plays out:
- Every step reads incrementally.
Conversationis cached per conversation, so each model call folds only the events since the previous one: the new prompt, or the step and its tool results. The view then trims that state to the budget. - The budget holds without breaking tool calls. Old tool outputs shrink first. Then whole old segments go, while the current turn stays. A step never loses its results, so the chat API never sees an unanswered tool call.
- Compaction is a checkpoint. When the view had to drop segments (or the current turn alone is over budget), the model agent has everything but the newest segments that fit half the budget summarized before its model call, in requests of at most the budget each. The runtime stores that as the fold's checkpoint: from then on the conversation starts there, in this process and after a restart alike, and it fits the budget again. A tool result that lands while the summary is written is not lost: it is folded on top.
See Also
.folds(folds)in the AgentFactory reference- Event history and stores:
history.context({ names, after }),history.latest()and what a store must implement - Agents · Networks