DeepSeek Harness Sistema de eventos: emit / bail / serial / waterfall

¿Cómo se comunican los plugins con bajo acoplamiento? Los eventos son el mecanismo central de comunicación de Cordis.

Harness utiliza ampliamente los eventos para implementar puntos de extensión escalables; en este artículo se explican en detalle los cinco modos de distribución.


Uso básico

Los eventos tienen dos extremos: escucha y disparo.

Ejemplo

// Escuchar el evento: registrar un callback
ctx.on('event-name', (payload) => {
  // Manejar el evento
})

// Disparar el evento: transmitir a todos los listeners
ctx.emit('event-name', payload)

Los listeners registrados mediante ctx.on se eliminan automáticamente al desinstalar el plugin.


Cinco modos de distribución

Cordis proporciona varios modos de distribución, adecuados para diferentes contratos de interacción.

五种分发模式行为示意图

ModoMétodo de distribución¿Usa await?Orden¿Tiene valor de retorno?Escenario típico
emitDifusiónctx.emitnoSegún el orden de registronoTipo notificación: todos los listeners observan
bailCortocircuitoctx.bailnoSegún el orden de registroSíTipo decisión: gana el primer valor de retorno válido
serialSecuencialctx.serialSíSegún el orden de registroSíInicialización por fases: ejecutar en orden y esperar
waterfallCanalización (pipe)ctx.waterfallnoSegún el orden de registroSíCadena de procesamiento: envolver el valor de retorno downstream capa por capa
parallelParaleloctx.parallelSíTodo en paralelonoFan-out: múltiples listeners procesan en paralelo

Cada evento tiene exactamente un modo de distribución, y solo se puede distribuir mediante el método correspondiente.

La documentación oficial de introducción marca waterfall como "sin await"; pero como los listeners suelen ser funciones asíncronas, en los ejemplos del tutorial generalmente se escribe await ctx.waterfall(...) para esperar el resultado final de toda la cadena de procesamiento. Solo hay que recordar esta diferencia; ninguna de las dos formas es incorrecta.


emit: difusión

Todos los listeners se ejecutan de forma síncrona y los valores de retorno se ignoran.

Ejemplo

// Emisor: transmitir un mensaje de "listo"
ctx.emit('my-plugin/ready', { id: 'ejemplo-worker-1' })

// Listener: al recibir el mensaje de listo, imprime un registro
ctx.on('my-plugin/ready', ({ id }) => {
  console.log(`${id} is ready`)
})

bail: cortocircuito

Los listeners se ejecutan en orden; el primer valor de retorno que no sea null, false ni undefined se convierte en el resultado final.

Ejemplo

// Distribuidor: realizar una comprobación y tomar el primer resultado "con opinión"
const result = ctx.bail('some-check', input)

// Listener: si cumple la condición de intercepción, devuelve 'blocked'; de lo contrario, continúa
ctx.on('some-check', (input) => {
  if (shouldBlock(input)) return 'blocked'
  // Devolver null / false / undefined significa "no tengo opinión", permitiendo que los listeners siguientes continúen
})

serial: ejecución secuencial

Los listeners se ejecutan en orden de registro y esperan resultados asíncronos.

El primer valor de retorno que no sea null, false ni undefined detiene la ejecución posterior.

Ejemplo

// Ejecutar secuencialmente una inicialización por fases, esperando a que cada fase termine
await ctx.serial('setup-phase', context)

waterfall: canalización (pipe)

Cada listener puede envolver el valor de retorno downstream, formando una cadena de procesamiento.

El listener recibe (...args, next); llamar a next() ejecuta el listener downstream, y el valor de retorno downstream se devuelve a la capa de envoltura actual mediante next().

Ejemplo

// Distribuidor: el valor inicial es input, que se pasa al primer listener
const output = await ctx.waterfall('my-plugin/transform', input, async () => input)

