Vai al contenuto
mcpd
nessun daemon
Codice

Workflow

Un workflow è dati che il daemon tiene e corpi che il daemon non vede mai. Questa pagina sono i dati: che cosa può dire una definizione, e che cosa ogni costrutto fa fare al daemon.

La divisione del lavoro

Un’esecuzione durevole ha due proprietari. Il daemon possiede l’esecuzione fra un passo e l’altro: il cursore, il diario, il budget dei nuovi tentativi, i timer e le attese. Il tuo processo possiede tutto quello che sta dentro un passo: il modello che chiami, il prompt che mandi, gli strumenti che usi, le credenziali che tieni. Al daemon non viene consegnato niente di tutto questo, e non esegue mai un modello per tuo conto.

Così un workflow arriva in due parti da un solo file. Il grafo va al daemon come definizione, un documento che il daemon percorre. I corpi dei passi restano nel tuo processo, nel linguaggio in cui li hai scritti, perché li serva un worker. Non c’è niente di cui fare il deploy sul daemon, e un corpo può cambiare senza che la definizione cambi affatto.

The cursor

The daemon holds the run between steps: where it is, what each step answered, what it is waiting for and until when. A worker holds one step at a time, and only while it runs. During a wait no process is alive at all.

classify task your process draft agent your process approval wait_event the daemon alone send task · at_most_once your process retry

Scriverne uno

Il builder produce tutte e due le parti. document() è la definizione, e ogni chiamata a step o a agent trattiene il proprio corpo per il worker. Ogni SDK ha lo stesso builder con gli stessi nove costrutti, così un workflow scritto in un linguaggio si legge uguale in un altro, e il daemon archivia lo stesso documento qualunque linguaggio l’abbia scritto.

un workflow di assistenza

const support = workflow({ id: 'support_ticket' })
  .step('classify', async (ctx, input: { ticket: string }) => ({
    kind: input.ticket.startsWith('Q') ? 'question' : 'bug',
  }))
  .agent(
    'draft',
    async (ctx, input: { kind: string }) => {
      // Your own model call, recorded under this step in the run's trace. Its
      // usage adds up into the step's, which the daemon prices.
      return await ctx.withSpan('model_call', async (span) => {
        span.setModel('claude-sonnet-4', 'anthropic')
        span.setUsage({ inputTokens: 812, outputTokens: 37 })
        return `an answer to a ${input.kind}`
      })
    },
    { input: { kind: '${steps.classify.output.kind}' } },
  )
  .waitFor('approval', {
    event: 'approved',
    key: '${input.ticket}',
    timeoutMs: 7 * 24 * 3_600_000,
    when: when('steps.classify.output.kind', 'eq', 'bug'),
  })
  .step(
    'send',
    async (ctx, input: { text: string }) => {
      if (!(await deliver(input.text, ctx.idempotencyKey))) {
        throw new RetryableError('mail relay busy', { retryAfterMs: 2_000 })
      }
      return { sent: true }
    },
    {
      input: { text: '${steps.draft.output}' },
      effect: 'at_most_once',
      retry: { maxAttempts: 3, backoff: 'exponential', initialMs: 500 },
    },
  )
  .output({ sent: '${steps.send.output.sent}' })

Tre cose di quell’esempio sono la forma di ogni workflow. L’input di un passo è un template che il daemon rende a ogni tentativo, così draft legge quello che ha risposto classify senza che nessuno dei due corpi sappia dell’altro. Una guardia decide se un nodo gira o no: una domanda viene mandata così com’è, un bug aspetta una persona. E l’ultimo passo è at_most_once con una politica di nuovi tentativi, che è la combinazione di cui ha bisogno un effetto collaterale: il daemon riprova dopo un guasto passeggero, e non esegue mai un corpo due volte senza che gli sia stato detto.

I costrutti

Nove, e ogni SDK li ha tutti e nove. Due si prendono in carico, cioè li serve un worker; gli altri sono del daemon.

CostruttoChi lo esegueChe cosa fa
task Un worker Lavoro da prendere in carico. Il worker esegue il codice che ha registrato sotto questo id.
agent Un worker La stessa cosa a livello di protocollo, registrata come un turno di agente: il tuo ciclo, con quante chiamate al modello vuoi, come un solo passo durevole. La traccia e l’interfaccia lo distinguono da un task semplice.
sleep Il daemon Un ritardo di tanti millisecondi. Durante il ritardo non è vivo nessun processo.
sleep_until Il daemon Un ritardo fino a un istante reso da un template, per esempio una data di fatturazione che arriva nell’input.
wait_event Il daemon Una pausa finché non arriva un evento con questo nome e questa chiave, oppure finché non passa la scadenza. La chiave è resa per ogni esecuzione, così un’attesa si rivolge a un solo ordine e a nessun altro.
parallel Tutti e due Rami con un nome, ognuno un passo da prendere in carico, tutti su disco prima che ne venga offerto uno. L’esecuzione va avanti quando finisce l’ultimo ramo.
foreach Tutti e due Un passo da prendere in carico per ogni elemento dell’array a un percorso, limitato da max_items e, se lo dici, da quanti ne girano insieme. Gli output tornano come array nell’ordine degli elementi.
loop Tutti e due Un ciclo limitato su un solo passo da prendere in carico. until è valutato dopo ogni giro e lo può chiudere in anticipo.
subworkflow Il daemon Un’esecuzione figlia di un’altra definizione, con un diario suo. Il suo output diventa l’output di questo passo, e annullare il genitore annulla anche questa.

