Level 12
Sessions
- user
- assistant
- tool_result
EventBus
The problem
The history from level 2 is a list in memory. When the program stops (a deploy, a crash, a user who closes the tab and comes back tomorrow), the list is gone and the agent starts over, asking again for everything it already knew.
Saving it only at the end isn’t enough either: a run that fails halfway is exactly the one you’d want to look at, or pick up from where it stopped.
The solution
Give every conversation an id and a place on disk, and treat the history as that file, not as a variable. Three moves cover it:
-
Save as you go
Write each message to the session file the moment it exists, one line each. A crash mid-run loses nothing.
-
Resume
A new agent on the same session id reads the file back into its history before the next request.
-
Fork
Copy the history into a new session to try something else. The original stays as it was.
JSONL, one JSON message per line, is the usual format: adding a message is adding a line, and a half-written last line is easy to spot. Claude Code and Codex keep their own sessions the same way, which is how they resume.
The cast
Same cast as always, on a quest.
- The adventure log the session file
- One slot per session id, one tick per message, written the moment it exists.
- The bandoneón the history
- What the agent has in memory. It empties when the game is switched off; the log doesn’t.
- CONTINUE resume
- A new agent on the same id, with the log read back into its history.
- COPY A QUEST fork
- A second slot that starts as a copy of the first. After that, each one only grows on its own.
- Astor the loop
- Runs the loop as usual, with every message going into the log.
- The town the tools
- The elder, the guard and the item shop:
talk_toandbuy.
The code
With astorlm: pass a sessionId and a FileSessionManager. The agent loads that
session when it’s created and saves it after every message; a new id starts an empty one. fork() returns
a new agent on a copy of the session.
From scratch: the loop from level 2, with its history read from a JSONL file first and every new message appended to it. A fork is a copy of the file.
import { FileSessionManager, OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const talkTo = tool({
name: 'talk_to',
description: 'Talk to someone in town. Returns what they say.',
schema: z.object({ npc: z.string() }),
execute: async ({ npc }) => town.talk(npc), // your code
})
const buy = tool({
name: 'buy',
description: 'Buy an item at the shop. Returns the price and the gold left.',
schema: z.object({ item: z.string() }),
execute: async ({ item }) => shop.buy(item), // your code
})
// The adventure log: quest-1.jsonl (one message per line) and quest-1.meta.json, in this folder.
const sessionManager = new FileSessionManager({ dir: './sessions' })
const quest = (sessionId: string) =>
createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [talkTo, buy],
sessionId, // a new id starts an empty log; a known one loads it
sessionManager,
})
// Day 1. Every message is saved as soon as it's added, so a crash loses nothing.
const day1 = await quest('quest-1')
await day1.run('I need to get into the Cave of Echoes. Find out what it takes, and get what you can.')
// …the process ends. Day 2: a new agent on the same id reads the log back before answering.
const day2 = await quest('quest-1')
console.log(day2.getMessages().length) // 6: yesterday is in the history
await day2.run('I got the Silver Key from the mayor’s daughter. What now?')
// A fork: a new session with a copy of every message. quest-1 stays as it was.
const west = await day2.fork({ newSessionId: 'quest-2' })
await west.run('Suppose the key doesn’t fit. Is there another way in?')
// Sessions from scratch: a JSONL file per conversation. Node's standard library only.
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
type Message = { role: string; content: unknown; [key: string]: unknown }
const DIR = './sessions'
const pathOf = (id: string) => `${DIR}/${id}.jsonl`
// 1. Load: one JSON message per line. No file yet means a new, empty session.
export function load(id: string): Message[] {
if (!existsSync(pathOf(id))) return []
return readFileSync(pathOf(id), 'utf8')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line))
}
// 2. Save as you go: append each message the moment it exists. A crash mid-run loses nothing.
function append(id: string, message: Message): void {
mkdirSync(DIR, { recursive: true })
appendFileSync(pathOf(id), JSON.stringify(message) + '\n')
}
// 3. Fork: copy the file. From here on, each session only appends to its own.
export function fork(from: string, to: string): void {
copyFileSync(pathOf(from), pathOf(to))
}
// 4. The loop from level 2, with its history loaded first and every new message written down.
export async function runSession(id: string, prompt: string, maxTurns = 10): Promise<string> {
const messages = load(id)
const add = (message: Message) => {
messages.push(message)
append(id, message)
}
add({ role: 'user', content: prompt })
for (let turn = 1; turn <= maxTurns; turn++) {
const choice = await chat(messages) // one model call, as in level 2
add(choice.message)
if (choice.finish_reason !== 'tool_calls') return choice.message.content ?? ''
for (const call of choice.message.tool_calls ?? []) {
add({ role: 'tool', tool_call_id: call.id, content: await runTool(call) })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
// Day 1, then day 2 on the same id, then a fork to try another way.
await runSession('quest-1', 'I need to get into the Cave of Echoes. Find out what it takes, and get what you can.')
await runSession('quest-1', 'I got the Silver Key from the mayor’s daughter. What now?')
fork('quest-1', 'quest-2')
await runSession('quest-2', 'Suppose the key doesn’t fit. Is there another way in?')
# Sessions from scratch: a JSONL file per conversation. Standard library only.
import json
import shutil
from pathlib import Path
DIR = Path("sessions")
def path_of(session_id):
return DIR / f"{session_id}.jsonl"
# 1. Load: one JSON message per line. No file yet means a new, empty session.
def load(session_id):
path = path_of(session_id)
if not path.exists():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
# 2. Save as you go: append each message the moment it exists. A crash mid-run loses nothing.
def append(session_id, message):
DIR.mkdir(exist_ok=True)
with path_of(session_id).open("a", encoding="utf-8") as log:
log.write(json.dumps(message) + "\n")
# 3. Fork: copy the file. From here on, each session only appends to its own.
def fork(source, target):
shutil.copyfile(path_of(source), path_of(target))
# 4. The loop from level 2, with its history loaded first and every new message written down.
def run_session(session_id, prompt, max_turns=10):
messages = load(session_id)
def add(message):
messages.append(message)
append(session_id, message)
add({"role": "user", "content": prompt})
for _ in range(max_turns):
choice = chat(messages) # one model call, as in level 2
add(choice["message"])
if choice["finish_reason"] != "tool_calls":
return choice["message"].get("content") or ""
for call in choice["message"].get("tool_calls", []):
add({"role": "tool", "tool_call_id": call["id"], "content": run_tool(call)})
raise RuntimeError(f"No answer after {max_turns} turns")
# Day 1, then day 2 on the same id, then a fork to try another way.
run_session("quest-1", "I need to get into the Cave of Echoes. Find out what it takes, and get what you can.")
run_session("quest-1", "I got the Silver Key from the mayor's daughter. What now?")
fork("quest-1", "quest-2")
run_session("quest-2", "Suppose the key doesn't fit. Is there another way in?")
What to watch
- A resumed session sends everything again. Day 30 of a long conversation carries the 29 before it. Sessions keep the history; compacting it (level 9) is what keeps it from outgrowing the context window.
- A fork copies the history, not the world. The torch was bought once, and it’s in both slots. Emails sent, orders placed and files written by tools happened for real, in every branch.
- Check the last line when you resume. If the program died between a tool call and its result, the log ends with a call nobody answered, and most providers reject that. Drop it, or add an error result for it.
- One writer per session. Two processes appending to the same file at once interleave their lines. Give each run its own session, or a lock.
- Session files are personal data. They hold everything the user said and every tool result. Decide where they live, who can read them, and when they’re deleted.