Niveau 12
Sessions
- user
- assistant
- tool_result
EventBus
Le problème
L'historique du niveau 2 est une liste en mémoire. Quand le programme s'arrête (un déploiement, un plantage, quelqu'un qui ferme l'onglet et revient demain), la liste disparaît et l'agent repart de zéro, en redemandant tout ce qu'il savait déjà.
L'enregistrer seulement à la fin ne suffit pas non plus : une exécution qui échoue en route est justement celle qu'on voudrait examiner, ou reprendre là où elle s'est arrêtée.
La solution
Donnez à chaque conversation un id et une place sur le disque, et traitez l'historique comme ce fichier, pas comme une variable. Trois gestes suffisent :
-
Enregistrer au fil de l'eau
Écrivez chaque message dans le fichier de session dès qu'il existe, une ligne par message. Si tout s'arrête en route, rien n'est perdu.
-
Reprendre
Un nouvel agent sur le même id de session relit le fichier dans son historique avant la requête suivante.
-
Bifurquer
Copiez l'historique dans une nouvelle session pour tenter autre chose. L'originale reste telle quelle.
Le JSONL, un message JSON par ligne, est le format habituel : ajouter un message, c'est ajouter une ligne, et une dernière ligne écrite à moitié se repère facilement. Claude Code et Codex gardent leurs propres sessions de la même façon, et c'est ainsi qu'ils les reprennent.
Les personnages
Les mêmes personnages que d'habitude, en pleine quête.
- Le journal d'aventure le fichier de session
- Un emplacement par id de session, une marque par message, écrite dès qu'il existe.
- Le bandonéon l'historique
- Ce que l'agent a en mémoire. Il se vide quand on éteint le jeu ; le journal, non.
- CONTINUE reprendre
- Un nouvel agent sur le même id, avec le journal relu dans son historique.
- COPY A QUEST bifurquer
- Un deuxième emplacement qui démarre comme une copie du premier. Ensuite, chacun grandit de son côté.
- Astor la boucle
- Fait tourner la boucle comme d'habitude, et chaque message part dans le journal.
- Le village les outils
- L'ancien, le garde et la boutique :
talk_toetbuy.
Le code
Avec astorlm : passez un sessionId et un FileSessionManager. L'agent charge
cette session à sa création et l'enregistre après chaque message ; un nouvel id en démarre une vide. fork()
renvoie un nouvel agent sur une copie de la session.
À partir de zéro : la boucle du niveau 2, avec son historique lu d'abord dans un fichier JSONL et chaque nouveau message ajouté à la fin. Bifurquer, c'est copier le fichier.
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?")
Points de vigilance
- Une session reprise renvoie tout. Le jour 30 d'une longue conversation porte les 29 précédents. Les sessions gardent l'historique ; c'est la compaction (niveau 9) qui l'empêche de dépasser la fenêtre de contexte.
- Bifurquer copie l'historique, pas le monde. La torche a été achetée une fois, et elle est dans les deux emplacements. Les emails envoyés, les commandes passées et les fichiers écrits par les outils ont eu lieu pour de vrai, dans toutes les branches.
- Vérifiez la dernière ligne à la reprise. Si le programme est mort entre un appel d'outil et son résultat, le journal se termine par un appel resté sans réponse, et la plupart des fournisseurs le refusent. Supprimez-le, ou ajoutez-lui un résultat d'erreur.
- Un seul écrivain par session. Deux processus qui ajoutent des lignes au même fichier en même temps les mélangent. Donnez à chaque exécution sa propre session, ou un verrou.
- Les fichiers de session sont des données personnelles. Ils contiennent tout ce que l'utilisateur a dit et chaque résultat d'outil. Décidez où ils vivent, qui peut les lire et quand ils sont supprimés.