Plugins

Ядро ходу каналу

Ядро ходу каналу — це спільна вхідна машина станів, яка перетворює нормалізовану подію платформи на хід агента. Plugin-и каналів надають факти платформи та зворотний виклик доставки. Ядро відповідає за оркестрацію: приймання, класифікацію, попередню перевірку, розв’язання, авторизацію, складання, запис, диспетчеризацію та завершення.

Використовуйте це, коли ваш Plugin перебуває на гарячому шляху вхідних повідомлень. Для подій, що не є повідомленнями (slash-команди, модальні вікна, взаємодії з кнопками, події життєвого циклу, реакції, голосовий стан), залишайте їх локальними для Plugin-а. Ядро відповідає лише за події, які можуть стати текстовим ходом агента.

Навіщо потрібне спільне ядро

Plugin-и каналів повторюють той самий вхідний потік: нормалізувати, маршрутизувати, обмежити, побудувати контекст, записати метадані сесії, диспетчеризувати хід агента, завершити стан доставки. Без спільного ядра зміну до обмеження за згадкою, видимих відповідей лише для інструментів, метаданих сесії, відкладеної історії або завершення диспетчеризації потрібно застосовувати окремо для кожного каналу.

Ядро навмисно тримає чотири поняття окремими:

  • ConversationFacts: звідки надійшло повідомлення
  • RouteFacts: який агент і яка сесія мають його обробити
  • ReplyPlanFacts: куди мають надходити видимі відповіді
  • MessageFacts: яке тіло та додатковий контекст має бачити агент

Slack DM, теми Telegram, потоки Matrix і тематичні сесії Feishu на практиці розрізняють усе це. Якщо трактувати їх як один ідентифікатор, з часом виникає розходження.

Життєвий цикл етапів

Ядро виконує той самий фіксований конвеєр незалежно від каналу:

  1. ingest -- адаптер перетворює необроблену подію платформи на NormalizedTurnInput
  2. classify -- адаптер оголошує, чи може ця подія почати хід агента
  3. preflight -- адаптер виконує дедуплікацію, відкидання власного відлуння, гідратацію, debounce, дешифрування, часткове попереднє заповнення фактів
  4. resolve -- адаптер повертає повністю складений хід (маршрут, план відповіді, повідомлення, доставка)
  5. authorize -- до складених фактів застосовується політика DM, груп, згадок і команд
  6. assemble -- FinalizedMsgContext будується з фактів через buildContext
  7. record -- зберігаються вхідні метадані сесії та останній маршрут
  8. dispatch -- хід агента виконується через буферизований диспетчер блоків
  9. finalize -- onFinalize адаптера виконується навіть у разі помилки диспетчеризації

Кожен етап створює структуровану подію журналу, коли надано зворотний виклик log. Див. Спостережуваність.

Види допуску

Ядро не викидає помилку, коли хід заблоковано. Воно повертає ChannelTurnAdmission:

Вид Коли
dispatch Хід допущено. Хід агента виконується, і шлях видимої відповіді задіюється.
observeOnly Хід виконується наскрізно, але адаптер доставки не надсилає нічого видимого. Використовується для агентів-спостерігачів трансляцій та інших пасивних багатоагентних потоків.
handled Подію платформи спожито локально (життєвий цикл, реакція, кнопка, модальне вікно). Ядро пропускає диспетчеризацію.
drop Шлях пропуску. Необов’язковий recordHistory: true зберігає повідомлення у відкладеній історії групи, щоб майбутня згадка мала контекст.

Допуск може надійти з classify (клас події вказав, що вона не може почати хід), з preflight (дедуплікація, власне відлуння, відсутня згадка із записом історії) або з самого resolveTurn.

Точки входу

Runtime надає три бажані точки входу, щоб адаптери могли підключатися на рівні, який відповідає каналу.

runtime.channel.turn.run(...)             // повний конвеєр, керований адаптером
runtime.channel.turn.runPrepared(...)     // канал володіє диспетчеризацією; ядро виконує запис + завершення
runtime.channel.turn.buildContext(...)    // чисте відображення фактів у FinalizedMsgContext

