Ir al contenido

Kernel de turno de canal

El núcleo de turnos de canal es la máquina de estado de entrada compartida que convierte un evento de plataforma normalizado en un turno de agente. Los complementos de canal proporcionan los datos de la plataforma y la devolución de llamada de entrega. Core posee la orquestación: ingestión, clasificación, preverificación, resolución, autorización, ensamblaje, registro, despacho y finalización.

Use esto cuando su complemento esté en la ruta de acceso rápida de mensajes entrantes. Para eventos que no son mensajes (comandos de barra, modales, interacciones de botones, eventos de ciclo de vida, reacciones, estado de voz), manténgalos localmente en el complemento. El núcleo solo posee eventos que pueden convertirse en un turno de texto de agente.

Los complementos de canal repiten el mismo flujo de entrada: normalizar, enrutar, filtrar, construir un contexto, registrar metadatos de sesión, despachar el turno de agente y finalizar el estado de entrega. Sin un núcleo compartido, un cambio en el filtrado de menciones, respuestas visibles solo para herramientas, metadatos de sesión, historial pendiente o finalización del despacho debe aplicarse por canal.

El núcleo mantiene deliberadamente separados cuatro conceptos:

  • ConversationFacts: de dónde vino el mensaje
  • RouteFacts: qué agente y sesión deben procesarlo
  • ReplyPlanFacts: a dónde deben ir las respuestas visibles
  • MessageFacts: qué cuerpo y contexto complementario debe ver el agente

Los mensajes directos de Slack, los temas de Telegram, los hilos de Matrix y las sesiones de temas de Feishu los distinguen en la práctica. Tratarlos como un solo identificador causa desviaciones con el tiempo.

El núcleo ejecuta la misma canalización fija independientemente del canal:

  1. ingest — el adaptador convierte un evento de plataforma sin procesar en NormalizedTurnInput
  2. classify — el adaptador declara si este evento puede iniciar un turno de agente
  3. preflight — el adaptador realiza deduplicación, eco propio, hidratación, antirrebote, descifrado, relleno previo parcial de hechos
  4. resolve — el adaptador devuelve un turno completamente ensamblado (ruta, plan de respuesta, mensaje, entrega)
  5. authorize — política de MD, grupo, mención y comando aplicada a los hechos ensamblados
  6. assembleFinalizedMsgContext construido a partir de los hechos a través de buildContext
  7. record — metadatos de la sesión entrante y última ruta persistida
  8. dispatch — turno de agente ejecutado a través del despachador de bloques almacenados en búfer
  9. finalizeonFinalize del adaptador se ejecuta incluso en caso de error de envío

Cada etapa emite un evento de registro estructurado cuando se proporciona una devolución de llamada log. Consulte Observabilidad.

El kernel no genera excepciones cuando un turno está restringido. Devuelve un ChannelTurnAdmission:

TipoCuándo
dispatchEl turno es admitido. Se ejecuta el turno del agente y se ejerce la ruta de respuesta visible.
observeOnlyEl turno se ejecuta de extremo a extremo, pero el adaptador de entrega no envía nada visible. Se utiliza para agentes de observador de difusión y otros flujos multiagente pasivos.
handledUn evento de plataforma se consumió localmente (ciclo de vida, reacción, botón, modal). El núcleo omite el despacho.
dropRuta omitida. Opcionalmente recordHistory: true mantiene el mensaje en el historial del grupo pendiente para que una mención futura tenga contexto.

La admisión puede provenir de classify (la clase de evento indicó que no puede iniciar un turno), de preflight (deduplicación, eco propio, mención faltante con registro de historial), o del propio resolveTurn.

El tiempo de ejecución expone tres puntos de entrada preferidos para que los adaptadores puedan participar en el nivel que coincida con el canal.

runtime.channel.turn.run(...) // adapter-driven full pipeline
runtime.channel.turn.runAssembled(...) // already-built context + delivery adapter
runtime.channel.turn.runPrepared(...) // channel owns dispatch; kernel runs record + finalize
runtime.channel.turn.buildContext(...) // pure facts to FinalizedMsgContext mapping

Dos funciones auxiliares de tiempo de ejecución más antiguas siguen disponibles para la compatibilidad con el Plugin SDK:

runtime.channel.turn.runResolved(...) // deprecated compatibility alias; prefer run
runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer runAssembled

Úselo cuando su canal pueda expresar su flujo de entrada como un ChannelTurnAdapter<TRaw>. El adaptador tiene devoluciones de llamada para ingest, classify opcional, preflight opcional, resolveTurn obligatorio y onFinalize opcional.

