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

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