Files
pi_harness/packages/agent/learning/04-SESSION-ARCHITECTURE.md
2026-07-29 10:59:18 +07:00

16 KiB

Session Architecture

Overview

The session system provides persistent, branchable conversation history. It's the storage layer that enables:

  • Conversation persistence across restarts
  • Branching to earlier points in conversation
  • Context window compaction
  • Session tree navigation

Core Concepts

1. SessionTreeEntry

The fundamental unit of session history:

type SessionTreeEntry =
  | MessageEntry
  | ModelChangeEntry
  | ThinkingLevelChangeEntry
  | ActiveToolsChangeEntry
  | CompactionEntry
  | BranchSummaryEntry
  | CustomEntry
  | CustomMessageEntry
  | LabelEntry
  | LeafEntry
  | SessionInfoEntry;

Key properties:

  • id: Unique identifier (UUID v7)
  • parentId: Points to parent entry (forms tree structure)
  • timestamp: ISO 8601 string

2. Tree Structure

Entry tree (simplified):
    
    root (parentId: null)
      ├─► message (user #1) [id: 1]
      │   └─► message (assistant #1) [id: 2]
      │       └─► tool_result [id: 3]
      │           └─► message (user #2) [id: 4]
      │               └─► compaction [id: 5]  ← New root for future
      │                   ├─► retained messages here
      │                   └─► message (assistant #2) [id: 6]
      │                       └─► message (user #3) [id: 7]
      │                           └─► leaf [id: 8]  ← Current head
      │
      └─► branch_summary [id: 9]  ← Point where branch was created
          └─► message (user #4) [id: 10]
              └─► message (assistant #4) [id: 11]
                  └─► leaf [id: 12]

3. Context Building

Context = Current state needed for LLM call:

interface SessionContext {
  systemPrompt: string;
  messages: AgentMessage[];
  thinkingLevel: ThinkingLevel;
  model: { provider: string; modelId: string } | null;
  activeToolNames: string[] | null;
}

Building context involves:

  1. Tracing from leaf to root (path entries)
  2. Applying transforms (compaction, etc.)
  3. Projecting entries to messages
  4. Deriving state (model, thinking level, active tools)

Session Storage Interface

SessionStorage<TMetadata>

interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
  // Metadata
  readonly id: string;
  readonly metadata: TMetadata;
  
  // Entry operations
  getLeafId(): Promise<string | null>;
  setLeafId(id: string): Promise<void>;
  getEntry(id: string): Promise<SessionTreeEntry | undefined>;
  getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]>;
  getBranch(): Promise<SessionTreeEntry[]>;
  
  // Write operations
  appendEntry(entry: SessionTreeEntry): Promise<string>;
  
  // Branch operations
  fork(targetId: string): Promise<SessionStorage>;
  delete(): Promise<void>;
  
  // Cleanup
  cleanup(): Promise<void>;
}

Built-in Implementations

MemoryStorage

class MemoryStorage<TMetadata> implements SessionStorage<TMetadata> {
  // In-memory storage using Map
  // Good for: Testing, short-lived sessions
  // Not good for: Persistence across runs
}

JSONLStorage

class JSONLStorage<TMetadata> implements SessionStorage<TMetadata> {
  // File-based storage using JSONL format
  // One file per entry: entries/{id}.json
  // Metadata file: metadata.json
  
  // Good for: Development, local sessions
  // Not good for: High-concurrency, production
  
  // File structure:
  // session/
  //   metadata.json
  //   entries/
  //     {id1}.json
  //     {id2}.json
  //     ...
}

Session Class

Session<TMetadata>

High-level session API built on storage:

class Session<TMetadata extends SessionMetadata = SessionMetadata> {
  // Metadata
  readonly id: string;
  readonly storage: SessionStorage<TMetadata>;
  
  // Read operations
  getMetadata(): Promise<TMetadata>;
  getLeafId(): Promise<string>;
  getEntry(id: string): Promise<SessionTreeEntry | undefined>;
  getBranch(): Promise<SessionTreeEntry[]>;
  buildContext(options?: SessionContextBuildOptions): Promise<SessionContext>;
  
  // Write operations
  appendMessage(message: AgentMessage): Promise<string>;
  appendModelChange(provider: string, modelId: string): Promise<string>;
  appendThinkingLevelChange(thinkingLevel: ThinkingLevel): Promise<string>;
  appendActiveToolsChange(activeToolNames: string[]): Promise<string>;
  appendCompaction(...): Promise<string>;
  appendBranchSummary(...): Promise<string>;
  appendCustomEntry(customType: string, data: unknown): Promise<string>;
  appendCustomMessageEntry(...): Promise<string>;
  appendLabel(targetId: string, label: string): Promise<void>;
  appendSessionName(name: string): Promise<string>;
  
  // Branch operations
  fork(targetId: string): Promise<Session>;
  delete(): Promise<void>;
}

Context Building Details

Path Tracing

Goal: Get all entries from leaf to root.

async function getPathEntries(session: Session): Promise<SessionTreeEntry[]> {
  const path: SessionTreeEntry[] = [];
  let currentId = await session.getLeafId();
  
  while (currentId !== null) {
    const entry = await session.getEntry(currentId);
    if (!entry) break;
    
    path.unshift(entry);
    currentId = entry.parentId;
  }
  
  return path;
}

Default Transform

Purpose: Apply compaction logic to context.

function defaultContextEntryTransform(
  pathEntries: readonly SessionTreeEntry[]
): SessionTreeEntry[] {
  let compaction: CompactionEntry | null = null;
  for (const entry of pathEntries) {
    if (entry.type === "compaction") {
      compaction = entry;
    }
  }
  
  if (!compaction) {
    return [...pathEntries];  // No compaction
  }
  
  // Compaction retains either:
  // 1. All entries after compaction (retainedTail)
  // 2. Entries from firstKeptEntryId to compaction (inclusive)
  
  const entries: SessionTreeEntry[] = [compaction];
  const compactionIdx = pathEntries.findIndex(e => e.id === compaction.id);
  
  if (compaction.retainedTail) {
    // Include everything after compaction
    for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
      entries.push(pathEntries[i]!);
    }
    return entries;
  }
  
  if (compaction.firstKeptEntryId) {
    // Include entries from firstKeptEntryId to compaction
    let foundFirstKept = false;
    for (let i = compactionIdx - 1; i >= 0; i--) {
      const entry = pathEntries[i]!;
      if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
      if (foundFirstKept) entries.unshift(entry);
    }
  }
  
  // Always include entries after compaction
  for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
    entries.push(pathEntries[i]!);
  }
  
  return entries;
}

Entry to Message Projection

function sessionEntryToContextMessages(
  entry: SessionTreeEntry,
  index: number,
  entries: readonly SessionTreeEntry[],
  options: SessionContextBuildOptions = {}
): AgentMessage[] {
  if (entry.type === "message") {
    return [entry.message as AgentMessage];
  }
  
  if (entry.type === "custom_message") {
    return [createCustomMessage(...)];
  }
  
  if (entry.type === "compaction") {
    return [
      createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
      ...(entry.retainedTail ?? [])
    ];
  }
  
  if (entry.type === "branch_summary" && entry.summary) {
    return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
  }
  
  if (entry.type === "custom") {
    // Custom entry projectors can convert to messages
    return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])];
  }
  
  return [];  // Skip other entry types
}

