Ir al contenido
mcpd
sin daemon
Código

Workflows

Un workflow son datos que guarda el daemon y cuerpos que el daemon nunca ve. Esta página son los datos: qué puede decir una definición, y qué le hace hacer al daemon cada construcción.

El reparto del trabajo

Una ejecución duradera tiene dos dueños. El daemon es dueño de la ejecución entre pasos: el cursor, el diario, el presupuesto de reintentos, los temporizadores y las esperas. Tu proceso es dueño de todo lo que hay dentro de un paso: el modelo al que llamas, el prompt que envías, las herramientas que ejecutas, las credenciales que guardas. Al daemon no se le entrega nada de eso, y nunca ejecuta un modelo en tu nombre.

Así que un workflow llega en dos partes desde un mismo archivo. El grafo va al daemon como una definición, un documento que el daemon recorre. Los cuerpos de los pasos se quedan en tu proceso, en el lenguaje en que los escribiste, para que los sirva un worker. No hay nada que desplegar en el daemon, y un cuerpo puede cambiar sin que la definición cambie en absoluto.

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

Escribir uno

El constructor produce las dos partes. document() es la definición, y cada llamada a step o a agent se queda con su cuerpo para el worker. Cada SDK tiene el mismo constructor con las mismas nueve construcciones, así que un workflow escrito en un lenguaje se lee igual en otro, y el daemon almacena el mismo documento lo haya escrito quien lo haya escrito.

un workflow de soporte

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}' })

Tres cosas de ese ejemplo son la forma de todo workflow. La entrada de un paso es una plantilla que el daemon renderiza en cada intento, así que draft lee lo que respondió classify sin que ninguno de los dos cuerpos sepa del otro. Una guarda decide si un nodo llega a ejecutarse: una pregunta se envía tal cual, un bug espera a una persona. Y el último paso es at_most_once con una política de reintentos, que es la combinación que necesita un efecto secundario: el daemon reintenta un fallo pasajero, y nunca ejecuta un cuerpo dos veces sin que se lo digan.

Las construcciones

Nueve, y cada SDK tiene las nueve. Dos son reclamables, es decir, las sirve un worker; el resto son del propio daemon.

ConstrucciónQuién la ejecutaQué hace
task Un worker Trabajo reclamable. El worker ejecuta el código que registró con este id.
agent Un worker Lo mismo sobre el cable, registrado como un turno de agente: tu propio bucle, con todas las llamadas al modelo que hagan falta, como un único paso duradero. La traza y la interfaz lo distinguen de un task normal.
sleep El daemon Un retardo de tantos milisegundos. Durante él no hay ningún proceso vivo.
sleep_until El daemon Un retardo hasta un instante renderizado desde una plantilla, por ejemplo una fecha de facturación que viene en la entrada.
wait_event El daemon Una pausa hasta que llega un evento con este nombre y esta clave, o hasta que vence el plazo. La clave se renderiza por ejecución, así que una espera se dirige a un pedido y a ningún otro.
parallel Los dos Ramas con nombre, cada una un paso reclamable, todas en disco antes de que se ofrezca ninguna. La ejecución sigue adelante cuando acaba la última rama.
foreach Los dos Un paso reclamable por cada elemento del array que hay en una ruta, acotado por max_items y, si lo pides, por cuántos corren a la vez. Las salidas vuelven como un array en el orden de los elementos.
loop Los dos Un bucle acotado sobre un paso reclamable. until se evalúa después de cada iteración y puede terminarlo antes.
subworkflow El daemon Una ejecución hija de otra definición, con su propio diario. Su salida pasa a ser la salida de este paso, y cancelar la ejecución madre la cancela.

Una rama, el cuerpo de un foreach y el cuerpo de un loop son un paso reclamable cada uno. Un cuerpo que necesita varios pasos es un subworkflow, y eso mantiene plano cualquier diario: una ejecución es una lista, nunca un árbol de listas.

Plantillas y guardas

Una plantilla es JSON con referencias $${path} dentro de sus cadenas. El daemon la renderiza cuando se crea un intento, con lo que la ejecución tiene en ese momento: input, la entrada de la ejecución; steps.<id>, lo que respondió un paso anterior, con .output, .status y .error; run, el id y la correlación de la propia ejecución; y, dentro de un foreach o de un loop, item, index e iter.index. Una referencia que ocupa toda la cadena conserva su tipo, así que "$${steps.classify.output}" le entrega al paso siguiente un objeto, y no una cadena con un objeto dentro.

