mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
e31a6e29ff
The four Telegram dispatch controllers were partitions of one closure: ~75 factory parameter slots, 7 post-construction back-edge setters, a shared mutable state bag, and load-bearing construction order. One turn record now carries the once-resolved config and all state; the four files remain as implementation with a hand-written leaf type contract (four state-slice types). Rides along: dead generation fence deleted (constant-0 from birth), queuedFinal ||= fix with regression (suppressed exec-approval turns no longer trigger a spurious fallback), collapse resolver/mutator split. Dispatch tests and harness byte-identical to main; live E2E lifecycle proof on the PR. Co-authored-by: Ayaan Zaidi <hi@obviy.us>
169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
// Telegram plugin module implements reasoning lane coordinator behavior.
|
|
import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { findCodeRegions, isInsideCode } from "openclaw/plugin-sdk/text-chunking";
|
|
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
|
|
import type {
|
|
TelegramBufferedFinalAnswer,
|
|
TelegramReasoningStepState,
|
|
} from "./bot-message-dispatch.types.js";
|
|
|
|
// A durable reasoning message already marked channel-side: 🧠 + italic body
|
|
// (see markReasoningMessage). Detect it so a re-split passes it through
|
|
// unchanged instead of re-marking.
|
|
const REASONING_MESSAGE_RE = /^🧠\s+_/u;
|
|
// Core's formatReasoningMessage prefixes the italic body with a literal
|
|
// "Thinking" header. Telegram renders durable thoughts with the 🧠 marker
|
|
// (Discord parity), so this header must be rewritten channel-side.
|
|
const CORE_THINKING_HEADER_RE = /^Thinking\.{0,3}\s*\n+/u;
|
|
const LEGACY_REASONING_MESSAGE_PREFIX = "Reasoning:\n";
|
|
|
|
// Rewrite core's "Thinking\n\n_body_" into "🧠 _body_": strip the header word
|
|
// and prefix the first italic line with 🧠. Keeps the italic body intact so
|
|
// Telegram HTML renders it as before.
|
|
function markReasoningMessage(formatted: string): string {
|
|
const withoutHeader = formatted.replace(CORE_THINKING_HEADER_RE, "");
|
|
return withoutHeader.replace(/^_/u, "🧠 _");
|
|
}
|
|
const REASONING_TAG_PREFIXES = [
|
|
"<think",
|
|
"<thinking",
|
|
"<thought",
|
|
"<antthinking",
|
|
"<mm:think",
|
|
"</think",
|
|
"</thinking",
|
|
"</thought",
|
|
"</antthinking",
|
|
"</mm:think",
|
|
];
|
|
const THINKING_TAG_RE =
|
|
/<\s*(\/?)\s*(?:(?:antml:|mm:)?(?:think(?:ing)?|thought)|antthinking)\b[^<>]*>/gi;
|
|
|
|
function extractThinkingFromTaggedStreamOutsideCode(text: string): string {
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
const codeRegions = findCodeRegions(text);
|
|
let result = "";
|
|
let lastIndex = 0;
|
|
let inThinking = false;
|
|
THINKING_TAG_RE.lastIndex = 0;
|
|
for (const match of text.matchAll(THINKING_TAG_RE)) {
|
|
const idx = match.index ?? 0;
|
|
if (isInsideCode(idx, codeRegions)) {
|
|
continue;
|
|
}
|
|
if (inThinking) {
|
|
result += text.slice(lastIndex, idx);
|
|
}
|
|
const isClose = match[1] === "/";
|
|
inThinking = !isClose;
|
|
lastIndex = idx + match[0].length;
|
|
}
|
|
if (inThinking) {
|
|
result += text.slice(lastIndex);
|
|
}
|
|
return result.trim();
|
|
}
|
|
|
|
function isPartialReasoningTagPrefix(text: string): boolean {
|
|
const trimmed = normalizeLowercaseStringOrEmpty(text.trimStart());
|
|
if (!trimmed.startsWith("<")) {
|
|
return false;
|
|
}
|
|
if (trimmed.includes(">")) {
|
|
return false;
|
|
}
|
|
return REASONING_TAG_PREFIXES.some((prefix) => prefix.startsWith(trimmed));
|
|
}
|
|
|
|
type TelegramReasoningSplit = {
|
|
reasoningText?: string;
|
|
answerText?: string;
|
|
};
|
|
|
|
export function splitTelegramReasoningText(
|
|
text?: string,
|
|
isReasoning?: boolean,
|
|
): TelegramReasoningSplit {
|
|
if (typeof text !== "string") {
|
|
return {};
|
|
}
|
|
|
|
if (isReasoning !== true) {
|
|
return { answerText: text };
|
|
}
|
|
|
|
const trimmed = text.trim();
|
|
if (isPartialReasoningTagPrefix(trimmed)) {
|
|
return {};
|
|
}
|
|
if (REASONING_MESSAGE_RE.test(trimmed)) {
|
|
return { reasoningText: trimmed };
|
|
}
|
|
// Durable reasoning payloads arrive pre-formatted by core with the "Thinking"
|
|
// header; rewrite that to the 🧠 marker rather than passing it through.
|
|
if (CORE_THINKING_HEADER_RE.test(trimmed)) {
|
|
return { reasoningText: markReasoningMessage(trimmed) };
|
|
}
|
|
if (
|
|
trimmed.startsWith(LEGACY_REASONING_MESSAGE_PREFIX) &&
|
|
trimmed.length > LEGACY_REASONING_MESSAGE_PREFIX.length
|
|
) {
|
|
return { reasoningText: trimmed };
|
|
}
|
|
|
|
const taggedReasoning = extractThinkingFromTaggedStreamOutsideCode(text);
|
|
const strippedAnswer = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
|
|
|
|
return {
|
|
reasoningText: markReasoningMessage(
|
|
formatReasoningMessage(taggedReasoning || strippedAnswer || text),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function createTelegramReasoningStepState(): TelegramReasoningStepState {
|
|
let reasoningStatus: "none" | "hinted" | "delivered" = "none";
|
|
let bufferedFinalAnswer: TelegramBufferedFinalAnswer | undefined;
|
|
|
|
const noteReasoningHint = () => {
|
|
if (reasoningStatus === "none") {
|
|
reasoningStatus = "hinted";
|
|
}
|
|
};
|
|
|
|
const noteReasoningDelivered = () => {
|
|
reasoningStatus = "delivered";
|
|
};
|
|
|
|
const shouldBufferFinalAnswer = () => {
|
|
return reasoningStatus === "hinted" && !bufferedFinalAnswer;
|
|
};
|
|
|
|
const bufferFinalAnswer = (value: TelegramBufferedFinalAnswer) => {
|
|
bufferedFinalAnswer = value;
|
|
};
|
|
|
|
const takeBufferedFinalAnswer = (): TelegramBufferedFinalAnswer | undefined => {
|
|
const value = bufferedFinalAnswer;
|
|
bufferedFinalAnswer = undefined;
|
|
return value;
|
|
};
|
|
|
|
const resetForNextStep = () => {
|
|
reasoningStatus = "none";
|
|
bufferedFinalAnswer = undefined;
|
|
};
|
|
|
|
return {
|
|
noteReasoningHint,
|
|
noteReasoningDelivered,
|
|
shouldBufferFinalAnswer,
|
|
bufferFinalAnswer,
|
|
takeBufferedFinalAnswer,
|
|
resetForNextStep,
|
|
};
|
|
}
|