La prima chiamata a un tool è quella facile. Quella difficile è il tentativo successivo, dopo che il CRM ha accettato una modifica, la rete ha perso la risposta e il worker si è riavviato. A quel punto, né una risposta fluida né un system prompt più lungo ti dicono se l’azione di business è avvenuta. Hai un problema di sistemi distribuiti con un pianificatore probabilistico attaccato.
Considera un agente che prepara una nota per una scheda cliente. Una nota duplicata può sembrare innocua, ma lo stesso schema di esecuzione prima o poi arriva alla creazione di ordini, a una modifica dell’account o a un’operazione finanziaria. Il runtime non dovrebbe imparare la propria semantica di consegna nel momento in cui l’azione diventa costosa.
Questo design di riferimento separa cinque responsabilità: ingresso delle richieste autenticate, recupero delle evidenze, pianificazione del modello, ammissione dei comandi ed esecuzione degli effetti collaterali. Solo l’ultimo componente detiene una capacità di connettore con ambito ristretto. Il pianificatore non riceve mai una credenziale generica, e né un documento recuperato né il risultato di un tool possono concedergli un nuovo permesso.
1. Rendi eseguibile il confine di autorità
Il modello può proporre il nome di un’azione e i relativi argomenti. Tutto il resto proviene da servizi attendibili. Ricava tenant e principal da una sessione verificata; valida l’azione rispetto a una allowlist versionata; valida gli argomenti rispetto a uno schema chiuso. Rifiuta proprietà sconosciute e input troppo lunghi. Risolvi la risorsa di destinazione all’interno dello stesso tenant prima di ammettere un comando.
Questo conta perché una chiamata a un tool sintatticamente valida non è per forza autorizzata. OWASP identifica la prompt injection indiretta tramite contenuti esterni come un rischio. Un documento che dice a un assistente di esportare dei record resta un documento, non un’autorità. Delimitatori, grounding sul retrieval e istruzioni testuali sono livelli utili, ma nessuno sostituisce l’enforcement al confine del connettore.
Il contratto seguente va applicato dopo la validazione e l’approvazione. Il suo tipo è documentazione, non un meccanismo di sicurezza: i tipi TypeScript scompaiono a runtime. Solo il servizio di ammissione dovrebbe costruire e rendere persistente questo record, e il dispatcher deve caricarlo da uno storage attendibile invece di accettare un oggetto fornito dal modello.
type ApprovedCommand = Readonly<{
tenantId: string;
operationId: string;
principalId: string;
action: "crm.note.create";
resourceId: string;
payload: Readonly<{ body: string }>;
requestHash: string;
expectedRevision: string;
authorizationDecisionId: string;
policyVersion: string;
expiresAt: string;
}>;
type ExecutionOutcome =
| { state: "succeeded"; receipt: string }
| { state: "denied" | "expired"; reason: string }
| { state: "unknown"; operationId: string };
2. Approva un comando, non una conversazione
Un’approvazione dovrebbe identificare esattamente tenant, principal, azione, destinazione, argomenti e revisione attesa della risorsa. Calcola lato server un digest su una rappresentazione canonica di questi campi validati. Conserva insieme a esso la decisione di approvazione, la versione della policy e la scadenza. Non calcolare l’hash di una stringa JSON arbitraria dando per scontato che un diverso ordine delle proprietà indichi richieste diverse.
L’interfaccia di approvazione dovrebbe mostrare la modifica concreta proposta e le evidenze necessarie per valutarla. Dopo l’approvazione, congela quel comando. Se il modello rivede il contenuto, il record di destinazione cambia o la precondizione di business scade, crea una nuova proposta e ottieni di nuovo la decisione appropriata. Non riutilizzare silenziosamente la vecchia approvazione per un nuovo payload.
Al momento del dispatch, verifica che la decisione sia ancora valida e ricontrolla la policy di accesso corrente. Un permesso revocato dovrebbe bloccare un’azione in coda. Dove il sistema di business supporta una scrittura condizionale, invia la revisione attesa come precondizione, così che nessun altro attore possa modificare il record tra l’ispezione e la modifica. Una precondizione non soddisfatta torna in revisione; non è un motivo per eliminare la condizione e riprovare.
L’approvazione umana è uno stato persistente, non un lock di database mantenuto aperto. Rendi persistente la richiesta e rilascia le risorse durante l’attesa. In PostgreSQL i lock durano normalmente fino alla fine della transazione; lasciare una transazione aperta mentre una persona decide crea contesa, senza rendere il sistema remoto parte di quella transazione.
3. Dai all’intento un’identità persistente
Scegli un identificativo di operazione una sola volta per l’intento di business accettato, prima del primo dispatch. Un retry mantiene quell’identificativo. Limita la sua unicità al tenant e salva insieme a esso un hash della richiesta. Stessa chiave e stesso hash indicano un replay; stessa chiave con hash diverso è un conflitto da rifiutare, non da interpretare come un aggiornamento.
La struttura di storage minima qui sotto separa il ledger dei comandi dall’outbox. L’outbox significa che il commit di un comando comporta anche il commit dell’obbligo di eseguirne il dispatch. Non significa che l’azione a valle sia avvenuta. Memorizza solo i campi necessari per eseguire e verificare l’operazione; la memorizzazione effettiva dei payload richiede cifratura, restrizioni di accesso e una policy di conservazione.
CREATE TABLE agent_command (
tenant_id uuid NOT NULL,
operation_id uuid NOT NULL,
request_hash bytea NOT NULL,
command jsonb NOT NULL,
state text NOT NULL CHECK (state IN (
'pending', 'dispatching', 'succeeded',
'denied', 'expired', 'unknown'
)),
provider_receipt text,
lease_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, operation_id)
);
CREATE TABLE command_outbox (
tenant_id uuid NOT NULL,
operation_id uuid NOT NULL,
available_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, operation_id),
FOREIGN KEY (tenant_id, operation_id)
REFERENCES agent_command (tenant_id, operation_id)
);
4. Ammetti una volta, esegui il dispatch con un lease limitato
La transazione di ammissione inserisce insieme la riga del ledger e la relativa voce nell’outbox. ON CONFLICT DO NOTHING di PostgreSQL impedisce che una chiave primaria duplicata crei un secondo comando. RETURNING include solo le righe effettivamente inserite, quindi solo la prima ammissione aggiunge un obbligo di dispatch. Usa parametri vincolati su un’unica connessione al database, con un timeout di transazione e il rollback in caso di errore.
Questo estratto presuppone che autenticazione, autorizzazione, controlli di approvazione e canonicalizzazione del payload siano già andati a buon fine. Illustra soltanto l’ammissione. Isolamento a livello di riga, query di dispatch, crittografia e permessi del ciclo di vita restano volutamente fuori dall’estratto; una colonna tenant_id da sola non è un controllo di isolamento tra tenant.
BEGIN ISOLATION LEVEL READ COMMITTED;
WITH admitted AS (
INSERT INTO agent_command (
tenant_id, operation_id, request_hash, command, state
) VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (tenant_id, operation_id) DO NOTHING
RETURNING tenant_id, operation_id
)
INSERT INTO command_outbox (tenant_id, operation_id)
SELECT tenant_id, operation_id FROM admitted;
SELECT request_hash, state, provider_receipt
FROM agent_command
WHERE tenant_id = $1 AND operation_id = $2;
COMMIT;
Confronta l’hash restituito con quello inviato prima di confermare l’ammissione. Una mancata corrispondenza è un conflitto in stile HTTP 409. La seconda istruzione ottiene un nuovo snapshot in Read Committed, quindi può vedere un inserimento concorrente dopo che il controllo di unicità lo ha atteso. Non eliminare la riga del ledger finché può ancora arrivare un replay; i retry delle transazioni a livelli di isolamento più rigidi richiedono una gestione dedicata.
Un worker prende in carico un comando in sospeso con una transazione breve, imposta lo stato dispatching e registra un lease a durata limitata prima del commit. Più worker concorrenti possono usare il row locking e SKIP LOCKED per prelevare dalla coda. La presa in carico deve verificare lo stato corrente in modo atomico e il completamento deve essere subordinato al claim token del worker. Lo storage di produzione richiede quindi un claim token o una fencing generation in aggiunta allo schema semplificato mostrato qui.
Rilascia i lock del database prima della chiamata di rete. Il sistema remoto non può partecipare a quella transazione locale. La scadenza di un lease non prova che il worker originale si sia fermato, e un fencing token locale non può bloccare un’API a valle che non lo comprende. Il recupero deve seguire il contratto di idempotenza del sistema a valle, non limitarsi a riportare ogni lease scaduto a pending.
5. Unknown è un esito a pieno titolo
Un timeout significa che il chiamante non ha ottenuto una risposta definitiva. Non significa che il server abbia eseguito un rollback. Se il provider supporta l’idempotenza, invia la chiave stabile dell’operazione con la stessa richiesta, rispettando l’ambito e il periodo di conservazione della chiave documentati dal provider. Registra la ricevuta risultante in modo persistente prima di confermare l’elemento della coda.
Un crash tra il successo remoto e la scrittura della ricevuta locale resta possibile. Il recupero può interrogare il provider tramite la chiave dell’operazione, oppure ripetere esattamente la stessa richiesta se quel provider garantisce la sicurezza del replay. Se nessuna delle due opzioni è disponibile, sposta il comando in unknown e richiedi una riconciliazione. Generare una nuova chiave per sfuggire all’incertezza crea proprio l’effetto collaterale duplicato che il ledger doveva evitare.
Non rieseguire il planner per ricostruire un retry. Una nuova risposta del modello può scegliere un payload o una destinazione diversi anche con lo stesso prompt. Riproduci il comando persistito e registra separatamente ogni tentativo di esecuzione. Una volta che un comando è in uno stato terminale, ogni consegna successiva deve restituire il risultato registrato senza creare un’altra voce nell’outbox.
Un vincolo di unicità nel database e un outbox garantiscono un intento persistente e un dispatch controllato. Non creano però per magia effetti collaterali exactly-once in un sistema esterno. Questo risultato più forte dipende dall’operazione a valle, dalla sua atomicità, dal suo comportamento di deduplicazione e da quanto a lungo ricorda la chiave.
6. Limita il runtime, poi strumenta i confini
Un workflow ha bisogno di budget per token del modello, chiamate ai tool, tempo trascorso e lavoro concorrente. Applicali nel codice, fuori dal planner. Riserva il budget in modo atomico prima di ammettere lavoro in parallelo, così più worker non possono vedere tutti lo stesso saldo residuo. Rilascia le prenotazioni inutilizzate dopo un evento di completamento autorevole; includi retry, retrieval e revisione umana nel modello dei costi operativi.
Usa gli span OpenTelemetry per retrieval, pianificazione, autorizzazione, ammissione e dispatch. Propaga il contesto attraverso la coda dove opportuno e usa gli span link per collegare tentativi asincroni o trace che riprendono in seguito. Inserisci gli identificativi delle operazioni in attributi di trace controllati o in un archivio di audit ad accesso controllato, non in label di metriche illimitate.
Un trace non è il ledger di audit. I trace possono essere campionati, scartati o eliminati secondo una diversa policy di conservazione. Il record persistente deve stabilire quale comando approvato è stato tentato, quale decisione di policy lo ha consentito, cosa ha riportato il sistema a valle e se è avvenuta una riconciliazione. Prompt grezzi, token di accesso e record aziendali completi non dovrebbero essere il payload di telemetria predefinito.
I cambi di modello devono preservare questi contratti di esecuzione. Confronta i candidati su un set di valutazione fisso e versionato, che includa rifiuti, escalation e accuratezza degli argomenti dei tool. Una valutazione in replay deve sostituire le mutazioni con stub; un’esecuzione shadow non deve attivare scritture in produzione. Una migliore qualità linguistica non è un motivo per ampliare l’ambito di un tool.
7. Testa i punti in cui il processo può spezzarsi
La suite di test sui guasti deve interrompere l’esecuzione in punti precisi: prima del commit dell’ammissione, dopo il commit ma prima del dispatch, dopo la scrittura remota ma prima della persistenza della ricevuta, e dopo la persistenza ma prima della conferma alla coda. Aggiungi retry concorrenti con la stessa chiave, hash del payload non corrispondenti e due worker in competizione per lo stesso comando.
Le asserzioni riguardano stato ed effetti, non solo i codici di stato HTTP. Deve esserci un solo comando ammesso per ogni chiave legata al tenant; una mancata corrispondenza dell’hash non deve mai modificarne il payload; un successo registrato non deve essere inviato di nuovo. Con un provider simulato dotato di un contratto di idempotenza valido, il replay deve produrre un solo effetto. Con un provider non idempotente e una risposta persa, il risultato atteso è un comando irrisolto, non un successo inventato.
Fai anche scadere l’approvazione mentre il comando è in coda, revoca l’accesso prima del dispatch, modifica la revisione della risorsa e inserisci testo simile a istruzioni nei documenti recuperati. Verifica che nessuno di questi percorsi ampli le capacità del connettore. Testa l’esaurimento del budget mentre i passaggi paralleli sono ancora attivi e assicurati che il runtime smetta di pianificare nuovo lavoro, invece di lasciare che il modello negozi un altro tentativo.
È questo il livello di ingegneria che adotterei: il sistema sa spiegare cosa è stato proposto, cosa è stato permesso, cosa è stato tentato e cosa è accertato che sia successo. Il modello può cambiare. Il confine attorno all’azione di business deve restare esplicito.
Fonti e contesto
Architettura di riferimento e codice illustrativo, non la descrizione di un runtime Pedalion già rilasciato né un’implementazione pronta per la produzione. Sicurezza, multi-tenancy, conservazione dei dati e contratti con i provider richiedono una revisione specifica per ogni implementazione.



