@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 +1 -1
- package/src/cli/output.ts +51 -5
- package/src/index.js +42 -14
- package/src/provider/models.ts +1 -1
- package/src/provider/provider.ts +122 -2
- package/src/session/prompt.ts +16 -0
package/package.json
CHANGED
package/src/cli/output.ts
CHANGED
|
@@ -62,16 +62,62 @@ export function isCompactJson(): boolean {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
package/src/provider/models.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/src/provider/provider.ts
CHANGED
|
@@ -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
|
-
|
|
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}`;
|
package/src/session/prompt.ts
CHANGED
|
@@ -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;
|