@link-assistant/agent 0.16.17 → 0.17.0

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,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',
@@ -0,0 +1,303 @@
1
+ import { Log } from './log';
2
+ import { Flag } from '../flag/flag';
3
+
4
+ /**
5
+ * Shared verbose HTTP fetch wrapper.
6
+ *
7
+ * Intercepts fetch() calls and logs request/response details as JSON objects
8
+ * when --verbose mode is enabled. Used across the entire codebase (tools, auth,
9
+ * config, providers) to ensure uniform and predictable HTTP logging.
10
+ *
11
+ * Features:
12
+ * - Logs HTTP request: method, URL, sanitized headers, body preview
13
+ * - Logs HTTP response: status, headers, duration, body preview
14
+ * - Logs HTTP errors: stack trace, error cause chain
15
+ * - Sequential call numbering for correlation
16
+ * - Error-resilient: logging failures never break the actual HTTP request
17
+ * - Runtime verbose check: respects Flag.OPENCODE_VERBOSE at call time
18
+ *
19
+ * @see https://github.com/link-assistant/agent/issues/215
20
+ */
21
+
22
+ const log = Log.create({ service: 'http' });
23
+
24
+ /** Global call counter shared across all verbose fetch wrappers */
25
+ let globalHttpCallCount = 0;
26
+
27
+ /**
28
+ * Sanitize HTTP headers by masking sensitive values.
29
+ * Masks authorization, x-api-key, and api-key headers.
30
+ */
31
+ export function sanitizeHeaders(
32
+ rawHeaders: HeadersInit | Record<string, string> | Headers | undefined
33
+ ): Record<string, string> {
34
+ const sanitized: Record<string, string> = {};
35
+ if (!rawHeaders) return sanitized;
36
+
37
+ const entries =
38
+ rawHeaders instanceof Headers
39
+ ? Array.from(rawHeaders.entries())
40
+ : Array.isArray(rawHeaders)
41
+ ? rawHeaders
42
+ : Object.entries(rawHeaders);
43
+
44
+ for (const [key, value] of entries) {
45
+ const lower = key.toLowerCase();
46
+ if (
47
+ lower === 'authorization' ||
48
+ lower === 'x-api-key' ||
49
+ lower === 'api-key'
50
+ ) {
51
+ sanitized[key] =
52
+ typeof value === 'string' && value.length > 8
53
+ ? value.slice(0, 4) + '...' + value.slice(-4)
54
+ : '[REDACTED]';
55
+ } else {
56
+ sanitized[key] = String(value);
57
+ }
58
+ }
59
+ return sanitized;
60
+ }
61
+
62
+ /**
63
+ * Create a body preview string, truncated to maxChars.
64
+ */
65
+ export function bodyPreview(
66
+ body: BodyInit | null | undefined,
67
+ maxChars = 200000
68
+ ): string | undefined {
69
+ if (!body) return undefined;
70
+
71
+ const bodyStr =
72
+ typeof body === 'string'
73
+ ? body
74
+ : body instanceof ArrayBuffer || body instanceof Uint8Array
75
+ ? `[binary ${(body as ArrayBuffer).byteLength ?? (body as Uint8Array).length} bytes]`
76
+ : body instanceof URLSearchParams
77
+ ? body.toString()
78
+ : undefined;
79
+
80
+ if (bodyStr && typeof bodyStr === 'string') {
81
+ return bodyStr.length > maxChars
82
+ ? bodyStr.slice(0, maxChars) +
83
+ `... [truncated, total ${bodyStr.length} chars]`
84
+ : bodyStr;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ export interface VerboseFetchOptions {
90
+ /** Identifier for the caller (e.g. 'webfetch', 'auth-plugins', 'config') */
91
+ caller: string;
92
+ /** Maximum chars for response body preview (default: 200000) */
93
+ responseBodyMaxChars?: number;
94
+ /** Maximum chars for request body preview (default: 200000) */
95
+ requestBodyMaxChars?: number;
96
+ }
97
+
98
+ /**
99
+ * Wrap a fetch function with verbose HTTP logging.
100
+ *
101
+ * When Flag.OPENCODE_VERBOSE is true, logs all HTTP requests and responses
102
+ * as JSON objects. When verbose is false, returns a no-op passthrough.
103
+ *
104
+ * All logging is wrapped in try/catch so it never breaks the actual HTTP request.
105
+ *
106
+ * @param innerFetch - The fetch function to wrap (defaults to global fetch)
107
+ * @param options - Configuration for the wrapper
108
+ * @returns A wrapped fetch function with verbose logging
109
+ */
110
+ export function createVerboseFetch(
111
+ innerFetch: typeof fetch = fetch,
112
+ options: VerboseFetchOptions
113
+ ): typeof fetch {
114
+ const {
115
+ caller,
116
+ responseBodyMaxChars = 200000,
117
+ requestBodyMaxChars = 200000,
118
+ } = options;
119
+
120
+ return async (
121
+ input: RequestInfo | URL,
122
+ init?: RequestInit
123
+ ): Promise<Response> => {
124
+ // Check verbose flag at call time
125
+ if (!Flag.OPENCODE_VERBOSE) {
126
+ return innerFetch(input, init);
127
+ }
128
+
129
+ globalHttpCallCount++;
130
+ const callNum = globalHttpCallCount;
131
+
132
+ const url =
133
+ typeof input === 'string'
134
+ ? input
135
+ : input instanceof URL
136
+ ? input.toString()
137
+ : input.url;
138
+ const method = init?.method ?? 'GET';
139
+
140
+ // Prepare request details for logging (error-resilient)
141
+ let sanitizedHdrs: Record<string, string> = {};
142
+ let reqBodyPreview: string | undefined;
143
+ try {
144
+ sanitizedHdrs = sanitizeHeaders(init?.headers as HeadersInit | undefined);
145
+ reqBodyPreview = bodyPreview(init?.body, requestBodyMaxChars);
146
+ } catch (prepError) {
147
+ log.warn('verbose logging: failed to prepare request details', {
148
+ caller,
149
+ error:
150
+ prepError instanceof Error ? prepError.message : String(prepError),
151
+ });
152
+ }
153
+
154
+ // Log request
155
+ log.info('HTTP request', {
156
+ caller,
157
+ callNum,
158
+ method,
159
+ url,
160
+ headers: sanitizedHdrs,
161
+ bodyPreview: reqBodyPreview,
162
+ });
163
+
164
+ const startMs = Date.now();
165
+ try {
166
+ const response = await innerFetch(input, init);
167
+ const durationMs = Date.now() - startMs;
168
+
169
+ // Log response
170
+ try {
171
+ log.info('HTTP response', {
172
+ caller,
173
+ callNum,
174
+ method,
175
+ url,
176
+ status: response.status,
177
+ statusText: response.statusText,
178
+ durationMs,
179
+ responseHeaders: Object.fromEntries(response.headers.entries()),
180
+ });
181
+ } catch {
182
+ // Ignore logging errors
183
+ }
184
+
185
+ // Log response body
186
+ const contentType = response.headers.get('content-type') ?? '';
187
+ const isStreaming =
188
+ contentType.includes('event-stream') ||
189
+ contentType.includes('octet-stream');
190
+
191
+ if (response.body) {
192
+ try {
193
+ if (isStreaming) {
194
+ const [sdkStream, logStream] = response.body.tee();
195
+
196
+ // Consume log stream asynchronously
197
+ (async () => {
198
+ try {
199
+ const reader = logStream.getReader();
200
+ const decoder = new TextDecoder();
201
+ let preview = '';
202
+ let truncated = false;
203
+ while (true) {
204
+ const { done, value } = await reader.read();
205
+ if (done) break;
206
+ if (!truncated) {
207
+ const chunk = decoder.decode(value, { stream: true });
208
+ preview += chunk;
209
+ if (preview.length > responseBodyMaxChars) {
210
+ preview = preview.slice(0, responseBodyMaxChars);
211
+ truncated = true;
212
+ }
213
+ }
214
+ }
215
+ log.info('HTTP response body (stream)', {
216
+ caller,
217
+ callNum,
218
+ url,
219
+ bodyPreview: truncated
220
+ ? preview + `... [truncated]`
221
+ : preview,
222
+ });
223
+ } catch {
224
+ // Ignore logging errors
225
+ }
226
+ })();
227
+
228
+ return new Response(sdkStream, {
229
+ status: response.status,
230
+ statusText: response.statusText,
231
+ headers: response.headers,
232
+ });
233
+ } else {
234
+ const bodyText = await response.text();
235
+ const preview =
236
+ bodyText.length > responseBodyMaxChars
237
+ ? bodyText.slice(0, responseBodyMaxChars) +
238
+ `... [truncated, total ${bodyText.length} chars]`
239
+ : bodyText;
240
+ log.info('HTTP response body', {
241
+ caller,
242
+ callNum,
243
+ url,
244
+ bodyPreview: preview,
245
+ });
246
+ return new Response(bodyText, {
247
+ status: response.status,
248
+ statusText: response.statusText,
249
+ headers: response.headers,
250
+ });
251
+ }
252
+ } catch {
253
+ // If body logging fails, return original response
254
+ return response;
255
+ }
256
+ }
257
+
258
+ return response;
259
+ } catch (error) {
260
+ const durationMs = Date.now() - startMs;
261
+ try {
262
+ log.error('HTTP request failed', {
263
+ caller,
264
+ callNum,
265
+ method,
266
+ url,
267
+ durationMs,
268
+ error:
269
+ error instanceof Error
270
+ ? {
271
+ name: error.name,
272
+ message: error.message,
273
+ stack: error.stack,
274
+ cause:
275
+ error.cause instanceof Error
276
+ ? error.cause.message
277
+ : error.cause
278
+ ? String(error.cause)
279
+ : undefined,
280
+ }
281
+ : String(error),
282
+ });
283
+ } catch {
284
+ // Ignore logging errors
285
+ }
286
+ throw error;
287
+ }
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Get the current global HTTP call count (for testing).
293
+ */
294
+ export function getHttpCallCount(): number {
295
+ return globalHttpCallCount;
296
+ }
297
+
298
+ /**
299
+ * Reset the global HTTP call count (for testing).
300
+ */
301
+ export function resetHttpCallCount(): void {
302
+ globalHttpCallCount = 0;
303
+ }