Costruire un agent harness: il loop e le sue cinque vie d’uscita
Un loop di 15 righe che funziona subito, poi rotto apposta sette volte: a partire da una fuga costata 77 volte più del limite stretto.
In questa pagina
Cominciamo dalla parte onesta, perché nessun altro lo dirà: «harness» è gergo, non uno standard. Non esiste una specifica, né un comitato, né una definizione di riferimento. I quattro paper citati in questo capitolo — ReAct,1 CoALA,2 SWE-bench e vLLM — non usano mai la parola nei loro abstract. L’implementazione più scaricata della cosa, il pacchetto ai di Vercel con 89,4 milioni di download al mese, non la usa nemmeno: la stringa harness compare zero volte nei 397 KB di dichiarazioni di tipo distribuite dalla versione 7.0.93.3 L’unico posto in cui la parola ha un ruolo portante significa tutt’altro. SWE-bench dice «harness» cinque volte nel suo README, sempre come evaluation harness — l’impalcatura containerizzata che applica una patch ed esegue i test — e il suo modulo Python è letteralmente swebench.harness.run_evaluation.4
Quindi due cose diverse condividono un nome. Un evaluation harness tiene fermo l’agent e lo valuta. Un agent harness è il programma che esegue l’agent: chiama il modello, esegue ciò che il modello chiede, decide quando fermarsi e conserva lo stato nel mezzo. Questo capitolo costruisce il secondo, in meno di duecento righe di TypeScript, senza alcun framework.
Il loop in sé è di quindici righe e funziona al primo tentativo. Tutto quello che viene dopo è un modo per uscirne.
Mostra dettagli
Cosa serve a questo capitolo dai precedenti.
- Capitolo 14 per il client: scadenze, triage degli stati, cancellazione, chiavi di idempotenza e la tecnica del provider mock riutilizzata qui.
- Capitolo 16 per l’aritmetica: gli input token crescono con il quadrato della conversazione, e le tariffe usate sotto sono quelle lette lì il 6 settembre 2026.
- Capitolo 18 per il catalogo degli strumenti: uno schema che il modello vede, un endpoint che non vede mai, e la regola secondo cui gli errori sono contesto, non eccezioni.
- Capitolo 22 per il loop che questo eredita, e per le due definizioni pubblicate di «agent» che non concordano tra loro.
Niente tensori qui. Questo è il secondo hub di dipendenze del corso: i Capitoli 24, 25, 29 e 30 girano sul file qui sotto, e dal 26 al 28 si costruisce su ciò che può raggiungere.
Un provider che puoi scriptare
Link alla sezione: Un provider che puoi scriptareIl Capitolo 14 non poteva essere scritto contro un provider reale, perché non puoi chiedergli un 429 in un momento scelto. Questo capitolo ha lo stesso problema in una forma diversa: non puoi chiedere a un modello reale di scappare via, o di richiedere due volte di fila lo stesso identico strumento, su richiesta e in modo riproducibile.
Quindi il primo programma è un provider scriptato: un endpoint con la forma di una chat completions API la cui risposta è funzione dell’indice del turno e di ciò che gli strumenti hanno restituito finora. Conta i token con un vero encoder byte-pair, quindi i soldi qui sotto sono aritmetica e non decorazione.
const SCRIPTS = {
// A well-behaved task: list, read, answer.
plan: (t) =>
t === 0 ? asks(call("c1", "list_files", {}))
: t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
: text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),
// Never declares itself done.
runaway: (t) => asks(call(`c${t}`, "list_files", {})),
// Guesses a file name, then corrects itself IF it was told what happened.
recover: (t, all) =>
t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
: /Call list_files/.test(all)
? (t === 1 ? asks(call("c2", "list_files", {}))
: t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
: text("errors.log mentions a timeout."))
: text("I could not read the file, so I do not know."),
};
const turn = messages.filter((m) => m.role === "assistant").length;
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);Due righe reggono il design. L’indice del turno è derivato dalla conversazione, non tenuto in una variabile, quindi il provider è stateless e un’esecuzione può essere uccisa e ripresa contro di esso. E recover legge i risultati degli strumenti prima di decidere: un modello scriptato che legge la propria trascrizione è il minimo necessario per misurare se il harness gli ha dato qualcosa che valesse la pena leggere.
Il catalogo è quello del Capitolo 18, quattro strumenti su tre file: list_files, read_file, delete_file — marcato needsApproval — e scan_archive, che è lento di proposito.
Il loop che funziona
Link alla sezione: Il loop che funzionaEcco l’idea intera, prima di qualunque parte che la renda sopravvivibile.
while (true) {
const reply = await callModel(base, messages, tools, signal);
messages.push(reply.message);
const calls = reply.message.tool_calls ?? [];
if (!calls.length) return reply.message.content;
for (const c of calls) {
const tool = byName.get(c.function.name);
const result = await tool.run(JSON.parse(c.function.arguments));
messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
}
}Puntalo al provider scriptato e fa esattamente ciò che sembra fare:
plan, cap 20 turns=3 tools=2 in=815 out=70 cost=$0.002470 ms=89 status=completed
answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
per-turn prompt tokens: 204, 269, 342Tre turni, due esecuzioni di strumenti, un quarto di centesimo di dollaro. Nota l’ultima riga: 204, 269, 342. Ogni turno reinvia tutto quello che lo precede, cioè la fattura quadratica del Capitolo 16 che arriva in un posto dove nessuno ha digitato nulla. Il resto di questo capitolo è ciò che succede quando quella riga non smette di crescere.
Rottura uno: il task che non finisce mai
Link alla sezione: Rottura uno: il task che non finisce maiPunta lo stesso loop allo script runaway — un modello che chiede uno strumento a ogni singolo turno e non emette mai prosa — e il return marcato non scatta mai. Non c’è altra uscita. Il programma gira finché muore il processo o la carta di credito.
La correzione è una riga, è il primo controllo raccomandato dalla letteratura,5 e prima o poi la scrivono tutti. Quello che quasi nessuno fa è misurare quanto vale:
| limite turni | chiamate al modello | input tokens | costo |
|---|---|---|---|
| 8 | 8 | 3.431 | $0.009070 |
| 20 | 20 | 16.259 | $0.038038 |
| 50 | 50 | 88.649 | $0.191098 |
| 100 | 100 | 337.299 | $0.702198 |
Leggi insieme le ultime due righe. Raddoppiare il limite da 50 a 100 non ha raddoppiato il costo; lo ha moltiplicato per 3,7. Gli input token sono passati da 88.649 a 337.299, un fattore 3,8, perché il turno porta con sé ogni turno precedente e il totale è . Un limite di turni non è una manopola lineare. È una manopola sulla radice quadrata del tuo caso peggiore, ed è per questo che alzarla da 20 a 100 «solo per sicurezza» è una decisione da prezzare prima di prenderla.
Rottura due: un limite sui turni non è un limite sui soldi
Link alla sezione: Rottura due: un limite sui turni non è un limite sui soldiIl problema di un limite sui turni è che un turno non ha un prezzo fisso. Venti turni su una trascrizione breve costano $0.038 sopra. Venti turni con un catalogo da 200 strumenti, un set di documenti recuperati e quaranta messaggi di cronologia costano centinaia di volte tanto, e il limite non lo sa. Ciò che l’operatore vuole limitare è la fattura.
Quindi il loop conta i soldi, usando il computeCost del Capitolo 16 contro le tariffe lette lì — $2.00 per milione di input token e $12.00 per milione di output, per il modello prezzato in tutto questo corso:
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;
// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" });
// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);Stesso script in fuga, nessun limite di turni, tre budget:
| budget | turni raggiunti | spesa effettiva |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
Vale la pena dare un nome a due cose. Primo, il budget compra ogni volta un numero diverso di turni, ed è questo il punto: limita ciò che interessa all’operatore e lascia che il conteggio dei turni cada dove lo mette la trascrizione. Secondo, ogni riga sfora. Il budget era $0.010 e sono stati spesi $0.010780, perché il controllo gira prima di un turno e il prezzo di un turno non è noto finché non è finito. Non puoi limitare la spesa esattamente; puoi limitarla entro il costo di un turno. Dillo nell’interfaccia invece di fingere, e metti il controllo prima della chiamata così lo sforamento è di un turno e non di due.
Cinque modi per uscire dal loop, non uno
Link alla sezione: Cinque modi per uscire dal loop, non unoA questo punto il loop ha tre uscite, e la forma del resto del capitolo è visibile. Un’esecuzione di produzione finisce esattamente in uno di cinque modi, e non sono variazioni della stessa cosa:
| come finisce | chi ha deciso | cosa dovrebbe fare il chiamante |
|---|---|---|
| il modello ha smesso di chiedere | il modello | leggere la risposta |
| limite turni | tu, in anticipo | alzare il limite, o accettare un risultato parziale |
| budget esaurito | tu, in anticipo | approvare più soldi, o accettare un risultato parziale |
| un errore che non puoi ritentare | il provider o uno strumento | correggere il deploy; decide il triage del Capitolo 14 |
| è intervenuto un umano | una persona | attendere un verdetto, poi riprendere |
Collassare tutto in un boolean è l’errore di design più comune in questo file, ed è costoso in un modo specifico: tre dei cinque casi sono riprendibili e due no. Un agent che ha raggiunto il limite di turni ha una trascrizione valida, un risultato parziale reale e un passo successivo; un agent che ha ricevuto un 401 non ha nulla di tutto questo. Quindi il harness registra la ragione come dato:
export type RunStatus =
| "running" | "completed" | "failed"
| "max_turns_exceeded" | "budget_exceeded" | "interrupted";
export type Interruption =
| { type: "approval"; callId: string; toolName: string; args: unknown }
| { type: "max_turns" } | { type: "max_budget" }
| { type: "cancelled"; reason: string };Rottura tre: uno strumento fallisce
Link alla sezione: Rottura tre: uno strumento fallisceIl Capitolo 18 si chiudeva con un’affermazione senza numero: restituisci l’errore di uno strumento al modello come risultato dello strumento invece di sollevarlo, e di solito il modello si corregge da solo. Ecco il numero.
Un fallimento, tre policy. Il modello scriptato indovina un file che non esiste; lo strumento lancia no such file: timeout.log. Call list_files to see what exists.
| cosa fa il harness con l’errore | turni | esecuzioni strumento | costo | cosa ha ricevuto l’utente |
|---|---|---|---|---|
| lo lancia fuori dal loop | 1 | 1 | $0.000756 | uno stack trace |
restituisce Error: the tool failed. | 2 | 1 | $0.001462 | «Non sono riuscito a leggere il file, quindi non lo so.» |
| restituisce ciò che è successo davvero | 4 | 3 | $0.003550 | «errors.log cita un timeout.» |
La terza riga costa 4,7 volte la prima ed è l’unica che risponde alla domanda. E la seconda riga è quella interessante, perché è ciò che la maggior parte dei codebase fa davvero: l’errore è stato catturato, il loop è sopravvissuto, al modello è stato detto che qualcosa è fallito e non cosa, e lui si è arreso educatamente. La differenza tra la seconda e la terza riga non è error handling. È una frase scritta per un lettore.
Il harness quindi tratta uno strumento che lancia come dato, e rende la formulazione una policy:
} catch (err: any) {
if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);
}Il Capitolo 18 avvertiva anche dell’altro lato, e anche quello ha un prezzo. Punta il loop a uno strumento che fallisce per una ragione che nessun messaggio può correggere — una lettura che il processo non è autorizzato a fare — e il modello la ritenta per sempre:
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""Undici esecuzioni identiche di una chiamata che non può riuscire, 5,2 volte il costo dell’esecuzione che si è ripresa da un errore correggibile, e niente alla fine. Gli errori sono contesto; un errore permanente è contesto che avvelena il resto dell’esecuzione. La distinzione è il triage degli stati del Capitolo 14 spostato di un livello: un errore su cui il modello può agire torna nella trascrizione, e un errore su cui non può agire dovrebbe fermare l’esecuzione con una ragione. Il limite di turni è ciò che oggi sta tra te e il secondo caso, ed è un pavimento, non una correzione.
Rottura quattro: la stessa chiamata, due volte
Link alla sezione: Rottura quattro: la stessa chiamata, due volteOra il fallimento che la maggior parte delle persone dà per impossibile. I modelli si ripetono. Chiedi a qualunque loop di girare abbastanza a lungo e vedrai lo stesso identico strumento con gli stessi identici argomenti in due turni consecutivi.
Misurato rispetto alla baseline dello stesso task senza ripetizione:
| turni | esecuzioni strumento | costo | |
|---|---|---|---|
| il task, senza ripetizione | 2 | 1 | $0.001396 |
| lo stesso task, una chiamata ripetuta | 3 | 2 | $0.002446 |
| ripetuta, con una cache dei risultati sugli strumenti read-only | 3 | 1 | $0.002446 |
La chiamata duplicata è costata $0.001050 in più, un aumento del 75%, ed ecco la parte che sorprende: mettere in cache il risultato non ne ha recuperato nulla. La deduplicazione ha risparmiato l’esecuzione dello strumento e non il turno, perché quando il tuo codice nota la ripetizione il modello è già stato pagato per averla chiesta. Il risparmio è reale quando lo strumento è lento, soggetto a rate limit o fatturato per chiamata — ed è zero sulla voce di costo che è cresciuta.
C’è una versione peggiore. Applica la stessa cache a uno strumento che scrive, e la seconda chiamata silenziosamente non avviene:
naive cache on every tool 3 turns, 1 tool run, files deleted: ["access.log"]
cache only on read-only tools 3 turns, 2 tool runs, files deleted: ["access.log","access.log"]Quale delle due è corretta? Nessuna, in modo conoscibile. Il protocollo dice che sono due chiamate: portano due valori tool_call_id diversi. Gli argomenti dicono che potrebbero essere una. Un harness che decide confrontando stringhe di argomenti un giorno inghiottirà la seconda di due addebiti identici e intenzionali — e il Capitolo 14 ha già nominato l’unico meccanismo che risolve onestamente la questione, cioè una chiave di idempotenza generata per operazione logica dal livello che sa che cosa è l’operazione. Finché lo strumento non ne porta una, il default difendibile è il gate read-only qui sopra: metti in cache le letture, esegui le scritture e lascia che l’idempotenza propria della scrittura gestisca il resto.
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {
state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
continue;
}Rottura cinque: elimina qualcosa
Link alla sezione: Rottura cinque: elimina qualcosaLo script destructive elenca i file e poi chiede di eliminarne uno che il task non aveva mai menzionato. Finora, niente nel loop lo fermerebbe.
Uno strumento marcato needsApproval non fallisce e non procede. Ferma l’esecuzione e restituisce il controllo, con tutto ciò che serve a una persona per decidere:
if (tool.needsApproval && !state.approved.includes(c.id)) {
trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3 deleted=["access.log"] "Deleted access.log to free space."
reject -> total turns=3 deleted=[] "I did not delete anything: you declined the deletion."Questo è l’intero meccanismo, e il motivo per cui è un return invece di una callback è la sezione successiva: tra lo stop e il verdetto, il processo potrebbe non esistere più.
Ma prima, la misura che nessuno si aspetta. Un rifiuto non è assenza di risultato — la trascrizione ha uno slot indicizzato da tool_call_id e qualcosa deve entrarci. Esegui due volte lo stesso rifiuto, cambiando solo ciò che quel qualcosa dice:
rejected with a reason deleted=[] the agent then told the user:
"I did not delete anything: you declined the deletion."
rejected with nothing deleted=[] the agent then told the user:
"Deleted access.log to free space."In nessuna delle due esecuzioni è stato eliminato qualcosa, e nella seconda all’utente viene detto che è successo. Il sistema di permessi ha funzionato perfettamente; il report è una bugia. È lo stesso meccanismo della tabella sugli errori degli strumenti, che arriva in un punto molto più importante — un umano ha detto no, l’azione è stata bloccata correttamente, e il riepilogo dell’agent contraddice la realtà perché il rifiuto non è mai stato scritto dove il modello legge. La regola che ne deriva è breve: qualunque cosa il tuo codice decida su una chiamata a uno strumento, scrivi la decisione nella trascrizione, a parole. Il Capitolo 30 torna su questo dal lato sicurezza, dove è la differenza tra audit trail e finzione.
Rottura sei: il processo muore
Link alla sezione: Rottura sei: il processo muoreUn’approvazione richiede minuti o ore. Un deploy richiede secondi. Se l’esecuzione vive in una variabile locale dentro una richiesta HTTP, ogni riavvio è un’esecuzione persa e ogni approvazione è una corsa.
Quindi l’esecuzione non è una closure. È un semplice oggetto serializzabile — messaggi, conteggio dei turni, costo, stato, interruzione, lista degli id di chiamata approvati — e il loop è una funzione pura su di esso. Quel singolo vincolo è ciò che rende la persistenza una questione da una riga:
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));La domanda di correttezza non è salvare. È cosa succede al rientro, e la risposta ingenua ti fa pagare due volte. Se il processo è morto dopo che il modello ha chiesto uno strumento ma prima che il risultato fosse scritto, una ripresa che inizia chiamando di nuovo il modello paga per un turno che ha già — e se inizia rieseguendo gli strumenti, esegue una scrittura due volte.
La correzione è far iniziare il loop chiedendo alla trascrizione cosa è in sospeso:
export function pending(state: RunState): ToolCall[] {
const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
const last = state.messages.at(-1);
if (last?.role !== "assistant") return [];
return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));
}Ogni iterazione svuota prima pending e chiede al modello solo quando non c’è nulla in sospeso. La ripresa diventa lo stesso percorso di codice del caso normale, e così anche l’approvazione — una chiamata approvata è semplicemente una chiamata pendente che ora può essere eseguita. Uccidi il processo a metà task e riavvialo:
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2 cost=$0.001570 messages=6 status=running
resumed and finished: turns=3 cost=$0.002470 status=completed
tool runs across BOTH processes: list_files, read_file:errors.logDue esecuzioni di strumenti attraverso due processi per un task che ne richiede due, e il costo finale è identico all’esecuzione che non è mai crashata. Il costo si accumula attraverso il riavvio perché era nello stato, non in una variabile.
Rottura sette: tre minuti di silenzio
Link alla sezione: Rottura sette: tre minuti di silenzioscan_archive qui impiega tre secondi e rappresenta lo strumento che in produzione impiega tre minuti. Mentre gira mancano due cose: l’utente non ha idea che stia succedendo qualcosa, e il pulsante Stop non fa nulla.
Entrambe hanno la stessa correzione, ed è il AbortSignal del Capitolo 14 spinto un livello più in profondità. Il segnale non è solo per il fetch — viene passato dentro lo strumento, e uno strumento ben scritto lo rispetta:
result = await tool.run(JSON.parse(c.function.arguments), {
signal,
progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});progress: scanned 200 of 1200 files (t+506 ms)
progress: scanned 400 of 1200 files (t+1007 ms)
no cancellation: stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"Due millisecondi dal clic allo stop, perché lo sleep dentro lo strumento ascolta lo stesso segnale del fetch. Inseriscilo solo in fetch e lo stesso pulsante Stop aspetta tre secondi — la durata dello strumento — e l’esecuzione si «cancella» dopo che il lavoro che stava annullando è già finito. Una cancellazione che non è cablata fino in fondo è uno spinner che dice la parola giusta.
La trace, e perché non è un log
Link alla sezione: La trace, e perché non è un logIl harness emette una riga per evento, e il vocabolario è abbastanza piccolo da memorizzare: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped.
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}Tre proprietà la rendono una trace invece di logging. Ogni riga porta il run id, quindi un’esecuzione che attraversa tre processi e due giorni è una sola query. Ogni riga turn porta i propri conteggi di token e il costo progressivo, quindi «perché questa esecuzione è costata quaranta dollari» è una domanda a cui si può rispondere dopo il fatto invece che riprodurre solo in teoria. E run_stopped porta la ragione, cioè il campo che trasforma un ticket di supporto in una risposta da una riga: un agent che si è fermato al budget e un agent che è crashato sembrano identici dall’esterno e richiedono risposte opposte.
L’aritmetica della latenza
Link alla sezione: L’aritmetica della latenzaIl Capitolo 13 ha misurato il tempo al primo token su hardware tuo. Il Capitolo 14 lo ha misurato attraverso un socket. Un agent lo moltiplica, e il moltiplicatore è un numero che nessuno ha scelto:
Lo stesso task da tre turni, cambiando solo la latenza del provider:
| latenza del provider per turno | tempo reale, 3 turni |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
Il harness stesso contribuisce quindici millisecondi a un’esecuzione di tre turni. Tutto il resto è moltiplicato per un numero che non controlli — impostato dentro uno scheduler di serving che mette in batch la tua richiesta con le richieste di sconosciuti6 — e è scelto dal modello. Ecco perché lo streaming del Capitolo 14 qui conta più che in una chat e aiuta meno: puoi fare streaming del turno finale, e i quattro turni prima sono silenzio a meno che il harness non emetta progresso. È anche l’intero argomento a favore dell’evento tool_progress qui sopra — in un agent, l’unità onesta di feedback non è il token, è lo step.
Lo stesso harness, con un modello reale dietro la porta
Link alla sezione: Lo stesso harness, con un modello reale dietro la portaTutto quanto sopra è girato contro un provider scriptato, che prova il harness e non prova nulla sui modelli. Quindi cambia una riga — la giunzione del Capitolo 14, LLM_BASE_URL — e punta lo stesso codice a un Qwen2.5-0.5B-Instruct locale con gli stessi quattro strumenti. Sei task sugli stessi tre file:
turns=2 tools=1 wall= 15,260ms Which file mentions a timeout? -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms List the files and then read each one.
turns=2 tools=1 wall= 10,698ms Which file is the largest? -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms Is there a file about rotating logs?
TOTAL turns=12 toolruns=7 wall=82,896ms mean turn=6,908msTre risultati, e il terzo è la ragione per cui questa sezione esiste.
Ogni singolo task è finito esattamente in due turni. Il limite di turni non è mai scattato, il budget non è mai scattato, e l’unica uscita del loop è stata il modello che produceva prosa. Un modello da mezzo miliardo di parametri non itera; risponde al suo secondo respiro, che abbia ciò che gli serve oppure no. Il conteggio dei turni è una proprietà del modello, non del tuo loop.
Il turno medio ha richiesto 6.908 millisecondi, quindi la tabella della latenza sopra non è un giocattolo: a questa dimensione, un’ipotetica esecuzione da otto turni è quasi un minuto di wall clock senza nulla sullo schermo.
E le risposte sono sbagliate. Il file più grande è errors.log; il modello ha elencato i file, non li ha mai letti e ne ha nominato uno comunque. Il primo task ha indovinato un nome file, gli è stato detto che non esisteva, e ha concluso. Il harness ha funzionato impeccabilmente in tutte e sei le esecuzioni. Un harness rende un agent governabile, non corretto — il Capitolo 29 è come scopri quale dei due, e il Capitolo 30 è quanto costa quando nessuno l’ha fatto.
Subagents, nominati qui e addebitati dopo
Link alla sezione: Subagents, nominati qui e addebitati dopoUno strumento nel catalogo può avere un’altra esecuzione dietro. L’interfaccia è quella del Capitolo 18 — uno schema e un endpoint — e un intero agent ci sta dietro perché quell’interfaccia è stretta:
const research: Tool = {
name: "research",
description: "Investigate one question and return a short summary.",
parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
readOnly: true,
async run(args, ctx) {
const child = newRun(RESEARCH_SYSTEM, args.question); // its own transcript
const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
return out.output ?? "no result";
},
};Tre cose sono già giuste in quelle dieci righe e tutte e tre sono conseguenze delle decisioni prese sopra: il figlio ha la propria window, quindi la trascrizione del genitore riceve un riepilogo invece di tutto ciò che il figlio ha letto; ha i propri limiti, quindi un figlio in fuga non può spendere il budget del genitore; ed eredita il segnale, quindi uno Stop cancella l’albero. Perché una window pulita sia il punto e non un effetto collaterale è nel Capitolo 24; i cinque pattern di orchestrazione — prompt chaining, routing, parallelizzazione, orchestrator-workers, evaluator-optimiser — e l’handoff sono nel Capitolo 25.
Dove sono i framework, e perché questo corso non ne ha usato uno
Link alla sezione: Dove sono i framework, e perché questo corso non ne ha usato unoNulla di quanto sopra va letto come un argomento contro le librerie. Misurato il 7 settembre 2026, per il mese concluso il 29 agosto:7
| package | download in quel mese | cosa ti dà |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, approvazione strumenti, step hooks |
@anthropic-ai/claude-agent-sdk | 41.558.352 | il Claude Code harness come libreria: loop, sessioni, hooks, permessi, subagents8 |
@langchain/langgraph | 12.812.815 | il loop come grafo di stato esplicito |
langchain | 11.359.058 | catene, agents, integrazioni |
@openai/agents | 6.093.155 | agents, handoffs, guardrails |
@mastra/core | 5.914.502 | agents, workflow, memoria |
Il motivo per cui questo corso scrive il loop a mano invece di insegnarne uno è dichiarato, non implicito, ed è misurabile. Nei dodici mesi fino al 7 settembre 2026, ai ha pubblicato 945 versioni ed è passato dalla major 5 alla major 7, e la sua classe agent è ancora esportata come Experimental_Agent; langchain ha pubblicato 132 versioni nella stessa finestra; @openai/agents ne ha pubblicate 83 ed è ancora su 0.x, quindici mesi dopo la prima release.7 Un capitolo scritto contro una qualunque di quelle API invecchia in una stagione, e questo è pubblicato in trentatré lingue, quindi ogni riedizione costa l’intera traduzione. Ciò che sta sotto tutte non si muove: un loop, una regola di arresto, un catalogo, un executor, un po’ di stato.
E l’implementazione di riferimento concorda con questo capitolo sulla parte che conta. In ai versione 7.0.93 l’uscita del loop non è un numero — è stopWhen, una lista di predicati, di cui il conteggio degli step è solo uno:3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsLo stopping è plurale nell’implementazione più usata di questo loop, per la stessa ragione per cui è plurale nelle centonovantasei righe qui sopra.
Dove si va dopo
Link alla sezione: Dove si va dopoOra hai un harness: un loop, un catalogo, un executor, cinque vie d’uscita, un’esecuzione persistita, un segnale che raggiunge gli strumenti e una trace con un run id su ogni riga. I Capitoli 24, 25, 29 e 30 costruiscono su questo file, e dal 26 al 28 su ciò che può raggiungere.
Gli resta un problema, e le misure sopra lo indicano da tutto il tempo. Guarda ancora una volta la tabella della fuga: 3.431 input token a otto turni, 337.299 a cento. Guarda l’esecuzione funzionante: 204, 269, 342. Ogni turno reinvia l’intera trascrizione, quindi il context di un agent si riempie della propria cronologia — e il modello è peggiore a usare l’estremità lontana di una window lunga rispetto a quella vicina, motivo per cui un buon agent al turno cinque è confuso al turno quaranta.
Un limite di turni non lo corregge. Ti impedisce solo di pagare per guardarlo succedere. Ciò che lo corregge è decidere, a ogni singolo turno, quali token meritano la window: cosa compattare, cosa spostare fuori in una nota che l’agent può recuperare, cosa passare a un subagent con una window pulita, e quali definizioni di strumenti valgono la loro tassa permanente. Il Capitolo 24 misura dove va davvero la window — e la sorpresa è che non è la conversazione.
Fonti e metodo
Link alla sezione: Fonti e metodoOgni numero in questo capitolo è uscito dai due server descritti sopra, su Node 22 tramite interfaccia loopback: un provider scriptato che conta i token con l’encoding o200k_base, e Qwen/Qwen2.5-0.5B-Instruct dietro un endpoint della stessa forma, greedy decoding, su CPU. I costi sono calcolati dai conteggi di token misurati alle tariffe lette nel Capitolo 16 il 6 settembre 2026 — $2.00 per milione di input token e $12.00 per milione di output — e nessuna richiesta in questo capitolo è andata a un endpoint a pagamento. Le risposte del modello locale sono risposte di un modello piccolo; leggile come evidenza sul loop, che è identico in entrambi i casi, e non come benchmark di ciò che fanno i modelli attuali.
Riferimenti
Link alla sezione: Riferimenti-
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. e Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). L’interleaving di trace di ragionamento e azioni che il loop implementa, e la fonte dell’osservazione che agire permette a un modello di «gestire eccezioni» — che è esattamente ciò che misura la tabella sugli errori degli strumenti sopra. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. e Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Il trattamento formale di ciò che il loop sopra fa informalmente: componenti di memoria modulari, uno spazio d’azione strutturato che copre memoria interna e ambienti esterni, e «un processo decisionale generalizzato per scegliere le azioni». Leggilo per il vocabolario che manca al termine industriale — in particolare la separazione tra memoria di lavoro, episodica, semantica e procedurale, la cui ombra pratica è la tabella a tre store del Capitolo 24. ↩
-
ai(Vercel AI SDK) versione 7.0.93, pubblicata il 4 settembre 2026; dichiarazioni di tipo lette dacdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsil 7 settembre 2026. Il file da 397 KB contiene zero occorrenze della stringaharness. La classe agent èdeclare class ToolLoopAgent, esportata sia comeToolLoopAgentsia comeExperimental_Agent;declare function isStepCount(stepCount: number)— esportato comestepCountIs— è citato testualmente sopra;type StopConditionè mostrato senza il suo secondo parametro di tipo (RUNTIME_CONTEXT extends Context = Context), che è l’unica elisione nell’estratto, così come la forma distopWhen?: Arrayable<StopCondition<...>>sugenerateTextestreamText. Lo stesso file dichiaratoolApproval,ToolApprovalStatus,prepareSteperepairToolCall, cioè l’implementazione di riferimento è arrivata indipendentemente a gate di approvazione, preparazione per-step ed error repair. ↩ ↩2 -
Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. e Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). L’abstract chiama l’artefatto «evaluation framework» di 2.294 problemi e non usa mai la parola «harness»; il README del progetto (
github.com/SWE-bench/SWE-bench, letto il 7 settembre 2026) la usa cinque volte, sempre come «evaluation harness», e l’entry point èpython -m swebench.harness.run_evaluation. Questo è l’altro senso della parola: un’impalcatura che tiene fermo l’agent e lo valuta, non il loop che lo esegue. ↩ -
Anthropic, Building effective agents, 19 dicembre 2024,
anthropic.com/engineering/building-effective-agents, letto il 7 settembre 2026. Il modello aumentato come building block, l’agent come LLM che «usa strumenti sulla base del feedback ambientale in un loop», e la raccomandazione di condizioni di arresto «come un numero massimo di iterazioni» per mantenere il controllo. Il Capitolo 22 cita per intero la sua definizione. ↩ -
Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. e Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). L’altro loop — lo scheduler di serving che mette in batch la tua richiesta con le richieste di sconosciuti e gestisce la KV cache del Capitolo 13. Vale la pena sapere che esiste proprio perché non è tuo: la latenza che il tuo harness moltiplica viene impostata al suo interno, e nessun lavoro sul tuo loop la sposta. ↩
-
Conteggi dei download del registro npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, una finestra esplicita invece di quella mobilelast-month, e cronologie delle release daregistry.npmjs.org/<package>; entrambi interrogati il 7 settembre 2026. I conteggi delle release sono il numero di versioni pubblicate nei dodici mesi fino a quella data, build canary incluse:ai945 (ultima 7.0.93 il 2026-09-04, con le major 5, 6 e 7 tutte apparse dentro la finestra),langchain132 (ultima 1.5.10 il 2026-08-20),@openai/agents83 (ultima 0.17.0 il 2026-08-19, prima pubblicazione 2025-06-03). ↩ ↩2 -
Il Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) è il Claude Code harness impacchettato come libreria — agent loop, strumenti file e shell integrati, context management, sessioni, hooks, permessi e subagents — documentato sucode.claude.com/docs/en/agent-sdk. È la cosa più vicina a un resoconto pubblicato di ciascun meccanismo che questo capitolo costruisce a mano, e vale la pena leggerlo accanto alla tua implementazione per le parti a cui dà un nome e che questo capitolo indica soltanto. ↩