Skip to content

Core 2: Session Event Log

Official counterpart: core/session — "the session log is the single source of truth for the model's context"

Core Idea: What the Model Sees Must Be in the Log

The biggest difference between an agent and a plain chat is process complexity: the model requests multiple times, calls tools, gets results, and continues. If we only kept the "final conversation", we could never answer:

  • What exactly did the model see? (audit)
  • What did the tool return at each step? (replay)
  • How do we resume after an interruption? (recovery)
  • How does the UI render streaming output? (fidelity)

The official answer: everything is based on an append-only event log. Anything that enters a model request must first be written as a session event; the model's message history (deriveMessages()) is projected from the log, not maintained separately.

text
user/message ──► assistant/message(tool_calls) ──► tool/result ──► assistant/message
      └──────────────── log (ground truth) ──────────────────────┘

                              ▼ deriveMessages()
                   the messages array the model actually sees

Event Types

Event typeDurableRole
turn/start / turn/endBoundaries of one turn
step/start / step/endBoundaries of one step (a request + its tools)
user/messageUser input, enters the model context
assistant/chunkStreaming deltas (replay/UI fidelity)
assistant/messageComplete assistant message (incl. tool_calls), enters the context
tool/resultTool result, enters the context

Why keep assistant/chunk?

assistant/message is the final result; chunk is the process. Keeping chunks in the log lets the UI replay exactly how the text was generated. The official calls this "preserving replay and UI fidelity".

Implementation

Append-only log

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

Key points:

  • append() adds seq and ts automatically; events are immutable — that's "append-only"
  • deriveMessages() cares about only the three message events and projects them into an array the model can consume directly

Message projection

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

Note the assistant/message projection: include tool_calls when present; tool/result pairs with the assistant's tool call via tool_call_id — a hard requirement of the OpenAI-compatible protocol, order must not change:

ts
// what the model sees (strict order)
[
  { role: "user", content: "What is 1+1?" },
  { role: "assistant", content: "", tool_calls: [{ id: "call_1", function: { name: "run_bash", arguments: "..." } }] },
  { role: "tool", tool_call_id: "call_1", content: "2" },
  { role: "assistant", content: "The answer is 2." },
]

Persistence: JSONL

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

One JSON event per line, session metadata (id) on the first line. An append-only log fits JSONL naturally: resume = read line by line, persist = append.

Session registry

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

Corresponds to the official ctx.sessions — manages multiple sessions (multi-session web UI, resume-after-interrupt).

A Real Session Log

This is the actual output of pnpm run demo (scripted demo):

text
turn/start           {"agent":"5a1b896f-beb"}
user/message         {"content":"1+1 等于多少?用工具算一下"}
step/start           {"step":0}
assistant/chunk      {"delta":{"content":"让我先算"}}
assistant/chunk      {"delta":{"content":"一下。"}}
assistant/message    {"content":"让我先算一下。","tool_calls":[{"id":"call_1",...}]}
tool/result          {"tool_call_id":"call_1","content":"2"}
step/end             {"step":0,"tool_calls":1}
step/start           {"step":1}
assistant/chunk      {"delta":{"content":"计算完成"}}
assistant/chunk      {"delta":{"content":":1+1"}}
assistant/chunk      {"delta":{"content":"=2。"}}
assistant/message    {"content":"计算完成:1+1=2。","tool_calls":[]}
step/end             {"step":1,"tool_calls":0}
turn/end             {}

The "2" in tool/result is not fabricated — it's the real result of run_bash executing echo 1+1 | bc.

An engineering iron rule

Any new input entering the model context (e.g. skill injection in a later chapter) must first go into the log, then into the request. Bypassing the log corrupts replay and audit. The official enforces this with runtime invariants; we rely on discipline.

Test Coverage

ts
it("deriveMessages correctly projects model history", () => {
  // ...build the log...
  expect(messages).toEqual([...]); // assert order and fields one by one
});

it("non-message events (step/start etc.) never enter the model context", () => {
  // turn/start, step/start, assistant/chunk are all filtered
  expect(roles).toEqual(["user", "assistant"]);
});

Recap

  • The log is the single source of truth; context is projected from it
  • Message events user/message / assistant/message / tool/result enter the model; everything else serves replay and audit
  • JSONL persistence makes sessions resumable and replayable

Next: LLM Adapter Seam →

基于 MIT 许可的 deepseek-ai/deepseek-harness 设计理念 · 本教程为独立教学项目,与 DeepSeek 官方无隶属关系