@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Agent runtime backed by the Pi SDK provider catalog",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
@@ -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
- export function coerceMcpContent(out, {
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 bytes = base64Bytes(part.data);
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: part.data,
870
- mimeType: part.mimeType || part.mime_type || "image/png",
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: part.data,
891
- mimeType: part.mimeType || part.mime_type || "image/png",
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
- function mcpContentWasTruncated(out, { textLimit = MCP_TEXT_RESULT_LIMIT, imageInlineMaxBytes = MCP_IMAGE_INLINE_MAX_BYTES } = {}) {
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 base64Bytes(part.data) > imageInlineMaxBytes;
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, imageInlineMaxBytes }),
1164
+ result_truncated: mcpContentWasTruncated(out, { textLimit }) || imageTruncations.length > 0,
1140
1165
  raw: compactRawMcpResult(out),
1141
1166
  ...(imageTruncations.length ? {
1142
1167
  tool_payload_truncated: true,
@@ -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 readImageForModel(source, file_path, imageMime);
156
- if (image.error !== undefined) return image.error;
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
+ }
@@ -79,7 +79,7 @@ export function coerceMcpContent(out: any, { textLimit, imageInlineMaxBytes, per
79
79
  toolName?: string;
80
80
  toolUseId?: any;
81
81
  onTruncate?: any;
82
- }): any;
82
+ }): Promise<any[]>;
83
83
  /**
84
84
  * @param {any} mcpConfig
85
85
  * @param {Set<any>} [reservedNames]
@@ -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;