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:
parent
f2f561fab1
commit
6f27dd51a7
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user