Un ramo, un corpo di foreach e un corpo di loop sono un passo da prendere in carico ciascuno. Un corpo che ha bisogno di più passi è un subworkflow, ed è questo che tiene piatto ogni diario: un’esecuzione è una lista, mai un albero di liste.

Template e guardie

Un template è JSON con riferimenti $${path} dentro le sue stringhe. Il daemon lo rende quando crea un tentativo, a partire da quello che l’esecuzione tiene in quel momento: input, l’input dell’esecuzione; steps.<id>, quello che ha risposto un passo precedente, con .output, .status e .error; run, l’id e la correlazione dell’esecuzione stessa; e dentro un foreach o un loop, item, index e iter.index. Un riferimento che occupa tutta la stringa conserva il proprio tipo, così "$${steps.classify.output}" consegna al passo successivo un oggetto, non una stringa che ne contiene uno.

Una guardia è un predicato sugli stessi percorsi: eq, ne, lt, lte, gt, gte, in, exists e matches, messi insieme con all, any e not. Una guardia falsa scrive il nodo come skipped e l’esecuzione va avanti. Un percorso assente rende falso ogni confronto invece di sollevare un errore, perché un’esecuzione che muore su un campo mancante è un’esecuzione che nessuno può portare a termine.

Il daemon convalida il documento quando lo archivia. Una guardia che nomina un percorso che nessuno può produrre, un template che legge un passo dichiarato più tardi, un foreach su qualcosa che non è un percorso, un id che non corrisponde a ^[a-z][a-z0-9_]*$: ognuno di questi è rifiutato con un 400 che nomina il campo. Anche i campi sconosciuti sono rifiutati, così una guardia scritta male è un errore e non un’esecuzione che salta la sua guardia.

Ogni durata ha un tetto oltre che un minimo. run_timeout_ms, step_timeout_ms, l’initial_ms e il max_ms di un nuovo tentativo, il ms di uno sleep e il timeout_ms di un’attesa valgono ciascuno al massimo 315360000000, cioè 3650 giorni, e uno più lungo viene rifiutato con un 400 che nomina il campo, il tetto e il valore che hai mandato. Una scadenza più lontana di così non entrerebbe nell’orologio su cui il daemon la archivia.

Nuovi tentativi, effetti e fallimento

Un corpo che fallisce dice al daemon come, con una classe. retryable vale un altro tentativo, e il daemon ne fa uno finché dura il budget retry del nodo, con il backoff del nodo, aspettando almeno il retry_after_ms che il corpo ha chiesto, che è tenuto allo stesso tetto di 3650 giorni di ogni altra durata. fatal e invalid_input non vengono ritentati. unservable vuol dire che questo worker non tiene la definizione o il passo, e il tentativo torna in coda per un altro worker senza consumare il budget. Un corpo che solleva qualunque altra cosa è fatal.

max_attempts conta ogni tentativo compreso il primo, quindi il valore predefinito di uno vuol dire nessun nuovo tentativo. Finito il budget, decide on_error: fail_run, il valore predefinito, chiude l’esecuzione con il messaggio del passo come errore; continue segna il passo come fallito e va avanti, così una guardia successiva può leggere steps.<id>.status e aggirarlo.

effect dice quanto costerebbe un corpo eseguito due volte. Sotto at_least_once, il valore predefinito, un lease perso rimette il tentativo in coda e il corpo può girare di nuovo. Sotto at_most_once il worker deve scrivere started prima del corpo, e un tentativo già partito che perde il lease fallisce invece di girare di nuovo. Usalo per il passo che manda l’email o addebita la carta, e dai a quel corpo l’idempotency_key del lease da passare alla terza parte: è stabile fra i nuovi tentativi dello stesso passo, così un tentativo dopo una risposta persa viene riconosciuto anche dall’altro lato.

Le versioni

Una definizione è immutabile una volta archiviata. Manda di nuovo lo stesso corpo e ti torna indietro la stessa versione; manda un corpo diverso sotto una versione che esiste già e il daemon rifiuta con un 409, perché altrimenti un’esecuzione che ha fissato quella versione se la vedrebbe cambiare sotto i piedi. Lascia fuori la versione e il daemon assegna il numero successivo, che è quello che fa un worker all’avvio. Un’esecuzione fissa la versione con cui è partita e non la risolve mai una seconda volta, così un deploy che archivia la versione 3 porta a termine le esecuzioni sulla versione 2 come erano state scritte.

I limiti

Una definizione può dichiarare max_steps, run_timeout_ms e max_cost_usd. Il primo conta ogni tentativo, il secondo l’età dell’esecuzione, il terzo il consumo valorizzato che i passi riferiscono; al tetto il daemon chiude l’esecuzione come timed_out o failed, con il tetto nominato come errore. Un loop che non converge e un passo di agente che non smette di spendere sono lo stesso problema per il daemon, e lo stesso flag li ferma tutti e due.

Che cosa tiene il daemon

L’input di un’esecuzione, il suo stato, il suo output e l’input e l’output di ogni passo sono tenuti nello storage delle esecuzioni esattamente come sono stati mandati. Non sono oscurati: sono lo stato di lavoro del workflow stesso, un passo successivo li rilegge, e un valore che l’oscuratore avesse riscritto cambierebbe quello che fa l’esecuzione. Gli span che un’esecuzione scrive nello storage delle tracce sono oscurati come ogni altro span. Anche la conservazione e la cancellazione raggiungono lo storage delle esecuzioni, per le esecuzioni finite. Esecuzioni dice esattamente che cosa cancella ciascuna.

Poi