refactor(channels): route inbound turns through kernel

This commit is contained in:
Peter Steinberger
2026-04-30 04:08:44 +01:00
parent 6e73101df3
commit ffe67e9cdc
31 changed files with 1827 additions and 1389 deletions

View File

@@ -69,9 +69,24 @@ function makeRuntime(): GatewayPluginRuntime {
recordInboundSession: vi.fn(async () => undefined),
},
turn: {
runPrepared: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await params.runDispatch() };
run: vi.fn(async (rawParams: unknown) => {
const params = rawParams as {
raw: unknown;
adapter: {
ingest: (raw: unknown) => unknown;
resolveTurn: (...args: unknown[]) => unknown;
};
};
const input = await params.adapter.ingest(params.raw);
const turn = (await params.adapter.resolveTurn(
input,
{
kind: "message",
canStartAgentTurn: true,
},
{},
)) as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await turn.runDispatch() };
}),
},
text: {

View File

@@ -141,9 +141,24 @@ function makeRuntime(params: {
recordInboundSession: vi.fn(async () => undefined),
},
turn: {
runPrepared: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await params.runDispatch() };
run: vi.fn(async (rawParams: unknown) => {
const params = rawParams as {
raw: unknown;
adapter: {
ingest: (raw: unknown) => unknown;
resolveTurn: (...args: unknown[]) => unknown;
};
};
const input = await params.adapter.ingest(params.raw);
const turn = (await params.adapter.resolveTurn(
input,
{
kind: "message",
canStartAgentTurn: true,
},
{},
)) as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await turn.runDispatch() };
}),
},
text: {

View File

@@ -10,6 +10,7 @@
* Separated from gateway.ts for testability and to keep handleMessage thin.
*/
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import {
parseAndSendMediaTags,
sendPlainReply,
@@ -224,238 +225,256 @@ export async function dispatchOutbound(
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
agentId,
});
const dispatchPromise = runtime.channel.turn.runPrepared({
const dispatchPromise = runtime.channel.turn.run({
channel: "qqbot",
accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
record: {
onRecordError: (err: unknown) => {
log?.error(
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
);
},
},
runDispatch: () =>
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix,
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
hasResponse = true;
raw: inbound,
adapter: {
ingest: () => ({
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: inbound,
}),
resolveTurn: () => ({
channel: "qqbot",
accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
record: {
onRecordError: (err: unknown) => {
log?.error(
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
);
},
},
runDispatch: () =>
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix,
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
hasResponse = true;
// ---- Tool deliver ----
if (info.kind === "tool") {
toolDeliverCount++;
const toolText = (payload.text ?? "").trim();
if (toolText) {
toolTexts.push(toolText);
}
if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls);
}
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl);
}
// ---- Tool deliver ----
if (info.kind === "tool") {
toolDeliverCount++;
const toolText = (payload.text ?? "").trim();
if (toolText) {
toolTexts.push(toolText);
}
if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls);
}
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl);
}
if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
try {
await sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
} catch {}
}
return;
}
if (toolFallbackSent) {
return;
}
if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
} else {
if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
try {
await sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
} catch {}
}
return;
}
if (toolFallbackSent) {
return;
}
if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
} else {
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
}
}, TOOL_ONLY_TIMEOUT);
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
// ---- Block deliver ----
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
}, TOOL_ONLY_TIMEOUT);
return;
}
// ---- Block deliver ----
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onDeliver(payload);
} catch (err) {
log?.error(
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const replyPreview = (payload.text ?? "").trim();
if (
event.type === "group" &&
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
) {
log?.info(
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
);
return;
}
if (streamingController.shouldFallbackToStatic) {
log?.info("Streaming API unavailable, falling back to static for this deliver");
} else {
recordOutbound();
return;
}
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
if (quoteRef && !quoteRefUsed) {
quoteRefUsed = true;
return quoteRef;
}
return undefined;
};
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
};
const deliverActx = { account, qualifiedTarget, log };
// 1. Media tags
const mediaResult = await parseAndSendMediaTags(
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
deliverDeps,
);
if (mediaResult.handled) {
recordOutbound();
return;
}
replyText = mediaResult.normalizedText;
// 2. Structured payload (QQBOT_PAYLOAD:)
const handled = await handleStructuredPayload(
replyCtx,
replyText,
recordOutbound,
replyDeps,
);
if (handled) {
return;
}
// 3. Voice-intent plain text
if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) {
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
if (sentVoice) {
recordOutbound();
return;
}
}
// 4. Plain text + images/media
await sendPlainReply(
payload,
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
toolMediaUrls,
deliverDeps,
);
recordOutbound();
},
onError: async (err: unknown) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
const streamErrMsg =
streamErr instanceof Error ? streamErr.message : String(streamErr);
log?.error(`Streaming onError failed: ${streamErrMsg}`);
}
if (!streamingController.shouldFallbackToStatic) {
return;
}
}
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
},
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
? true
: (() => {
const s = account.config?.streaming;
if (s === false) {
return true;
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
return typeof s === "object" && s !== null && s.mode === "off";
})(),
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onPartialReply(payload);
} catch (partialErr) {
await streamingController.onDeliver(payload);
} catch (err) {
log?.error(
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
);
}
},
}
: {}),
},
const replyPreview = (payload.text ?? "").trim();
if (
event.type === "group" &&
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
) {
log?.info(
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
);
return;
}
if (streamingController.shouldFallbackToStatic) {
log?.info("Streaming API unavailable, falling back to static for this deliver");
} else {
recordOutbound();
return;
}
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
if (quoteRef && !quoteRefUsed) {
quoteRefUsed = true;
return quoteRef;
}
return undefined;
};
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
};
const deliverActx = { account, qualifiedTarget, log };
// 1. Media tags
const mediaResult = await parseAndSendMediaTags(
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
deliverDeps,
);
if (mediaResult.handled) {
recordOutbound();
return;
}
replyText = mediaResult.normalizedText;
// 2. Structured payload (QQBOT_PAYLOAD:)
const handled = await handleStructuredPayload(
replyCtx,
replyText,
recordOutbound,
replyDeps,
);
if (handled) {
return;
}
// 3. Voice-intent plain text
if (
payload.audioAsVoice === true &&
!payload.mediaUrl &&
!payload.mediaUrls?.length
) {
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
if (sentVoice) {
recordOutbound();
return;
}
}
// 4. Plain text + images/media
await sendPlainReply(
payload,
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
toolMediaUrls,
deliverDeps,
);
recordOutbound();
},
onError: async (err: unknown) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
const streamErrMsg =
streamErr instanceof Error ? streamErr.message : String(streamErr);
log?.error(`Streaming onError failed: ${streamErrMsg}`);
}
if (!streamingController.shouldFallbackToStatic) {
return;
}
}
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
},
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
? true
: (() => {
const s = account.config?.streaming;
if (s === false) {
return true;
}
return typeof s === "object" && s !== null && s.mode === "off";
})(),
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
try {
await streamingController.onPartialReply(payload);
} catch (partialErr) {
log?.error(
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
);
}
},
}
: {}),
},
}),
}),
},
});
try {
@@ -493,7 +512,10 @@ export async function dispatchOutbound(
// ============ ctxPayload builder ============
function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime): unknown {
function buildCtxPayload(
inbound: InboundContext,
runtime: GatewayPluginRuntime,
): FinalizedMsgContext {
const { event } = inbound;
return runtime.channel.reply.finalizeInboundContext({
Body: inbound.body,
@@ -549,5 +571,5 @@ function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime)
ReplyToIsQuote: inbound.replyTo.isQuote,
}
: {}),
});
}) as FinalizedMsgContext;
}

View File

@@ -57,7 +57,7 @@ export interface GatewayPluginRuntime {
recordInboundSession: (params: unknown) => Promise<unknown>;
};
turn: {
runPrepared: (params: unknown) => Promise<unknown>;
run: (params: unknown) => Promise<unknown>;
};
text: {
chunkMarkdownText: (text: string, limit: number) => string[];