Два старіші помічники runtime залишаються доступними для сумісності з Plugin SDK:

runtime.channel.turn.runResolved(...)      // застарілий псевдонім сумісності; віддавайте перевагу run
runtime.channel.turn.dispatchAssembled(...) // застарілий псевдонім сумісності; віддавайте перевагу run або runPrepared

run

Використовуйте, коли ваш канал може виразити свій вхідний потік як ChannelTurnAdapter<TRaw>. Адаптер має зворотні виклики для ingest, необов’язкового classify, необов’язкового preflight, обов’язкового resolveTurn і необов’язкового onFinalize.

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 має правильну форму, коли канал має невелику логіку адаптера й отримує користь від володіння життєвим циклом через хуки.

runPrepared

Використовуйте, коли канал має складний локальний диспетчер із попередніми переглядами, повторами, редагуваннями або bootstrap-ом потоку, який має залишатися у власності каналу. Ядро все одно записує вхідну сесію перед диспетчеризацією та надає уніфікований DispatchedChannelTurnResult.

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();
  },
});

Насичені канали (Matrix, Mattermost, Microsoft Teams, Feishu, QQ Bot) використовують runPrepared, бо їхній диспетчер оркеструє специфічну для платформи поведінку, про яку ядро не має знати.

buildContext

Чиста функція, яка відображає пакети фактів у FinalizedMsgContext. Використовуйте її, коли ваш канал вручну реалізує частину конвеєра, але хоче узгоджену форму контексту.

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

buildContext також корисна всередині зворотних викликів resolveTurn, коли складається хід для run.

Типи фактів

Факти, які ядро споживає з вашого адаптера, не залежать від платформи. Перетворіть об’єкти платформи на ці форми, перш ніж передавати їх ядру.

NormalizedTurnInput

Поле Призначення
id Стабільний id повідомлення, що використовується для дедуплікації та журналів
timestamp Необов’язковий epoch у мс
rawText Тіло в тому вигляді, у якому його отримано з платформи
textForAgent Необов’язкове очищене тіло для агента (видалення згадки, обрізання введення)
textForCommands Необов’язкове тіло, що використовується для розбору /command
raw Необов’язкове наскрізне посилання для зворотних викликів адаптера, яким потрібен оригінал

ChannelEventClass

Поле Призначення
kind message, command, interaction, reaction, lifecycle, unknown
canStartAgentTurn Якщо false, ядро повертає { kind: "handled" }
requiresImmediateAck Підказка для адаптерів, яким потрібно ACK до диспетчеризації

SenderFacts

Поле Призначення
id Стабільний id відправника на платформі
name Відображуване ім’я
username Handle, якщо відрізняється від name
tag Дискримінатор у стилі Discord або тег платформи
roles id ролей, що використовуються для зіставлення allowlist ролей учасників
isBot True, коли відправник є відомим ботом (ядро використовує для відкидання)
isSelf True, коли відправник є самим налаштованим агентом
displayLabel Попередньо відрендерена мітка для тексту конверта

ConversationFacts

Поле Призначення
kind direct, group або channel
id id розмови, що використовується для маршрутизації
label Людська мітка для конверта
spaceId Необов’язковий ідентифікатор зовнішнього простору (workspace Slack, homeserver Matrix)
parentId id зовнішньої розмови, коли це потік
threadId id потоку, коли це повідомлення всередині потоку
nativeChannelId Нативний для платформи id каналу, коли він відрізняється від id маршрутизації
routePeer Peer, що використовується для пошуку resolveAgentRoute

RouteFacts

