Producción

Configura Temporal para workflows duraderos, transmite eventos a las UIs, aplica limitación de tasa adaptativa y utiliza recordatorios del sistema.

Limitación de tasa de modelos

Todos los proveedores de modelos aplican límites de tasa. Supéralos y tus peticiones fallarán con errores 429. Peor aún: en un despliegue multi-réplica, cada réplica martillea la API de forma independiente, provocando una limitación agregada que es invisible para los procesos individuales.

El problema

Escenario: Despliegas 10 réplicas de tu servicio de agente. Cada réplica cree disponer de 100K tokens/minuto. Combinadas, envían 1M de tokens/minuto —10 veces tu cuota real—. El proveedor estrangula de forma agresiva. Las peticiones fallan aleatoriamente en todas las réplicas.

Sin limitación de tasa:

  • Las peticiones fallan de forma impredecible con 429
  • No hay visibilidad sobre la capacidad restante
  • Los reintentos empeoran la congestión
  • La experiencia de usuario se degrada bajo carga

Con limitación de tasa adaptativa:

  • Cada réplica comparte un presupuesto coordinado
  • Las peticiones se encolan hasta que hay capacidad disponible
  • El backoff se propaga por todo el clúster
  • Degradación elegante en lugar de fallos

Visión general

El paquete features/model/middleware proporciona un limitador de tasa adaptativo estilo AIMD bajo el cliente de modelo validado. Pide al proveedor el recuento exacto de tokens de entrada, bloquea a los llamadores hasta que haya capacidad y ajusta su presupuesto de tokens de entrada por minuto cuando el proveedor limita el tráfico. Nunca estima tokens ni mide cuotas de salida.

Estrategia AIMD

El limitador utiliza una estrategia de Incremento Aditivo / Decremento Multiplicativo (AIMD):

EventoAcciónFórmula
ÉxitoSondeo (incremento aditivo)TPM += recoveryRate (5 % del inicial)
ErrRateLimitedBackoff (decremento multiplicativo)TPM *= 0.5

El valor efectivo de tokens por minuto (TPM) está acotado por:

  • Mínimo: 10 % del TPM inicial (suelo para evitar la inanición)
  • Máximo: el techo configurado mediante maxTPM

Uso básico

Crea un único limitador por proceso y envuelve tu cliente de modelo:

import (
    "context"

    "goa.design/goa-ai/features/model/middleware"
    "goa.design/goa-ai/runtime/agent/runtime"
)

func main() {
    ctx := context.Background()
    rt := runtime.New(runtimeStore) // almacenamiento del runtime propiedad del host

    // Vertex Gemini proporciona el recuento exacto que exige el limitador.
    modelClient, err := rt.NewVertexGeminiModelClient(ctx, runtime.VertexConfig{
        ProjectID:    "my-gcp-project",
        Location:     "us-central1",
        DefaultModel: "gemini-2.5-flash",
    })
    if err != nil {
        panic(err)
    }

    limiter := middleware.NewAdaptiveRateLimiter(ctx, nil, "", 60000, 120000)
    rateLimitedClient, err := limiter.Middleware()(modelClient)
    if err != nil {
        panic(err)
    }

    if err := rt.RegisterModel("default", rateLimitedClient); err != nil {
        panic(err)
    }
}

Limitación de tasa consciente del clúster

Para despliegues multiproceso, coordina la limitación de tasa entre instancias usando un mapa replicado de Pulse:

import (
    "context"

    "goa.design/goa-ai/features/model/middleware"
    "goa.design/pulse/rmap"
)

func main() {
    ctx := context.Background()

    // Crea un mapa replicado de Pulse respaldado por Redis
    rm, err := rmap.Join(ctx, "rate-limits", redisClient)
    if err != nil {
        panic(err)
    }
    defer rm.Close()

    // Crea un limitador consciente del clúster
    // Todos los procesos que compartan este mapa y clave coordinan sus presupuestos
    limiter := middleware.NewAdaptiveRateLimiter(
        ctx,
        rm,
        "vertex:gemini",  // clave compartida para esta familia de modelos
        60000,            // TPM inicial
        120000,           // TPM máximo
    )

    // Envuelve tu cliente como antes
    rateLimitedClient, err := limiter.Middleware()(vertexClient)
    if err != nil {
        panic(err)
    }
}

Mientras las lecturas y escrituras del mapa replicado funcionen:

  • El backoff se propaga globalmente: cuando cualquier proceso recibe ErrRateLimited, todos los procesos reducen su presupuesto
  • El sondeo se coordina: las peticiones exitosas incrementan el presupuesto compartido
  • Reconciliación automática: los procesos vigilan los cambios externos y actualizan sus limitadores locales

Un mapa nulo o una clave vacía crea deliberadamente un limitador local al proceso. Si el estado compartido no se puede iniciar o actualizar, las llamadas al modelo siguen usando el presupuesto adaptativo local hasta que un evento posterior del mapa vuelva a sincronizarlo. Supervisa Redis cuando necesites coordinación entre procesos.

