fix(agents): delivered turns get the channel formatting rules once (#158028)

Related: #157877
Related: #108264

## What Problem This Solves

Fixes: a turn delivered to Telegram gets the channel's formatting rules in a different shape, twice, or not at all, depending on how it started.

- Reply turns carry the rules inside the inbound metadata. On an account with `richMessages: true`, they also get a separate "Collapsible Details" section, so the `<details>` rule arrives twice.
- Cron announce turns (#157877) carry the same rules in a second block with a different schema.
- Subagent and requester announce turns, and other delivered agent turns (inter-session steps, `openclaw agent --deliver`), get no formatting rules.

## User Impact

User impact: every turn whose visible output is delivered to a channel gets that channel's formatting rules once, in one block. This covers replies, heartbeats, cron announces and subagent announces. For Telegram, the rich-message rules apply only when the delivering account has `richMessages: true`. Other accounts get the standard-formatting rules. Turns with no chat delivery get no rules: no delivery, webhook delivery, subagent children, and Control UI chat. Slack announces now get Slack's rules too. No config change is required.

## Why This Change Was Made

- One core function builds the block: `buildDeliveryFormatPrompt`. Reply turns read the loaded plugin that received the message, as before. Cron and agent-command turns resolve the delivering plugin with on-demand loading, for example for the first cron announce after startup. It asks the plugin for the delivering account's rules and renders one trusted `### Delivery Format` block (`openclaw.delivery_format.v1`) into the extra system prompt. This is the path every runtime (embedded, Codex, CLI) and its compaction already reads.
- Three entry points call this function, each only when its turn has a channel delivery target:
  - Reply metadata, which also covers heartbeats and system events.
  - Cron delivery resolution.
  - Agent-command session preparation, for turns that deliver or use message-tool delivery. It uses the outbound channel and account chosen by delivery preflight, so a turn that starts on one account and delivers through another gets the delivering account's rules.
- Removed:
  - The cron-only builder and its `openclaw.delivery_meta.v1` schema.
  - The `response_format` field in the inbound metadata.
  - Telegram's `markdownDetails` capability. The Telegram contract already contains the `<details>` rule.
- The block has no per-turn fields, so its bytes are the same for every turn kind on the same channel and contract. It stays in trusted system metadata and never enters the transcript. The CLI session-binding inputs are unchanged: replies still keep this block out of the static binding text, and cron still hashes it.
- External ACP agents do not receive OpenClaw's extra system prompt, so they do not get this block. The docs say "OpenClaw agent turn" for that reason.
- Upgrade: the old cron `Delivery Context` block (#157877) is not in any release. Reply turns keep this block out of the CLI session-binding text, as before. A CLI-backed cron session on `main` whose bound prompt text changes resumes with `system-prompt` content drift, not a reset (covered by `src/agents/cli-session.test.ts`, "resumes on content drift").
- Plugin SDK: `inboundFormattingHints` is a shipped field, so this change keeps its name and signature. It is an intentional scope extension: core now calls it for every OpenClaw agent turn whose delivery target is the channel, not only inbound replies. The hook only receives `cfg` and `accountId`, with no inbound message data, so an existing hook returns the same rules it returns today. The SDK docs and type comment describe the new scope.
- Control UI keeps its core-owned `markdownDetails` capability, because it has no channel plugin.
- Follow-ups, not in this PR:
  - A `message` tool send to a different channel than the turn's own still gets only the current channel's rules.
  - `/btw` side answers do not get the block yet.

## Evidence

- Focused tests at the real entry points, with a Telegram stub whose rich and plain accounts return different contracts:
  - `src/auto-reply/reply/get-reply.delivery-format.test.ts`: `getReplyFromConfig` replies on a rich account and on a plain account each get exactly one block with their own contract. A Telegram heartbeat through `runHeartbeatOnce` gets exactly one block. A Control UI reply gets none.
  - `src/commands/agent.delivery-format.test.ts`: `agentCommandFromIngress` with `deliver: true` (rich account) and with message-tool delivery (plain account) each append exactly one block after the caller's prompt. A turn from the rich account that delivers through the plain account gets the plain rules. An undelivered turn keeps the caller's prompt unchanged.
  - `src/cron/isolated-agent/run.delivery-formatting.test.ts`: cron announces load the Telegram plugin on demand, as on a cold Gateway. They get exactly one block for rich and plain accounts, and none with `mode: "none"`.
  - `extensions/telegram/src/channel-actions.contract.test.ts`: in the real Telegram plugin, a rich account and a plain account on the same bot get different contracts, and neither advertises `markdownDetails`.
- The tests fail with the old production code restored: both delivered agent-command cases get no block, and the Telegram test finds `markdownDetails`. With the source account used instead of the outbound account, the cross-account case fails.
- Test cost with `pnpm test <file> --maxWorkers=1`: about 7.5 s of test time for the agent-command file, 9 s for the reply and heartbeat file, and 4.3 s for the cron file. Most of the wall time is cold transform.
- Neighboring suites pass: inbound metadata, prompt session context, reply media-only, agent command, session preparation and the system prompt. Changed-file typecheck, format and line-cap checks pass.
- Live Telegram Test Server run with a real user recorder and a recording mock provider, on `main` and on this branch (reply and cron code as in the current head):

  | Run | Model request system prompt | Telegram result |
  | --- | --- | --- |
  | `main`, reply, rich on | rules inside inbound metadata `response_format`, plus a separate `## Collapsible Details` section (the `<details>` rule twice) | rich message |
  | `main`, cron announce, rich on | rules in `### Delivery Context` (`openclaw.delivery_meta.v1`) | rich message |
  | this branch, reply, rich on | one `### Delivery Format` block (`openclaw.delivery_format.v1`, `markdown_telegram_rich`), `<details>` rule once, no `Collapsible Details` section, no `response_format` in inbound metadata | rich message |
  | this branch, cron announce, rich on | the same `### Delivery Format` block, byte for byte; no `Delivery Context` block | rich message |
  | this branch, reply, rich off | one `### Delivery Format` block with the "Telegram rich OFF" rules | plain text message |

  The mock reply is fixed, so the Telegram message does not change between `main` and this branch. No screenshots are attached for that reason.
- Not live-tested: subagent announces, heartbeats, and the Codex and CLI runtimes. They read the same extra-system-prompt input that the focused tests cover.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi 2026-09-25 16:39:29 +05:30 • committed by GitHub
parent beadb8c83a
commit fd39fc4842
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 418 additions and 217 deletions

View file

@ -397,7 +397,6 @@ src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts
src/auto-reply/reply/get-reply-run.media-only.test.ts
src/auto-reply/reply/get-reply.ts
src/auto-reply/reply/inbound-meta.test.ts
src/auto-reply/reply/inbound-meta.ts
src/auto-reply/reply/model-selection.test.ts
src/auto-reply/reply/queue.collect.test.ts
src/auto-reply/reply/queue/drain.ts

View file

@ -29,7 +29,7 @@ Rich Telegram surfaces: formatted messages, inline keyboards, agent message acti
}
```
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.3 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).
When enabled: every OpenClaw agent turn delivered to this bot/account (replies, heartbeats, cron announces, and subagent announces) is told rich messages are available, with the supported Markdown + HTML-island authoring contract; Markdown text renders through OpenClaw's Markdown IR as typed Bot API 10.3 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).
Ordinary rich body text, including list items, quotes, and disclosure bodies, preserves parsed Markdown spaces and newlines. Entities decode once: `&amp;` displays `&`, while `\&amp;` and `&amp;amp;` display literal `&amp;`. Escaped tags such as `&lt;b&gt;` stay visible text, and image alternatives stay plain text; neither becomes an HTML island. Unsupported HTML stays visible without suppressing Markdown formatting inside it. HTML attributes and recognized inline comments retain their literal source during Markdown parsing; supported attributes are then decoded by the HTML mapper. HTML-island summaries and figure captions keep their separate HTML normalization.

View file

@ -30,6 +30,12 @@ shared `message` tool. Your plugin owns:
- **Threading** - how replies are threaded
- **Heartbeat typing** - optional typing/busy signals for heartbeat delivery
targets
- **Formatting contract** - optional `agentPrompt.inboundFormattingHints`,
resolved per delivering account. Despite its name, core gives it to every
OpenClaw agent turn whose delivery target is the channel: replies,
heartbeats, cron announces, and subagent announces. A `message` tool send to
another channel does not get that channel's rules, and external ACP agents do
not receive it. Keep all formatting rules in this one hook.
Core owns the shared message tool, prompt wiring, the outer session-key shape,
generic `:thread:` bookkeeping, and dispatch. For configured agent group

View file

@ -17,12 +17,10 @@ export const telegramAgentPrompt: NonNullable<ChannelPlugin["agentPrompt"]> = {
cfg,
accountId: accountId ?? undefined,
});
return [
...(inlineButtonsScope === "off" ? [] : ["inlineButtons"]),
...(isTelegramRichMessagesEnabled(cfg, accountId) ? ["markdownDetails"] : []),
];
return inlineButtonsScope === "off" ? [] : ["inlineButtons"];
},
// Every runtime receives the channel authoring contract via inbound-meta response_format.
// The only Telegram formatting contract, including `<details>`. Core delivers it to
// every turn whose output reaches this account: replies, heartbeats, cron, announces.
inboundFormattingHints: ({ cfg, accountId }) => {
const richMessages = isTelegramRichMessagesEnabled(cfg, accountId);
if (richMessages) {

View file

@ -141,6 +141,33 @@ describe("telegram actions contract", () => {
},
);
it("owns one formatting contract, chosen by the delivering account's richMessages", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: {
rich: { botToken: "tok-rich", richMessages: true },
plain: { botToken: "tok-plain" },
},
},
},
};
const agentPrompt = telegramPlugin.agentPrompt;
const rich = agentPrompt?.inboundFormattingHints?.({ cfg, accountId: "rich" });
const plain = agentPrompt?.inboundFormattingHints?.({ cfg, accountId: "plain" });
expect(rich?.text_markup).toBe("markdown_telegram_rich");
expect(rich?.rules.join("\n")).toContain("<details>");
expect(plain?.text_markup).toBe("markdown");
expect(plain?.rules[0]).toMatch(/^Telegram rich OFF\./);
// `<details>` guidance lives only in the contract, not in a second capability route.
for (const accountId of ["rich", "plain"]) {
expect(agentPrompt?.messageToolCapabilities?.({ cfg, accountId })).not.toContain(
"markdownDetails",
);
}
});
it("exposes Telegram thread create CLI remapping through the exported plugin", () => {
const request = telegramPlugin.actions?.resolveCliActionRequest?.({
action: "thread-create",

View file

@ -465,7 +465,7 @@ async function agentCommandInternal(
}),
{ config: cfg },
);
sessionEntry = embeddedSessionState.sessionEntry;
({ sessionEntry, opts } = embeddedSessionState);
const { requestedThinkLevel, runContext } = embeddedSessionState;
const modelSelection = await measureAgentStartup(

View file

@ -5,6 +5,7 @@ import type { InternalSessionEntry, SessionEntry } from "../../config/sessions/t
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { registerAgentRunContext } from "../../infra/agent-run-registry.js";
import { buildDeliveryFormatPrompt } from "../../infra/outbound/delivery-format-prompt.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { isSubagentCoordinationInputProvenance } from "../../sessions/input-provenance.js";
@ -254,13 +255,32 @@ export async function prepareEmbeddedSessionState(params: {
assertSignalCurrent();
}
const runContext = resolveAgentRunContext(params.opts);
// Announce and inter-session turns get the delivering channel's contract, like replies.
const deliveryFormat =
(params.opts.deliver === true || params.opts.sourceReplyDeliveryMode === "message_tool_only") &&
buildDeliveryFormatPrompt({
cfg: params.cfg,
// Delivery preflight records the actual outbound target as reply* options.
channel: params.opts.replyChannel ?? runContext.messageChannel,
accountId: params.opts.replyAccountId ?? runContext.accountId,
agentId: params.sessionAgentId,
allowBootstrap: true,
});
const extraSystemPrompt = [params.opts.extraSystemPrompt, deliveryFormat].filter(Boolean);
return {
sessionEntry,
requestedThinkLevel,
resolvedVerboseLevel,
skillsSnapshot,
runContext: resolveAgentRunContext(params.opts),
runContext,
opts: deliveryFormat
? { ...params.opts, extraSystemPrompt: extraSystemPrompt.join("\n\n") }
: params.opts,
};
}
export type EmbeddedSessionState = Awaited<ReturnType<typeof prepareEmbeddedSessionState>>;
export type EmbeddedSessionState = Omit<
Awaited<ReturnType<typeof prepareEmbeddedSessionState>>,
"opts"
>;

View file

@ -1619,7 +1619,7 @@ describe("buildAgentSystemPrompt", () => {
it("adds collapsible-details guidance only for supported full prompts", () => {
const supportedPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: { channel: "telegram", capabilities: ["markdownDetails"] },
runtimeInfo: { channel: "webchat", capabilities: ["markdownDetails"] },
});
const unsupportedPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@ -1627,12 +1627,12 @@ describe("buildAgentSystemPrompt", () => {
});
const sameChannelUnsupportedPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: { channel: "telegram", capabilities: [] },
runtimeInfo: { channel: "webchat", capabilities: [] },
});
const minimalPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
promptMode: "minimal",
runtimeInfo: { channel: "telegram", capabilities: ["markdownDetails"] },
runtimeInfo: { channel: "webchat", capabilities: ["markdownDetails"] },
});
expect(supportedPrompt).toContain("## Collapsible Details");

View file

@ -0,0 +1,149 @@
// Reply and heartbeat turns get the delivering Telegram account's formatting contract once.
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { heartbeatRunnerTelegramPlugin } from "../../../test/helpers/infra/heartbeat-runner-channel-plugins.js";
import * as embeddedAgent from "../../agents/embedded-agent.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { runHeartbeatOnce } from "../../infra/heartbeat-runner.js";
import { seedMainSessionStore } from "../../infra/heartbeat-runner.test-utils.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../../test-utils/openclaw-test-state.js";
import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
import { getReplyFromConfig } from "./get-reply.js";
import { finalizeInboundContext } from "./inbound-context.js";
let state: OpenClawTestState | undefined;
beforeEach(() => {
resetSystemEventsForTest();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...heartbeatRunnerTelegramPlugin,
agentPrompt: {
inboundFormattingHints: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
params.cfg.channels?.telegram?.accounts?.[params.accountId ?? ""]?.richMessages
? { text_markup: "markdown_telegram_rich", rules: ["Telegram rich ON."] }
: { text_markup: "markdown", rules: ["Telegram rich OFF."] },
},
},
},
]),
);
});
afterEach(async () => {
vi.restoreAllMocks();
await state?.cleanup();
state = undefined;
resetSystemEventsForTest();
resetPluginRuntimeStateForTest();
});
async function setup(label: string) {
state = await createOpenClawTestState({ label, env: { OPENCLAW_TEST_FAST: "0" } });
const storePath = path.join(state.root, "sessions.json");
const cfg = withFullRuntimeReplyConfig({
agents: {
defaults: {
workspace: state.workspaceDir,
skipBootstrap: true,
model: { primary: "mock-openai/gpt-5.6-luna" },
models: { "mock-openai/gpt-5.6-luna": { agentRuntime: { id: "openclaw" } } },
heartbeat: { every: "5m", target: "last" },
},
},
channels: {
telegram: {
allowFrom: ["*"],
accounts: { rich: { richMessages: true }, plain: { richMessages: false } },
},
},
plugins: { enabled: false },
session: { store: storePath },
} as OpenClawConfig);
await state.writeConfig(cfg);
const runAgent = vi.spyOn(embeddedAgent, "runEmbeddedAgent").mockImplementation(async (p) => ({
payloads: [{ text: "HEARTBEAT_OK" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: p.sessionId, provider: "mock-openai", model: "gpt-5.6-luna" },
},
}));
const lastPrompt = () => runAgent.mock.calls.at(-1)?.[0].extraSystemPrompt ?? "";
return { cfg, storePath, lastPrompt };
}
function expectContractOnce(prompt: string, markup: string) {
expect(prompt.split("### Delivery Format")).toHaveLength(2);
expect(prompt).toContain(`"text_markup": "${markup}"`);
}
it.each([
["rich", "markdown_telegram_rich"],
["plain", "markdown"],
])("gives a Telegram reply on the %s account its contract once", async (accountId, markup) => {
const { cfg, lastPrompt } = await setup("reply-delivery-format");
await getReplyFromConfig(
finalizeInboundContext({
Body: "Post the status table",
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123",
AccountId: accountId,
ChatType: "direct",
SessionKey: `agent:main:telegram:${accountId}:direct:123`,
}),
undefined,
cfg,
);
expectContractOnce(lastPrompt(), markup);
});
it("gives no contract to a reply without a channel delivery target", async () => {
const { cfg, lastPrompt } = await setup("reply-delivery-format-webchat");
await getReplyFromConfig(
finalizeInboundContext({
Body: "Post the status table",
Provider: "webchat",
Surface: "webchat",
ChatType: "direct",
SessionKey: "agent:main:dashboard:format",
}),
undefined,
cfg,
);
expect(lastPrompt()).not.toContain("### Delivery Format");
});
it("gives a heartbeat delivered to Telegram the delivering account's contract once", async () => {
const { cfg, storePath, lastPrompt } = await setup("heartbeat-delivery-format");
const sessionKey = await seedMainSessionStore(storePath, cfg, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "-100155462274",
lastAccountId: "rich",
});
enqueueSystemEvent("Reminder: post the status table", {
sessionKey,
contextKey: "cron:status",
});
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
sessionKey,
source: "cron",
reason: "cron:status",
deps: { getReplyFromConfig },
});
expect(result.status).toBe("ran");
expectContractOnce(lastPrompt(), "markdown_telegram_rich");
});

View file

@ -2,8 +2,6 @@
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";
import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js";
import type { TemplateContext } from "../templating.js";
@ -17,39 +15,9 @@ import { prepareReplyConversation } from "./prompt-session-context.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", async (importOriginal) => ({
...(await importOriginal<typeof import("../../channels/plugins/registry-loaded.js")>()),
getLoadedChannelPluginById: (channelId: string) =>
channelId === "slack"
? {
agentPrompt: {
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,
}));
vi.mock("../../channels/registry.js", () => ({
normalizeAnyChannelId: (channelId?: string) => channelId?.trim().toLowerCase(),
// Delivery formatting has its own reply-turn coverage; keep these tests on the metadata block.
vi.mock("../../infra/outbound/delivery-format-prompt.js", () => ({
buildDeliveryFormatPrompt: () => undefined,
}));
function parseInboundMetaPayload(text: string): Record<string, unknown> {
@ -299,72 +267,7 @@ describe("buildInboundMetaSystemPrompt", () => {
expect(payload["sender_id"]).toBeUndefined();
});
it("includes Slack mrkdwn response format hints for Slack chats and threads cfg", () => {
formattingHintCalls.length = 0;
resetPluginRuntimeStateForTest();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack-plugin",
source: "test",
plugin: {
id: "slack",
meta: {
id: "slack",
label: "Slack",
selectionLabel: "Slack",
docsPath: "/channels/slack",
blurb: "test stub",
},
capabilities: { chatTypes: ["channel"] },
config: { listAccountIds: () => [], resolveAccount: () => ({}) },
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.",
],
}),
},
},
},
]),
);
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({
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.",
],
});
expect(formattingHintCalls).toEqual([{ cfg, accountId: "work" }]);
});
it("uses one prepared conversation for system-event metadata and response formatting", () => {
it("uses one prepared conversation for system-event metadata", () => {
const conversation = prepareReplyConversation({
ctx: { InternalTurnSource: "heartbeat" },
sessionEntry: {
@ -386,25 +289,8 @@ describe("buildInboundMetaSystemPrompt", () => {
surface: "slack",
chat_type: "channel",
account_id: "work",
response_format: { text_markup: "slack_mrkdwn" },
});
});
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();
});
});
describe("buildInboundUserContextPrefix", () => {

View file

@ -4,11 +4,10 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { CurrentInboundPromptContext } from "../../agents/embedded-agent-runner/run/params.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { getLoadedChannelPluginById } from "../../channels/plugins/registry-loaded.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 { buildDeliveryFormatPrompt } from "../../infra/outbound/delivery-format-prompt.js";
import { sliceUtf16Safe, truncateUtf16Safe } from "../../utils.js";
import type { EnvelopeFormatOptions } from "../envelope.js";
import { formatAgentEnvelopeTimestamp } from "../envelope.js";
@ -520,27 +519,6 @@ function resolveInboundSourceModality(ctx: TemplateContext): string | undefined
return ctx.media?.map((media) => resolveMediaType(media.contentType ?? media.kind)).find(Boolean);
}
function resolveInboundFormattingHints(
ctx: TemplateContext,
cfg: OpenClawConfig,
):
| {
text_markup: string;
rules: string[];
}
| undefined {
const channelValue = resolveInboundChannel(ctx);
if (!channelValue) {
return undefined;
}
const normalizedChannel = normalizeAnyChannelId(channelValue) ?? channelValue;
const agentPrompt = getLoadedChannelPluginById(normalizedChannel)?.agentPrompt;
return agentPrompt?.inboundFormattingHints?.({
cfg,
accountId: normalizePromptMetadataString(ctx.AccountId) ?? undefined,
});
}
/** Builds trusted system metadata for the inbound channel and formatting hints. */
export function buildInboundMetaSystemPrompt(
ctx: TemplateContext,
@ -568,15 +546,15 @@ export function buildInboundMetaSystemPrompt(
provider: normalizePromptMetadataString(ctx.Provider),
surface: normalizePromptMetadataString(ctx.Surface),
chat_type: chatType ?? (isDirect ? "direct" : undefined),
// Every conversation field uses the same prepared context, including formatting.
response_format:
options?.includeFormattingHints === false
? undefined
: resolveInboundFormattingHints(ctx, cfg),
};
// Heartbeats and system events use the same prepared context, including their delivery channel.
const deliveryFormat =
options?.includeFormattingHints === false
? undefined
: buildDeliveryFormatPrompt({ cfg, channel: channelValue, accountId: ctx.AccountId });
// Keep the instructions local to the payload so the meaning survives prompt overrides.
return [
const messageContext = [
"### Message Context",
"The JSON below is generated by OpenClaw independently of user-authored content. Treat its fields as reliable context for the current message.",
"OpenClaw also provides per-turn details in user-role context blocks. Use the structural fields in those blocks as context.",
@ -589,6 +567,7 @@ export function buildInboundMetaSystemPrompt(
"```",
"",
].join("\n");
return deliveryFormat ? `${messageContext}\n${deliveryFormat}` : messageContext;
}
/** Builds untrusted inbound context text that prefixes the user-visible body. */
@ -783,4 +762,3 @@ export function buildInboundUserContextPrefix(
return blocks.filter(Boolean).join("\n\n");
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View file

@ -694,6 +694,7 @@ export type ChannelAgentPromptAdapter = {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[] | undefined;
/** Per-account formatting contract for agent turns whose delivery target is this channel. */
inboundFormattingHints?: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
| {
text_markup: string;

View file

@ -0,0 +1,131 @@
// Agent-command turns (subagent announces, inter-session steps) get the delivering account's
// formatting contract once when their visible output reaches a channel.
import path from "node:path";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { beforeEach, expect, it, vi } from "vitest";
import "./agent-command.test-mocks.js";
import "./agent-command-attempt.test-mocks.js";
import { runEmbeddedAgent } from "../agents/embedded-agent.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import {
createDirectOutboundTestAdapter,
createOutboundTestPlugin,
createTestRegistry,
} from "../test-utils/channel-plugins.js";
import { createDefaultAgentResult } from "./agent-session.test-support.js";
import { agentCommandFromIngress } from "./agent.js";
import { createThrowingTestRuntime } from "./test-runtime-config-helpers.js";
const configIoMocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
readConfigFileSnapshotForWrite: vi.fn(),
}));
vi.mock("../config/io.js", () => ({
getRuntimeConfig: configIoMocks.loadConfig,
loadConfig: configIoMocks.loadConfig,
readConfigFileSnapshotForWrite: configIoMocks.readConfigFileSnapshotForWrite,
}));
vi.mock("../agents/command/delivery.runtime.js", () => ({
deliverAgentCommandResult: vi.fn(async () => ({ payloads: [], meta: {} })),
}));
beforeEach(() => {
vi.clearAllMocks();
resetPluginRuntimeStateForTest();
clearSessionStoreCacheForTest();
vi.mocked(runEmbeddedAgent).mockResolvedValue(createDefaultAgentResult());
configIoMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: { valid: false, resolved: {} },
writeOptions: {},
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: createDirectOutboundTestAdapter({ channel: "telegram" }),
}),
agentPrompt: {
inboundFormattingHints: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => ({
text_markup: params.cfg.channels?.telegram?.accounts?.[params.accountId ?? ""]
?.richMessages
? "markdown_telegram_rich"
: "markdown",
rules: [],
}),
},
},
},
]),
);
});
it.each([
{ turn: "a delivered announce", mode: { deliver: true }, accountId: "rich", rich: true },
{
turn: "a delivery through another account",
mode: { deliver: true, replyAccountId: "plain" },
accountId: "rich",
rich: false,
},
{
turn: "a message-tool announce",
mode: { sourceReplyDeliveryMode: "message_tool_only" as const },
accountId: "plain",
rich: false,
},
{ turn: "an undelivered turn", mode: {}, accountId: "rich", rich: undefined },
])(
"gives $turn from the $accountId account its delivering formatting contract",
async (testCase) => {
await withTempHome(async (home) => {
configIoMocks.loadConfig.mockReturnValue({
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
models: { "anthropic/claude-opus-4-6": {} },
workspace: path.join(home, "openclaw"),
},
},
session: { store: path.join(home, "sessions.json"), mainKey: "main" },
channels: {
telegram: { accounts: { rich: { richMessages: true }, plain: { richMessages: false } } },
},
} as OpenClawConfig);
await agentCommandFromIngress(
{
message: "child finished",
agentId: "main",
sessionKey: "agent:main:telegram:direct:1222",
to: "+1222",
channel: "telegram",
accountId: testCase.accountId,
extraSystemPrompt: "Announce the child result.",
allowModelOverride: false,
sessionEffects: "internal",
...testCase.mode,
},
createThrowingTestRuntime(),
);
const prompt = vi.mocked(runEmbeddedAgent).mock.calls.at(-1)?.[0].extraSystemPrompt ?? "";
if (testCase.rich === undefined) {
expect(prompt).toBe("Announce the child result.");
return;
}
const markup = testCase.rich ? "markdown_telegram_rich" : "markdown";
expect(prompt.startsWith("Announce the child result.\n\n### Delivery Format")).toBe(true);
expect(prompt.split("### Delivery Format")).toHaveLength(2);
expect(prompt).toContain(`"text_markup": "${markup}"`);
});
},
);

