import type { ContentBlock, McpServer, ToolCallContent } from '@agentclientprotocol/sdk';
import {
buildImageCompressionCaption,
compressBase64ForModel,
type ContentPart,
type McpServerConfig,
parseImageDataUrl,
persistOriginalImage,
} from '@moonshot-ai/agent-core-v2';
import type { ToolResultEvent } from '@moonshot-ai/agent-core-v2/events';
import type { ToolInputDisplay } from '@moonshot-ai/agent-core-v2/tool/toolInputDisplay';
import { log } from './log';
import { isHideOutputMarker } from './marker';
/**
* Convert an array of ACP {@link ContentBlock}s into agent-core-v2
* {@link ContentPart}s suitable for a user `ContextMessage`'s `content`.
*
* Image parts are built from the client-declared MIME verbatim; run the
* result through {@link compressPromptImageParts} before submitting so
* unsupported formats are dropped and MIME aliases canonicalized. Audio and
* blob embedded resources are dropped with a warning (ACP
* `promptCapabilities` currently advertise audio as unsupported).
*/
export function acpBlocksToContentParts(blocks: readonly ContentBlock[]): readonly ContentPart[] {
const out: ContentPart[] = [];
for (const block of blocks) {
if (block.type === 'text') {
out.push({ type: 'text', text: block.text });
continue;
}
if (block.type === 'image') {
const url = `data:${block.mimeType};base64,${block.data}`;
out.push({ type: 'image_url', imageUrl: { url } });
continue;
}
if (block.type === 'audio') {
log.warn('acp: dropping unsupported audio prompt block', {
mimeType: block.mimeType,
});
continue;
}
if (block.type === 'resource_link') {
const fileRef = fileLinkToTextRef(block.uri);
if (fileRef !== null) {
out.push({ type: 'text', text: fileRef });
continue;
}
const text = ``;
out.push({ type: 'text', text });
continue;
}
if (block.type === 'resource') {
const resource = block.resource;
if ('text' in resource) {
// TextResourceContents — wrap as a `` element so the
// model sees the uri provenance alongside the text body.
const text = `${resource.text}`;
out.push({ type: 'text', text });
continue;
}
// BlobResourceContents — drop+warn.
log.warn('acp: dropping blob embedded resource', {
uri: resource.uri,
mimeType: resource.mimeType,
});
continue;
}
// Future-proof: anything else (new ACP block kinds) → warn and drop.
log.warn('acp: dropping unsupported prompt content block', {
type: (block as { type: string }).type,
});
}
return out;
}
/**
* Shrink oversized inline images in a prompt-part list — the ACP ingestion
* point's input-stage compression, mirroring kap-server's upload-time step
* (`resolvePromptMediaFiles`). Best effort: a part that cannot be compressed
* is passed through unchanged.
*
* Compression is NOT duplicated by the engine: agent-core-v2's prompt pipeline
* (`agent/prompt/promptService.ts`) only *extracts* pre-existing compression
* captions from user text (rerouting them to system reminders) — it never
* compresses images at the prompt entry, so the edge ingestion point owns
* that step.
*
* Format gating is deliberately left to the engine: the accepted image
* formats depend on the provider the agent is bound to, which this edge does
* not know. The engine's prompt pipeline gates every image part against that
* provider's set (dropping rejected parts for a text notice and rewriting
* accepted MIME aliases to their canonical form) before anything reaches the
* session history, so parts in formats we cannot re-encode pass through here
* untouched.
*
* Compression is never silent: a re-encoded image gains a caption text part
* immediately before it stating what the original was, and the original bytes
* are persisted (into `originalsDir` — typically the session's
* media-originals dir — or the shared temp-dir fallback) so the model can
* read fine detail back via ReadMediaFile + region.
*/
export async function compressPromptImageParts(
parts: readonly ContentPart[],
options: {
readonly originalsDir?: string | undefined;
/**
* Longest-edge ceiling (px) override. The ACP server runs the engine
* in-process, so the Agent-scope `ImageConfigBridge` has already pushed
* the env-resolved `[image]` config section into the compression module's
* global seam — leave this `undefined` (the default) and the configured /
* built-in cap applies. The override exists for tests.
*/
readonly maxImageEdgePx?: number | undefined;
} = {},
): Promise {
const out: ContentPart[] = [];
for (const part of parts) {
if (part.type === 'image_url') {
const parsed = parseImageDataUrl(part.imageUrl.url);
if (parsed !== null) {
const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, {
maxEdge: options.maxImageEdgePx,
});
if (result.changed) {
const originalPath = await persistOriginalImage(
Buffer.from(parsed.base64, 'base64'),
parsed.mimeType,
{ dir: options.originalsDir },
);
out.push({
type: 'text',
text: buildImageCompressionCaption({
original: {
width: result.originalWidth,
height: result.originalHeight,
byteLength: result.originalByteLength,
mimeType: parsed.mimeType,
},
final: {
width: result.width,
height: result.height,
byteLength: result.finalByteLength,
mimeType: result.mimeType,
},
originalPath,
}),
});
out.push({
type: 'image_url',
imageUrl: { ...part.imageUrl, url: `data:${result.mimeType};base64,${result.base64}` },
});
continue;
}
}
}
out.push(part);
}
return out;
}
/**
* Convert ACP `session/new` / `session/load` `mcpServers` — a named array
* discriminated by `type` (absent = stdio) — into the engine's name-keyed
* {@link McpServerConfig} record. Returns `undefined` for an absent/empty
* list (or when every entry was dropped) so the engine builds no session
* overlay. The unstable `type: 'acp'` transport is unsupported and dropped
* with a warning.
*/
export function acpMcpServersToConfigRecord(
servers: readonly McpServer[] | undefined,
): Record | undefined {
if (servers === undefined || servers.length === 0) return undefined;
const out: Record = {};
for (const server of servers) {
if (!('type' in server)) {
out[server.name] = {
transport: 'stdio',
command: server.command,
args: server.args,
env: namedPairsToRecord(server.env),
runtime_id: 'local',
};
continue;
}
if (server.type === 'http' || server.type === 'sse') {
out[server.name] = {
transport: server.type,
url: server.url,
headers: namedPairsToRecord(server.headers),
};
continue;
}
log.warn('acp: dropping unsupported MCP server transport', {
name: server.name,
type: server.type,
});
}
return Object.keys(out).length === 0 ? undefined : out;
}
/** ACP env/header lists are `{name, value}` arrays; the engine wants a record. */
function namedPairsToRecord(
pairs: readonly { readonly name: string; readonly value: string }[],
): Record | undefined {
if (pairs.length === 0) return undefined;
return Object.fromEntries(pairs.map((p) => [p.name, p.value]));
}
/**
* Minimum-viable XML-attribute escaping for prompt-embedded resource
* wrappers. The output is consumed by an LLM, not parsed by a canonical
* XML parser, so we only escape the five characters that would change the
* apparent tag structure: `&`, `<`, `>`, `"`, `'`. `&` must run
* first to avoid double-escaping the entities introduced by the others.
*/
function escapeXmlAttr(s: string): string {
return s
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function fileLinkToTextRef(uri: string): string | null {
let url: URL;
try {
url = new URL(uri);
} catch {
return null;
}
if (url.protocol !== 'file:') return null;
let path: string;
try {
path = decodeURIComponent(url.pathname);
} catch {
return null;
}
// `file://server/share/a.ts` is the URI form of a Windows UNC path
// (`\\server\share\a.ts`). `URL.pathname` only carries `/share/a.ts`; the
// host is part of the file location, so keep it in the projected text ref.
// `file://localhost/...` is still treated as local. Host is lower-cased so
// `file://Server/...` and `file://server/...` collapse to one ref.
const host = url.hostname.toLowerCase();
const isUncHost = host !== '' && host !== 'localhost';
// Drive-letter normalization is local-only: a UNC URI never legitimately
// carries `/C:/...` in its path, so we leave such inputs untouched rather
// than stripping a leading slash that would alter the UNC payload.
if (!isUncHost && /^\/[A-Za-z]:/.test(path)) path = path.slice(1);
if (isUncHost) {
path = `//${host}${path.startsWith('/') ? path : `/${path}`}`;
}
const range = parseLineRange(url.hash) ?? parseLineRange(url.search);
return range !== null ? `${path}:${range}` : path;
}
function parseLineRange(suffix: string): string | null {
if (!suffix) return null;
const body = suffix.replace(/^[#?]/, '');
const match = /^(?:lines?=|L)(\d+)(?:[-:]L?(\d+))?/i.exec(body);
if (!match) return null;
return match[2] !== undefined ? `${match[1]}-${match[2]}` : match[1]!;
}
/**
* Project a {@link ToolInputDisplay} block into an ACP {@link ToolCallContent}
* entry for the tool-call card. Diff/file_io blocks become inline diffs;
* plan_review becomes a text content entry; everything else yields `null`
* (the caller drops it).
*/
export function displayBlockToAcpContent(block: ToolInputDisplay): ToolCallContent | null {
if (block.kind === 'diff') {
return {
type: 'diff',
path: block.path,
oldText: block.before,
newText: block.after,
};
}
if (block.kind === 'file_io' && block.before !== undefined && block.after !== undefined) {
return {
type: 'diff',
path: block.path,
oldText: block.before,
newText: block.after,
};
}
if (block.kind === 'plan_review') {
const text = composePlanContent(block);
if (text === null) return null;
return { type: 'content', content: { type: 'text', text } };
}
return null;
}
/**
* Render the text body of a `plan_review` display block. Empty plan → `null`
* (caller drops the entry). When `block.path` is set, prefix with the on-disk
* location so the client can show it alongside the markdown body.
*/
function composePlanContent(
block: Extract,
): string | null {
if (block.plan.trim().length === 0) return null;
if (block.path !== undefined) {
return `Plan saved to: ${block.path}\n\n${block.plan}`;
}
return block.plan;
}
/**
* Convert a {@link ToolResultEvent}'s `output` into ACP
* {@link ToolCallContent} entries.
*
* A non-empty string is passed through as a text block; objects/arrays are
* JSON-stringified (best-effort — falls back to a placeholder on circular
* structures). Empty/undefined/null output yields an empty array — the caller
* still emits a `tool_call_update` so the client sees the status transition
* to completed/failed.
*
* Diff content does NOT come from this function: `ToolResultEvent` has no
* `display` field; diffs attach to `ToolCallStartedEvent.display` and are
* emitted by `toolCallStartToSessionUpdate`.
*/
export function toolResultToAcpContent(event: ToolResultEvent): ToolCallContent[] {
const out = event.output;
// Array output containing the HideOutputMarker tells the adapter to suppress
// this tool's textual content entirely (e.g. terminal output routed through
// its own reverse-RPC channel). Detected before any other processing so
// mark-bearing outputs never leak even a stringified preview.
if (Array.isArray(out) && out.some(isHideOutputMarker)) {
return [];
}
if (out === undefined || out === null) return [];
if (typeof out === 'string') {
if (out.length === 0) return [];
return [{ type: 'content', content: { type: 'text', text: out } }];
}
// Best-effort stringify for object/array outputs.
let text: string;
try {
text = JSON.stringify(out);
} catch {
text = '[object]';
}
if (!text) return [];
return [{ type: 'content', content: { type: 'text', text } }];
}