mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(telegram): deliver rich-message authoring contract to every runtime via inbound formatting hints
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user