DEV Community

Cover image for DeepSeek Harness Series (05): Sessions and Memory — How Conversation History Survives
WonderLab
WonderLab

Posted on

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

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        │
└──────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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/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 }
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

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}` }],
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)