Recuento exacto de tokens

El limitador llama a CountTokens en el cliente envuelto antes de reservar capacidad. Exige Exact=true y nunca estima.

Vertex Gemini proporciona una operación nativa. Bedrock devuelve model.ErrTokenCountingUnsupported cuando no puede contar exactamente, y OpenAI no ofrece un contador nativo. Envolver un cliente sin soporte funciona, pero su primera llamada Complete o Stream devuelve ese error antes de la inferencia.

Integración con el runtime

Conecta los clientes con tasa limitada al runtime de Goa-AI:

vertexLimiter := middleware.NewAdaptiveRateLimiter(ctx, nil, "", 60000, 120000)
limitedVertex, err := vertexLimiter.Middleware()(vertexClient)
if err != nil {
    panic(err)
}

rt := runtime.New(runtimeStore, runtime.WithEngine(temporalEng))
if err := rt.RegisterModel("gemini", limitedVertex); err != nil {
    panic(err)
}

Qué ocurre bajo carga

Nivel de tráficoSin limitadorCon limitador
Por debajo de la cuotaLas peticiones tienen éxitoLas peticiones tienen éxito
En la cuotaFallos 429 aleatoriosLas peticiones se encolan y luego tienen éxito
Ráfaga por encima de la cuotaCascada de fallos, el proveedor bloqueaEl backoff absorbe la ráfaga, recuperación gradual
Sobrecarga sostenidaTodas las peticiones fallanLas peticiones se encolan con latencia acotada

Parámetros de ajuste

ParámetroValor por defectoDescripción
initialTPM(obligatorio)Presupuesto inicial de tokens por minuto
maxTPM(obligatorio)Techo para sondeo
Suelo10 % del inicialPresupuesto mínimo (evita la inanición)
Tasa de recuperación5 % del inicialIncremento aditivo por éxito
Factor de backoff0.5Decremento multiplicativo ante 429

Ejemplo: con initialTPM=60000, maxTPM=120000:

  • Suelo: 6.000 TPM
  • Recuperación: +3.000 TPM por lote exitoso
  • Backoff: dividir a la mitad el TPM actual ante un 429

Monitorización

Mide la latencia de las llamadas al modelo y los errores terminales model.ErrRateLimited en la telemetría del cliente envuelto. Supervisa también Redis cuando uses un mapa replicado.

Buenas prácticas

  • Un limitador por modelo/proveedor: crea limitadores separados para modelos distintos y aísla sus presupuestos
  • Fija un TPM inicial realista: comienza con el límite de tasa documentado por tu proveedor o con una estimación conservadora
  • Usa limitación consciente del clúster en producción: coordina las réplicas para evitar el estrangulamiento agregado
  • Monitoriza los eventos de backoff: registra o emite métricas cuando se produzcan backoffs para detectar un estrangulamiento sostenido
  • Fija maxTPM por encima del inicial: deja margen para sondear cuando el tráfico esté por debajo de la cuota

Observabilidad GenAI con OpenTelemetry

Cuando se configura un tracer, Goa-AI emite spans neutrales respecto al proveedor siguiendo las convenciones semánticas GenAI de OpenTelemetry para operaciones de agente dentro del planner:

  • las llamadas al modelo usan gen_ai.operation.name="chat" y nombres de span como chat {model}
  • las finalizaciones de herramientas usan gen_ai.operation.name="execute_tool" y nombres de span como execute_tool {tool_name}
  • la delegación agente-como-herramienta usa gen_ai.operation.name="invoke_agent" y nombres de span como invoke_agent {agent_name}

Estos spans incluyen gen_ai.conversation.id, gen_ai.agent.id, gen_ai.agent.name, gen_ai.request.model, gen_ai.response.model, uso de tokens, motivos de finalización, identificadores de herramientas y tiempo hasta el primer fragmento en streaming cuando está disponible. El runtime registra identificadores, conteos, tiempos y errores por defecto; el texto de prompts, el historial de chat, los argumentos de herramientas y los resultados de herramientas siguen siendo política de la aplicación y no se adjuntan automáticamente.

Esto mantiene la telemetría open source de Goa-AI portable entre backends OpenTelemetry y, al mismo tiempo, da a los sistemas de producción suficiente estructura para agrupar una conversación, comparar latencia y uso de tokens por modelo, e inspeccionar cadenas de herramientas multiagente.


Overrides de prompts con almacén Mongo

La gestión de prompts en producción suele utilizar:

  • prompt specs base registradas en runtime.PromptRegistry, y
  • registros de override con scope persistidos en Mongo mediante features/prompt/mongo.

Cableado

import (
    promptmongo "goa.design/goa-ai/features/prompt/mongo"
    clientmongo "goa.design/goa-ai/features/prompt/mongo/clients/mongo"
    "goa.design/goa-ai/runtime/agent/runtime"
)

promptClient, err := clientmongo.New(clientmongo.Options{
    Client:     mongoClient,
    Database:   "assistant",
    Collection: "prompt_overrides", // optional (default is prompt_overrides)
})
if err != nil {
    panic(err)
}

