La maggior parte dei team di agenti non costruisce un harness. Ne adotta uno. LangChain, LangGraph, OpenAI Agents SDK, Anthropic SDK, CrewAI, AutoGen, il loop, gli strumenti, la memoria e l'orchestrazione vengono presi dallo scaffale come un'unica decisione. L'harness è un framework che importi. Se qualcosa al suo interno non si adatta, lo fork, lo combatti o lo aggiri.

Penso che questa forma sia sbagliata, ed è il motivo per cui ogni team di agenti che opera a lungo termine finisce prima o poi per riscrivere il proprio harness da zero. L'harness non è una cosa sola. Sono dieci o dodici cose diverse raggruppate insieme perché l'ecosistema circostante non ti offre un modo per comporle. I pacchetti Pi agent sono sulla strada giusta, ma sono ancora nel paradigma di "Aggiungi un altro servizio e integralo con tutti gli altri". Il motore iii tratta tutti i worker allo stesso modo e rimuove completamente la logica di integrazione. Il router del provider, il vault delle credenziali, il motore delle policy, il gate di approvazione, il catalogo dei modelli, l'archiviazione delle sessioni, il tracker del budget, il fanout degli hook post-chiamata e il durable turn loop sono preoccupazioni indipendenti. Tutti questi sono interoperabili con la tua coda, server HTTP/API, streaming e persino worker del browser. Un framework che li spedisce come un unico blocco ti sta vendendo un compromesso che non dovevi fare.
La scommessa alla base di iii è che non dovrebbero essere un unico blocco. Dovrebbe esserci un insieme di worker su un motore condiviso, ciascuno sostituibile, ciascuno versionato indipendentemente, ciascuno connesso da un singolo primitivo: un trigger (iii.trigger()) che ogni altro worker usa allo stesso modo. L'harness diventa una pila di worker installabili, e "costruisci il tuo" smette di significare "forka un framework". Significa "scambia qualche worker".
Questo post illustra come appare realmente. La pila completa che guida un turno di un agente iii oggi, perché ogni livello è un worker a sé stante e come sostituirne uno qualsiasi.
I 15 compiti che un harness per agenti deve svolgere
Se riduci un harness di produzione per agenti alle sue responsabilità, ottieni un elenco che assomiglia più o meno a questo:
- Accettare una richiesta di turno da un client e persisterla
- Risolvere le credenziali per il provider di modelli che verrà chiamato
- Verificare cosa può effettivamente fare il modello scelto (visione, strumenti, streaming, finestra di contesto)
- Guidare la macchina a stati per-turno: provision, streaming assistant, esecuzione strumenti, steering, teardown
- Caricare e servire i corpi delle skill che descrivono la forma della richiesta, i codici di errore e le note d'uso di ogni funzione
- Assembrare il prompt di sistema, il paragrafo della modalità, il preambolo di identità, la directory di lavoro e l'appendice delle skill predefinite
- Fare streaming dei token al client man mano che il modello li produce
- Controllare ogni chiamata a uno strumento (che è solo una funzione) rispetto a una policy prima che venga eseguita
- Mettere in pausa le chiamate a strumenti che necessitano di una decisione umana e reindirizzare la risposta al turno corretto
- Tracciare la spesa LLM rispetto ai budget per workspace o per agente
- Eseguire hook prima e dopo le chiamate a strumenti (logging, redazione, effetti collaterali personalizzati)
- Persistere la sessione come un albero ramificato in modo che fork e riprese funzionino
- Compattare la cronologia della sessione quando la finestra di contesto si riempie
- Emettere un flusso di eventi a cui l'interfaccia utente si abbona
- Pezzo mancante in ogni azienda che costruisce agenti, come vedo io. Trasportare una traccia OpenTelemetry attraverso ogni passaggio in modo da poter fare debug
Ogni harness serio per agenti fa la maggior parte di queste cose. Quelli costosi le fanno tutte. Quelli economici tagliano gli angoli e li ricostruiscono quando arrivano in produzione. I framework li raggruppano in un monolito e spediscono una versione di ciascuno. Quest'ultima parte è quella che ti costa, perché dopo un anno scopri che il motore delle policy che vuoi non è quello che il framework fornisce, e sostituirlo significa sostituire l'harness.
L'harness iii spedisce ognuno di quei tredici compiti come worker separato nel registro workers.iii.dev. Ognuno parla lo stesso protocollo WebSocket. Ognuno registra funzioni e trigger sullo stesso bus del motore. Ognuno è aggiungibile, sostituibile e scrivibile in qualsiasi linguaggio con un SDK con iii worker add.
La pila, per worker
Ecco la pila di produzione effettiva dal monorepo iii-hq/workers, con il compito di ogni worker in una riga. L'intero bundle è disponibile su github.com/iii-hq/workers/harness:

