@link-assistant/agent 0.16.7 → 0.16.9

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": "@link-assistant/agent",
3
- "version": "0.16.7",
3
+ "version": "0.16.9",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli/output.ts CHANGED
@@ -62,16 +62,62 @@ export function isCompactJson(): boolean {
62
62
  }
63
63
 
64
64
  /**
65
- * Format a message as JSON string
65
+ * Safe JSON replacer that handles cyclic references and non-serializable values.
66
+ * Returns a replacer function that tracks seen objects to detect cycles.
67
+ *
68
+ * @see https://github.com/link-assistant/agent/issues/200
69
+ */
70
+ function createSafeReplacer(): (key: string, value: unknown) => unknown {
71
+ const seen = new WeakSet();
72
+ return (_key: string, value: unknown) => {
73
+ if (typeof value === 'object' && value !== null) {
74
+ if (seen.has(value)) {
75
+ return '[Circular]';
76
+ }
77
+ seen.add(value);
78
+ }
79
+ // Convert Error objects to plain objects
80
+ if (value instanceof Error) {
81
+ return {
82
+ name: value.name,
83
+ message: value.message,
84
+ stack: value.stack,
85
+ ...(value.cause ? { cause: value.cause } : {}),
86
+ };
87
+ }
88
+ // Handle BigInt (not JSON-serializable by default)
89
+ if (typeof value === 'bigint') {
90
+ return value.toString();
91
+ }
92
+ return value;
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Format a message as JSON string.
98
+ * Handles cyclic references and non-serializable values safely.
99
+ *
66
100
  * @param message - The message object to format
67
101
  * @param compact - Override the global compact setting
102
+ * @see https://github.com/link-assistant/agent/issues/200
68
103
  */
69
104
  export function formatJson(message: OutputMessage, compact?: boolean): string {
70
- // Check local, global, and Flag settings for compact mode
71
105
  const useCompact = compact ?? isCompactJson();
72
- return useCompact
73
- ? JSON.stringify(message)
74
- : JSON.stringify(message, null, 2);
106
+ try {
107
+ return useCompact
108
+ ? JSON.stringify(message, createSafeReplacer())
109
+ : JSON.stringify(message, createSafeReplacer(), 2);
110
+ } catch (_e) {
111
+ // Last resort fallback - should never happen with safe replacer
112
+ const fallback: OutputMessage = {
113
+ type: message.type ?? 'error',
114
+ message: 'Failed to serialize output',
115
+ serializationError: _e instanceof Error ? _e.message : String(_e),
116
+ };
117
+ return useCompact
118
+ ? JSON.stringify(fallback)
119
+ : JSON.stringify(fallback, null, 2);
120
+ }
75
121
  }
76
122
 
77
123
  /**
package/src/index.js CHANGED
@@ -55,28 +55,56 @@ try {
55
55
  let hasError = false;
56
56
 
57
57
  // Install global error handlers to ensure non-zero exit codes
58
+ // All output is JSON to ensure machine-parsability (#200)
58
59
  process.on('uncaughtException', (error) => {
59
60
  hasError = true;
60
- outputError({
61
- errorType: error.name || 'UncaughtException',
62
- message: error.message,
63
- stack: error.stack,
64
- });
61
+ try {
62
+ outputError({
63
+ errorType: error?.name || 'UncaughtException',
64
+ message: error?.message || String(error),
65
+ stack: error?.stack,
66
+ ...(error?.cause ? { cause: String(error.cause) } : {}),
67
+ });
68
+ } catch (_serializationError) {
69
+ // Last resort: write minimal JSON directly to stderr
70
+ process.stderr.write(
71
+ `${JSON.stringify({
72
+ type: 'error',
73
+ errorType: 'UncaughtException',
74
+ message: String(error),
75
+ })}\n`
76
+ );
77
+ }
65
78
  process.exit(1);
66
79
  });
67
80
 
68
81
  process.on('unhandledRejection', (reason, _promise) => {
69
82
  hasError = true;
70
- const errorOutput = {
71
- errorType: 'UnhandledRejection',
72
- message: reason?.message || String(reason),
73
- stack: reason?.stack,
74
- };
75
- // If the error has a data property with a suggestion (e.g., ProviderModelNotFoundError), add it as a hint
76
- if (reason?.data?.suggestion) {
77
- errorOutput.hint = reason.data.suggestion;
83
+ try {
84
+ const errorOutput = {
85
+ errorType: 'UnhandledRejection',
86
+ message: reason?.message || String(reason),
87
+ stack: reason?.stack,
88
+ };
89
+ // If the error has a data property with a suggestion (e.g., ProviderModelNotFoundError), add it as a hint
90
+ if (reason?.data?.suggestion) {
91
+ errorOutput.hint = reason.data.suggestion;
92
+ }
93
+ // Include error data for debugging (#200)
94
+ if (reason?.data) {
95
+ errorOutput.data = reason.data;
96
+ }
97
+ outputError(errorOutput);
98
+ } catch (_serializationError) {
99
+ // Last resort: write minimal JSON directly to stderr
100
+ process.stderr.write(
101
+ `${JSON.stringify({
102
+ type: 'error',
103
+ errorType: 'UnhandledRejection',
104
+ message: String(reason),
105
+ })}\n`
106
+ );
78
107
  }
79
- outputError(errorOutput);
80
108
  process.exit(1);
81
109
  });
82
110
 
@@ -83,7 +83,7 @@ export namespace ModelsDev {
83
83
  *
84
84
  * This prevents ProviderModelNotFoundError when:
85
85
  * - User runs agent for the first time (no cache)
86
- * - User has outdated cache missing new models like kimi-k2.5-free
86
+ * - User has outdated cache missing new models like glm-5-free
87
87
  *
88
88
  * @see https://github.com/link-assistant/agent/issues/175
89
89
  */
@@ -1201,6 +1201,113 @@ export namespace Provider {
1201
1201
  sessionID: provider.id,
1202
1202
  });
1203
1203
 
1204
+ // Wrap fetch with verbose HTTP logging when --verbose is enabled
1205
+ // This logs raw HTTP request/response details for debugging provider issues
1206
+ // See: https://github.com/link-assistant/agent/issues/200
1207
+ if (Flag.OPENCODE_VERBOSE) {
1208
+ const innerFetch = options['fetch'];
1209
+ options['fetch'] = async (
1210
+ input: RequestInfo | URL,
1211
+ init?: RequestInit
1212
+ ): Promise<Response> => {
1213
+ const url =
1214
+ typeof input === 'string'
1215
+ ? input
1216
+ : input instanceof URL
1217
+ ? input.toString()
1218
+ : input.url;
1219
+ const method = init?.method ?? 'GET';
1220
+
1221
+ // Sanitize headers - mask authorization values
1222
+ const sanitizedHeaders: Record<string, string> = {};
1223
+ const rawHeaders = init?.headers;
1224
+ if (rawHeaders) {
1225
+ const entries =
1226
+ rawHeaders instanceof Headers
1227
+ ? Array.from(rawHeaders.entries())
1228
+ : Array.isArray(rawHeaders)
1229
+ ? rawHeaders
1230
+ : Object.entries(rawHeaders);
1231
+ for (const [key, value] of entries) {
1232
+ const lower = key.toLowerCase();
1233
+ if (
1234
+ lower === 'authorization' ||
1235
+ lower === 'x-api-key' ||
1236
+ lower === 'api-key'
1237
+ ) {
1238
+ sanitizedHeaders[key] =
1239
+ typeof value === 'string' && value.length > 8
1240
+ ? value.slice(0, 4) + '...' + value.slice(-4)
1241
+ : '[REDACTED]';
1242
+ } else {
1243
+ sanitizedHeaders[key] = String(value);
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ // Log request body preview (truncated)
1249
+ let bodyPreview: string | undefined;
1250
+ if (init?.body) {
1251
+ const bodyStr =
1252
+ typeof init.body === 'string'
1253
+ ? init.body
1254
+ : init.body instanceof ArrayBuffer ||
1255
+ init.body instanceof Uint8Array
1256
+ ? `[binary ${(init.body as ArrayBuffer).byteLength ?? (init.body as Uint8Array).length} bytes]`
1257
+ : undefined;
1258
+ if (bodyStr && typeof bodyStr === 'string') {
1259
+ bodyPreview =
1260
+ bodyStr.length > 2000
1261
+ ? bodyStr.slice(0, 2000) +
1262
+ `... [truncated, total ${bodyStr.length} chars]`
1263
+ : bodyStr;
1264
+ }
1265
+ }
1266
+
1267
+ log.info(() => ({
1268
+ message: 'HTTP request',
1269
+ providerID: provider.id,
1270
+ method,
1271
+ url,
1272
+ headers: sanitizedHeaders,
1273
+ bodyPreview,
1274
+ }));
1275
+
1276
+ const startMs = Date.now();
1277
+ try {
1278
+ const response = await innerFetch(input, init);
1279
+ const durationMs = Date.now() - startMs;
1280
+
1281
+ log.info(() => ({
1282
+ message: 'HTTP response',
1283
+ providerID: provider.id,
1284
+ method,
1285
+ url,
1286
+ status: response.status,
1287
+ statusText: response.statusText,
1288
+ durationMs,
1289
+ responseHeaders: Object.fromEntries(response.headers.entries()),
1290
+ }));
1291
+
1292
+ return response;
1293
+ } catch (error) {
1294
+ const durationMs = Date.now() - startMs;
1295
+ log.error(() => ({
1296
+ message: 'HTTP request failed',
1297
+ providerID: provider.id,
1298
+ method,
1299
+ url,
1300
+ durationMs,
1301
+ error:
1302
+ error instanceof Error
1303
+ ? { name: error.name, message: error.message }
1304
+ : String(error),
1305
+ }));
1306
+ throw error;
1307
+ }
1308
+ };
1309
+ }
1310
+
1204
1311
  // Check if we have a bundled provider first - this avoids runtime package installation
1205
1312
  // which can hang or timeout due to known Bun issues
1206
1313
  // @see https://github.com/link-assistant/agent/issues/173
@@ -1310,8 +1417,21 @@ export namespace Provider {
1310
1417
 
1311
1418
  // For synthetic providers, we don't need model info from the database
1312
1419
  const info = isSyntheticProvider ? null : provider.info.models[modelID];
1313
- if (!isSyntheticProvider && !info)
1314
- throw new ModelNotFoundError({ providerID, modelID });
1420
+ if (!isSyntheticProvider && !info) {
1421
+ const availableInProvider = Object.keys(provider.info.models).slice(
1422
+ 0,
1423
+ 10
1424
+ );
1425
+ const suggestion = `Model "${modelID}" not found in provider "${providerID}". Available models: ${availableInProvider.join(', ')}${Object.keys(provider.info.models).length > 10 ? ` (and ${Object.keys(provider.info.models).length - 10} more)` : ''}.`;
1426
+ log.error(() => ({
1427
+ message: 'model not found in provider',
1428
+ providerID,
1429
+ modelID,
1430
+ availableModels: availableInProvider,
1431
+ totalModels: Object.keys(provider.info.models).length,
1432
+ }));
1433
+ throw new ModelNotFoundError({ providerID, modelID, suggestion });
1434
+ }
1315
1435
 
1316
1436
  try {
1317
1437
  const keyReal = `${providerID}/${modelID}`;
@@ -362,10 +362,24 @@ export namespace SessionPrompt {
362
362
 
363
363
  let model;
364
364
  try {
365
+ // Log model resolution attempt for debugging (#200)
366
+ log.info(() => ({
367
+ message: 'resolving model',
368
+ providerID: lastUser.model.providerID,
369
+ modelID: lastUser.model.modelID,
370
+ sessionID,
371
+ step,
372
+ }));
365
373
  model = await Provider.getModel(
366
374
  lastUser.model.providerID,
367
375
  lastUser.model.modelID
368
376
  );
377
+ log.info(() => ({
378
+ message: 'model resolved successfully',
379
+ providerID: lastUser.model.providerID,
380
+ modelID: lastUser.model.modelID,
381
+ resolvedModelID: model.language?.modelId,
382
+ }));
369
383
  } catch (error) {
370
384
  // When an explicit provider is specified, do NOT silently fall back to default
371
385
  // This ensures user's explicit choice is respected
@@ -376,6 +390,8 @@ export namespace SessionPrompt {
376
390
  providerID: lastUser.model.providerID,
377
391
  modelID: lastUser.model.modelID,
378
392
  error: error instanceof Error ? error.message : String(error),
393
+ stack: error instanceof Error ? error.stack : undefined,
394
+ hint: 'Check that the model exists in the provider. Use --verbose for more details.',
379
395
  }));
380
396
  // Re-throw the error so it can be handled by the caller
381
397
  throw error;