promptStore, err := promptmongo.NewStore(promptClient)
if err != nil {
    panic(err)
}

rt := runtime.New(
    runtimeStore,
    runtime.WithEngine(temporalEng),
    runtime.WithPromptStore(promptStore),
)

Resolución de overrides y despliegue progresivo

La precedencia de overrides es determinista:

  1. scope session
  2. scope facility
  3. scope org
  4. scope global
  5. prompt spec base (cuando no existe override)

Estrategia de rollout recomendada:

  • Registra primero las nuevas prompt specs base.
  • Despliega los overrides primero con un scope amplio (org) y luego restríngelos a facility/session para canarios.
  • Rastrea las versiones efectivas mediante los eventos prompt_rendered y model.Request.PromptRefs.
  • Haz rollback escribiendo un override más reciente en el mismo scope (o eliminando overrides específicos de scope para volver al valor por defecto).

Configuración de Temporal

Esta sección cubre la configuración de Temporal para workflows de agente duraderos en entornos de producción.

Visión general

Temporal proporciona ejecución duradera para tus agentes Goa-AI. Las ejecuciones de agente se convierten en workflows de Temporal con historial basado en eventos. Las llamadas a herramientas se convierten en actividades con reintentos configurables. Cada transición de estado se persiste. Un worker reiniciado reproduce el historial y continúa exactamente donde se quedó.

Cómo funciona la durabilidad

ComponenteRolDurabilidad
WorkflowOrquestación de la ejecución del agenteBasado en eventos; sobrevive a reinicios
Actividad de planificaciónLlamada de inferencia al LLMReintentos ante fallos transitorios
Actividad de ejecución de herramientaInvocación de herramientaPolíticas de reintento por herramienta
EstadoHistorial de turnos, resultados de herramientasPersistido en el historial del workflow

Ejemplo concreto: tu agente llama a un LLM, que devuelve 3 llamadas a herramientas. Dos herramientas se completan. El servicio de la tercera herramienta falla.

  • Sin Temporal: toda la ejecución falla. Vuelves a ejecutar la inferencia ($$$) y a reejecutar las dos herramientas que ya habían tenido éxito.
  • Con Temporal: solo se reintenta la herramienta caída. El workflow se reproduce desde el historial —sin nueva llamada al LLM, sin reejecutar las herramientas ya completadas—. Coste: un reintento, no un reinicio completo.

Qué sobrevive a los fallos

Escenario de falloSin TemporalCon Temporal
Crash del proceso workerEjecución perdida, reinicio desde ceroSe reproduce desde el historial y continúa
Timeout de llamada a herramientaLa ejecución falla (o requiere manejo manual)Reintento automático con backoff
Límite de tasa (429)La ejecución fallaHace backoff, reintenta automáticamente
Partición de redProgreso parcial perdidoSe reanuda tras la reconexión
Despliegue durante una ejecuciónLas ejecuciones en curso fallanLos workers drenan y los nuevos reanudan

Instalación

Opción 1: Docker (desarrollo)

Una sola línea para desarrollo local:

docker run --rm -d --name temporal-dev -p 7233:7233 temporalio/auto-setup:latest

Opción 2: Temporalite (desarrollo)

go install go.temporal.io/server/cmd/temporalite@latest
temporalite start

Opción 3: Temporal Cloud (producción)

Regístrate en temporal.io y configura tu cliente con las credenciales de la nube.

Opción 4: Autohospedado (producción)

Despliega Temporal usando Docker Compose o Kubernetes. Consulta la documentación de Temporal para guías de despliegue.

Configuración del runtime

Goa-AI abstrae el backend de ejecución detrás de la interfaz Engine. Intercambia motores sin cambiar el código del agente:

Motor en memoria (desarrollo):

// Default: no external dependencies
rt := runtime.New(storageinmem.New())

Motor Temporal (producción):

import (
    runtimeTemporal "goa.design/goa-ai/runtime/agent/engine/temporal"
    temporalclient "go.temporal.io/sdk/client"
    "go.temporal.io/sdk/worker"
    "go.temporal.io/sdk/workflow"

    // Your generated tool specs aggregate.
    // The generated package exposes: func Spec(tools.Ident) (*tools.ToolSpec, bool)
    specs "<module>/gen/<service>/agents/<agent>/specs"
)

const releaseBuildID = "git-sha-or-image-digest"

temporalEng, err := runtimeTemporal.NewWorker(runtimeTemporal.Options{
    ClientOptions: &temporalclient.Options{
        HostPort:  "127.0.0.1:7233",
        Namespace: "default",
        // Required: enforce goa-ai's workflow boundary contract.
        // Tool results and server-data cross workflow boundaries as canonical JSON bytes
        // (for example api.ToolEvent payloads), not decoded planner.ToolResult values.
        DataConverter: runtimeTemporal.NewAgentDataConverter(specs.Spec),
    },
    WorkerOptions: runtimeTemporal.WorkerOptions{
        TaskQueue: "orchestrator.chat",
        Options: worker.Options{
            DeploymentOptions: worker.DeploymentOptions{
                UseVersioning: true,
                Version: worker.WorkerDeploymentVersion{
                    DeploymentName: "assistant",
                    BuildID:        releaseBuildID,
                },
                DefaultVersioningBehavior: workflow.VersioningBehaviorPinned,
            },
        },
    },
})
if err != nil {
    panic(err)
}
defer temporalEng.Close()

