LaserData Cloud
Laser SDKAdvanced

Context in depth

Context scopes, assembly policies, direct assembly controls, read windows, scoped memory, state folds, checkpoints, and snapshots

This page holds the full detail behind the Context guide. Read the guide first.

Open a context scope

laser.context(conversation_id) returns a context scope for one conversation. The call does no I/O. Every operation on the scope uses that conversation ID. A session is a conversation with a recorded lifecycle, and its ID is a conversation ID, so a scope reads a session's records too. session.scope() (TypeScript: the scope property) returns the scope of a session.

append(topic, payload) writes one message to a topic. The message carries the conversation ID in its provenance headers, and the conversation ID is also its partition key, so one conversation's messages on one topic share a partition.

fetch(topics, n) reads the last n messages of this conversation across the selected topics. Each result is a context message:

FieldRustTypeScriptPython
Log positionididid
Provenanceprovenanceprovenanceprovenance
Raw bodypayloadpayloadpayload
Decoded AGDX envelope, when presentenvelopeenvelopeenvelope (a dict)
Topic nametopictopictopic
Broker append time in microsecondstimestamp_microstimestampMicrostimestamp_micros
Numeric stream and topic IDsstream_id, topic_idstreamId, topicIdstream_id, topic_id

The numeric IDs let you build a source reference without another lookup.

A read collects the conversation's messages from each topic and sorts them by the timestamp Iggy assigns. Each topic has its own offsets, so the timestamp is the only clock shared across topics. Messages with the same timestamp are ordered by the position of their topic in the list you passed, then by partition, then by offset. A message whose provenance does not decode is skipped.

Policies

fetch limits a read by count. For other limits, pass an assembly policy to fetch_with(topics, policy) (TypeScript: fetchWith).

PolicyKeeps
LastN(n)The newest n messages
TokenBudget(n)The newest messages that fit n estimated tokens, and always at least one
RoleFilter(agents)Messages whose provenance names one of the given agents
Chain([...])The result of applying each policy in turn to the output of the one before

The default token estimate is the payload byte count divided by four, rounded up. Every SDK and the managed session index use this same estimate. To use your own tokenizer, pass an estimator: Rust TokenBudget::with_estimator(max, fn), TypeScript new TokenBudget(max, fn), and Python TokenBudget(max, estimator=fn).

fetch(topics, n) and block(topics, n) keep the last n messages and take an optional token budget applied after n, the same as Chain([LastN(n), TokenBudget(t)]). Rust passes the budget as an Option<usize> third argument and TypeScript as an optional third argument. Python takes topics, n, and token_budget as keywords, n defaults to 50, and without topics it reads agent.sessions.

Each built-in policy reports a name, a version, and a selection(history) with the kept and dropped messages and the reason. The names are last_n(n), token_budget(n), role_filter, and chain(..) with the inner names. A session records the name and version in the manifest of an assembled context.

Write your own policy

A policy takes the ordered history and returns the messages to keep. Each kept message still names its source topic.

LanguageShape
RustImplement ContextPolicy with select(&self, history: &[ContextMessage]) -> Vec<ContextMessage>. name defaults to custom and version to 1
TypeScriptAn object with select(history), and optionally name(), version(), and selection(history)
PythonA synchronous callable that takes the messages, or an object with a select(messages) method. It can set name and version attributes

This policy drops large messages before the count limit applies.

const small: ContextPolicy = {
  select: (history) => history.filter((message) => message.payload.byteLength < 2_000)
}

const turns = await ctx.fetchWith([AgentTopic.Sessions], new Chain([small, new LastN(20)]))
struct Small;

impl ContextPolicy for Small {
    fn select(&self, history: &[ContextMessage]) -> Vec<ContextMessage> {
        history
            .iter()
            .filter(|message| message.payload.len() < 2_000)
            .cloned()
            .collect()
    }
}

let turns = scope
    .fetch_with(
        vec![AgentTopic::Sessions],
        Box::new(Chain(vec![Box::new(Small), Box::new(LastN(20))])),
    )
    .await?;