await runtime.channel.turn.run({
channel: "tlon",
accountId,
raw: platformEvent,
adapter: {
ingest(raw) {
return {
id: raw.messageId,
timestamp: raw.timestamp,
rawText: raw.body,
textForAgent: raw.body,
};
},
classify(input) {
return { kind: "message", canStartAgentTurn: input.rawText.length > 0 };
},
async preflight(input, eventClass) {
if (await isDuplicate(input.id)) {
return { admission: { kind: "drop", reason: "dedupe" } };
}
return {};
},
resolveTurn(input) {
return buildAssembledTurn(input);
},
onFinalize(result) {
clearPendingGroupHistory(result);
},
},
});

run es la forma adecuada cuando el canal tiene una lógica de adaptador pequeña y se beneficia de poseer el ciclo de vida a través de ganchos.

Úselo cuando el canal ya haya resuelto el enrutamiento, haya construido un FinalizedMsgContext, y solo necesite el registro compartido, la canalización de respuesta, el despacho y el orden de finalización. Esta es la forma preferida para rutas de entrada empaquetadas simples que de otro modo repetirían la plantilla de createChannelMessageReplyPipeline(...) y runPrepared(...).

await runtime.channel.turn.runAssembled({
cfg,
channel: "irc",
accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await sendPlatformReply(payload);
},
onError: (err, info) => {
runtime.error?.(`reply ${info.kind} failed: ${String(err)}`);
},
},
});

Elija runAssembled sobre runPrepared cuando el único comportamiento de despacho propio del canal sea la entrega final de la carga útil más escritura opcional, opciones de respuesta, entrega durable o registro de errores.

Úselo cuando el canal tenga un despachador local complejo con vistas previas, reintentos, ediciones o inicio de subprocesos que debe seguir siendo propiedad del canal. El kernel todavía registra la sesión de entrada antes del despacho y expone un DispatchedChannelTurnResult uniforme.

const { dispatchResult } = await runtime.channel.turn.runPrepared({
channel: "matrix",
accountId,
routeSessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
onRecordError,
updateLastRoute,
},
onPreDispatchFailure: async (err) => {
await stopStatusReactions();
},
runDispatch: async () => {
return await runMatrixOwnedDispatcher();
},
});

Los canales ricos (Matrix, Mattermost, Microsoft Teams, Feishu, QQ Bot) usan runPrepared porque su despachador orquesta comportamientos específicos de la plataforma que el kernel no debe conocer.

Una función pura que mapea paquetes de hechos en FinalizedMsgContext. Úsela cuando su canal implemente manualmente parte de la canalización pero desea una forma de contexto consistente.

const ctxPayload = runtime.channel.turn.buildContext({
channel: "googlechat",
accountId,
messageId,
timestamp,
from,
sender,
conversation,
route,
reply,
message,
access,
media,
supplemental,
});

buildContext también es útil dentro de las devoluciones de llamada resolveTurn al ensamblar un turno para run.

Los hechos que el núcleo consume de su adaptador son independientes de la plataforma. Traduzca los objetos de la plataforma a estas formas antes de entregarlos al núcleo.