rt := runtime.New(runtimeStore, runtime.WithEngine(temporalEng))

Propiedad del almacenamiento del runtime

runtime.New requiere un único storage.Store. En producción, un solo servicio de la aplicación debe ser propietario de la base de datos que contiene el estado de las sesiones, los metadatos de las ejecuciones, los checkpoints de continuación y los registros inmutables. Los workers de agentes llaman a ese propietario mediante una API tipada; no abren conexiones independientes a sus colecciones.

Los datos del producto siguen perteneciendo al servicio del producto. Por ejemplo, un servicio de chat conserva sus transcripciones, valoraciones y campos de búsqueda aunque otro servicio sea propietario del almacén del runtime de Goa-AI.

Memoria y sesiones define el contrato completo del almacenamiento: los cambios de ciclo de vida y sus registros se guardan juntos, los reintentos exactos devuelven el resultado aceptado, los hijos nuevos requieren un padre activo, el JSON de eventos guardados es estricto y se conserva la procedencia de la cancelación. Runtime define cómo los hosts validan y entregan los eventos finales después de que se cierre el historial del motor. Mantén estas reglas en el almacén del runtime que las posee, en lugar de reproducirlas en cada worker.

El cambio desde session.Store y runlog.Store es un cambio coordinado del almacenamiento. Antes de que el nuevo runtime escriba, los metadatos, checkpoints y registros existentes deben cumplir el contrato integrado de storage.Store. Despliega juntos al propietario del almacenamiento y a todos los workers que lo llaman. Los escritores antiguos de almacenes separados y los nuevos escritores integrados no deben solaparse.

Realiza la conversión sin tráfico con un programa de migración desechable en un job o pod temporal de la base de datos. La aplicación host posee ese programa; no forma parte del release normal y se elimina tras verificar el cambio.

  1. Crea y verifica una copia de seguridad y confirma que no haya writers.
  2. Ejecuta la migración en modo de verificación y corrige cada registro rechazado.
  3. Aplica la conversión y verifica esquema, índices, sesiones, metadatos, checkpoints v7 y registros inmutables.
  4. Despliega juntos al propietario y a todos los workers, y elimina el programa.

Una vez iniciada la conversión, el rollback restaura la copia completa. No ejecutes writers antiguos sobre una base parcialmente convertida.

Tiempos y reintentos de actividad

Usa el DSL para presupuestos semánticos de ejecución: cuánto puede durar toda la ejecución, cuánto puede durar un intento de planificación y cuánto puede durar un intento de herramienta.

Goa-AI inicia cada workflow de agente una sola vez. No reinicia el workflow completo después de un fallo porque un intento anterior puede haber llamado a herramientas o guardado un registro final. La durabilidad procede del replay del historial y de los reintentos de cada activity de planificación, herramienta, hook y almacenamiento.

Agent("operator", "Production operations agent", func() {
    RunPolicy(func() {
        DefaultCaps(MaxToolCalls(20), MaxRecoveryTurns(3))
        Timing(func() {
            Budget("5m")
            Plan("45s")
            Tools("90s")
        })
    })
})

El adaptador de Temporal es el propietario de los aspectos mecánicos del motor de workflows, como la espera en cola y los timeouts de liveness. Configúralos en el motor, no en el DSL:

temporalEng, err := runtimeTemporal.NewWorker(runtimeTemporal.Options{
    ClientOptions: &client.Options{
        HostPort:  "127.0.0.1:7233",
        Namespace: "default",
    },
    WorkerOptions: runtimeTemporal.WorkerOptions{
        TaskQueue: "orchestrator.chat",
    },
    ActivityDefaults: runtimeTemporal.ActivityDefaults{
        Planner: runtimeTemporal.ActivityTimeoutDefaults{
            QueueWaitTimeout: 30 * time.Second,
            LivenessTimeout:  20 * time.Second,
        },
        Tool: runtimeTemporal.ActivityTimeoutDefaults{
            QueueWaitTimeout: 2 * time.Minute,
            LivenessTimeout:  20 * time.Second,
        },
    },
})

Las actividades generadas de plan/resume, execute-tool y publicación de hooks utilizan políticas de reintento que solo son seguras cuando los reintentos son lógicamente idempotentes. Los eventos de hook llevan claves de evento estables y las ejecuciones de herramientas deben persistir o reproducir los resultados canónicos por ToolCallID en lugar de repetir efectos laterales irreversibles.

Configuración de workers

Los workers sondean colas de tareas y ejecutan workflows/actividades. Los workers se inician automáticamente para cada agente registrado —en la mayoría de los casos no se necesita configuración manual de workers—.

