Orkas Orkas
Pagina iniziale Blog Architettura
Architettura

Il livello che trasforma un modello in un prodotto: la progettazione dell'Agent Harness di Orkas

Come Orkas trasforma le chiamate ai modelli in un ambiente di esecuzione desktop affidabile per gli agenti: cicli di esecuzione in streaming, instradamento degli strumenti, compattazione del contesto, astrazione dei fornitori, memoria e sessioni resistenti agli arresti anomali.

Chiunque abbia lanciato un prodotto basato su agenti conosce la sensazione: mettere in piedi una demo è rapido, ma trasformarla in qualcosa di cui un utente si fida ogni giorno — qualcosa che funziona tutto il giorno sul suo computer senza bloccarsi — è difficile. E la parte difficile non è collegare il modello. È l’intero livello che lo circonda.

Questo livello ha diversi nomi; quello che userò è Agent Harness. Si colloca tra il "grande modello" e le "funzionalità del prodotto" ed è il vero ambiente di esecuzione: trasforma una singola richiesta dell’utente in una conversazione con il modello che procede per più cicli, intercalando chiamate agli strumenti, restituendo i risultati, compattando il contesto prima che superi il limite, riprovando in caso di problemi di rete e recuperando la conversazione anche dopo un arresto anomalo del processo. Il modello ragiona; l’harness traduce quel ragionamento in una sequenza affidabile di azioni.

Orkas è un’app desktop basata su agenti che funziona sul computer dell’utente, e il suo harness risiede interamente sul client. Questo articolo spiega come è costruito tale livello: come è suddiviso, come funziona il ciclo di esecuzione, come vengono astratti strumenti e modelli e come vengono gestite memoria e sessioni. I dettagli del codice sono stati ripuliti e generalizzati, ma la struttura ingegneristica è reale.

In breve L’harness è la parte che installi davvero Tutto ciò che è descritto qui è incluso nell’app desktop: lo stesso livello che esegue i tuoi agenti in locale, con il codice sorgente su GitHub.
Scarica Orkas — gratis

I livelli

Scomponendo un prodotto basato su agenti, si ottengono all’incirca questi livelli, disposti dal basso verso l’alto:

┌──────────────────────────────────────────────────────────────────────────────┐
│  Funzionalità del prodotto (chat / abilità / connettori / sincronizzazione)  │
├──────────────────────────────────────────────────────────────────────────────┤
│  Infrastruttura di esecuzione dell’agente (ciclo / strumenti / sessione)     │
├──────────────────────────────────────────────────────────────────────────────┤
│  Astrazione dei fornitori (unifica più fornitori di LLM)                     │
├──────────────────────────────────────────────────────────────────────────────┤
│  Infrastruttura (tipi / errori / log / configurazione)                       │
└──────────────────────────────────────────────────────────────────────────────┘

Qui è incorporata una scelta dalle conseguenze importanti: tutta l’inferenza del modello avviene sul client. L’app desktop non è un client leggero: contiene l’harness stesso e chiama direttamente il modello. Il server gestisce solo account, sincronizzazione tra dispositivi e fatturazione; non esegue affatto l’agente. Questa decisione ha determinato quasi tutto ciò che ne è seguito: le sessioni vengono salvate sul disco locale, gli strumenti operano direttamente nella directory di lavoro dell’utente e i dati sensibili non lasciano mai il computer.

L’harness stesso si divide in alcune parti: il ciclo di esecuzione (runner), la sessione, gli strumenti, il livello Provider e la memoria. Vediamole una alla volta.

Il ciclo di esecuzione: un generatore in streaming

Il cuore dell’harness è il runner. In una frase, ciò che fa è: parlare ripetutamente con il modello finché questo non dice "ho finito".

È implementato come generatore asincrono, e questa scelta conta. Una singola esecuzione dell’agente è molto più di "inviare una richiesta e attendere un risultato". Nel frattempo succedono molte cose: il modello emette token, vuole chiamare uno strumento, lo strumento ha terminato, il contesto è diventato abbastanza lungo da attivare la compattazione, la rete ha avuto un problema e stiamo riprovando. Con le callback o le semplici Promise, è difficile esporre questi stati intermedi in modo chiaro al chiamante. Con un generatore diventano tutti un flusso di eventi emessi tramite yield:

type AgentRunEvent =
  | { type: "text_delta"; text: string }              // model emitting tokens
  | { type: "tool_start"; name: string; input: unknown } // a tool starts executing
  | { type: "tool_end"; name: string; result: string }   // a tool finished
  | { type: "compaction"; tokensBefore: number; tokensAfter: number } // context compacted
  | { type: "retry"; attempt: number; reason: string }   // error, retrying
  | { type: "done"; result: AgentRunResult }             // terminal

L’interfaccia utente si iscrive a questo flusso di eventi e visualizza in tempo reale l’output del modello e l’esecuzione degli strumenti. Il punto di ingresso senza streaming, internamente, si limita a "consumare il flusso fino alla fine e prendere il done finale": entrambi i punti di ingresso condividono un’unica implementazione, quindi non esiste un secondo percorso di codice che possa perdere la sincronizzazione.

Cosa succede all’interno di un turno

Scomposto nei suoi passaggi, un turno si presenta all’incirca così:

  1. Inserire il messaggio dell’utente, eventualmente con immagini, nella cronologia della sessione.
  2. Comporre il prompt di sistema, inserendo gli strumenti attualmente disponibili, l’indice delle abilità e così via.
  3. Analizzare la stringa del modello e risolverla in un Provider concreto e in un ID di modello.
  4. Convertire tutti gli strumenti in definizioni comprensibili al modello e inviarle insieme alla cronologia.
  5. Consumare il flusso di risposta del modello, emettendo il testo token per token tramite yield e raccogliendo le eventuali chiamate agli strumenti effettuate dal modello.
  6. Quando il flusso termina, esaminare il motivo dell’arresto del modello:
  • Se è tool_use, il modello vuole chiamare uno strumento: eseguire gli strumenti, poi tornare al passaggio 5 e interrogare di nuovo il modello.
  • Altrimenti il turno è concluso: comporre il risultato, eseguire yield done e terminare.

Qui va mantenuta un’invariante: ogni chiamata a uno strumento effettuata dal modello deve essere immediatamente seguita nella cronologia dal risultato corrispondente dello strumento. L’API del modello impone rigorosamente questo abbinamento: se viene violato, la richiesta successiva produrrà un errore oppure resterà bloccata. Torneremo su questo punto quando parleremo del ripristino automatico delle sessioni.

Come viene restituito il risultato di una chiamata a uno strumento

Il modello non esegue direttamente gli strumenti; dice soltanto "vorrei chiamare read_file con questi argomenti". Una volta che il runner rileva questa intenzione:

for (const call of toolUseBlocks) {
  yield { type: "tool_start", name: call.name, input: call.input };

  const tool = this.tools.get(call.name);
  const ctx = { workingDir, signal, state: { sandboxEnv } };
  const result = await tool.execute(call.input, ctx);

  // append the result to the session as a tool-result message
  session.addToolResult(call.id, result);

  yield { type: "tool_end", name: call.name, result: result.content };
}

Gli strumenti vengono eseguiti in sequenza, i risultati vengono registrati nella cronologia nell’ordine dichiarato dal modello e poi il modello viene interrogato di nuovo, questa volta con quei risultati a disposizione. Dopo averli esaminati, può chiamare un altro strumento oppure fornire la risposta finale. Questo ciclo "interrogazione → chiamata → risposta → nuova interrogazione" è proprio ciò che consente a un agente di completare attività articolate in più passaggi.

Un dettaglio merita una menzione a parte: alcuni strumenti restituiscono immagini, come schermate acquisite o immagini generate. Molti modelli, però, non accettano immagini nel canale dei risultati degli strumenti. Orkas gestisce questa situazione separando l’immagine in un messaggio utente distinto, collocato dopo il risultato dello strumento: il modello legge prima "lo strumento ha restituito questo testo", poi, nel turno immediatamente successivo, vede l’immagine corrispondente. Un piccolo compromesso che aggira le differenze di capacità tra i Provider.

Cosa fare quando il contesto sta per superare il limite

