fix(acp): forward inbound media attachments through ACP topic binding turns

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 <noreply@anthropic.com>
This commit is contained in:
lumen claw 2026-03-09 05:46:37 -07:00
parent f2f561fab1
commit 6f27dd51a7
5 changed files with 50 additions and 2 deletions

View File

@ -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) => {

View File

@ -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,

View File

@ -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;

View File

@ -39,9 +39,15 @@ export type AcpRuntimeEnsureInput = {
env?: Record<string, string>;
};
export type AcpRuntimeTurnAttachment = {
mediaType: string;
data: string;
};
export type AcpRuntimeTurnInput = {
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;

View File

@ -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<AcpTurnAttachment[]> {
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),