Cambios del contrato generado

Cuando cambien de forma incompatible los agentes generados, los paquetes de completion o los payloads persistidos del runtime, regenera todos los agentes y las completions, drena o detén el trabajo afectado y despliega conjuntamente el runtime, los workers y los llamadores. Goa-AI no ofrece lectura dual para los contratos generados del runtime.

El runtime acepta únicamente el esquema exacto goa-ai.run-suspension.v7. Los planificadores que esperan preguntas, aclaraciones o herramientas externas conservan el ModelToolCallID del proveedor; el workflow asigna el ToolCallID independiente del runtime antes de guardar la suspensión. Las suspensiones con otros esquemas no se reanudan. Antes de cambiar este esquema en el futuro, inventaría y retira el trabajo guardado incompatible durante el despliegue coordinado; no añadas un lector dual ni deduzcas campos ausentes.

Buenas prácticas

  • Utiliza namespaces separados para entornos distintos (dev, staging, prod)
  • Configura políticas de reintento por toolset basadas en las características de fiabilidad
  • Monitoriza la ejecución de workflows usando la UI y las herramientas de observabilidad de Temporal
  • Fija timeouts adecuados para las actividades: equilibra fiabilidad frente a detección de cuelgues
  • Usa Temporal Cloud en producción para evitar la carga operativa

UI de streaming

Esta sección muestra cómo transmitir eventos del agente a UIs en tiempo real utilizando la infraestructura de streaming de Goa-AI.

Visión general

Goa-AI publica streams propiedad de la sesión de eventos tipados que pueden entregarse a las UIs a través de:

  • Server-Sent Events (SSE)
  • WebSockets
  • Buses de mensajes (Pulse, Redis Streams, etc.)

Todos los eventos visibles en el stream para una sesión se añaden a un único stream: session/<session_id>. Cada evento lleva tanto run_id como session_id para que las UIs puedan agrupar eventos en carriles/tarjetas por ejecución. Las ejecuciones anidadas de agentes se enlazan mediante eventos child_run_linked. Las UIs cierran SSE/WebSocket de forma determinista cuando observan run_stream_end para la ejecución activa.

Interfaz Stream Sink

Implementa la interfaz stream.Sink:

type Sink interface {
    Send(ctx context.Context, event stream.Event) error
    Close(ctx context.Context) error
}

Tipos de evento

El paquete stream define tipos de evento concretos que implementan stream.Event. Los más comunes para las UIs son:

Tipo de eventoDescripción
AssistantReplyFragmentos de mensaje del asistente (texto en streaming)
PlannerThoughtBloques de pensamiento del planner (notas y razonamiento estructurado)
ToolStartEjecución de herramienta iniciada
ToolUpdateProgreso de ejecución de herramienta (actualizaciones del número esperado de hijos)
ToolEndEjecución de herramienta completada (resultado, error, telemetría)
AwaitClarificationEl planner espera una aclaración humana
AwaitExternalToolsEl planner espera resultados de herramientas externas
UsageUso de tokens por invocación de modelo
WorkflowActualizaciones de ciclo de vida y fase de la ejecución
ChildRunLinkedEnlace desde la llamada a una herramienta padre a la ejecución de un agente hijo
RunStreamEndMarcador de frontera explícito del stream para una ejecución (no aparecerán más eventos visibles en el stream para esa ejecución)

Los transportes suelen hacer type-switch sobre stream.Event para obtener seguridad en tiempo de compilación:

switch e := evt.(type) {
case stream.AssistantReply:
    // e.Data.Text
case stream.PlannerThought:
    // e.Data.Note o campos de razonamiento estructurado
case stream.ToolStart:
    // e.Data.ToolCallID, e.Data.ToolName, e.Data.Payload
case stream.ToolEnd:
    // e.Data.Result, e.Data.Error, e.Data.ResultPreview
case stream.ChildRunLinked:
    // e.Data.ToolName, e.Data.ToolCallID, e.Data.ChildRunID, e.Data.ChildAgentID
case stream.RunStreamEnd:
    // la ejecución ya no tiene más eventos visibles en el stream
}

Ejemplo: sumidero SSE

type SSESink struct {
    w http.ResponseWriter
}

func (s *SSESink) Send(ctx context.Context, event stream.Event) error {
    switch e := event.(type) {
    case stream.AssistantReply:
        fmt.Fprintf(s.w, "data: assistant: %s\n\n", e.Data.Text)
    case stream.PlannerThought:
        if e.Data.Note != "" {
            fmt.Fprintf(s.w, "data: thinking: %s\n\n", e.Data.Note)
        }
    case stream.ToolStart:
        fmt.Fprintf(s.w, "data: tool_start: %s\n\n", e.Data.ToolName)
    case stream.ToolEnd:
        fmt.Fprintf(s.w, "data: tool_end: %s status=%v\n\n",
            e.Data.ToolName, e.Data.Error == nil)
    case stream.ChildRunLinked:
        fmt.Fprintf(s.w, "data: child_run_linked: %s child=%s\n\n",
            e.Data.ToolName, e.Data.ChildRunID)
    case stream.RunStreamEnd:
        fmt.Fprintf(s.w, "data: run_stream_end: %s\n\n", e.RunID())
    }
    s.w.(http.Flusher).Flush()
    return nil
}

