Session / Memory Management
Purpose
Allow agents to maintain context across multiple turns and activities. Rather than starting every LLM call cold, agents can resume a prior conversation — preserving reasoning context, established brand tone, and intermediate results without re-sending all prior content.
Related: Agent Execution Engine — adapter implementations | Database —
sessionstable | Skills System — the client context file is always re-injected (skills), separate from session history
Why Sessions Matter
- A Activity Planner that decomposes a brief into 6 subtasks can later resume to synthesise results with full context of the original brief.
- A Copywriter writing a 10-part blog series can resume with brand voice already established in the session rather than re-injecting it on every call.
- Session resumption dramatically reduces token spend on continuation tasks.
Claude Session Management
How it works
Claude Code CLI natively manages session state. On the first call, Claude creates a session and stores it as a .jsonl file:
~/.claude/projects/<cwd>/<session-id>.jsonlEach line is a conversation turn. The session ID is returned in the system/init NDJSON event on stdout.
Resumption
Pass --resume <sessionId> on subsequent calls. Claude reloads the .jsonl file and continues the conversation from where it left off.
# First call — new session
claude --output-format stream-json -p "Write a blog post outline about X"
# → emits: { "type": "system", "session_id": "sess_01abc..." }
# Resume call — continues context
claude --output-format stream-json --resume sess_01abc -p "Now write section 2 in full"Our responsibility
Claude manages the file storage. We wrap this with a DB record for:
- Associating the session with a specific
campaign_idandagent_type - Tracking token usage and cost accumulating across the session
- Enforcing TTL expiry (session cleanup policy)
- Enabling the Activity Planner to look up the right session when dispatching continuation tasks
Ollama Session Management
Ollama has no built-in session system. We maintain conversation state manually.
Approach
Store the full messages array in the sessions.message_history JSONB column. Before each call, load the history from DB; after each call, append the new assistant message and write back.
// Load history
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.campaignId, campaignId), eq(sessions.agentType, agentType))
});
const history = session?.messageHistory ?? [];
// Build request
const messages = [
{ role: 'system', content: systemPrompt + injectedSkills },
...history,
{ role: 'user', content: taskPrompt }
];
// Call Ollama
const response = await ollamaChat(model, messages);
// Persist updated history
await db.update(sessions).set({
messageHistory: [...history, { role: 'user', content: taskPrompt }, response.message],
tokenCount: (session?.tokenCount ?? 0) + response.eval_count,
}).where(eq(sessions.id, session.id));Truncation strategy
Ollama context windows are finite. When messageHistory exceeds a configured token threshold (e.g. 80% of model context), a summarisation pass runs before the next call:
- Send the history to the same Ollama model with a summarisation prompt.
- Replace the full history with a single
systemmessage containing the summary. - Continue the conversation from the condensed context.
This keeps costs near-zero (Ollama = free) while preserving critical context.
Database Schema
sessions table
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_session_id VARCHAR(255), -- Claude session ID; null for Ollama
agent_type VARCHAR(100) NOT NULL,
campaign_id UUID NOT NULL REFERENCES campaigns(id),
model VARCHAR(100) NOT NULL,
message_history JSONB, -- Ollama multi-turn; null for Claude
token_count INTEGER DEFAULT 0,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON sessions(external_session_id);
CREATE INDEX ON sessions(campaign_id, agent_type);Lookup pattern
When a new task arrives for an agent on an existing campaign, the queue worker looks up the session before starting the executor:
async function resolveSession(campaignId: string, agentType: string): Promise<Session | null> {
return db.query.sessions.findFirst({
where: and(
eq(sessions.campaignId, campaignId),
eq(sessions.agentType, agentType),
gt(sessions.expiresAt, new Date()) // not expired
)
});
}Session Lifecycle
Task arrives for campaign X, agent type: copywriter
│
▼
Look up session (campaign_id=X, agent_type=copywriter, not expired)
│
found?
┌─────┴──────┐
YES NO
│ │
Resume Create new session record
session (no external_session_id yet)
│ │
└─────┬───────┘
│
▼
Execute LLM call
│
▼
On completion:
- Claude: upsert session record with external_session_id from init event
- Ollama: append messages to message_history, update token_count
│
▼
Link session_id to task_run recordSession Expiry & Cleanup
Sessions have a configurable TTL. Defaults:
| Provider | Default TTL |
|---|---|
| Claude | 30 days (aligns with Claude’s own project session retention) |
| Ollama | 7 days |
A cleanup cron job runs nightly:
// Runs at 02:00 UTC daily
async function cleanupExpiredSessions() {
const deleted = await db.delete(sessions)
.where(lt(sessions.expiresAt, new Date()))
.returning({ id: sessions.id });
// Claude sessions: the .jsonl file is managed by Claude CLI itself
// Ollama sessions: DB record deletion is sufficient (no file to clean)
logger.info(`Cleaned up ${deleted.length} expired sessions`);
}Optional: Long Session Summarisation (Claude)
For very long-running campaigns, Claude session .jsonl files can grow large, increasing the token cost of resumption. To mitigate:
- Monitor
sessions.token_count— when it exceeds a threshold (configurable, e.g. 50k tokens), trigger summarisation. - Start a new Claude session with a summarised brief instead of resuming the full history.
- Update the
sessionsrecord with the newexternal_session_idand resettoken_count.
This is optional — only needed for campaigns with many continuation tasks.
Package Location
Session management logic lives in packages/agent-engine/src/sessions/:
sessions/
├── resolver.ts # resolveSession() — find or create session for a task
├── claude-session.ts # extract session ID from NDJSON, upsert record
├── ollama-session.ts # load/save message_history, truncation logic
├── cleanup.ts # TTL expiry cron handler
└── types.ts