diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index c222af9a2b82..5319cda2c8b3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/docs/channels/telegram/rich-messages.md b/docs/channels/telegram/rich-messages.md index da9c0be9a945..54b25d91a911 100644 --- a/docs/channels/telegram/rich-messages.md +++ b/docs/channels/telegram/rich-messages.md @@ -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: `&` displays `&`, while `\&` and `&amp;` display literal `&`. Escaped tags such as `<b>` 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. diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index c402022ddeb2..734aa2d949c1 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -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 diff --git a/extensions/telegram/src/agent-prompt.ts b/extensions/telegram/src/agent-prompt.ts index 9e8605118687..576ff6470c4d 100644 --- a/extensions/telegram/src/agent-prompt.ts +++ b/extensions/telegram/src/agent-prompt.ts @@ -17,12 +17,10 @@ export const telegramAgentPrompt: NonNullable = { 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 `
`. 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) { diff --git a/extensions/telegram/src/channel-actions.contract.test.ts b/extensions/telegram/src/channel-actions.contract.test.ts index 71afa27d904b..a56d45b36c73 100644 --- a/extensions/telegram/src/channel-actions.contract.test.ts +++ b/extensions/telegram/src/channel-actions.contract.test.ts @@ -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("
"); + expect(plain?.text_markup).toBe("markdown"); + expect(plain?.rules[0]).toMatch(/^Telegram rich OFF\./); + // `
` 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", diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index f9fc7f8e83d1..fbb9045758b8 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -465,7 +465,7 @@ async function agentCommandInternal( }), { config: cfg }, ); - sessionEntry = embeddedSessionState.sessionEntry; + ({ sessionEntry, opts } = embeddedSessionState); const { requestedThinkLevel, runContext } = embeddedSessionState; const modelSelection = await measureAgentStartup( diff --git a/src/agents/command/session-preparation.ts b/src/agents/command/session-preparation.ts index c00e52be56e8..9ac9c4fb2365 100644 --- a/src/agents/command/session-preparation.ts +++ b/src/agents/command/session-preparation.ts @@ -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>; +export type EmbeddedSessionState = Omit< + Awaited>, + "opts" +>; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index bcb8e3585a6b..6ec2b741a9f7 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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"); diff --git a/src/auto-reply/reply/get-reply.delivery-format.test.ts b/src/auto-reply/reply/get-reply.delivery-format.test.ts new file mode 100644 index 000000000000..05060f7d271f --- /dev/null +++ b/src/auto-reply/reply/get-reply.delivery-format.test.ts @@ -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"); +}); diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index bc6cf5acd5df..c78d028d5d3b 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -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()), - 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 .", - "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 { @@ -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 .", - "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 .", - "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", () => { diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 672842b60914..b018fc64ff02 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -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. */ diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index fe6448075cd1..18a714246bc2 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/commands/agent.delivery-format.test.ts b/src/commands/agent.delivery-format.test.ts new file mode 100644 index 000000000000..9c2e3ac42549 --- /dev/null +++ b/src/commands/agent.delivery-format.test.ts @@ -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}"`); + }); + }, +); diff --git a/src/cron/isolated-agent/run-delivery-trace.ts b/src/cron/isolated-agent/run-delivery-trace.ts index 5d4ea1210407..4ebee5a04e5b 100644 --- a/src/cron/isolated-agent/run-delivery-trace.ts +++ b/src/cron/isolated-agent/run-delivery-trace.ts @@ -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; - 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; diff --git a/src/cron/isolated-agent/run-delivery.runtime.ts b/src/cron/isolated-agent/run-delivery.runtime.ts index e8ac74dc4b7e..498929ca4462 100644 --- a/src/cron/isolated-agent/run-delivery.runtime.ts +++ b/src/cron/isolated-agent/run-delivery.runtime.ts @@ -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, diff --git a/src/cron/isolated-agent/run.delivery-formatting.test.ts b/src/cron/isolated-agent/run.delivery-formatting.test.ts index 8f85af28052e..386e2da8cfe2 100644 --- a/src/cron/isolated-agent/run.delivery-formatting.test.ts +++ b/src/cron/isolated-agent/run.delivery-formatting.test.ts @@ -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."); }); diff --git a/src/infra/outbound/delivery-format-prompt.ts b/src/infra/outbound/delivery-format-prompt.ts new file mode 100644 index 000000000000..858ea6eea952 --- /dev/null +++ b/src/infra/outbound/delivery-format-prompt.ts @@ -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"); +} diff --git a/test/vitest/vitest.database-worker-core-paths.mjs b/test/vitest/vitest.database-worker-core-paths.mjs index 733b4c110716..c75a7e565c5e 100644 --- a/test/vitest/vitest.database-worker-core-paths.mjs +++ b/test/vitest/vitest.database-worker-core-paths.mjs @@ -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",