func (s *SSESink) Close(ctx context.Context) error {
    return nil
}

Suscripción al stream de sesión (Pulse)

En producción, las UIs consumen el stream de sesión (session/<session_id>) desde un bus compartido (Pulse / Redis Streams) y filtran por run_id. Cierra SSE/WebSocket cuando observes run_stream_end para la ejecución activa.

Sumidero global de stream

Para transmitir todas las ejecuciones a través de un sumidero global (por ejemplo, Pulse), configura el runtime con un sumidero de stream:

rt := runtime.New(
    runtimeStore,
    runtime.WithStream(pulseSink), // or your custom sink
)

El runtime instala por defecto un stream.Subscriber que:

  • mapea eventos de hook a valores stream.Event
  • usa el StreamProfile por defecto, que emite respuestas del asistente, pensamientos del planner, start/update/end de herramientas, esperas, uso, workflow, enlaces child_run_linked y el marcador terminal run_stream_end

Perfiles de stream

No todos los consumidores necesitan todos los eventos. Los perfiles de stream filtran eventos para distintas audiencias, reduciendo el ruido y el ancho de banda para casos de uso específicos.

PerfilCaso de usoEventos incluidos
UserChatProfile()UI de chat de usuario finalRespuestas del asistente, start/end de herramientas, finalización del workflow
AgentDebugProfile()Depuración del desarrolladorTodo, incluidos los pensamientos del planner
MetricsProfile()Pipelines de observabilidadSolo eventos de uso y de workflow

Uso de perfiles incorporados:

// Chat para el usuario: respuestas, estado de herramienta, finalización
profile := stream.UserChatProfile()

// Vista de depuración: todo incluyendo pensamientos del planner
profile := stream.AgentDebugProfile()

// Pipeline de métricas: solo eventos de uso y workflow
profile := stream.MetricsProfile()

sub, _ := stream.NewSubscriberWithProfile(sink, profile)

Perfiles personalizados:

// Control granular sobre qué eventos emitir
profile := stream.StreamProfile{
    Assistant:  true,
    Thoughts:   false,  // Omitir pensamientos del planner
    ToolStart:  true,
    ToolUpdate: true,
    ToolEnd:    true,
    Usage:      false,  // Omitir eventos de uso
    Workflow:   true,
    ChildRuns:  true,   // Incluir enlaces herramienta padre → ejecución hija
}

sub, _ := stream.NewSubscriberWithProfile(sink, profile)

Los perfiles personalizados son útiles cuando:

  • Necesitas eventos específicos para un consumidor especializado (por ejemplo, seguimiento del progreso)
  • Quieres reducir el tamaño de la carga útil para clientes móviles
  • Estás construyendo pipelines de analítica que solo necesitan ciertos eventos

Avanzado: Pulse y puentes de stream

En configuraciones de producción, a menudo querrás:

  • publicar eventos en un bus compartido (por ejemplo, Pulse)
  • usar un stream propiedad de la sesión en ese bus (session/<session_id>)

Goa-AI proporciona:

  • features/stream/pulse – una implementación de stream.Sink respaldada por Pulse
  • runtime/agent/stream/bridge – helpers para cablear el bus de hooks a cualquier sumidero

Cableado típico:

pulseClient := pulse.NewClient(redisClient)
s, err := pulseSink.NewSink(pulseSink.Options{
    Client: pulseClient,
    // Optional: override stream naming (defaults to `session/<SessionID>`).
    StreamID: func(ev stream.Event) (string, error) {
        if ev.SessionID() == "" {
            return "", errors.New("missing session id")
        }
        return fmt.Sprintf("session/%s", ev.SessionID()), nil
    },
})
if err != nil { log.Fatal(err) }

rt := runtime.New(
    runtimeStore,
    runtime.WithEngine(eng),
    runtime.WithStream(s),
)

Recordatorios del sistema

Los modelos derivan. Olvidan instrucciones. Ignoran contexto que estaba claro hace 10 turnos. Cuando tu agente ejecuta tareas de larga duración, necesitas una forma de inyectar orientación dinámica y contextual sin contaminar la conversación con el usuario.

El problema

Escenario: tu agente gestiona una lista de tareas. Tras 20 turnos, el usuario pregunta “¿qué hago ahora?” pero el modelo ha derivado —no recuerda que hay una tarea pendiente en progreso—. Necesitas darle un empujón sin que el usuario vea un incómodo mensaje “RECORDATORIO: tienes una tarea en progreso”.

Sin recordatorios del sistema:

  • Saturas el system prompt con todos los escenarios posibles
  • La orientación se pierde en conversaciones largas
  • No hay forma de inyectar contexto basado en los resultados de herramientas
  • Los usuarios ven el andamiaje interno del agente

