@mono-agent/agent-runtime 0.21.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { formatSkillBodyWithPathNote } from "../prompt/skill-index.js";
|
|
32
32
|
import { MAX_TOOL_RESULT_BYTES, summarisePayload, wrapToolsWithBloatGuard } from "../tool-bloat.js";
|
|
33
33
|
import { wrapToolsWithApprovalGate } from "../approval.js";
|
|
34
|
+
import { normalizeImageForModel } from "./shared/image.js";
|
|
34
35
|
import { isInsidePath } from "./shared/path-resolver.js";
|
|
35
36
|
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
36
37
|
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
@@ -850,7 +851,27 @@ async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine,
|
|
|
850
851
|
}
|
|
851
852
|
}
|
|
852
853
|
|
|
853
|
-
|
|
854
|
+
// MCP servers hand back screenshots at whatever size they captured — the
|
|
855
|
+
// Playwright browser tools in particular follow browser_resize, so a wide desktop
|
|
856
|
+
// capture arrives well past the provider ceiling while staying tiny in bytes and
|
|
857
|
+
// sailing through the byte cap below. Normalize pixels before measuring bytes: a
|
|
858
|
+
// shrunk screenshot may now fit the inline budget instead of being dropped for a
|
|
859
|
+
// text pointer.
|
|
860
|
+
async function normalizeMcpImage(data, mimeType) {
|
|
861
|
+
const raw = typeof data === "string" ? data : String(data ?? "");
|
|
862
|
+
const source = Buffer.from(raw, "base64");
|
|
863
|
+
if (source.length === 0) return { data: raw, mimeType };
|
|
864
|
+
const normalized = await normalizeImageForModel(source, mimeType);
|
|
865
|
+
// Shrinking is best-effort. An undecodable payload keeps the block exactly as
|
|
866
|
+
// the server sent it: losing the tool result would be worse than an oversized one.
|
|
867
|
+
if (normalized.reason !== undefined) return { data: raw, mimeType };
|
|
868
|
+
// Images already within the ceiling come back as the same buffer. Return the
|
|
869
|
+
// original base64 so the common path stays byte-identical and re-encodes nothing.
|
|
870
|
+
if (normalized.data === source) return { data: raw, mimeType };
|
|
871
|
+
return { data: normalized.data.toString("base64"), mimeType: normalized.mimeType };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export async function coerceMcpContent(out, {
|
|
854
875
|
textLimit = MCP_TEXT_RESULT_LIMIT,
|
|
855
876
|
imageInlineMaxBytes = MCP_IMAGE_INLINE_MAX_BYTES,
|
|
856
877
|
persistArtifact = null,
|
|
@@ -859,15 +880,16 @@ export function coerceMcpContent(out, {
|
|
|
859
880
|
onTruncate = null,
|
|
860
881
|
} = {}) {
|
|
861
882
|
if (Array.isArray(out?.content) && out.content.length) {
|
|
862
|
-
return out.content.map((part) => {
|
|
883
|
+
return await Promise.all(out.content.map(async (part) => {
|
|
863
884
|
if (part.type === "text") return { type: "text", text: truncateMcpText(part.text || "", textLimit).text };
|
|
864
885
|
if (part.type === "image") {
|
|
865
|
-
const
|
|
886
|
+
const image = await normalizeMcpImage(part.data, part.mimeType || part.mime_type || "image/png");
|
|
887
|
+
const bytes = base64Bytes(image.data);
|
|
866
888
|
if (bytes > imageInlineMaxBytes) {
|
|
867
889
|
const summary = summarisePayload(toolName, [{
|
|
868
890
|
type: "image",
|
|
869
|
-
data:
|
|
870
|
-
mimeType:
|
|
891
|
+
data: image.data,
|
|
892
|
+
mimeType: image.mimeType,
|
|
871
893
|
}], persistArtifact, { maxBytes: imageInlineMaxBytes, toolUseId });
|
|
872
894
|
if (summary.truncated && typeof onTruncate === "function") {
|
|
873
895
|
try {
|
|
@@ -887,21 +909,24 @@ export function coerceMcpContent(out, {
|
|
|
887
909
|
}
|
|
888
910
|
return {
|
|
889
911
|
type: "image",
|
|
890
|
-
data:
|
|
891
|
-
mimeType:
|
|
912
|
+
data: image.data,
|
|
913
|
+
mimeType: image.mimeType,
|
|
892
914
|
};
|
|
893
915
|
}
|
|
894
916
|
return { type: "text", text: truncateMcpText(JSON.stringify(part), textLimit).text };
|
|
895
|
-
});
|
|
917
|
+
}));
|
|
896
918
|
}
|
|
897
919
|
return [{ type: "text", text: truncateMcpText(JSON.stringify(out || {}), textLimit).text }];
|
|
898
920
|
}
|
|
899
921
|
|
|
900
|
-
|
|
922
|
+
// Text-only. Images are measured after dimension normalization inside
|
|
923
|
+
// coerceMcpContent, which reports a real truncation through onTruncate; judging
|
|
924
|
+
// the raw part here would flag a screenshot that shrinking brought back under budget.
|
|
925
|
+
function mcpContentWasTruncated(out, { textLimit = MCP_TEXT_RESULT_LIMIT } = {}) {
|
|
901
926
|
if (Array.isArray(out?.content) && out.content.length) {
|
|
902
927
|
return out.content.some((part) => {
|
|
903
928
|
if (part.type === "text") return truncateMcpText(part.text || "", textLimit).truncated;
|
|
904
|
-
if (part.type === "image") return
|
|
929
|
+
if (part.type === "image") return false;
|
|
905
930
|
return truncateMcpText(JSON.stringify(part), textLimit).truncated;
|
|
906
931
|
});
|
|
907
932
|
}
|
|
@@ -1116,7 +1141,7 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
1116
1141
|
}
|
|
1117
1142
|
const imageTruncations = [];
|
|
1118
1143
|
return {
|
|
1119
|
-
content: coerceMcpContent(out, {
|
|
1144
|
+
content: await coerceMcpContent(out, {
|
|
1120
1145
|
textLimit,
|
|
1121
1146
|
imageInlineMaxBytes,
|
|
1122
1147
|
persistArtifact,
|
|
@@ -1136,7 +1161,7 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
1136
1161
|
// throwing away the bounded content or structuredContent below.
|
|
1137
1162
|
...(out?.isError === true ? { mcp_result_is_error: true } : {}),
|
|
1138
1163
|
mcp_call_duration_ms: mcpCallDurationMs,
|
|
1139
|
-
result_truncated: mcpContentWasTruncated(out, { textLimit
|
|
1164
|
+
result_truncated: mcpContentWasTruncated(out, { textLimit }) || imageTruncations.length > 0,
|
|
1140
1165
|
raw: compactRawMcpResult(out),
|
|
1141
1166
|
...(imageTruncations.length ? {
|
|
1142
1167
|
tool_payload_truncated: true,
|
package/src/agent/tools/read.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { extname } from "node:path";
|
|
3
|
-
import { decode as decodeBmp } from "bmp-ts";
|
|
4
|
-
import sharp from "sharp";
|
|
5
3
|
import {
|
|
6
4
|
DEFAULT_MAX_READ_CHARS,
|
|
7
5
|
DEFAULT_READ_LINES,
|
|
8
6
|
MAX_READ_LINES,
|
|
9
7
|
} from "./shared/constants.js";
|
|
10
8
|
import { boundedInt, rememberRead, trimLine } from "./shared/dedup.js";
|
|
9
|
+
import { normalizeImageForModel } from "./shared/image.js";
|
|
11
10
|
import { capChars } from "./shared/output-truncation.js";
|
|
12
11
|
import {
|
|
13
12
|
isPathAllowed,
|
|
@@ -31,17 +30,6 @@ const IMAGE_MIME_BY_EXT = {
|
|
|
31
30
|
".bmp": "image/bmp",
|
|
32
31
|
};
|
|
33
32
|
|
|
34
|
-
// Anthropic rejects images with an edge longer than 8,000 px. Normalize Read
|
|
35
|
-
// results to that shared provider-safe ceiling before the tool-result byte cap
|
|
36
|
-
// runs, while leaving the source file untouched.
|
|
37
|
-
const MAX_INLINE_IMAGE_EDGE_PX = 8_000;
|
|
38
|
-
const ANIMATED_IMAGE_MIME_TYPES = new Set(["image/gif", "image/webp"]);
|
|
39
|
-
const OUTPUT_MIME_BY_FORMAT = {
|
|
40
|
-
png: "image/png",
|
|
41
|
-
jpeg: "image/jpeg",
|
|
42
|
-
gif: "image/gif",
|
|
43
|
-
webp: "image/webp",
|
|
44
|
-
};
|
|
45
33
|
const PROTECTED_READ_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
46
34
|
const PROTECTED_READ_SOURCE = String.raw`
|
|
47
35
|
"use strict";
|
|
@@ -49,69 +37,6 @@ const { readFileSync } = require("node:fs");
|
|
|
49
37
|
process.stdout.write(readFileSync(process.argv[1]).toString("base64"));
|
|
50
38
|
`;
|
|
51
39
|
|
|
52
|
-
/**
|
|
53
|
-
* @param {Buffer} source
|
|
54
|
-
* @param {string} filePath
|
|
55
|
-
* @param {string} imageMime
|
|
56
|
-
*/
|
|
57
|
-
async function readImageForModel(source, filePath, imageMime) {
|
|
58
|
-
const inputOptions = { animated: ANIMATED_IMAGE_MIME_TYPES.has(imageMime) };
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
let width;
|
|
62
|
-
let height;
|
|
63
|
-
let createPipeline;
|
|
64
|
-
|
|
65
|
-
if (imageMime === "image/bmp") {
|
|
66
|
-
// The prebuilt Sharp binaries do not include a BMP loader. Decode to raw
|
|
67
|
-
// RGBA first, then let Sharp handle the provider-safe resize and PNG output.
|
|
68
|
-
const decoded = decodeBmp(source, { toRGBA: true });
|
|
69
|
-
width = decoded.width;
|
|
70
|
-
height = Math.abs(decoded.height);
|
|
71
|
-
createPipeline = () => sharp(decoded.data, {
|
|
72
|
-
raw: { width, height, channels: 4 },
|
|
73
|
-
});
|
|
74
|
-
} else {
|
|
75
|
-
const metadata = await sharp(source, inputOptions).metadata();
|
|
76
|
-
width = metadata.width;
|
|
77
|
-
// Sharp exposes animated images as a vertical stack internally. Providers
|
|
78
|
-
// care about the dimensions of each frame, not the height of that stack.
|
|
79
|
-
height = metadata.pageHeight ?? metadata.height;
|
|
80
|
-
createPipeline = () => sharp(source, inputOptions);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
|
|
84
|
-
throw new Error("could not determine positive pixel dimensions");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (width <= MAX_INLINE_IMAGE_EDGE_PX && height <= MAX_INLINE_IMAGE_EDGE_PX) {
|
|
88
|
-
return { data: source, mimeType: imageMime };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
let pipeline = createPipeline()
|
|
92
|
-
.autoOrient()
|
|
93
|
-
.resize({
|
|
94
|
-
width: MAX_INLINE_IMAGE_EDGE_PX,
|
|
95
|
-
height: MAX_INLINE_IMAGE_EDGE_PX,
|
|
96
|
-
fit: "inside",
|
|
97
|
-
withoutEnlargement: true,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
// Sharp cannot emit BMP, so resized BMP input becomes lossless PNG.
|
|
101
|
-
if (imageMime === "image/bmp") pipeline = pipeline.png();
|
|
102
|
-
|
|
103
|
-
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
|
104
|
-
const mimeType = OUTPUT_MIME_BY_FORMAT[info.format];
|
|
105
|
-
if (mimeType === undefined) {
|
|
106
|
-
throw new Error(`unsupported normalized image format: ${info.format}`);
|
|
107
|
-
}
|
|
108
|
-
return { data, mimeType };
|
|
109
|
-
} catch (error) {
|
|
110
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
111
|
-
return { error: `Error: Unable to read image ${filePath}: ${reason}` };
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
40
|
/**
|
|
116
41
|
* @param {{file_path: string, offset?: number, start_line?: number, limit?: number, max_output_chars?: number, workdir?: string}} params
|
|
117
42
|
* @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
@@ -152,8 +77,8 @@ export async function readToolImpl({ file_path, offset = 0, start_line, limit, m
|
|
|
152
77
|
// capped by the shared tool-result bloat guard.
|
|
153
78
|
const imageMime = IMAGE_MIME_BY_EXT[extname(target).toLowerCase()];
|
|
154
79
|
if (imageMime !== undefined) {
|
|
155
|
-
const image = await
|
|
156
|
-
if (image.
|
|
80
|
+
const image = await normalizeImageForModel(source, imageMime);
|
|
81
|
+
if (image.reason !== undefined) return `Error: Unable to read image ${file_path}: ${image.reason}`;
|
|
157
82
|
return { kind: "image", data: image.data.toString("base64"), mimeType: image.mimeType };
|
|
158
83
|
}
|
|
159
84
|
const content = source.toString("utf8");
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { decode as decodeBmp } from "bmp-ts";
|
|
2
|
+
import sharp from "sharp";
|
|
3
|
+
|
|
4
|
+
// Anthropic allows an 8,000 px edge per image, but drops to 2,000 px per edge as
|
|
5
|
+
// soon as a single request carries more than 20 image blocks — and images nested
|
|
6
|
+
// in tool results, plus every image replayed from an earlier turn, count toward
|
|
7
|
+
// that threshold. A screenshot-heavy conversation crosses 20 easily, and one
|
|
8
|
+
// oversized image then rejects the whole request with an invalid_request_error
|
|
9
|
+
// that no retry or model failover can clear. Normalize every inline image to the
|
|
10
|
+
// stricter ceiling so the count never matters.
|
|
11
|
+
//
|
|
12
|
+
// 2,000 px sits above the standard tier's 1,568 px native long edge and just under
|
|
13
|
+
// the 2,576 px high-resolution tier, so legibility is effectively unchanged: the
|
|
14
|
+
// provider would downscale past this point for token accounting anyway.
|
|
15
|
+
export const MAX_INLINE_IMAGE_EDGE_PX = 2_000;
|
|
16
|
+
|
|
17
|
+
const ANIMATED_IMAGE_MIME_TYPES = new Set(["image/gif", "image/webp"]);
|
|
18
|
+
const OUTPUT_MIME_BY_FORMAT = {
|
|
19
|
+
png: "image/png",
|
|
20
|
+
jpeg: "image/jpeg",
|
|
21
|
+
gif: "image/gif",
|
|
22
|
+
webp: "image/webp",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cap an image's pixel dimensions to the provider-safe ceiling. Resolves to
|
|
27
|
+
* `{ data, mimeType }` on success — with `data` being the source buffer itself when
|
|
28
|
+
* it already fits, so callers can skip a pointless re-encode — or `{ reason }` when
|
|
29
|
+
* the bytes could not be decoded. Never mutates the source.
|
|
30
|
+
*
|
|
31
|
+
* @param {Buffer} source
|
|
32
|
+
* @param {string} imageMime
|
|
33
|
+
*/
|
|
34
|
+
export async function normalizeImageForModel(source, imageMime) {
|
|
35
|
+
const inputOptions = { animated: ANIMATED_IMAGE_MIME_TYPES.has(imageMime) };
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
let width;
|
|
39
|
+
let height;
|
|
40
|
+
let createPipeline;
|
|
41
|
+
|
|
42
|
+
if (imageMime === "image/bmp") {
|
|
43
|
+
// The prebuilt Sharp binaries do not include a BMP loader. Decode to raw
|
|
44
|
+
// RGBA first, then let Sharp handle the provider-safe resize and PNG output.
|
|
45
|
+
const decoded = decodeBmp(source, { toRGBA: true });
|
|
46
|
+
width = decoded.width;
|
|
47
|
+
height = Math.abs(decoded.height);
|
|
48
|
+
createPipeline = () => sharp(decoded.data, {
|
|
49
|
+
raw: { width, height, channels: 4 },
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
const metadata = await sharp(source, inputOptions).metadata();
|
|
53
|
+
width = metadata.width;
|
|
54
|
+
// Sharp exposes animated images as a vertical stack internally. Providers
|
|
55
|
+
// care about the dimensions of each frame, not the height of that stack.
|
|
56
|
+
height = metadata.pageHeight ?? metadata.height;
|
|
57
|
+
createPipeline = () => sharp(source, inputOptions);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
|
|
61
|
+
throw new Error("could not determine positive pixel dimensions");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (width <= MAX_INLINE_IMAGE_EDGE_PX && height <= MAX_INLINE_IMAGE_EDGE_PX) {
|
|
65
|
+
return { data: source, mimeType: imageMime };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let pipeline = createPipeline()
|
|
69
|
+
.autoOrient()
|
|
70
|
+
.resize({
|
|
71
|
+
width: MAX_INLINE_IMAGE_EDGE_PX,
|
|
72
|
+
height: MAX_INLINE_IMAGE_EDGE_PX,
|
|
73
|
+
fit: "inside",
|
|
74
|
+
withoutEnlargement: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Sharp cannot emit BMP, so resized BMP input becomes lossless PNG.
|
|
78
|
+
if (imageMime === "image/bmp") pipeline = pipeline.png();
|
|
79
|
+
|
|
80
|
+
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
|
81
|
+
const mimeType = OUTPUT_MIME_BY_FORMAT[info.format];
|
|
82
|
+
if (mimeType === undefined) {
|
|
83
|
+
throw new Error(`unsupported normalized image format: ${info.format}`);
|
|
84
|
+
}
|
|
85
|
+
return { data, mimeType };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return { reason: error instanceof Error ? error.message : String(error) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cap an image's pixel dimensions to the provider-safe ceiling. Resolves to
|
|
3
|
+
* `{ data, mimeType }` on success — with `data` being the source buffer itself when
|
|
4
|
+
* it already fits, so callers can skip a pointless re-encode — or `{ reason }` when
|
|
5
|
+
* the bytes could not be decoded. Never mutates the source.
|
|
6
|
+
*
|
|
7
|
+
* @param {Buffer} source
|
|
8
|
+
* @param {string} imageMime
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeImageForModel(source: Buffer, imageMime: string): Promise<{
|
|
11
|
+
data: Buffer<ArrayBufferLike>;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
reason?: undefined;
|
|
14
|
+
} | {
|
|
15
|
+
data: Buffer<ArrayBuffer>;
|
|
16
|
+
mimeType: any;
|
|
17
|
+
reason?: undefined;
|
|
18
|
+
} | {
|
|
19
|
+
reason: string;
|
|
20
|
+
data?: undefined;
|
|
21
|
+
mimeType?: undefined;
|
|
22
|
+
}>;
|
|
23
|
+
export const MAX_INLINE_IMAGE_EDGE_PX: 2000;
|