← Indice documentazione Microprogettazione › scratchpad

Metnos

scratchpad — il foglio di appunti per quel che non sta nel discorso
Microprogettazione
Pubblico: chi capisce come Metnos gestisce dati grandi senza saturare il contesto LLM.
Microprogettazione — allineata al codice. Cluster scratchpad 34/34 verde, integrato in agent_runtime. Il turn log include campi di scrub credenziali (redacted: bool, n_redacted_fields: int): i valori in chiaro di pwd/user sono sostituiti da <REDACTED:cred> in user_query, step.raw_args e step.resolved_args prima della serializzazione JSONL. Telemetria fine StepLog: cinque campi opzionali (intent_ms, prefilter_ms, vaglio_ms, exec_ms, rerank_ms) nel record di passo. Riferimento di implementazione: /opt/metnos/runtime/scratchpad.py.
Stato nella sequenza dei microdesign: under approvalapprovedtestedimplemented.

Indice

  1. Il problema da risolvere
  2. L'idea dello scratchpad
  3. Quando un'osservazione va in scratchpad
  4. Cosa vede il LLM al posto dell'osservazione completa
  5. Come il LLM legge dallo scratchpad: scratchpad_read
  6. Storage e ciclo di vita
  7. Builtin: perché scratchpad_read è speciale
  8. Limiti e sviluppi futuri

1. Il problema da risolvere

Immagina che un utente chieda: «leggi il file /tmp/big_log.txt e dimmi gli errori». Il file pesa 800 KB. L'executor read_files 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 nella finestra di contesto di un LLM piccolo (8K-32K token) significano esplosione del contesto.

Una prima risposta ingenua: tronchiamo. Teniamo solo i primi N caratteri dell'osservazione. 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 read_files 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 l'archiviazione dell'osservazione dalla sua vista nel contesto. L'osservazione vera, completa, va in un archivio temporaneo (lo scratchpad). Nel contesto del LLM mettiamo solo un breve riassunto e un identificatore. Quando il LLM ha bisogno di vederne porzioni, le chiede esplicitamente.

Executores. read_files /tmp/big.log obs 89KB Runtime size > 4KB ? sí: scarica in scratchpad sostituisci con synth salva intero Scratchpad SQLite id, turn_id, content, summary, ttl=1h obs synth LLM (vede synth) {ok:true, scratchpad_id:"485d7b…", summary:"INFO... ERROR" size:89042} decide cosa serve tool_call: scratchpad_read id="485d7b…", mode="tail", n=200 Runtime: ricerca + porzione recupera dal SQLite, ritorna porzione final_answer all'utente
Sequenza tipica: l'executor produce un'osservazione grande, il runtime la scarica nello scratchpad e mostra al LLM solo il riassunto; il LLM, se gli serve di più, chiama esplicitamente scratchpad_read con la modalità che vuole.

2. L'idea dello scratchpad

Lo scratchpad è un piccolo database SQLite locale (~/.local/share/metnos/scratchpad.db) dove il runtime parcheggia le osservazioni grandi. Per ogni voce salva:

La voce vive solo per la durata del turno (e un po' oltre, per sicurezza). Una funzione di pulizia automatica rimuove le voci scadute all'avvio del turno successivo.

3. Quando un'osservazione va in scratchpad

Il pianificatore, dopo che un executor ha prodotto la sua osservazione, 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'osservazione occupa ~1000 token nel contesto, che è già significativo per un turno multipasso di 3-5 passi. Sotto i 4 KB il costo è trascurabile e metterla in scratchpad sarebbe un'inutile cerimonia.

La soglia è configurabile nel config; il default è sufficientemente generoso per la maggior parte degli usi.

4. Cosa vede il LLM al posto dell'osservazione completa

Quando un'osservazione viene messa in scratchpad, il pianificatore costruisce un'osservazione sintetica per la cronologia del LLM. Esempio reale dal POC:

Caso: l'utente chiede di leggere un log da 89 KB

L'executor read_files ha restituito un'osservazione di 89 KB. Il runtime salva gli 89 KB nello scratchpad e mette nella cronologia del LLM:

{
 "ok": true,
 "scratchpad_id": "485d7beae4e144eb",
 "size_bytes": 89042,
 "kind": "text",
 "summary": "INFO 10:00:01 evento numero 0\nINFO 10:00:02 evento numero 1\n[... 88500 caratteri omessi...]\nERROR 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 riassunto è un troncamento intelligente: i primi 500 caratteri, un segnaposto con il numero esatto di caratteri omessi, gli ultimi 500. L'LLM vede insieme l'inizio e la fine del contenuto: nella maggior parte dei casi basta a:

La metadata originale dell'osservazione viene preservata: il LLM continua a sapere il path, la dimensione, l'encoding, ecc.

Riassunto intelligente per i binari

Per un'osservazione di tipo binario (es. un file zip letto con encoding=binary), il riassunto diventa una nota tecnica:

"[BINARY: 45123 bytes, sha256=abc123def456...]"

Niente anteprima testuale (sarebbe incomprensibile), ma l'LLM vede dimensione e impronta. Per leggere porzioni, deve chiamare scratchpad_read in modalità binaria.

5. Come il LLM legge dallo scratchpad: scratchpad_read

Il LLM, vedendo nella cronologia un'osservazione 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'osservazione precedentemente salvata...",
 "parameters": {
 "type": "object",
 "required": ["scratchpad_id"],
 "properties": {
 "scratchpad_id": {"type": "string", "description": "Id ottenuto dal campo 'scratchpad_id' di un'osservazione 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)."}
 }
 }
}

Le quattro modalità

ModeCosa restituisceQuando usarlo
fullTutto il contenuto.Solo quando è davvero piccolo (sotto la soglia o poco oltre). Sconsigliato per file grandi.
headPrimi N caratteri (default 2000).Quando l'utente chiede l'inizio o quando serve capire il formato.
tailUltimi N caratteri (default 2000).Quando l'utente chiede la fine, gli ultimi eventi, le ultime righe.
rangeCaratteri da start a end (esclusivo).Quando il LLM vuole esplorare una porzione specifica già identificata.
Esempio: pattern reale dal POC

Utente: "scarica https://httpbin.org/get e salvalo in /tmp/out.txt".

Step 1: get_urls(url=https://httpbin.org/get) ritorna 14 KB di JSON. Va in scratchpad, id eae04122bd704636.

Passo 2: il LLM, vedendo nella cronologia l'osservazione con scratchpad_id, sa che il contenuto vero è là. Propone:
write_files(path="/tmp/out.txt", content="{{step1.content}}").

Il runtime risolve {{step1.content}} recuperando dallo scratchpad il contenuto completo (non il riassunto), e lo passa a write_files.

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 cronologia vedeva il riassunto. La sintassi {{stepN.field}} e lo scratchpad cooperano: il riferimento si risolve ricostruendo dal database, non dal riassunto visibile.

6. Storage e ciclo di vita

Schema SQL

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).

Garbage collection

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.

Isolation fra turni

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.

7. Builtin: perché scratchpad_read è speciale

A differenza degli executor "normali" (read_files, get_urls, 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 osservazione in scratchpad, il LLM non vede il tool: meno rumore nel contesto. Appena la prima osservazione grande viene scaricata, lo scratchpad_read entra nel catalogo dei tools per i passi successivi.

Il guard "duplicate read" (vedi agent_runtime cap. 10) non si applica a scratchpad_read, perché chiamarlo più volte sullo stesso scratchpad_id con modalità o intervallo diversi è il caso d'uso normale.

Perché un builtin e non un executor a tutti gli effetti? Perché lo scratchpad è una primitiva del runtime, non una capability che un sintetizzatore (synt) potrebbe inventare o scrivere. Un executor "scratchpad_read" su disco firmato sarebbe inutilmente ricco di cerimonia per qualcosa che il runtime conosce già intimamente. La famiglia dei builtin (di cui scratchpad_read è il primo) include in futuro probabilmente uno scheduler e simili.

8. Limiti e sviluppi futuri

Limite Quando si toglie
TTL fisso (1 ora) per tutte le vociQuando un'osservazione deve sopravvivere oltre il turno (es. per essere recuperata in un turno futuro): TTL dichiarabile per voce.
Riassunto unicamente "testa + coda"Quando un riassunto semantico (generato dall'LLM) per osservazioni da 50KB-1MB diventa utile. Implica una piccola chiamata LLM al momento dello scaricamento, da bilanciare col costo.
Range solo per indici di carattere/byteQuando il LLM vuole "righe N..M" o "righe che corrispondono a una regex": estensione della modalità range con sotto-modalità line, grep.
Niente compressione del contenutoQuando il volume dello scratchpad cresce (per ora è trascurabile, la pulizia automatica lo tiene sgombro).
Niente "scratchpad federato" fra turni / fra istanze remoteQuando esecuzione remota o synt distribuito richiederà condivisione (al momento ogni istanza Metnos ha il suo).

Note finali

Lo scratchpad è un componente piccolo (~200 righe di Python) ma architettonicamente importante: senza di esso, il sistema sarebbe limitato a osservazioni che entrano comodamente in 1500 caratteri di contesto, cioè banalmente nulla di utile. Con lo scratchpad, la scala dell'utile cresce di tre ordini di grandezza (file dell'ordine dei MB leggibili a porzioni) senza saturare il LLM.

Senza lo scratchpad, una troncatura cieca butterebbe via la quasi totalità del contenuto degli executor di lettura, anche quando quel contenuto sarebbe il dato chiave per la risposta utente.