706 lines
16 KiB
Markdown
706 lines
16 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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>`
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
class MemoryStorage<TMetadata> implements SessionStorage<TMetadata> {
|
|
// In-memory storage using Map
|
|
// Good for: Testing, short-lived sessions
|
|
// Not good for: Persistence across runs
|
|
}
|
|
```
|
|
|
|
#### JSONLStorage
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
class MemoryRepo<TMetadata> implements SessionRepo<TMetadata> {
|
|
// In-memory storage using Map<string, Session<TMetadata>>
|
|
// Good for: Testing, ephemeral sessions
|
|
}
|
|
```
|
|
|
|
#### JSONLRepo
|
|
|
|
```typescript
|
|
class JSONLRepo<TMetadata> implements SessionRepo<TMetadata> {
|
|
// File-based storage
|
|
// Sessions stored in: sessions/{id}/
|
|
// Good for: Local development
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Entry Types Reference
|
|
|
|
### MessageEntry
|
|
|
|
```typescript
|
|
interface MessageEntry extends SessionTreeEntryBase {
|
|
type: "message";
|
|
message: AgentMessage;
|
|
}
|
|
```
|
|
|
|
**Stored**: Every user/assistant/toolResult message
|
|
|
|
### ModelChangeEntry
|
|
|
|
```typescript
|
|
interface ModelChangeEntry extends SessionTreeEntryBase {
|
|
type: "model_change";
|
|
provider: string;
|
|
modelId: string;
|
|
}
|
|
```
|
|
|
|
**Stored**: When model is changed via `setModel()`
|
|
|
|
### ThinkingLevelChangeEntry
|
|
|
|
```typescript
|
|
interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
|
|
type: "thinking_level_change";
|
|
thinkingLevel: ThinkingLevel;
|
|
}
|
|
```
|
|
|
|
**Stored**: When thinking level is changed via `setThinkingLevel()`
|
|
|
|
### ActiveToolsChangeEntry
|
|
|
|
```typescript
|
|
interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
|
|
type: "active_tools_change";
|
|
activeToolNames: string[];
|
|
}
|
|
```
|
|
|
|
**Stored**: When active tools are changed via `setActiveTools()`
|
|
|
|
### CompactionEntry
|
|
|
|
```typescript
|
|
interface CompactionEntry extends SessionTreeEntryBase {
|
|
type: "compaction";
|
|
summary: string;
|
|
firstKeptEntryId?: string;
|
|
tokensBefore: number;
|
|
details?: CompactionDetails;
|
|
usage?: Usage;
|
|
retainedTail?: AgentMessage[];
|
|
}
|
|
```
|
|
|
|
**Stored**: After compaction
|
|
|
|
### BranchSummaryEntry
|
|
|
|
```typescript
|
|
interface BranchSummaryEntry extends SessionTreeEntryBase {
|
|
type: "branch_summary";
|
|
summary: string;
|
|
details?: unknown;
|
|
usage?: Usage;
|
|
fromId: string;
|
|
}
|
|
```
|
|
|
|
**Stored**: When creating a branch
|
|
|
|
### CustomEntry
|
|
|
|
```typescript
|
|
interface CustomEntry extends SessionTreeEntryBase {
|
|
type: "custom";
|
|
customType: string;
|
|
data: unknown;
|
|
}
|
|
```
|
|
|
|
**Stored**: Custom application data (not visible to model)
|
|
|
|
### CustomMessageEntry
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
interface LabelEntry extends SessionTreeEntryBase {
|
|
type: "label";
|
|
targetId: string; // Entry ID being labeled
|
|
label: string;
|
|
}
|
|
```
|
|
|
|
**Stored**: User-assigned labels for entries
|
|
|
|
### LeafEntry
|
|
|
|
```typescript
|
|
interface LeafEntry extends SessionTreeEntryBase {
|
|
type: "leaf";
|
|
targetId: string; // Current leaf entry ID
|
|
}
|
|
```
|
|
|
|
**Stored**: Updates to current session head
|
|
|
|
### SessionInfoEntry
|
|
|
|
```typescript
|
|
interface SessionInfoEntry extends SessionTreeEntryBase {
|
|
type: "session_info";
|
|
name: string;
|
|
}
|
|
```
|
|
|
|
**Stored**: Session name/description
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### 1. Use Branching for Experiments
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// After each turn, check if compaction needed
|
|
if (estimateTokens(context) > MAX_TOKENS * 0.8) {
|
|
await harness.compact();
|
|
}
|
|
```
|
|
|
|
### 3. Use Custom Entries for Metadata
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// Mark important conversation points
|
|
await harness.appendLabel(messageId, "IMPORTANT_DECISION");
|
|
await harness.appendLabel(messageId, "BLOCKER");
|
|
```
|
|
|
|
### 5. Handle Branching Gracefully
|
|
|
|
```typescript
|
|
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
|