@myagentroam/agent 0.9.158 → 0.9.159

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.
@@ -36,6 +36,15 @@ export interface ModelAttemptDiagnostic {
36
36
  connectionId?: string;
37
37
  connectionReused?: boolean;
38
38
  proxyType?: 'DIRECT' | 'SOCKS5';
39
+ /** Actual serialized WebSocket request size, including inline image Base64. */
40
+ requestBytes?: number;
41
+ imageCount?: number;
42
+ /** Decoded byte count of inline input images; external URLs do not add bytes. */
43
+ imageBytes?: number;
44
+ requestFrameCount?: number;
45
+ sentFrameCount?: number;
46
+ webSocketPhase?: 'CONNECT' | 'SEND' | 'RECEIVE';
47
+ webSocketCloseCode?: number;
39
48
  durationMs?: number;
40
49
  errorCode?: string;
41
50
  errorMessage?: string;
@@ -25,6 +25,7 @@ interface PreparedResponsesRequest {
25
25
  readonly body: ResponsesRequestBody;
26
26
  readonly continuation: boolean;
27
27
  }
28
+ type ResponsesWebSocketDiagnostic = Pick<ModelAttemptDiagnostic, 'requestBytes' | 'imageCount' | 'imageBytes' | 'requestFrameCount' | 'sentFrameCount' | 'webSocketPhase' | 'webSocketCloseCode'>;
28
29
  export declare class OpenAiResponsesAdapter implements ModelAdapter {
29
30
  #private;
30
31
  constructor(configuration: MarAgentModelConfiguration);
@@ -51,7 +52,8 @@ declare class OpenAiResponsesTurnSession implements ModelTurnSession {
51
52
  fullRequestBody(): ResponsesRequestBody;
52
53
  currentTurnState(): string | undefined;
53
54
  beginAttempt(): void;
54
- connectionDiagnostic(): Pick<ModelAttemptDiagnostic, 'connectionId' | 'connectionReused' | 'proxyType'>;
55
+ captureWebSocketDiagnostic(diagnostic: ResponsesWebSocketDiagnostic): void;
56
+ connectionDiagnostic(): Pick<ModelAttemptDiagnostic, 'connectionId' | 'connectionReused' | 'proxyType'> & ResponsesWebSocketDiagnostic;
55
57
  captureTurnState(value: unknown): void;
56
58
  withTurnStateMetadata(body: ResponsesRequestBody): ResponsesRequestBody;
57
59
  invalidateWebSocket(): void;
@@ -8,6 +8,7 @@ import { isRetryableUpstreamOverloadCode, number, parseSse, record, text, observ
8
8
  import { connectSocks5Target, fetchModelResponse, isRetryableModelHttpStatus, isRetryableModelTransportError, modelRetryMaxAttempts, isRetryableModelRequestError, modelRetryDelay, waitForModelRetry } from './http.js';
9
9
  import { acquireModelCredential, appendModelCredentialHeaders } from './runtime-credential.js';
10
10
  const WEBSOCKET_IDLE_TIMEOUT_MS = 300_000;
11
+ const WEBSOCKET_SEND_FRAME_BYTES = 1024 * 1024;
11
12
  export const OPENAI_RESPONSES_EARLY_EOF_REASON = 'OPENAI_RESPONSES_EARLY_EOF';
12
13
  export const OPENAI_RESPONSES_WEBSOCKET_CONNECTION_LIMIT_REASON = 'OPENAI_RESPONSES_WEBSOCKET_CONNECTION_LIMIT';
13
14
  const X_CODEX_TURN_STATE = 'x-codex-turn-state';
@@ -624,6 +625,7 @@ class OpenAiResponsesTurnSession {
624
625
  #fullBody;
625
626
  #completedResponseId;
626
627
  #connectionDiagnostic;
628
+ #webSocketDiagnostic;
627
629
  constructor(adapter, state, cacheLease) {
628
630
  this.adapter = adapter;
629
631
  this.state = state;
@@ -735,9 +737,13 @@ class OpenAiResponsesTurnSession {
735
737
  }
736
738
  beginAttempt() {
737
739
  this.#connectionDiagnostic = undefined;
740
+ this.#webSocketDiagnostic = undefined;
741
+ }
742
+ captureWebSocketDiagnostic(diagnostic) {
743
+ this.#webSocketDiagnostic = diagnostic;
738
744
  }
739
745
  connectionDiagnostic() {
740
- return this.#connectionDiagnostic ?? {};
746
+ return { ...this.#connectionDiagnostic, ...this.#webSocketDiagnostic };
741
747
  }
742
748
  captureTurnState(value) {
743
749
  if (this.#turnState !== undefined || typeof value !== 'string')
@@ -1010,6 +1016,10 @@ async function* parseOpenAiResponsesSse(response, signal, continuation) {
1010
1016
  }
1011
1017
  }
1012
1018
  async function* webSocketResponseEvents(input) {
1019
+ const body = input.turn.withTurnStateMetadata(input.body);
1020
+ const payload = Buffer.from(JSON.stringify({ type: 'response.create', ...body }), 'utf8');
1021
+ const diagnostic = webSocketRequestDiagnostic(body, payload.length);
1022
+ input.turn.captureWebSocketDiagnostic(diagnostic);
1013
1023
  const socket = await input.turn.webSocketForRequest({
1014
1024
  configuration: input.configuration,
1015
1025
  promptCacheKey: input.promptCacheKey,
@@ -1018,32 +1028,75 @@ async function* webSocketResponseEvents(input) {
1018
1028
  continuation: input.continuation,
1019
1029
  credentialAcquired: input.credentialAcquired
1020
1030
  });
1021
- const body = input.turn.withTurnStateMetadata(input.body);
1022
- await sendWebSocketMessage(socket, JSON.stringify({ type: 'response.create', ...body }), input.signal);
1023
- let terminal = false;
1024
- for await (const item of webSocketMessages(socket, input.signal)) {
1025
- const event = record(item);
1026
- const type = text(event.type);
1027
- input.turn.captureTurnState(turnStateFromEvent(event));
1028
- if (type !== 'response.metadata' && type !== 'codex.response.metadata')
1029
- yield item;
1030
- if (type === 'response.completed' ||
1031
- type === 'response.incomplete' ||
1032
- type === 'response.failed' ||
1033
- type === 'error') {
1034
- terminal = true;
1035
- return;
1031
+ const messages = webSocketMessages(socket, input.signal, (code) => {
1032
+ diagnostic.webSocketCloseCode = code;
1033
+ });
1034
+ try {
1035
+ diagnostic.webSocketPhase = 'SEND';
1036
+ await sendWebSocketMessage(socket, payload, input.signal, () => {
1037
+ diagnostic.sentFrameCount = (diagnostic.sentFrameCount ?? 0) + 1;
1038
+ });
1039
+ diagnostic.webSocketPhase = 'RECEIVE';
1040
+ let terminal = false;
1041
+ for await (const item of messages) {
1042
+ const event = record(item);
1043
+ const type = text(event.type);
1044
+ input.turn.captureTurnState(turnStateFromEvent(event));
1045
+ if (type !== 'response.metadata' && type !== 'codex.response.metadata')
1046
+ yield item;
1047
+ if (type === 'response.completed' ||
1048
+ type === 'response.incomplete' ||
1049
+ type === 'response.failed' ||
1050
+ type === 'error') {
1051
+ terminal = true;
1052
+ return;
1053
+ }
1036
1054
  }
1055
+ if (!terminal && !input.signal.aborted)
1056
+ throw new MarAgentError('MAR_AGENT_MODEL_PROTOCOL_ERROR', 'OpenAI Responses WebSocket closed before a terminal event.', {
1057
+ details: {
1058
+ reason: OPENAI_RESPONSES_EARLY_EOF_REASON,
1059
+ transport: 'WEBSOCKET',
1060
+ continuation: input.continuation
1061
+ },
1062
+ retryable: true
1063
+ });
1064
+ }
1065
+ finally {
1066
+ messages.dispose();
1037
1067
  }
1038
- if (!terminal && !input.signal.aborted)
1039
- throw new MarAgentError('MAR_AGENT_MODEL_PROTOCOL_ERROR', 'OpenAI Responses WebSocket closed before a terminal event.', {
1040
- details: {
1041
- reason: OPENAI_RESPONSES_EARLY_EOF_REASON,
1042
- transport: 'WEBSOCKET',
1043
- continuation: input.continuation
1044
- },
1045
- retryable: true
1046
- });
1068
+ }
1069
+ function webSocketRequestDiagnostic(body, requestBytes) {
1070
+ let imageCount = 0;
1071
+ let imageBytes = 0;
1072
+ for (const item of body.input) {
1073
+ const entry = record(item);
1074
+ const parts = Array.isArray(entry.content)
1075
+ ? entry.content
1076
+ : Array.isArray(entry.output)
1077
+ ? entry.output
1078
+ : [];
1079
+ for (const value of parts) {
1080
+ const part = record(value);
1081
+ if (part.type !== 'input_image')
1082
+ continue;
1083
+ imageCount++;
1084
+ const url = text(part.image_url);
1085
+ if (url === undefined)
1086
+ continue;
1087
+ const marker = url.indexOf(';base64,');
1088
+ if (url.startsWith('data:') && marker >= 0)
1089
+ imageBytes += Buffer.byteLength(url.slice(marker + 8), 'base64');
1090
+ }
1091
+ }
1092
+ return {
1093
+ requestBytes,
1094
+ imageCount,
1095
+ imageBytes,
1096
+ requestFrameCount: Math.ceil(requestBytes / WEBSOCKET_SEND_FRAME_BYTES),
1097
+ sentFrameCount: 0,
1098
+ webSocketPhase: 'CONNECT'
1099
+ };
1047
1100
  }
1048
1101
  async function waitForWebSocketOpen(socket, signal) {
1049
1102
  if (signal.aborted) {
@@ -1101,29 +1154,55 @@ async function waitForWebSocketOpen(socket, signal) {
1101
1154
  socket.once('unexpected-response', unexpectedResponse);
1102
1155
  });
1103
1156
  }
1104
- async function sendWebSocketMessage(socket, value, signal) {
1105
- if (signal.aborted)
1106
- throw signal.reason;
1107
- await new Promise((resolve, reject) => {
1108
- const cleanup = () => signal.removeEventListener('abort', abort);
1109
- const abort = () => {
1110
- socket.terminate();
1111
- cleanup();
1112
- reject(signal.reason);
1113
- };
1114
- signal.addEventListener('abort', abort, { once: true });
1115
- socket.send(value, (error) => {
1116
- cleanup();
1117
- if (error)
1118
- reject(webSocketReceiveError(error));
1119
- else if (signal.aborted)
1157
+ async function sendWebSocketMessage(socket, payload, signal, frameSent) {
1158
+ for (let offset = 0; offset < payload.length; offset += WEBSOCKET_SEND_FRAME_BYTES) {
1159
+ if (signal.aborted)
1160
+ throw signal.reason;
1161
+ const end = Math.min(payload.length, offset + WEBSOCKET_SEND_FRAME_BYTES);
1162
+ await new Promise((resolve, reject) => {
1163
+ let settled = false;
1164
+ const cleanup = () => {
1165
+ settled = true;
1166
+ signal.removeEventListener('abort', abort);
1167
+ socket.off('error', error);
1168
+ socket.off('close', close);
1169
+ };
1170
+ const abort = () => {
1171
+ cleanup();
1172
+ socket.terminate();
1120
1173
  reject(signal.reason);
1121
- else
1122
- resolve();
1174
+ };
1175
+ const error = (cause) => {
1176
+ cleanup();
1177
+ reject(webSocketReceiveError(cause));
1178
+ };
1179
+ const close = () => error(new Error('WebSocket closed while sending the request.'));
1180
+ signal.addEventListener('abort', abort, { once: true });
1181
+ socket.once('error', error);
1182
+ socket.once('close', close);
1183
+ try {
1184
+ socket.send(payload.subarray(offset, end), { binary: false, fin: end === payload.length }, (cause) => {
1185
+ if (settled)
1186
+ return;
1187
+ cleanup();
1188
+ if (cause)
1189
+ reject(webSocketReceiveError(cause));
1190
+ else if (signal.aborted)
1191
+ reject(signal.reason);
1192
+ else {
1193
+ frameSent();
1194
+ resolve();
1195
+ }
1196
+ });
1197
+ }
1198
+ catch (cause) {
1199
+ cleanup();
1200
+ reject(webSocketReceiveError(cause));
1201
+ }
1123
1202
  });
1124
- });
1203
+ }
1125
1204
  }
1126
- async function* webSocketMessages(socket, signal) {
1205
+ function webSocketMessages(socket, signal, closed) {
1127
1206
  const queue = [];
1128
1207
  let resume;
1129
1208
  const push = (item) => {
@@ -1136,7 +1215,10 @@ async function* webSocketMessages(socket, signal) {
1136
1215
  value,
1137
1216
  observedAt: performance.now()
1138
1217
  });
1139
- const close = () => push({ type: 'closed' });
1218
+ const close = (code) => {
1219
+ closed(code);
1220
+ push({ type: 'closed' });
1221
+ };
1140
1222
  const error = (cause) => push({
1141
1223
  type: 'error',
1142
1224
  error: webSocketReceiveError(cause)
@@ -1153,49 +1235,57 @@ async function* webSocketMessages(socket, signal) {
1153
1235
  socket.on('close', close);
1154
1236
  socket.on('error', error);
1155
1237
  signal.addEventListener('abort', abort, { once: true });
1156
- try {
1157
- let idleDeadline = Date.now() + WEBSOCKET_IDLE_TIMEOUT_MS;
1158
- while (true) {
1159
- if (Date.now() >= idleDeadline)
1160
- throw webSocketIdleError(socket);
1161
- if (queue.length === 0)
1162
- await new Promise((resolve, reject) => {
1163
- const timer = setTimeout(() => {
1164
- resume = undefined;
1165
- reject(webSocketIdleError(socket));
1166
- }, Math.max(0, idleDeadline - Date.now()));
1167
- resume = () => {
1168
- clearTimeout(timer);
1169
- resolve();
1170
- };
1171
- });
1172
- const item = queue.shift();
1173
- if (item === undefined)
1174
- continue;
1175
- if (item.type === 'closed')
1176
- return;
1177
- if (item.type === 'error')
1178
- throw item.error;
1179
- const text = await webSocketMessageText(item.value);
1180
- if (Buffer.byteLength(text) > 16 * 1024 * 1024)
1181
- throw new MarAgentError('MAR_AGENT_MODEL_PROTOCOL_ERROR', 'Model response exceeded the size limit.');
1182
- try {
1183
- const event = JSON.parse(text);
1184
- if (!isWebSocketNonProgressEvent(event))
1185
- idleDeadline = Date.now() + WEBSOCKET_IDLE_TIMEOUT_MS;
1186
- yield observeModelStreamEvent(event, item.observedAt);
1187
- }
1188
- catch (cause) {
1189
- throw new MarAgentError('MAR_AGENT_MODEL_PROTOCOL_ERROR', 'Model WebSocket contained invalid JSON.', { cause });
1190
- }
1191
- }
1192
- }
1193
- finally {
1238
+ const dispose = () => {
1194
1239
  signal.removeEventListener('abort', abort);
1195
1240
  socket.off('message', message);
1196
1241
  socket.off('close', close);
1197
1242
  socket.off('error', error);
1198
- }
1243
+ };
1244
+ return {
1245
+ dispose,
1246
+ async *[Symbol.asyncIterator]() {
1247
+ try {
1248
+ let idleDeadline = Date.now() + WEBSOCKET_IDLE_TIMEOUT_MS;
1249
+ while (true) {
1250
+ if (Date.now() >= idleDeadline)
1251
+ throw webSocketIdleError(socket);
1252
+ if (queue.length === 0)
1253
+ await new Promise((resolve, reject) => {
1254
+ const timer = setTimeout(() => {
1255
+ resume = undefined;
1256
+ reject(webSocketIdleError(socket));
1257
+ }, Math.max(0, idleDeadline - Date.now()));
1258
+ resume = () => {
1259
+ clearTimeout(timer);
1260
+ resolve();
1261
+ };
1262
+ });
1263
+ const item = queue.shift();
1264
+ if (item === undefined)
1265
+ continue;
1266
+ if (item.type === 'closed')
1267
+ return;
1268
+ if (item.type === 'error')
1269
+ throw item.error;
1270
+ const text = await webSocketMessageText(item.value);
1271
+ if (Buffer.byteLength(text) > 16 * 1024 * 1024)
1272
+ throw new MarAgentError('MAR_AGENT_MODEL_PROTOCOL_ERROR', 'Model response exceeded the size limit.');
1273
+ try {
1274
+ const event = JSON.parse(text);
1275
+ if (!isWebSocketNonProgressEvent(event))
1276
+ idleDeadline = Date.now() + WEBSOCKET_IDLE_TIMEOUT_MS;
1277
+ yield observeModelStreamEvent(event, item.observedAt);
1278
+ }
1279
+ catch (cause) {
1280
+ throw new MarAgentError('MAR_AGENT_MODEL_PROTOCOL_ERROR', 'Model WebSocket contained invalid JSON.', { cause });
1281
+ }
1282
+ }
1283
+ }
1284
+ finally {
1285
+ dispose();
1286
+ }
1287
+ }
1288
+ };
1199
1289
  }
1200
1290
  function webSocketIdleError(socket) {
1201
1291
  terminateWebSocket(socket);
@@ -27,17 +27,25 @@ export class ViewImageTool {
27
27
  const path = await this.paths.resolve(input.path);
28
28
  const handle = await open(path, 'r');
29
29
  try {
30
- const stat = await handle.stat();
30
+ const stat = await handle.stat({ bigint: true });
31
31
  if (!stat.isFile())
32
32
  throw new MarAgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Path is not a regular file.');
33
- if (stat.size > TOOL_EXECUTION_LIMITS.viewImageMaxBytes)
33
+ if (stat.size > BigInt(TOOL_EXECUTION_LIMITS.viewImageMaxBytes))
34
34
  throw new MarAgentError('MAR_AGENT_TOOL_OUTPUT_LIMIT', `Image exceeds the ${TOOL_EXECUTION_LIMITS.viewImageMaxBytes}-byte limit.`);
35
35
  signal?.throwIfAborted();
36
36
  const data = await handle.readFile();
37
37
  signal?.throwIfAborted();
38
38
  if (data.byteLength > TOOL_EXECUTION_LIMITS.viewImageMaxBytes)
39
39
  throw new MarAgentError('MAR_AGENT_TOOL_OUTPUT_LIMIT', `Image exceeds the ${TOOL_EXECUTION_LIMITS.viewImageMaxBytes}-byte limit.`);
40
+ const after = await handle.stat({ bigint: true });
41
+ signal?.throwIfAborted();
42
+ if (stat.size !== after.size ||
43
+ after.size !== BigInt(data.byteLength) ||
44
+ stat.mtimeNs !== after.mtimeNs ||
45
+ stat.ctimeNs !== after.ctimeNs)
46
+ return incompleteImage();
40
47
  const mimeType = detectImageMime(data);
48
+ validateImageContainer(data, mimeType);
41
49
  const metadata = {
42
50
  path: input.path,
43
51
  mimeType,
@@ -80,3 +88,101 @@ export function detectImageMime(data) {
80
88
  function invalidInput() {
81
89
  throw new MarAgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'View image input is invalid.');
82
90
  }
91
+ // Basic container checks catch partial writes without decoding or changing pixels.
92
+ function validateImageContainer(data, mimeType) {
93
+ if (mimeType === 'image/jpeg') {
94
+ let offset = 2;
95
+ let inScan = false;
96
+ let hasScan = false;
97
+ while (offset < data.length) {
98
+ if (data[offset++] !== 0xff) {
99
+ if (!inScan)
100
+ incompleteImage();
101
+ continue;
102
+ }
103
+ while (data[offset] === 0xff)
104
+ offset++;
105
+ if (offset >= data.length)
106
+ incompleteImage();
107
+ const marker = data[offset++];
108
+ if (marker === 0 || (marker >= 0xd0 && marker <= 0xd7)) {
109
+ if (!inScan)
110
+ incompleteImage();
111
+ continue;
112
+ }
113
+ if (marker === 0xd9) {
114
+ if (!hasScan)
115
+ incompleteImage();
116
+ return;
117
+ }
118
+ if (marker === 0xd8)
119
+ incompleteImage();
120
+ if (marker === 1)
121
+ continue;
122
+ if (data.length - offset < 2)
123
+ incompleteImage();
124
+ const length = data.readUInt16BE(offset);
125
+ if (length < 2 || offset + length > data.length)
126
+ incompleteImage();
127
+ offset += length;
128
+ if (marker === 0xda)
129
+ hasScan = true;
130
+ inScan = marker === 0xda || (inScan && marker === 0xdc);
131
+ }
132
+ incompleteImage();
133
+ }
134
+ if (mimeType === 'image/webp') {
135
+ const end = data.readUInt32LE(4) + 8;
136
+ if (end > data.length || end < 20)
137
+ incompleteImage();
138
+ let offset = 12;
139
+ let hasPixels = false;
140
+ while (offset < end) {
141
+ if (end - offset < 8)
142
+ incompleteImage();
143
+ const length = data.readUInt32LE(offset + 4);
144
+ const next = offset + 8 + length + (length % 2);
145
+ if (next > end)
146
+ incompleteImage();
147
+ const type = data.toString('ascii', offset, offset + 4);
148
+ if ((type === 'VP8 ' || type === 'VP8L' || type === 'ANMF') && length > 0)
149
+ hasPixels = true;
150
+ offset = next;
151
+ }
152
+ if (!hasPixels)
153
+ incompleteImage();
154
+ return;
155
+ }
156
+ let offset = 8;
157
+ let hasPixels = false;
158
+ while (offset < data.length) {
159
+ if (data.length - offset < 12)
160
+ incompleteImage();
161
+ const length = data.readUInt32BE(offset);
162
+ const next = offset + 12 + length;
163
+ if (next > data.length)
164
+ incompleteImage();
165
+ const type = data.toString('ascii', offset + 4, offset + 8);
166
+ if (offset === 8) {
167
+ if (type !== 'IHDR' ||
168
+ length !== 13 ||
169
+ data.readUInt32BE(offset + 8) === 0 ||
170
+ data.readUInt32BE(offset + 12) === 0)
171
+ incompleteImage();
172
+ }
173
+ else if (type === 'IHDR')
174
+ incompleteImage();
175
+ if (type === 'IDAT' && length > 0)
176
+ hasPixels = true;
177
+ if (type === 'IEND') {
178
+ if (length !== 0 || !hasPixels)
179
+ incompleteImage();
180
+ return;
181
+ }
182
+ offset = next;
183
+ }
184
+ incompleteImage();
185
+ }
186
+ function incompleteImage() {
187
+ throw new MarAgentError('MAR_AGENT_IMAGE_INCOMPLETE', 'Image file is incomplete, malformed, or changed while reading. Wait for the writer to finish and read it again.');
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/agent",
3
- "version": "0.9.158",
3
+ "version": "0.9.159",
4
4
  "description": "Embeddable MAR coding agent SDK and CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",