scratchpad — il foglio di appunti per quel che non sta nel discorso
Immagina che un utente chieda: «leggi il file /tmp/big_log.txt e dimmi gli errori». Il file pesa 800 KB. L'executor fs_read lo legge senza problemi. Ma adesso il contenuto deve arrivare al LLM perché possa decidere quali siano gli errori. E qui sta il problema: 800 KB nel context window di un LLM piccolo (8K-32K token) significa esplosione del contesto.
Una prima ingenua risposta: tronchiamo. Diciamo che teniamo solo i primi 1500 caratteri dell'observation. Funziona per file piccoli. Ma se l'utente cerca "errori" e gli errori stanno alla fine del log, abbiamo perso esattamente la parte che servirebbe. Una troncatura cieca distrugge informazione utile.
Una seconda risposta: chiediamo al LLM di esprimere in anticipo cosa vuole. Se chiede "gli errori", aggiungiamo a fs_read un parametro filter_regex e gli passiamo solo le righe che fanno match. Funziona, ma costringe l'LLM a sapere prima cosa cercherà, e a saperlo dire in regex. Per molti casi reali il LLM vuole esplorare: "facci vedere l'inizio per capire il formato, poi cerco gli errori, poi voglio i dettagli del primo errore".
Lo scratchpad è la terza risposta: separiamo lo storage dell'observation dalla sua vista nel context. L'osservazione vera, completa, va in un archivio temporaneo (lo scratchpad). Nel context del LLM mettiamo solo un breve summary + un identificatore. Quando il LLM ha bisogno di vederne porzioni, le chiede esplicitamente. Esplora.
scratchpad_read con la modalità che vuole.
Lo scratchpad è un piccolo database SQLite locale (~/.local/share/metnos/scratchpad.db) dove il runtime parcheggia le observation grandi. Per ogni voce salva:
scratchpad_id univoco (16 caratteri esadecimali);turn_id a cui appartiene (cosí possiamo isolare voci di turni diversi);kind: text oppure binary;content completo (testo UTF-8 o bytes raw);size_bytes;summary — vedi sotto;created_at e expires_at (TTL di un'ora di default).La voce vive solo per la durata del turno (e un po' oltre, per sicurezza). Una funzione di garbage collection rimuove le entries scadute al boot del prossimo turno.
Il pianificatore, dopo che un executor ha prodotto la sua observation, applica una regola semplice:
se len(json.dumps(observation)) > SCRATCHPAD_THRESHOLD_BYTES (default 4096):
salva l'observation in scratchpad
crea una versione "sintetica" da mettere nella history del LLM
altrimenti:
metti l'observation completa nella history (con eventuale truncation a 1500 char)
La soglia di 4 KB è calibrata sul fatto che oltre i 4 KB di JSON un'observation occupa ~1000 token nel context, che è già significativo per un turno multistep di 3-5 passi. Sotto i 4 KB il costo è trascurabile e mettere in scratchpad sarebbe un'inutile cerimonia.
La soglia è configurabile nel config; il default è sufficientemente generoso per la maggior parte degli usi.
Quando un'observation viene messa in scratchpad, il pianificatore costruisce una observation sintetica per la history del LLM. Esempio reale dal POC:
L'executor fs_read ha restituito un'observation di 89 KB. Il runtime salva l'89 KB nello scratchpad e mette nella history del LLM:
{
"ok": true,
"scratchpad_id": "485d7beae4e144eb",
"size_bytes": 89042,
"kind": "text",
"summary": "INFO 2026-04-26 10:00:01 evento numero 0\nINFO 2026-04-26 10:00:02 evento numero 1\n[... 88500 caratteri omessi ...]\nERROR 2026-04-26 23:59:59 ULTIMO_EVENTO_CRITICO\n",
"metadata": {
"path": "/tmp/big_log.txt",
"bytes": 89042,
"encoding": "utf-8",
...
},
"_note": "Observation grande salvata in scratchpad. Per leggerla per intero o parzialmente usa il tool scratchpad_read."
}
Il summary è uno smart truncation: i primi 500 caratteri + un placeholder con il numero esatto di caratteri omessi + gli ultimi 500. Il LLM vede contemporaneamente l'inizio e la fine del contenuto, sufficiente nella maggior parte dei casi per:
La metadata originale dell'observation viene preservata: il LLM continua a sapere il path, la dimensione, l'encoding, ecc.
Per observation di tipo binario (es. un file zip letto con encoding=binary), il summary diventa una nota tecnica:
"[BINARY: 45123 bytes, sha256=abc123def456...]"
Niente preview testuale (sarebbe gibberish), ma l'LLM vede dimensione e impronta. Per leggere porzioni, deve chiamare scratchpad_read in mode binary.
scratchpad_read
Il LLM, vedendo nella history un'observation con scratchpad_id, sa che può chiamare un tool builtin chiamato scratchpad_read per accedere al contenuto pieno o a parti.
Lo schema del tool:
{
"name": "scratchpad_read",
"description": "Legge dallo scratchpad un'observation precedentemente salvata...",
"parameters": {
"type": "object",
"required": ["scratchpad_id"],
"properties": {
"scratchpad_id": {"type": "string", "description": "Id ottenuto dal campo 'scratchpad_id' di un'observation precedente."},
"mode": {"type": "string", "enum": ["full", "head", "tail", "range"], "default": "head"},
"n": {"type": "integer", "description": "Per mode head/tail: numero di caratteri da leggere. Default 2000."},
"start": {"type": "integer", "description": "Per mode range: indice di inizio."},
"end": {"type": "integer", "description": "Per mode range: indice di fine (esclusivo)."}
}
}
}
| Mode | Cosa restituisce | Quando usarlo |
|---|---|---|
full | Tutto il contenuto. | Solo quando è davvero piccolo (sotto la soglia o poco oltre). Sconsigliato per file grandi. |
head | Primi N caratteri (default 2000). | Quando l'utente chiede l'inizio o quando serve capire il formato. |
tail | Ultimi N caratteri (default 2000). | Quando l'utente chiede la fine, gli ultimi eventi, le ultime righe. |
range | Caratteri da start a end (esclusivo). | Quando il LLM vuole esplorare una porzione specifica già identificata. |
Utente: "scarica https://httpbin.org/get e salvalo in /tmp/out.txt".
Step 1: web_fetch(url=https://httpbin.org/get) ritorna 14 KB di JSON. Va in scratchpad, id eae04122bd704636.
Step 2: il LLM, vedendo nella history l'observation con scratchpad_id, sa che il contenuto vero è là. Propone:
fs_write(path="/tmp/out.txt", content="{{step1.content}}").
Il runtime risolve {{step1.content}} recuperando dallo scratchpad il contenuto completo (non il summary), e lo passa a fs_write.
Risultato: file scritto correttamente, 14 KB di byte effettivi.
La cosa interessante: il LLM ha capito da solo che doveva fare riferimento al contenuto del passo precedente, anche se nella sua history vedeva il summary. La sintassi {{stepN.field}} e lo scratchpad cooperano: il riferimento si risolve ricostruendo dal database, non dal summary visibile.
CREATE TABLE entries (
id TEXT PRIMARY KEY,
turn_id TEXT NOT NULL,
step_num INTEGER,
executor_name TEXT,
content_kind TEXT NOT NULL, -- 'text' | 'binary'
content BLOB NOT NULL,
size_bytes INTEGER NOT NULL,
summary TEXT,
created_at REAL NOT NULL,
expires_at REAL NOT NULL
);
SQLite, file unico in ~/.local/share/metnos/scratchpad.db. Indici su turn_id (per recuperare entries di un turno) e expires_at (per il GC).
Il pianificatore chiama scratchpad.gc() all'inizio di ogni turno, che rimuove tutte le entries con expires_at < now(). TTL di default: 1 ora dalla creazione. Configurabile via parametro ttl_seconds in put().
Cosí uno scratchpad usato pesantemente per qualche minuto torna pulito dopo un'ora. Niente growth illimitata.
Ogni turno ha un suo turn_id uuid. Il pianificatore mostra al LLM solo le entries del turno corrente (via list_for_turn(turn_id)). Anche se nello SQLite ci sono entries di altri turni (in attesa di GC), il LLM non le vede e non vi può accedere.
A differenza degli executor "normali" (fs_read, web_fetch, ecc.), scratchpad_read non vive su disco come pacchetto firmato. Esiste solo nel runtime: il pianificatore costruisce dinamicamente il suo schema (un dict Python costante) e lo aggiunge ai tools passati al LLM quando ci sono entries scratchpad attive nel turno corrente.
Se non c'è nessuna observation in scratchpad, il LLM non vede il tool: meno rumore nel context. Appena la prima observation grande viene offloaded, lo scratchpad_read entra nel catalogo dei tools per i prossimi step.
Il guard "duplicate read" (vedi agent_runtime cap. 10) non si applica a scratchpad_read, perché chiamarlo più volte sullo stesso scratchpad_id con mode/range diversi è il caso d'uso normale.
scheduler e simili.
| Limite v1.1 | Quando si toglie |
|---|---|
| TTL fisso (1 ora) per tutte le entries | Quando una osservation deve sopravvivere oltre il turno (es. per essere recuperata in un turno futuro): TTL dichiarabile per entry. |
| Summary unicamente "head + tail" | Quando un summary semantico (LLM-generated) per observation 50KB-1MB diventa utile. Implica una chiamata LLM mini al momento dell'offload, da bilanciare col costo. |
| Range solo per indici di carattere/byte | Quando il LLM vuole "righe N..M" o "righe che matchano regex": estensione del mode range con sotto-modalità line, grep. |
| Niente compressione del contenuto | Quando il volume di scratchpad cresce (per ora è trascurabile, GC lo tiene pulito). |
| Niente "scratchpad federato" fra turni / fra istanze remote | Quando esecuzione remota o synt distribuito richiederà condivisione (al momento ogni istanza Metnos ha il suo). |
Lo scratchpad è un componente piccolo (~200 righe di Python) ma architettonicamente importante: senza di esso, il sistema sarebbe limitato a observation che entrano comodamente in 1500 caratteri di context, cioè banalmente nulla di utile. Con lo scratchpad, la scala dell'utile cresce di tre ordini di grandezza (file MB-scale leggibili a porzioni) senza saturare il LLM.
Concept emerso dal POC del 26 aprile 2026 dopo che lo stress test D-obs ha mostrato che la troncatura cieca a 1500 caratteri buttava via il 99% del contenuto degli executor di lettura, anche quando quel contenuto sarebbe stato il dato chiave per la risposta utente.