L’ostacolo più frequente nelle attività lunghe è la finestra di contesto. Orkas non aspetta che sia piena: imposta una soglia del 60%. Dopo ogni ciclo di strumenti, stima quanta parte della finestra occupano i token attuali e, una volta superato il 60%, attiva preventivamente la compattazione.

La compattazione chiede al modello di riassumere la conversazione precedente, poi sostituisce i vecchi messaggi con quel riepilogo, conservando solo la parte finale più recente. Sembra semplice, ma c’è un’insidia: dopo la sostituzione, la parte conservata non deve iniziare con un "risultato di strumento orfano". Non può esserci un "risultato senza chiamata corrispondente", altrimenti l’invariante di abbinamento viene nuovamente violata. La logica di compattazione si assicura quindi che il taglio avvenga in un punto che non separi una chiamata dal suo risultato.

Qui c’è una scelta più interessante che vale la pena approfondire: perché adottare l’approccio grossolano "riassumere l’intero blocco al 60%", anziché qualcosa di più granulare, come assegnare un punteggio a ogni messaggio ed eliminarlo in base all’importanza, estrarre informazioni strutturate dagli output degli strumenti o mantenere un albero di memoria a più livelli? Questi approcci sembrano ottimi negli articoli di ricerca, ma abbiamo deliberatamente scelto un’altra strada, per tre ragioni.

La prima è la cache. La cache dei prompt del modello funziona per prefisso: finché il prefisso della cronologia rimane invariato, quel segmento viene recuperato dalla cache, con un risparmio sia sui costi sia sulla latenza. Una compattazione granulare riscrive continuamente la parte centrale della cronologia, invalidando ripetutamente il prefisso memorizzato nella cache: ogni modifica impone una nuova, ampia elaborazione preliminare del prompt. La strategia "lasciare tutto com’è, poi compattare una sola volta al raggiungimento della soglia" mantiene stabile il prefisso nella stragrande maggioranza dei turni, invalidandolo solo con quell’unica compattazione. È molto più favorevole alla cache.

La seconda è la complessità. L’invariante "ogni chiamata a uno strumento deve avere un risultato abbinato", su cui continuiamo a insistere: più si sfoltisce la cronologia in modo granulare, più è probabile violarla in qualche caso particolare. Un riepilogo complessivo deve proteggere un solo punto di taglio valido; i punti in cui si può sbagliare sono un ordine di grandezza in meno. Una categoria di casi limite in meno significa una categoria di incidenti in produzione in meno.

La terza è beneficiare dei miglioramenti dei modelli. Negli ultimi due anni le finestre di contesto sono cresciute costantemente, e i modelli gestiscono sempre meglio i contesti lunghi. Investire oggi in un elaborato algoritmo di compattazione significa, in sostanza, combattere un problema che si sta riducendo: è probabile che si finisca di ottimizzarlo proprio quando la generazione successiva raddoppia la propria finestra, trasformando quella complessità in un puro onere. Affidare invece il riepilogo al modello stesso porta miglioramenti automatici man mano che il modello migliora: più è capace di individuare ciò che conta, maggiore è la qualità del riepilogo, senza che noi cambiamo una riga. La complessità che il modello può gestire al posto tuo è complessità di cui non dovresti farti carico.

La stima dei token nasconde un problema facile da trascurare: il cinese. Se si stima il cinese usando le intuizioni valide per l’inglese, all’incirca un token ogni pochi caratteri, si ottiene un conteggio fortemente sottostimato. Nella sua stima, Orkas attribuisce un peso separato ai caratteri CJK; altrimenti, per una conversazione interamente in cinese, la soglia verrebbe valutata in modo errato e la compattazione non scatterebbe quando dovrebbe.

Errori e nuovi tentativi

Quando si opera sul computer dell’utente e si dipende da un’API esterna del modello, gli errori sono la norma, non l’eccezione. Il runner li suddivide in alcune categorie e le tratta ciascuna in modo diverso:

  • Per cui è possibile riprovare: limiti di frequenza, timeout, connessioni interrotte, 5xx. Attesa esponenziale con variazione casuale, fino a un massimo di 30 secondi; se si tratta di un limite di frequenza e il server ha inviato retry-after, rispettarlo.
  • Per cui non ha senso riprovare: casi come gli errori di autenticazione; nessun numero di tentativi può risolverli, quindi viene restituito immediatamente un errore.
  • Speciali: superamento della finestra di contesto. Tentare prima la compattazione, riprovare una volta dopo e restituire un errore solo se anche questo tentativo fallisce.