CampoPropósito
idId de mensaje estable usado para deduplicación y registros
timestampEpoch ms opcional
rawTextCuerpo tal como se recibió de la plataforma
textForAgentCuerpo limpio opcional para el agente (eliminación de menciones, recorte de escritura)
textForCommandsCuerpo opcional utilizado para el análisis /command
rawReferencia de paso opcional para devoluciones de llamada del adaptador que necesitan el original
CampoPropósito
kindmessage, command, interaction, reaction, lifecycle, unknown
canStartAgentTurnSi es falso, el kernel devuelve { kind: "handled" }
requiresImmediateAckSugerencia para adaptadores que necesitan ACK antes del despacho
CampoPropósito
idId de remitente estable de la plataforma
nameNombre para mostrar
usernameIdentificador si es distinto de name
tagDiscriminador estilo Discord o etiqueta de plataforma
rolesIds de roles, utilizados para la coincidencia de la lista blanca de roles de miembros
isBotVerdadero cuando el remitente es un bot conocido (el núcleo lo usa para descartar)
isSelfVerdadero cuando el remitente es el propio agente configurado
displayLabelEtiqueta pre-renderizada para el texto del sobre
CampoPropósito
kinddirect, group o channel
idId de conversación utilizado para el enrutamiento
labelEtiqueta humana para el sobre
spaceIdIdentificador de espacio externo opcional (espacio de trabajo de Slack, hogar de servidor Matrix)
parentIdId de conversación externa cuando esto es un hilo
threadIdId del hilo cuando este mensaje está dentro de un hilo
nativeChannelIdId. de canal nativo de la plataforma cuando es diferente del id. de enrutamiento
routePeerPar utilizado para la búsqueda de resolveAgentRoute
CampoPropósito
agentIdAgente que debe manejar este turno
accountIdInvalidación opcional (canales multicuenta)
routeSessionKeyClave de sesión utilizada para el enrutamiento
dispatchSessionKeyClave de sesión utilizada en el envío cuando es diferente de la clave de ruta
persistedSessionKeyClave de sesión escrita en los metadatos de sesión persistidos
parentSessionKeyPadre para sesiones bifurcadas/en hilo
modelParentSessionKeyPadre del lado del modelo para sesiones bifurcadas
mainSessionKeyPin principal del propietario del DM para conversaciones directas
createIfMissingPermitir que el paso de registro cree una fila de sesión faltante
CampoPropósito
toDestino de respuesta lógica escrito en el contexto To
originatingToDestino de contexto de origen (OriginatingTo)
nativeChannelIdId. de canal nativo de la plataforma para la entrega
replyTargetDestino final de respuesta visible si difiere de to
deliveryTargetInvalidación de entrega de nivel inferior
replyToIdId. de mensaje citado/anclado
replyToIdFullId. citado en forma completa cuando la plataforma tiene ambos
messageThreadIdId. de hilo en el momento de la entrega
threadParentIdId. del mensaje principal del hilo
sourceReplyDeliveryModethread, reply, channel, direct, o none

AccessFacts lleva los booleanos que necesita la etapa de autorización. La coincidencia de identidad permanece en el canal: el kernel solo consume el resultado.

CampoPropósito
dmDecisión de permitir/emparejar/denegar DM y lista allowFrom
groupPolítica de grupo, permitir ruta, permitir remitente, lista de permitidos, requisito de mención
commandsAutorización de comandos a través de los autorizadores configurados
mentionsSi la detección de mención es posible y si se mencionó al agente
CampoPropósito
bodyCuerpo final del sobre (formateado)
rawBodyCuerpo de entrada sin procesar
bodyForAgentCuerpo que ve el agente
commandBodyCuerpo utilizado para el análisis de comandos
envelopeFromEtiqueta de remitente prerrenderizada para el sobre
senderLabelAnulación opcional para el remitente renderizado
previewVista previa breve redactada para registros
inboundHistoryEntradas recientes del historial de entrada cuando el canal mantiene un búfer

El contexto suplementario cubre la cita, el reenvío y el contexto de arranque de hilo. El kernel aplica la política contextVisibility configurada. El adaptador del canal solo proporciona hechos y marcas senderAllowed para que la política entre canales se mantenga constante.

Los medios tienen forma de hechos. La descarga de la plataforma, la autenticación, la política SSRF, las reglas de CDN y el descifrado permanecen locales del canal. El kernel asigna los hechos a MediaPath, MediaUrl, MediaType, MediaPaths, MediaUrls, MediaTypes y MediaTranscribedIndexes.

Use toInboundMediaFacts(...) de openclaw/plugin-sdk/channel-inbound cuando su canal tenga una lista de medios resuelta y solo necesite adjuntar hechos genéricos:

media: toInboundMediaFacts(resolvedMedia, {
kind: "image",
messageId: input.id,
});

Si los medios mezclan archivos locales y entradas solo de URL, mantenga la lista como hechos de medios. Core preserva los índices de la matriz cuando escribe los campos de contexto heredados para que la comprensión descendente de medios, los marcadores de transcripción y las notas del prompt sigan refiriéndose al mismo adjunto.

Para los mensajes de grupo omitidos que deben estar disponibles para una mención posterior, pase los hechos de medios a través del campo preflight.media del turno. El núcleo convierte esos hechos en entradas de medios de historial delimitadas antes de grabar:

preflight(input) {
return {
admission: { kind: "drop", reason: "missing_mention", recordHistory: true },
media: () => toInboundMediaFacts(resolveLocalImages(input), {
kind: "image",
messageId: input.id,
}),
history: {
key: historyKey,
limit: historyLimit,
mediaLimit: 4,
shouldRecord: () => stillCurrent(input),
},
};
}

El historial de medios es intencionalmente conservador: solo imágenes hoy, solo rutas legibles locales, delimitado por el límite de medios configurado, y aún vinculado a la clave de historial del canal. Las URL de proveedores autenticados deben ser descargadas por el complemento antes de que se conviertan en medios visibles para el modelo.