def small(messages):
    return [message for message in messages if len(message.payload) < 2_000]


turns = await ctx.fetch_with([ls.AgentTopic.Sessions], ls.Chain([small, ls.LastN(20)]))

Assemble without a scope

ContextAssembler reads a conversation directly and exposes every read control. Rust and TypeScript use a builder and finish with .build().assemble(laser). Python's laser.assemble_context(conversation_id, ..) takes the same controls as keywords.

ControlRust builderTypeScript builderPython keyword
Conversation, required.conversation_id(id).conversationId(id)First argument
Topics, default agent.sessions.topics(vec).topics([..])topics=
Policy, default LastN(50).policy(Box<dyn ContextPolicy>).policy(policy)policy=
Include child conversations.across_subconversations(true).acrossSubconversations()across_subconversations=True
Start offsets per partition.from_offsets(map).fromOffsets(map)from_offsets=
Resume after a checkpoint.from_checkpoint(checkpoint).fromCheckpoint(checkpoint)from_checkpoint=
Stop at a checkpoint.to_checkpoint(checkpoint).toCheckpoint(checkpoint)to_checkpoint=

Child conversations are those whose parent or root conversation is this one. Start offsets are per partition, and one map applies to every topic. A checkpoint is per topic and per partition, and it overrides the start offsets. When resuming, a partition missing from the checkpoint starts at zero. When stopping, a missing partition reads as empty.

Python also takes the shorthand last_n=, roles=, and token_budget=. policy= replaces them, and combining policy= with any of them raises an invalid error. With roles= and no last_n=, Python applies no count limit. Otherwise last_n defaults to 50.

Read window

A context read does not walk an unlimited history. Each partition read examines at most CONTEXT_READ_WINDOW (10,000) raw records before it keeps this conversation's messages.

  • An open read takes the newest 10,000 records.
  • A read that stops at a checkpoint takes the 10,000 that end at the checkpoint.
  • A read that resumes from offsets or a checkpoint starts at the later of that position and the start of the newest 10,000.

Many conversations share a partition, so older messages of a quiet conversation on a busy partition can fall outside the read. All three SDKs use the same window. State folds are different: every replay bound except the last-N bound reads its whole range, see Fold state from the log.

The scope reaches every primitive

One scope also gives you:

  • block(topics, n, token_budget), the last n messages as one newline-joined, prompt-ready string, with the same optional token budget as fetch. Python takes block(topics=, n=, token_budget=).
  • memory(..), this conversation's memory. See Scoped memory.
  • graph(name), the shared knowledge graph. It is not narrowed to the conversation, because a graph holds relationships shared across conversations. In Rust, it needs the graph feature.
  • state(topics, bound, init, fold), state rebuilt from the log. See Fold state from the log.
  • checkpoint(topics), where the topics end now. Python's topics defaults to agent.sessions. context::checkpoint(&laser, &topics) in Rust, context_checkpoint(laser, topics) in Python, and contextCheckpoint(laser, topics) in TypeScript record the same without a scope.

Scoped memory

scope.memory(..) returns a scoped memory, a memory handle with the conversation already applied, so remember and recall take no conversation argument. Rust takes a namespace, and memory_with(namespace, backend) picks the backend. Python takes a namespace or a memory handle, and memory_with(namespace, backend, embedder=) picks the backend. TypeScript takes a namespace or a memory handle, such as laser.memory("notes") or a vector handle, and memoryWith(namespace, backend, embedder?) picks the backend.

All three SDKs support remember, recall, search, block(token_budget), consolidate(max_items), forget(id), and improve(..).

VerbRustTypeScriptPython
Rememberremember(bytes).send()remember(bytes).send()remember(payload) returns the ID
Recallrecall() builder, then fetch()recall() builder, then fetch()recall(limit=, folded=, ..)
Keyword searchsearch(query) builder, then .limit(n), .folded(), and .awaitsearch(query, { limit, folded })search(query, limit=, folded=)
Feedbackimprove(Feedback::new(id, weight))improve({ target, weight })improve(target, weight, note=)
Consolidateconsolidate(max_items), or consolidate_with(max_items, summarizer, prune_summarized)consolidate(maxItems, { summarizer, pruneSummarized })consolidate(max_items, summarizer=, prune_summarized=)

