DeepSeek Harness Series (05): Sessions and Memory — How Conversation History Survives

What happens when your Agent crashes mid-run? Does history survive a process restart? Can you rewind to an earlier step and try a different path? This article unpacks dsh's Session design: the append-only log, derived history, format versioning, the Fork API, and why the model never sees dirty failed attempts.

·10 min read·AI Engineering

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:

  1. Crash safety: when a process dies, already-written events are intact — no partial writes, no dirty state
  2. Replayability: replaying the same log always produces the identical derived history, on any machine
  3. 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 TypeMeaning
turn/startA Turn opens
turn/endA Turn closes, with an end reason
step/startA Step opens
step/endA Step closes
user/messageUser input (or injected context)
system/messageRendered system prompt
assistant/messageSuccessful model output (enters derived history)
assistant/attemptModel attempt that failed (does NOT enter derived history)
tool/callModel requests a tool call
tool/resultTool execution result
request/headerSnapshot 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/message
  • user/message
  • assistant/message
  • tool/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/messageassistant/attempt
When writtenModel completes output successfullyNetwork 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:

  1. Copies all events from the source Session up to and including boundary as the child's "inherited prefix"
  2. The child Session tracks its inherited event count (inheritedEventCount)
  3. Subsequent writes to the child do not affect the source
  4. A session/end-seed event 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 DecisionReason
Append-only logCrash safety + replayability + complete audit trail
assistant/attempt excluded from historyFailed attempts don't pollute the model's context
deriveMessages() is a cached pure functionMultiple reads incur no extra cost; replayed results are identical
Format version numberSupports log migration without silently misreading old formats
Fork APIBranch from any stable turn boundary without affecting the source
Persistence handled by pluginsCore 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