@link-assistant/agent 0.16.16 → 0.16.18

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.
@@ -1,11 +1,13 @@
1
1
  import { Global } from '../global';
2
2
  import { Log } from '../util/log';
3
+ import { createVerboseFetch } from '../util/verbose-fetch';
3
4
  import path from 'path';
4
5
  import z from 'zod';
5
6
  import { data } from './models-macro';
6
7
 
7
8
  export namespace ModelsDev {
8
9
  const log = Log.create({ service: 'models.dev' });
10
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'models.dev' });
9
11
  const filepath = path.join(Global.Path.cache, 'models.json');
10
12
 
11
13
  export const Model = z
@@ -145,7 +147,7 @@ export namespace ModelsDev {
145
147
  export async function refresh() {
146
148
  const file = Bun.file(filepath);
147
149
  log.info(() => ({ message: 'refreshing', file }));
148
- const result = await fetch('https://models.dev/api.json', {
150
+ const result = await verboseFetch('https://models.dev/api.json', {
149
151
  headers: {
150
152
  'User-Agent': 'agent-cli/1.0.0',
151
153
  },
@@ -1207,8 +1207,22 @@ export namespace Provider {
1207
1207
  // When verbose is disabled, the wrapper is a no-op passthrough with negligible overhead.
1208
1208
  // See: https://github.com/link-assistant/agent/issues/200
1209
1209
  // See: https://github.com/link-assistant/agent/issues/206
1210
+ // See: https://github.com/link-assistant/agent/issues/215
1210
1211
  {
1211
1212
  const innerFetch = options['fetch'];
1213
+ let verboseWrapperConfirmed = false;
1214
+ let httpCallCount = 0;
1215
+
1216
+ // Log at SDK creation time that the fetch wrapper is installed.
1217
+ // This runs once per provider SDK creation (not per request).
1218
+ // If verbose is off at creation time, the per-request check still applies.
1219
+ // See: https://github.com/link-assistant/agent/issues/215
1220
+ log.info('verbose HTTP fetch wrapper installed', {
1221
+ providerID: provider.id,
1222
+ pkg,
1223
+ verboseAtCreation: Flag.OPENCODE_VERBOSE,
1224
+ });
1225
+
1212
1226
  options['fetch'] = async (
1213
1227
  input: RequestInfo | URL,
1214
1228
  init?: RequestInit
@@ -1218,6 +1232,24 @@ export namespace Provider {
1218
1232
  return innerFetch(input, init);
1219
1233
  }
1220
1234
 
1235
+ httpCallCount++;
1236
+ const callNum = httpCallCount;
1237
+
1238
+ // Log a one-time confirmation that the verbose wrapper is active for this provider.
1239
+ // This diagnostic breadcrumb confirms the wrapper is in the fetch chain.
1240
+ // Also write to stderr as a redundant channel — stdout JSON may be filtered by wrappers.
1241
+ // See: https://github.com/link-assistant/agent/issues/215
1242
+ if (!verboseWrapperConfirmed) {
1243
+ verboseWrapperConfirmed = true;
1244
+ log.info('verbose HTTP logging active', {
1245
+ providerID: provider.id,
1246
+ });
1247
+ // Redundant stderr confirmation — visible even if stdout is piped/filtered
1248
+ process.stderr.write(
1249
+ `[verbose] HTTP logging active for provider: ${provider.id}\n`
1250
+ );
1251
+ }
1252
+
1221
1253
  const url =
1222
1254
  typeof input === 'string'
1223
1255
  ? input
@@ -1226,50 +1258,64 @@ export namespace Provider {
1226
1258
  : input.url;
1227
1259
  const method = init?.method ?? 'GET';
1228
1260
 
1229
- // Sanitize headers - mask authorization values
1230
- const sanitizedHeaders: Record<string, string> = {};
1231
- const rawHeaders = init?.headers;
1232
- if (rawHeaders) {
1233
- const entries =
1234
- rawHeaders instanceof Headers
1235
- ? Array.from(rawHeaders.entries())
1236
- : Array.isArray(rawHeaders)
1237
- ? rawHeaders
1238
- : Object.entries(rawHeaders);
1239
- for (const [key, value] of entries) {
1240
- const lower = key.toLowerCase();
1241
- if (
1242
- lower === 'authorization' ||
1243
- lower === 'x-api-key' ||
1244
- lower === 'api-key'
1245
- ) {
1246
- sanitizedHeaders[key] =
1247
- typeof value === 'string' && value.length > 8
1248
- ? value.slice(0, 4) + '...' + value.slice(-4)
1249
- : '[REDACTED]';
1250
- } else {
1251
- sanitizedHeaders[key] = String(value);
1261
+ // Wrap all verbose logging in try/catch so it never breaks the actual HTTP request.
1262
+ // If logging fails, the request must still proceed — logging is observability, not control flow.
1263
+ // See: https://github.com/link-assistant/agent/issues/215
1264
+ let sanitizedHeaders: Record<string, string> = {};
1265
+ let bodyPreview: string | undefined;
1266
+ try {
1267
+ // Sanitize headers - mask authorization values
1268
+ const rawHeaders = init?.headers;
1269
+ if (rawHeaders) {
1270
+ const entries =
1271
+ rawHeaders instanceof Headers
1272
+ ? Array.from(rawHeaders.entries())
1273
+ : Array.isArray(rawHeaders)
1274
+ ? rawHeaders
1275
+ : Object.entries(rawHeaders);
1276
+ for (const [key, value] of entries) {
1277
+ const lower = key.toLowerCase();
1278
+ if (
1279
+ lower === 'authorization' ||
1280
+ lower === 'x-api-key' ||
1281
+ lower === 'api-key'
1282
+ ) {
1283
+ sanitizedHeaders[key] =
1284
+ typeof value === 'string' && value.length > 8
1285
+ ? value.slice(0, 4) + '...' + value.slice(-4)
1286
+ : '[REDACTED]';
1287
+ } else {
1288
+ sanitizedHeaders[key] = String(value);
1289
+ }
1252
1290
  }
1253
1291
  }
1254
- }
1255
1292
 
1256
- // Log request body preview (truncated)
1257
- let bodyPreview: string | undefined;
1258
- if (init?.body) {
1259
- const bodyStr =
1260
- typeof init.body === 'string'
1261
- ? init.body
1262
- : init.body instanceof ArrayBuffer ||
1263
- init.body instanceof Uint8Array
1264
- ? `[binary ${(init.body as ArrayBuffer).byteLength ?? (init.body as Uint8Array).length} bytes]`
1265
- : undefined;
1266
- if (bodyStr && typeof bodyStr === 'string') {
1267
- bodyPreview =
1268
- bodyStr.length > 2000
1269
- ? bodyStr.slice(0, 2000) +
1270
- `... [truncated, total ${bodyStr.length} chars]`
1271
- : bodyStr;
1293
+ // Log request body preview (truncated)
1294
+ if (init?.body) {
1295
+ const bodyStr =
1296
+ typeof init.body === 'string'
1297
+ ? init.body
1298
+ : init.body instanceof ArrayBuffer ||
1299
+ init.body instanceof Uint8Array
1300
+ ? `[binary ${(init.body as ArrayBuffer).byteLength ?? (init.body as Uint8Array).length} bytes]`
1301
+ : undefined;
1302
+ if (bodyStr && typeof bodyStr === 'string') {
1303
+ bodyPreview =
1304
+ bodyStr.length > 2000
1305
+ ? bodyStr.slice(0, 2000) +
1306
+ `... [truncated, total ${bodyStr.length} chars]`
1307
+ : bodyStr;
1308
+ }
1272
1309
  }
1310
+ } catch (prepError) {
1311
+ // If header/body processing fails, log the error but continue with the request
1312
+ log.warn('verbose logging: failed to prepare request details', {
1313
+ providerID: provider.id,
1314
+ error:
1315
+ prepError instanceof Error
1316
+ ? prepError.message
1317
+ : String(prepError),
1318
+ });
1273
1319
  }
1274
1320
 
1275
1321
  // Use direct (non-lazy) logging for HTTP request/response to ensure output
@@ -1277,7 +1323,9 @@ export namespace Provider {
1277
1323
  // The verbose check is already done above, so lazy evaluation is not needed here.
1278
1324
  // See: https://github.com/link-assistant/agent/issues/211
1279
1325
  log.info('HTTP request', {
1326
+ caller: `provider/${provider.id}`,
1280
1327
  providerID: provider.id,
1328
+ callNum,
1281
1329
  method,
1282
1330
  url,
1283
1331
  headers: sanitizedHeaders,
@@ -1286,13 +1334,21 @@ export namespace Provider {
1286
1334
 
1287
1335
  const startMs = Date.now();
1288
1336
  try {
1289
- const response = await innerFetch(input, init);
1337
+ // Pass Bun-specific verbose:true to get detailed connection debugging
1338
+ // (prints request/response headers to stderr on socket errors).
1339
+ // This is a no-op on non-Bun runtimes.
1340
+ // See: https://bun.sh/docs/api/fetch
1341
+ // See: https://github.com/link-assistant/agent/issues/215
1342
+ const verboseInit = { ...init, verbose: true } as RequestInit;
1343
+ const response = await innerFetch(input, verboseInit);
1290
1344
  const durationMs = Date.now() - startMs;
1291
1345
 
1292
1346
  // Use direct (non-lazy) logging to ensure HTTP response details are captured
1293
1347
  // See: https://github.com/link-assistant/agent/issues/211
1294
1348
  log.info('HTTP response', {
1349
+ caller: `provider/${provider.id}`,
1295
1350
  providerID: provider.id,
1351
+ callNum,
1296
1352
  method,
1297
1353
  url,
1298
1354
  status: response.status,
@@ -1342,7 +1398,9 @@ export namespace Provider {
1342
1398
  // Use direct (non-lazy) logging for stream body
1343
1399
  // See: https://github.com/link-assistant/agent/issues/211
1344
1400
  log.info('HTTP response body (stream)', {
1401
+ caller: `provider/${provider.id}`,
1345
1402
  providerID: provider.id,
1403
+ callNum,
1346
1404
  url,
1347
1405
  bodyPreview: truncated
1348
1406
  ? bodyPreview + `... [truncated]`
@@ -1370,7 +1428,9 @@ export namespace Provider {
1370
1428
  // Use direct (non-lazy) logging for non-streaming body
1371
1429
  // See: https://github.com/link-assistant/agent/issues/211
1372
1430
  log.info('HTTP response body', {
1431
+ caller: `provider/${provider.id}`,
1373
1432
  providerID: provider.id,
1433
+ callNum,
1374
1434
  url,
1375
1435
  bodyPreview,
1376
1436
  });
@@ -1386,15 +1446,29 @@ export namespace Provider {
1386
1446
  } catch (error) {
1387
1447
  const durationMs = Date.now() - startMs;
1388
1448
  // Use direct (non-lazy) logging for error path
1449
+ // Include stack trace and error cause for better debugging of socket errors
1389
1450
  // See: https://github.com/link-assistant/agent/issues/211
1451
+ // See: https://github.com/link-assistant/agent/issues/215
1390
1452
  log.error('HTTP request failed', {
1453
+ caller: `provider/${provider.id}`,
1391
1454
  providerID: provider.id,
1455
+ callNum,
1392
1456
  method,
1393
1457
  url,
1394
1458
  durationMs,
1395
1459
  error:
1396
1460
  error instanceof Error
1397
- ? { name: error.name, message: error.message }
1461
+ ? {
1462
+ name: error.name,
1463
+ message: error.message,
1464
+ stack: error.stack,
1465
+ cause:
1466
+ error.cause instanceof Error
1467
+ ? error.cause.message
1468
+ : error.cause
1469
+ ? String(error.cause)
1470
+ : undefined,
1471
+ }
1398
1472
  : String(error),
1399
1473
  });
1400
1474
  throw error;
@@ -166,6 +166,8 @@ export namespace RetryFetch {
166
166
  }
167
167
 
168
168
  const timeout = setTimeout(resolve, ms);
169
+ // Prevent sleep timer from keeping event loop alive (#213)
170
+ if (timeout.unref) timeout.unref();
169
171
 
170
172
  if (signal) {
171
173
  const abortHandler = () => {
@@ -224,6 +226,8 @@ export namespace RetryFetch {
224
226
  )
225
227
  );
226
228
  }, remainingTimeout);
229
+ // Prevent timer from keeping event loop alive after cleanup (#213)
230
+ if (globalTimeoutId.unref) globalTimeoutId.unref();
227
231
  timers.push(globalTimeoutId);
228
232
 
229
233
  // Periodically check if user canceled (every 10 seconds)
@@ -244,6 +248,8 @@ export namespace RetryFetch {
244
248
  // Check immediately and then every 10 seconds
245
249
  checkUserCancellation();
246
250
  const intervalId = setInterval(checkUserCancellation, 10_000);
251
+ // Prevent interval from keeping event loop alive after cleanup (#213)
252
+ if ((intervalId as any).unref) (intervalId as any).unref();
247
253
  timers.push(intervalId as unknown as NodeJS.Timeout);
248
254
  }
249
255
 
@@ -364,6 +370,16 @@ export namespace RetryFetch {
364
370
  return response;
365
371
  }
366
372
 
373
+ // If retry on rate limits is disabled, return 429 immediately
374
+ if (!Flag.RETRY_ON_RATE_LIMITS) {
375
+ log.info(() => ({
376
+ message:
377
+ 'rate limit retry disabled (--no-retry-on-rate-limits), returning 429',
378
+ sessionID,
379
+ }));
380
+ return response;
381
+ }
382
+
367
383
  // Check if we're within the global retry timeout
368
384
  const elapsed = Date.now() - startTime;
369
385
  if (elapsed >= maxRetryTimeout) {
@@ -246,7 +246,9 @@ export namespace Server {
246
246
  const server = Bun.serve({
247
247
  port: opts.port,
248
248
  hostname: opts.hostname,
249
- idleTimeout: 0,
249
+ // Use default idle timeout (255s) instead of 0 (infinite) to prevent
250
+ // keeping the event loop alive after server.stop(). See #213.
251
+ idleTimeout: 255,
250
252
  fetch: App().fetch,
251
253
  });
252
254
  return server;
@@ -123,6 +123,8 @@ export namespace SessionRetry {
123
123
  export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
124
124
  return new Promise((resolve, reject) => {
125
125
  const timeout = setTimeout(resolve, ms);
126
+ // Prevent sleep timer from keeping event loop alive (#213)
127
+ if (timeout.unref) timeout.unref();
126
128
  signal.addEventListener(
127
129
  'abort',
128
130
  () => {
@@ -1,6 +1,9 @@
1
1
  import z from 'zod';
2
2
  import { Tool } from './tool';
3
3
  import DESCRIPTION from './codesearch.txt';
4
+ import { createVerboseFetch } from '../util/verbose-fetch';
5
+
6
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'codesearch' });
4
7
 
5
8
  const API_CONFIG = {
6
9
  BASE_URL: 'https://mcp.exa.ai',
@@ -73,7 +76,7 @@ export const CodeSearchTool = Tool.define('codesearch', {
73
76
  'content-type': 'application/json',
74
77
  };
75
78
 
76
- const response = await fetch(
79
+ const response = await verboseFetch(
77
80
  `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`,
78
81
  {
79
82
  method: 'POST',
@@ -2,6 +2,9 @@ import z from 'zod';
2
2
  import { Tool } from './tool';
3
3
  import TurndownService from 'turndown';
4
4
  import DESCRIPTION from './webfetch.txt';
5
+ import { createVerboseFetch } from '../util/verbose-fetch';
6
+
7
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'webfetch' });
5
8
 
6
9
  const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB
7
10
  const DEFAULT_TIMEOUT = 30 * 1000; // 30 seconds
@@ -59,7 +62,7 @@ export const WebFetchTool = Tool.define('webfetch', {
59
62
  'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8';
60
63
  }
61
64
 
62
- const response = await fetch(params.url, {
65
+ const response = await verboseFetch(params.url, {
63
66
  signal: AbortSignal.any([controller.signal, ctx.abort]),
64
67
  headers: {
65
68
  'User-Agent':
@@ -2,6 +2,9 @@ import z from 'zod';
2
2
  import { Tool } from './tool';
3
3
  import DESCRIPTION from './websearch.txt';
4
4
  import { Config } from '../config/config';
5
+ import { createVerboseFetch } from '../util/verbose-fetch';
6
+
7
+ const verboseFetch = createVerboseFetch(fetch, { caller: 'websearch' });
5
8
 
6
9
  const API_CONFIG = {
7
10
  BASE_URL: 'https://mcp.exa.ai',
@@ -91,7 +94,7 @@ export const WebSearchTool = Tool.define('websearch', {
91
94
  'content-type': 'application/json',
92
95
  };
93
96
 
94
- const response = await fetch(
97
+ const response = await verboseFetch(
95
98
  `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`,
96
99
  {
97
100
  method: 'POST',
@@ -9,6 +9,8 @@ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
9
9
  timeout = setTimeout(() => {
10
10
  reject(new Error(`Operation timed out after ${ms}ms`));
11
11
  }, ms);
12
+ // Prevent timeout from keeping the event loop alive (#213)
13
+ timeout.unref();
12
14
  }),
13
15
  ]);
14
16
  }