Nivel 12
Sesiones
- user
- assistant
- tool_result
EventBus
El problema
El historial del nivel 2 es una lista en memoria. Cuando el programa se detiene (un deploy, una caída, alguien que cierra la pestaña y vuelve mañana), la lista desaparece y el agente empieza de cero, preguntando otra vez todo lo que ya sabía.
Guardarla solo al final tampoco alcanza: una ejecución que falla a mitad de camino es justo la que querrías mirar, o retomar desde donde se cortó.
La solución
Dale a cada conversación un id y un lugar en disco, y trata el historial como ese archivo, no como una variable. Tres movimientos lo cubren:
-
Guardar sobre la marcha
Escribe cada mensaje en el archivo de la sesión apenas existe, una línea por mensaje. Si algo se cae a mitad de camino, no se pierde nada.
-
Retomar
Un agente nuevo con el mismo id de sesión vuelve a leer el archivo en su historial antes del próximo pedido.
-
Bifurcar
Copia el historial en una sesión nueva para probar otra cosa. La original queda como estaba.
JSONL, un mensaje JSON por línea, es el formato habitual: agregar un mensaje es agregar una línea, y una última línea escrita a medias es fácil de detectar. Claude Code y Codex guardan sus propias sesiones de la misma forma, y así es como las retoman.
El elenco
El mismo elenco de siempre, en una aventura.
- El registro de aventura el archivo de sesión
- Una ranura por id de sesión, una marca por mensaje, escrita apenas existe.
- El bandoneón el historial
- Lo que el agente tiene en memoria. Se vacía cuando se apaga el juego; el registro no.
- CONTINUE retomar
- Un agente nuevo con el mismo id, con el registro vuelto a leer en su historial.
- COPY A QUEST bifurcar
- Una segunda ranura que empieza como copia de la primera. Después, cada una crece por su cuenta.
- Astor el bucle
- Corre el bucle como siempre, y cada mensaje va al registro.
- El pueblo las herramientas
- El anciano, el guardia y la tienda:
talk_toybuy.
El código
Con astorlm: pasa un sessionId y un FileSessionManager. El agente carga esa
sesión cuando se crea y la guarda después de cada mensaje; un id nuevo empieza una vacía. fork() devuelve
un agente nuevo sobre una copia de la sesión.
Desde cero: el bucle del nivel 2, con su historial leído primero de un archivo JSONL y cada mensaje nuevo agregado al final. Bifurcar es copiar el archivo.
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?")
Qué vigilar
- Una sesión retomada vuelve a mandar todo. El día 30 de una conversación larga lleva encima los 29 anteriores. Las sesiones conservan el historial; compactarlo (nivel 9) es lo que evita que supere la ventana de contexto.
- Bifurcar copia el historial, no el mundo. La antorcha se compró una vez, y está en las dos ranuras. Los emails enviados, los pedidos hechos y los archivos escritos por las herramientas pasaron de verdad, en todas las ramas.
- Revisa la última línea al retomar. Si el programa murió entre una llamada a una herramienta y su resultado, el registro termina con una llamada que nadie respondió, y la mayoría de los proveedores la rechaza. Descártala, o agrégale un resultado de error.
- Un solo escritor por sesión. Dos procesos agregando líneas al mismo archivo a la vez las mezclan. Dale a cada ejecución su propia sesión, o un candado.
- Los archivos de sesión son datos personales. Guardan todo lo que dijo el usuario y cada resultado de herramienta. Decide dónde viven, quién puede leerlos y cuándo se borran.