// Listener: debe llamar a next(), procesar el resultado downstream y luego devolverlo
ctx.on('my-plugin/transform', async (_input, next) => {
  const downstream = await next()
  return downstream.trim()
})

El listener de waterfall debe llamar a next(). No llamar a next cortocircuita toda la canalización; esto es un diseño intencional para implementar lógica de intercepción o puerta de enlace (gateway).

Un listener de estrategia, cuando tiene poder de decisión, puede devolver directamente sin llamar a next(), bloqueando así toda la cadena.

Un listener que solo anota u observa debe delegar en el downstream.


Eventos con seguridad de tipos

Utiliza la declaración de fusión (declaration merging) de TypeScript para proporcionar seguridad de tipos a los eventos.

Ejemplo

// Ruta del archivo: scratch-plugin/src/events.ts
import '@deepseek-ai/cordis'

// Declaración de fusión: registrar el nombre del evento y su firma
declare module '@deepseek-ai/cordis' {
  interface Events {
    'my-plugin/ready': (payload: { id: string }) => void
    'my-plugin/check': (input: string) => boolean | undefined
    'my-plugin/transform': (input: string, next: () => Promise<string>) => Promise<string>
  }
}

// En adelante, los argumentos de ctx.on('my-plugin/ready', ...) y ctx.emit('my-plugin/ready', ...)
// se infieren automáticamente; escribir mal el nombre del evento o el tipo de los parámetros generará errores en tiempo de compilación

Eventos Cordis y registro de sesión

Los eventos Cordis de Harness siguen la nomenclatura namespace/action, por ejemplo agent/step, agent/request, agent/request-error, tools/result y session/event.

Ten en cuenta la distinción entre dos tipos de eventos:

EventoQué esCómo observarlo
agent/step, tools/result, etc.Eventos Cordis, distribución en tiempo realDirectamente con ctx.on('tools/result', ...)
turn/*、step/*、tool/call、tool/result、compaction/*Tipos de eventos de sesión persistidosEscucha session/event y verifica event.type

turn/*, step/*, tool/call, tool/result y compaction/* son tipos de eventos de sesión persistidos, no eventos Cordis del mismo nombre.

Cuando necesites observarlos, escucha session/event y verifica event.type.


Ejemplo práctico: plugin de registro

La documentación oficial usa el siguiente plugin para registrar las llamadas a herramientas y sus resultados, escuchando el evento Cordis tools/result.

Ejemplo

// Ruta del archivo: scratch-plugin/src/tool-logger.ts
import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools'

export const name = 'tool-logger'

export function apply(ctx: Context) {
  // Escuchar el evento tools/result: se dispara cada vez que se completa la ejecución de una herramienta
  ctx.on('tools/result', (exec, result) => {
    // Imprimir el nombre de la herramienta y sus parámetros
    console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
    // Concatenar los bloques de texto del contenido del resultado e imprimir solo los primeros 100 caracteres
    const text = result.content
      .map(block => block.type === 'text' ? block.text : '')
      .join('')
    console.log(`[tool result] ${text.slice(0, 100)}`)
  })
}

Este plugin imprime en la terminal el nombre de la herramienta, los parámetros y el resumen del resultado cada vez que el Agent llama a una herramienta.

Los listeners se registran mediante ctx.on y se eliminan automáticamente al desinstalar el plugin.


Resumen y autoevaluación

Los cinco modos de distribución cubren cinco tipos de contratos: "difusión, decisión, secuencial, canalización y paralelo"; waterfall debe llamar a next(); la fusión con declare module garantiza tipos para los nombres de eventos y sus parámetros.

Ponte a prueba:

  1. ¿Qué método se utiliza para disparar cada uno de los cinco modos de distribución? ¿Cuáles tienen valor de retorno?
  2. ¿Qué ocurre si un listener de waterfall no llama a next()? ¿Es un bug o un diseño intencional?
  3. Para eventos de sesión persistidos como turn/start, ¿qué evento se debe escuchar y qué campo se debe verificar?
Otras extensiones