search is a keyword recall. It needs no embedder, returns at most 50 items unless you set a limit, and reads the managed view unless you fold. The handle accessor returns the underlying memory for reads across conversations, and unscoped laser.memory(..) reads across conversations too. Default recall reads the managed key-value view on Laser Stack or LaserData Cloud. Folded recall rebuilds memory from the topic and works on plain Apache Iggy. Memory covers recall strategies, backends, and consolidation.

This example remembers a note in the conversation, recalls it with folded recall, gives it feedback, and forgets it.

const notes = laser.context(conversation).memory("notes")

const id = await notes.remember(new TextEncoder().encode("drain node-7 before the upgrade")).send()
const hits = await notes.recall().limit(5).folded().fetch()

await notes.improve({ target: id, weight: 1 })
await notes.forget(id)
let notes = laser.context(conversation).memory("notes");

let id = notes
    .remember("drain node-7 before the upgrade".as_bytes())
    .send()
    .await?;
let hits = notes.recall().limit(5).folded().fetch().await?;

notes.improve(Feedback::new(id, 1.0)).await?;
notes.forget(id).await?;
notes = laser.context(conversation).memory("notes")

note_id = await notes.remember("drain node-7 before the upgrade")
hits = await notes.recall(limit=5, folded=True)

await notes.improve(note_id, 1.0)
await notes.forget(note_id)

Fold state from the log

scope.state(topics, bound, init, fold) folds the conversation's messages into your own state under a replay bound.

BoundRust ReplayBoundTypeScriptPython keywordReads
Last N messagesLast(n){ kind: "last", count }last_n=The newest 10,000 records of each partition
From offsetsFromOffsets(map){ kind: "from-offsets", offsets }from_offsets=From the offsets to the tail
After a checkpointFromCheckpoint(cp){ kind: "from-checkpoint", checkpoint }from_checkpoint=From the checkpoint to the tail
Up to a checkpointAt(cp){ kind: "at", checkpoint }at=From the start to the checkpoint
Whole partitionFull{ kind: "full" }full=TrueEverything

Python takes exactly one bound keyword. For state folds, the offsets are grouped by topic name, unlike the single map that ContextAssembler takes, so one topic's offsets never skip records in another topic.

ConversationState.load(laser, conversation, topics, bound, init, fold) runs the same fold without a scope. Rust calls ConversationState::load, TypeScript ConversationState.load, and Python ConversationState.load(laser, conversation, topics, init, fold, *, ..) with the bound as a keyword.

scope.checkpoint(topics) records the next offset of each partition of each topic. A checkpoint covers whole partitions, so other conversations on those partitions move its offsets too. It is a client bookmark, never a record on the log. Persist it with serde in Rust, JSON.stringify and Checkpoint.fromJSON in TypeScript, and to_json and Checkpoint.from_json in Python.

This example records a checkpoint, appends one more message, and counts the conversation's messages after the checkpoint.

const topics = [AgentTopic.Sessions]
const checkpoint = await ctx.checkpoint(topics)

await ctx.append(AgentTopic.Sessions, new TextEncoder().encode("drain node-9"))

const since = await ctx.state(topics, { kind: "from-checkpoint", checkpoint }, 0, (count) => count + 1)
let topics = vec![AgentTopic::Sessions];
let checkpoint = scope.checkpoint(&topics).await?;

scope.append(AgentTopic::Sessions, "drain node-9".as_bytes()).await?;

let since = scope
    .state(topics, ReplayBound::FromCheckpoint(checkpoint), 0usize, |count, _message| count + 1)
    .await?;
topics = [ls.AgentTopic.Sessions]
checkpoint = await ctx.checkpoint(topics)

await ctx.append(ls.AgentTopic.Sessions, b"drain node-9")

since = await ctx.state(topics, 0, lambda count, message: count + 1, from_checkpoint=checkpoint)

