@andreprado/agentkit 0.1.0 → 0.1.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.
@@ -12,9 +12,19 @@ import type {
12
12
  WebhookVerificationResult,
13
13
  } from "../channels";
14
14
  import { AgentKitError } from "../errors";
15
+ import {
16
+ CHANNEL_DOWNLOAD_TIMEOUT_MS,
17
+ CHANNEL_SEND_TIMEOUT_MS,
18
+ CHANNEL_STATUS_TIMEOUT_MS,
19
+ classifySendRequestError,
20
+ isRetryableReadRequestError,
21
+ } from "./provider-fetch";
22
+ import { createPublicHttpsFetch, isBlockedOutboundHost } from "./net-guard";
15
23
 
16
24
  type EvolutionFetch = (url: string, init?: RequestInit) => Promise<Response>;
17
25
 
26
+ const evolutionFetch: EvolutionFetch = createPublicHttpsFetch("EVOLUTION_API_BASE_URL");
27
+
18
28
  type EvolutionSendTextRequest = {
19
29
  method: "POST";
20
30
  url: string;
@@ -218,7 +228,7 @@ export function buildEvolutionSendTextRequest(input: ChannelSendInput): Evolutio
218
228
 
219
229
  export async function sendEvolutionMessage(
220
230
  input: ChannelSendInput,
221
- fetcher: EvolutionFetch = fetch,
231
+ fetcher: EvolutionFetch = evolutionFetch,
222
232
  ): Promise<ChannelSendResult> {
223
233
  if (input.secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
224
234
  const request = buildEvolutionSendTextRequest(input);
@@ -267,6 +277,7 @@ export async function sendEvolutionMessage(
267
277
 
268
278
  try {
269
279
  response = await fetcher(actualRequest.url, {
280
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
270
281
  method: actualRequest.method,
271
282
  headers: {
272
283
  apikey: apiKey,
@@ -276,11 +287,12 @@ export async function sendEvolutionMessage(
276
287
  body: JSON.stringify(actualRequest.body),
277
288
  });
278
289
  } catch (error) {
290
+ const failure = classifySendRequestError(error);
279
291
  return {
280
292
  ok: false,
281
- retryable: true,
282
- code: "channel_provider_unavailable",
283
- message: `Evolution sendText request failed before a response: ${redactString(
293
+ retryable: failure.retryable,
294
+ code: failure.code,
295
+ message: `Evolution sendText request ${failure.reason}: ${redactString(
284
296
  error instanceof Error ? error.message : String(error),
285
297
  [apiKey, baseUrl, instanceName],
286
298
  )}`,
@@ -293,7 +305,7 @@ export async function sendEvolutionMessage(
293
305
  };
294
306
  }
295
307
 
296
- const payload = await readEvolutionResponseBody(response);
308
+ const payload = await readEvolutionResponseBody(response).catch(() => null);
297
309
  const redactedPayload = redactValue(payload, [apiKey, baseUrl, instanceName]);
298
310
  const messageId = readEvolutionProviderMessageId(payload);
299
311
 
@@ -345,7 +357,7 @@ export async function sendEvolutionMessage(
345
357
 
346
358
  export async function downloadEvolutionMedia(
347
359
  input: ChannelMediaDownloadInput,
348
- fetcher: EvolutionFetch = fetch,
360
+ fetcher: EvolutionFetch = evolutionFetch,
349
361
  ): Promise<ChannelMediaDownloadResult> {
350
362
  const baseUrl = input.secrets.EVOLUTION_API_BASE_URL;
351
363
  const apiKey = input.secrets.EVOLUTION_API_KEY;
@@ -388,6 +400,7 @@ export async function downloadEvolutionMedia(
388
400
 
389
401
  try {
390
402
  response = await fetcher(url, {
403
+ signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
391
404
  method: "POST",
392
405
  headers: {
393
406
  apikey: apiKey,
@@ -406,7 +419,7 @@ export async function downloadEvolutionMedia(
406
419
  } catch (error) {
407
420
  return {
408
421
  ok: false,
409
- retryable: true,
422
+ retryable: isRetryableReadRequestError(error),
410
423
  code: "channel_provider_unavailable",
411
424
  message: `Evolution media download failed before a response: ${redactString(
412
425
  error instanceof Error ? error.message : String(error),
@@ -435,7 +448,15 @@ export async function downloadEvolutionMedia(
435
448
  };
436
449
  }
437
450
 
438
- throw error;
451
+ return {
452
+ ok: false,
453
+ retryable: true,
454
+ code: "channel_provider_unavailable",
455
+ message: `Evolution media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
456
+ providerMetadata: {
457
+ status: response.status,
458
+ },
459
+ };
439
460
  }
440
461
 
441
462
  if (!response.ok) {
@@ -540,7 +561,7 @@ export async function downloadEvolutionMedia(
540
561
 
541
562
  export async function getEvolutionStatus(
542
563
  input: ChannelStatusInput,
543
- fetcher: EvolutionFetch = fetch,
564
+ fetcher: EvolutionFetch = evolutionFetch,
544
565
  ): Promise<ChannelStatusResult> {
545
566
  const missingSecrets = ["EVOLUTION_API_BASE_URL", "EVOLUTION_API_KEY", "EVOLUTION_INSTANCE_NAME"].filter(
546
567
  (secret) => !input.secrets[secret],
@@ -573,6 +594,7 @@ export async function getEvolutionStatus(
573
594
 
574
595
  try {
575
596
  response = await fetcher(url, {
597
+ signal: AbortSignal.timeout(CHANNEL_STATUS_TIMEOUT_MS),
576
598
  method: "GET",
577
599
  headers: {
578
600
  apikey: input.secrets.EVOLUTION_API_KEY,
@@ -590,7 +612,7 @@ export async function getEvolutionStatus(
590
612
  };
591
613
  }
592
614
 
593
- const payload = await readEvolutionResponseBody(response);
615
+ const payload = await readEvolutionResponseBody(response).catch(() => null);
594
616
 
595
617
  if (!response.ok) {
596
618
  return {
@@ -1160,64 +1182,15 @@ function validateEvolutionBaseUrl(baseUrl: string): string {
1160
1182
 
1161
1183
  const host = parsed.hostname.toLowerCase();
1162
1184
 
1163
- if (isBlockedHost(host)) {
1185
+ if (isBlockedOutboundHost(host)) {
1164
1186
  throw new Error("EVOLUTION_API_BASE_URL must be a public HTTPS origin.");
1165
1187
  }
1166
1188
 
1167
1189
  return parsed.origin;
1168
1190
  }
1169
1191
 
1170
- function isBlockedHost(host: string): boolean {
1171
- return (
1172
- host === "localhost" ||
1173
- host === "metadata.google.internal" ||
1174
- host.endsWith(".localhost") ||
1175
- host.endsWith(".local") ||
1176
- host.endsWith(".internal") ||
1177
- isBlockedIpv4(host) ||
1178
- isBlockedIpv6(host)
1179
- );
1180
- }
1181
1192
 
1182
- function isBlockedIpv4(host: string): boolean {
1183
- const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
1184
1193
 
1185
- if (!match) {
1186
- return false;
1187
- }
1188
-
1189
- const parts = match.slice(1).map((part) => Number(part));
1190
-
1191
- if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
1192
- return true;
1193
- }
1194
-
1195
- const [a, b] = parts;
1196
-
1197
- return (
1198
- a === 0 ||
1199
- a === 10 ||
1200
- a === 127 ||
1201
- (a === 100 && b >= 64 && b <= 127) ||
1202
- (a === 169 && b === 254) ||
1203
- (a === 172 && b >= 16 && b <= 31) ||
1204
- (a === 192 && b === 168) ||
1205
- (a === 198 && (b === 18 || b === 19)) ||
1206
- a >= 224
1207
- );
1208
- }
1209
-
1210
- function isBlockedIpv6(host: string): boolean {
1211
- const normalized = host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
1212
-
1213
- return (
1214
- normalized === "::1" ||
1215
- normalized === "::" ||
1216
- normalized.startsWith("fc") ||
1217
- normalized.startsWith("fd") ||
1218
- normalized.startsWith("fe80:")
1219
- );
1220
- }
1221
1194
 
1222
1195
  function readRecord(record: Record<string, unknown>, key: string): Record<string, unknown> {
1223
1196
  const value = record[key];
@@ -12,9 +12,19 @@ import type {
12
12
  WebhookVerificationResult,
13
13
  } from "../channels";
14
14
  import { AgentKitError } from "../errors";
15
+ import {
16
+ CHANNEL_DOWNLOAD_TIMEOUT_MS,
17
+ CHANNEL_SEND_TIMEOUT_MS,
18
+ CHANNEL_STATUS_TIMEOUT_MS,
19
+ classifySendRequestError,
20
+ isRetryableReadRequestError,
21
+ } from "./provider-fetch";
22
+ import { createPublicHttpsFetch, isBlockedOutboundHost } from "./net-guard";
15
23
 
16
24
  type UazapiFetch = (url: string, init?: RequestInit) => Promise<Response>;
17
25
 
26
+ const uazapiFetch: UazapiFetch = createPublicHttpsFetch("UAZAPI_BASE_URL");
27
+
18
28
  type UazapiSendTextRequest = {
19
29
  method: "POST";
20
30
  url: string;
@@ -195,7 +205,7 @@ export function buildUazapiSendTextRequest(input: ChannelSendInput): UazapiSendT
195
205
 
196
206
  export async function sendUazapiMessage(
197
207
  input: ChannelSendInput,
198
- fetcher: UazapiFetch = fetch,
208
+ fetcher: UazapiFetch = uazapiFetch,
199
209
  ): Promise<ChannelSendResult> {
200
210
  if (input.secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
201
211
  const request = buildUazapiSendTextRequest(input);
@@ -243,6 +253,7 @@ export async function sendUazapiMessage(
243
253
 
244
254
  try {
245
255
  response = await fetcher(actualRequest.url, {
256
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
246
257
  method: actualRequest.method,
247
258
  headers: {
248
259
  token,
@@ -252,13 +263,14 @@ export async function sendUazapiMessage(
252
263
  body: JSON.stringify(actualRequest.body),
253
264
  });
254
265
  } catch (error) {
266
+ const failure = classifySendRequestError(error);
255
267
  const message = redactString(error instanceof Error ? error.message : String(error), [token, baseUrl]);
256
268
 
257
269
  return {
258
270
  ok: false,
259
- retryable: true,
260
- code: "channel_provider_unavailable",
261
- message: `UAZAPI sendText request failed before a response: ${message}`,
271
+ retryable: failure.retryable,
272
+ code: failure.code,
273
+ message: `UAZAPI sendText request ${failure.reason}: ${message}`,
262
274
  providerRequestId: request.idempotencyKey,
263
275
  providerMetadata: {
264
276
  method: request.method,
@@ -268,7 +280,7 @@ export async function sendUazapiMessage(
268
280
  };
269
281
  }
270
282
 
271
- const payload = await readUazapiResponseBody(response);
283
+ const payload = await readUazapiResponseBody(response).catch(() => null);
272
284
  const redactedPayload = redactValue(payload, [token, baseUrl]);
273
285
  const messageId = readUazapiProviderMessageId(payload);
274
286
 
@@ -320,7 +332,7 @@ export async function sendUazapiMessage(
320
332
 
321
333
  export async function downloadUazapiMedia(
322
334
  input: ChannelMediaDownloadInput,
323
- fetcher: UazapiFetch = fetch,
335
+ fetcher: UazapiFetch = uazapiFetch,
324
336
  ): Promise<ChannelMediaDownloadResult> {
325
337
  const token = input.secrets.UAZAPI_TOKEN;
326
338
  const baseUrl = input.secrets.UAZAPI_BASE_URL;
@@ -362,6 +374,7 @@ export async function downloadUazapiMedia(
362
374
 
363
375
  try {
364
376
  response = await fetcher(url, {
377
+ signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
365
378
  method: "POST",
366
379
  headers: {
367
380
  token,
@@ -379,7 +392,7 @@ export async function downloadUazapiMedia(
379
392
  } catch (error) {
380
393
  return {
381
394
  ok: false,
382
- retryable: true,
395
+ retryable: isRetryableReadRequestError(error),
383
396
  code: "channel_provider_unavailable",
384
397
  message: `UAZAPI media download failed before a response: ${redactString(
385
398
  error instanceof Error ? error.message : String(error),
@@ -408,7 +421,15 @@ export async function downloadUazapiMedia(
408
421
  };
409
422
  }
410
423
 
411
- throw error;
424
+ return {
425
+ ok: false,
426
+ retryable: true,
427
+ code: "channel_provider_unavailable",
428
+ message: `UAZAPI media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
429
+ providerMetadata: {
430
+ status: response.status,
431
+ },
432
+ };
412
433
  }
413
434
 
414
435
  if (!response.ok) {
@@ -511,7 +532,7 @@ export async function downloadUazapiMedia(
511
532
 
512
533
  export async function getUazapiStatus(
513
534
  input: ChannelStatusInput,
514
- fetcher: UazapiFetch = fetch,
535
+ fetcher: UazapiFetch = uazapiFetch,
515
536
  ): Promise<ChannelStatusResult> {
516
537
  const missingSecrets = ["UAZAPI_BASE_URL", "UAZAPI_TOKEN", "UAZAPI_WEBHOOK_TOKEN"].filter(
517
538
  (secret) => !input.secrets[secret],
@@ -541,6 +562,7 @@ export async function getUazapiStatus(
541
562
 
542
563
  try {
543
564
  response = await fetcher(url, {
565
+ signal: AbortSignal.timeout(CHANNEL_STATUS_TIMEOUT_MS),
544
566
  method: "GET",
545
567
  headers: {
546
568
  token: input.secrets.UAZAPI_TOKEN,
@@ -558,7 +580,7 @@ export async function getUazapiStatus(
558
580
  };
559
581
  }
560
582
 
561
- const payload = await readUazapiResponseBody(response);
583
+ const payload = await readUazapiResponseBody(response).catch(() => null);
562
584
 
563
585
  if (!response.ok) {
564
586
  return {
@@ -1034,7 +1056,7 @@ function validateUazapiBaseUrl(rawBaseUrl: string): string {
1034
1056
  throw new Error("UAZAPI_BASE_URL must not include credentials.");
1035
1057
  }
1036
1058
 
1037
- if (isBlockedHostname(url.hostname)) {
1059
+ if (isBlockedOutboundHost(url.hostname)) {
1038
1060
  throw new Error("UAZAPI_BASE_URL host is not allowed.");
1039
1061
  }
1040
1062
 
@@ -1045,165 +1067,12 @@ function validateUazapiBaseUrl(rawBaseUrl: string): string {
1045
1067
  return url.href.replace(/\/$/, "");
1046
1068
  }
1047
1069
 
1048
- function isBlockedHostname(hostname: string): boolean {
1049
- const host = normalizeUrlHostname(hostname);
1050
-
1051
- if (
1052
- host === "localhost" ||
1053
- host.endsWith(".localhost") ||
1054
- host === "metadata.google.internal" ||
1055
- host === "169.254.169.254"
1056
- ) {
1057
- return true;
1058
- }
1059
1070
 
1060
- const ipv4 = parseIpv4Octets(host);
1061
1071
 
1062
- if (ipv4) {
1063
- return isBlockedIpv4Octets(ipv4);
1064
- }
1065
1072
 
1066
- if (host.includes(":")) {
1067
- return isBlockedIpv6Hostname(host);
1068
- }
1069
-
1070
- return false;
1071
- }
1072
-
1073
- function normalizeUrlHostname(hostname: string): string {
1074
- const host = hostname.toLowerCase().replace(/\.$/, "");
1075
- return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
1076
- }
1077
1073
 
1078
- function isBlockedIpv4Octets(parts: number[]): boolean {
1079
- const [a, b] = parts;
1080
1074
 
1081
- return (
1082
- a === 10 ||
1083
- a === 127 ||
1084
- (a === 169 && b === 254) ||
1085
- (a === 172 && b !== undefined && b >= 16 && b <= 31) ||
1086
- (a === 192 && b === 168) ||
1087
- a === 0
1088
- );
1089
- }
1090
-
1091
- function isBlockedIpv6Hostname(host: string): boolean {
1092
- if (host.includes("%")) {
1093
- return true;
1094
- }
1095
-
1096
- const segments = parseIpv6Segments(host);
1097
-
1098
- if (!segments) {
1099
- return false;
1100
- }
1101
-
1102
- const first = segments[0] ?? 0;
1103
- const isUnspecified = segments.every((segment) => segment === 0);
1104
- const isLoopback = segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1;
1105
- const isUniqueLocal = (first & 0xfe00) === 0xfc00;
1106
- const isLinkLocal = (first & 0xffc0) === 0xfe80;
1107
- const isIpv4Mapped = segments.slice(0, 5).every((segment) => segment === 0) && segments[5] === 0xffff;
1108
- const isIpv4Compatible = segments.slice(0, 6).every((segment) => segment === 0) && (segments[6] !== 0 || segments[7] !== 0);
1109
-
1110
- if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal) {
1111
- return true;
1112
- }
1113
-
1114
- if (isIpv4Mapped || isIpv4Compatible) {
1115
- return isBlockedIpv4Octets([
1116
- (segments[6] >> 8) & 255,
1117
- segments[6] & 255,
1118
- (segments[7] >> 8) & 255,
1119
- segments[7] & 255,
1120
- ]);
1121
- }
1122
-
1123
- return false;
1124
- }
1125
-
1126
- function parseIpv6Segments(host: string): number[] | undefined {
1127
- if (!host.includes(":")) {
1128
- return undefined;
1129
- }
1130
1075
 
1131
- if ((host.match(/::/g) ?? []).length > 1) {
1132
- return undefined;
1133
- }
1134
-
1135
- const [left = "", right = ""] = host.split("::");
1136
- const leftSegments = parseIpv6SegmentSide(left);
1137
- const rightSegments = parseIpv6SegmentSide(right);
1138
-
1139
- if (!leftSegments || !rightSegments) {
1140
- return undefined;
1141
- }
1142
-
1143
- if (!host.includes("::")) {
1144
- return leftSegments.length === 8 ? leftSegments : undefined;
1145
- }
1146
-
1147
- const missing = 8 - leftSegments.length - rightSegments.length;
1148
-
1149
- if (missing < 1) {
1150
- return undefined;
1151
- }
1152
-
1153
- return [...leftSegments, ...Array.from({ length: missing }, () => 0), ...rightSegments];
1154
- }
1155
-
1156
- function parseIpv6SegmentSide(value: string): number[] | undefined {
1157
- if (!value) {
1158
- return [];
1159
- }
1160
-
1161
- const parts = value.split(":");
1162
- const segments: number[] = [];
1163
-
1164
- for (const [index, part] of parts.entries()) {
1165
- if (!part) {
1166
- return undefined;
1167
- }
1168
-
1169
- if (part.includes(".")) {
1170
- if (index !== parts.length - 1) {
1171
- return undefined;
1172
- }
1173
-
1174
- const ipv4 = parseIpv4Octets(part);
1175
-
1176
- if (!ipv4) {
1177
- return undefined;
1178
- }
1179
-
1180
- segments.push((ipv4[0] << 8) | ipv4[1], (ipv4[2] << 8) | ipv4[3]);
1181
- continue;
1182
- }
1183
-
1184
- if (!/^[0-9a-f]{1,4}$/i.test(part)) {
1185
- return undefined;
1186
- }
1187
-
1188
- segments.push(Number.parseInt(part, 16));
1189
- }
1190
-
1191
- return segments;
1192
- }
1193
-
1194
- function parseIpv4Octets(host: string): number[] | undefined {
1195
- if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
1196
- return undefined;
1197
- }
1198
-
1199
- const parts = host.split(".").map(Number);
1200
-
1201
- if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
1202
- return undefined;
1203
- }
1204
-
1205
- return parts;
1206
- }
1207
1076
 
1208
1077
  function stripWhatsappJid(value: string): string {
1209
1078
  return value
@@ -9,6 +9,12 @@ import type {
9
9
  WebhookVerificationResult,
10
10
  } from "../channels";
11
11
  import { AgentKitError } from "../errors";
12
+ import {
13
+ CHANNEL_DOWNLOAD_TIMEOUT_MS,
14
+ CHANNEL_SEND_TIMEOUT_MS,
15
+ classifySendRequestError,
16
+ isRetryableReadRequestError,
17
+ } from "./provider-fetch";
12
18
 
13
19
  export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
14
20
  type: "whatsapp",
@@ -364,6 +370,7 @@ export async function sendZapsterMessage(
364
370
 
365
371
  try {
366
372
  response = await fetcher(actualRequest.url, {
373
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
367
374
  method: actualRequest.method,
368
375
  headers: {
369
376
  Authorization: `Bearer ${apiKey}`,
@@ -373,13 +380,14 @@ export async function sendZapsterMessage(
373
380
  body: JSON.stringify(actualRequest.body),
374
381
  });
375
382
  } catch (error) {
383
+ const failure = classifySendRequestError(error);
376
384
  const errorMessage = redactString(error instanceof Error ? error.message : String(error), [apiKey, instanceId]);
377
385
 
378
386
  return {
379
387
  ok: false,
380
- retryable: true,
381
- code: "channel_provider_unavailable",
382
- message: `Zapster sendMessage request failed before a response: ${errorMessage}`,
388
+ retryable: failure.retryable,
389
+ code: failure.code,
390
+ message: `Zapster sendMessage request ${failure.reason}: ${errorMessage}`,
383
391
  providerRequestId: request.idempotencyKey,
384
392
  providerMetadata: {
385
393
  method: request.method,
@@ -476,6 +484,7 @@ export async function downloadZapsterMedia(
476
484
 
477
485
  try {
478
486
  response = await fetcher(mediaUrl.url, {
487
+ signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
479
488
  method: "GET",
480
489
  headers: {
481
490
  Authorization: `Bearer ${apiKey}`,
@@ -484,7 +493,7 @@ export async function downloadZapsterMedia(
484
493
  } catch (error) {
485
494
  return {
486
495
  ok: false,
487
- retryable: true,
496
+ retryable: isRetryableReadRequestError(error),
488
497
  code: "channel_provider_unavailable",
489
498
  message: `Zapster media download failed before a response: ${redactString(
490
499
  error instanceof Error ? error.message : String(error),
@@ -591,8 +600,8 @@ async function readResponseBytesWithLimit(
591
600
  }
592
601
  | {
593
602
  ok: false;
594
- retryable: false;
595
- code: "channel_audio_too_large";
603
+ retryable: boolean;
604
+ code: "channel_audio_too_large" | "channel_provider_unavailable";
596
605
  message: string;
597
606
  providerMetadata?: Record<string, unknown>;
598
607
  }
@@ -613,7 +622,18 @@ async function readResponseBytesWithLimit(
613
622
  }
614
623
 
615
624
  if (!response.body) {
616
- const audio = new Uint8Array(await response.arrayBuffer());
625
+ let audio: Uint8Array;
626
+
627
+ try {
628
+ audio = new Uint8Array(await response.arrayBuffer());
629
+ } catch (error) {
630
+ return {
631
+ ok: false,
632
+ retryable: true,
633
+ code: "channel_provider_unavailable",
634
+ message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
635
+ };
636
+ }
617
637
 
618
638
  if (audio.byteLength > maxBytes) {
619
639
  return {
@@ -659,6 +679,14 @@ async function readResponseBytesWithLimit(
659
679
 
660
680
  chunks.push(chunk.value);
661
681
  }
682
+ } catch (error) {
683
+ await reader.cancel().catch(() => undefined);
684
+ return {
685
+ ok: false,
686
+ retryable: true,
687
+ code: "channel_provider_unavailable",
688
+ message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
689
+ };
662
690
  } finally {
663
691
  reader.releaseLock();
664
692
  }
@@ -123,9 +123,32 @@ function resolveLocalStoragePath(root: string, config: AgentConfig): string | un
123
123
  return undefined;
124
124
  }
125
125
 
126
+ // Every import of the config goes through a uniquely named temp module, and
127
+ // Node never evicts imported modules. Re-importing on each request (the dev and
128
+ // container servers load the capsule per request) grows memory without bound,
129
+ // so reuse the import while the config source is unchanged. Edits still reload.
130
+ const configModuleCache = new Map<string, { source: string; value: Promise<unknown> }>();
131
+
126
132
  async function loadConfigModule(configPath: string): Promise<unknown> {
127
133
  const source = await readFile(configPath, "utf8");
134
+ const cached = configModuleCache.get(configPath);
135
+
136
+ if (cached?.source === source) {
137
+ return cached.value;
138
+ }
139
+
140
+ const value = importConfigModule(configPath, source);
141
+ configModuleCache.set(configPath, { source, value });
142
+ value.catch(() => {
143
+ if (configModuleCache.get(configPath)?.value === value) {
144
+ configModuleCache.delete(configPath);
145
+ }
146
+ });
147
+
148
+ return value;
149
+ }
128
150
 
151
+ async function importConfigModule(configPath: string, source: string): Promise<unknown> {
129
152
  try {
130
153
  return await importConfigSource(configPath, source);
131
154
  } catch (error) {