View file

@ -3,7 +3,6 @@ import { resolveStaticSessionMcpServerNames } from "../../agents/agent-bundle-mc
import { resolveCodexMcpToolOverridesForAgent } from "../../agents/cli-runner/bundle-mcp-codex.js";
import { wrapUntrustedPromptDataBlock } from "../../agents/sanitize-for-prompt.js";
/** Delivery planning, prompt policy, and delivery trace construction for cron runs. */
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type {
SourceDeliveryOutcome,
@ -317,7 +316,7 @@ export async function resolveCronDeliveryContext(params: {
sourceDelivery: resolveCronSourceDeliveryPlan({ deliveryPlan, resolvedDelivery }),
};
}
const { resolveDeliveryTarget, resolveOutboundChannelPlugin } = await loadCronDeliveryRuntime();
const { buildDeliveryFormatPrompt, resolveDeliveryTarget } = await loadCronDeliveryRuntime();
const resolvedDelivery = await resolveDeliveryTarget(params.cfg, params.agentId, {
...deliveryPlan,
sessionTarget: params.job.payload.kind === "agentTurn" ? params.job.sessionTarget : undefined,
@ -325,65 +324,24 @@ export async function resolveCronDeliveryContext(params: {
// delivery session rather than the creator's last conversation.
sessionKey: resolveCronDeliverySessionKey(params.job),
});
// Announce runs have no inbound message. Take formatting hints from the same plugin
// that delivers the output, which can be a cold bootstrap outside the active registry.
const deliveryPlugin =
deliveryPlan.requested && resolvedDelivery.ok
? resolveOutboundChannelPlugin({
channel: resolvedDelivery.channel,
cfg: params.cfg,
agentId: params.agentId,
allowBootstrap: true,
})
: undefined;
return {
deliveryPlan,
deliveryRequested: deliveryPlan.requested,
resolvedDelivery,
deliverySystemPrompt: deliveryPlugin
? buildCronDeliveryMetaSystemPrompt({
cfg: params.cfg,
plugin: deliveryPlugin,
accountId: resolvedDelivery.accountId,
})
: undefined,
deliverySystemPrompt:
deliveryPlan.requested && resolvedDelivery.ok
? buildDeliveryFormatPrompt({
cfg: params.cfg,
channel: resolvedDelivery.channel,
accountId: resolvedDelivery.accountId,
agentId: params.agentId,
allowBootstrap: true,
})
: undefined,
sourceDelivery: resolveCronSourceDeliveryPlan({ deliveryPlan, resolvedDelivery }),
};
}
/**
* Builds trusted system metadata that gives a run the delivering channel's formatting
* hints. Returns undefined when the plugin has none, so the system prompt stays unchanged.
*/
function buildCronDeliveryMetaSystemPrompt(params: {
cfg: OpenClawConfig;
plugin: Pick<ChannelPlugin, "id" | "agentPrompt">;
accountId?: string;
}): string | undefined {
const responseFormat = params.plugin.agentPrompt?.inboundFormattingHints?.({
cfg: params.cfg,
accountId: params.accountId,
});
if (!responseFormat) {
return undefined;
}
const payload = {
schema: "openclaw.delivery_meta.v1",
account_id: params.accountId,
channel: params.plugin.id,
response_format: responseFormat,
};
return [
"### Delivery Context",
"The JSON below is generated by OpenClaw. Your visible output for this run is delivered to this channel; follow its response_format.",
"",
"```json",
JSON.stringify(payload, null, 2),
"```",
"",
].join("\n");
}
export function appendCronDeliveryInstruction(params: {
commandBody: string;
deliveryRequested: boolean;

View file

@ -1,5 +1,5 @@
// Runtime delivery seam for isolated cron agent run orchestration.
export { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js";
export { buildDeliveryFormatPrompt } from "../../infra/outbound/delivery-format-prompt.js";
export { resolveDeliveryTarget } from "./delivery-target.js";
export {
dispatchCronDelivery,

View file

@ -114,8 +114,8 @@ describe("runCronIsolatedAgentTurn delivery formatting hints", () => {
"rich",
);
expect(prompt).toContain("### Delivery Context");
expect(prompt).toContain('"account_id": "rich"');
expect(prompt?.split("### Delivery Format")).toHaveLength(2);
expect(prompt).toContain('"schema": "openclaw.delivery_format.v1"');
expect(prompt).toContain('"text_markup": "markdown_telegram_rich"');
expect(prompt).toContain("Telegram rich ON.");
});
@ -126,6 +126,7 @@ describe("runCronIsolatedAgentTurn delivery formatting hints", () => {
"plain",
);
expect(prompt?.split("### Delivery Format")).toHaveLength(2);
expect(prompt).toContain('"text_markup": "markdown"');
expect(prompt).toContain("Telegram rich OFF.");
});

View file

@ -0,0 +1,46 @@
import { getLoadedChannelPlugin } from "../../channels/plugins/index.js";
import { normalizeAnyChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
/** Renders the delivering channel's formatting contract; only delivered runs bootstrap. */
export function buildDeliveryFormatPrompt(params: {
cfg: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
agentId?: string;
allowBootstrap?: boolean;
}): string | undefined {
if (!params.channel) {
return undefined;
}
const plugin = params.allowBootstrap
? resolveOutboundChannelPlugin({
channel: params.channel,
cfg: params.cfg,
agentId: params.agentId,
allowBootstrap: true,
})
: getLoadedChannelPlugin(normalizeAnyChannelId(params.channel) ?? params.channel);
const responseFormat = plugin?.agentPrompt?.inboundFormattingHints?.({
cfg: params.cfg,
accountId: params.accountId?.trim() || undefined,
});
if (!plugin || !responseFormat) {
return undefined;
}
const payload = {
schema: "openclaw.delivery_format.v1",
channel: plugin.id,
response_format: responseFormat,
};
return [
"### Delivery Format",
"The JSON below is generated by OpenClaw. Your visible output for this turn is delivered to this channel; follow its response_format.",
"",
"```json",
JSON.stringify(payload, null, 2),
"```",
"",
].join("\n");
}

View file

@ -108,6 +108,7 @@ export const databaseWorkerCoreTestFiles = [
"src/auto-reply/reply/body.test.ts",
"src/auto-reply/reply/get-reply.binding-route-owner.test.ts",
"src/auto-reply/reply/get-reply.dashboard.test.ts",
"src/auto-reply/reply/get-reply.delivery-format.test.ts",
"src/auto-reply/reply/get-reply.explicit-owner.test.ts",
"src/auto-reply/reply/get-reply.text-directives.test.ts",
"src/auto-reply/reply/get-reply.timeout.test.ts",