Una guarda es un predicado sobre esas mismas rutas: eq, ne, lt, lte, gt, gte, in, exists y matches, combinados con all, any y not. Una guarda que sale falsa escribe el nodo como skipped y la ejecución sigue. Una ruta que no está hace que toda comparación sea falsa en lugar de lanzar un error, porque una ejecución que muere por un campo que falta es una ejecución que nadie puede terminar.

El daemon valida el documento cuando lo almacena. Una guarda que nombra una ruta que nada puede producir, una plantilla que lee un paso declarado más tarde, un foreach sobre algo que no es una ruta, un id que no encaja con ^[a-z][a-z0-9_]*$: cada uno se rechaza con un 400 que nombra el campo. Los campos desconocidos también se rechazan, así que una guarda mal escrita es un error, y no una ejecución que se salta su guarda.

Cada duración tiene un tope además de un mínimo. run_timeout_ms, step_timeout_ms, el initial_ms y el max_ms de un reintento, el ms de un sleep y el timeout_ms de una espera valen como mucho 315360000000, que son 3650 días, y uno más largo se rechaza con un 400 que nombra el campo, el tope y el valor que enviaste. Un plazo más lejano que eso no cabría en el reloj con el que el daemon lo guarda.

Reintentos, efectos y fallos

Un cuerpo que falla le dice al daemon cómo, con una clase. retryable merece otro intento, y el daemon lo hace mientras dure el presupuesto retry del nodo, con el backoff del nodo, y esperando al menos el retry_after_ms que pidió el cuerpo, que está sujeto al mismo tope de 3650 días que cualquier otra duración. fatal e invalid_input no se reintentan. unservable significa que este worker no guarda la definición o el paso, y el intento vuelve a la cola para otro worker sin gastar presupuesto. Un cuerpo que lanza cualquier otra cosa es fatal.

max_attempts cuenta todos los intentos, incluido el primero, así que el valor por defecto de uno significa que no hay reintento. Agotado el presupuesto, decide on_error: fail_run, el valor por defecto, termina la ejecución con el mensaje del paso como su error; continue marca el paso como fallido y sigue adelante, de modo que una guarda posterior puede leer steps.<id>.status y esquivarlo.

effect dice lo que costaría duplicar un cuerpo. Bajo at_least_once, el valor por defecto, un lease que se pierde vuelve a encolar el intento y el cuerpo puede ejecutarse otra vez. Bajo at_most_once el worker debe publicar started antes del cuerpo, y un intento ya iniciado que pierde su lease falla en vez de volver a ejecutarse. Úsalo para el paso que envía el correo o cobra la tarjeta, y dale a ese cuerpo el idempotency_key del lease para que se lo pase al tercero: es estable entre reintentos del mismo paso, así que un reintento tras una respuesta perdida se reconoce también al otro lado.

Versiones

Una definición es inmutable una vez almacenada. Publica el mismo cuerpo otra vez y recibes de vuelta la misma versión; publica un cuerpo distinto bajo una versión que ya existe y el daemon lo rechaza con un 409, porque si no una ejecución que fijó esa versión cambiaría por debajo. Omite la versión y el daemon asigna el número siguiente, que es lo que hace un worker al arrancar. Una ejecución fija la versión con la que empezó y no vuelve a resolverla nunca, así que un despliegue que almacena la versión 3 termina las ejecuciones de la versión 2 tal como se escribieron.

Límites

Una definición puede declarar max_steps, run_timeout_ms y max_cost_usd. El daemon cuenta cada intento contra el primero, la edad de la ejecución contra el segundo, y el precio del consumo que informan los pasos contra el tercero, y termina la ejecución como timed_out o failed en el tope, con el tope nombrado como el error. Un loop que no converge y un paso de agente que no deja de gastar son el mismo problema para el daemon, y el mismo campo detiene los dos.

Qué guarda el daemon

La entrada de una ejecución, su estado, su salida y la entrada y la salida de cada paso se guardan en el almacén de ejecuciones tal como se enviaron. No se ocultan: son el estado de trabajo del propio workflow, un paso posterior los vuelve a leer, y un valor que el ocultador hubiera reescrito cambiaría lo que hace la ejecución. Los spans que una ejecución escribe en el almacén de trazas se ocultan como cualquier otro span. La retención y la supresión llegan también al almacén de ejecuciones, para las ejecuciones que han terminado. Ejecuciones dice exactamente qué borra cada una.

A continuación