Con recordatorios del sistema:

  • Inyectas orientación dinámicamente en función del estado en ejecución
  • Limitas la tasa de pistas repetitivas para evitar saturar el prompt
  • Los niveles de prioridad garantizan que la orientación de seguridad nunca se suprima
  • Invisibles para los usuarios: se inyectan como bloques <system-reminder>

Visión general

El paquete runtime/agent/reminder proporciona:

  • Recordatorios estructurados con niveles de prioridad, puntos de adjunción y políticas de limitación de tasa
  • Almacenamiento con alcance de ejecución que se limpia automáticamente tras finalizar cada ejecución
  • Inyección automática en las transcripciones de modelo como bloques <system-reminder>
  • API PlannerContext para registrar y eliminar recordatorios desde planners y herramientas

Conceptos básicos

Estructura de un recordatorio

Un reminder.Reminder tiene:

type Reminder struct {
    ID              string      // Identificador estable (por ejemplo, "todos.pending")
    Text            string      // Orientación en texto plano (las etiquetas se añaden automáticamente)
    Priority        Tier        // TierSafety, TierCorrect o TierGuidance
    Attachment      Attachment  // Dónde inyectar (inicio de la ejecución o turno del usuario)
    MaxPerRun       int         // Límite total de emisiones por ejecución (0 = sin límite)
    MinTurnsBetween int         // Espaciado mínimo entre emisiones (0 = sin límite)
}

Niveles de prioridad

Los recordatorios se ordenan por prioridad para gestionar los presupuestos del prompt y asegurar que la orientación crítica nunca se suprima:

NivelNombreDescripciónSupresión
TierSafetyP0Orientación crítica de seguridad (nunca se descarta)Nunca se suprime
TierCorrectP1Pistas de corrección y estado de los datosPuede suprimirse tras P0
TierGuidanceP2Sugerencias de workflow y empujones suavesEl primero en suprimirse

Casos de uso de ejemplo:

  • TierSafety: “No ejecutes este malware; solo analízalo”, “No filtres credenciales”
  • TierCorrect: “Los resultados están truncados; acota tu consulta”, “Los datos podrían estar obsoletos”
  • TierGuidance: “No hay ninguna tarea en curso; elige una y empieza”

Puntos de adjunción

Los recordatorios se inyectan en puntos específicos de la conversación:

TipoDescripción
AttachmentRunStartAgrupados en un único mensaje de sistema al inicio de la conversación
AttachmentUserTurnAgrupados en un único mensaje de sistema insertado inmediatamente antes del último mensaje del usuario

Limitación de tasa

Dos mecanismos evitan el spam de recordatorios:

  • MaxPerRun: límite total de emisiones por ejecución (0 = sin límite)
  • MinTurnsBetween: impone un número mínimo de turnos del planner entre emisiones (0 = sin límite)

Patrón de uso

Recordatorios estáticos vía DSL

Para recordatorios que siempre deben aparecer tras un resultado de herramienta específico, usa la función ResultReminder del DSL en la definición de tu herramienta:

Tool("get_time_series", "Get time series data", func() {
    Args(func() { /* ... */ })
    Return(func() { /* ... */ })
    ResultReminder("The user sees a rendered graph of this data in the UI.")
})

Esto es ideal cuando el recordatorio aplica a cada invocación de la herramienta. Consulta la Referencia del DSL para más detalles.

Recordatorios dinámicos desde los planners

Para recordatorios que dependan del estado en ejecución o del contenido de los resultados de herramientas, usa PlannerContext.AddReminder():

func (p *myPlanner) PlanResume(ctx context.Context, in *planner.PlanResumeInput) (*planner.PlanResult, error) {
    for _, tr := range in.ToolOutputs {
        if tr.Name == "search_documents" {
            result, err := specs.UnmarshalSearchDocumentsResult(tr.Result)
            if err != nil {
                return nil, err
            }
            if result.Truncated {
                in.Agent.AddReminder(reminder.Reminder{
                    ID:       "search.truncated",
                    Text:     "Search results are truncated. Consider narrowing your query.",
                    Priority: reminder.TierCorrect,
                    Attachment: reminder.Attachment{
                        Kind: reminder.AttachmentUserTurn,
                    },
                    MaxPerRun:       3,
                    MinTurnsBetween: 2,
                })
            }
        }
    }
    // Continuar con la planificación...
}

Eliminación de recordatorios

Usa RemoveReminder() cuando una precondición ya no se cumpla:

if allTodosCompleted {
    in.Agent.RemoveReminder("todos.no_active")
}

Preservación de los contadores de limitación de tasa

AddReminder() preserva los contadores de emisión al actualizar un recordatorio existente por ID. Si necesitas cambiar el contenido del recordatorio pero mantener los límites de tasa:

in.Agent.AddReminder(reminder.Reminder{
    ID:              "todos.pending",
    Text:            buildUpdatedText(snap),
    Priority:        reminder.TierGuidance,
    Attachment:      reminder.Attachment{Kind: reminder.AttachmentUserTurn},
    MinTurnsBetween: 3,
})