El código de turno de mensaje debe usar createChannelHistoryWindow(...) en lugar de llamar a los ayudantes de mapa de bajo nivel reply-history directamente. Los antiguos ayudantes de mapa permanecen importables como exportaciones de compatibilidad en desuso, pero el nuevo código de tiempo de ejecución del complemento no debe llamarlos. La fachada de la ventana mantiene el contexto de texto, InboundHistory estructurado, la normalización de medios de historial y la limpieza detrás de una API propiedad del núcleo, mientras que aún permite que el canal elija cómo se representa una línea de historial.

const history = createChannelHistoryWindow({ historyMap: groupHistories });
await history.recordWithMedia({
historyKey,
limit: historyLimit,
entry,
media: () =>
toInboundMediaFacts(resolvedImages, {
kind: "image",
messageId: entry.messageId,
}),
});
const combinedBody = history.buildPendingContext({
historyKey,
limit: historyLimit,
currentMessage,
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
});

Las antiguas exportaciones buildPendingHistoryContextFromMap, buildInboundHistoryFromMap, recordPendingHistoryEntry* y clearHistoryEntries* permanecen como compatibilidad en desuso para complementos que aún no han migrado. El nuevo trabajo de canal debe usar la ventana o las opciones de grabación/finalización del núcleo de turno.

Grupo de solo texto con mención requerida:

preflight(input) {
const decision = resolveInboundMentionDecision({ facts, policy });
if (decision.shouldSkip) {
return {
admission: { kind: "drop", reason: "missing_mention", recordHistory: true },
history: { key: historyKey, limit: historyLimit },
};
}
return { access: { mentions: decision } };
}

Mensaje de solo imagen seguido de una mención posterior:

preflight(input) {
if (!wasMentioned && resolvedImages.length > 0) {
return {
admission: { kind: "drop", reason: "missing_mention", recordHistory: true },
media: () => toInboundMediaFacts(resolvedImages, {
kind: "image",
messageId: input.id,
}),
history: { key: historyKey, limit: historyLimit, mediaLimit: 4 },
};
}
return {};
}

Respuesta explícita a imagen:

resolveTurn(input, _eventClass, preflight) {
return {
...assembled,
media: toInboundMediaFacts([...currentMedia, ...referencedReplyMedia]),
supplemental: {
quote: preflight.supplemental?.quote,
},
};
}

Mensaje directo con historial:

resolveTurn(input) {
return {
...assembled,
history: undefined,
message: {
rawBody: input.rawText,
bodyForAgent: input.textForAgent,
},
};
}

Para run completo, la forma del adaptador es:

type ChannelTurnAdapter<TRaw> = {
ingest(raw: TRaw): Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null;
classify?(input: NormalizedTurnInput): Promise<ChannelEventClass> | ChannelEventClass;
preflight?(input: NormalizedTurnInput, eventClass: ChannelEventClass): Promise<PreflightFacts | ChannelTurnAdmission | null | undefined>;
resolveTurn(input: NormalizedTurnInput, eventClass: ChannelEventClass, preflight: PreflightFacts): Promise<ChannelTurnResolved> | ChannelTurnResolved;
onFinalize?(result: ChannelTurnResult): Promise<void> | void;
};

resolveTurn devuelve un ChannelTurnResolved, que es un AssembledChannelTurn con un tipo de admisión opcional. Devolver { admission: { kind: "observeOnly" } } ejecuta el turno sin producir salida visible. El adaptador sigue siendo dueño de la devolución de llamada de entrega; simplemente se convierte en una no-op para ese turno.

onFinalize se ejecuta en cada resultado, incluidos los errores de envío. Úselo para borrar el historial de grupos pendientes, eliminar las reacciones de reconocimiento, detener los indicadores de estado y vaciar el estado local.

El núcleo no llama a la plataforma directamente. El canal entrega al núcleo un ChannelEventDeliveryAdapter:

type ChannelEventDeliveryAdapter = {
deliver(payload: ReplyPayload, info: ChannelDeliveryInfo): Promise<ChannelDeliveryResult | void>;
onError?(err: unknown, info: { kind: string }): void;
durable?: false | DurableInboundReplyDeliveryOptions;
};
type ChannelDeliveryResult = {
messageIds?: string[];
receipt?: MessageReceipt;
threadId?: string;
replyToId?: string;
visibleReplySent?: boolean;
};