Undici worker. Un motore. Ognuno ha una versione pubblicata. Ognuno è eseguibile indipendentemente come processo standalone (pnpm dev:<worker> in sviluppo, iii worker add <worker-specifico> come binario di rilascio) o come parte del punto di ingresso composito che li avvia insieme.
Il motivo per cui questo è importante: ogni riquadro in quella tabella è un punto in cui qualcuno può passarti un worker diverso, e tu tieni il resto. Non ti piace il catalogo dei modelli statico? Collega un worker che registra models::list e legge da un'API live. Non ti piacciono le credenziali basate su file? Collega un worker che registra auth::get_token e legge da un gestore di segreti. Vuoi un FSM di turno diverso per un workflow che si dirama in modo differente? Sostituisci turn-orchestrator, ogni dipendente chiama run::start e legge turn_state attraverso lo stesso bus, quindi il resto della pila non cambia.
Come funziona effettivamente il loop
La forma di un turno è questa, attraversando i worker nell'ordine in cui si attivano.
Un browser/CLI/chat POSTa un turno attraverso harness::trigger con {session_id, message_id, payload}. Il meta-worker harness inoltra il payload a run::start. Quel salto esiste così che il wrapper dello span OpenTelemetry possa seminare gli ID di sessione e messaggio come baggage, che si propaga a ogni chiamata iii.trigger annidata attraverso ogni worker nella pila. L'albero delle tracce dall'altra parte è un grafo connesso.
run::start arriva al turn-orchestrator. Persiste la richiesta di esecuzione, semina il record TurnStateRecord iniziale nello stato iii in session/<sid>/turn_state e ritorna immediatamente. Il lavoro effettivo avviene all'interno della macchina a stati durable per-turno, risvegliata dalle pubblicazioni sulla FIFO turn-step.
I due stati terminali sono stopped (uscita pulita tramite finishSession()) e failed (un'eccezione imprevista di un handler finisce qui, accusa la coda in modo che smetta di ritentare e mostra message_complete{stop_reason:'error'} più agent_end in modo che l'interfaccia utente mostri il motivo). Il teardown è una port finishSession() inline chiamata da qualsiasi percorso di fine turno, non un passo separato accodato.
provisioning fa tre cose. Avvia una microVM iii-sandbox se l'esecuzione necessita di isolamento. Chiama directory::skills::download per ogni namespace in system_default_skills (predefinito ["iii://iii-directory/index"]) in modo che iii-directory pre-carichi i corpi delle skill con cui l'esecuzione inizia. E assembla il prompt di sistema in tre strati: un paragrafo di modalità scelto da run_request.mode (plan, ask o agent), il preambolo di identità iii che insegna al modello la convenzione agent_trigger e il pattern di scoperta on-demand directory::skills::get, e un indice aggiunto delle skill predefinite con cui l'agente si avvia. Il chiamante può sovrascrivere l'intero prompt passando system_prompt su run::start; altrimenti l'orchestrator lo costruisce. Gli schemi delle funzioni provengono dal catalogo live del motore.
assistant_streaming chiama provider::<nome>::stream sul worker provider che corrisponde al campo provider dell'esecuzione. Il worker provider recupera le credenziali tramite auth::get_token (auth-credentials), trasmette la risposta SSE del modello in un canale iii, e l'orchestrator drena quel canale emettendo eventi message_update su agent::events per il fanout dell'interfaccia utente. La creazione del canale e il loop di lettura vivono dietro un MessagePump basato su pull in provider-stream.ts, quindi lo stato di streaming rimane concentrato sulle transizioni.
Quando l'assistant restituisce chiamate a strumenti, il FSM entra in function_execute. Ogni chiamata a uno strumento passa attraverso dispatchWithHook, il singolo punto di strozzatura nell'orchestrator. consultBefore chiama policy::check_permissions direttamente con un timeout di 5 secondi. Il worker delle policy (il meta-worker harness, nella pila predefinita) legge iii-permissions.yaml, confronta il function_id della chiamata con il set di regole e restituisce uno di tre risultati:
- allow: il dispatch procede; l'orchestrator attiva la funzione target e scrive il risultato
- deny: il dispatch si interrompe con un DenialEnvelope, il risultato diventa un record di negazione
- needs_approval: la singola chiamata si ferma nella lista awaiting_approval del turno. Il resto del batch continua a fare dispatch. Il turno passa a function_awaiting_approval solo quando una o più voci sono in sospeso.
Il risveglio dell'approvazione è reattivo e condiviso. L'orchestrator registra esattamente un trigger di stato turn::on_approval sull'ambito approvals. Quando la console chiama approval::resolve, il worker approval-gate scrive approvals/<sid>/<cid> = {decision, reason} nello stato iii. Quella scrittura attiva turn::on_approval, che fa avanzare la sessione interessata. function_awaiting_approval legge solo le decisioni appena arrivate, esegue il dispatch di ciascuna non appena arriva (allow diventa un dispatch pre-approvato, deny o aborted diventa una negazione sintetica) e avanza quando awaiting_approval[] è vuoto. Nessuna funzione di ripresa per chiamata da registrare. Nessuna ri-scansione all'avvio per recuperare le approvazioni in sospeso. Un trigger copre ogni sessione.
Fail-closed per costruzione: se il worker delle policy è irraggiungibile o il timeout di 5 secondi scatta, consultBefore nega la chiamata con un involucro gate_unavailable. Se iii::durable::publish stesso ha generato un errore, il fanout dell'hook restituisce publish_failed: true e l'orchestrator lo tratta come un deny.
Alcuni vantaggi in termini di latenza emergono da questa forma. L'hook dopo la chiamata alla funzione short-circuita publish_collect tramite una cache di presenza degli abbonati quando nessun abbonato durable è registrato per l'argomento, rimuovendo circa 500ms per chiamata di funzione eseguita. tearing_down è inline in finishSession(), rimuovendo un salto di coda durable per turno. context-compaction si abbona a un flusso dedicato agent::turn_end che l'orchestrator emette ai confini del turno, quindi i risvegli del compattatore sono per turno invece che per evento. Il trigger di stato session-create fanout filtra solo per ambito e corrisponde in-process, quindi la precedente RPC harness::session::is_create_event per scrittura è sparita.
Dopo il completamento del batch, steering_check decide se continuare, fermarsi o raggiungere max_turns. Se continua, torna ad assistant_streaming. Se ferma o max, finishSession() viene eseguito inline: emette agent_end, libera la sandbox, passa a stopped.
Durante l'intera esecuzione, ogni worker che partecipa emette span OTel etichettati con iii.session.id, iii.message.id e iii.function.id. Questi tag sono ciò che engine::traces::group_by del motore legge per popolare "Group by Session" / "Group by Message" / "Group by Function" nell'interfaccia delle tracce. La strumentazione è automatica: src/runtime/worker.ts avvolge ogni registerFunction in un Proxy in modo che nessun worker debba ricordarsi di aggiungere span.
Costruisci il tuo
La parte interessante è che nessuno dei worker sopra è speciale. Ognuno è un processo che apre un WebSocket verso il motore, registra alcune funzioni e trigger e viene eseguito. Il contratto è lo stesso contratto che usa ogni worker applicativo. L'harness è costruito sullo stesso primitivo su cui è costruita la tua logica di business.
Il che significa che "costruisci il tuo harness" si scompone nella stessa operazione di "scrivi un qualsiasi worker". Scegli il livello che vuoi sostituire, scrivi un worker che registra le stesse funzioni sul bus, lo aggiungi con iii worker add e il resto della pila inizia a usare il tuo worker.
Due livelli non compaiono nella tabella dei worker sopra, ma sono importanti per come si comporta l'harness. Le skill sono il modo in cui ogni worker pubblicizza cosa fanno le sue funzioni. Ogni worker può pubblicare una skill su iii://<worker>/<funzione> che l'agente recupera tramite directory::skills::get prima di chiamare quella funzione per la prima volta. Il prompt di sistema viene assemblato per turno da un paragrafo di modalità, il preambolo di identità iii e i corpi delle skill predefinite con cui l'esecuzione è stata configurata. Entrambi sono guidati dal bus: le skill sono servite dal worker iii-directory, il prompt di sistema è assemblato dal turn-orchestrator. Entrambi sono sostituibili.
Cinque esempi concreti.
Sostituisci il catalogo dei modelli con un'API live. Scrivi un worker che registra models::list, models::get, models::supports. Fai in modo che recuperi dall'endpoint del catalogo del tuo provider ogni N minuti e faccia cache. Pubblicalo. iii worker add tuo-org/dynamic-models-catalog. Ferma il worker statico models-catalog. Il turn-orchestrator non nota la differenza. Chiama iii.trigger('models::list') e il motore instrada verso il worker che ha registrato quell'ID di funzione più recentemente.
Aggiungi un nuovo provider. La forma è già dimostrata da provider-kimi e provider-lmstudio. Ognuno è un worker che registra provider::<nome>::stream e provider::<nome>::complete, drena uno stream SSE dall'API upstream in un canale iii e scrive l'utilizzo del modello in llm-budget tramite budget::record. Aggiungere un quinto provider significa scrivere una cartella con un iii.worker.yaml e un register.ts. Pubblica sul registro o tienilo in locale. Il turn-orchestrator sceglie il provider in base al campo provider dell'esecuzione; i nuovi provider diventano disponibili nell'istante in cui il worker si connette.
Servi skill da un archivio di artefatti privato. Scrivi un worker che registra directory::skills::get e directory::skills::list, supportato dal tuo sistema di documentazione interno o da un bucket S3 privato. Disconnetti o rinomina il worker predefinito iii-directory. Il bootstrap dell'orchestrator chiama directory::skills::download per namespace; il tuo worker risponde. Il pattern "recupera la skill per funzione prima di chiamare una nuova funzione" dell'agente continua a funzionare invariato perché la forma del wire è la stessa.
Sovrascrivi completamente il prompt di sistema. run::start accetta un campo opzionale system_prompt. Passalo e l'orchestrator userà la tua stringa alla lettera, saltando l'assemblaggio del paragrafo di modalità + preambolo di identità + appendice delle skill. Utile quando hai un asset di prompt esistente che vuoi che l'harness rispetti senza modifiche. Il download delle skill viene comunque eseguito nel bootstrap, quindi l'agente mantiene la scoperta on-demand directory::skills::get anche con un prompt personalizzato.
Sostituisci la superficie dell'interfaccia utente del gate di approvazione. Il worker predefinito approval-gate registra approval::resolve. Lo schema del wire è una chiamata di funzione:
L'handler persiste approvals/<sid>/<cid> = {decision, reason} nello stato iii. Il singolo trigger di stato turn::on_approval dell'orchestrator raccoglie quella scrittura e risveglia la sessione corretta. Se vuoi guidare le approvazioni da Slack invece che dalla console, scrivi un worker Slack che ascolta i comandi slash /approve <id> e /deny <id>, poi chiama approval::resolve con il payload corretto. L'orchestrator non nota la differenza. L'intero worker approval-gate rimane invariato. Hai aggiunto un nuovo worker; non hai sostituito quello esistente.
Se vuoi un motore di policy diverso (OPA, Cedar, il tuo DSL), scrivi un worker che registra policy::check_permissions e restituisce { decision, rule_id?, matched_constraint? }. Disconnetti il worker delle policy predefinito (che è racchiuso all'interno del meta-worker harness, quindi disabiliteresti quell'handler o eseguiresti un meta-worker snellito). consultBefore del turn-orchestrator non nota la differenza. Stesso timeout di 5 secondi, stesse semantiche fail-closed, stessa forma del wire.
Il punto di questi esempi non sono le sostituzioni specifiche. È la forma dell'operazione. Ogni livello dell'harness nella pila iii è raggiungibile attraverso uno o due ID di funzione sul bus. Sostituire un livello significa scrivere un worker che registra quegli ID. Il resto del sistema rimane.
L'harness è un cursore, non un bivio
Il dibattito classico sull'harness si inquadra come sottile vs spesso. Il loop sottile di Anthropic contro il DAG esplicito di LangGraph. L'inquadramento presuppone che tu scelga un lato e ci conviva.
Quando l'harness è composto da worker sullo stesso bus, sottile vs spesso è solo un conteggio di quanti worker installi. Un harness sottile è turn-orchestrator più provider-anthropic più auth-credentials più un meta-worker harness minimale. Tutto qui. Niente approvazioni, niente budget, niente motore di policy, niente fanout di hook. Esegui qualsiasi cosa. Fidati del modello. Utile per agenti di ricerca autonomi, loop sperimentali, qualsiasi cosa interna.
Un harness spesso è tutti e tredici i worker più context-compaction più un worker di policy personalizzato più un approval-gate personalizzato più una superficie di approvazione integrata con Slack più il worker di budget che impone limiti per workspace. Utile per un agente che esegue workflow per i clienti in cui ogni chiamata a uno strumento deve essere verificabile e ogni spesa del modello deve confluire in un dashboard finanziario.
La distanza architetturale tra sottile e spesso non è una riscrittura. È un cambiamento di configurazione. Stesso protocollo wire, stessa forma delle tracce, stessa storia di osservabilità. Il cursore si muove aggiungendo e rimuovendo worker dal tuo config.yaml. Tutto il resto rimane.
Si applica anche all'interno di un singolo worker. Il turn-orchestrator ha appena rilasciato un refactoring che ha ridotto il suo FSM da undici stati a sette, ha eliminato il meccanismo per-chiamata turn::approval_resume::<sid>/<cid> a favore di un singolo trigger di stato reattivo turn::on_approval sull'ambito approvals e ha inlineato tearing_down in una port finishSession(). Ogni altro worker nella pila (approval-gate, session, llm-budget, providers, models-catalog, auth-credentials, hook-fanout, context-compaction) è rimasto invariato. La forma del wire approval::resolve non si è mossa. I contratti hanno retto. Questa è la proprietà che la composizione ti dà: una grande riscrittura interna di un worker è un cambiamento autonomo perché ogni vicino comunica con esso attraverso ID di funzione a livello di bus.
Questa è la parte che il modello a framework non può darti. Un framework sceglie una posizione sul cursore per te e ti blocca lì. Il modello a worker lascia il cursore nelle tue mani.
Cosa significa in pratica
Se hai eseguito un agente su un framework e hai sentito gli stessi problemi di confine che la maggior parte dei team incontra su larga scala, la risposta probabilmente non è "riscrivere l'harness nel nostro framework". Il motore delle policy non si estende come serve. L'interfaccia di approvazione è collegata alla superficie di chat del framework. Il deposito delle credenziali non può parlare con il tuo gestore di segreti. Il tracker del budget è in un database sidecar che la traccia non può vedere. La risposta è passare a un substrato in cui l'harness è scomposto fin dall'inizio.
Il modo più veloce per percepire questo concetto è clonare github.com/iii-hq/workers, pnpm install, pnpm build ed eseguire il punto di ingresso composito. Otterrai l'harness completo di quattordici worker puntato a un motore iii. Puoi disabilitare qualsiasi worker rimuovendo la sua voce dalla lista di avvio. Puoi sostituire qualsiasi worker scrivendo un sostituto che registra gli stessi ID di funzione. Puoi estendere qualsiasi worker aggiungendo un abbonato ai suoi topic di hook. hook-fanout::publish_collect è il generico su cui si basa ogni hook di iii.
La documentazione è su iii.dev/docs. Il motore è su github.com/iii-hq/iii. Il registro dei worker è su workers.iii.dev. Il bundle dell'harness è su github.com/iii-hq/workers/harness.
La scommessa
Un harness non è una cosa che installi. Un harness è un insieme di compiti che il tuo sistema deve svolgere affinché un agente funzioni in modo durevole, sicuro e osservabile. L'era dei framework ha raggruppato questi compiti insieme perché niente al di sotto ti dava un modo per comporli.
La scommessa di iii è che un singolo primitivo — un worker che si connette al motore tramite WebSocket e registra funzioni e trigger — è abbastanza piccolo da assorbire ognuno di quei compiti separatamente, e che la pila risultante è più utile di qualsiasi framework perché ogni livello è indipendentemente sostituibile.
Non adotti l'harness iii. Installa i worker che vuoi, scrivi quelli di cui hai bisogno e ti ritrovi con un harness esattamente modellato sul tuo sistema. Stesso protocollo su ogni livello. Stessa traccia attraverso ogni chiamata. Stesso iii worker add per le parti che prendi dal registro come per quelle che pubblichi tu stesso.
Ecco cosa significa "costruisci il tuo harness per agenti" quando il substrato ha la forma giusta. Scegli i worker. Scrivi quelli mancanti. Componi. L'harness è la composizione.
Unisciti a noi nel costruire l'harness perfetto per agenti di cui il mondo moderno ha bisogno: discord.gg/iiidev
iii è open source. Inizia su iii.dev/docs. I worker dell'harness sono su github.com/iii-hq/workers e il motore è su github.com/iii-hq/iii.
— Mike Piccolo, Fondatore e CEO @iiidevs