Поле Призначення
agentId Агент, який має обробити цей хід
accountId Необов’язкове перевизначення (багатоакаунтні канали)
routeSessionKey Ключ сесії, що використовується для маршрутизації
dispatchSessionKey Ключ сесії, що використовується під час диспетчеризації, коли відрізняється від ключа маршруту
persistedSessionKey Ключ сесії, записаний до збережених метаданих сесії
parentSessionKey Батьківський ключ для розгалужених/потокових сесій
modelParentSessionKey Батьківський ключ на боці моделі для розгалужених сесій
mainSessionKey Закріплення власника основного DM для прямих розмов
createIfMissing Дозволити кроку запису створити відсутній рядок сесії

ReplyPlanFacts

Поле Призначення
to Логічна ціль відповіді, записана в контекст To
originatingTo Початкова ціль контексту (OriginatingTo)
nativeChannelId Нативний для платформи ідентифікатор каналу для доставки
replyTarget Кінцева видима ціль відповіді, якщо вона відрізняється від to
deliveryTarget Низькорівневе перевизначення доставки
replyToId Ідентифікатор процитованого/прив’язаного повідомлення
replyToIdFull Повна форма процитованого ідентифікатора, коли платформа має обидві
messageThreadId Ідентифікатор треду на момент доставки
threadParentId Ідентифікатор батьківського повідомлення треду
sourceReplyDeliveryMode thread, reply, channel, direct або none

AccessFacts

AccessFacts містить булеві значення, потрібні етапу авторизації. Зіставлення ідентичності залишається в каналі: ядро лише споживає результат.

Поле Призначення
dm Рішення дозволу/спарювання/заборони DM та список allowFrom
group Політика групи, дозвіл маршруту, дозвіл відправника, allowlist, вимога згадки
commands Авторизація команд у налаштованих авторизаторах
mentions Чи можливе виявлення згадки та чи було згадано агента

MessageFacts

Поле Призначення
body Кінцеве тіло конверта (відформатоване)
rawBody Сире вхідне тіло
bodyForAgent Тіло, яке бачить агент
commandBody Тіло, що використовується для розбору команд
envelopeFrom Попередньо відрендерена мітка відправника для конверта
senderLabel Необов’язкове перевизначення для відрендереного відправника
preview Короткий редагований попередній перегляд для журналів
inboundHistory Останні записи вхідної історії, коли канал тримає буфер

SupplementalContextFacts

Додатковий контекст охоплює контекст цитати, пересланого повідомлення та початкового завантаження треду. Ядро застосовує налаштовану політику contextVisibility. Адаптер каналу лише надає факти та прапорці senderAllowed, щоб міжканальна політика залишалася узгодженою.

InboundMediaFacts

Медіа має форму фактів. Завантаження платформи, автентифікація, політика SSRF, правила CDN і дешифрування залишаються локальними для каналу. Ядро відображає факти в MediaPath, MediaUrl, MediaType, MediaPaths, MediaUrls, MediaTypes і MediaTranscribedIndexes.

Контракт адаптера

Для повного run форма адаптера така:

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 повертає ChannelTurnResolved, тобто AssembledChannelTurn з необов’язковим видом допуску. Повернення { admission: { kind: "observeOnly" } } запускає хід без створення видимого виводу. Адаптер усе ще володіє callback доставки; він просто стає no-op для цього ходу.

onFinalize виконується для кожного результату, включно з помилками диспетчеризації. Використовуйте його, щоб очистити очікувану групову історію, прибрати ack-реакції, зупинити індикатори стану та скинути локальний стан.

Адаптер доставки

Ядро не викликає платформу напряму. Канал передає ядру ChannelTurnDeliveryAdapter:

type ChannelTurnDeliveryAdapter = {
  deliver(payload: ReplyPayload, info: ChannelDeliveryInfo): Promise&lt;ChannelDeliveryResult | void&gt;;
  onError?(err: unknown, info: { kind: string }): void;
  durable?: false | DurableInboundReplyDeliveryOptions;
};

type ChannelDeliveryResult = {
  messageIds?: string[];
  receipt?: MessageReceipt;
  threadId?: string;
  replyToId?: string;
  visibleReplySent?: boolean;
};