deliver se llama una vez por cada fragmento de respuesta almacenado en búfer. Durante la migración del ciclo de vida del mensaje, la entrega de eventos de canal ensamblados es propiedad del canal de forma predeterminada: un campo durable omitido significa que el núcleo debe llamar a deliver directamente y no debe enrutar a través de la entrega genérica de salida. Establezca durable solo después de que el canal haya sido auditado para demostrar que la ruta de envío genérica conserva el comportamiento de entrega anterior, incluidos los objetivos de respuesta/hilo, el manejo de medios, los cachés de mensajes enviados/eco propio, la limpieza de estado y los IDs de mensajes devueltos. durable: false sigue siendo una ortografía de compatibilidad para “usar la devolución de llamada propia del canal”, pero los canales no migrados no deberían necesitar agregarla. Devuelva los IDs de mensajes de la plataforma cuando el canal los tenga para que el despachador pueda conservar los anclajes de hilo y editar fragmentos posteriores; las rutas de entrega más nuevas también deben devolver receipt para que la recuperación, la finalización de vista previa y la supresión de duplicados puedan abandonar messageIds. Para turnos de solo observación, devuelva { visibleReplySent: false } o use createNoopChannelEventDeliveryAdapter().

Los canales que usan runPrepared con un despachador totalmente propiedad del canal no tienen un ChannelEventDeliveryAdapter. Esos despachadores no son duraderos de forma predeterminada. Deben mantener su ruta de entrega directa hasta que se acepten explícitamente al nuevo contexto de envío con un objetivo completo, un adaptador seguro para repetición, un contrato de recibo y ganchos de efectos secundarios del canal.

Los asistentes de compatibilidad pública como recordInboundSessionAndDispatchReply, dispatchInboundReplyWithBase y los asistentes de MD directos deben mantener el comportamiento durante la migración. No deben llamar a la entrega duradera genérica antes de las devoluciones de llamada deliver o reply propiedad de la persona que llama.

La etapa de registro envuelve recordInboundSession. La mayoría de los canales pueden usar los valores predeterminados. Anular mediante record:

record: {
groupResolution,
createIfMissing: true,
updateLastRoute,
onRecordError: (err) => log.warn("record failed", err),
trackSessionMetaTask: (task) => pendingTasks.push(task),
}

El despachador espera a la etapa de registro. Si el registro arroja un error, el kernel ejecuta onPreDispatchFailure (cuando se proporciona a runPrepared) y vuelve a lanzar el error.

Cada etapa emite un evento estructurado cuando se suministra una devolución de llamada log:

await runtime.channel.turn.run({
channel: "twitch",
accountId,
raw,
adapter,
log: (event) => {
runtime.log?.debug?.(`turn.${event.stage}:${event.event}`, {
channel: event.channel,
accountId: event.accountId,
messageId: event.messageId,
sessionKey: event.sessionKey,
admission: event.admission,
reason: event.reason,
});
},
});

Etapas registradas: ingest, classify, preflight, resolve, authorize, assemble, record, dispatch, finalize. Evite registrar cuerpos sin procesar; use MessageFacts.preview para obtener vistas previas redactadas cortas.

El kernel es propietario de la orquestación. El canal sigue siendo propietario de:

  • Transportes de plataforma (puerta de enlace, REST, websocket, sondeo, webhooks)
  • Resolución de identidad y coincidencia de nombres para mostrar
  • Comandos nativos, comandos de barra, autocompletado, modales, botones, estado de voz
  • Renderizado de tarjetas, modales y tarjetas adaptables
  • Autenticación de medios, reglas de CDN, medios cifrados, transcripción
  • APIs de edición, reacción, redacción y presencia
  • Relleno y obtención del historial del lado de la plataforma
  • Flujos de emparejamiento que requieren verificación específica de la plataforma

Si dos canales comienzan a necesitar el mismo ayudante para uno de estos, extraiga un ayudante compartido del SDK en lugar de insertarlo en el kernel.

runtime.channel.turn.* es parte de la superficie pública del tiempo de ejecución del complemento. Los tipos de datos (SenderFacts, ConversationFacts, RouteFacts, ReplyPlanFacts, AccessFacts, MessageFacts, SupplementalContextFacts, InboundMediaFacts) y las formas de admisión (ChannelTurnAdmission, ChannelEventClass) son accesibles a través de PluginRuntime desde openclaw/plugin-sdk/core.

Se aplican las reglas de compatibilidad con versiones anteriores: los nuevos campos de hechos son aditivos, los tipos de admisión no se cambian de nombre y los nombres de los puntos de entrada se mantienen estables. Las necesidades de nuevos canales que requieran un cambio no aditivo deben pasar por el proceso de migración del SDK del complemento.