C’è un’altra categoria: "lo strumento stesso ha fallito". Questo non fa fallire l’intero turno: il fallimento di uno strumento è di per sé un’informazione per il modello, che, vedendo "quel comando ha prodotto un errore", può benissimo provare un approccio diverso. L’harness distingue questi errori transitori degli strumenti dai guasti veri e propri: non interrompe il flusso e non li perde; compaiono nelle statistiche a posteriori. (Questi dati alimentano poi il meccanismo di autoevoluzione, che sarà l’argomento del prossimo articolo.)

Il segnale esterno di annullamento (AbortSignal) viene controllato in ogni punto chiave. L’utente preme "Interrompi" e il turno corrente si arresta immediatamente, senza avviare nuovi tentativi.

Astrazione degli strumenti: abbastanza semplice da estendere

L’interfaccia degli strumenti è volutamente essenziale:

interface AgentTool {
  readonly name: string;
  readonly description: string;          // shown to the model
  readonly inputSchema: Record<string, unknown>;  // JSON Schema to constrain inputs
  execute(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}

Uno strumento è semplicemente "un nome + una descrizione per il modello + uno schema di input + una funzione di esecuzione". Gli strumenti integrati — lettura di file, scrittura di file, esecuzione di comandi shell, ricerca e recupero di contenuti web — implementano tutti questa interfaccia. Il livello desktop vi aggiunge un insieme di strumenti pensati per l’uso locale, come la ricerca nella base di conoscenza, la generazione di immagini e le chiamate a connettori esterni, ma l’interfaccia rimane la stessa.

Il vantaggio di un’interfaccia essenziale è che l’origine di uno strumento è irrilevante per il runner: integrato, definito dall’utente o caricato da un’abilità, si tratta sempre dello stesso tipo di oggetto, registrato in un’unica Map<string, AgentTool> e convertito in definizioni leggibili dal modello a ogni turno.

Gli strumenti con effetti collaterali, come i comandi shell, passano attraverso un esecutore isolato: timeout, limiti alla lunghezza dell’output, un elenco di comandi bloccati e variabili d’ambiente passate separatamente anziché modificare l’ambiente globale del processo. Quest’ultima modifica si propagherebbe a una moltitudine di processi figli e, in un’architettura multiprocesso come Electron, potrebbe facilmente compromettere l’avvio.

Il livello Provider: unificare molti modelli in un’unica interfaccia

Le preferenze degli utenti in fatto di modelli sono molto varie, e un prodotto non può vincolarsi rigidamente a un solo fornitore. Sotto l’harness, Orkas introduce un’astrazione Provider che unifica i modelli di fornitori diversi dietro un’unica interfaccia:

interface LLMProvider {
  readonly id: string;
  complete(params: CompletionParams): Promise<CompletionResult>;
  stream(params: CompletionParams): AsyncIterable<StreamEvent>;
  validateAuth(): Promise<boolean>;
}

Il runner soprastante comunica esclusivamente con questa interfaccia; non sa quale fornitore ci sia dietro. Un registro gestisce l’instradamento in base alla stringa del modello: una forma esplicita provider/model viene suddivisa direttamente; un semplice nome di modello viene attribuito in base al prefisso. Anche l’autenticazione, tramite chiave API o token OAuth, viene gestita qui, e un token OAuth scaduto viene rinnovato automaticamente.

Quando si unificano molti modelli, il vero grattacapo non è il completamento del testo: sono i casi particolari in cui le semantiche dei fornitori divergono. Ecco due esempi che ci hanno creato problemi.

Uno è preservare i blocchi di ragionamento tra fornitori diversi. I modelli di ragionamento emettono un segmento di contenuto di "pensiero"; alcuni fornitori lo cifrano e richiedono che venga restituito identico, altri lo rappresentano con un insieme diverso di campi. Se un utente passa dal fornitore A al fornitore B nel corso della conversazione, la firma di quel segmento di ragionamento nella cronologia non corrisponde più. La soluzione consiste nel contrassegnare ogni messaggio della cronologia con l’indicazione del "modello che lo ha prodotto", così che il livello di trasformazione possa decidere se conservarlo identico: stesso modello, conservarlo; modello diverso, convertirlo in una forma ridotta secondo le regole.

L’altro è la cache dei prompt. Tra i turni di una stessa sessione il prefisso si ripete molto, e memorizzarlo nella cache consente risparmi significativi sui costi e sulla latenza. L’implementazione passa l’ID della sessione come chiave di cache ai fornitori che lo supportano, gestendo anche i limiti di lunghezza della chiave imposti da ciascun fornitore, per esempio troncandola o calcolandone l’hash se è troppo lunga.

È tutto lavoro tedioso, ma è proprio questo livello di lavoro tedioso che permette al runner soprastante di comportarsi come se "esistesse un solo tipo di modello".

Memoria: due meccanismi, ciascuno con il proprio compito

La "memoria" in Orkas consiste in realtà in due meccanismi paralleli che risolvono due problemi completamente diversi. Uno è una base di conoscenza fondata sul recupero di informazioni, per il materiale voluminoso da "andare a consultare quando serve". L’altro è la memoria tra sessioni, per il piccolo insieme di fatti chiave da "tenere sempre a mente". Molti prodotti mescolano questi due meccanismi; mantenerli separati rende tutto molto più chiaro.

Base di conoscenza: recupero ibrido

Il primo meccanismo si rivolge a contenuti voluminosi ma rilevanti solo occasionalmente: i documenti dell’utente, gli appunti passati, le conoscenze di settore. Si tratta di una base di conoscenza locale con recupero vettoriale, disponibile in due backend: una versione leggera interamente in memoria, per i test e gli usi temporanei, e una versione persistente in un database locale, per la produzione, con indicizzazione a testo completo e vettori.

I dati entrano seguendo questo percorso:

documenti → suddivisione ai confini delle righe (con sovrapposizione) → doppia indicizzazione
                                                                      ├─ indice di testo completo (parole chiave, nessun costo di embedding)
                                                                      └─ indice vettoriale (se è configurato un modello di embedding)

I segmenti vengono tagliati in corrispondenza dei confini tra righe, con una piccola sovrapposizione, per evitare di spezzare a metà un’unità di significato completa. Il recupero è ibrido: una ricerca vettoriale, per la vicinanza semantica, e una ricerca per parole chiave, per le corrispondenze letterali, con i due insiemi di risultati combinati tramite RRF (Reciprocal Rank Fusion):

score = Σ  1 / (k + rank_i)

Più in alto si posiziona un risultato in una delle ricerche, maggiore è il suo contributo; sommando i contributi di entrambe, si rispetta la rilevanza semantica senza perdere le corrispondenze letterali esatte. I pesi di vettori e parole chiave sono regolabili e, per impostazione predefinita, favoriscono la semantica. Dopo l’unione, i risultati vengono deduplicati in base a "(documento, riga iniziale)", conservando solo il migliore per ogni posizione; poi si eliminano quelli al di sotto di una soglia e si restituiscono i primi K.

Perché non affidarsi solo ai vettori? Perché il recupero vettoriale spesso fallisce con nomi propri, simboli del codice e stringhe letterali esatte: ricerche che non hanno nulla di particolare dal punto di vista semantico, ma in cui il testo esatto conta molto. La ricerca basata solo sulle parole chiave, invece, non coglie "lo stesso significato espresso in modo diverso". Usarle entrambe è un compromesso molto pratico tra qualità del recupero e costo.

Memoria tra sessioni: tenere a mente l’utente

La base di conoscenza risolve il problema del "troppo materiale da tenere in memoria". Esiste però un’altra categoria di informazioni, di volume minimo, che deve essere sempre tenuta a mente: chi è questo utente, cosa preferisce, cosa è stato concordato l’ultima volta. Queste informazioni non dovrebbero dipendere dal recupero per "essere richiamate per fortuna": dovrebbero essere presenti a ogni singolo turno.

Per questo, Orkas costruisce un livello separato di memoria tra sessioni, suddiviso in due parti in base al contenuto:

  • Profilo utente: fatti stabili sulla persona — ruolo, preferenze, stile comunicativo, stack tecnologico.
  • Note sui fatti: fatti duraturi sul lavoro — decisioni, traguardi, convenzioni del progetto.

Entrambe le parti sono piccole, ciascuna con un limite rigido di poche migliaia di caratteri, che le obbliga a conservare solo ciò che è davvero utile a lungo termine. Non passano attraverso il recupero: vengono invece fissate direttamente nel prompt di sistema all’inizio di ogni turno. Questo significa che l’agente semplicemente "sa" queste cose, senza dover ricordarsi di andarle a cercare. È un’impostazione esattamente opposta a quella della base di conoscenza: la base di conoscenza è "recuperare solo quando serve, poi rimuovere"; la memoria tra sessioni è "sempre presente, sempre visibile".

Le scritture passano attraverso uno strumento di memoria dedicato che il modello chiama quando ritiene, durante la conversazione, che "valga la pena ricordare questo a lungo termine". Lo strumento supporta aggiunta, sostituzione di sottostringhe ed eliminazione. Cosa salvare e cosa no è indicato chiaramente nella descrizione dello strumento: le correzioni e le preferenze dell’utente hanno la massima priorità; decisioni e convenzioni durature vengono salvate; lo stato transitorio dell’attività corrente, le informazioni di debug occasionali e tutto ciò che può essere facilmente riscoperto, invece, no. La memoria serve per i "fatti duraturi sull’utente e sul progetto", non per "il punto a cui sono arrivato questa volta".

C’è un dettaglio facile da trascurare ma piuttosto importante: prima di ogni scrittura viene eseguita una scansione di sicurezza. Questo contenuto entra nel prompt di sistema senza modifiche e persiste a lungo tra le sessioni: costituisce di fatto una superficie duratura per l’iniezione di istruzioni. Ogni informazione di memoria che sta per essere scritta su disco viene quindi prima analizzata alla ricerca di schemi sospetti: formulazioni classiche di prompt injection, come "ignora tutte le istruzioni precedenti" e simili, comandi che tentano di esfiltrare chiavi, caratteri Unicode invisibili nascosti nel testo. Se viene rilevata una corrispondenza, la scrittura viene respinta del tutto. Con l’aggiunta della deduplicazione e della riduzione dei contenuti che superano il limite, questo livello di memoria rimane utile senza diventare un rischio.

Insieme, i due meccanismi coprono entrambi gli estremi: "grande ma occasionale" e "piccolo ma costante". La base di conoscenza gestisce il primo, la memoria tra sessioni il secondo. Aggiungendo la comprensione che l’agente ha di sé stesso, argomento del prossimo articolo, un agente Orkas si presenta con tre tipi di memoria contemporaneamente: sul materiale, sull’utente e su sé stesso.

Sessioni: progettate per resistere agli arresti anomali e ripristinarsi

Una sessione gestisce la cronologia dei messaggi. La versione di base è semplicemente un array di messaggi in memoria, con riduzione e compattazione della cronologia. Ma qualsiasi cosa venga eseguita sul computer di un utente deve presumere di poter essere terminata in qualsiasi momento: l’utente chiude l’app, il sistema si riavvia, un timeout del watchdog termina il processo. Per questo, in produzione viene usata una sessione persistente, scritta in un file JSONL locale, con un messaggio per riga.

Esistono due strategie di scrittura: l’aggiunta di un nuovo messaggio usa un’aggiunta atomica; qualsiasi operazione che riscriva l’intero file, come compattazione e svuotamento, usa "scrittura di un file temporaneo + rinomina atomica". In questo modo, anche se l’alimentazione si interrompe durante la scrittura, non rimane mai una registrazione parziale e corrotta.

La parte più interessante è il ripristino delle chiamate orfane agli strumenti. Torniamo all’invariante di abbinamento: il modello effettua una chiamata a uno strumento, l’harness la esegue e il risultato viene registrato. Se si interrompe uno qualsiasi di questi tre passaggi, sul disco rimane un elemento orfano, "una chiamata senza risultato". Se alla successiva apertura si carica quella sessione e la si invia al modello così com’è, l’API la rifiuterà oppure resterà bloccata.

La logica di ripristino viene eseguita ogni volta che una sessione viene caricata dal disco ed è idempotente:

  1. Esaminare tutti i messaggi dell’assistente e raccogliere gli ID delle chiamate agli strumenti che contengono.
  2. Cercare nei messaggi successivi i risultati corrispondenti degli strumenti.
  3. Per ogni chiamata priva di un risultato corrispondente, sintetizzarne uno contrassegnato come "interrotto".
  4. Nel frattempo, allineare l’ordine dei risultati all’ordine di dichiarazione delle chiamate ed eliminare gli eventuali risultati orfani senza una chiamata corrispondente.

Dopo questo passaggio, è garantito che la sessione soddisfi il requisito di abbinamento dell’API e possa essere inviata senza problemi. Il meccanismo sembra ordinario, ma è la rete di sicurezza che garantisce che "la conversazione di un utente non si blocchi in modo permanente per un singolo arresto anomalo".

Alcune decisioni che, a posteriori, hanno fatto la differenza

Mettendo insieme tutti questi elementi, alcune decisioni si sono rivelate particolarmente preziose a posteriori.

Generatori come interfaccia principale. Le modalità con e senza streaming condividono un’unica implementazione, lo stato intermedio emerge naturalmente e l’interfaccia utente può mostrare tutti i dettagli che desidera. Questo ha evitato un’intera categoria di bug di incoerenza che sarebbero nati "implementando prima la modalità senza streaming e aggiungendo lo streaming in seguito".

Compattare al 60%, senza aspettare che la finestra sia piena. Lascia margine per la compattazione stessa, che richiede a sua volta una chiamata al modello, ed evita di dover correre ai ripari all’ultimo momento.

L’invariante di abbinamento attraversa tutto il sistema. Dal punto di taglio della compattazione alla scrittura su disco, fino al ripristino al caricamento, ogni parte che interviene sulla sessione rispetta la stessa regola. Con un’unica regola, nessun punto deve inventare una propria logica correttiva.

Lavoro tedioso concentrato nel livello Provider. Tutte le complicazioni tra fornitori — blocchi di ragionamento, chiavi di cache, differenze di capacità — vengono assorbite da questo unico livello, lasciando pulito il runner soprastante. Se un giorno si aggiunge un nuovo fornitore di modelli, la modifica resta quasi interamente confinata lì.

Conclusione

L’harness di Orkas non contiene algoritmi strabilianti. Il suo valore sta nel prendere l’obiettivo "far funzionare un agente in modo affidabile in un ambiente reale" e suddividerlo in un insieme di moduli dai confini chiari, ciascuno responsabile di una parte: il runner gestisce il ciclo e i nuovi tentativi, gli strumenti gestiscono le capacità, il livello Provider gestisce l’unificazione dei modelli, la memoria gestisce il recupero delle informazioni, la sessione gestisce la persistenza e il ripristino. Nessuno di questi moduli è complesso di per sé; solo insieme sostengono qualcosa che le persone usano ogni giorno.

Se c’è una lezione da trarre, è questa: rendere il ciclo di esecuzione un generatore in streaming semplifica molto la gestione degli stati intermedi; una volta stabilita un’invariante fondamentale, come "le chiamate agli strumenti devono essere abbinate", mantenerla coerentemente durante compattazione, scritture su disco e caricamento, senza ammettere eccezioni in alcun punto; concentrare il lavoro tedioso legato alle differenze tra fornitori in un unico livello, tenendolo fuori dalla logica applicativa; e, soprattutto, presumere che il processo verrà terminato nel peggior momento possibile e scrivere in anticipo la logica di ripristino per quel momento.

Il prossimo articolo approfondirà una parte più interessante di Orkas: come questo agente impara dal proprio utilizzo, distilla l’esperienza in abilità riutilizzabili e diventa gradualmente più utile.