Antipatrón: no llames a RemoveReminder() seguido de AddReminder() para el mismo ID —esto reinicia los contadores y salta MinTurnsBetween—.

Inyección y formato

Etiquetado automático

El runtime envuelve automáticamente el texto del recordatorio con etiquetas <system-reminder> al inyectarlo en las transcripciones:

// Tú proporcionas texto plano:
Text: "Results are truncated. Narrow your query."

// El runtime inyecta:
<system-reminder>Results are truncated. Narrow your query.</system-reminder>

Explicar los recordatorios a los modelos

Incluye reminder.DefaultExplanation en el system prompt para que los modelos sepan cómo interpretar los bloques <system-reminder>:

const systemPrompt = `
You are a helpful assistant.

` + reminder.DefaultExplanation + `

Follow all instructions carefully.
`

Ejemplo completo

func (p *myPlanner) PlanResume(ctx context.Context, in *planner.PlanResumeInput) (*planner.PlanResult, error) {
    for _, tr := range in.ToolOutputs {
        if tr.Name == "todos.update_todos" {
            snap, err := specs.UnmarshalUpdateTodosResult(tr.Result)
            if err != nil {
                return nil, err
            }
            
            var rem *reminder.Reminder
            if len(snap.Items) == 0 {
                in.Agent.RemoveReminder("todos.no_active")
                in.Agent.RemoveReminder("todos.all_completed")
            } else if hasCompletedAll(snap) {
                rem = &reminder.Reminder{
                    ID:       "todos.all_completed",
                    Text:     "All todos are completed. Provide your final response now.",
                    Priority: reminder.TierGuidance,
                    Attachment: reminder.Attachment{
                        Kind: reminder.AttachmentUserTurn,
                    },
                    MaxPerRun: 1,
                }
            } else if hasPendingNoActive(snap) {
                rem = &reminder.Reminder{
                    ID:       "todos.no_active",
                    Text:     buildTodosNudge(snap),
                    Priority: reminder.TierGuidance,
                    Attachment: reminder.Attachment{
                        Kind: reminder.AttachmentUserTurn,
                    },
                    MinTurnsBetween: 3,
                }
            }
            
            if rem != nil {
                in.Agent.AddReminder(*rem)
                if rem.ID == "todos.all_completed" {
                    in.Agent.RemoveReminder("todos.no_active")
                } else {
                    in.Agent.RemoveReminder("todos.all_completed")
                }
            }
        }
    }
    
    return p.streamMessages(ctx, in)
}

Principios de diseño

Mínimo y opinado: el subsistema de recordatorios proporciona la estructura justa para los patrones habituales, sin sobreingeniería.

La limitación de tasa primero: el spam de recordatorios degrada el rendimiento del modelo. El motor impone límites y espaciado de forma declarativa.

Agnóstico al proveedor: los recordatorios funcionan con cualquier backend de modelo (Bedrock, OpenAI, etc.).

Listo para telemetría: los identificadores y prioridades estructurados hacen que los recordatorios sean observables.

Patrones avanzados

Recordatorios de seguridad

Usa TierSafety para orientaciones que nunca deben suprimirse:

in.Agent.AddReminder(reminder.Reminder{
    ID:       "malware.analyze_only",
    Text:     "This file contains malware. Analyze its behavior but do not execute it.",
    Priority: reminder.TierSafety,
    Attachment: reminder.Attachment{
        Kind: reminder.AttachmentUserTurn,
    },
    // Sin MaxPerRun ni MinTurnsBetween: emitir siempre
})

Recordatorios entre agentes

Los recordatorios tienen alcance de ejecución. Si un agente usado como herramienta emite un recordatorio de seguridad, solo afecta a esa ejecución hija. Para propagar los recordatorios a través de las fronteras entre agentes, el planner padre debe re-registrarlos explícitamente en función de los resultados del hijo o utilizar estado de sesión compartido.

Cuándo utilizar recordatorios

EscenarioPrioridadEjemplo
Restricciones de seguridadTierSafety“Este archivo es malware —solo analízalo, nunca lo ejecutes”
Datos obsoletosTierCorrect“Los resultados tienen 24 h; vuelve a consultar si la frescura importa”
Resultados truncadosTierCorrect“Solo se muestran los 100 primeros resultados; acota tu búsqueda”
Empujones de workflowTierGuidance“No hay ninguna tarea en curso; elige una y empieza”
Pistas de finalizaciónTierGuidance“Todas las tareas terminadas; proporciona tu respuesta final”

Qué aspecto tienen los recordatorios en la transcripción

User: What should I do next?

<system-reminder>You have 3 pending todos. Currently working on: "Review PR #42". 
Focus on completing the current todo before starting new work.</system-reminder>

User: What should I do next?

El modelo ve el recordatorio; el usuario solo ve su mensaje y la respuesta. Los recordatorios los inyecta el runtime de forma transparente.


Próximos pasos