fix(telegram): deliver rich-message authoring contract to every runtime via inbound formatting hints

This commit is contained in:
Ayaan Zaidi
2026-07-15 16:34:57 +05:30
parent fa3b1b08fc
commit 8160bbdf2e
14 changed files with 301 additions and 191 deletions
+3 -3
View File
@@ -369,9 +369,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Rich message formatting">
Outbound text uses standard Telegram HTML messages by default, readable across current clients: bold, italic, links, code, spoilers, quotes — not Bot API 10.1 rich-only blocks (native tables, details, rich media, formulas).
Outbound text uses standard Telegram HTML messages by default, readable across current clients: bold, italic, links, code, spoilers, quotes — not Bot API 10.2 rich-only blocks (native tables, details, rich media, formulas).
Opt into Bot API 10.1 rich messages:
Opt into Bot API 10.2 rich messages:
```json5
{
@@ -383,7 +383,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
}
```
When enabled: the agent is told rich messages are available for this bot/account; Markdown text renders through OpenClaw's Markdown IR as Telegram rich HTML; explicit rich HTML payloads preserve supported Bot API 10.1 tags (headings, tables, details, rich media, formulas); media captions still use Telegram HTML captions (rich messages do not replace captions, and captions cap at 1024 characters).
When enabled: the agent is told rich messages are available for this bot/account (with the supported Markdown + HTML-island authoring contract); Markdown text renders through OpenClaw's Markdown IR as typed Bot API 10.2 rich blocks (headings, tables, details, checklists, rich media, formulas, maps, collages); media captions still use Telegram HTML captions (rich messages do not replace captions, and captions cap at 1024 characters).
This keeps model text away from Telegram's rich-Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text splits automatically across Telegram's limits. Tables over the 20-column limit fall back to a code block.
@@ -24,23 +24,64 @@ describe("telegram actions contract", () => {
});
it.each([
{ richMessages: undefined, expected: false },
{ richMessages: false, expected: false },
{ richMessages: true, expected: true },
])("advertises Telegram rich text only when enabled", ({ richMessages, expected }) => {
{
richMessages: undefined as boolean | undefined,
expectedMarkup: "markdown",
expectedOn: false,
},
{
richMessages: false as boolean | undefined,
expectedMarkup: "markdown",
expectedOn: false,
},
{
richMessages: true as boolean | undefined,
expectedMarkup: "markdown_telegram_rich",
expectedOn: true,
},
])(
"returns inbound formatting hints for richMessages=$richMessages",
({ richMessages, expectedMarkup, expectedOn }) => {
const hints = telegramPlugin.agentPrompt?.inboundFormattingHints?.({
cfg: {
channels: {
telegram: {
botToken: "test-token-placeholder",
richMessages,
},
},
} as OpenClawConfig,
});
expect(hints?.text_markup).toBe(expectedMarkup);
if (expectedOn) {
expect(hints?.rules.join(" ")).toContain("Telegram rich ON");
expect(hints?.rules.join(" ")).toContain("Bot API 10.2 blocks");
expect(hints?.rules.join(" ")).toContain("<details><summary>");
expect(hints?.rules.join(" ")).toContain("Not MarkdownV2/parse_mode");
expect(hints?.rules.join(" ")).toContain("Media https URLs only, block-level only");
} else {
expect(hints?.rules.join(" ")).toContain("Telegram rich OFF");
expect(hints?.rules.join(" ")).toContain("richMessages");
expect(hints?.rules.join(" ")).not.toContain("Telegram rich ON");
}
},
);
it("does not advertise a richText message-tool capability", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {
channels: {
telegram: {
botToken: "test-token-placeholder",
richMessages,
richMessages: true,
},
},
} as OpenClawConfig,
});
expect(capabilities).toContain("inlineButtons");
expect(capabilities?.includes("richText")).toBe(expected);
expect(capabilities).not.toContain("richText");
});
it("advertises inline buttons when legacy Telegram capabilities are empty", () => {
@@ -89,8 +130,8 @@ describe("telegram actions contract", () => {
expect(capabilities).not.toContain("inlineButtons");
});
it("uses the selected Telegram account's rich text setting", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
it("uses the selected Telegram account's richMessages for inbound formatting hints", () => {
const hints = telegramPlugin.agentPrompt?.inboundFormattingHints?.({
cfg: {
channels: {
telegram: {
@@ -107,12 +148,13 @@ describe("telegram actions contract", () => {
accountId: "ops",
});
expect(capabilities).not.toContain("richText");
expect(hints?.text_markup).toBe("markdown");
expect(hints?.rules.join(" ")).toContain("Telegram rich OFF");
});
it("does not resolve Telegram credentials while checking prompt capabilities", () => {
it("does not resolve Telegram credentials while checking inbound formatting hints", () => {
expect(() =>
telegramPlugin.agentPrompt?.messageToolCapabilities?.({
telegramPlugin.agentPrompt?.inboundFormattingHints?.({
cfg: {
channels: {
telegram: {
@@ -125,8 +167,8 @@ describe("telegram actions contract", () => {
).not.toThrow();
});
it("uses the configured default Telegram account for prompt capabilities", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
it("uses the configured default Telegram account for inbound formatting hints", () => {
const hints = telegramPlugin.agentPrompt?.inboundFormattingHints?.({
cfg: {
channels: {
telegram: {
@@ -146,7 +188,8 @@ describe("telegram actions contract", () => {
} as OpenClawConfig,
});
expect(capabilities).toContain("richText");
expect(hints?.text_markup).toBe("markdown_telegram_rich");
expect(hints?.rules.join(" ")).toContain("Telegram rich ON");
});
it("exposes Telegram thread create CLI remapping through the exported plugin", () => {
+26 -4
View File
@@ -815,12 +815,34 @@ export const telegramPlugin = createChatChannelPlugin({
cfg,
accountId: accountId ?? undefined,
});
const capabilities = inlineButtonsScope === "off" ? [] : ["inlineButtons"];
return inlineButtonsScope === "off" ? [] : ["inlineButtons"];
},
// Authoring contract lives here so every runtime (including native Codex)
// sees it via inbound-meta response_format; core system-prompt no longer owns it.
inboundFormattingHints: ({ cfg, accountId }) => {
const selectedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg);
if (mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true) {
capabilities.push("richText");
const richMessages =
mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true;
if (richMessages) {
return {
text_markup: "markdown_telegram_rich",
rules: [
"Telegram rich ON (Bot API 10.2 blocks; OpenClaw maps markdown + these HTML islands to typed blocks).",
'Supported: headings, tables (markdown, or `<table>` HTML for caption/colspan/rowspan/align), block/pull quotes (`<aside>` + `<cite>`), `<details><summary>` (+`open`), dividers `<hr/>`, sup/sub/mark/spoilers, `<ul>`/`<ol>` + `<input type="checkbox" checked/>` tasks, code, anchors `<a name="x"></a>` + `<a href="#x">label</a>`, custom emoji `<tg-emoji emoji-id="...">`, maps `<tg-map lat="" long="" zoom=""/>`, collages/slideshows `<tg-collage>`/`<tg-slideshow>`, block media e.g. `<img src="https://..."/>` (+`<figure>`/`<figcaption>`).',
"Math: `<tg-math>` inline, `<tg-math-block>` block; never `$...$`/`\\(...\\)`.",
"Not MarkdownV2/parse_mode.",
"Collapse=`<details>` (not expandable blockquote); structured bullets=`<ul><li>` (not literal bullets).",
"Media https URLs only, block-level only, captions/credits when useful; buttons plain text; normal files via attachments.",
],
};
}
return capabilities;
return {
text_markup: "markdown",
rules: [
"Telegram rich OFF. Standard Telegram formatting only; no rich tables/details/block media/formulas.",
"Owner can enable `richMessages` for this Telegram account.",
],
};
},
reactionGuidance: ({ cfg, accountId }) => {
const level = resolveTelegramReactionLevel({
+1 -1
View File
@@ -1,6 +1,6 @@
// HTML-fragment parsing and inline-island conversion for the Telegram rich
// blocks emitter. Agents author rich content as markdown plus a documented set
// of HTML islands (see the core system prompt's "Telegram rich ON" contract);
// of HTML islands (see agentPrompt.inboundFormattingHints "markdown_telegram_rich");
// this module owns the tolerant parser and inline (RichText-level) mapping,
// while rich-blocks-html-map.ts owns block-level island mapping.
import { tokenizeHtmlTags } from "openclaw/plugin-sdk/text-chunking";
@@ -162,21 +162,18 @@ describe("buildCliAgentSystemPrompt", () => {
expect(prompt).toContain("sessionId=session-123");
});
it("includes Telegram rich text guidance for CLI final replies", () => {
it("includes Telegram channel context for CLI final replies without core rich guidance", () => {
const prompt = buildCliAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
tools: [],
modelDisplay: "anthropic/claude-opus-4-8",
runtimeChannel: "telegram",
runtimeChatType: "direct",
runtimeCapabilities: ["richText"],
});
expect(prompt).toContain("Telegram rich ON");
expect(prompt).toContain("headings, tables");
expect(prompt).toContain("media https URLs only, block-level only");
expect(prompt).toContain("Not MarkdownV2/parse_mode");
expect(prompt).toContain("channel=telegram");
expect(prompt).not.toContain("Telegram rich ON");
expect(prompt).not.toContain("Telegram rich OFF");
expect(prompt).not.toContain("### message tool");
});
+5 -5
View File
@@ -2372,7 +2372,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("passes Telegram rich text capabilities into CLI system prompts", async () => {
it("passes Telegram channel context into CLI system prompts without core rich guidance", async () => {
const { dir, sessionFile } = createSessionFile();
setActivePluginRegistry(
createTestRegistry([
@@ -2382,7 +2382,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
plugin: {
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
agentPrompt: {
messageToolCapabilities: () => ["richText"],
messageToolCapabilities: () => ["inlineButtons"],
},
} satisfies ChannelPlugin,
},
@@ -2398,14 +2398,14 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-telegram-rich-text",
runId: "run-test-telegram-channel",
messageChannel: "telegram",
config: createCliBackendConfig(),
});
expect(context.systemPrompt).toContain("channel=telegram");
expect(context.systemPrompt).toContain("Telegram rich ON");
expect(context.systemPrompt).toContain("Not MarkdownV2/parse_mode");
expect(context.systemPrompt).not.toContain("Telegram rich ON");
expect(context.systemPrompt).not.toContain("Telegram rich OFF");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
+6 -40
View File
@@ -1085,7 +1085,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("style primary|success|danger");
});
it("describes Telegram rich text only for rich Telegram runtimes", () => {
it("does not embed Telegram rich-text authoring guidance in core messaging", () => {
const telegramPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["message"],
@@ -1094,14 +1094,6 @@ describe("buildAgentSystemPrompt", () => {
capabilities: ["richText"],
},
});
const discordPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["message"],
runtimeInfo: {
channel: "discord",
capabilities: ["richText"],
},
});
const plainTelegramPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["message"],
@@ -1110,49 +1102,23 @@ describe("buildAgentSystemPrompt", () => {
},
});
expect(telegramPrompt).toContain("Telegram rich ON");
expect(telegramPrompt).toContain("Bot API 10.2 blocks");
expect(telegramPrompt).toContain("<details><summary>");
expect(telegramPrompt).toContain("caption/colspan/rowspan/align");
expect(telegramPrompt).toContain("block/pull quotes");
expect(telegramPrompt).toContain('<input type="checkbox" checked/>');
expect(telegramPrompt).toContain('anchors `<a name="x"></a>` + `<a href="#x">label</a>`');
expect(telegramPrompt).toContain(
"Math: `<tg-math>` inline, `<tg-math-block>` block; never `$...$`/`\\(...\\)`",
);
expect(telegramPrompt).toContain("collages/slideshows");
expect(telegramPrompt).toContain('<tg-map lat="" long="" zoom=""/>');
expect(telegramPrompt).toContain("Collapse=`<details>` (not expandable blockquote)");
expect(telegramPrompt).toContain("structured bullets=`<ul><li>` (not literal bullets)");
expect(telegramPrompt).toContain('block media e.g. `<img src="https://..."/>`');
expect(telegramPrompt).toContain("captions/credits when useful");
expect(telegramPrompt).toContain("media https URLs only, block-level only");
expect(telegramPrompt).toContain("Not MarkdownV2/parse_mode");
expect(telegramPrompt).toContain("OpenClaw maps markdown + these HTML islands to typed blocks");
expect(telegramPrompt).toContain("buttons plain text");
expect(telegramPrompt.indexOf("Telegram rich ON")).toBeGreaterThan(
telegramPrompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY),
);
expect(discordPrompt).not.toContain("Telegram rich ON");
expect(telegramPrompt).not.toContain("Telegram rich ON");
expect(telegramPrompt).not.toContain("Telegram rich OFF");
expect(plainTelegramPrompt).not.toContain("Telegram rich ON");
expect(plainTelegramPrompt).toContain("Telegram rich OFF");
expect(plainTelegramPrompt).toContain("no rich tables");
expect(plainTelegramPrompt).toContain("enable rich messages for this account/channel");
expect(plainTelegramPrompt).not.toContain("Telegram rich OFF");
expect(telegramPrompt).toContain("final text normally routes to source");
});
it("describes Telegram rich text for source replies without the message tool", () => {
it("describes source replies without the message tool", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "telegram",
capabilities: ["richText"],
},
});
expect(prompt).toContain("final text normally routes to source");
expect(prompt).toContain("If turn says final private");
expect(prompt).toContain("Telegram rich ON");
expect(prompt).toContain("headings, tables");
expect(prompt).not.toContain("### message tool");
});
-9
View File
@@ -500,7 +500,6 @@ function buildMessagingSection(params: {
isMinimal: boolean;
availableTools: Set<string>;
inlineButtonsEnabled: boolean;
richTextEnabled: boolean;
runtimeChannel?: string;
runtimeChatType?: ChatType;
messageChannelOptions?: string;
@@ -516,8 +515,6 @@ function buildMessagingSection(params: {
const showGenericInlineButtonHint = params.runtimeChannel !== "slack";
const groupMessageToolOnly =
messageToolOnly && (params.runtimeChatType === "group" || params.runtimeChatType === "channel");
const telegramRuntime = params.runtimeChannel === "telegram";
const telegramRichTextEnabled = telegramRuntime && params.richTextEnabled;
const hasSessionsSpawn = params.availableTools.has("sessions_spawn");
const hasSubagents = params.availableTools.has("subagents");
const hasSessionsYield = params.availableTools.has("sessions_yield");
@@ -537,11 +534,6 @@ function buildMessagingSection(params: {
messageToolOnly
? "- Current source visible reply MUST use `message(action=send)`; final text is private. Skip tool = user gets nothing. Brief tool-call progress is visible; no hidden instructions/private data/reasoning."
: "- Current-session final text normally routes to source. If turn says final private, visible output uses `message(action=send)`.",
telegramRuntime
? telegramRichTextEnabled
? '- Telegram rich ON (Bot API 10.2 blocks; OpenClaw maps markdown + these HTML islands to typed blocks): headings, tables (markdown, or `<table>` HTML for caption/colspan/rowspan/align), block/pull quotes (`<aside>` + `<cite>`), `<details><summary>` (+`open`), dividers `<hr/>`, sup/sub/mark/spoilers, `<ul>`/`<ol>` + `<input type="checkbox" checked/>` tasks, code, anchors `<a name="x"></a>` + `<a href="#x">label</a>`, custom emoji `<tg-emoji emoji-id="...">`, maps `<tg-map lat="" long="" zoom=""/>`, collages/slideshows `<tg-collage>`/`<tg-slideshow>`, block media e.g. `<img src="https://..."/>` (+`<figure>`/`<figcaption>`). Math: `<tg-math>` inline, `<tg-math-block>` block; never `$...$`/`\\(...\\)`. Not MarkdownV2/parse_mode. Collapse=`<details>` (not expandable blockquote); structured bullets=`<ul><li>` (not literal bullets); media https URLs only, block-level only, captions/credits when useful; buttons plain text; normal files via attachments.'
: "- Telegram rich OFF. Standard Telegram HTML only; no rich tables/details/media/formulas. Ask owner to enable rich messages for this account/channel."
: "",
"- Cross-session: `sessions_send(sessionKey, message)`.",
subagentOrchestrationGuidance,
completionEventGuidance,
@@ -1316,7 +1308,6 @@ export function buildAgentSystemPrompt(params: {
isMinimal,
availableTools,
inlineButtonsEnabled,
richTextEnabled: runtimeCapabilitiesLower.has("richtext"),
runtimeChannel,
runtimeChatType,
messageChannelOptions,
+4 -1
View File
@@ -656,7 +656,10 @@ export async function runPreparedReply(
const groupSystemPrompt = normalizeOptionalString(promptSessionCtx.GroupSystemPrompt) ?? "";
const inboundMetaPrompt = buildInboundMetaSystemPrompt(
isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined },
{ includeFormattingHints: !useFastReplyRuntime },
cfg,
// promptSessionCtx restores the persisted channel/account for system-event
// turns, so reply formatting hints resolve against the delivery channel.
{ includeFormattingHints: !useFastReplyRuntime, formattingHintsCtx: promptSessionCtx },
);
const execOverridePromptHint = buildExecOverridePromptHint({
execOverrides,
+168 -94
View File
@@ -1,6 +1,7 @@
// Tests inbound metadata normalization before prompt injection.
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry, SessionGoalStatus } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { withEnv } from "../../test-utils/env.js";
@@ -11,21 +12,33 @@ import {
refreshActiveGoalContext,
} from "./inbound-meta.js";
const EMPTY_CFG = {} as OpenClawConfig;
const { formattingHintCalls } = vi.hoisted(() => ({
formattingHintCalls: [] as Array<{ cfg: OpenClawConfig; accountId?: string | null }>,
}));
vi.mock("../../channels/plugins/registry-loaded.js", () => ({
getLoadedChannelPluginById: (channelId: string) =>
channelId === "slack"
? {
agentPrompt: {
inboundFormattingHints: () => ({
text_markup: "slack_mrkdwn",
rules: [
"Use Slack mrkdwn, not standard Markdown.",
"Bold uses *single asterisks*.",
"Links use <url|label>.",
"Code blocks use triple backticks without a language identifier.",
"Do not use markdown headings or pipe tables.",
],
}),
inboundFormattingHints: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => {
formattingHintCalls.push(params);
return {
text_markup: "slack_mrkdwn",
rules: [
"Use Slack mrkdwn, not standard Markdown.",
"Bold uses *single asterisks*.",
"Links use <url|label>.",
"Code blocks use triple backticks without a language identifier.",
"Do not use markdown headings or pipe tables.",
],
};
},
},
}
: undefined,
@@ -112,17 +125,20 @@ function createGoalSessionEntry(
describe("buildInboundMetaSystemPrompt", () => {
it("includes stable routing fields and omits chat ids", () => {
const prompt = buildInboundMetaSystemPrompt({
MessageSid: "123",
MessageSidFull: "123",
ReplyToId: "99",
OriginatingTo: "telegram:5494292670",
AccountId: " work ",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
} as TemplateContext);
const prompt = buildInboundMetaSystemPrompt(
{
MessageSid: "123",
MessageSidFull: "123",
ReplyToId: "99",
OriginatingTo: "telegram:5494292670",
AccountId: " work ",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
} as TemplateContext,
EMPTY_CFG,
);
const payload = parseInboundMetaPayload(prompt);
expect(payload["schema"]).toBe("openclaw.inbound_meta.v2");
@@ -132,39 +148,48 @@ describe("buildInboundMetaSystemPrompt", () => {
});
it("keeps task-scoped chat ids out of the system prompt for cache stability", () => {
const first = buildInboundMetaSystemPrompt({
OriginatingTo: "paperclip:issue:c585d0cc",
OriginatingChannel: "paperclip",
Provider: "paperclip",
Surface: "paperclip",
ChatType: "direct",
AccountId: "default",
} as TemplateContext);
const second = buildInboundMetaSystemPrompt({
OriginatingTo: "paperclip:issue:ca527062",
OriginatingChannel: "paperclip",
Provider: "paperclip",
Surface: "paperclip",
ChatType: "direct",
AccountId: "default",
} as TemplateContext);
const first = buildInboundMetaSystemPrompt(
{
OriginatingTo: "paperclip:issue:c585d0cc",
OriginatingChannel: "paperclip",
Provider: "paperclip",
Surface: "paperclip",
ChatType: "direct",
AccountId: "default",
} as TemplateContext,
EMPTY_CFG,
);
const second = buildInboundMetaSystemPrompt(
{
OriginatingTo: "paperclip:issue:ca527062",
OriginatingChannel: "paperclip",
Provider: "paperclip",
Surface: "paperclip",
ChatType: "direct",
AccountId: "default",
} as TemplateContext,
EMPTY_CFG,
);
expect(parseInboundMetaPayload(first)["chat_id"]).toBeUndefined();
expect(first).toBe(second);
});
it("does not include per-turn message identifiers (cache stability)", () => {
const prompt = buildInboundMetaSystemPrompt({
MessageSid: "123",
MessageSidFull: "123",
ReplyToId: "99",
SenderId: "289522496",
OriginatingTo: "telegram:5494292670",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
} as TemplateContext);
const prompt = buildInboundMetaSystemPrompt(
{
MessageSid: "123",
MessageSidFull: "123",
ReplyToId: "99",
SenderId: "289522496",
OriginatingTo: "telegram:5494292670",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
} as TemplateContext,
EMPTY_CFG,
);
const payload = parseInboundMetaPayload(prompt);
expect(payload["message_id"]).toBeUndefined();
@@ -174,33 +199,39 @@ describe("buildInboundMetaSystemPrompt", () => {
});
it("does not include per-turn flags in system metadata", () => {
const prompt = buildInboundMetaSystemPrompt({
ReplyToBody: "quoted",
ForwardedFrom: "sender",
ThreadStarterBody: "starter",
InboundHistory: [{ sender: "a", body: "b", timestamp: 1 }],
WasMentioned: true,
OriginatingTo: "telegram:-1001249586642",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
} as TemplateContext);
const prompt = buildInboundMetaSystemPrompt(
{
ReplyToBody: "quoted",
ForwardedFrom: "sender",
ThreadStarterBody: "starter",
InboundHistory: [{ sender: "a", body: "b", timestamp: 1 }],
WasMentioned: true,
OriginatingTo: "telegram:-1001249586642",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
} as TemplateContext,
EMPTY_CFG,
);
const payload = parseInboundMetaPayload(prompt);
expect(payload["flags"]).toBeUndefined();
});
it("keeps explicit bot mentions out of the system metadata", () => {
const prompt = buildInboundMetaSystemPrompt({
OriginatingTo: "telegram:-1001249586642",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: true,
} as TemplateContext);
const prompt = buildInboundMetaSystemPrompt(
{
OriginatingTo: "telegram:-1001249586642",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: true,
} as TemplateContext,
EMPTY_CFG,
);
const payload = parseInboundMetaPayload(prompt);
expect(payload["flags"]).toBeUndefined();
@@ -209,21 +240,25 @@ describe("buildInboundMetaSystemPrompt", () => {
});
it("omits sender_id when blank", () => {
const prompt = buildInboundMetaSystemPrompt({
MessageSid: "458",
SenderId: " ",
OriginatingTo: "telegram:-1001249586642",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
} as TemplateContext);
const prompt = buildInboundMetaSystemPrompt(
{
MessageSid: "458",
SenderId: " ",
OriginatingTo: "telegram:-1001249586642",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
} as TemplateContext,
EMPTY_CFG,
);
const payload = parseInboundMetaPayload(prompt);
expect(payload["sender_id"]).toBeUndefined();
});
it("includes Slack mrkdwn response format hints for Slack chats", () => {
it("includes Slack mrkdwn response format hints for Slack chats and threads cfg", () => {
formattingHintCalls.length = 0;
resetPluginRuntimeStateForTest();
setActivePluginRegistry(
createTestRegistry([
@@ -258,13 +293,20 @@ describe("buildInboundMetaSystemPrompt", () => {
]),
);
const prompt = buildInboundMetaSystemPrompt({
OriginatingTo: "channel:C123",
OriginatingChannel: "slack",
Provider: "slack",
Surface: "slack",
ChatType: "channel",
} as TemplateContext);
const cfg = {
channels: { slack: { botToken: "test-token-placeholder" } },
} as OpenClawConfig;
const prompt = buildInboundMetaSystemPrompt(
{
OriginatingTo: "channel:C123",
OriginatingChannel: "slack",
Provider: "slack",
Surface: "slack",
ChatType: "channel",
AccountId: " work ",
} as TemplateContext,
cfg,
);
const payload = parseInboundMetaPayload(prompt);
expect(payload["response_format"]).toEqual({
@@ -277,16 +319,48 @@ describe("buildInboundMetaSystemPrompt", () => {
"Do not use markdown headings or pipe tables.",
],
});
expect(formattingHintCalls).toEqual([{ cfg, accountId: "work" }]);
});
it("omits response format hints for non-Slack chats", () => {
const prompt = buildInboundMetaSystemPrompt({
OriginatingTo: "telegram:123",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
} as TemplateContext);
it("resolves response format hints from formattingHintsCtx for system-event turns", () => {
const prompt = buildInboundMetaSystemPrompt(
{
OriginatingChannel: "heartbeat",
Provider: "heartbeat",
Surface: "heartbeat",
ChatType: "direct",
} as TemplateContext,
EMPTY_CFG,
{
formattingHintsCtx: {
OriginatingChannel: "slack",
Provider: "slack",
Surface: "slack",
ChatType: "channel",
AccountId: "work",
} as TemplateContext,
},
);
const payload = parseInboundMetaPayload(prompt);
const responseFormat = payload["response_format"] as { text_markup?: string } | undefined;
expect(responseFormat?.text_markup).toBe("slack_mrkdwn");
// Trusted metadata still identifies the system event; only authoring hints
// follow the delivery channel.
expect(payload["channel"]).toBe("heartbeat");
});
it("omits response format hints when the channel plugin has no formatting hook", () => {
const prompt = buildInboundMetaSystemPrompt(
{
OriginatingTo: "telegram:123",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
} as TemplateContext,
EMPTY_CFG,
);
const payload = parseInboundMetaPayload(prompt);
expect(payload["response_format"]).toBeUndefined();
+14 -3
View File
@@ -9,6 +9,7 @@ import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import { normalizeAnyChannelId } from "../../channels/registry.js";
import { resolveSessionGoalDisplayState } from "../../config/sessions/goals.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { sliceUtf16Safe, truncateUtf16Safe } from "../../utils.js";
import type { EnvelopeFormatOptions } from "../envelope.js";
import { formatEnvelopeTimestamp } from "../envelope.js";
@@ -543,7 +544,10 @@ function resolveInboundSourceModality(ctx: TemplateContext): string | undefined
return resolveMediaType(ctx.MediaType) ?? ctx.MediaTypes?.map(resolveMediaType).find(Boolean);
}
function resolveInboundFormattingHints(ctx: TemplateContext):
function resolveInboundFormattingHints(
ctx: TemplateContext,
cfg: OpenClawConfig,
):
| {
text_markup: string;
rules: string[];
@@ -557,6 +561,7 @@ function resolveInboundFormattingHints(ctx: TemplateContext):
const agentPrompt = (getLoadedChannelPluginById(normalizedChannel) as ChannelPlugin | undefined)
?.agentPrompt;
return agentPrompt?.inboundFormattingHints?.({
cfg,
accountId: normalizePromptMetadataString(ctx.AccountId) ?? undefined,
});
}
@@ -564,7 +569,8 @@ function resolveInboundFormattingHints(ctx: TemplateContext):
/** Builds trusted system metadata for the inbound channel and formatting hints. */
export function buildInboundMetaSystemPrompt(
ctx: TemplateContext,
options?: { includeFormattingHints?: boolean },
cfg: OpenClawConfig,
options?: { includeFormattingHints?: boolean; formattingHintsCtx?: TemplateContext },
): string {
const chatType = normalizeChatType(ctx.ChatType);
const isDirect = !chatType || chatType === "direct";
@@ -587,8 +593,13 @@ export function buildInboundMetaSystemPrompt(
provider: normalizePromptMetadataString(ctx.Provider),
surface: normalizePromptMetadataString(ctx.Surface),
chat_type: chatType ?? (isDirect ? "direct" : undefined),
// Authoring hints follow the reply delivery channel, not the inbound event:
// system-event turns (heartbeat/cron) carry the persisted channel/account in
// formattingHintsCtx while ctx still identifies the system provider.
response_format:
options?.includeFormattingHints === false ? undefined : resolveInboundFormattingHints(ctx),
options?.includeFormattingHints === false
? undefined
: resolveInboundFormattingHints(options?.formattingHintsCtx ?? ctx, cfg),
};
// Keep the instructions local to the payload so the meaning survives prompt overrides.
+1 -1
View File
@@ -678,7 +678,7 @@ export type ChannelAgentPromptAdapter = {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[] | undefined;
inboundFormattingHints?: (params: { accountId?: string | null }) =>
inboundFormattingHints?: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
| {
text_markup: string;
rules: string[];
@@ -342,7 +342,7 @@ function createExtraSystemPrompt(params: {
intro?: string;
}): string {
return [
buildInboundMetaSystemPrompt(params.ctx),
buildInboundMetaSystemPrompt(params.ctx, {}),
params.chatContext,
params.intro,
params.ctx.GroupSystemPrompt,
@@ -147,7 +147,7 @@ function buildAutoReplySystemPrompt(params: {
groupSystemPrompt?: string;
}) {
const extraSystemPromptParts = [
buildInboundMetaSystemPrompt(params.sessionCtx),
buildInboundMetaSystemPrompt(params.sessionCtx, {}),
params.sessionCtx.ChatType === "direct" || params.sessionCtx.ChatType === "dm"
? buildDirectChatContext({
sessionCtx: params.sessionCtx,
@@ -699,14 +699,17 @@ async function createMaintenanceScenario(workspaceDir: string): Promise<PromptSc
].join("\n");
const postCompactionSystemPrompt = buildSystemPrompt({
workspaceDir,
extraSystemPrompt: buildInboundMetaSystemPrompt({
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "D123",
AccountId: "A1",
ChatType: "direct",
}),
extraSystemPrompt: buildInboundMetaSystemPrompt(
{
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
OriginatingTo: "D123",
AccountId: "A1",
ChatType: "direct",
},
{},
),
});
return {
scenario: "maintenance-prompts",