From 6f27dd51a7d0be633878394bc768fbb8a566322a Mon Sep 17 00:00:00 2001 From: lumen claw Date: Mon, 9 Mar 2026 05:46:37 -0700 Subject: [PATCH 1/3] fix(acp): forward inbound media attachments through ACP topic binding turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Telegram topic is bound to an agent via ACP, images and files sent in the topic were silently dropped — the agent received only a placeholder tag instead of the actual image content. The media was correctly downloaded to ~/.openclaw/media/inbound/ but never forwarded through the ACP turn. Changes: - Add AcpTurnAttachment type to AcpRunTurnInput and AcpRuntimeTurnInput - In dispatch-acp: read MediaPaths/MediaTypes from FinalizedMsgContext, read each file and base64-encode it as an attachment for the ACP turn - In manager.core: thread attachments through to runtime.runTurn() - In acpx runtime: when attachments are present, serialize stdin as a JSON content-blocks array (text + image blocks) instead of plain text, matching the ACP protocol's standard multi-modal message format Fixes #40978 Co-Authored-By: Claude Sonnet 4.6 --- extensions/acpx/src/runtime.ts | 10 ++++++++- src/acp/control-plane/manager.core.ts | 1 + src/acp/control-plane/manager.types.ts | 6 ++++++ src/acp/runtime/types.ts | 6 ++++++ src/auto-reply/reply/dispatch-acp.ts | 29 +++++++++++++++++++++++++- 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 5fa56d109e5..4f7ff263a2a 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -310,7 +310,15 @@ export class AcpxRuntime implements AcpRuntime { // Ignore EPIPE when the child exits before stdin flush completes. }); - child.stdin.end(input.text); + if (input.attachments && input.attachments.length > 0) { + const blocks: unknown[] = [{ type: "text", text: input.text }]; + for (const attachment of input.attachments) { + blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data }); + } + child.stdin.end(JSON.stringify(blocks)); + } else { + child.stdin.end(input.text); + } let stderr = ""; child.stderr.on("data", (chunk) => { diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index a64b1fae7eb..f511355ae87 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -655,6 +655,7 @@ export class AcpSessionManager { for await (const event of runtime.runTurn({ handle, text: input.text, + attachments: input.attachments, mode: input.mode, requestId: input.requestId, signal: combinedSignal, diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index 7337e8063f9..33c2355305c 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -47,10 +47,16 @@ export type AcpInitializeSessionInput = { backendId?: string; }; +export type AcpTurnAttachment = { + mediaType: string; + data: string; +}; + export type AcpRunTurnInput = { cfg: OpenClawConfig; sessionKey: string; text: string; + attachments?: AcpTurnAttachment[]; mode: AcpRuntimePromptMode; requestId: string; signal?: AbortSignal; diff --git a/src/acp/runtime/types.ts b/src/acp/runtime/types.ts index 6a3d3bb3f8e..2d4b10ccf2c 100644 --- a/src/acp/runtime/types.ts +++ b/src/acp/runtime/types.ts @@ -39,9 +39,15 @@ export type AcpRuntimeEnsureInput = { env?: Record; }; +export type AcpRuntimeTurnAttachment = { + mediaType: string; + data: string; +}; + export type AcpRuntimeTurnInput = { handle: AcpRuntimeHandle; text: string; + attachments?: AcpRuntimeTurnAttachment[]; mode: AcpRuntimePromptMode; requestId: string; signal?: AbortSignal; diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 33990cb20d6..108ebaaf87e 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -1,4 +1,6 @@ +import fs from "node:fs/promises"; import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; +import type { AcpTurnAttachment } from "../../acp/control-plane/manager.types.js"; import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js"; import { formatAcpRuntimeErrorText } from "../../acp/runtime/error-text.js"; import { toAcpRuntimeError } from "../../acp/runtime/errors.js"; @@ -57,6 +59,29 @@ function resolveAcpPromptText(ctx: FinalizedMsgContext): string { ]).trim(); } +async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise { + const paths = ctx.MediaPaths; + const types = ctx.MediaTypes; + if (!Array.isArray(paths) || paths.length === 0) { + return []; + } + const results: AcpTurnAttachment[] = []; + for (let i = 0; i < paths.length; i++) { + const filePath = paths[i]; + const mediaType = Array.isArray(types) ? (types[i] ?? "application/octet-stream") : "application/octet-stream"; + if (typeof filePath !== "string" || !filePath.trim()) { + continue; + } + try { + const buf = await fs.readFile(filePath); + results.push({ mediaType, data: buf.toString("base64") }); + } catch { + // Skip unreadable files — do not block the text turn. + } + } + return results; +} + function resolveCommandCandidateText(ctx: FinalizedMsgContext): string { return resolveFirstContextText(ctx, ["CommandBody", "BodyForCommands", "RawBody", "Body"]).trim(); } @@ -189,7 +214,8 @@ export async function tryDispatchAcpReply(params: { }); const promptText = resolveAcpPromptText(params.ctx); - if (!promptText) { + const attachments = await resolveAcpAttachments(params.ctx); + if (!promptText && attachments.length === 0) { const counts = params.dispatcher.getQueuedCounts(); delivery.applyRoutedCounts(counts); params.recordProcessed("completed", { reason: "acp_empty_prompt" }); @@ -251,6 +277,7 @@ export async function tryDispatchAcpReply(params: { cfg: params.cfg, sessionKey, text: promptText, + attachments: attachments.length > 0 ? attachments : undefined, mode: "prompt", requestId: resolveAcpRequestId(params.ctx), onEvent: async (event) => await projector.onEvent(event), From e57fd4bbb18f79422b199597e83d5da8a66efcae Mon Sep 17 00:00:00 2001 From: lumen claw Date: Mon, 9 Mar 2026 05:49:56 -0700 Subject: [PATCH 2/3] refactor(acp): reuse normalizeAttachments() for media resolution in dispatch-acp Replace custom MediaPaths/MediaTypes parsing with the existing normalizeAttachments() + normalizeAttachmentPath() from media-understanding/attachments.normalize, which already handles all edge cases (file:// URIs, single vs array, MediaUrl fallback). Co-Authored-By: Claude Sonnet 4.6 --- src/auto-reply/reply/dispatch-acp.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 108ebaaf87e..94bf9c83c4e 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -1,6 +1,10 @@ import fs from "node:fs/promises"; import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; import type { AcpTurnAttachment } from "../../acp/control-plane/manager.types.js"; +import { + normalizeAttachmentPath, + normalizeAttachments, +} from "../../media-understanding/attachments.normalize.js"; import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js"; import { formatAcpRuntimeErrorText } from "../../acp/runtime/error-text.js"; import { toAcpRuntimeError } from "../../acp/runtime/errors.js"; @@ -60,21 +64,16 @@ function resolveAcpPromptText(ctx: FinalizedMsgContext): string { } async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise { - const paths = ctx.MediaPaths; - const types = ctx.MediaTypes; - if (!Array.isArray(paths) || paths.length === 0) { - return []; - } + const mediaAttachments = normalizeAttachments(ctx); const results: AcpTurnAttachment[] = []; - for (let i = 0; i < paths.length; i++) { - const filePath = paths[i]; - const mediaType = Array.isArray(types) ? (types[i] ?? "application/octet-stream") : "application/octet-stream"; - if (typeof filePath !== "string" || !filePath.trim()) { + for (const attachment of mediaAttachments) { + const filePath = normalizeAttachmentPath(attachment.path); + if (!filePath) { continue; } try { const buf = await fs.readFile(filePath); - results.push({ mediaType, data: buf.toString("base64") }); + results.push({ mediaType: attachment.mime ?? "application/octet-stream", data: buf.toString("base64") }); } catch { // Skip unreadable files — do not block the text turn. } From 74d9fb6741b16ad403cb7723469f3085e3eab38d Mon Sep 17 00:00:00 2001 From: lumen claw Date: Mon, 9 Mar 2026 05:53:47 -0700 Subject: [PATCH 3/3] fix(acp): address review feedback on media attachment forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime.ts: skip empty text block when input.text is empty; only emit type: "image" blocks for image/* MIME types — non-image attachments (documents, PDFs, audio, video) are silently skipped as the ACP protocol does not define a binary content block for them - dispatch-acp.ts: add 10 MB file size guard in resolveAcpAttachments to prevent unbounded memory allocation when large files are inbound Co-Authored-By: Claude Sonnet 4.6 --- extensions/acpx/src/runtime.ts | 11 +++++++++-- src/auto-reply/reply/dispatch-acp.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 4f7ff263a2a..9e711c530b3 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -311,9 +311,16 @@ export class AcpxRuntime implements AcpRuntime { }); if (input.attachments && input.attachments.length > 0) { - const blocks: unknown[] = [{ type: "text", text: input.text }]; + const blocks: unknown[] = []; + if (input.text) { + blocks.push({ type: "text", text: input.text }); + } for (const attachment of input.attachments) { - blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data }); + if (attachment.mediaType.startsWith("image/")) { + blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data }); + } + // Non-image attachments (documents, PDFs, audio, video) are not supported + // as binary content blocks in the ACP protocol — skip silently. } child.stdin.end(JSON.stringify(blocks)); } else { diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 94bf9c83c4e..1f5b69beb6f 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -63,6 +63,8 @@ function resolveAcpPromptText(ctx: FinalizedMsgContext): string { ]).trim(); } +const ACP_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; // 10 MB — consistent with Telegram mediaMaxMb default + async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise { const mediaAttachments = normalizeAttachments(ctx); const results: AcpTurnAttachment[] = []; @@ -72,6 +74,13 @@ async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise ACP_ATTACHMENT_MAX_BYTES) { + logVerbose( + `dispatch-acp: skipping attachment ${filePath} (${stat.size} bytes exceeds ${ACP_ATTACHMENT_MAX_BYTES} byte limit)`, + ); + continue; + } const buf = await fs.readFile(filePath); results.push({ mediaType: attachment.mime ?? "application/octet-stream", data: buf.toString("base64") }); } catch {