Branching

What is Branching?

Branching creates a new session tree from an existing one, starting at a specific point.

Example use case:

Original tree:
  root → A → B → C → D (leaf)

Branch at B:
  root → A → B → B' (leaf)  ← New branch
              \
               → C → D (leaf)  ← Original branch

Fork Operation

async function fork(session: Session, targetId: string): Promise<Session> {
  // 1. Clone storage (copy entries up to targetId)
  const newStorage = await session.storage.fork(targetId);
  
  // 2. Create new session from storage
  const newSession = new Session({ storage: newStorage });
  
  // 3. Set leaf to targetId
  await newSession.getStorage().setLeafId(targetId);
  
  return newSession;
}

Branch Summary

When branching, a branch_summary entry is created:

interface BranchSummaryEntry extends SessionTreeEntryBase {
  type: "branch_summary";
  summary: string;  // Human-readable summary
  details?: unknown;  // Implementation details
  usage?: Usage;  // LLM usage for generating summary
  fromId: string;  // Entry ID where branch was created
}

Purpose: Help model understand what happened in the branch.


Compaction

What is Compaction?

Compaction replaces old conversation history with a summary, reducing context size.

Before compaction:

message (user #1)
message (assistant #1)
tool_result
message (user #2)
message (assistant #2)
tool_result
... (many more messages)

After compaction:

compaction (summary: "User asked X, assistant did Y, then Z...")
message (assistant #3)  ← Recent messages retained
message (user #3)

Compaction Entry

interface CompactionEntry extends SessionTreeEntryBase {
  type: "compaction";
  summary: string;  // Summarized history
  firstKeptEntryId?: string;  // First entry kept after compaction
  tokensBefore: number;  // Context size before compaction
  details?: CompactionDetails;  // File operations, etc.
  usage?: Usage;  // LLM usage for generating summary
  retainedTail?: AgentMessage[];  // Recent messages stored inline
}

Compaction Process

async function compact(session: Session): Promise<CompactionResult> {
  // 1. Get branch entries
  const entries = await session.getBranch();
  
  // 2. Prepare compaction
  const preparation = prepareCompaction(entries, settings);
  // Identifies which messages to summarize, retained tail, etc.
  
  // 3. Generate summary using LLM
  const summary = await generateSummary(
    preparation.messagesToSummarize,
    preparation.retainedTail
  );
  
  // 4. Create compaction entry
  const compactionEntry: CompactionEntry = {
    type: "compaction",
    id: uuidv7(),
    parentId: preparation.firstKeptEntry.parentId,
    timestamp: new Date().toISOString(),
    summary: summary.text,
    firstKeptEntryId: preparation.firstKeptEntry.id,
    tokensBefore: preparation.tokensBefore,
    details: {
      readFiles: preparation.fileOps.readFiles,
      modifiedFiles: preparation.fileOps.modifiedFiles
    },
    usage: summary.usage
  };
  
  // 5. Persist entry
  const compactionId = await session.storage.appendEntry(compactionEntry);
  
  return {
    summary: summary.text,
    firstKeptEntryId: preparation.firstKeptEntry.id,
    tokensBefore: preparation.tokensBefore,
    usage: summary.usage,
    retainedTail: preparation.retainedTail,
    details: compactionEntry.details
  };
}

Session Repositories

SessionRepo<TMetadata>

Repository pattern for session management:

interface SessionRepo<TMetadata extends SessionMetadata = SessionMetadata> {
  // CRUD
  create(options: CreateSessionOptions<TMetadata>): Promise<Session<TMetadata>>;
  open(id: string): Promise<Session<TMetadata>>;
  list(): Promise<SessionInfo[]>;
  delete(id: string): Promise<void>;
  
  // Forking
  fork(id: string, targetId: string): Promise<Session<TMetadata>>;
  
  // Cleanup
  cleanup(): Promise<void>;
}

Built-in Implementations

MemoryRepo

class MemoryRepo<TMetadata> implements SessionRepo<TMetadata> {
  // In-memory storage using Map<string, Session<TMetadata>>
  // Good for: Testing, ephemeral sessions
}

JSONLRepo

class JSONLRepo<TMetadata> implements SessionRepo<TMetadata> {
  // File-based storage
  // Sessions stored in: sessions/{id}/
  // Good for: Local development
}

Entry Types Reference

MessageEntry

interface MessageEntry extends SessionTreeEntryBase {
  type: "message";
  message: AgentMessage;
}

Stored: Every user/assistant/toolResult message

ModelChangeEntry

interface ModelChangeEntry extends SessionTreeEntryBase {
  type: "model_change";
  provider: string;
  modelId: string;
}

Stored: When model is changed via setModel()

ThinkingLevelChangeEntry

interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
  type: "thinking_level_change";
  thinkingLevel: ThinkingLevel;
}

Stored: When thinking level is changed via setThinkingLevel()

ActiveToolsChangeEntry

interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
  type: "active_tools_change";
  activeToolNames: string[];
}

Stored: When active tools are changed via setActiveTools()

CompactionEntry

interface CompactionEntry extends SessionTreeEntryBase {
  type: "compaction";
  summary: string;
  firstKeptEntryId?: string;
  tokensBefore: number;
  details?: CompactionDetails;
  usage?: Usage;
  retainedTail?: AgentMessage[];
}

Stored: After compaction

BranchSummaryEntry

interface BranchSummaryEntry extends SessionTreeEntryBase {
  type: "branch_summary";
  summary: string;
  details?: unknown;
  usage?: Usage;
  fromId: string;
}

Stored: When creating a branch

CustomEntry

interface CustomEntry extends SessionTreeEntryBase {
  type: "custom";
  customType: string;
  data: unknown;
}

Stored: Custom application data (not visible to model)

CustomMessageEntry

interface CustomMessageEntry extends SessionTreeEntryBase {
  type: "custom_message";
  customType: string;
  content: string | (TextContent | ImageContent)[];
  display: string;
  details: unknown;
}

Stored: Custom messages that appear in conversation

LabelEntry

interface LabelEntry extends SessionTreeEntryBase {
  type: "label";
  targetId: string;  // Entry ID being labeled
  label: string;
}

Stored: User-assigned labels for entries

LeafEntry

interface LeafEntry extends SessionTreeEntryBase {
  type: "leaf";
  targetId: string;  // Current leaf entry ID
}

Stored: Updates to current session head

SessionInfoEntry

interface SessionInfoEntry extends SessionTreeEntryBase {
  type: "session_info";
  name: string;
}

Stored: Session name/description


Best Practices

1. Use Branching for Experiments

// Original branch
await harness.prompt("Build a web app");

// Experiment branch
const experimentalSession = await session.fork(leafId);
const experimentalHarness = new AgentHarness({
  ...options,
  session: experimentalSession
});

await experimentalHarness.prompt("Try using React instead");

2. Compact Regularly

// After each turn, check if compaction needed
if (estimateTokens(context) > MAX_TOKENS * 0.8) {
  await harness.compact();
}

3. Use Custom Entries for Metadata

// Store application state without exposing to model
await harness.appendMessage({
  role: "custom",
  type: "task_progress",
  taskId: "abc123",
  steps: [...]
});

// Custom entry won't appear in model context

4. Label Important Points

// Mark important conversation points
await harness.appendLabel(messageId, "IMPORTANT_DECISION");
await harness.appendLabel(messageId, "BLOCKER");

5. Handle Branching Gracefully

try {
  await harness.navigateTree(targetId, { summarize: true });
} catch (error) {
  if (error instanceof AgentHarnessError && error.code === "branch_summary") {
    // Branch summary failed, navigate without summary
    await harness.navigateTree(targetId, { summarize: false });
  }
}

Summary

Session architecture provides:

  • Persistent conversation history (JSONL storage)
  • Branchable conversation trees
  • Context window compaction
  • Custom metadata and messages

Key operations:

  • buildContext() → Get LLM context from tree
  • appendMessage() → Add message to tree
  • fork() → Create branch at point
  • compact() → Summarize history

Storage layers:

  • MemoryStorage → Testing, ephemeral
  • JSONLStorage → Development, local