Start with a Practical Question
Your Agent crashes halfway through a run. After restarting, is the conversation history still there?
Or: you want to "go back in time" to a specific turn and try a different path from there — does dsh support that?
Both questions point to the same design: Session.
What a Session Actually Is
Many people assume a Session is just "save the message list somewhere." dsh's Session is not designed that way.
A dsh Session is an append-only log of typed events.
┌──────────────────────────────────────────────────────────┐
│ Session Log (seq = monotonic position, starts at 0) │
│ │
│ seq=0: turn/start { turn: 1 } │
│ seq=1: user/message { role: 'user', ... } │
│ seq=2: system/message { message: {...} } │
│ seq=3: request/header { header: {...} } │
│ seq=4: assistant/message { message: {...} } │
│ seq=5: tool/call { name: 'read_file', ... } │
│ seq=6: tool/result { message: {...} } │
│ seq=7: assistant/message { message: {...} } │
│ seq=8: turn/end { reason: { kind: 'completed' } } │
│ │
│ Append-only: existing entries are never modified │
└──────────────────────────────────────────────────────────┘
The model's "message history" is not stored separately — it's derived from this log.
Why the extra indirection? Because an append-only log gives you three things for free:
- Crash safety: when a process dies, already-written events are intact — no partial writes, no dirty state
- Replayability: replaying the same log always produces the identical derived history, on any machine
-
Full audit trail: failed attempts (
assistant/attempt) stay in the log forever — they just don't enter model history
This pattern is called Event Sourcing in software engineering. If you've used Git, you already understand the idea: each commit only adds a new record, the history only grows, never changes.
The Event Vocabulary: What's Inside a Session
The Session log is composed of typed SessionEvent entries, each with a fixed structure:
// packages/core/session/src/types.ts
type SessionEvent<T extends SessionEventType = SessionEventType> = {
// Type (e.g. 'turn/start', 'assistant/message')
type: T
// Monotonic position in this Session (= log.length, always contiguous)
seq: SessionSeq
// Unix epoch milliseconds when appended
time: number
// Event payload (must be JSON-serializable)
data: SessionEventMap[T]
// Optional: readers may skip this event if they don't recognize its type
ignorable?: true
}
The core event types at a glance:
| Event Type | Meaning |
|---|---|
turn/start |
A Turn opens |
turn/end |
A Turn closes, with an end reason |
step/start |
A Step opens |
step/end |
A Step closes |
user/message |
User input (or injected context) |
system/message |
Rendered system prompt |
assistant/message |
Successful model output (enters derived history) |
assistant/attempt |
Model attempt that failed (does NOT enter derived history) |
tool/call |
Model requests a tool call |
tool/result |
Tool execution result |
request/header |
Snapshot of request config (model, token limits, etc.) |
The Surface: Where Derived History Comes From
Of all events in the Session log, only 4 event types produce LLM messages. These are called SurfaceEventType:
system/messageuser/messageassistant/messagetool/result
These four make up the "Surface" — think of it as the subset that "surfaces" to the model. Everything else (turn/start, step/end, assistant/attempt, etc.) is structural log metadata that produces no LLM messages.
Every Surface event carries a surfaceOp marker declaring how it joined the ordered queue:
type SurfaceOp =
// Append to the tail — the normal path for all messages
| 'append'
// Replace nodes from startSeq through endSeq (used during compaction)
| { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq }
In normal usage, everything is 'append'. The replace op is used during compaction — when a conversation gets too long, a range of earlier messages is summarized into a single replacement message to save tokens.
deriveMessages(): How History Is Reconstructed
Session.deriveMessages() is the core method that rebuilds the message list from the log:
// packages/core/session/src/index.ts (simplified)
class Session {
/**
* Derive the LLM message history by walking the ordered Surface events.
*
* Cached: each surface node is projected exactly once; a surface replace
* triggers a rebuild. Each call returns a new array, but the Message
* objects inside are shared, deep-frozen references.
*/
deriveMessages(): Message[] {
// Walk surface.nodes (ordered list of surface event seq numbers)
// Call deriveEventMessage(event) on each
// Return results that are not null
}
}
The projection rule (deriveEventMessage) is straightforward:
// packages/core/session/src/surface.ts
export function deriveEventMessage(event: SessionEvent): Message | null {
switch (event.type) {
case 'user/message':
// Project verbatim as a user-role message
return event.data
case 'system/message':
case 'assistant/message':
// Empty content → null (an empty turn must not appear in the transcript)
if (event.data.message.content.length === 0) return null
return event.data.message
case 'tool/result':
// Project as a user message with a tool-result block
return event.data.message
default:
// Turn/step boundaries, assistant/attempt, log-only events → no message
return null
}
}
Two edge cases worth calling out:
Empty-content assistant/message: This happens when a step is cut short by max-tokens before the model produced any output. The event is still written to the log (to record token usage and the stream) but projects to null — otherwise the model would see a blank assistant turn in its history, which would confuse it.
assistant/attempt is completely invisible to projection: No matter what went wrong (network error, context overflow, cancellation), a failed attempt only touches the log. The model's next request sees a clean state, as if the failed attempt never happened.
assistant/message vs assistant/attempt: Going Deeper
Article 04 (Agent Loop) introduced these two events. Here's the same distinction from the Session perspective:
assistant/message |
assistant/attempt |
|
|---|---|---|
| When written | Model completes output successfully | Network error / context overflow / cancel / stream error |
| Surface event | ✅ Has surfaceOp, enters derived history |
❌ No surfaceOp, log-only |
| Model sees on next request | ✅ Yes | ❌ No |
| Stays in the log | ✅ | ✅ (for audit and usage accounting) |
Why keep assistant/attempt in the log at all?
Because failed attempts still cost tokens. Even when the model returned nothing usable, the provider likely billed for the input tokens. Keeping attempts in the log lets you account for real token spend — including the failed requests — rather than only counting successful ones.
Session Header: Format Version and Metadata
Every Session has a SessionHeader stored outside the event log:
// packages/core/session/src/types.ts (simplified)
interface SessionHeader {
// Format version — currently 3
readonly version: typeof SESSION_FORMAT_VERSION // = 3
// Unique Session ID
readonly id: SessionId
// When the Session was created (Unix epoch milliseconds)
readonly createdAt: number
// Working directory the Session lives in
readonly cwd?: string
// Which Session this was forked from, if any
readonly parentSession?: SessionId
// Whether this Session contains an inherited fork prefix
readonly isSeeded: boolean
// For subagent children: delegation depth (guards against infinite recursion)
readonly delegationDepth?: number
}
// Current format version
export const SESSION_FORMAT_VERSION = 3
The format version enables migration. dsh Session logs live on disk and may be read by different versions of dsh. SESSION_FORMAT_VERSION ensures that:
- Old-format logs are upgraded to the current format through a migration chain before use
- A completely unknown version is rejected outright — dsh never silently reads a log wrong
This is the same idea as SQLite's PRAGMA user_version or Git's object format versioning.
Fork: Branch from Any History Point
This is one of the most interesting capabilities in dsh Sessions.
Imagine a conversation that's gone 10 turns. At turn 7, the model took a path you don't like. You want to rewind to the state at the end of turn 7 and try a different approach.
In dsh, that's called a Session Fork:
// packages/core/session/src/index.ts (SessionStore method)
/**
* Create a live child session from a stable prefix of a live source.
*
* @param source - Live source session object or ID
* @param boundary - Optional inclusive source event seq to fork through;
* defaults to the source's current last event.
* The selected prefix must not end inside an open Turn.
* @param childSessionId - Optional child session ID
*/
fork(
source: SessionForkSource,
boundary?: SessionSeq,
childSessionId?: SessionId,
): Session
What Fork does:
- Copies all events from the source Session up to and including
boundaryas the child's "inherited prefix" - The child Session tracks its inherited event count (
inheritedEventCount) - Subsequent writes to the child do not affect the source
- A
session/end-seedevent marks the boundary between inherited and live history
Source: [turn1][turn2][turn3][turn4]...[turn10]
↑ boundary
After Fork:
Source: continues...
Child: [turn1][turn2][turn3] ← inherited, immutable
[new turn4]... ← child-owned
A practical use case: have an Agent explore multiple solution paths in parallel, each as a fork — then compare results without wasting duplicate context by opening completely separate conversations.
Crash Recovery: The interrupted End Reason
If a process crashes in the middle of a Turn, the Session log has an open turn/start with no matching turn/end.
When dsh loads this Session next time, it detects the orphaned turn and synthesizes a close:
// One of the TurnEndReason variants
{ kind: 'interrupted' }
This is the only turn/end reason that the loop never emits during normal operation — it's exclusively synthesized by the persistence layer during crash recovery.
After synthesizing this turn/end, the Session is in a consistent, continuable state. All events written before the crash are intact and the Session can be resumed normally.
Walkthrough: Loading a Historical Session to Pass Memory Across Conversations
Here's a practical plugin that injects a summary from the previous Session at the end of each Turn:
// Cross-session memory plugin (pseudocode, illustrating core logic)
export const name = 'cross-session-memory'
export const inject = ['sessions']
export function apply(ctx: Context): void {
ctx.on('agent/turn-stopping', async (payload) => {
const { agent } = payload
// Find the previous Session (in practice, query from persistent storage)
const prevSessionId = await getPreviousSessionId()
if (!prevSessionId) return
// Load the previous Session's events
const prevSession = await loadSession(prevSessionId)
// Derive message history from the event log
const prevMessages = prevSession.deriveMessages()
// Extract the last few turns as a memory summary
const summary = buildSummary(prevMessages.slice(-10))
// Inject the summary into the current Session's next step
agent.inject({
type: 'user',
content: [{ type: 'text', text: `[Memory] ${summary}` }],
})
})
}
The key point: deriveMessages() is a pure function — given the same event log, the result is always identical. You can reconstruct history from any Session at any time, without a separate "history storage layer."
The Full Session Lifecycle
ctx.sessions.create()
↓
session/created (notifies listeners)
↓
[agent loop runs Turns]
↓
Events appended: turn/start → ... → turn/end
↓
session/flush (persistence layer writes buffered events to disk)
↓
[Agent becomes idle or task completes]
↓
session/disposed (Session removed from the store)
Persistence is handled by plugins (session/flush listeners), not the core Session class itself — which only manages the in-memory event log and derivation logic. This design means the persistence backend can be swapped (JSONL files, SQLite, remote API) without changing any Session semantics.
Design Principles Summary
dsh's entire Session design can be summed up in one sentence:
The log is the single source of truth; history is derived from the log.
| Design Decision | Reason |
|---|---|
| Append-only log | Crash safety + replayability + complete audit trail |
assistant/attempt excluded from history |
Failed attempts don't pollute the model's context |
deriveMessages() is a cached pure function |
Multiple reads incur no extra cost; replayed results are identical |
| Format version number | Supports log migration without silently misreading old formats |
| Fork API | Branch from any stable turn boundary without affecting the source |
| Persistence handled by plugins | Core decoupled from storage backend |
What's Next in the Series
The next article on Profile and Bundle: dsh's Configuration Assembly System covers how dsh assembles a complete Agent runtime from config files — what a Bundle is, what a Profile is, and how you can use this mechanism to compose different Agent configurations for different tasks.
Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.
Find more on my Homepage
Top comments (0)