deliver викликається один раз для кожного буферизованого фрагмента відповіді. Під час міграції життєвого циклу повідомлень доставка зібраного ходу каналу за замовчуванням належить каналу: відсутнє поле durable означає, що ядро має викликати deliver напряму й не має маршрутизувати через загальну вихідну доставку. Встановлюйте durable лише після аудиту каналу, який доведе, що загальний шлях надсилання зберігає стару поведінку доставки, включно з цілями відповіді/треду, обробкою медіа, кешами надісланих повідомлень/самоехо, очищенням стану та поверненими ідентифікаторами повідомлень. durable: false залишається сумісним написанням для "використовувати callback, що належить каналу", але немігрованим каналам не потрібно його додавати. Повертайте ідентифікатори повідомлень платформи, коли канал їх має, щоб диспетчер міг зберігати прив’язки тредів і редагувати пізніші фрагменти; новіші шляхи доставки також мають повертати receipt, щоб відновлення, фіналізація попереднього перегляду та придушення дублікатів могли відійти від messageIds. Для ходів лише зі спостереженням повертайте { visibleReplySent: false } або використовуйте createNoopChannelTurnDeliveryAdapter().

Канали, що використовують runPrepared із диспетчером, який повністю належить каналу, не мають ChannelTurnDeliveryAdapter. Такі диспетчери за замовчуванням не є durable. Вони мають зберігати свій прямий шлях доставки, доки явно не приєднаються до нового контексту надсилання з повною ціллю, безпечним для повторного відтворення адаптером, контрактом receipt і хуками побічних ефектів каналу.

Публічні помічники сумісності, як-от recordInboundSessionAndDispatchReply, dispatchInboundReplyWithBase і помічники direct-DM, мають зберігати поведінку під час міграції. Вони не повинні викликати загальну durable-доставку перед callback deliver або reply, що належать викликачу.

Параметри запису

Етап запису обгортає recordInboundSession. Більшість каналів можуть використовувати значення за замовчуванням. Перевизначайте через record:

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

Диспетчер очікує завершення етапу запису. Якщо запис кидає помилку, ядро запускає onPreDispatchFailure (коли його передано в runPrepared) і повторно кидає помилку.

Спостережуваність

Кожен етап випромінює структуровану подію, коли надано callback 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,
    });
  },
});

Етапи, що журналюються: ingest, classify, preflight, resolve, authorize, assemble, record, dispatch, finalize. Уникайте журналювання сирих тіл; використовуйте MessageFacts.preview для коротких редагованих попередніх переглядів.

Що залишається локальним для каналу

Ядро володіє оркестрацією. Канал усе ще володіє:

  • Транспорти платформи (Gateway, REST, websocket, polling, webhooks)
  • Розв’язання ідентичності та зіставлення відображуваних імен
  • Нативні команди, slash-команди, автодоповнення, модальні вікна, кнопки, стан голосу
  • Рендеринг карток, модальних вікон і adaptive-card
  • Автентифікація медіа, правила CDN, зашифровані медіа, транскрипція
  • API редагування, реакцій, редагування вмісту та присутності
  • Backfill і отримання історії на боці платформи
  • Потоки спарювання, що потребують перевірки, специфічної для платформи

Якщо двом каналам почне бути потрібен той самий помічник для одного з цих пунктів, винесіть спільний помічник SDK замість того, щоб проштовхувати його в ядро.

Стабільність

runtime.channel.turn.* є частиною публічної поверхні runtime Plugin. Типи фактів (SenderFacts, ConversationFacts, RouteFacts, ReplyPlanFacts, AccessFacts, MessageFacts, SupplementalContextFacts, InboundMediaFacts) і форми допуску (ChannelTurnAdmission, ChannelEventClass) доступні через PluginRuntime з openclaw/plugin-sdk/core.

Застосовуються правила зворотної сумісності: нові поля фактів є додатковими, види допуску не перейменовуються, а назви точок входу залишаються стабільними. Нові потреби каналу, що вимагають неадитивної зміни, мають проходити через процес міграції plugin SDK.

Пов’язане