Snapshots

A snapshot saves folded state at a checkpoint, so a later fold replays only the records after it. state_with(store, topics, init, fold) (TypeScript: stateWith) starts from the store's latest snapshot and folds only the records after it. A conversation with no snapshot folds fully from init. ConversationState.load_with(laser, store, conversation, topics, init, fold) (TypeScript: loadWith) does the same without a scope. Rust and Python decode the saved state as JSON. TypeScript decodes JSON by default and takes an optional decodeState function as the last argument.

To save one, build it with snapshot_from_checkpoint(laser, conversation, fold, checkpoint, state) (TypeScript: snapshotFromCheckpoint) and pass it to the store's save. The state is the folded value encoded as JSON bytes.

A snapshot stores its stream name, stream ID, and stream creation time, the conversation, and the fold name. It stores inclusive offsets for each topic ID, topic creation time, and partition. Before a fold uses a snapshot, it checks those creation times. When the stream or a topic was recreated, the fold refuses the snapshot with an invalid error.

Two built-in stores keep snapshots under agent.snapshots by default. TopicSnapshotStore keeps them on a topic and works on plain Apache Iggy. KvSnapshotStore keeps them in the managed key-value view and needs Laser Stack or LaserData Cloud.

StoreTypeScriptRustPython
Topic, default namenew TopicSnapshotStore(laser, fold)TopicSnapshotStore::new(laser, fold)TopicSnapshotStore(laser, fold)
Topic, your namenew TopicSnapshotStore(laser, fold, topic)TopicSnapshotStore::on_topic(laser, topic, fold)TopicSnapshotStore.on_topic(laser, topic, fold)
Key-value, default namespacenew KvSnapshotStore(laser, fold)KvSnapshotStore::new(laser, fold)KvSnapshotStore(laser, fold)
Key-value, your namespacenew KvSnapshotStore(laser, fold, namespace)KvSnapshotStore::in_namespace(laser, namespace, fold)KvSnapshotStore.in_namespace(laser, namespace, fold)

A custom store implements latest(conversation) and save(snapshot). Python accepts the backend directly or wraps it with SnapshotStore(backend), and its methods can return directly or through an awaitable.

Rust snapshot::encode and decode, Python encode_snapshot and decode_snapshot, and TypeScript encodeSnapshot and decodeSnapshot share the stored CBOR bytes. resume_offsets (TypeScript: resumeOffsets) converts inclusive snapshot offsets to next-read offsets and stops at the unsigned 64-bit maximum. checkpoint_from_snapshot (TypeScript: checkpointFromSnapshot) turns a snapshot into a checkpoint after the same creation time checks.

Where it runs

Context scopes, policies, assembly, state folds, checkpoints, and topic snapshots use ordinary Iggy topics and need no managed backend. Run bootstrap(partitions, retention) once per stream to create the agent topics, and give agent.sessions an explicit retention. In Rust, context needs the agent feature. Default recall through scope.memory(..) and KvSnapshotStore need the managed key-value view on Laser Stack or LaserData Cloud.

Key operations

VerbWhat it does
context(conversation_id)Scope everything below to one conversation
append(topic, payload)Write one message under this conversation
fetch(topics, n)Read the last n messages. Python: fetch(topics=, n=, token_budget=)
fetch_with(topics, policy)Read under a policy
block(topics, n, token_budget)The last n messages as one prompt-ready string
LastN, TokenBudget, RoleFilter, ChainThe built-in policies
ContextAssemblerRead a conversation with offsets, checkpoints, and child conversations. Python: assemble_context
memory(..) / graph(name)This conversation's memory, and the shared graph
state(topics, bound, init, fold)Fold the conversation's log into your own state
state_with(store, ..)The same fold seeded from a snapshot
ConversationState.load / load_withThe same folds without a scope
checkpoint(topics)Save the next offset of each partition
TopicSnapshotStore / KvSnapshotStoreThe built-in snapshot stores

TypeScript uses camelCase names, for example fetchWith, stateWith, and loadWith.

On this page